Бинарный поиск (binary search) находит элемент в отсортированном массиве, на каждом шаге отбрасывая половину: вместо у перебора. Та же идея работает для монотонной функции — это бинарный поиск по ответу.

Зачем нужно

Проверить, есть ли key в массиве, можно перебором — линейным поиском:

flag = false
for i = 0 ... N
    если arr[i] = key
        flag = true
        вывести(i)
        break
если flag = false
    вывести("не нашёл")

, . Быстрее нельзя, пока о массиве ничего не известно. Если же массив отсортирован — — одно сравнение с серединой сразу говорит, в какой половине искать.

При : перебор — итераций, около секунд; бинарный поиск — итераций.

Граница в линейном поиске

Индексы — от 0 до . Если for i = 0 ... N включает N, это обращение за пределы массива, в C++ — Неопределённое поведение.

Как работает

  • ⇒ искомое в левой половине;
  • ⇒ в правой.
L = 0
R = N - 1
пока (R - L > 1) {
    M = (L + R) / 2          // в C++: M = L + (R - L) / 2
    если arr[M] > key
        R = M
    иначе если arr[M] < key
        L = M
    иначе
        L = M
        break
}
если arr[L] = key или arr[R] = key
    вывести("нашли")
иначе
    вывести("не нашли")

Цикл сужает отрезок, пока в нём больше двух элементов. Оставшиеся два соседних кандидата проверяются после цикла.

Не из лекции: доказательство правильности

Инвариант: если key есть в массиве, он среди .

  • Вначале это весь массив.
  • arr[M] > key: из сортировки при , искомое в .
  • arr[M] < key: симметрично, в .
  • arr[M] = key: найден, L = M, проверка после цикла его увидит.

Остановка: при середина строго внутри, , и строго уменьшается.

Ответ: после цикла — кандидатов только два, и по инварианту искомое, если оно есть, среди них.

Асимптотика

Длина отрезка: . Остановка при , то есть : , .

Не из лекции: аккуратно

При части отрезка — и , новое . Шагов до — не больше . Почему основание логарифма в оценке не пишут — см. Асимптотические оценки.

Почему L + (R − L) / 2

На лекции отмечено: в C++ середину пишут как M = L + (R − L) / 2.

Не из лекции: причина

L + R при индексах около не помещается в int (максимум ). Переполнение знакового целого — UB, см. Неопределённое поведение и Целочисленные типы. R − L лежит между 0 и R и переполниться не может. Для результат тот же: .

Бинарный поиск по ответу

Обычный бинарный поиск ищет число в массиве, поиск по ответу — число, но в функции. Если монотонна на , её значения упорядочены, как отсортированный массив: . Середина отрезка по и сравнение с целью говорят, в какой половине ответ.

Пример:

. Функция монотонна при .

float L = 0, R = N
eps = 10^-5
пока (|L*L - N| > eps) {
    M = (L + R) / 2
    если M*M < N
        L = M
    иначе
        R = M
}

Второй вариант — с гарантированным числом итераций:

float L = 0, R = N
for i = 0 ... 1000
    M = (L + R) / 2
    если M*M < N
        L = M
    иначе
        R = M

Не из лекции: инвариант и границы

Инвариант: , то есть . Каждая ветка сохраняет свою половину неравенства, отрезок уменьшается вдвое.

Граница годится только при : из следует . При корень больше самого числа (), и нужна .

Почему вариант с eps опасен на дробных числах и сколько итераций из 1000 реально работают — проверено в конспекте лекции и объяснено в Типы с плавающей точкой.

Тонкости

  • Массив обязан быть отсортирован. На неотсортированном бинарный поиск не падает, а просто отвечает неверно.
  • Пустой массив () даёт : проверка arr[L] после цикла обратится к несуществующему элементу. Его проверяют до поиска.
  • Для поиска по ответу нужна монотонность. Без неё отбрасывание половины ничем не обосновано.

Частые ошибки

  • Считать середину как (L + R) / 2 на больших индексах в C++.
  • Искать дробный ответ «до точности eps» на float: цикл может не остановиться.
  • Не проверить после цикла оба оставшихся элемента — и arr[L], и arr[R].

Углубление — АиСД L03

Бинарный поиск назван среди применений сортировки: чтобы искать за , массив сначала сортируют.

Не из лекции: когда это выгодно

Сортировка дороже одного линейного поиска. Выгода появляется, когда запросов много: отсортировать один раз, а потом отвечать на каждый запрос за вместо .

Расхождение с L02

Было (L02): «Индексы — от 0 до . Если for i = 0 ... N включает N, это обращение за пределы массива».

В L03 и L04: запись 0 ... N в сортировках означает полуинтервал — иначе пузырёк с j = 0 ... N−i−1 и arr[j+1] выходил бы за массив, а MergeSort вызывается как (arr, 0, N, buffer) для arr[0; N). При таком чтении линейный поиск из L02 за массив не выходит.

Но запись непостоянна: в решете Эратосфена (L03) 2 ... N включает N, в проверке на простоту (L01) 1 ... p включает p. Предупреждение в L02 верно только при включающем чтении. Решение — за пользователем.