Алгоритмы и структуры данных

Binary Search

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

актуальноТекущий рабочий стандарт

Направления: Основы · Алгоритмы и структуры данных

Половинное деление на отсортированных данных: за логарифм шагов вместо линейного перебора. Приём шире поиска — им ищут ответ в любой монотонной задаче.

Обозначения
  • число объектов в выборке
  • логарифм: превращает произведения в суммы и сжимает масштаб
Каждый шаг вдвое сокращает область поиска

Ключевые тезисы

  • Требует отсортированности или монотонности предиката — без этого метод неприменим.
  • Двадцать шагов хватает на миллион элементов, тридцать — на миллиард.
  • по ответу решает задачи оптимизации, где прямая формула неизвестна.

Какую задачу решает

Половинное деление знают все, а применяют вдвое реже, чем стоило бы: в задачах оно чаще выглядит не как «найти элемент», а как «подобрать ответ». Общая формулировка одна — ищется граница между «не подходит» и «подходит», и каждая проверка отбрасывает половину оставшегося диапазона.

Метод обманчиво прост в описании и коварен в реализации: переполнение при вычислении середины, зацикливание из-за ветки, не уменьшающей отрезок, путаница между «любым» и «первым» вхождением. Поэтому разбор идёт от инварианта, а не от кода.

Подробный разбор

10 подтем — объяснения, формулы, примеры и интерактивные графики.

Линейный поиск в отсортированном массиве просматривает элементы по одному, хотя после первого же сравнения половина из них заведомо не подходит. На миллионе элементов это миллион сравнений вместо двадцати.

Вторая, менее очевидная форма той же боли — подбор ответа. «Какая минимальная скорость позволит успеть за 8 часов», «какой наименьший размер сервера выдержит нагрузку»: формулы нет, зато есть проверка конкретного значения. Перебор значений подряд стоит столько же, сколько велик диапазон ответа, — а диапазон бывает до .

На практике

Искать не элемент, а границу между «не подходит» и «подходит». Каждая проверка отбрасывает половину оставшихся вариантов.

Такая формулировка сразу покрывает оба случая: в массиве граница — это позиция первого элемента, не меньшего искомого; в задаче на подбор — наименьшее допустимое значение ответа. Алгоритм в обоих случаях один и тот же.

1  2  3  4  5  6  7  8  9        ищем 7
            ^
           mid = 5:  7 > 5  ->  левая половина больше не нужна

               6  7  8  9
                     ^
                    mid = 7:  нашли
Найти 7 среди 1…9: две проверки вместо девяти
ШагlohimidПроверкаЧто осталось
1084 (значение 5)5 < 7индексы 5…8
2586 (значение 7)7 = 7ответ найден

Тот же цикл в задаче про подбор скорости. Проверка — симуляция: успеваем ли за 8 часов. Она даёт «нет, нет, …, да, да», и мы ищем первое «да».

скорость:   1   2   3   4   5   6   7   8
успеваем:  нет нет нет нет нет  да  да  да
                                 ^
                                 ответ: 6

mid = 4:  не успеваем  ->  все скорости <= 4 отпадают
mid = 6:  успеваем     ->  ответ не правее 6
mid = 5:  не успеваем  ->  ответ ровно 6
Поиск границы: предикат монотонен

def lower_bound(a, target):
    lo, hi = 0, len(a)          # инвариант: ответ лежит в [lo, hi]
    while lo < hi:
        mid = lo + (hi - lo) // 2   # без переполнения
        if a[mid] < target:
            lo = mid + 1            # a[mid] точно не ответ
        else:
            hi = mid                # a[mid] может быть ответом
    return lo                       # lo == hi
Первый элемент, не меньший target — из этой функции выводится всё остальное
lo, hi = 1, 10**18              # заведомо плохое и заведомо хорошее
while lo < hi:
    mid = lo + (hi - lo) // 2
    if ok(mid):                 # проверка может быть сколь угодно сложной
        hi = mid
    else:
        lo = mid + 1
return lo
Поиск по ответу: тот же цикл поверх предиката
  • Время сравнений; для поиска по ответу это проверок, где — длина диапазона.
  • Память.
  • Предусловие — отсортированность массива или монотонность предиката: «нет, …, нет, да, …, да».
  • Полуинтервал [lo, hi): пустота — это lo == hi, и большинство ошибок на единицу исчезает само.

Корректность держится на инварианте: ответ всегда лежит в текущем отрезке [lo, hi]. Каждая ветка цикла сохраняет это свойство — либо мы отбрасываем половину, про которую доказано, что ответа там нет, либо сужаем отрезок, оставляя кандидата внутри. Когда границы сходятся, в отрезке остаётся ровно один элемент, и по инварианту это и есть ответ.

Завершимость — отдельное требование, и именно на нём зацикливаются реализации. Каждая ветка обязана строго уменьшать длину отрезка. lo = mid вместо lo = mid + 1 этого не делает: при hi - lo == 1 середина совпадает с lo, и цикл крутится вечно.

На практике

Монотонность предиката — не техническая деталь, а содержательное утверждение о задаче. «Если скорости 6 хватает, то и 7 хватит» нужно проговаривать явно: как только это перестаёт быть правдой, метод даёт неверный ответ, а не медленный.

Сигнал в условииВариант
Массив отсортирован, нужно найти или посчитатьклассический поиск или поиск границы
«Минимальное k, при котором получается», «максимальное, чтобы влезло»поиск по ответу
«Если подходит x, подходит и всё, что больше»монотонность названа прямо
Ответ — большое число, проверить его дёшевопоиск по ответу
Повёрнутый отсортированный массивпоиск с определением отсортированной половины
Массив не отсортирован, ищем значение, а не
Данные меняются между запросамидерево поиска, а не массив
На практике

Отсортированный вход — сигнал сам по себе: порядком пользуются двумя способами, и двумя указателями. Если решение не использует ни то, ни другое, в условии, скорее всего, дана не зря.

Что ищемУсловие сдвигаЧто вернуть
Точное вхождениеa[mid] == x — ответ найдениндекс или «нет»
Первую позицию, где a[i] >= xa[mid] < xlo = mid + 1lo
Последнюю позицию, где a[i] <= xa[mid] <= xlo = mid + 1lo - 1
Границу предикатаP(mid)hi = midlo
  • Повёрнутый массив — на каждом шаге определяем, какая половина отсортирована, и только потом сравниваем.
  • Массив неизвестной длины — сначала удваиваем правую границу, пока не перешагнём значение.
  • Вещественный ответ — фиксированное число итераций вместо условия сходимости: сто шагов дают точность от диапазона.
  • Унимодальная функция — половинное деление по или тернарный поиск.
  • Матрица, отсортированная по строкам и столбцам — обход от правого верхнего угла: шаг влево или вниз отбрасывает строку или столбец целиком.
На практике

Первые два варианта уже есть в стандартных библиотеках (bisect_left и bisect_right, lower_bound и upper_bound). Руками пишут поиск по предикату — тот, которого в библиотеке быть не может.

  • Зацикливание. Ветка, не уменьшающая отрезок (lo = mid), — самая частая причина бесконечного цикла.
  • Переполнение. (lo + hi) // 2 в языках с ограниченным целым типом переполняется на больших индексах; lo + (hi - lo) // 2 — нет.
  • Не тот ответ из двух. «Любое вхождение» и «первое вхождение» — разные задачи; при дубликатах разница видна сразу.
  • Немонотонный предикат. Метод молча вернёт какую-то границу — неверную.
  • Диапазон поиска. Границы должны быть заведомо плохой и заведомо хорошей; ответ вне диапазона не найдётся.
  • Вещественные сравнения. Условие lo < hi на float не завершается — цикл делают по числу итераций.

ПриёмКогда он лучшеЦена
Хеш-таблицапоиск по точному ключу, порядок не нужен памяти, нет диапазонных запросов
Дерево поискаданные меняются, порядок нужно поддерживать на вставку
Два указателяищется пара в отсортированном массиве без логарифма
Тернарный поискфункция унимодальна, а не монотонначуть больше проверок на шаг
Линейный проходпоиск однократный, массив не отсортирован ради одного поиска не окупается

  1. Почему lo = mid + 1, а не lo = mid?
  2. Чем lower_bound отличается от upper_bound и как из них получить число вхождений элемента?
  3. Сформулируйте предикат для задачи «минимальная скорость, чтобы успеть за 8 часов», и докажите его монотонность.
  4. Повёрнутый массив [4, 5, 6, 7, 0, 1, 2]: как за один шаг понять, какая половина отсортирована?
  5. Почему для вещественного ответа цикл делают фиксированной длины?

Чем заменяют

Видеолекции

Записи университетских курсов, где эта тема звучит. Тайм-кодов у них нет, поэтому открываются они с начала.

Связанные темы

Алгоритмическое мышление

Problem Pattern Recognition85%

Как распознать паттерн задачи · Алгоритмы и структуры данных

Уровень над алгоритмами: по формулировке условия понять, какой приём здесь работает. «Отсортированный массив», «подряд идущие элементы», «ближайший больший справа» — каждая такая фраза указывает на конкретное семейство решений.

Time and Space Complexity85%

Асимптотическая сложность · Алгоритмы и структуры данных

Способ сравнить алгоритмы, не запуская их: как растёт время работы с ростом входа. Отбрасывает константы и оставляет то, что решает на больших данных.

Recursion and Divide and Conquer85%

Рекурсия и разделяй-и-властвуй · Алгоритмы и структуры данных

Задача сводится к таким же задачам меньшего размера. Основа сортировки слиянием, быстрой сортировки, обхода деревьев и почти всей динамики.

Dynamic Programming85%

Динамическое программирование · Алгоритмы и структуры данных

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

Greedy Algorithms85%

Жадные алгоритмы · Алгоритмы и структуры данных

На каждом шаге берём локально лучший вариант и не пересматриваем решение. Работает не всегда — но когда работает, проще и быстрее динамики.

Проверить себя

Тема встречается в тесте по направлениям — ошибки приведут обратно на эту страницу.

Следующий шагПроверить себя: Алгоритмы и структуры данныхТема встречается в этом тесте 1 раз. Ошибка приведёт обратно на эту страницу — с объяснением, что именно не сошлось.Перейти →

Глава «Алгоритмы и структуры данных» последний раз правилась . Нашли ошибку — напишите.