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

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

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

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

Более структурно, если у нас есть отрезок поиска — изначально от l=1l=1 до r=100r=100 — то мы на каждой итерации выбираем «серединный» элемент m=l+r2m = \lfloor \frac{l+r}{2} \rfloor, спрашиваем «x>m?x>m?», выполняем присвоения l=m+1l = m + 1 или r=mr = m в зависимости от ответа, и повторяем процедуру с новыми границами, пока они не станут равными (что будет означать, что l=r=xl = 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=42x=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 вопросов, но в зависимости от загаданного числа иногда мы будем спрашивать ровно 77 вопросов, а иногда 66.

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

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

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

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

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

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]x)(a[k] \geq x) и хотим найти, начиная с какого kk оно выполняется.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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;
}

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