12 KiB
Динамическое программирование
Динамическое программирование является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность.
В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования.
!!! question "Подъем по лестнице"
Дана лестница с $n$ ступенями. На каждом шаге можно подниматься на $1$ или $2$ ступени. Сколько существует способов добраться до вершины лестницы?
Как показано на рисунке ниже, для лестницы с тремя ступенями существует три способа добраться до вершины.
Цель этой задачи -- найти количество способов, и можно попробовать использовать для ее решения метод поиска с возвратом. Более конкретно -- можно представить подъем по лестнице как процесс многократного выбора: начать с пола, на каждом этапе выбирать подъем на одну или две ступени, при достижении вершины лестницы количество способов увеличивается на 1, а при превышении вершины происходит обрезка. Ниже приведен код реализации.
[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 способа соответственно.
Рассмотрим следующий код, который, как и стандартный код поиска с возвратом, относится к поиску в глубину, но является более лаконичным.
[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 для записи решений каждой подзадачи и в процессе поиска устраним необходимость их повторной обработки.
- При первом вычислении
dp[i]мы записываем результат вmem[i]для дальнейшего использования. - Когда требуется повторно вычислить
dp[i], мы можем напрямую получить результат изmem[i], избегая повторной обработки.
Код реализации представлен ниже.
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
После внедрения запоминания все пересекающиеся подзадачи нужно вычислить только один раз, что оптимизирует временную сложность до O(n), это является значительным скачком.
Третий метод: динамическое программирование
Мемоизация поиска -- это метод «сверху вниз»: мы начинаем с исходной задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на более мелкие, пока не достигнем минимальных подзадач с известным решением (листовые узлы). Затем через возврат поэтапно собираем решения подзадач, чтобы построить решение исходной задачи.
В отличие от этого подхода динамическое программирование представляет собой метод «снизу вверх»: начиная с решения минимальных подзадач, итеративно строится решение более крупных подзадач, пока не будет получено решение исходной задачи.
Поскольку динамическое программирование не включает этап возврата, оно реализуется с использованием циклов и итераций, без необходимости в рекурсии. В следующем коде мы инициализируем массив dp для хранения решений подзадач, который выполняет ту же функцию запоминания, что и массив mem в мемоизации поиска.
[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 для хранения всех решений подзадач




