Skip to the content.

Двоичный (бинарный) поиск в массиве

Бинарный поиск - алгоритм, позволяющий очень быстро искать объект, удовлетворяющий определённым критериям, в упорядоченном множестве таких же объектов.

Идея

Пусть дан упорядоченный по неубыванию массив $a$ размера $n$ и число $x$, требуется найти первое вхождение числа $x$ в массив $a$ или сказать, что числа в массиве нет. Мысленно дополним массив: положим $a[-1] = -\infty, a[n] = +\infty$. Кроме этого, объявим две переменные $l=-1, r=n$, которые будут границами поиска.

В нашем алгоритме мы будем изменять границы $l, r$, поддерживая следующий инвариант: $a[l] < x, a[r] \geq x$.

Делать это будем, пока $r - l > 1$, следующим образом: объявим $m = \lfloor \dfrac{l+r}{2} \rfloor$.

Если $a[m] < x$, то присваиваем $l = m$, а иначе - $r = m$.

Реализация

Ниже приведена реализация данного алгоритма на языке python3

l, r = -1, n
while l + 1 < r:
    m = (l + r) // 2
    if a[m] < x:
        l = m
    else:
        r = m
if r < n and a[r] == x:
    print(x)
else:
    print(-1)

Корректность алгоритма

Заметим, что $r - l$, то есть длина отрезка, на котором мы ищем, каждый раз уменьшается примерно вдвое. Это значит, что рано или поздно она станет равной $1$, то есть $r - l = 1$, а значит, алгоритм завершится.

Раз мы сохраняли наш инвариант на протяжении всей работы, то он сохранится и после завершения, а значит, мы будем иметь $a[r - 1] < x, a[r] \geq x$.

Выходит, что $a[r]$ - первый элемент, не меньший $x$.

Если $a[r] = x$, то это и есть первое вхождение числа $x$. Если же $a[r] > x$, то $x$ не входит в массив: действительно, он упорядочен, а значит, все последующие числа не меньше $a[r]$, то есть больше $x$. Ну а все предыдущие будут меньше, ведь $a[r-1] < x$.

Асимптотика

Как уже было сказано, длина отрезка каждый раз уменьшается примерно вдвое.

Для начала будем считать, что длина каждую итерацию уменьшается строго вдвое: в таком случае $n = 2^p$ для какого-то натурального $p$. Если это не выполнено, то мы можем мысленнно дополнить массив до длины, равной степени двойки, увеличив его не более чем вдвое.

Несложно заметить, что в таком случае наш алгоритм делает ровно $p$ итераций.

Число $p$ называется двоичным логарифмом числа $n$ и обозначается так: $p = \log_2 n$. Более строго, $\log_b n$ это такое число, что $b^{\log_b n} = n$. Число $b$ называется основанием логарифма.

Оказывается, эта функция растёт очень медленно: к примеру, $2^{20} \approx 10^6$, значит, $\log_2 10^6 \approx 20$

Тогда асимптотика нашего алгоритма - $O(\log n)$.

В асимптотике не пишут основание логарифма: дело в том, что для перехода от основания $b_1$ к основанию $b_2$ необходимо домножить логарифм на константу $\log_{b_2} b_1$, а константы в $O$ не пишутся.

Во встроенных библиотеках

В C++ есть две стандартные функции: lower_bound() и upper_bound(). Они принимают указатели на начало и конец упорядоченного по неубыванию массива, а также число $x$. Первая возвращает указатель на первый элемент $>=x$, а вторая - на первый элемент $>x$.

Аналогичные функции есть в python3, они лежат в библиотеке bisect и называются bisect_left() и bisect_right(). Однако они возвращают индекс, а не указатель.

Если у нас есть, например, массив a = [1, 2, 2, 3, 4, 4, 4, 5, 7, 9, 9], то lower_bound(a.begin(), a.end(), 4) - a.begin() вернёт $4$, а upper_bound(a.begin(), a.end(), 4) - a.begin() - $7$.

Если мы для какого-нибудь $x$ посчитаем upper_bound(a.begin(), a.end(), x) - lower_bound(a.begin(), a.end(), x), то мы получим количество вхождений $x$ в данный массив.

Так, например, можно понять, что в массиве $a$ 3 вхождения числа $4$.