18 KiB
Подход к решению задач динамического программирования
В предыдущих двух разделах были представлены основные характеристики задач динамического программирования. Теперь давайте рассмотрим два более практических вопроса.
- Как определить, является ли задача задачей динамического программирования?
- С чего начать решение задачи динамического программирования и каковы полные шаги?
Определение задачи
В целом, если задача содержит перекрывающиеся подзадачи, оптимальную подструктуру и удовлетворяет свойству отсутствия последействия, то она обычно подходит для решения методом динамического программирования. Однако эти характеристики трудно извлечь непосредственно из описания задачи. Поэтому мы обычно ослабляем условия и сначала наблюдаем, подходит ли задача для решения методом поиска с возвратом (полного перебора).
Задачи, подходящие для решения методом поиска с возвратом, обычно удовлетворяют "модели дерева решений". Такие задачи можно описать с помощью древовидной структуры, где каждый узел представляет решение, а каждый путь представляет последовательность решений.
Другими словами, если задача содержит явную концепцию принятия решений и решение формируется через серию решений, то она удовлетворяет модели дерева решений и обычно может быть решена методом поиска с возвратом.
На этой основе задачи динамического программирования имеют некоторые дополнительные "плюсы" для определения.
- Задача содержит описание оптимизации, такое как максимум (минимум) или наибольшее (наименьшее) количество.
- Состояние задачи может быть представлено списком, многомерной матрицей или деревом, и между состоянием и окружающими его состояниями существует рекуррентная зависимость.
Соответственно, существуют и некоторые "минусы".
- Цель задачи — найти все возможные решения, а не найти оптимальное решение.
- В описании задачи есть явные признаки перестановок и комбинаций, требующие возврата конкретных множественных решений.
Если задача удовлетворяет модели дерева решений и имеет достаточно явные "плюсы", мы можем предположить, что это задача динамического программирования, и проверить это в процессе решения.
Шаги решения задачи
Процесс решения задач динамического программирования может различаться в зависимости от природы и сложности задачи, но обычно следует следующим шагам: описание решения, определение состояния, построение таблицы dp, вывод уравнения перехода состояния, определение граничных условий и т.д.
Для более наглядной демонстрации шагов решения мы используем классическую задачу "минимальная сумма пути".
!!! question
Дана двумерная сетка $n \times m$ `grid`, каждая ячейка сетки содержит неотрицательное целое число, представляющее стоимость этой ячейки. Робот начинает с верхней левой ячейки и может двигаться только вниз или вправо на один шаг за раз, пока не достигнет нижней правой ячейки. Верните минимальную сумму пути от верхней левой до нижней правой ячейки.
На рисунке ниже показан пример, где минимальная сумма пути для данной сетки равна 13.
Первый шаг: обдумать решение на каждом раунде, определить состояние и получить таблицу $dp$
Решение на каждом раунде в этой задаче — сделать один шаг вниз или вправо из текущей ячейки. Пусть индексы строки и столбца текущей ячейки равны [i, j], тогда после шага вниз или вправо индексы становятся [i+1, j] или [i, j+1]. Следовательно, состояние должно включать две переменные: индекс строки и индекс столбца, обозначаемые как [i, j].
Подзадача, соответствующая состоянию [i, j]: минимальная сумма пути от начальной точки [0, 0] до [i, j], решение обозначается как dp[i, j].
Таким образом, мы получаем двумерную матрицу dp, показанную на рисунке ниже, размер которой совпадает с входной сеткой grid.
!!! note
Процесс динамического программирования и поиска с возвратом можно описать как последовательность решений, а состояние состоит из всех переменных решения. Оно должно содержать все переменные, описывающие прогресс решения задачи, и содержать достаточно информации для вывода следующего состояния.
Каждое состояние соответствует подзадаче, и мы определяем таблицу $dp$ для хранения решений всех подзадач. Каждая независимая переменная состояния является одним измерением таблицы $dp$. По сути, таблица $dp$ — это отображение между состояниями и решениями подзадач.
Второй шаг: найти оптимальную подструктуру и вывести уравнение перехода состояния
Для состояния [i, j] оно может быть достигнуто только из верхней ячейки [i-1, j] или левой ячейки [i, j-1]. Следовательно, оптимальная подструктура: минимальная сумма пути до [i, j] определяется меньшей из минимальной суммы пути до [i, j-1] и минимальной суммы пути до [i-1, j].
На основе приведенного выше анализа можно вывести уравнение перехода состояния, показанное на рисунке ниже:
dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
!!! note
На основе определенной таблицы $dp$ подумайте о связи между исходной задачей и подзадачами, найдите способ построения оптимального решения исходной задачи через оптимальные решения подзадач, то есть оптимальную подструктуру.
Как только мы найдем оптимальную подструктуру, мы можем использовать ее для построения уравнения перехода состояния.
Третий шаг: определить граничные условия и порядок перехода состояний
В этой задаче состояния в первой строке могут быть получены только из состояния слева, состояния в первом столбце могут быть получены только из состояния сверху, поэтому первая строка i = 0 и первый столбец j = 0 являются граничными условиями.
Как показано на рисунке ниже, поскольку каждая ячейка переходит из левой и верхней ячеек, мы используем циклы для обхода матрицы: внешний цикл обходит строки, внутренний цикл обходит столбцы.
!!! note
Граничные условия в динамическом программировании используются для инициализации таблицы $dp$, а в поиске используются для обрезки.
Суть порядка перехода состояний заключается в том, чтобы при вычислении решения текущей задачи все более мелкие подзадачи, от которых она зависит, уже были правильно вычислены.
На основе приведенного выше анализа мы уже можем напрямую написать код динамического программирования. Однако декомпозиция подзадач — это подход "сверху вниз", поэтому реализация в порядке "полный перебор \rightarrow мемоизация поиска \rightarrow динамическое программирование" более соответствует мышлению.
Метод первый: полный перебор
Начиная с состояния [i, j], постоянно разбиваем на более мелкие состояния [i-1, j] и [i, j-1]. Рекурсивная функция включает следующие элементы.
- Параметры рекурсии: состояние
[i, j]. - Возвращаемое значение: минимальная сумма пути от
[0, 0]до[i, j], то естьdp[i, j]. - Условие завершения: когда
i = 0иj = 0, вернуть стоимостьgrid[0, 0]. - Обрезка: когда
i < 0илиj < 0, индекс выходит за границы, в этом случае вернуть стоимость+\infty, что означает невозможность.
Код реализации следующий:
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs}
На рисунке ниже показано дерево рекурсии с корнем dp[2, 1], которое содержит некоторые перекрывающиеся подзадачи, количество которых резко увеличивается с увеличением размера сетки grid.
По сути, причина перекрывающихся подзадач заключается в том, что существует несколько путей из верхнего левого угла к определенной ячейке.
Каждое состояние имеет два выбора: вниз и вправо. Для перехода из верхнего левого угла в нижний правый требуется всего m + n - 2 шагов, поэтому в худшем случае временная сложность составляет O(2^{m + n}), где n и m — количество строк и столбцов сетки соответственно. Обратите внимание, что этот метод расчета не учитывает ситуацию вблизи границ сетки. Когда достигается граница сетки, остается только один выбор, поэтому фактическое количество путей будет несколько меньше.
Метод второй: мемоизация поиска
Мы вводим список запоминания mem того же размера, что и сетка grid, для записи решений каждой подзадачи и обрезки перекрывающихся подзадач:
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
Как показано на рисунке ниже, после введения мемоизации все подзадачи нужно вычислить только один раз, поэтому временная сложность зависит от общего количества состояний, то есть размера сетки O(nm).
Метод третий: динамическое программирование
Реализация решения динамического программирования на основе итераций показана в коде ниже:
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
На рисунке ниже показан процесс перехода состояний минимальной суммы пути, который обходит всю сетку, поэтому временная сложность составляет $O(nm)$.
Размер массива dp равен n \times m, поэтому пространственная сложность составляет $O(nm)$.
Оптимизация пространства
Поскольку каждая ячейка связана только с ячейкой слева и сверху, мы можем использовать только одномерный массив для реализации таблицы dp.
Обратите внимание, что поскольку массив dp может представлять только одну строку состояний, мы не можем заранее инициализировать состояния первого столбца, а обновляем их при обходе каждой строки:
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}

















