Бинарный поиск - Алгоритмика
Бинарный поиск

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

Задача. Загадано целое число $x$ от $1$ до $100$, которое вам нужно отгадать какой-нибудь «данеткой»: например, вы можете спрашивать, больше ли число $x$ чем заданное, или четно ли оно. За сколько вопросов в худшем случае вы сможете найти число $x$?

Одно из оптимальных решений имеет такую структуру: первым вопросом спрашиваем «больше ли число $x$, чем 50», и если ответ «да», то дальше спрашиваем «больше ли число $x$, чем 75», иначе «больше ли число $x$, чем 25», и повторяем дальше, каждый раз уменьшая отрезок возможных значений в два (или почти в два) раза.

Более структурно, если у нас есть отрезок поиска — изначально от $l=1$ до $r=100$ — то мы на каждой итерации выбираем «серединный» элемент $m = \lfloor \frac{l+r}{2} \rfloor$, спрашиваем «$x>m?$», выполняем присвоения $l = m + 1$ или $r = m$ в зависимости от ответа, и повторяем процедуру с новыми границами, пока они не станут равными (что будет означать, что $l = r = x$).

Вот пример такой программы, которую можно запустить в консоли:

l, r = 1, 100

while l < r:
    m = (l + r) // 2
    resp = input(f'x > {m}? ')
    if resp == 'yes':
        l = m + 1
    else:
        r = m

print(f'x = {l}')

Если загадать $x=42$ и честно поотвечать на вопросы:

x > 50? no
x > 25? yes
x > 38? yes
x > 44? no
x > 41? yes
x > 43? no
x > 42? no
x = 42

Здесь нам потребовалось 7 вопросов, но в зависимости от загаданного числа иногда мы будем спрашивать ровно $7$ вопросов, а иногда $6$.

В общем случае, так как на каждой итерации длина отрезка поиска гарантированно уменьшится в два раза (возможно, с округлением вверх), то нам потребуется $O(\log n)$ операций, а точнее либо $\lceil \log_2 n \rceil$, либо $\lfloor \log_2 n \rfloor$.

В структурах данных

Пусть дан массив $a$, и требуется определить, есть ли в нём элемент $x$.

Очевидно, в худшем случае этого элемента в массиве нет, но чтобы удостовериться в этом, нужно потратить $O(n)$ операций на просмотр всех элементов массива.

Однако, если массив отсортирован, найти число $x$ в массиве можно и быстрее: можно аналогичным способом найти нижнюю грань (англ. lower bound) — самое малое число, не меньшее $x$ — и проверить, оказалось ли оно равно $x$.

bool find(int x, int *a, int n) {
    int l = 0, r = n + 1;
    // отрезок поиска теперь полуинтервал: [l, r)
    while (l + 1 < r) {
        int m = (l + r) / 2;
        if (a[m] >= x)
            l = m;
        else
            r = m;
    }
    return (l < n && a[l] == x);
}

Для стандартных контейнеров STL такая функция реализована:

bool find(x, vector<int> a) {
    auto it = lower_bound(a.begin(), a.end(), x);
    return (it != a.end() && *it == x);
}

Также в C++ есть функция upper_bound, которая находит первый элемент, который строго больше аргумента.

Проверка свойств

Подобный метод обобщается не только на поиск элементов, но и на проверку каких-либо монотонных свойств, которые сначала выполняются, а потом не выполняются, или наоборот.

Поиск нижней границы в массиве — частный случай: мы проверяем условие $(a[k] \geq x)$ и хотим найти, начиная с какого $k$ оно выполняется.

Другие примеры:

  • Найти последнее число, равное $x$ в отсортированном массиве, или вывести, что таких чисел нет.
  • Посчитать, сколько раз встречается число $x$ в отсортированном массиве.
  • Дан массив чисел, первая часть состоит из нечетных чисел, а вторая из четных. Найти индекс, начиная с которого все числа четные.

Все эти задачи решаются бинарным поиском за $O(\log{n})$. Правда нужно понимать, что в чистом виде такую задачу решать двоичным поиском бессмысленно — ведь чтобы создать массив размера $n$, уже необходимо потратить $O(n)$ операций.

Поэтому зачастую такие задачи сформулированы таким образом: дан отсортированный массив размера $n$. Нужно ответить на $m$ запросов вида «встречается ли число $x_i$ в массиве?». Подобные задача решается за $O(n + m\log{n})$ — нужно сначала создать массив за $O(n)$ и $m$ раз запустить бинарный поиск.

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

У нас все еще есть функция-предикат $f(x)$, которая сначала равна нулю, а потом единице, и мы хотим найти это место, где она меняет значение. Но теперь аргумент функции — вещественное число.

Пример такой функции:

$$ f(x) = \begin{cases} 0, & x^2 < 2 \\ 1, & x^2 \geq 2 \end{cases} $$

При $x < \sqrt 2$, $f(x) = 0$, а при сколько-либо большем значении $f(x) = 1$. Если мы научимся находить такое $x_0$, что $f(x_0) = 0$ и $f(x_0 + \epsilon) = 1$ для какого-то малого $\epsilon$, то мы научимся считать корень из двух — и любого другого положительного числа.

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

# известный пример
print(0.1 + 0.1 + 0.1)
# = 0.30000000000000004

Тем более не сможем найти точное значение $\sqrt 2$, потому что это бесконечная непериодическая дробь.

Так что при реализации бинарного поиска с вещественными аргументами нужно действовать осторожно:

  • Нужно убедиться, что $f(l_0) = 0$ и $f(r_0) = 1$.
  • Нужно либо останавливаться, когда $r - l < \epsilon$, либо (лучше) делать фиксированное число итераций.

Сколько итераций понадобится, чтобы получить $k$ правильных цифр? Так как двоичный поиск работает за двоичный логарифм, можно сказать, что на угадывание десятичного разряда числа потребуется примерно три шага бинпоиска ($\log 10 \approx 3$). Значит, если нам, например, нужно посчитать значение функции до шести знаков после запятой, то нам нужно ещё примерно 18 шагов уже после того, как расстояние между $l$ и $r$ достигло одного.

Чтобы каждый раз об этом не думать, можно считать, что ста шагов бинпоиска всегда хватит для всех разумных целей:

float sqrt(float x) {
    float l = 0, r = x;
    for (int i = 0; i < 100; i++) {
        float m = (l + r) / 2;
        if (m * m < x)
            l = m;
        else
            r = m;
    }
    return l;
}

На самом деле, так можно искать ноль любой непрерывной функции (мы сейчас искали ноль функции $x^2 - 2$), у которой известны значения аргумента, при которых она принимает значения меньше нуля и больше нуля.