Привет, Хабр! В 2006 году Джошуа Блох, автор "Effective Java" и один из создателей Java Collections Framework, опубликовал разбор бага в бинарном поиске JDK, который сидел в коде почти десять лет. Проблема оказалась в вычислении среднего индекса: (left + right) / 2 переполнялось на больших массивах. Бинарный поиск — один из первых алгоритмов, которые учат на курсах, и всё равно ошибка пряталась в стандартной библиотеке.
На собеседованиях задачи на поиск спрашивают постоянно. В продакшене разница между O(n) и O(log n) — это разница между секундами и миллисекундами на десяти миллионах записей.
В этой статье мы разберём четыре подхода:
линейный поиск
бинарный (с вариантами границ)
экспоненциальный
с использованием хеш-таблицы
и обсудим:
пограничные случаи
сравнительную таблицу
практическую задачу с LeetCode
Все примеры напишем на Python, каждый фрагмент можно запустить отдельно (чтобы побенчить, например).
Постановка задачи
Формально задача поиска формулируется так: дана коллекция элементов (чаще всего массив) arr и целевое значение target. Требуется определить, присутствует ли target в коллекции, и вернуть его индекс. Если элемент отсутствует, вернуть -1 или None. Коллекция может быть отсортированной или нет, статической или динамической.
Где это встречается на практике:
E-commerce: поиск товара по артикулу на складе. Дан массив всех артикулов (SKU - Stock Keeping Unit) - это миллионы записей. Например:
arr = [..., "1234-A-B", "2345-B-C", ..., "34567-C-D-E", ...],target = "34567-C-D-E".
Ожидается индекс позиции товара или-1, если отсутствует.
Fintech: проверка, была ли уже обработана транзакция с заданным ID. Массив ID отсортирован по времени:
arr = [73451002, 73451005, ..., 99814321],target = 81563444. Результат — индекс или-1.
Масштаб данных имеет значение:
Размер массива | Пример | Линейный поиск (сравнений) | Бинарный поиск (сравнений) |
Маленький (30) | ученики в классе | ||
Средний (5k) | песни в медиатеке | ||
Большой (200k) | жители среднего города | ||
Огромный (10M) | абоненты мобильного оператора |
Для 10 миллионов элементов линейный поиск в худшем случае делает 10 миллионов сравнений, что может занять секунды, в то время как бинарный справится за доли миллисекунды.
Основной технический разбор
Линейный поиск (Sequential Search)
Линейный поиск подходит для любой коллекции (отсортированной или нет). Он последовательно сравнивает каждый элемент с искомым.
Шаги алгоритма:
Инициализировать индекс
i = 0.Пока
i < len(arr), выполнять:Если
arr[i] == target, вернутьi.Увеличить
iна 1.
Если цикл завершился, вернуть
None(или-1).
Реализация на Python:
def linear_search(arr, target): for i, val in enumerate(arr): if val == target: return i return -1
Анализ сложности:
Время: худший и средний случай
O(n), лучший (элемент первый)O(1).Память:
O(1).
Бинарный поиск (Binary Search)
Бинарный поиск применяется только к отсортированным коллекциям. Он делит текущий интервал пополам и отбрасывает ту половину, в которой заведомо нет искомого элемента.
Шаги (итеративная версия):
Установить левую границу
left = 0, правуюright = len(arr) - 1.Пока
left <= right:Вычислить средний индекс:
mid = left + (right - left) // 2*.Сравнить
arr[mid]сtarget.Если
arr[mid] == target, вернутьmid.Если
arr[mid] < target, присвоитьleft = mid + 1.Иначе присвоить
right = mid - 1.
Если цикл завершился, элемент не найден — вернуть
-1.
Защита от переполнения:* формула mid = left + (right - left) // 2 вместо (left + right) // 2 исключает переполнение при больших индексах в языках с фиксированной разрядностью (например, C++, Java..). В великом и могучем Питоне переполнения нет, однако, на собеседованиях важно показать, что Вы об этом знаете.
Важно: стандартный (классический) бинарный поиск не гарантирует, какой из дубликатов будет найден. Если нужно первое или последнее вхождение, используют модификации lower_bound и upper_bound. В случае upper_bound, если быть точным, ищется первая позиция, при вставке на которую элемента, большего искомого, не нарушается отсортированность массива.
"""Классический бинарный поиск. Возвращает индекс любого вхождения target или -1""" def binary_search(arr, target): left, right = 0, len(arr) - 1 while left <= right: mid = (left + right) // 2 # В Python нет переполнения if arr[mid] == target: return mid if arr[mid] < target: left = mid + 1 else: right = mid - 1 return -1 """Возвращает первый индекс, где target может быть вставлен (левая граница).""" def lower_bound(arr, target): left, right = 0, len(arr) while left < right: mid = (left + right) // 2 if arr[mid] < target: left = mid + 1 else: right = mid return left """Возвращает первый индекс, где элемент больше target (правая граница)""" def upper_bound(arr, target): left, right = 0, len(arr) while left < right: mid = (left + right) // 2 if arr[mid] <= target: left = mid + 1 else: right = mid return left # Пример sorted_arr = [1, 3, 3, 5, 7, 9] print(binary_search(sorted_arr, 5)) # 3 print(lower_bound(sorted_arr, 3)) # 1 print(upper_bound(sorted_arr, 3)) # 3
Анализ сложности:
Время:
O(log n)во всех случаях.Память:
O(1)для итеративной версии.
Экспоненциальный поиск (Exponential Search)
Экспоненциальный поиск предназначен для отсортированных массивов, размер которых может быть неизвестен (например, бесконечный поток) или когда искомый элемент находится близко к началу. Сначала он находит диапазон, где может находиться элемент, экспоненциально увеличивая границу, а затем выполняет бинарный поиск на этом диапазоне.
Шаги алгоритма:
Если массив пуст, вернуть
-1.Если
arr[0] == target, вернуть0.Инициализировать
bound = 1.Пока
bound < len(arr)иarr[bound] < target, умножитьboundна 2 (расширяем границу).Выполнить бинарный поиск на интервале
[bound // 2, min(bound, len(arr)-1)].
Реализация на Python:
def exponential_search(arr, target): if not arr: return -1 if arr[0] == target: return 0 bound = 1 while bound < len(arr) and arr[bound] < target: bound *= 2 # Бинарный поиск на суженном интервале left = bound // 2 right = min(bound, len(arr) - 1) while left <= right: mid = (left + right) // 2 if arr[mid] == target: return mid if arr[mid] < target: left = mid + 1 else: right = mid - 1 return -1 # Пример big_arr = list(range(1_000_000)) print(exponential_search(big_arr, 123456)) # 123456
Анализ сложности:
Время:
O(log i), гдеi— индекс искомого элемента, в худшем случаеO(log n).Память:
O(1).
Поиск с помощью хеш-таблицы (Hash Table Search)
Хеш-таблицы обеспечивают константный в среднем доступ к элементам по ключу. В Python встроенные типы set и dict реализуют хеш-таблицы.
Шаги (при использовании set):
Построить множество из элементов коллекции (или поддерживать его динамически).
Проверить
target in set.
Реализация на Python:
"""Проверяет наличие target в коллекции за O(1) в среднем.""" def hash_search_set(arr, target): s = set(arr) # O(n) на построение return target in s def hash_search_dict(pairs, key): """Поиск значения по ключу в словаре.""" d = dict(pairs) return d.get(key) # Пример nums = [10, 20, 30, 40] print(hash_search_set(nums, 30)) # True pairs = [("a", 1), ("b", 2)] print(hash_search_dict(pairs, "b")) # 2
Анализ сложности:
Время: в среднем
O(1), в худшем (при множественных коллизиях)O(n).Память:
O(n)для хранения хеш-таблицы.
Чек-лист: когда какой алгоритм использовать
Выбор алгоритма поиска зависит от структуры данных и самого технического задания. Основываясь на своём опыте, собрал краткое руководство, которое нетрудно держать в голове:
Массив отсортирован?
Нет → если массив маленький (N < 100) или поиск выполняется редко, используйте линейный поиск. В противном случае рассмотрите сортировку с последующим бинарным поиском (если множество поисков) или переход к другим структурам (хеш-таблицы).
Да → переходим к пункту 2.
Массив статический (редко меняется)?
Да → бинарный поиск (или его вариации).
Нет → частые вставки/удаления: используйте хеш-таблицы (если порядок не важен). Формально тут ещё есть сбалансированные деревья (
set/mapв C++,SortedListв Python), но лично мне за всю практику в учёбе и мобильной разработке не приходилось писать их руками — хватало хеш-таблиц.
Размер массива неизвестен или потоковый? → экспоненциальный поиск.
Требуется ли константное время вставки и поиска? → хеш-таблица (но учтите затраты памяти и отсутствие порядка).
Сравнительная таблица
Алгоритм | Время (среднее) | Время (худшее) | Память | Требования | Рекомендуемые сценарии |
|---|---|---|---|---|---|
Линейный поиск | Любая коллекция | Малые объёмы, неотсортированные данные, однократный поиск | |||
Бинарный поиск | Отсортированная коллекция | Статические отсортированные массивы, частые поиски | |||
Экспоненциальный поиск | Отсортированная коллекция | Неограниченные/потоковые данные, элемент близок к началу | |||
Хеш-таблица | Хешируемые ключи, доп. память | Частые поиски/вставки, порядок не важен |
* - среднее время для хеш-таблиц верно при условии хорошей хеш-функции и низкого коэффициента заполнения. То есть, на практике для dict/set в Питоне это справедливо, но формально - с оговоркой.
Есть ещё интерполяционный поиск, который не был включён в таблицу. Основная идея: вместо того чтобы всегда делить массив пополам, он прикидывает, где примерно должен быть элемент. Примерно так же мы ищем слово в бумажном словаре — «щ» ищем ближе к концу, а не открываем на середине. На равномерных данных это даёт
O(log log n). Если Вы хотите полноценный разбор — пишите в комментариях, и мы соберём для Вас материал.
Для более явной демонстрации эффективности того или иного алгоритма всегда нужно держать в голове следующий график:

Он отчетливо показывает, как меняется время выполнения алгоритма в зависимости от размера входных данных.
Пограничные случаи
Реализации поисковых алгоритмов должны корректно обрабатывать нетривиальные ситуации:
Пустой массив – функции обязаны возвращать «специальное значение». В нашем коде это учтено: возвращается
-1илиNone.Дубликаты – классический бинарный поиск может вернуть любой из дубликатов. Если требуется первое или последнее вхождение, используйте
lower_bound/upper_bound. Важно: в production-коде недетерминированное поведение считается багом, если не оговорено иное xD.Переполнение при вычислении среднего индекса – в языках с фиксированной разрядностью (C++, Java) выражение
(left + right) // 2может вызвать переполнение. Всегда применяйте безопасную формулу:left + (right - left) // 2. В Python целые числа неограничены, но привычка писать так полезна как для кросс-языковой переносимости, так и для переносимости собесов.Числа с плавающей точкой – прямое сравнение
arr[mid] == targetненадёжно из-за погрешностей. Используйте сравнение с допуском:abs(arr[mid] - target) < eps.Элемент отсутствует – важно определить, что возвращать:
-1,Noneили бросать исключение. В наших примерах используется-1илиNone. Также проверяйте случаи, когдаtargetменьше минимального или больше максимального элемента — алгоритмы должны корректно возвращать «не найдено».Массив из одного элемента – частный случай, который часто выявляет ошибки в граничных условиях. Наши реализации проходят его (например,
binary_search([42], 42)вернёт0,binary_search([42], 100)вернёт-1).Отрицательные индексы, большие значения – убедитесь, что границы никогда не выходят за допустимый диапазон. В
lower_bound/upper_boundиспользуется полуинтервал[0, len(arr)), что исключает обращение к несуществующим элементам.
Практическая задача: поиск первого и последнего вхождения
Условие (LeetCode, Medium): Дан отсортированный массив целых чисел nums и целевое значение target. Найдите начальную и конечную позицию target в массиве. Если target не найден, вернуть [-1, -1]. Сложность должна быть O(log n).
nums = [5,7,7,8,8,10], target = 8 → [3,4] nums = [5,7,7,8,8,10], target = 6 → [-1,-1]
Решение с использованием lower_bound и upper_bound:
"""Возвращает [первый_индекс, последний_индекс] или [-1, -1].""" def search_range(nums, target): left_idx = lower_bound(nums, target) if left_idx == len(nums) or nums[left_idx] != target: return [-1, -1] right_idx = upper_bound(nums, target) - 1 return [left_idx, right_idx] # Пример print(search_range([5, 7, 7, 8, 8, 10], 8)) # [3, 4] print(search_range([5, 7, 7, 8, 8, 10], 6)) # [-1, -1]
Объяснение: lower_bound находит первый индекс, где target может быть вставлен (и фактически первый индекс target, если он есть). Проверяем, что индекс в пределах массива и элемент действительно равен target. Затем upper_bound находит первый индекс элемента, большего чем target; вычитаем 1, чтобы получить последнее вхождение. Сложность — два бинарных поиска, то есть O(log n).
Итого
Четыре алгоритма — линейный, бинарный (с вариациями), экспоненциальный и хеш-таблицы — закрывают большинство задач на поиск, которые встречаются на собеседованиях и в рабочем коде.
Что стоит запомнить:
O(n)vsO(log n)— не теоретическая мелочь. На миллионах записей это разница между «всё летает» и «пользователи жалуются».Лучшего алгоритма не существует. Данные отсортированы и не меняются — выбирайте бинарный поиск. Часто вставляете и удаляете — хеш-таблица. Маленький массив — используйте линейный.
Дьявол в деталях: переполнение
mid, дубликаты, пустой массив. Именно на этих мелочах получают WA (Wrong Answer) на последних тестах и ловят баги в проде (Джошуа Блох не зря был упомянут в самом начале).Бинарный поиск — это не только про массивы. Тот же приём работает везде, где есть монотонность: поиск по ответу, подбор параметра, бинарный поиск по времени.
Пользуясь случаем, хотел бы поделиться ссылкой на свой аккаунт на Stepik. Буду рад комментариям и лайкам под этой статьёй.
