Learning Platform
Глоссарий Troubleshooting
Урок 17.01 · 25 мин
Начальный
linear-searchO(n)predicatecache-localitypython-in

Что такое линейный поиск

Линейный поиск — это просмотр коллекции с начала до конца, элемент за элементом, пока не найдём нужный (или не убедимся, что его нет). На псевдокоде это три строки:

def linear_search(xs, target):
    for i, x in enumerate(xs):
        if x == target:
            return i
    return -1

Никаких индексов-инвариантов, никаких границ, никаких предусловий — просто проход. Это самый простой алгоритм поиска и одновременно — самый универсальный. Он работает на любой итерируемой структуре: list, tuple, генератор, файл, поток событий.

Сложность очевидна: в худшем случае мы посмотрим на все n элементов. Среднее — n/2, но в нотации Big-O это всё та же O(n).

Линейный поиск числа 30 в массиве

Каждый шаг — одно сравнение. На i=3 нашли совпадение и вернули индекс.

i=010сравнение 10 == 30: нет, идём дальше
i=125сравнение 25 == 30: нет, идём дальше
i=27сравнение 7 == 30: нет, идём дальше
i=330совпадение, возвращаем индекс 3
i=442сюда уже не доходим
итог3 сравнения + 1 совпадениев среднем нужно n/2 сравнений, в худшем — n

Когда O(n) лучше, чем O(log n)

Студентов учат: бинарный поиск быстрее линейного, потому что log(n) меньше n. Это правда — асимптотически. На практике у линейного поиска есть три серьёзных преимущества.

Первое — он работает на любой структуре. Не нужна сортировка. Не нужен random access. Достаточно итерации. Поэтому линейный поиск — единственный вариант, если у вас:

  • неотсортированный массив,
  • связный список (random access O(n), бинарный поиск теряет смысл),
  • генератор или поток,
  • файл, который читается построчно.

Второе — он cache-friendly. Идти подряд по непрерывному блоку памяти — лучший доступ для процессора. Префетчер CPU заранее тянет соседние cache-линии, и каждое следующее чтение почти бесплатное. Бинарный поиск, наоборот, прыгает по середине, четверти, восьмой части — это random access, и каждый прыжок может быть cache miss на 100 тактов.

Третье — на маленьких коллекциях он просто быстрее. Накладные расходы (вычисление середины, проверка границ, ветвление) у бинарного поиска заметные. Эксперимент: при n < 30 обычно линейный поиск выигрывает. В реальных реализациях стандартных библиотек (например, в Java TimSort) бинарный поиск переключается на линейный, когда фрагмент короче ~7 элементов.

Когда линейный поиск уместен

Три ситуации, где O(n) — правильный выбор.

случай 1n маленькоедо 20-30 элементов накладные расходы бинарного поиска делают его медленнее
итогlinear выигрываетменьше констант, лучше cache
случай 2нет сортировкиданные приходят в случайном порядке, сортировать ради одного поиска не выгодно
итогlinear — единственныйбинарный поиск требует отсортированных данных
случай 3предикатищем не по равенству, а по любому условию: первый элемент > X, первый чётный, первый с признаком
итогlinear естествененбинарный поиск не работает с произвольными предикатами

Поиск с предикатом

В Python линейный поиск чаще выглядит не как «найти равное», а как «найти первое подходящее»:

# первое чётное число
result = next((x for x in xs if x % 2 == 0), None)

# первый элемент, начинающийся с "user_"
first_user = next((s for s in events if s.startswith("user_")), None)

# все элементы старше 30 минут
fresh = [e for e in events if e.timestamp > now - 1800]

Это всё линейные проходы, но с предикатом — произвольным условием. Бинарный поиск тут невозможен: условие может быть произвольно сложным, а не просто x == target.

Многие задачи Data Engineer — это линейные проходы с предикатом: «отфильтруй события за последний час», «найди первый сбой в логе», «посчитай записи с ошибкой». На сотнях тысяч записей это O(n), и никакая структура данных не сделает быстрее, если предикат произвольный.

Python in и index — это линейный поиск для list

Стоит запомнить одну вещь, которая ускользает от джунов: оператор in и метод .index() для list — это линейные операции.

xs = list(range(1_000_000))

# O(n) — обход всего списка пока не найдём
print(999_999 in xs)        # медленно: проход почти до конца
print(xs.index(999_999))    # та же история

# для проверки наличия лучше set
xs_set = set(xs)
print(999_999 in xs_set)    # O(1), хеш-поиск

Это критично знать: код вида if user_id in users_list на миллионе пользователей — это миллион сравнений за каждый вызов. Если такая проверка делается в цикле — получаем O(n²) и серверный таймаут.

WARNING

x in list — O(n). x in set — O(1). x in dict — O(1). Если делаете повторные проверки наличия — конвертируйте в set один раз и работайте с ним. Это типичный паттерн ускорения с днями работы на «упс, я не знал, что in для list — это O(n)».

Замеры

Простой эксперимент: ищем число в разных структурах разного размера.

import timeit

n = 1_000_000
xs_list = list(range(n))
xs_set = set(xs_list)
target = n - 1   # последний элемент — худший случай для линейного поиска

# linear search через in
t_list = timeit.timeit(lambda: target in xs_list, number=10)
# hash lookup
t_set = timeit.timeit(lambda: target in xs_set, number=10)

print(f"list (O(n)): {t_list * 1000:.2f} ms")    # ~150 ms
print(f"set  (O(1)): {t_set * 1000:.4f} ms")     # ~0.0005 ms

Разница в 300 000 раз. Это не «set чуть быстрее» — это разные алгоритмические классы.

Но если изменить target на первый элемент (target = 0), linear search выиграет:

target = 0
t_list = timeit.timeit(lambda: target in xs_list, number=10)
# ~0.0001 ms — нашли мгновенно

В этом и суть линейного поиска: его время сильно зависит от позиции искомого элемента. Среднее — n/2, но если элемент почти всегда в начале, реальная скорость отличная.

Когда линейный поиск — лучший выбор

Подведём:

  1. Маленькие коллекции (n < 30-50). Накладные расходы бинарного поиска и хеширования съедают теоретическое преимущество.
  2. Однократный поиск в неотсортированных данных. Сортировка ради одного поиска — O(n log n) + O(log n) = O(n log n), хуже чем просто O(n).
  3. Поиск с произвольным предикатом. Бинарный/хеш-поиск работают только по равенству ключа.
  4. Потоковая обработка. В генераторе или файле нет случайного доступа.
  5. Поиск первого подходящего элемента. Часто целевой элемент находится в первой трети массива — реальное среднее меньше n/2.

В Data Engineering линейный поиск встречается в каждом ETL: «найди в батче событие с таким идентификатором», «отфильтруй неподходящие». Никакой структуры данных не нужно, если данные используются один раз.

Попробуй сам

Проверьте на себе разницу между in для list и set:

import timeit
import random

n = 100_000
data = list(range(n))
random.shuffle(data)

data_set = set(data)

# ищем 100 случайных элементов
targets = random.sample(range(n), 100)

t_list = timeit.timeit(
    lambda: [t in data for t in targets],
    number=10,
)
t_set = timeit.timeit(
    lambda: [t in data_set for t in targets],
    number=10,
)

print(f"list: {t_list * 1000:.2f} ms")
print(f"set:  {t_set * 1000:.4f} ms")
print(f"speedup: {t_list / t_set:.0f}x")

На моей машине разница около 1000x при n = 100_000. Запустите с разными n — увидите, как масштабируется разница: для set она остаётся постоянной, для list растёт линейно.

Коллекции Python: list, set, dict — когда что выбрать
Проверка знанийKnowledge check
У вас есть list из 50 000 user_id и нужно проверить 10 000 входящих событий на принадлежность пользователя списку. Сколько сравнений будет в наивном решении 'if event.user_id in users_list' и как переписать, чтобы получить O(n + m) вместо O(n * m)?
ОтветAnswer
Наивное решение: для каждого из 10 000 событий делается линейный поиск по 50 000 user_id. В худшем случае 10 000 * 50 000 = 500 000 000 сравнений, это O(n * m). Правильное решение — один раз построить set из user_id: users_set = set(users_list). Это O(n) на построение. Потом каждая проверка event.user_id in users_set — это O(1) хеш-поиск. Итого: O(n + m) = 50 000 + 10 000 = 60 000 операций. Ускорение примерно в 8000 раз. Это типичная ошибка джунов и одна из главных причин медленных ETL.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. У вас есть Python list из 100 000 user_id и нужно проверить 50 000 событий 'in users_list'. Какая алгоритмическая сложность у наивного решения и как её улучшить?

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

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

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

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