Перебор, ДП и жадные алгоритмы

Как из экспоненциального перебора получить полиномиальный алгоритм, когда хватает жадного выбора — и где это работает внутри git, gzip, Postgres и Elasticsearch.

Серия «Алгоритмы и структуры данных», часть 6. Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Рекурсия и дерево вызовов


def fib(n):
    if n < 2:        # база
        return n
    return fib(n - 1) + fib(n - 2)
  • Время — число узлов дерева, память — его глубина: на стеке живёт только текущая ветка.
  • Два вызова на уровень — дерево растёт как φn ≈ 1,6n. Причина — одни и те же подзадачи считаются снова и снова.
  • Запомнить ответ подзадачи — мемоизация. Дерево схлопывается в цепочку из 2n−1 вызовов.

Глубина ограничена стеком: Python — 1000 кадров (RecursionError), Node — ~10 тыс. (RangeError), Java — размер -Xss, Go растит стек горутины до 1 ГБ. Подробнее — в «Стек и куча».

Сверху вниз и снизу вверх


from functools import cache

@cache              # сверху вниз
def ways(n):
    if n <= 1:
        return 1
    return ways(n - 1) + ways(n - 2)

def ways_table(n):  # снизу вверх
    a, b = 1, 1     # dp[i-2], dp[i-1]
    for _ in range(n - 1):
        a, b = b, a + b
    return b

«Сколькими способами подняться на n ступенек шагами по 1 и 2?» Последний шаг — либо 1, либо 2, поэтому dp[i] = dp[i−1] + dp[i−2]. Таблица считает подзадачи в правильном порядке без рекурсии и стека, а когда нужны только две последние — хватает двух переменных.

Перебор с возвратом


def backtrack(state):
    if is_solution(state):
        yield list(state)
    for choice in choices(state):
        if not valid(state, choice):
            continue              # отсечение
        state.append(choice)      # вперёд
        yield from backtrack(state)
        state.pop()               # назад

Шаблон для «перечислить все»: подмножества, перестановки, сочетания с суммой, судоку, поиск слова в сетке. Ответов экспоненциально много — выигрыш только в отсечении: чем раньше видно, что ветка тупиковая, тем меньше дерево. Так же работают SAT-солверы и планировщики расписаний.

Рецепт динамического программирования

ДП подходит, когда у задачи есть оптимальная подструктура (ответ собирается из ответов подзадач) и подзадачи повторяются. Тогда решение строится в четыре шага.

1. Состояние

Что описывает подзадачу: dp[i] — лучший ответ для первых i элементов, dp[i][j] — для префиксов двух строк, dp[i][w] — для i предметов и вместимости w.

2. Переход

Как ответ собирается из меньших: перебрать последнее решение. «Грабитель домов»: дом i берём или нет — max(dp[i−1], dp[i−2] + a[i]).

3. База

Ответы для самых маленьких подзадач: пустая строка, ноль предметов, нулевая сумма. Ошибка в базе — самая частая ошибка в ДП.

4. Порядок и ответ

Считать так, чтобы нужные значения уже были готовы. Где лежит ответ: dp[n], max(dp), dp[n][m]. Сложность = число состояний × цена перехода.


def rob(a):                     # dp[i] = max(dp[i-1], dp[i-2] + a[i])
    prev2, prev1 = 0, 0         # база: ноль домов
    for x in a:                 # порядок: слева направо
        prev2, prev1 = prev1, max(prev1, prev2 + x)
    return prev1                # ответ: последнее состояние

Редакционное расстояние

Сколько вставок, удалений и замен превращают одно слово в другое (Левенштейн). dp[i][j] — расстояние между первыми i буквами одного слова и j буквами другого. Буквы совпали — берём диагональ, иначе 1 + минимум из трёх соседей.

  • Проверка орфографии и «Возможно, вы имели в виду…»; levenshtein() в Postgres (fuzzystrmatch).
  • Нечёткий поиск Elasticsearch (fuzziness: AUTO) — до 2 правок, через автомат Левенштейна, а не таблицу на каждый терм.
  • difflib.get_close_matches в Python считает другую меру — Ратклиффа — Обершелпа. Для Левенштейна берут rapidfuzz.

Как работает diff

Diff двух файлов — это наибольшая общая подпоследовательность строк (LCS): что в неё вошло, остаётся без изменений, остальное — «−» и «+». Таблица та же, что у Левенштейна, только без замен.

  • Таблица — O(n·m): два файла по 100 тыс. строк — 1010 клеток. Поэтому GNU diff и git используют алгоритм Майерса: O((n+m)·D), где D — число отличий. Похожие файлы сравниваются почти линейно.
  • git diff --patience и --histogram сначала сопоставляют уникальные строки — дифф получается понятнее, когда в коде много одинаковых }.

Рюкзак 0/1

Есть предметы с весом и ценностью и рюкзак вместимостью W. Каждый предмет берём целиком или не берём. dp[i][w] — лучшая ценность из первых i предметов при вместимости w:


dp[i][w] = max(dp[i-1][w],                        # не берём
               dp[i-1][w - wt[i]] + val[i])       # берём

O(n·W) — «псевдополиномиально»: зависит от величины W, а не от длины входа. Вместимость 109 так не решить. В жизни это бюджет: какие задачи взять в спринт, какие объекты держать в кэше, какие рекламные блоки поставить в слот. Жадный выбор по «ценности за килограмм» здесь ошибается.

Размен монет: жадно или ДП

Набрать сумму минимальным числом монет. Жадно — брать самую крупную монету, которая помещается. Для рублей, евро и долларов это оптимально: такие системы называют каноническими. Для произвольных номиналов — нет.


from math import inf
dp = [0] + [inf] * amount
for s in range(1, amount + 1):
    for c in coins:
        if c <= s:
            dp[s] = min(dp[s], dp[s - c] + 1)

Та же таблица отвечает на три вопроса: «минимум монет» (min), «сколькими способами» (+=, цикл по монетам снаружи) и «можно ли набрать вообще» (or).

Самая длинная возрастающая цепочка (LIS)

Простое ДП — O(n²): dp[i] — длина лучшей цепочки, кончающейся на a[i]. Быстрое — O(n log n): храним tails[k], самый маленький возможный конец цепочки длины k+1, и бинарным поиском находим, куда поставить очередное число.


from bisect import bisect_left
tails = []
for x in a:
    k = bisect_left(tails, x)
    if k == len(tails): tails.append(x)
    else:               tails[k] = x
return len(tails)

Это «пасьянс»: карты раскладывают в стопки. Отсюда patience diff — LIS по номерам уникальных общих строк двух файлов.

Экономия памяти: катящиеся строки

  • Если переход смотрит только на предыдущую строку таблицы, хранить нужно две строки: O(m) памяти вместо O(n·m). Левенштейн для строк по 105 символов: 40 ГБ против 800 КБ.
  • Рюкзак можно сжать до одной строки, если идти по вместимости справа налево.
  • Цена экономии — путь. Чтобы восстановить, какие правки или предметы выбраны, нужна полная таблица или алгоритм Хиршберга: «разделяй и властвуй» поверх двух строк, O(m) памяти и тот же путь.

def levenshtein(a, b):
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1,    # удалить
                cur[j - 1] + 1,            # вставить
                prev[j - 1] + (ca != cb))) # заменить
        prev = cur
    return prev[-1]

def knapsack(items, W):
    dp = [0] * (W + 1)
    for wt, val in items:
        # справа налево!
        for w in range(W, wt - 1, -1):
            dp[w] = max(dp[w], dp[w - wt] + val)
    return dp[W]

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

В однострочном рюкзаке с прошлого слайда цикл по w развернули: for w in range(wt, W + 1). Что изменится?

При обходе слева направо dp[w − wt] уже обновлён в этой же итерации — то есть уже учитывает текущий предмет, и его можно положить ещё раз. Получается рюкзак с неограниченным числом предметов (и размен монет). Обход справа налево читает значения ещё из «предыдущей строки». Такие ошибки тесты на маленьких примерах часто пропускают.

Жадные алгоритмы: когда выбор доказуемо верен

Жадный алгоритм делает локально лучший шаг и никогда его не пересматривает. Это верно, только если доказано: любое оптимальное решение можно переделать так, чтобы оно начиналось с жадного шага (аргумент обмена).

Выбрать больше всего непересекающихся встреч: сортировать по времени окончания и брать всё, что не пересекается. Первая закончившаяся встреча оставляет больше всего места для остальных — значит, её можно поставить в любое оптимальное решение вместо первой. По началу или по длине — ошибаются. Сколько нужно переговорок для всех встреч — тоже жадно: сортировка по началу и куча концов.

Код Хаффмана

Частым символам — короткие коды, редким — длинные. Жадно: дважды достать из кучи два самых редких узла и склеить их в один. Получается оптимальный префиксный код: ни один код не начало другого, поэтому разделители не нужны.

  • DEFLATE — gzip, zlib, PNG, zip, HTTP Content-Encoding: gzip: сначала LZ77 заменяет повторы ссылками, потом Хаффман кодирует результат.
  • HPACK в HTTP/2 сжимает заголовки статическим кодом Хаффмана; JPEG, Brotli и zstd тоже используют префиксные коды.
  • Дейкстра — тоже жадный алгоритм: берёт ближайшую непосещённую вершину. Подробнее — в презентации про графы.

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

В какой из задач жадный выбор «по лучшему отношению ценности к весу» гарантированно даёт оптимум?

В дробном рюкзаке выгоднее всего заполнить каждый килограмм самым «дорогим» товаром, а остаток добрать частью следующего — аргумент обмена проходит. В 0/1 рюкзаке целый предмет может оставить пустое место, которое ничем не заполнить, и жадность проигрывает (см. слайд 8). Монеты 1, 3, 4 на сумме 6: жадно 4+1+1, а оптимум 3+3.

ДП в реальных системах

PostgreSQL

Порядок соединений таблиц: планировщик перебирает подмножества таблиц снизу вверх — лучший план для пар, потом троек и так далее. До 8 таблиц (join_collapse_limit) — точно; от 12 (geqo_threshold) — генетический алгоритм, иначе 2n не уложить. Подробнее — в «PostgreSQL изнутри».

git, diff

LCS и алгоритм Майерса; patience diff — на LIS. Тот же подход в difflib.unified_diff, в слиянии веток и в rsync-подобных утилитах.

TeX

Алгоритм Кнута — Пласса переносит строки абзаца целиком: минимизирует сумму «некрасивости» строк. Браузеры и редакторы обычно переносят жадно — сравните на следующем слайде.

Биоинформатика

Выравнивание ДНК и белков — Нидлман — Вунш (глобальное) и Смит — Уотерман (локальное): тот же Левенштейн с весами за совпадения и разрывы.

Связь и речь

Алгоритм Витерби — ДП по времени: самая вероятная цепочка скрытых состояний. Декодирует свёрточные коды в Wi-Fi, LTE и спутниковой связи, распознавал речь в HMM-системах.

Маршрутизация

Беллман — Форд — ДП по числу рёбер: на нём протокол RIP и distance-vector маршрутизация. OSPF и IS-IS используют Дейкстру — жадный алгоритм.

Перенос строк: жадно против ДП

Жадный перенос кладёт в строку столько слов, сколько влезает. ДП выбирает разбиение всего абзаца: dp[i] — минимальная цена текста с i-го слова до конца, переход — где закончить первую строку.

Цена строки — квадрат пустого места справа (последняя строка бесплатна). Квадрат штрафует одну очень пустую строку сильнее, чем несколько чуть неполных. Это задача Text Justification с LeetCode и упрощённая версия того, что делает TeX.

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

  • «Сколькими способами», «минимальная/максимальная стоимость», «можно ли достичь» — почти всегда ДП.
  • «Перечислите все», «найдите любую расстановку» — перебор с возвратом.
  • Интервалы, расписание, «минимум операций» с очевидным лучшим шагом — жадный алгоритм после сортировки. Сначала поищите контрпример.
  • Ограничения подсказывают сложность: n ≤ 10 — n!, n ≤ 20 — 2n (маски), n ≤ 5000 — O(n²), n ≤ 105 — O(n log n).

Порядок ответа: перебор → «вот повторяющиеся подзадачи» → мемоизация → таблица → экономия памяти. Каждый шаг проговаривайте.

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

Решение сверху вниз с @cache прошло все тесты, а на n = 10 000 упало с RecursionError: maximum recursion depth exceeded. Что лучше сделать?

Мемоизация убирает повторы, но не глубину: цепочка из 10 000 вложенных вызовов остаётся. Поднять лимит можно, но @cache — обёртка на C, и каждый уровень тратит ещё и стек C-потока: процесс может упасть с segfault или упереться в отдельный лимит. Цикл по таблице работает с любым n. И помните: @lru_cache() без аргументов хранит только 128 значений — для ДП это тихая деградация до экспоненты.

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

  • cache_info() покажет попадания и промахи кэша: если промахов столько же, сколько вызовов, — подзадачи не повторяются, и мемоизация не нужна.
  • Сравните алгоритмы diff на своём репозитории: в больших рефакторингах histogram часто даёт заметно более читаемый результат.
  • В Postgres EXPLAIN на запросе из 10+ таблиц: порядок соединений, найденный ДП или GEQO, и время планирования.
  • Степень сжатия gzip зависит от энтропии данных: случайные байты не сжимаются, логи — в 5–10 раз.

from functools import cache
import sys, difflib

@cache
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

fib(300)
print(fib.cache_info())
# CacheInfo(hits=298, misses=301,
#           maxsize=None, currsize=301)
print(sys.getrecursionlimit())  # 1000

print(*difflib.unified_diff(old, new), sep='')

$ git diff --diff-algorithm=histogram HEAD~1
$ gzip -k -v access.log   # степень сжатия, %
psql> SHOW geqo_threshold;     -- 12
psql> EXPLAIN SELECT ... -- порядок JOIN

Главное

  • Рекурсия — дерево вызовов: время — число узлов, память — глубина. Повторяющиеся узлы — сигнал к мемоизации.
  • Перебор с возвратом перечисляет варианты; вся скорость — в раннем отсечении.
  • ДП = состояние, переход, база, порядок. Сложность — число состояний на цену перехода. Снизу вверх — без лимита рекурсии и с катящимися строками.
  • Левенштейн, LCS, рюкзак, монеты, LIS — шаблоны, на которых стоят проверка орфографии, diff, планировщик Postgres и выравнивание ДНК.
  • Жадный алгоритм верен, только когда это доказано обменом. Интервалы и Хаффман — да, 0/1 рюкзак и произвольные монеты — нет.

Сначала честный перебор, потом — какие подзадачи он считает дважды.