15. Динамическое программирование
Динамическое программирование — один из наиболее мощных методов алгоритмического проектирования, позволяющий решать задачи, в которых наивный полный перебор работает экспоненциально долго.
Определение
Динамическое программирование (dynamic programming, DP) — метод решения задачи путём разбиения её на состояния (подзадачи), вычисляемые с помощью переходов из более простых состояний. Состояния вычисляются в топологическом порядке графа зависимостей: каждое состояние решается ровно один раз, результат запоминается и переиспользуется.
Свойства
Ключевые компоненты динамического программирования:
- Состояния — описание подзадачи (например, $\operatorname{dist}[v]$, $l[i,j]$, $k[\text{mask}, v]$).
- Переходы — формула пересчёта состояния через предыдущие (рёбра графа зависимостей).
- Начальные состояния — база, значения которой известны явно.
- Порядок вычислений — топологическая сортировка графа зависимостей (циклов быть не должно).
- Восстановление ответа — запоминание оптимального перехода для каждого состояния, либо повторный перебор переходов.
Поиск кратчайшего пути в ациклическом ориентированном графе
Пусть перед нами стоит задача поиска кратчайших путей от вершины $s$ до всех остальных вершин в ациклическом ориентированном графе. Конечно, можно воспользоваться одним из уже изученных алгоритмов. Есть, однако, и более простое решение с линейным временем работы.
Для любой вершины $v \neq s$ верно следующее равенство:
$$ \operatorname{dist}[v]=\min \left\{\operatorname{dist}[u]+w_{e}: e=(u, v) \in E\right\} $$Будем перебирать вершины в порядке топологической сортировки. Тогда все значения $\operatorname{dist}$, встречающиеся в выражении справа, к моменту рассмотрения вершины $v$ уже будут найдены.
Алгоритм рассматривает подзадачи вычисления $\operatorname{dist}[v]$ в порядке увеличения сложности; каждую текущую подзадачу он сводит к нескольким подзадачам попроще, которые уже решены.
Метод динамического программирования
Метод динамического программирования так и устроен: задача, которую нужно решить, сводится к подзадачам попроще, те — к подзадачам ещё попроще, и так далее. После этого все подзадачи решаются в правильном порядке. Какой порядок правильный? Построим на подзадачах ориентированный граф: вершины — это подзадачи, а ребро из $a$ в $b$ есть, если для решения подзадачи $b$ требуется сначала решить $a$. Тогда правильный порядок — это топологическая сортировка этого графа. Этот граф, как правило, не строится явно; правильный порядок часто виден сразу.
На самом деле мы уже встречались с методом динамического программирования: полиномиальный алгоритм вычисления чисел Фибоначчи, алгоритмы Флойда и Беллмана-Форда, решето Эратосфена — все эти алгоритмы можно интерпретировать как динамическое программирование.
Способ вычисления переходов
Во многих задачах переходы можно вычислять двумя способами: «вперёд» и «назад». Так, при поиске кратчайшего пути в ациклическом ориентированном графе вместо того, чтобы смотреть «назад» на входящие в вершину рёбра и пересчитывать текущее состояние через предыдущие, мы могли бы смотреть «вперёд» на исходящие из вершины рёбра и пересчитывать следующие состояния через текущее.
Иногда оказывается, что один из способов по тем или иным причинам удобнее другого. Рассмотрим для примера такую модельную задачу: пусть $dp[1]=1$, и мы хотим для каждого $x$ от 2 до $n$ вычислить
$$ dp[x]=\sum_{\substack{d < x \\ d \mid x}} dp[d]. $$Для того, чтобы считать переходы назад, нам придётся найти все делители каждого числа. Считать же переходы вперёд очень просто: для фиксированного $d$ значение $dp[d]$ нужно добавить ко всем кратным $d$, не большим $n$. Суммарное время работы составит $O\!\left(\sum_{d=1}^{n} n/d\right) = O(n \log n)$.
Восстановление ответа
В задаче о кратчайшем пути может требоваться найти не просто длину пути, но и сам путь. Есть два стандартных способа восстановления ответа.
Первый: для каждого состояния запомнить, из какого состояния в него был сделан оптимальный переход. Тогда, пользуясь запомненными ссылками, можно откатиться от конечного состояния к начальному и восстановить весь путь.
Второй: ничего не запоминать, а для нахождения оптимального перехода просто перебрать все переходы заново.
Ленивое динамическое программирование
Вычисление переходов назад можно делать лениво: напишем рекурсивную функцию, делающую рекурсивные вызовы от состояний, которые нужны для вычисления текущего. При этом будем запоминать уже вычисленные значения. Пример ленивого решения задачи поиска кратчайшего пути в ациклическом ориентированном графе:
Свойства
Ленивая версия полезна, когда нужно вычислить значение не всех состояний, а одного конкретного. Модельный пример: вычисление $\gcd(a, b)$ через соотношение $\gcd(a, b) = \gcd(b, a \bmod b)$ требует лишь $O(\log n)$ состояний, поэтому ленивый подход значительно эффективнее полного вычисления всей таблицы.
Задачи
Далее рассматриваются примеры задач и их решения методом динамического программирования:
Сводка задач и алгоритмов
| Задача | Время | Память |
|---|---|---|
| 15.1. Наибольшая общая подпоследовательность | $O(nm)$ | $O(nm)$ / $O(\min(m,n))$ |
| 15.2. Расстояние Левенштейна | $O(nm)$ | $O(nm)$ |
| 15.3. Алгоритм Хиршберга | $O(nm)$ | $O(m + \log n)$ |
| 15.4. Наибольшая возрастающая подпоследовательность | $O(n \log n)$ | $O(n)$ |
| 15.5. Задача о рюкзаке | $O(nW)$ | $O(W)$ |
| 15.6. Произведение матриц | $O(n^3)$ | $O(n^2)$ |
| 15.7. НМВ в дереве | $O(V+E)$ | $O(V)$ |
| 15.8. Задача коммивояжёра | $O(2^n n^2)$ | $O(2^n n)$ |
| 15.9. Генерация по объекту | $O(n^2)$ | $O(n)$ |
| 15.10. Рекуррентные соотношения | $O(k^3 \log n)$ | $O(k^2)$ |