mirror of
https://github.com/krahets/hello-algo.git
synced 2026-07-12 15:36:05 +00:00
First version.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
# Подход к решению задач динамического программирования
|
||||
|
||||
В предыдущих двух разделах были представлены основные характеристики задач динамического программирования. Теперь давайте рассмотрим два более практических вопроса.
|
||||
|
||||
1. Как определить, является ли задача задачей динамического программирования?
|
||||
2. С чего начать решение задачи динамического программирования и каковы полные шаги?
|
||||
|
||||
## Определение задачи
|
||||
|
||||
В целом, если задача содержит перекрывающиеся подзадачи, оптимальную подструктуру и удовлетворяет свойству отсутствия последействия, то она обычно подходит для решения методом динамического программирования. Однако эти характеристики трудно извлечь непосредственно из описания задачи. Поэтому мы обычно ослабляем условия и **сначала наблюдаем, подходит ли задача для решения методом поиска с возвратом (полного перебора)**.
|
||||
|
||||
**Задачи, подходящие для решения методом поиска с возвратом, обычно удовлетворяют "модели дерева решений"**. Такие задачи можно описать с помощью древовидной структуры, где каждый узел представляет решение, а каждый путь представляет последовательность решений.
|
||||
|
||||
Другими словами, если задача содержит явную концепцию принятия решений и решение формируется через серию решений, то она удовлетворяет модели дерева решений и обычно может быть решена методом поиска с возвратом.
|
||||
|
||||
На этой основе задачи динамического программирования имеют некоторые дополнительные "плюсы" для определения.
|
||||
|
||||
- Задача содержит описание оптимизации, такое как максимум (минимум) или наибольшее (наименьшее) количество.
|
||||
- Состояние задачи может быть представлено списком, многомерной матрицей или деревом, и между состоянием и окружающими его состояниями существует рекуррентная зависимость.
|
||||
|
||||
Соответственно, существуют и некоторые "минусы".
|
||||
|
||||
- Цель задачи — найти все возможные решения, а не найти оптимальное решение.
|
||||
- В описании задачи есть явные признаки перестановок и комбинаций, требующие возврата конкретных множественных решений.
|
||||
|
||||
Если задача удовлетворяет модели дерева решений и имеет достаточно явные "плюсы", мы можем предположить, что это задача динамического программирования, и проверить это в процессе решения.
|
||||
|
||||
## Шаги решения задачи
|
||||
|
||||
Процесс решения задач динамического программирования может различаться в зависимости от природы и сложности задачи, но обычно следует следующим шагам: описание решения, определение состояния, построение таблицы $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$, что означает невозможность.
|
||||
|
||||
Код реализации следующий:
|
||||
|
||||
```src
|
||||
[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`, для записи решений каждой подзадачи и обрезки перекрывающихся подзадач:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
|
||||
```
|
||||
|
||||
Как показано на рисунке ниже, после введения мемоизации все подзадачи нужно вычислить только один раз, поэтому временная сложность зависит от общего количества состояний, то есть размера сетки $O(nm)$.
|
||||
|
||||

|
||||
|
||||
### Метод третий: динамическое программирование
|
||||
|
||||
Реализация решения динамического программирования на основе итераций показана в коде ниже:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
|
||||
```
|
||||
|
||||
На рисунке ниже показан процесс перехода состояний минимальной суммы пути, который обходит всю сетку, **поэтому временная сложность составляет $O(nm)$**.
|
||||
|
||||
Размер массива `dp` равен $n \times m$, **поэтому пространственная сложность составляет $O(nm)$**.
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
=== "<12>"
|
||||

|
||||
|
||||
### Оптимизация пространства
|
||||
|
||||
Поскольку каждая ячейка связана только с ячейкой слева и сверху, мы можем использовать только одномерный массив для реализации таблицы $dp$.
|
||||
|
||||
Обратите внимание, что поскольку массив `dp` может представлять только одну строку состояний, мы не можем заранее инициализировать состояния первого столбца, а обновляем их при обходе каждой строки:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}
|
||||
```
|
||||
Reference in New Issue
Block a user