Files
hello-algo/ru/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md
T
2026-01-20 15:08:42 +08:00

12 KiB

Динамическое программирование

Динамическое программирование является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность.

В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования.

!!! question "Подъем по лестнице"

Дана лестница с $n$ ступенями. На каждом шаге можно подниматься на $1$ или $2$ ступени. Сколько существует способов добраться до вершины лестницы?

Как показано на рисунке ниже, для лестницы с тремя ступенями существует три способа добраться до вершины.

Количество способов добраться до 3-й ступени

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

  1. При первом вычислении dp[i] мы записываем результат в mem[i] для дальнейшего использования.
  2. Когда требуется повторно вычислить 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 для хранения всех решений подзадач