Learning Platform
Глоссарий Troubleshooting
Урок 17.02 · 30 мин
Начальный
binary-searchO(log n)invariantssorted-arraybisect

Идея бинарного поиска

Если массив отсортирован, мы можем не идти по нему линейно. На каждом шаге мы делим оставшийся диапазон пополам и решаем, в какой половине искать. После каждого сравнения остаётся половина пространства поиска. Через k шагов от n элементов остаётся n / 2^k. Нам нужно n / 2^k = 1, отсюда k = log2(n).

Для миллиона элементов это 20 шагов. Для миллиарда — 30. Это и есть «магия» O(log n): рост настолько медленный, что практически не зависит от размера данных.

Бинарный поиск числа 30 в отсортированном массиве

Каждый шаг отбрасывает половину оставшегося диапазона.

i=05нижняя граница lo=0
i=112внутри диапазона
i=218внутри диапазона
i=325середина mid=3, значение 25, target=30, идём вправо
i=430внутри правой половины
i=542внутри правой половины
i=655верхняя граница hi=6
шаг 2lo=4 hi=6новый диапазон только из i=4,5,6
mid=54242 > 30, идём влево
шаг 3lo=4 hi=4осталась одна позиция
mid=430нашли, возвращаем 4

Инварианты

Бинарный поиск — алгоритм, где легко допустить ошибку, если не держать в голове инвариант. Инвариант — это условие, истинное на каждой итерации. Самая распространённая формулировка:

Если target существует, он лежит в полуоткрытом интервале [lo, hi). Всё, что слева от lo — точно меньше target. Всё, что справа от hi — точно больше или равно.

Эту формулировку нужно проговаривать вслух, потому что она диктует все границы:

  • Начальные значения: lo = 0, hi = len(xs). Не hi = len(xs) - 1 — это другая версия с закрытым интервалом.
  • Условие цикла: while lo < hi. Не <=.
  • Обновление: если xs[mid] < target, то lo = mid + 1. Если xs[mid] >= target, то hi = mid.
  • Возврат: после цикла lo == hi — это и есть позиция вставки или нахождения.

Разные книги выбирают разные конвенции (закрытый интервал, открытый, полуоткрытый). Важно выбрать одну и держаться её. Смешивать нельзя — получите off-by-one.

Итеративная реализация

def binary_search(xs, target):
    """
    Возвращает индекс target в отсортированном xs, или -1, если нет.
    Используем полуоткрытый интервал [lo, hi).
    """
    lo, hi = 0, len(xs)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if xs[mid] == target:
            return mid
        if xs[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return -1

Каждая строка тут — последствие инварианта.

lo + (hi - lo) // 2 вместо (lo + hi) // 2 — это защита от переполнения целого, актуальная в C/Java. В Python целые без ограничений, но я пишу так из привычки: один из самых известных багов в индустрии (баг в Java Arrays.binarySearch, 9 лет жил незамеченным) был именно (lo + hi) / 2. Об этом — в следующем уроке.

Поиск позиции вставки (lower_bound)

В Data Engineering чаще нужен не «найти равный», а «найти позицию, куда вставить, чтобы сохранить порядок» (или «найти первый элемент, не меньше target»):

def lower_bound(xs, target):
    """Первая позиция, где xs[i] >= target."""
    lo, hi = 0, len(xs)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if xs[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return lo   # позиция вставки, даже если target нет

Этот вариант не возвращает -1: если target нет, он возвращает позицию, куда бы его вставили. Это и есть bisect.bisect_left из стандартной библиотеки Python — мы дальше будем использовать именно её.

import bisect

xs = [5, 12, 18, 25, 30, 42, 55]

# найти позицию для вставки 27 (сохранив порядок)
print(bisect.bisect_left(xs, 27))   # 4

# найти позицию существующего элемента
print(bisect.bisect_left(xs, 30))   # 4

# bisect_right — позиция ПОСЛЕ всех равных
print(bisect.bisect_right(xs, 30))  # 5

Разница между bisect_left и bisect_right важна при дубликатах: если есть несколько равных, _left даёт первую позицию, _right — последнюю + 1. Их разница — количество равных элементов.

Почему именно O(log n)

Покажем на числах:

Сколько шагов нужно бинарному поиску

Двоичный логарифм растёт очень медленно — для практических задач это почти константа.

n1 000тысяча элементов
шагов10log2(1024) = 10
n1 000 000миллион — типичный размер таблицы в DE
шагов20log2(1 048 576) = 20
n1 000 000 000миллиард — большая таблица, индекс на диске
шагов30log2(2^30) = 30
nнаселение Землиоколо 8 миллиардов
шагов33log2(8 * 10^9) ~ 33

Удвоение размера данных добавляет один шаг. Удесятерение — три шага. Это и есть смысл логарифмической сложности: вы платите за поиск практически фиксированную цену.

Цена сортировки

Бинарный поиск требует отсортированных данных. Сортировка — O(n log n). Если вы планируете делать поиск один раз, бинарный поиск проигрывает линейному: O(n log n) + O(log n) хуже, чем O(n).

Бинарный поиск окупается, когда:

  1. Данные уже отсортированы (например, индекс в БД).
  2. Сортировка делается один раз, а поисков много.
  3. Данные приходят отсортированными (например, log по времени).

Если данные постоянно меняются и сортировать после каждой вставки накладно — нужны структуры, которые поддерживают порядок инкрементально: SortedList, B-tree, skip list. О sortedcontainers будет следующий урок.

TIP

Если ваш массив сортируется один раз и потом не меняется — забудьте про SortedList и прочие сложные структуры. Простой list + bisect быстрее по всем параметрам: меньше overhead, лучше cache locality, проще код. SortedList нужен, когда массив постоянно изменяется и порядок надо поддерживать.

Замеры

Сравним линейный и бинарный поиск на миллионе элементов:

import timeit
import bisect

n = 1_000_000
xs = list(range(n))
target = n - 1   # худший случай для линейного

# linear
t_linear = timeit.timeit(lambda: target in xs, number=100)

# binary (через bisect)
def bsearch(xs, t):
    i = bisect.bisect_left(xs, t)
    return i < len(xs) and xs[i] == t

t_binary = timeit.timeit(lambda: bsearch(xs, target), number=100)

print(f"linear: {t_linear * 10:.2f} ms per call")
print(f"binary: {t_binary * 1e6:.2f} us per call")
print(f"speedup: {t_linear / t_binary:.0f}x")

Типичный результат: linear около 8 мс, binary около 2 микросекунд. Ускорение порядка 4000 раз — это и есть log(1 000 000) / 1 000 000 умноженное на разницу констант.

Попробуй сам

Реализуйте бинарный поиск сами и проверьте на разных случаях:

def binary_search(xs, target):
    lo, hi = 0, len(xs)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if xs[mid] == target:
            return mid
        if xs[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return -1

# базовые случаи
assert binary_search([], 5) == -1
assert binary_search([5], 5) == 0
assert binary_search([5], 3) == -1
assert binary_search([1, 3, 5, 7, 9], 7) == 3
assert binary_search([1, 3, 5, 7, 9], 1) == 0
assert binary_search([1, 3, 5, 7, 9], 9) == 4
assert binary_search([1, 3, 5, 7, 9], 4) == -1

# граничные
assert binary_search([1, 2], 1) == 0
assert binary_search([1, 2], 2) == 1
assert binary_search([1, 2], 3) == -1

print("ok")

Если хоть один assert падает — у вас off-by-one. Это нормально с первого раза. Поправьте границы и повторите. Полный пройденный набор unit-тестов — единственный способ убедиться, что ваш бинарный поиск работает.

B-tree в PostgreSQL: бинарный поиск на страницах диска
Проверка знанийKnowledge check
У вас миллиард элементов и нужно проверить наличие 1000 разных значений. Сколько примерно сравнений сделает бинарный поиск, и почему это сильно лучше любой реализации линейного поиска?
ОтветAnswer
Бинарный поиск на миллиарде элементов делает log2(10^9) ~ 30 шагов на один запрос. 1000 запросов = 30 000 сравнений. Линейный поиск в худшем случае на каждом запросе делает 10^9 сравнений. 1000 запросов = 10^12 сравнений — это часы работы CPU. Бинарный сделает то же за миллисекунды. Это и есть сила логарифмической сложности: даже на самых больших коллекциях она остаётся фактически бесплатной. Но цена — данные должны быть отсортированы. Если массив постоянно изменяется, нужны другие структуры (SortedList, BST, B-tree), о которых поговорим дальше.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Бинарный поиск работает за O(log n). Сколько примерно сравнений потребуется на массиве из 1 миллиарда элементов?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5