Условия задачи

Нам нужно написать функцию, которая принимает отсортированный массив чисел numberArray и возвращает индекс найденного числа. Если индекс не найден, тогда возвращается -1.

Сразу уделю внимание на то, что длинна массива может быть любой. Массив может состоять из любых чисел и искомое число так же может быть любым.

Предположим у нас есть массив чисел от 1 до 100:

const numberArray [
   1,  2,  3,   4,  5,  6,  7,  8,  9, 10, 11, 12,
  13, 14, 15,  16, 17, 18, 19, 20, 21, 22, 23, 24,
  25, 26, 27,  28, 29, 30, 31, 32, 33, 34, 35, 36,
  37, 38, 39,  40, 41, 42, 43, 44, 45, 46, 47, 48,
  49, 50, 51,  52, 53, 54, 55, 56, 57, 58, 59, 60,
  61, 62, 63,  64, 65, 66, 67, 68, 69, 70, 71, 72,
  73, 74, 75,  76, 77, 78, 79, 80, 81, 82, 83, 84,
  85, 86, 87,  88, 89, 90, 91, 92, 93, 94, 95, 96,
  97, 98, 99, 100
]

Линейный поиск

Начнем с определения.

Линейный поиск — это простой алгоритм поиска элемента в структуре данных (например в массиве), который последовательно проверяет каждый элемент в структуре данных на совпадение с целевым значением. Он начинает с первого элемента и продолжает проверку до тех пор, пока не будет найден искомый элемент или пока не закончится весь набор данных.

Напишем функцию, которая будет проверять все элементы массива по очереди:

const linearSearch = (numbersArray, targetNumber) => {
  for (let i = 0; i < numbersArray.length; i++) {
    if (numbersArray[i] === targetNumber) {
      return i // Элемент найден
    }
  }
  
  return -1; // Элемент не найден
}

Если в качестве targetNumber мы передадим 1, тогда нам потребуется одна итерация цикла, чтобы найти число.

Если в качестве targetNumber мы передадим 100, тогда нам потребуется 100 итераций цикла, чтобы найти число.

В данном алгоритме сложность O(n). Такую сложность так же называют линейной сложностью.

Такой поиск крайне не эффективный при работе с большим набором данных.

Бинарный поиск

Бинарный поиск гораздо более эффективный в сравнении с линейным поиском.

Бинарный поиск основан на идее деления данных на половины и последующем поиске в одной из них с последующим делением.

Принцип бинарного поиска

Предположим, что в нашем отсортированном списке чисел от 1 до 100 мы будем искать число 87.

Первое действие:

  • Разделим наш массив пополам. У нас получилось 2 массива one и two. В one числа от 1 до 50, а в two с 51 до 100.

const one = [
  1,  2,  3,   4,  5,  6,  7,  8,  9, 10, 
  11, 12, 13, 14, 15,  16, 17, 18, 19, 20, 
  21, 22, 23, 24, 25, 26, 27,  28, 29, 30,
  31, 32, 33, 34, 35, 36, 37, 38, 39,  40, 
  41, 42, 43, 44, 45, 46, 47, 48, 50
]

const two = [
  51,  52, 53, 54, 55, 56, 57, 58, 59, 60,
  61, 62, 63,  64, 65, 66, 67, 68, 69, 70,
  71, 72, 73, 74, 75,  76, 77, 78, 79, 80,
  81, 82, 83, 84, 85, 86, 87,  88, 89, 90,
  91, 92, 93, 94, 95, 96, 97, 98, 99, 100
]
  • Посмотрим последний элемент массива one. 49 больше, чем 87? Нет.

  • Тогда смотрим последний элемент массива two. 100 больше, чем 87? Да, значит искать число будет массиве two. При первом действии мы уже отсекли половину данных.

Второе действие:

  • Массив two снова раздели на два. Получаем массив three c числами от 50 до 75 и four с числами от 76 до 100.

    const three = [
      50, 51,  52, 53, 54, 55, 56, 57, 58, 59, 60,
      61, 62, 63,  64, 65, 66, 67, 68, 69, 70, 71,
      72, 73, 74, 75 
    ]
    
    const four = [
      76, 77, 78, 79, 80, 81, 82, 83, 84, 95, 85,
      86, 87,  88, 89, 90, 91, 92, 93, 94, 96, 97,
      98, 99, 100
    ]
  • Посмотрим последний элемент массива three. 75 больше, чем 87? Нет.

  • Тогда смотрим последний элемент массива four. 100 больше, чем 87? Да, значит искать число будет в массиве four. При втором действии мы отсекли еще половину данных.

Третье действие:

  • Массив four снова раздели на два. Получаем массив five с числами от 76 до 87 и массив six с числами от 88 до 100.

    const five = [
      76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 
    ]
    
    const six = [
      88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100
    ]
  • Посмотрим последний элемент массива five.

  • 87 больше, чем 87? Нет. Они равны. Мы нашли наше число на третьем действии.

Алгоритм бинарного поиска

Бинарный поиск работает только в том случае, если список отсортирован!

По условию задачи у нас может быть любая длинна массива и любые отсортированные числа в начале массива.

Напишем решение для нашей задачи с использованием бинарного поиска.

Для начала нам нужно генерировать подобные массивы.

Напишем функцию getRandomNumber, которая возвращает рандомное число в заданном диапазоне:

const getRandomNumber = (min, max) => {
  // Метод Math.ceil() - округление вверх. Округляет аргумент до ближайшего большего целого.
  min = Math.ceil(min);
  // Метод Math.floor() - округление вниз. Округляет аргумент до ближайшего меньшего целого.
  max = Math.floor(max);

  // Максимум не включается, минимум включается
  return Math.floor(Math.random() * (max - min) + min);
}

Напишем функцию createRandomNumberArray, которая возвращает массив чисел заданной длинны с отсортированными числами.

const createRandomNumberArray = (arrayLength, firstNumber) => {
  return Array.from({ length: arrayLength }, (_, index) => index + firstNumber);
}

Далее напишем функцию binarySearch, которая будет возвращать объект с найденным индексом (или -1) и с количеством итераций цикла, которые мы прошли.

const binarySearch = (numbersArray, targetNumber) => {
  let left = 0;
  let right = numbersArray.length - 1;
  let foundIndex = -1
  let operations = 0
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    
    if (numbersArray[mid] === targetNumber) {
      foundIndex = mid; // Элемент найден
      break;
    } else if (numbersArray[mid] &lt; targetNumber) {
      left = mid + 1; // Ищем в правой половине
    } else {
      right = mid - 1; // Ищем в левой половине
    }
  
    operations += 1
  }
  
  return { foundIndex, operations }
}

Так же напишем функцию linearSearch c обычным линейным поиском, которая будет возвращать объект с найденным индексом или -1 и с количеством итераций цикла, которые мы прошли.

const linearSearch = (numbersArray, targetNumber) => {
  let foundIndex = -1
  let operations = 0

  for (let i = 0; i < numbersArray.length; i++) {
    if (numbersArray[i] === targetNumber) {
      foundIndex = i
      return { foundIndex, operations }; // Элемент найден
    }
    
    operations += 1
  }

  return { foundIndex, operations }; // Элемент не найден
}

Получим следующие данные:

// Случайная длина массива
const arrayLength = getRandomNumber(1000, 2000)
// Случайное число с которого будут начинаться наши числа.
const firstNumber = getRandomNumber(1, 1000)
// Случайное число, которое мы будем искать
const targetNumber = getRandomNumber(firstNumber, 1000)

И наконец-то создадим сам массив:

// Массив чисел
const numbersArray = createRandomNumberArray(arrayLength, firstNumber)

И в конце будем запускать наш код и сравним количество итераций цикла в функции и функции:

const binaryResult = binarySearch(numbersArray, targetNumber)
const linearResult = linearSearch(numbersArray, targetNumber)

console.log('arrayLength', arrayLength)
console.log('firstNumber', firstNumber)
console.log('targetNumber', targetNumber)
console.log('binaryResult', binaryResult)
console.log('linearResult', linearResult)

Полная версия кода:

const getRandomNumber = (min, max) => {
  // Метод Math.ceil() - округление вверх. Округляет аргумент до ближайшего большего целого.
  min = Math.ceil(min);
  // Метод Math.floor() - округление вниз. Округляет аргумент до ближайшего меньшего целого.
  max = Math.floor(max);

  // Максимум не включается, минимум включается
  return Math.floor(Math.random() * (max - min) + min);
}

const createRandomNumberArray = (arrayLength, firstNumber) => {
  return Array.from({ length: arrayLength }, (_, index) => index + firstNumber);
}

const binarySearch = (numbersArray, targetNumber) => {
  let left = 0;
  let right = numbersArray.length - 1;
  let foundIndex = -1
  let operations = 0
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    
    if (numbersArray[mid] === targetNumber) {
      foundIndex = mid; // Элемент найден
      break;
    } else if (numbersArray[mid] &lt; targetNumber) {
      left = mid + 1; // Ищем в правой половине
    } else {
      right = mid - 1; // Ищем в левой половине
    }
  
    operations += 1
  }
  
  return { foundIndex, operations }
}

const linearSearch = (numbersArray, targetNumber) => {
  let foundIndex = -1
  let operations = 0

  for (let i = 0; i < numbersArray.length; i++) {
    if (numbersArray[i] === targetNumber) {
      foundIndex = i
      return { foundIndex, operations }; // Элемент найден
    }
    
    operations += 1
  }

  return { foundIndex, operations }; // Элемент не найден
}

// Случайная длина массива
const arrayLength = getRandomNumber(1000, 2000)
// Случайное число с которого будут начинаться наши числа.
const firstNumber = getRandomNumber(1, 1000)
// Случайное число, которое мы будем искать
const targetNumber = getRandomNumber(firstNumber, 1000)

// Массив чисел
const numbersArray = createRandomNumberArray(arrayLength, firstNumber)

const binarySearch = (numbersArray, targetNumber) => {
  let left = 0;
  let right = numbersArray.length - 1;
  let foundIndex = -1
  let operations = 0
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    
    if (numbersArray[mid] === targetNumber) {
      foundIndex = mid; // Элемент найден
      break;
    } else if (numbersArray[mid] &lt; targetNumber) {
      left = mid + 1; // Ищем в правой половине
    } else {
      right = mid - 1; // Ищем в левой половине
    }
  
    operations += 1
  }
  
  return { foundIndex, operations }
}

const binaryResult = binarySearch(numbersArray, targetNumber)
const linearResult = linearSearch(numbersArray, targetNumber)

console.log('arrayLength', arrayLength)
console.log('firstNumber', firstNumber)
console.log('targetNumber', targetNumber)
console.log('binaryResult', binaryResult)
console.log('linearResult', linearResult)

Смотрим консоль с результатами выполнения:

arrayLength 1376
firstNumber 499
targetNumber 995
binaryResult { foundIndex: 496, operations: 9 }
linearResult { foundIndex: 496, operations: 496 }
arrayLength 1158
firstNumber 785
targetNumber 817
binaryResult { foundIndex: 32, operations: 8 }
linearResult { foundIndex: 32, operations: 32 }
arrayLength 1002
firstNumber 671
targetNumber 822
binaryResult { foundIndex: 151, operations: 7 }
linearResult { foundIndex: 151, operations: 151 }
arrayLength 1235
firstNumber 951
targetNumber 970
binaryResult { foundIndex: 19, operations: 9 }
linearResult { foundIndex: 19, operations: 19 }
arrayLength 1599
firstNumber 649
targetNumber 940
binaryResult { foundIndex: 291, operations: 10 }
linearResult { foundIndex: 291, operations: 291 }

В этих пяти случаях:

  • Для бинарного поиска потребовалось от 7 до 10 итераций цикла.

  • Для линейного поиска потребовалось от 19 до 496 итераций цикла.

Для линейного поиска максимальное количество итераций совпадает с размером массива.

С бинарным поиском дело обстоит иначе. Если список состоит из 100 эле­ментов, потребуется не более 7 попыток. Для списка из 4 миллиардов эле­ментов потребуется не более 32 попыток. Впечатляет, верно?

Бинарный поиск выполняется за логарифмическое время и имеет сложность O(log n).

Логарифмы

Логарифм - это математическая функция, которая говорит нам, сколько раз нужно умножить определенное число (называемое основанием логарифма) на само себя, чтобы получить другое число.

Давай представим, что у нас есть число 1000. Если мы возьмем логарифм числа 1000 по основанию 10, результат будет 3. Это означает, что 10 умноженное на себя три раза (10 * 10 * 10) равно 1000.

Логарифм − операция, обратная возведению в степень.

В нашем случае log всегда означает log по основанию 2.

Для бинарного поиска в худшем случае потребуется не более log n проверок.

Для списка из 8 элементов log 8 потребуется не более 3 проверок, потому что 2^3 = 8.

Для списка из 1024 элементов log 1024 не более 10 проверок, потому что 2^10 = 1024.

Комментарии (11)


  1. nameisBegemot
    28.12.2023 15:22

    Найти число бинарным поиском это конечно замечательно. Но в реальности чаще возникает другая задача - как найти период некоторых чисел, которые старше некоторого порогового значения. Например, из массива в 100 чисел найти индексы ячеек от 75.

    Прямо в лоб поделить массив пополам мало. Нужно найти границы интервалов. Код несложный, но интереснее поиска отдельного числа.

    А да, поиск интервалов все тот же бинарный поиск.


  1. Deosis
    28.12.2023 15:22
    +1

    const mid = Math.floor((left + right) / 2);

    Знаменитый баг, который ломает бинарный поиск на больших массивах.


    1. mayorovp
      28.12.2023 15:22

      Конкретно Math.floor ничего не ломает, оно просто медленное.

      Чтобы его сломать, нужны индексы более 2^{53} \approx 8 \cdot 10^{15} . Я не верю в такие массивы на Javascript.


    1. Neoldian
      28.12.2023 15:22
      -1

      Что забавно, почти в каждой статье или заметке по бинарному поиску пишут именно так) Что бы не ломать потом голову лучше писать a + (b-a)/2 , и не будет никакого переполнения.


      1. mayorovp
        28.12.2023 15:22

        Если написать  a + (b-a)/2 - может получиться нецелый индекс. Числа-то в Javascript вещественные...


        1. Neoldian
          28.12.2023 15:22

          Приведение к целому конечно необходимо, я про то что именно выражение лучше писать a + (b-a)/2, в js оно как раз без последствий. Статья то образовательная, и не хочется чтобы у кого то, кто перепишет на более близких к железу языках как (a+b)/2, была головная боль с overflow)


  1. Metotron0
    28.12.2023 15:22

    Мне помнится, что в книге «Грокаем алгоритмы» бинарный поиск расписан очень подробно. Неужели была необходимость в ещё одном объяснении, а не в ссылке на страницу книги?

    Собственно, поэтому я и не пишу статьи на хабр: они будут состоять только из ссылки.


    1. Semigradsky
      28.12.2023 15:22
      +1

      Книгу купить ещё надо


      1. Metotron0
        28.12.2023 15:22

        Не подумал


    1. qss53770
      28.12.2023 15:22
      +1

      Да про бин поиск везде написано куча читай не хочу, автор недавно видимо сделал это открытие и думает дай ка напишу на хбре про стандартную задачу и стандартный алгоритм


  1. nronnie
    28.12.2023 15:22

    Посмотрим последний элемент массива one. 49 больше, чем 87? Нет.

    @Open-JS, у вас в примере кода "49" в массиве куда-то потерялось - поправьте.