Алгоритмы · 3 из 7

Сортировка и поиск

Почему база данных всё время сортирует, отчего Python сортирует почти упорядоченный список за линейное время, откуда берётся n log n и как написать бинарный поиск без ошибки на единицу.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки. Примеры кода — на Python.

Зачем сортировать

  • В отсортированном ищут за O(log n): бинарный поиск, страница B-дерева, индекс SSTable.
  • Равные и близкие значения оказываются рядом: дубликаты, группы, соседи. Это sort | uniq -c и GroupAggregate в Postgres.
  • Два отсортированных потока сливаются за один проход: merge join, слияние сегментов в LSM-деревьях (RocksDB, Cassandra), shuffle в Spark.
  • ORDER BY, оконные функции, CREATE INDEX — тоже сортировка. Её цена видна в EXPLAIN.

Типичный приём: заплатить O(n log n) один раз, а дальше всё делать линейными проходами.


from itertools import groupby

by_user = lambda r: r.user_id
logs.sort(key=by_user)            # O(n log n) один раз
for user, rows in groupby(logs, by_user):
    print(user, len(list(rows)))  # группы идут подряд
# без sort groupby склеит только соседние — частый баг

def intersect(a, b):              # оба отсортированы
    i = j = 0                     # O(n + m)
    while i < len(a) and j < len(b):
        if a[i] < b[j]:   i += 1
        elif a[i] > b[j]: j += 1
        else:   # совпало — как в merge join
            yield a[i]; i += 1; j += 1

Четыре сортировки на одном массиве

  • Вставками — O(n²), но на почти отсортированном почти O(n). Поэтому её используют для маленьких кусков внутри «больших» сортировок.
  • Слиянием — всегда n log n, стабильна, но нужен O(n) дополнительной памяти.
  • Быстрая — на практике самая быстрая: на месте и дружит с кэшем. Но всё решает опорный элемент.
  • Пирамидальная — гарантированные n log n на месте, но прыгает по памяти и нестабильна.

Выберите массив и нажмите «Пуск». Здесь быстрая сортировка наивная: опорный элемент — последний. Посмотрите, что с ней будет на отсортированном и обратном массиве.

Опорный элемент и худший случай

  • Если опорный элемент каждый раз минимальный, разбиение отщепляет один элемент: n уровней рекурсии и n²/2 сравнений.
  • Медиана трёх и случайный выбор спасают на обычных данных. Но для любого детерминированного правила можно построить «убийственный» вход — это вектор DoS.
  • Introsort (C++ std::sort, .NET): если рекурсия глубже 2·log₂n — переключаемся на heapsort. Гарантия O(n log n).
  • pdqsort (Go 1.19+ slices.Sort, sort.Sort; Rust sort_unstable до ipnsort в 1.81): introsort + отдельная обработка повторов + распознавание уже упорядоченных кусков.

Выберите данные и стратегию. Здесь n = 2000 и считаются настоящие сравнения.

Стабильность: равные не меняются местами

Стабильная сортировка сохраняет исходный порядок элементов с равными ключами. Поэтому сортировку по нескольким ключам можно делать проходами: сначала по второстепенному, потом по главному.


staff.sort(key=lambda e: e.salary, reverse=True)
# внутри отдела останется порядок по зарплате:
staff.sort(key=lambda e: e.dept)
# то же за один проход:
staff.sort(key=lambda e: (e.dept, -e.salary))

Стабильны: Python sort, Java для объектов, JS Array.prototype.sort (по стандарту с ES2019), Go slices.SortStableFunc. Нестабильны: Go slices.Sort, C++ std::sort, Java Arrays.sort(int[]) — для чисел это и не важно.

Timsort: использовать готовый порядок

  • Ищет серии (runs) — уже возрастающие или строго убывающие куски. Убывающие разворачивает.
  • Короткие серии добивает бинарными вставками до minrun (32–64 элемента).
  • Сливает серии со стека так, чтобы их длины оставались сбалансированными.
  • Галоп: если одна серия выигрывает 7 раз подряд, Timsort переходит на экспоненциальный поиск и копирует элементы пачками.

Python list.sort (с 3.11 — стратегия слияний powersort), Java для объектов, V8, Android. Отсортированный список — n−1 сравнение. Выберите данные и нажимайте «Шаг».

Почему быстрее n log n нельзя

Сортировка сравнениями — это дерево решений: в узле сравнение, в листе готовая перестановка. Листьев не меньше n!, значит, высота дерева — худшее число сравнений — не меньше log₂(n!) ≈ n log₂n − 1,44n.

Граница — для алгоритмов, которые только сравнивают. Counting и radix sort смотрят на сами ключи и её обходят. Нажмите на перестановку — подсветится путь по дереву для трёх элементов.

Counting и radix sort: без сравнений

  • Counting sort: ключи из малого диапазона k — посчитать, сколько каждого, и выписать. O(n + k).
  • LSD radix sort: d проходов стабильной раскладки по корзинам, от младшего разряда к старшему. O(d·(n + b)). 32-битные числа — 4 прохода по байту.
  • Где встречается: ClickHouse сортирует числовые колонки radix sort, на GPU сортируют им же (CUB), numpy.sort(kind='stable') для целых до 16 бит.

Нажимайте «Шаг»: раскладка по текущему разряду, затем сбор корзин по порядку. Стабильность раскладки — ключ к тому, что следующий проход не портит предыдущий.

Топ-K и k-я порядковая статистика

  • Сортировать всё ради десяти лучших — O(n log n) лишней работы.
  • Куча на k элементов: O(n log k), работает на потоке. heapq.nlargest(10, items). Postgres для ORDER BY … LIMIT 10 пишет Sort Method: top-N heapsort.
  • Quickselect: разбиение как в быстрой сортировке, но рекурсия только в ту часть, где лежит k. В среднем O(n). numpy.partition, C++ nth_element — introselect.

Ищем медиану. Нажимайте «Шаг»: после разбиения опорный элемент стоит на своём окончательном месте, и половину массива можно больше не смотреть.

Данные больше памяти: внешняя сортировка

  • Фаза 1: читаем столько, сколько влезает в память, сортируем и пишем на диск отсортированную серию.
  • Фаза 2: сливаем k серий сразу. Минимум из k «голов» даёт куча: O(n log k). Если серий слишком много — в несколько проходов.
  • Postgres так сортирует, когда не хватает work_mem: в плане Sort Method: external merge Disk: …. Подробнее о памяти — в «PostgreSQL изнутри».
  • Так же устроены GNU sort (-S задаёт буфер) и слияние сегментов в LSM-деревьях.

Здесь «память» вмещает 6 записей. Нажимайте «Шаг».

Проверьте себя

Заказы уже отсортированы по дате. Делаем orders.sort(key=lambda o: o.status). В каком порядке окажутся заказы внутри одного статуса?

Timsort стабилен: элементы с равным ключом сохраняют исходный порядок. Это гарантия языка, а не случайность реализации, — на ней и держится сортировка в несколько проходов. В Go так ведёт себя только slices.SortStableFunc; обычный slices.SortFunc (pdqsort) порядок равных не сохраняет.

Бинарный поиск без ошибок на единицу


def lower_bound(a, x):
    lo, hi = 0, len(a)     # ответ в [lo, hi]
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] < x:
            lo = mid + 1   # a[..mid] < x
        else:
            hi = mid       # a[mid..] >= x
    return lo              # первый a[i] >= x

Пишите от инварианта: «слева от lo всё < x, начиная с hi — всё ≥ x». Отрезок каждый раз строго сужается, а ответ из него не выпадает. upper_bound отличается одним знаком: a[mid] <= x.

Бинарный поиск в реальном коде

  • Python bisect (с 3.10 — параметр key), Go sort.Search и slices.BinarySearch, C++ lower_bound. Java Collections.binarySearch при промахе возвращает -(точка вставки) - 1.
  • (lo + hi) / 2 переполняет int в Java и C — этот баг 9 лет жил в JDK. Пишут lo + (hi - lo) / 2 или (lo + hi) >>> 1.
  • Где он работает: поиск ключа на странице B-дерева, офсеты в .index-файлах Kafka, блоки SSTable, git bisect — бинарный поиск по истории коммитов.

import bisect

a = [10, 20, 20, 20, 30]
bisect.bisect_left(a, 20)    # 1 — первый >= 20
bisect.bisect_right(a, 20)   # 4 — первый > 20
# сколько двадцаток: 4 - 1 = 3, за O(log n)

def grade(score, bounds=(60, 70, 80, 90)):
    return 'FDCBA'[bisect.bisect_right(bounds, score)]

grade(85)   # 'B'

bisect.insort(events, e)     # O(log n) на поиск,
                             # но O(n) на сдвиг списка!
# много вставок — sortedcontainers.SortedList

Бинарный поиск по ответу

Ищем не элемент, а минимальное значение, при котором проверка проходит. Условие одно: проверка монотонна — если вместимости c хватает, хватит и c + 1.


def ship_within_days(w, days):
    def fits(cap):          # жадно грузим по порядку
        need, load = 1, 0
        for x in w:
            if load + x > cap:
                need, load = need + 1, 0
            load += x
        return need <= days
    lo, hi = max(w), sum(w) # ответ в [lo, hi]
    while lo < hi:
        mid = (lo + hi) // 2
        if fits(mid): hi = mid
        else: lo = mid + 1
    return lo               # O(n · log(sum))

Признаки в условии: «минимально возможное максимальное», «наименьшая скорость, чтобы успеть», «за D дней».

Поиск в повёрнутом массиве

Отсортированный массив сдвинули по кругу: [15, 18, … 31, 2, 5, … 13]. Целиком он не упорядочен, но в каждом отрезке [lo, hi] хотя бы одна половина от mid отсортирована. Проверяем, лежит ли цель в ней, — и отбрасываем другую.


if a[lo] <= a[mid]:          # левая половина ровная
    if a[lo] <= x < a[mid]: hi = mid - 1
    else:                   lo = mid + 1
else:                       # ровная — правая
    if a[mid] < x <= a[hi]: lo = mid + 1
    else:                   hi = mid - 1

Выберите, что искать, и нажимайте «Шаг». Цветом выделена отсортированная половина.

Интервалы и заметающая прямая

  • Сколько нужно переговорок: разложить начала (+1) и концы (−1) на одну ось, отсортировать и пройти слева направо. Максимум счётчика — ответ. При равном времени конец идёт раньше начала.
  • Слить пересечения: отсортировать по началу, вести текущий отрезок и расширять его, пока следующий начинается не позже его конца.
  • Где: free/busy в календарях, объединение диапазонов IP и времени, range_agg в Postgres, поиск пересечений в геометрии.

Обе задачи — O(n log n) из-за сортировки, сам проход линейный. Выберите режим и нажимайте «Шаг».

Как узнать задачу на собеседовании

«массив отсортирован», «найти первый / последний», нужно O(log n)бинарный поиск: lower_bound / upper_bound
«минимальное максимальное», «наименьшая скорость», «за D дней»бинпоиск по ответу + жадная проверка
«k-й по величине», «топ-K частых», «k ближайших точек»куча на k элементов или quickselect
«встречи», «отрезки», «пересекаются ли»сортировка по началу, проход или sweep line
«пара / тройка с суммой», «ближайшее значение»сортировка + два указателя
«значения от 0 до 10⁵», «оценки», «буквы»counting sort / массив счётчиков, O(n)
«слить k отсортированных списков»куча из k голов, O(n log k)
«отсортировать 100 ГБ, памяти 1 ГБ»внешняя сортировка: серии + k-way merge

Перед решением уточните: нужна ли стабильность, можно ли менять входной массив, какой диапазон значений и есть ли повторы. Каждый ответ меняет выбор алгоритма.

Проверьте себя

Сортируем миллион строк: sorted(words, key=str.lower). Сколько раз вызовется str.lower?

Ключ вычисляется один раз для каждого элемента и запоминается, а сравниваются уже готовые ключи (~n·log₂n ≈ 20 млн сравнений, но в C). Поэтому в Python 3 убрали cmp: функция сравнения вызывалась бы на каждом сравнении. Если без неё никак — functools.cmp_to_key, и это в разы медленнее. В JS компаратор вызывается на каждое сравнение, а без него [10, 9, 1].sort() сравнивает строки и даёт [1, 10, 9].

Проверьте себя

Что вернёт sorted(['яблоко', 'Ёлка', 'арбуз', 'ёж', 'Банан'])?

Строки сравниваются по кодам символов: Ё = U+0401 стоит перед всеми заглавными, ё = U+0451 — после я, а все заглавные — перед строчными. Для человеческого порядка нужна collation: key=locale.strxfrm с ru_RU.UTF-8 или ICU (PyICU). В Postgres порядок ORDER BY и индексов задаёт collation колонки, и обновление glibc 2.28 однажды поменяло его так, что индексы пришлось перестраивать.

Как это увидеть у себя


EXPLAIN (ANALYZE) SELECT * FROM orders
ORDER BY created_at LIMIT 10;
--  Sort Method: top-N heapsort  Memory: 27kB

EXPLAIN (ANALYZE) SELECT * FROM orders ORDER BY total;
--  Sort Method: external merge  Disk: 181240kB
--  не влезло в work_mem: поднять для сессии или
--  дать индекс, который отдаёт строки уже по порядку

from timeit import timeit
import random
a = list(range(10**6))
b = random.sample(a, len(a))
timeit(lambda: sorted(a), number=10)  # ~0,2 с
timeit(lambda: sorted(b), number=10)  # ~11 с
# отсортированный вход — одна серия, n − 1 сравнение

$ sort -S 2G --parallel=8 big.csv > out.csv
  • Нужны первые k — не сортируйте всё: heapq.nsmallest, LIMIT с индексом.
  • Данные приходят почти по порядку (логи, время) — Timsort сделает это почти за O(n). Не перемешивайте их зря.
  • Держать коллекцию отсортированной при частых вставках — SortedList, B-дерево или куча, а не insort в список.
  • Сравнивайте через key, а не через компаратор. Для массивов чисел — numpy.sort: тот же алгоритм, но без объектов Python.
  • Пользователю показываете строки — сортируйте с collation, а не по кодам символов.

Главное

  • Сортировка — это подготовка: после неё поиск, дубликаты, группировка и слияние становятся линейными или логарифмическими.
  • Быстрая сортировка быстра, пока опорный элемент удачный. Стандартные библиотеки страхуются: introsort, pdqsort.
  • Стабильность нужна для сортировки по нескольким ключам. Python, Java для объектов и JS стабильны; Go slices.Sort и C++ std::sort — нет.
  • Timsort использует порядок, который уже есть в данных. Сравнениями быстрее n log n нельзя; radix и counting обходят это через ключи.
  • Топ-K — куча или quickselect. Данные больше памяти — серии и k-way merge.
  • Бинарный поиск пишут от инварианта на полуинтервале. Он работает везде, где есть монотонность, — даже по ответу.

Отсортируй — и задача станет проходом.