Половинное деление на отсортированных данных: за логарифм шагов вместо линейного перебора. Приём шире поиска — им ищут ответ в любой монотонной задаче.
- число объектов в выборке
- логарифм: превращает произведения в суммы и сжимает масштаб
Ключевые тезисы
- Требует отсортированности или монотонности предиката — без этого метод неприменим.
- Двадцать шагов хватает на миллион элементов, тридцать — на миллиард.
- по ответу решает задачи оптимизации, где прямая формула неизвестна.
Какую задачу решает
Половинное деление знают все, а применяют вдвое реже, чем стоило бы: в задачах оно чаще выглядит не как «найти элемент», а как «подобрать ответ». Общая формулировка одна — ищется граница между «не подходит» и «подходит», и каждая проверка отбрасывает половину оставшегося диапазона.
Метод обманчиво прост в описании и коварен в реализации: переполнение при вычислении середины, зацикливание из-за ветки, не уменьшающей отрезок, путаница между «любым» и «первым» вхождением. Поэтому разбор идёт от инварианта, а не от кода.
Подробный разбор
Линейный поиск в отсортированном массиве просматривает элементы по одному, хотя после первого же сравнения половина из них заведомо не подходит. На миллионе элементов это миллион сравнений вместо двадцати.
Вторая, менее очевидная форма той же боли — подбор ответа. «Какая минимальная скорость позволит успеть за 8 часов», «какой наименьший размер сервера выдержит нагрузку»: формулы нет, зато есть проверка конкретного значения. Перебор значений подряд стоит столько же, сколько велик диапазон ответа, — а диапазон бывает до .
Искать не элемент, а границу между «не подходит» и «подходит». Каждая проверка отбрасывает половину оставшихся вариантов.
Такая формулировка сразу покрывает оба случая: в массиве граница — это позиция первого элемента, не меньшего искомого; в задаче на подбор — наименьшее допустимое значение ответа. Алгоритм в обоих случаях один и тот же.
1 2 3 4 5 6 7 8 9 ищем 7
^
mid = 5: 7 > 5 -> левая половина больше не нужна
6 7 8 9
^
mid = 7: нашли| Шаг | lo | hi | mid | Проверка | Что осталось |
|---|---|---|---|---|---|
| 1 | 0 | 8 | 4 (значение 5) | 5 < 7 | индексы 5…8 |
| 2 | 5 | 8 | 6 (значение 7) | 7 = 7 | ответ найден |
Тот же цикл в задаче про подбор скорости. Проверка — симуляция: успеваем ли за 8 часов. Она даёт «нет, нет, …, да, да», и мы ищем первое «да».
скорость: 1 2 3 4 5 6 7 8
успеваем: нет нет нет нет нет да да да
^
ответ: 6
mid = 4: не успеваем -> все скорости <= 4 отпадают
mid = 6: успеваем -> ответ не правее 6
mid = 5: не успеваем -> ответ ровно 6def 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 == hilo, 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] >= x | a[mid] < x → lo = mid + 1 | lo |
Последнюю позицию, где a[i] <= x | a[mid] <= x → lo = mid + 1 | lo - 1 |
| Границу предиката | P(mid) → hi = mid | lo |
- Повёрнутый массив — на каждом шаге определяем, какая половина отсортирована, и только потом сравниваем.
- Массив неизвестной длины — сначала удваиваем правую границу, пока не перешагнём значение.
- Вещественный ответ — фиксированное число итераций вместо условия сходимости: сто шагов дают точность от диапазона.
- Унимодальная функция — половинное деление по или тернарный поиск.
- Матрица, отсортированная по строкам и столбцам — обход от правого верхнего угла: шаг влево или вниз отбрасывает строку или столбец целиком.
Первые два варианта уже есть в стандартных библиотеках (bisect_left и bisect_right, lower_bound и upper_bound). Руками пишут поиск по предикату — тот, которого в библиотеке быть не может.
- Зацикливание. Ветка, не уменьшающая отрезок (
lo = mid), — самая частая причина бесконечного цикла. - Переполнение.
(lo + hi) // 2в языках с ограниченным целым типом переполняется на больших индексах;lo + (hi - lo) // 2— нет. - Не тот ответ из двух. «Любое вхождение» и «первое вхождение» — разные задачи; при дубликатах разница видна сразу.
- Немонотонный предикат. Метод молча вернёт какую-то границу — неверную.
- Диапазон поиска. Границы должны быть заведомо плохой и заведомо хорошей; ответ вне диапазона не найдётся.
- Вещественные сравнения. Условие
lo < hiнаfloatне завершается — цикл делают по числу итераций.
| Приём | Когда он лучше | Цена |
|---|---|---|
| Хеш-таблица | поиск по точному ключу, порядок не нужен | памяти, нет диапазонных запросов |
| Дерево поиска | данные меняются, порядок нужно поддерживать | на вставку |
| Два указателя | ищется пара в отсортированном массиве | без логарифма |
| Тернарный поиск | функция унимодальна, а не монотонна | чуть больше проверок на шаг |
| Линейный проход | поиск однократный, массив не отсортирован | ради одного поиска не окупается |
- Почему
lo = mid + 1, а неlo = mid? - Чем
lower_boundотличается отupper_boundи как из них получить число вхождений элемента? - Сформулируйте предикат для задачи «минимальная скорость, чтобы успеть за 8 часов», и докажите его монотонность.
- Повёрнутый массив [4, 5, 6, 7, 0, 1, 2]: как за один шаг понять, какая половина отсортирована?
- Почему для вещественного ответа цикл делают фиксированной длины?
Чем заменяют
Видеолекции
Записи университетских курсов, где эта тема звучит. Тайм-кодов у них нет, поэтому открываются они с начала.
Связанные темы
Алгоритмическое мышление
Problem Pattern Recognition85%
Как распознать паттерн задачи · Алгоритмы и структуры данныхУровень над алгоритмами: по формулировке условия понять, какой приём здесь работает. «Отсортированный массив», «подряд идущие элементы», «ближайший больший справа» — каждая такая фраза указывает на конкретное семейство решений.
Time and Space Complexity85%
Асимптотическая сложность · Алгоритмы и структуры данныхСпособ сравнить алгоритмы, не запуская их: как растёт время работы с ростом входа. Отбрасывает константы и оставляет то, что решает на больших данных.
Recursion and Divide and Conquer85%
Рекурсия и разделяй-и-властвуй · Алгоритмы и структуры данныхЗадача сводится к таким же задачам меньшего размера. Основа сортировки слиянием, быстрой сортировки, обхода деревьев и почти всей динамики.
Dynamic Programming85%
Динамическое программирование · Алгоритмы и структуры данныхПеребор, в котором каждое подсостояние считается ровно один раз. Превращает экспоненциальный перебор в полиномиальный, если подзадачи перекрываются.
Greedy Algorithms85%
Жадные алгоритмы · Алгоритмы и структуры данныхНа каждом шаге берём локально лучший вариант и не пересматриваем решение. Работает не всегда — но когда работает, проще и быстрее динамики.
Всё, что названо в описании, разобрано здесь же или в соседней теме — переходы под каждым определением.
Инвариант цикла
Ответ всегда внутри [lo, hi]: единственный надёжный способ не ошибиться с границами.
Разбор ниже: Почему это работаетПоиск по ответу
Перебираются не индексы, а значения ответа; проверка может быть симуляцией.
Разбор ниже: Механика на игрушечном примереМонотонность
Подходит x — подходит и всё правее. Без этого метод неверен, а не медленен.
Разбор ниже: Почему это работаетТема встречается в тесте по направлениям — ошибки приведут обратно на эту страницу.
Глава «Алгоритмы и структуры данных» последний раз правилась . Нашли ошибку — напишите.