* docs(ru): replace prose quotes with guillemets * docs(ru): replace prose semicolons with periods * docs(ru): align animation title forms * docs(ru): align figure and table references
13 KiB
Первое знакомство с динамическим программированием
Динамическое программирование (dynamic programming) - это важная алгоритмическая парадигма, которая разбивает задачу на последовательность более мелких подзадач и за счет хранения их решений избегает повторных вычислений, тем самым резко повышая эффективность по времени.
В этом разделе мы начнем с классического примера: сначала представим его грубое решение методом поиска с возвратом, увидим в нем перекрывающиеся подзадачи, а затем постепенно выведем более эффективное решение на основе динамического программирования.
!!! question "Подъем по лестнице"
Дана лестница из $n$ ступеней. За один шаг можно подняться на $1$ или на $2$ ступени. Сколькими способами можно добраться до вершины?
Как показано на рисунке ниже, для лестницы из 3 ступеней существует 3 способа добраться до вершины.
Цель этой задачи - вычислить количество способов. Поэтому можно попробовать использовать для ее решения метод поиска с возвратом. Если представить подъем по лестнице как последовательность решений, то мы начинаем от земли и на каждом раунде выбираем прыжок на 1 или на 2 ступени. Всякий раз, когда достигаем вершины, увеличиваем число способов на 1 , а если перескакиваем вершину, обрезаем эту ветвь. Код выглядит так:
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
Метод 1: полный перебор
Алгоритм поиска с возвратом обычно не раскладывает задачу явно на подзадачи. Вместо этого он рассматривает решение как последовательность решений, используя попытки и обрезку для поиска всех возможных ответов.
Попробуем посмотреть на задачу именно как на разложение подзадач. Пусть число способов добраться до ступени i равно dp[i]. Тогда dp[i] - это исходная задача, а ее подзадачи включают:
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
Поскольку за один раунд можно подняться только на 1 или на 2 ступени, стоя на ступени 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] .
Продолжая это рассуждение, мы видим, что подзадачи порождают все более мелкие перекрывающиеся подзадачи без конца. Подавляющая часть вычислительных ресурсов уходит именно на них.
Метод 2: поиск с мемоизацией
Чтобы ускорить алгоритм, мы хотим, чтобы каждая перекрывающаяся подзадача вычислялась только один раз. Для этого объявим массив mem для хранения решения каждой подзадачи и будем обрезать повторные вычисления в процессе поиска.
- Когда
dp[i]вычисляется впервые, мы сохраняем его вmem[i]для последующего использования. - Когда значение
dp[i]требуется снова, мы просто берем его напрямую изmem[i]и тем самым избегаем повторного вычисления подзадачи.
Код приведен ниже:
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
Как показано на рисунке ниже, после введения мемоизации каждая перекрывающаяся подзадача вычисляется только один раз, и временная сложность оптимизируется до $O(n)$ . Это огромный скачок в эффективности.
Метод 3: динамическое программирование
Поиск с мемоизацией - это метод «сверху вниз» : мы начинаем с исходной задачи (корня), рекурсивно раскладываем более крупные подзадачи на меньшие, пока не достигнем наименьших подзадач с уже известным ответом (листьев). Затем в процессе возврата постепенно собираем решения подзадач и тем самым получаем решение исходной задачи.
Напротив, динамическое программирование - это метод «снизу вверх» : начиная с решений наименьших подзадач, мы итеративно строим решения для более крупных подзадач, пока не получим ответ на исходную задачу.
Поскольку в динамическом программировании нет этапа возврата, для его реализации достаточно обычных циклов, без рекурсии. В приведенном ниже коде мы инициализируем массив dp для хранения решений подзадач. Он выполняет ту же роль, что и массив mem в мемоизированном поиске:
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
На рисунке ниже смоделирован процесс выполнения этого кода.
Как и в поиске с возвратом, в динамическом программировании используется понятие «состояние» для обозначения некоторого этапа решения задачи. Каждое состояние соответствует одной подзадаче и ее локально оптимальному решению. Например, в задаче о лестнице состояние определяется текущим номером ступени i .
На основе сказанного можно подвести несколько часто используемых терминов динамического программирования.
- Массив
dpназывают таблицей dp, аdp[i]обозначает решение подзадачи, соответствующей состояниюi. - Состояния, соответствующие наименьшим подзадачам (первая и вторая ступени), называют начальными состояниями.
- Рекуррентную формулу
dp[i] = dp[i-1] + dp[i-2]называют уравнением перехода состояния.
Оптимизация пространства
Внимательный читатель мог заметить, что поскольку dp[i] зависит только от dp[i-1] и dp[i-2] , нам не нужен весь массив dp для хранения ответов всех подзадач. Достаточно двух переменных, которые будут «перекатываться» вперед. Код имеет вид:
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
Из кода видно, что после отказа от массива dp пространственная сложность уменьшается с O(n) до O(1) .
Во многих задачах динамического программирования текущее состояние зависит лишь от ограниченного числа предыдущих состояний. Тогда можно сохранять только действительно нужные состояния и за счет «уменьшения размерности» экономить память. Этот прием оптимизации памяти называют «скользящими переменными» или «скользящим массивом».




