Условия задачи
Нам нужно написать функцию, которая принимает отсортированный массив чисел 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снова раздели на два. Получаем массивthreec числами от 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] < 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] < 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] < 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)

Deosis
28.12.2023 15:22+1const mid = Math.floor((left + right) / 2);
Знаменитый баг, который ломает бинарный поиск на больших массивах.

mayorovp
28.12.2023 15:22Конкретно Math.floor ничего не ломает, оно просто медленное.
Чтобы его сломать, нужны индексы более
. Я не верю в такие массивы на Javascript.

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

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

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

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

qss53770
28.12.2023 15:22+1Да про бин поиск везде написано куча читай не хочу, автор недавно видимо сделал это открытие и думает дай ка напишу на хбре про стандартную задачу и стандартный алгоритм
          
 
nameisBegemot
Найти число бинарным поиском это конечно замечательно. Но в реальности чаще возникает другая задача - как найти период некоторых чисел, которые старше некоторого порогового значения. Например, из массива в 100 чисел найти индексы ячеек от 75.
Прямо в лоб поделить массив пополам мало. Нужно найти границы интервалов. Код несложный, но интереснее поиска отдельного числа.
А да, поиск интервалов все тот же бинарный поиск.