Как из экспоненциального перебора получить полиномиальный алгоритм, когда хватает жадного выбора — и где это работает внутри git, gzip, Postgres и Elasticsearch.
Серия «Алгоритмы и структуры данных», часть 6. Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
def fib(n):
if n < 2: # база
return n
return fib(n - 1) + fib(n - 2)
Глубина ограничена стеком: 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-солверы и планировщики расписаний.
ДП подходит, когда у задачи есть оптимальная подструктура (ответ собирается из ответов подзадач) и подзадачи повторяются. Тогда решение строится в четыре шага.
Что описывает подзадачу: dp[i] — лучший ответ для первых i элементов, dp[i][j] — для префиксов двух строк, dp[i][w] — для i предметов и вместимости w.
Как ответ собирается из меньших: перебрать последнее решение. «Грабитель домов»: дом i берём или нет — max(dp[i−1], dp[i−2] + a[i]).
Ответы для самых маленьких подзадач: пустая строка, ноль предметов, нулевая сумма. Ошибка в базе — самая частая ошибка в ДП.
Считать так, чтобы нужные значения уже были готовы. Где лежит ответ: 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).fuzziness: AUTO) — до 2 правок, через автомат Левенштейна, а не таблицу на каждый терм.difflib.get_close_matches в Python считает другую меру — Ратклиффа — Обершелпа. Для Левенштейна берут rapidfuzz.Diff двух файлов — это наибольшая общая подпоследовательность строк (LCS): что в неё вошло, остаётся без изменений, остальное — «−» и «+». Таблица та же, что у Левенштейна, только без замен.
git diff --patience и --histogram сначала сопоставляют уникальные строки — дифф получается понятнее, когда в коде много одинаковых }.Есть предметы с весом и ценностью и рюкзак вместимостью 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).
Простое ДП — 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 по номерам уникальных общих строк двух файлов.
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] уже обновлён в этой же итерации — то есть уже учитывает текущий предмет, и его можно положить ещё раз. Получается рюкзак с неограниченным числом предметов (и размен монет). Обход справа налево читает значения ещё из «предыдущей строки». Такие ошибки тесты на маленьких примерах часто пропускают.Жадный алгоритм делает локально лучший шаг и никогда его не пересматривает. Это верно, только если доказано: любое оптимальное решение можно переделать так, чтобы оно начиналось с жадного шага (аргумент обмена).
Выбрать больше всего непересекающихся встреч: сортировать по времени окончания и брать всё, что не пересекается. Первая закончившаяся встреча оставляет больше всего места для остальных — значит, её можно поставить в любое оптимальное решение вместо первой. По началу или по длине — ошибаются. Сколько нужно переговорок для всех встреч — тоже жадно: сортировка по началу и куча концов.
Частым символам — короткие коды, редким — длинные. Жадно: дважды достать из кучи два самых редких узла и склеить их в один. Получается оптимальный префиксный код: ни один код не начало другого, поэтому разделители не нужны.
Content-Encoding: gzip: сначала LZ77 заменяет повторы ссылками, потом Хаффман кодирует результат.В какой из задач жадный выбор «по лучшему отношению ценности к весу» гарантированно даёт оптимум?
Порядок соединений таблиц: планировщик перебирает подмножества таблиц снизу вверх — лучший план для пар, потом троек и так далее. До 8 таблиц (join_collapse_limit) — точно; от 12 (geqo_threshold) — генетический алгоритм, иначе 2n не уложить. Подробнее — в «PostgreSQL изнутри».
LCS и алгоритм Майерса; patience diff — на LIS. Тот же подход в difflib.unified_diff, в слиянии веток и в rsync-подобных утилитах.
Алгоритм Кнута — Пласса переносит строки абзаца целиком: минимизирует сумму «некрасивости» строк. Браузеры и редакторы обычно переносят жадно — сравните на следующем слайде.
Выравнивание ДНК и белков — Нидлман — Вунш (глобальное) и Смит — Уотерман (локальное): тот же Левенштейн с весами за совпадения и разрывы.
Алгоритм Витерби — ДП по времени: самая вероятная цепочка скрытых состояний. Декодирует свёрточные коды в Wi-Fi, LTE и спутниковой связи, распознавал речь в HMM-системах.
Беллман — Форд — ДП по числу рёбер: на нём протокол RIP и distance-vector маршрутизация. OSPF и IS-IS используют Дейкстру — жадный алгоритм.
Жадный перенос кладёт в строку столько слов, сколько влезает. ДП выбирает разбиение всего абзаца: dp[i] — минимальная цена текста с i-го слова до конца, переход — где закончить первую строку.
Цена строки — квадрат пустого места справа (последняя строка бесплатна). Квадрат штрафует одну очень пустую строку сильнее, чем несколько чуть неполных. Это задача Text Justification с LeetCode и упрощённая версия того, что делает TeX.
Порядок ответа: перебор → «вот повторяющиеся подзадачи» → мемоизация → таблица → экономия памяти. Каждый шаг проговаривайте.
Решение сверху вниз с @cache прошло все тесты, а на n = 10 000 упало с RecursionError: maximum recursion depth exceeded. Что лучше сделать?
@cache — обёртка на C, и каждый уровень тратит ещё и стек C-потока: процесс может упасть с segfault или упереться в отдельный лимит. Цикл по таблице работает с любым n. И помните: @lru_cache() без аргументов хранит только 128 значений — для ДП это тихая деградация до экспоненты.cache_info() покажет попадания и промахи кэша: если промахов столько же, сколько вызовов, — подзадачи не повторяются, и мемоизация не нужна.histogram часто даёт заметно более читаемый результат.EXPLAIN на запросе из 10+ таблиц: порядок соединений, найденный ДП или GEQO, и время планирования.
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
Сначала честный перебор, потом — какие подзадачи он считает дважды.