mirror of
https://github.com/krahets/hello-algo.git
synced 2026-07-08 13:36:06 +00:00
First version.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# Динамическое программирование
|
||||
|
||||
*Динамическое программирование* является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность.
|
||||
|
||||
В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования.
|
||||
|
||||
!!! question "Подъем по лестнице"
|
||||
|
||||
Дана лестница с $n$ ступенями. На каждом шаге можно подниматься на $1$ или $2$ ступени. Сколько существует способов добраться до вершины лестницы?
|
||||
|
||||
Как показано на рисунке ниже, для лестницы с тремя ступенями существует три способа добраться до вершины.
|
||||
|
||||

|
||||
|
||||
Цель этой задачи -- найти количество способов, **и можно попробовать использовать для ее решения метод поиска с возвратом**. Более конкретно -- можно представить подъем по лестнице как процесс многократного выбора: начать с пола, на каждом этапе выбирать подъем на одну или две ступени, при достижении вершины лестницы количество способов увеличивается на 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]
|
||||
$$
|
||||
|
||||
Это означает, что в задаче подъема по лестнице между подзадачами существует рекуррентная зависимость, и **решение исходной задачи можно построить из решений подзадач**. На рисунке ниже демонстрируется эта рекуррентная зависимость.
|
||||
|
||||

|
||||
|
||||
Можно получить решение методом полного перебора на основе рекуррентной формулы. Начиная с $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$ можно столкнуться с длительной работой алгоритма.
|
||||
|
||||

|
||||
|
||||
Как видно из рисунка, **экспоненциальная временная сложность вызвана перекрывающимися подзадачами**. Например, $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)$, это является значительным скачком.
|
||||
|
||||

|
||||
|
||||
## Третий метод: динамическое программирование
|
||||
|
||||
**Мемоизация поиска -- это метод «сверху вниз»**: мы начинаем с исходной задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на более мелкие, пока не достигнем минимальных подзадач с известным решением (листовые узлы). Затем через возврат поэтапно собираем решения подзадач, чтобы построить решение исходной задачи.
|
||||
|
||||
В отличие от этого подхода **динамическое программирование представляет собой метод «снизу вверх»**: начиная с решения минимальных подзадач, итеративно строится решение более крупных подзадач, пока не будет получено решение исходной задачи.
|
||||
|
||||
Поскольку динамическое программирование не включает этап возврата, оно реализуется с использованием циклов и итераций, без необходимости в рекурсии. В следующем коде мы инициализируем массив dp для хранения решений подзадач, который выполняет ту же функцию запоминания, что и массив mem в мемоизации поиска.
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
|
||||
```
|
||||
|
||||
На рисунке ниже иллюстрируется процесс выполнения приведенного выше кода.
|
||||
|
||||

|
||||
|
||||
Как и в алгоритмах поиска с возвратом, в динамическом программировании используется концепция состояния для обозначения определенной стадии решения задачи. Каждое состояние соответствует подзадаче и соответствующему локальному оптимальному решению. Например, состояние задачи подъема по лестнице определяется текущей ступенью $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` **для хранения всех решений подзадач**
|
||||
Reference in New Issue
Block a user