Что такое бинарный поиск?
Двоичный поиск — это алгоритм поиска, который находит положение целевого значения в отсортированном массиве. Двоичный поиск сравнивает целевое значение со средним элементом массива. Если они не равны, половина, в которой не может лежать цель, исключается, и поиск продолжается в оставшейся половине, снова беря средний элемент для сравнения с целевым значением и повторяя это до тех пор, пока целевое значение не будет найдено. Если поиск заканчивается тем, что оставшаяся половина пуста, цель отсутствует в массиве.
Идея бинарного поиска заключается в «разделяй и властвуй», непрерывно деля количество элементов-кандидатов пополам на каждой итерации. Это свойство позволяет алгоритму иметь временную сложность O(log n).
Плюсы
- O(log n) временная сложность. Это один из самых быстрых алгоритмов поиска;
- Это просто реализовать;
- Это легко понять;
Минусы
- Он работает только с отсортированными массивами;
- Временная сложность вставки составляет O(log n). Я отношу это к минусам, потому что существуют алгоритмы с O(1) временной сложностью для вставки, но во многих случаях это не имеет большого значения, потому что O(log n) по-прежнему очень хорошая производительность;
Требования
- Массив должен быть отсортирован;
- Для использования алгоритма бинарного поиска массив должен иметь произвольный доступ, что означает, что доступ к элементам возможен с использованием их индекса;
- Данные, хранящиеся в массиве, должны иметь тип, поддерживающий сравнение. Например, a ‹ b, a › b, a == b;
- Сравнение, используемое для поиска значения в массиве, должно быть таким же, которое используется для сортировки или вставки значений в массив;
Временная сложность (бинарный поиск против линейного поиска)
Демонстрационная анимация
Бинарный поиск против линейного поиска
Интерактивная реализация
static int BinarySearch(int[] array, int search) { var left = 0; var right = array.Length - 1; while (left <= right) { var middle = left + ((right - left) / 2); if (array[middle] == search) { return middle; } if (array[middle] < search) { left = middle + 1; } else { right = middle - 1; } } return -1; }
Рекурсивная реализация
static int BinarySearch(int[] array, int search, int left, int right) { while(left <= right) { var middle = left + ((right - left) / 2); if(search == array[middle]) { return middle; } if(search > array[middle]) { return BinarySearch(array, search, middle + 1, right); } return BinarySearch(array, search, left, middle - 1); } return -1; }
Несколько советов по реализации бинарного поиска
Нашел средний индекс
Очень часто средний индекс вычисляется как (left + right) / 2, но это не лучший способ сделать это. Если массив очень большой, сумма left и right может превысить максимальное значение целочисленного типа. Чтобы избежать этой проблемы, мы можем использовать следующую формулу: слева + ((справа — слева) / 2).
Давайте разберем эту формулу по частям, чтобы понять проблему.
Console.WriteLine($"MaxInt: {int.MaxValue:n0}"); // MaxInt: 2 147 483 647 var left = int.MaxValue - 1_000; var right = int.MaxValue - 300; Console.WriteLine($"left: {left:n0}"); // left: 2 147 482 647 Console.WriteLine($"right: {right:n0}"); // right: 2 147 483 347 // Bad why var badWhyStep1 = left + right; Console.WriteLine($"Bad why -> left + right: {badWhyStep1:n0}"); // Bad why -> left + right: -1 302 var badWhyStep2 = badWhyStep1 / 2; Console.WriteLine($"Bad why -> (left + right) / 2: {badWhyStep2:n0}"); // Bad why -> (left + right) / 2: -651 // Good why var goodWhyStep1 = right - left; Console.WriteLine($"Good why -> right - left: {goodWhyStep1:n0}"); // Good why -> right - left: 700 var goodWhyStep2 = goodWhyStep1 / 2; Console.WriteLine($"Good why -> (right - left) / 2: {goodWhyStep2:n0}"); // Good why -> (right - left) / 2: 350 var goodWhyStep3 = left + goodWhyStep2; Console.WriteLine($"Good why -> left + ((right - left) / 2): {goodWhyStep3:n0}"); // Good why -> left + ((right - left)) / 2: 2 147 482 997 // right: 2 147 483 147
Как видите, для большого массива операция left + right может переполнить максимальное значение целочисленного типа и вернуть значение Wearg.
Использовать оператор сдвига
Во встроенной реализации бинарного поиска некоторых языков можно увидеть использование оператора сдвига ›› 1 для деления значения на 2.
Во-первых, мы можем сделать это, потому что сдвиг операция ››1 эквивалентна делению на 2. Во-вторых, потому что побитовые операции более близки к машинному коду и могут быть быстрее операции деления.
До
var middle = left + ((right - left) / 2);
После
var middle = left + ((right - left) >> 1);
Возвращает дополнение индекса, если значение не найдено
В предыдущих примерах я возвращал -1, когда значение не было найдено. Использование условия index ‹ 0 служит индикатором того, что значение не было найдено. Однако предположим, что мы также хотим использовать ту же реализацию для определения следующего подходящего индекса для вставки значения. В этом сценарии индекс -1 не особенно полезен.
Мы знаем, что когда мы пытаемся найти значение в нашем отсортированном массиве, алгоритм останавливается, когда он находит значение или нет. Если значение не найдено, левый индекс будет указывать на позицию, в которую нужно вставить значение.
Имея это в виду, если значение не найдено, мы можем просто использовать оператор ~. Этот оператор инвертирует все биты, включая бит знака, в результате чего получается отрицательное значение. Мы можем использовать это отрицательное значение, чтобы показать, что значение не найдено. Однако, если мы применим операцию дополнения еще раз, мы получим позицию, в которую нужно вставить значение. Этот подход позволяет нам использовать одну и ту же реализацию как для поиска значения, так и для определения следующей возможной точки вставки.
public void Add(int item) { var position = Search(item); if(position < 0) { position = ~position; } items.Insert(position, item); } public int Search(int item) { // ... // When the value is not found return ~left; }
Двоичный поиск не только для чисел
Алгоритм бинарного поиска работает не только с числами. Его можно использовать с любым типом данных, который поддерживает сравнение. Например, в C# вы можете создать класс и реализовать интерфейс IComparable для определения логики сравнения.
public int BinarySearch(T item) { var left = 0; var right = _items.Count - 1; while(left <= right) { var middle = left + ((right - left) >> 1); if(_items[middle].CompareTo(item) == 0) { return middle; } if(_items[middle].CompareTo(item) < 0) { left = middle + 1; } else { right = middle - 1; } } return ~left; } public class Person : IComparable<Person> { public string? FirstName { get; set; } public string? LastName { get; set; } public int Age { get; set; } public int CompareTo(Person? other) { if(other is null) { return -1; } var result = Age.CompareTo(other.Age); if(result != 0) { return result; } result = compareTo(FirstName, other?.FirstName); if(result != 0) { return result; } return compareTo(LastName, other?.LastName); static int compareTo(string? left, string? right) { if(left is null && right is null) { return 0; } if(left is null) { return -1; } if(right is null) { return 1; } return left.CompareTo(right); } } }
Полный пример доступен на мой GitHub