First version.

This commit is contained in:
krahets
2026-01-20 15:08:42 +08:00
parent 2213a59ff6
commit 8071daddaa
106 changed files with 11790 additions and 0 deletions
@@ -0,0 +1,100 @@
# Динамическое программирование
*Динамическое программирование* является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность.
В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования.
!!! question "Подъем по лестнице"
Дана лестница с $n$ ступенями. На каждом шаге можно подниматься на $1$ или $2$ ступени. Сколько существует способов добраться до вершины лестницы?
Как показано на рисунке ниже, для лестницы с тремя ступенями существует три способа добраться до вершины.
![Количество способов добраться до 3-й ступени](../assets/climbing_stairs_example.png)
Цель этой задачи -- найти количество способов, **и можно попробовать использовать для ее решения метод поиска с возвратом**. Более конкретно -- можно представить подъем по лестнице как процесс многократного выбора: начать с пола, на каждом этапе выбирать подъем на одну или две ступени, при достижении вершины лестницы количество способов увеличивается на 1, а при превышении вершины происходит обрезка. Ниже приведен код реализации.
```src
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
```
## Первый метод: полный перебор
Алгоритм поиска с возвратом обычно не разбивает задачу явным образом, а рассматривает ее решение как серию шагов принятия решений, исследуя пути обхода и выполняя обрезку.
Можно попытаться проанализировать эту задачу с точки зрения разбиения. Пусть для достижения $i$-й ступени существует $dp[i]$ способов, тогда $dp[i]$ является исходной задачей, а ее подзадачи включают следующие:
$$
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
$$
На каждом этапе можно подниматься только на одну или две ступени, поэтому перед на $i$-й ступенью мы находились либо на $(i - 1)$-й, либо на $(i - 2)$-й ступени. Другими словами, на $i$-ю ступень можно перейти только с $(i - 1)$-й или $(i - 2)$-й ступени.
Отсюда следует важный вывод: **количество способов добраться до** $(i - 1)$-**й ступени плюс количество способов добраться до** $(i - 2)$-**й ступени равно количеству способов добраться до** $i$-**й ступени**. Формула выглядит следующим образом:
$$
dp[i] = dp[i-1] + dp[i-2]
$$
Это означает, что в задаче подъема по лестнице между подзадачами существует рекуррентная зависимость, и **решение исходной задачи можно построить из решений подзадач**. На рисунке ниже демонстрируется эта рекуррентная зависимость.
![Рекуррентная зависимость количества способов подъема по лестнице](../assets/climbing_stairs_state_transfer.png)
Можно получить решение методом полного перебора на основе рекуррентной формулы. Начиная с $dp[n]$, **большая задача рекурсивно разбивается на сумму двух меньших задач**, пока не будут достигнуты минимальные подзадачи $dp[1]$ и $dp[2]$, для которых возвращаются известные решения: $dp[1] = 1$, $dp[2] = 2$. То есть для достижения 1-й и 2-й ступеней существует 1 и 2 способа соответственно.
Рассмотрим следующий код, который, как и стандартный код поиска с возвратом, относится к поиску в глубину, но является более лаконичным.
```src
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
```
На рисунке ниже изображено рекурсивное дерево, образованное полным перебором. Для задачи $dp[n]$ глубина рекурсивного дерева равна $n$, а временная сложность составляет $O(2^n)$. Экспоненциальный рост приводит к взрывному увеличению, и при вводе достаточно большого $n$ можно столкнуться с длительной работой алгоритма.
![Рекурсивное дерево для подъема по лестнице](../assets/climbing_stairs_dfs_tree.png)
Как видно из рисунка, **экспоненциальная временная сложность вызвана перекрывающимися подзадачами**. Например, $dp[9]$ разбивается на $dp[8]$ и $dp[7]$, $dp[8]$ разбивается на $dp[7]$ и $dp[6]$ -- обе задачи содержат подзадачу $dp[7]$. Таким образом, в подзадачах содержатся более мелкие перекрывающиеся подзадачи, и большая часть вычислительных ресурсов тратится на их обработку.
## Второй метод: мемоизация поиска
Для повышения эффективности алгоритма **необходимо, чтобы все перекрывающиеся подзадачи вычислялись только один раз**. Для этого мы объявим массив mem для записи решений каждой подзадачи и в процессе поиска устраним необходимость их повторной обработки.
1. При первом вычислении $dp[i]$ мы записываем результат в `mem[i]` для дальнейшего использования.
2. Когда требуется повторно вычислить $dp[i]$, мы можем напрямую получить результат из `mem[i]`, избегая повторной обработки.
Код реализации представлен ниже.
```src
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
```
После внедрения запоминания все пересекающиеся подзадачи нужно вычислить только один раз, что оптимизирует временную сложность до $O(n)$, это является значительным скачком.
![Мемоизация поиска и соответствующее дерево рекурсии](../assets/climbing_stairs_dfs_memo_tree.png)
## Третий метод: динамическое программирование
**Мемоизация поиска -- это метод «сверху вниз»**: мы начинаем с исходной задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на более мелкие, пока не достигнем минимальных подзадач с известным решением (листовые узлы). Затем через возврат поэтапно собираем решения подзадач, чтобы построить решение исходной задачи.
В отличие от этого подхода **динамическое программирование представляет собой метод «снизу вверх»**: начиная с решения минимальных подзадач, итеративно строится решение более крупных подзадач, пока не будет получено решение исходной задачи.
Поскольку динамическое программирование не включает этап возврата, оно реализуется с использованием циклов и итераций, без необходимости в рекурсии. В следующем коде мы инициализируем массив dp для хранения решений подзадач, который выполняет ту же функцию запоминания, что и массив mem в мемоизации поиска.
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
```
На рисунке ниже иллюстрируется процесс выполнения приведенного выше кода.
![Применение динамического программирования для подъема по лестнице](../assets/climbing_stairs_dp.png)
Как и в алгоритмах поиска с возвратом, в динамическом программировании используется концепция состояния для обозначения определенной стадии решения задачи. Каждое состояние соответствует подзадаче и соответствующему локальному оптимальному решению. Например, состояние задачи подъема по лестнице определяется текущей ступенью $i$.
На основе этого можно обобщить часто используемые термины динамического программирования.
- Массив `dp` называется таблицей dp, $dp[i]$ обозначает решение подзадачи, соответствующей состоянию $i$.
- Состояния, соответствующие минимальным подзадачам (1-я и 2-я ступени лестницы), называются начальными состояниями.
- Рекуррентное соотношение $dp[i] = dp[i-1] + dp[i-2]$ называется уравнением перехода состояния.
## Оптимизация пространства
Внимательный читатель может заметить, что, **поскольку** $dp[i]$ **зависит только от** $dp[i-1]$ **и** $dp[i-2]$, **нам не нужно использовать целый массив** `dp` **для хранения всех решений подзадач**