Бинарный поиск
Идея
Ищем слово в бумажном словаре: открываем середину, смотрим, левее или правее нужное слово, и отбрасываем половину. Каждый шаг уменьшает область поиска вдвое → O(log n): миллион элементов — 20 шагов, миллиард — 30.
Условие: данные отсортированы (или, шире, есть монотонное условие «нет-нет-нет-да-да-да»).
def binary_search(nums, target):
lo, hi = 0, len(nums) - 1 # ищем в отрезке [lo, hi] включительно
while lo <= hi:
mid = (lo + hi) // 2
print(f" lo={lo}, hi={hi}, mid={mid}, nums[mid]={nums[mid]}")
if nums[mid] == target:
return mid
if nums[mid] < target:
lo = mid + 1 # target правее
else:
hi = mid - 1 # target левее
return -1
nums = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
print(binary_search(nums, 23))
print(binary_search(nums, 7))
Пройдите пример 👣 Пошагово и посмотрите, как сужаются границы.
Инварианты — как не ошибиться
Ошибки бинарного поиска — классика: бесконечный цикл, пропуск элемента, выход за границы. Защита — явный инвариант: что истинно для границ на каждом шаге.
| Вариант | Отрезок | Условие цикла | Сдвиг |
|---|---|---|---|
| поиск точного значения | [lo, hi], hi = n - 1 |
lo <= hi |
lo = mid + 1, hi = mid - 1 |
| первый «да» (lower bound) | [lo, hi), hi = n |
lo < hi |
«нет» → lo = mid + 1, «да» → hi = mid |
Второй вариант универсален: ищем первую позицию, где условие становится истинным.
def first_true(lo, hi, cond):
"""Первое x в [lo, hi), для которого cond(x) истинно (cond монотонно: F F F T T T)."""
while lo < hi:
mid = (lo + hi) // 2
if cond(mid):
hi = mid # mid подходит — ответ mid или левее
else:
lo = mid + 1 # mid не подходит — ответ правее
return lo
nums = [1, 2, 2, 2, 5, 7]
first = first_true(0, len(nums), lambda i: nums[i] >= 2) # первое вхождение 2
after = first_true(0, len(nums), lambda i: nums[i] > 2) # первое больше 2
print(first, after, "→ двойка встречается", after - first, "раза")
В Python нет переполнения
В C/Java (lo + hi) / 2 может переполниться, поэтому пишут lo + (hi - lo) / 2. В Python целые числа неограниченные — (lo + hi) // 2 безопасно, но о переполнении могут спросить.
Модуль bisect
import bisect
scores = [10, 20, 20, 20, 30, 40]
print(bisect.bisect_left(scores, 20)) # первая позиция, куда можно вставить 20 — слева от равных
print(bisect.bisect_right(scores, 20)) # справа от равных
print(bisect.bisect_left(scores, 25))
bisect.insort(scores, 25) # вставка с сохранением порядка (поиск O(log n), вставка O(n))
print(scores)
def grade(score, breakpoints=(60, 70, 80, 90), grades="FDCBA"):
return grades[bisect.bisect(breakpoints, score)]
print([grade(s) for s in (33, 60, 77, 89, 90, 100)])
events = [(1, "старт"), (5, "обед"), (9, "отчёт")] # поиск по ключу (Python 3.10+)
i = bisect.bisect_right(events, 6, key=lambda e: e[0])
print("последнее событие до 6:", events[i - 1])
На собеседовании обычно просят написать поиск самому, но знать bisect полезно.
Поиск по ответу
Мощный приём: если ответ — число, и можно быстро проверить, подходит ли кандидат, причём проверка монотонна («если подходит k, подходит и любое большее»), — ищем минимальный подходящий k бинарным поиском по значениям.
def min_capacity(weights, days):
"""Минимальная грузоподъёмность корабля, чтобы перевезти посылки по порядку за days дней."""
def can_ship(capacity):
need_days, load = 1, 0
for w in weights:
if load + w > capacity:
need_days += 1
load = 0
load += w
return need_days <= days
lo, hi = max(weights), sum(weights) # ответ точно в этом диапазоне
while lo < hi:
mid = (lo + hi) // 2
if can_ship(mid):
hi = mid
else:
lo = mid + 1
return lo
print(min_capacity([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5))
def isqrt(n):
"""Целая часть квадратного корня без math."""
lo, hi = 0, n + 1
while lo < hi: # первое x, у которого x*x > n
mid = (lo + hi) // 2
if mid * mid > n:
hi = mid
else:
lo = mid + 1
return lo - 1
print(isqrt(17), isqrt(10**20), isqrt(0))
Признаки: «минимальная скорость/вместимость/время, при которой…», «максимальное минимальное расстояние», «разбить на k частей с минимальной максимальной суммой».
Повёрнутый массив и пик
def search_rotated(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[lo] <= nums[mid]: # левая половина отсортирована
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
else: # правая половина отсортирована
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return -1
print(search_rotated([4, 5, 6, 7, 0, 1, 2], 0), search_rotated([4, 5, 6, 7, 0, 1, 2], 3))
def find_peak(nums):
"""Индекс любого элемента, который больше соседей."""
lo, hi = 0, len(nums) - 1
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < nums[mid + 1]:
lo = mid + 1 # подъём — пик правее
else:
hi = mid # спуск — пик здесь или левее
return lo
print(find_peak([1, 2, 1, 3, 5, 6, 4]))
Итог
- Бинарный поиск — O(log n) на монотонных данных; каждый шаг отбрасывает половину.
- Держите инвариант:
[lo, hi]+lo <= hiдля точного поиска,[lo, hi)+lo < hiдля «первого подходящего». bisect_left/bisect_right/insort— готовые функции.- Поиск по ответу: бинарный поиск по значению с функцией проверки.
- Повёрнутый массив и пик — бинарный поиск по признаку половины.
Практика решено 0 из 5
Классический бинарный поиск. Верните индекс target в отсортированном списке nums
или -1. Не используйте bisect и index — напишите цикл с двумя границами.
Какие тесты проверяют код (3)
assert binary_search([-1, 0, 3, 5, 9, 12], 9) == 4 and binary_search([-1, 0, 3, 5, 9, 12], 2) == -1
assert binary_search([], 1) == -1 and binary_search([5], 5) == 0 and binary_search([1, 2], 2) == 1
class Spy(list):
reads = 0
def __getitem__(self, i):
Spy.reads += 1
return super().__getitem__(i)
nums = Spy(range(0, 200_000, 2))
assert binary_search(nums, 199_998) == 99_999 and binary_search(nums, 7) == -1
assert 0 < Spy.reads < 100, f"Прочитано элементов по индексу: {Spy.reads} — нужен O(log n) через nums[mid]"
def binary_search(nums, target):
lo, hi = 0, len(nums) - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
Первое вхождение (lower bound). Верните индекс первого элемента, который >= target
(если все меньше — len(nums)). Это аналог bisect.bisect_left, но напишите его сами.
Через неё легко найти, сколько раз число встречается в отсортированном списке.
Какие тесты проверяют код (3)
assert lower_bound([1, 2, 2, 2, 5], 2) == 1 and lower_bound([1, 2, 2, 2, 5], 3) == 4 assert lower_bound([1, 2, 3], 0) == 0 and lower_bound([1, 2, 3], 9) == 3 and lower_bound([], 5) == 0 import bisect, random; r = random.Random(7); data = sorted(r.randint(0, 50) for _ in range(200)); assert all(lower_bound(data, t) == bisect.bisect_left(data, t) for t in range(-2, 55))
def lower_bound(nums, target):
lo, hi = 0, len(nums)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
Поиск по ответу: Коко и бананы. Есть кучи бананов piles и h часов. За час Коко съедает
до k бананов из одной кучи (если в куче меньше — доедает её и ждёт следующего часа).
Найдите минимальное целое k, при котором она успеет за h часов (h >= len(piles)).
Куча может быть до 10⁹ бананов — перебор k по одному не подойдёт.
Какие тесты проверяют код (3)
assert min_eating_speed([3, 6, 7, 11], 8) == 4 and min_eating_speed([30, 11, 23, 4, 20], 5) == 30 assert min_eating_speed([30, 11, 23, 4, 20], 6) == 23 and min_eating_speed([1], 1) == 1 import time start = time.perf_counter() assert min_eating_speed([10**9] * 3, 3) == 10**9 assert time.perf_counter() - start < 0.2, "Перебор k по одному слишком медленный — ищите k бинарным поиском"
def min_eating_speed(piles, h):
def hours(k):
return sum((p + k - 1) // k for p in piles)
lo, hi = 1, max(piles)
while lo < hi:
mid = (lo + hi) // 2
if hours(mid) <= h:
hi = mid
else:
lo = mid + 1
return lo
Минимум в повёрнутом массиве. Отсортированный массив без повторов «повернули»:
[0, 1, 2, 4, 5, 6, 7] → [4, 5, 6, 7, 0, 1, 2]. Найдите минимальный элемент за O(log n).
Какие тесты проверяют код (3)
assert find_min_rotated([4, 5, 6, 7, 0, 1, 2]) == 0 and find_min_rotated([3, 4, 5, 1, 2]) == 1
assert find_min_rotated([11, 13, 15, 17]) == 11 and find_min_rotated([2, 1]) == 1 and find_min_rotated([1]) == 1
class Spy(list):
reads = 0
def __getitem__(self, i):
Spy.reads += 1
return super().__getitem__(i)
data = Spy(list(range(500_000, 1_000_000)) + list(range(500_000)))
assert find_min_rotated(data) == 0
assert 0 < Spy.reads < 200, f"Прочитано по индексу {Spy.reads} элементов — нужно O(log n) через nums[mid]"
def find_min_rotated(nums):
lo, hi = 0, len(nums) - 1
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] > nums[hi]:
lo = mid + 1
else:
hi = mid
return nums[lo]
Чем опасна запись while lo < hi: mid = (lo + hi) // 2; if cond: lo = mid else: hi = mid - 1?
Войдите, чтобы отмечать пройденные уроки.