# Динамическое программирование *Динамическое программирование* является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность. В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования. !!! 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` **для хранения всех решений подзадач**