mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-14 02:10:37 +08:00
First version.
This commit is contained in:
88
ru/docs/chapter_dynamic_programming/dp_problem_features.md
Normal file
88
ru/docs/chapter_dynamic_programming/dp_problem_features.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Особенности задач динамического программирования
|
||||
|
||||
В предыдущем разделе мы изучили, как динамическое программирование решает исходную задачу путем разложения на подзадачи. На самом деле разложение на подзадачи -- это универсальный алгоритмический подход, который по-разному применяется в методах «разделяй и властвуй», динамическом программировании и поиске с возвратом.
|
||||
|
||||
- Алгоритм «разделяй и властвуй» рекурсивно делит исходную задачу на несколько независимых подзадач до самых минимальных и в процессе обратного хода объединяет решения всех подзадач.
|
||||
- Динамическое программирование также осуществляет рекурсивное разбиение задачи. Основное отличие от алгоритмов «разделяй и властвуй» заключается в том, что подзадачи в динамическом программировании взаимозависимы, и в процессе разбиения возникает множество перекрывающихся подзадач.
|
||||
- Алгоритмы поиска с возвратом исчерпывают все возможные решения методом проб и возвратов, осекая ненужные ветви поиска с помощью обрезки. Решение исходной задачи состоит из серии шагов принятия решений, каждый шаг можно рассматривать как подзадачу.
|
||||
|
||||
На практике динамическое программирование часто используется для решения задач оптимизации, которые не только содержат перекрывающиеся подзадачи, но и обладают двумя другими важными свойствами: оптимальной подструктурой и отсутствием последействия.
|
||||
|
||||
## Оптимальная подструктура
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:我们对爬楼梯问题稍作改动,使之更加适合展示最优子结构概念... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:!!! question "爬楼梯最小代价"... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:如下图所示,若第 $1$、$2$、$3$ 阶的代价分别为 $1$、$10$、$1$ ,则从地面爬到第 $3$ 阶的最小代价为 $2$... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:设 $dp[i]$ 为爬到第 $i$ 阶累计付出的代价... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:这便可以引出最优子结构的含义... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:本题显然具有最优子结构... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:那么,上一节的爬楼梯题目有没有最优子结构呢... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:根据状态转移方程,以及初始状态... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:下图展示了以上代码的动态规划过程... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:本题也可以进行空间优化... -->
|
||||
|
||||
## Отсутствие последействия
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:无后效性是动态规划能够有效解决问题的重要特性之一... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:以爬楼梯问题为例,给定状态 $i$... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:然而,如果我们给爬楼梯问题添加一个约束... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:!!! question "带约束爬楼梯"... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:如下图所示,爬上第 $3$ 阶仅剩 $2$ 种可行方案... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:在该问题中,如果上一轮是跳 $1$ 阶上来的... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:不难发现,此问题已不满足无后效性... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:为此,我们需要扩展状态定义... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:- 当上一轮跳了 $1$ 阶时... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:如下图所示,在该定义下... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:最终,返回 $dp[n, 1] + dp[n, 2]$ 即可... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:在上面的案例中,由于仅需多考虑前面一个状态... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:!!! question "爬楼梯与障碍生成"... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:在这个问题中,下次跳跃依赖过去所有的状态... -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性... -->
|
||||
183
ru/docs/chapter_dynamic_programming/dp_solution_pipeline.md
Normal file
183
ru/docs/chapter_dynamic_programming/dp_solution_pipeline.md
Normal file
@@ -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}
|
||||
```
|
||||
101
ru/docs/chapter_dynamic_programming/edit_distance_problem.md
Normal file
101
ru/docs/chapter_dynamic_programming/edit_distance_problem.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Задача о редакционном расстоянии
|
||||
|
||||
Редакционное расстояние, также называемое расстоянием Левенштейна, указывает минимальное количество изменений, необходимых для взаимного преобразования двух строк, и обычно используется в информационном поиске и обработке естественного языка для измерения сходства двух последовательностей.
|
||||
|
||||
!!! question
|
||||
|
||||
Даны две строки $s$ и $t$, верните минимальное количество шагов редактирования, необходимых для преобразования $s$ в $t$.
|
||||
|
||||
Вы можете выполнять три операции редактирования в строке: вставить символ, удалить символ, заменить символ на любой другой символ.
|
||||
|
||||
Как показано на рисунке ниже, для преобразования `kitten` в `sitting` требуется 3 шага редактирования, включая 2 операции замены и 1 операцию добавления; для преобразования `hello` в `algo` требуется 3 шага, включая 2 операции замены и 1 операцию удаления.
|
||||
|
||||

|
||||
|
||||
**Задачу о редакционном расстоянии можно естественным образом объяснить с помощью модели дерева решений**. Строки соответствуют узлам дерева, один раунд решения (одна операция редактирования) соответствует ребру дерева.
|
||||
|
||||
Как показано на рисунке ниже, без ограничения операций каждый узел может породить множество ребер, каждое ребро соответствует одной операции, что означает, что существует множество возможных путей преобразования от `hello` к `algo`.
|
||||
|
||||
С точки зрения дерева решений цель этой задачи -- найти кратчайший путь между узлом `hello` и узлом `algo`.
|
||||
|
||||

|
||||
|
||||
### Подход динамического программирования
|
||||
|
||||
**Первый шаг: обдумать решение на каждом раунде, определить состояние, чтобы получить таблицу $dp$**
|
||||
|
||||
Решение на каждом раунде -- это выполнение одной операции редактирования над строкой $s$.
|
||||
|
||||
Мы хотим, чтобы в процессе операций редактирования размер задачи постепенно уменьшался, чтобы можно было построить подзадачи. Пусть длины строк $s$ и $t$ равны $n$ и $m$ соответственно, сначала рассмотрим символы в конце обеих строк $s[n-1]$ и $t[m-1]$.
|
||||
|
||||
- Если $s[n-1]$ и $t[m-1]$ одинаковы, мы можем пропустить их и сразу рассмотреть $s[n-2]$ и $t[m-2]$.
|
||||
- Если $s[n-1]$ и $t[m-1]$ различны, нам нужно выполнить одно редактирование $s$ (вставка, удаление, замена), чтобы символы в конце обеих строк совпали, после чего можно пропустить их и рассмотреть задачу меньшего размера.
|
||||
|
||||
Другими словами, каждое решение (операция редактирования), которое мы принимаем в строке $s$, приводит к изменению оставшихся несопоставленных символов в $s$ и $t$. Следовательно, состояние -- это текущие рассматриваемые $i$-й и $j$-й символы в $s$ и $t$, обозначаемые как $[i, j]$.
|
||||
|
||||
Состояние $[i, j]$ соответствует подзадаче: **минимальное количество шагов редактирования, необходимых для изменения первых $i$ символов $s$ на первые $j$ символов $t$**.
|
||||
|
||||
Таким образом, получаем двумерную таблицу $dp$ размером $(i+1) \times (j+1)$.
|
||||
|
||||
**Второй шаг: найти оптимальную подструктуру, затем вывести уравнение перехода состояния**
|
||||
|
||||
Рассмотрим подзадачу $dp[i, j]$, символы в конце соответствующих двух строк -- это $s[i-1]$ и $t[j-1]$, можно разделить на три случая, показанные на рисунке ниже, в зависимости от различных операций редактирования.
|
||||
|
||||
1. Добавить $t[j-1]$ после $s[i-1]$, тогда остается подзадача $dp[i, j-1]$.
|
||||
2. Удалить $s[i-1]$, тогда остается подзадача $dp[i-1, j]$.
|
||||
3. Заменить $s[i-1]$ на $t[j-1]$, тогда остается подзадача $dp[i-1, j-1]$.
|
||||
|
||||

|
||||
|
||||
На основе приведенного выше анализа можно получить оптимальную подструктуру: минимальное количество шагов редактирования $dp[i, j]$ равно минимальному количеству шагов редактирования среди $dp[i, j-1]$, $dp[i-1, j]$, $dp[i-1, j-1]$, плюс текущий шаг редактирования $1$. Соответствующее уравнение перехода состояния:
|
||||
|
||||
$$
|
||||
dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1
|
||||
$$
|
||||
|
||||
Обратите внимание, **когда $s[i-1]$ и $t[j-1]$ одинаковы, не требуется редактировать текущий символ**, в этом случае уравнение перехода состояния:
|
||||
|
||||
$$
|
||||
dp[i, j] = dp[i-1, j-1]
|
||||
$$
|
||||
|
||||
**Третий шаг: определить граничные условия и порядок перехода состояния**
|
||||
|
||||
Когда обе строки пусты, количество шагов редактирования равно $0$, то есть $dp[0, 0] = 0$. Когда $s$ пуста, но $t$ не пуста, минимальное количество шагов редактирования равно длине $t$, то есть первая строка $dp[0, j] = j$. Когда $s$ не пуста, но $t$ пуста, минимальное количество шагов редактирования равно длине $s$, то есть первый столбец $dp[i, 0] = i$.
|
||||
|
||||
Наблюдая за уравнением перехода состояния, решение $dp[i, j]$ зависит от решений слева, сверху и слева-сверху, поэтому можно обойти всю таблицу $dp$ в прямом порядке с помощью двух циклов.
|
||||
|
||||
### Реализация кода
|
||||
|
||||
```src
|
||||
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp}
|
||||
```
|
||||
|
||||
Как показано на рисунке ниже, процесс перехода состояния задачи о редакционном расстоянии очень похож на задачу о рюкзаке, оба можно рассматривать как процесс заполнения двумерной сетки.
|
||||
|
||||
=== "<1>"
|
||||

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

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

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

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

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

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

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

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

|
||||
3
ru/docs/chapter_dynamic_programming/index.md
Normal file
3
ru/docs/chapter_dynamic_programming/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Динамическое программирование
|
||||
|
||||
{width="3.2760411198600177in" height="4.239583333333333in"}
|
||||
@@ -0,0 +1,100 @@
|
||||
# Динамическое программирование
|
||||
|
||||
*Динамическое программирование* является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность.
|
||||
|
||||
В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования.
|
||||
|
||||
!!! question "Подъем по лестнице"
|
||||
|
||||
Дана лестница с $n$ ступенями. На каждом шаге можно подниматься на $1$ или $2$ ступени. Сколько существует способов добраться до вершины лестницы?
|
||||
|
||||
Как показано на рисунке ниже, для лестницы с тремя ступенями существует три способа добраться до вершины.
|
||||
|
||||

|
||||
|
||||
Цель этой задачи -- найти количество способов, **и можно попробовать использовать для ее решения метод поиска с возвратом**. Более конкретно -- можно представить подъем по лестнице как процесс многократного выбора: начать с пола, на каждом этапе выбирать подъем на одну или две ступени, при достижении вершины лестницы количество способов увеличивается на 1, а при превышении вершины происходит обрезка. Ниже приведен код реализации.
|
||||
|
||||
```src
|
||||
[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 способа соответственно.
|
||||
|
||||
Рассмотрим следующий код, который, как и стандартный код поиска с возвратом, относится к поиску в глубину, но является более лаконичным.
|
||||
|
||||
```src
|
||||
[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]`, избегая повторной обработки.
|
||||
|
||||
Код реализации представлен ниже.
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
|
||||
```
|
||||
|
||||
После внедрения запоминания все пересекающиеся подзадачи нужно вычислить только один раз, что оптимизирует временную сложность до $O(n)$, это является значительным скачком.
|
||||
|
||||

|
||||
|
||||
## Третий метод: динамическое программирование
|
||||
|
||||
**Мемоизация поиска -- это метод «сверху вниз»**: мы начинаем с исходной задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на более мелкие, пока не достигнем минимальных подзадач с известным решением (листовые узлы). Затем через возврат поэтапно собираем решения подзадач, чтобы построить решение исходной задачи.
|
||||
|
||||
В отличие от этого подхода **динамическое программирование представляет собой метод «снизу вверх»**: начиная с решения минимальных подзадач, итеративно строится решение более крупных подзадач, пока не будет получено решение исходной задачи.
|
||||
|
||||
Поскольку динамическое программирование не включает этап возврата, оно реализуется с использованием циклов и итераций, без необходимости в рекурсии. В следующем коде мы инициализируем массив dp для хранения решений подзадач, который выполняет ту же функцию запоминания, что и массив mem в мемоизации поиска.
|
||||
|
||||
```src
|
||||
[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` **для хранения всех решений подзадач**
|
||||
168
ru/docs/chapter_dynamic_programming/knapsack_problem.md
Normal file
168
ru/docs/chapter_dynamic_programming/knapsack_problem.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Задача о рюкзаке 0-1
|
||||
|
||||
Задача о рюкзаке является отличной вводной задачей для динамического программирования и представляет собой одну из наиболее распространенных форм задач в динамическом программировании. Она имеет множество вариаций, таких как задача о рюкзаке 0-1, задача о полном рюкзаке, задача о множественном рюкзаке и другие.
|
||||
|
||||
В этом разделе мы сначала решим наиболее распространенную задачу о рюкзаке 0-1.
|
||||
|
||||
!!! question
|
||||
|
||||
Дано $n$ предметов, где $i$-й предмет имеет вес $wgt[i-1]$ и ценность $val[i-1]$, а также рюкзак вместимостью $cap$. Каждый предмет можно выбрать только один раз. Найдите максимальную ценность предметов, которые можно поместить в рюкзак при ограниченной вместимости.
|
||||
|
||||
Рассмотрим рисунок ниже: поскольку нумерация предметов $i$ начинается с $1$, а индексы массива начинаются с $0$, предмет $i$ соответствует весу $wgt[i-1]$ и ценности $val[i-1]$.
|
||||
|
||||

|
||||
|
||||
Мы можем рассматривать задачу о рюкзаке 0-1 как процесс, состоящий из $n$ раундов принятия решений, где для каждого предмета существует два решения: не класть в рюкзак и положить в рюкзак. Таким образом, эта задача удовлетворяет модели дерева решений.
|
||||
|
||||
Цель этой задачи — найти "максимальную ценность предметов, которые можно поместить в рюкзак при ограниченной вместимости", поэтому с большой вероятностью это задача динамического программирования.
|
||||
|
||||
**Шаг первый: обдумать решение на каждом раунде, определить состояние и получить таблицу $dp$**
|
||||
|
||||
Для каждого предмета: если не класть его в рюкзак, вместимость рюкзака не изменяется; если положить в рюкзак, вместимость рюкзака уменьшается. Отсюда получаем определение состояния: текущий номер предмета $i$ и вместимость рюкзака $c$, обозначаемые как $[i, c]$.
|
||||
|
||||
Состояние $[i, c]$ соответствует подзадаче: **максимальная ценность первых $i$ предметов в рюкзаке вместимостью $c$**, обозначаемая как $dp[i, c]$.
|
||||
|
||||
Необходимо найти $dp[n, cap]$, поэтому требуется двумерная таблица $dp$ размером $(n+1) \times (cap+1)$.
|
||||
|
||||
**Шаг второй: найти оптимальную подструктуру и вывести уравнение перехода состояния**
|
||||
|
||||
После принятия решения относительно предмета $i$ остается подзадача принятия решений для первых $i-1$ предметов, которая может быть разделена на следующие два случая:
|
||||
|
||||
- **Не класть предмет $i$**: вместимость рюкзака не изменяется, состояние переходит в $[i-1, c]$.
|
||||
- **Положить предмет $i$**: вместимость рюкзака уменьшается на $wgt[i-1]$, ценность увеличивается на $val[i-1]$, состояние переходит в $[i-1, c-wgt[i-1]]$.
|
||||
|
||||
Приведенный выше анализ раскрывает оптимальную подструктуру этой задачи: **максимальная ценность $dp[i, c]$ равна большему значению из двух вариантов: не класть предмет $i$ и положить предмет $i$**. Отсюда можно вывести уравнение перехода состояния:
|
||||
|
||||
$$
|
||||
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
|
||||
$$
|
||||
|
||||
Необходимо отметить, что если текущий вес предмета $wgt[i - 1]$ превышает оставшуюся вместимость рюкзака $c$, то можно выбрать только вариант не класть предмет в рюкзак.
|
||||
|
||||
**Шаг третий: определить граничные условия и порядок перехода состояний**
|
||||
|
||||
Когда нет предметов или вместимость рюкзака равна $0$, максимальная ценность равна $0$, то есть первый столбец $dp[i, 0]$ и первая строка $dp[0, c]$ равны $0$.
|
||||
|
||||
Текущее состояние $[i, c]$ переходит из состояния сверху $[i-1, c]$ и состояния слева сверху $[i-1, c-wgt[i-1]]$, поэтому с помощью двух вложенных циклов можно последовательно обойти всю таблицу $dp$.
|
||||
|
||||
На основе приведенного выше анализа мы далее последовательно реализуем решения методом полного перебора, мемоизации поиска и динамического программирования.
|
||||
|
||||
### Первый метод: полный перебор
|
||||
|
||||
Код поиска включает следующие элементы:
|
||||
|
||||
- **Параметры рекурсии**: состояние $[i, c]$.
|
||||
- **Возвращаемое значение**: решение подзадачи $dp[i, c]$.
|
||||
- **Условие завершения**: когда номер предмета выходит за границы $i = 0$ или оставшаяся вместимость рюкзака равна $0$, завершить рекурсию и вернуть ценность $0$.
|
||||
- **Обрезка**: если текущий вес предмета превышает оставшуюся вместимость рюкзака, можно выбрать только вариант не класть предмет в рюкзак.
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
|
||||
```
|
||||
|
||||
Как показано на рисунке ниже, поскольку каждый предмет порождает две ветви поиска (не выбирать и выбирать), временная сложность составляет $O(2^n)$.
|
||||
|
||||
Наблюдая за деревом рекурсии, легко заметить наличие перекрывающихся подзадач, например $dp[1, 10]$ и других. Когда предметов много, вместимость рюкзака велика, особенно когда много предметов одинакового веса, количество перекрывающихся подзадач значительно увеличивается.
|
||||
|
||||

|
||||
|
||||
### Второй метод: мемоизация поиска
|
||||
|
||||
Чтобы гарантировать, что перекрывающиеся подзадачи вычисляются только один раз, мы используем список мемоизации `mem` для записи решений подзадач, где `mem[i][c]` соответствует $dp[i, c]$.
|
||||
|
||||
После введения мемоизации **временная сложность зависит от количества подзадач**, то есть $O(n \times cap)$. Код реализации приведен ниже:
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
|
||||
```
|
||||
|
||||
На рисунке ниже показаны ветви поиска, которые были обрезаны при мемоизации поиска.
|
||||
|
||||

|
||||
|
||||
### Третий метод: динамическое программирование
|
||||
|
||||
Динамическое программирование по сути представляет собой процесс заполнения таблицы $dp$ при переходе состояний. Код приведен ниже:
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
|
||||
```
|
||||
|
||||
Как показано на рисунке ниже, временная сложность и пространственная сложность определяются размером массива `dp`, то есть $O(n \times cap)$.
|
||||
|
||||
=== "<1>"
|
||||

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

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

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

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

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

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

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

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

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

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

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

|
||||
|
||||
=== "<13>"
|
||||

|
||||
|
||||
=== "<14>"
|
||||

|
||||
|
||||
### Оптимизация пространства
|
||||
|
||||
Поскольку каждое состояние зависит только от состояний предыдущей строки, мы можем использовать два массива для последовательного продвижения, снижая пространственную сложность с $O(n^2)$ до $O(n)$.
|
||||
|
||||
Подумаем дальше: можем ли мы реализовать оптимизацию пространства, используя только один массив? Как видно, каждое состояние переходит из ячейки прямо сверху или слева сверху. Предположим, что есть только один массив: когда начинается обход $i$-й строки, этот массив все еще хранит состояния строки $i-1$.
|
||||
|
||||
- Если использовать прямой порядок обхода, то при достижении $dp[i, j]$ значения слева сверху $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ могут быть уже перезаписаны, и в этом случае невозможно получить правильный результат перехода состояния.
|
||||
- Если использовать обратный порядок обхода, проблема перезаписи не возникнет, и переход состояния может выполняться корректно.
|
||||
|
||||
На рисунке ниже показан процесс перехода от строки $i = 1$ к строке $i = 2$ в одном массиве. Подумайте о разнице между прямым и обратным порядком обхода.
|
||||
|
||||
=== "<1>"
|
||||

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

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

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

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

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

|
||||
|
||||
В реализации кода нам нужно только удалить первое измерение $i$ массива `dp` и изменить внутренний цикл на обратный порядок обхода:
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}
|
||||
```
|
||||
25
ru/docs/chapter_dynamic_programming/summary.md
Normal file
25
ru/docs/chapter_dynamic_programming/summary.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Резюме
|
||||
|
||||
### Ключевые моменты
|
||||
|
||||
- Динамическое программирование разбивает задачу на подзадачи и за счет сохранения решений подзадач избегает повторных вычислений, повышая эффективность вычислений.
|
||||
- Без учета времени все задачи динамического программирования можно решить методом поиска с возвратом (полным перебором), но в дереве рекурсии существует большое количество перекрывающихся подзадач, что крайне неэффективно. Введение списка мемоизации позволяет сохранять решения всех вычисленных подзадач, тем самым гарантируя, что перекрывающиеся подзадачи вычисляются только один раз.
|
||||
- Мемоизация поиска -- это рекурсивный метод «сверху вниз», в то время как соответствующее динамическое программирование -- это итеративный метод «снизу вверх», который подобен «заполнению таблицы». Поскольку текущее состояние зависит только от некоторых локальных состояний, мы можем исключить одно измерение таблицы $dp$, тем самым снижая пространственную сложность.
|
||||
- Разбиение на подзадачи -- это универсальный алгоритмический подход, который имеет различные свойства в методах «разделяй и властвуй», динамическом программировании и поиске с возвратом.
|
||||
- Задачи динамического программирования обладают тремя основными свойствами: перекрывающиеся подзадачи, оптимальная подструктура и отсутствие последействия.
|
||||
- Если оптимальное решение исходной задачи можно построить из оптимальных решений подзадач, то она обладает оптимальной подструктурой.
|
||||
- Отсутствие последействия означает, что для данного состояния его будущее развитие зависит только от этого состояния и не зависит от всех прошлых состояний. Многие задачи комбинаторной оптимизации не обладают свойством отсутствия последействия и не могут быть быстро решены с помощью динамического программирования.
|
||||
|
||||
**Задача о рюкзаке**
|
||||
|
||||
- Задача о рюкзаке -- одна из самых типичных задач динамического программирования, имеющая варианты: рюкзак 0-1, полный рюкзак, множественный рюкзак и другие.
|
||||
- В задаче о рюкзаке 0-1 состояние определяется как максимальная стоимость первых $i$ предметов в рюкзаке вместимостью $c$. На основе двух решений -- не класть в рюкзак и класть в рюкзак -- можно получить оптимальную подструктуру и построить уравнение перехода состояния. При оптимизации пространства, поскольку каждое состояние зависит от состояний сверху и слева сверху, необходимо обходить список в обратном порядке, чтобы избежать перезаписи состояния слева сверху.
|
||||
- В задаче о полном рюкзаке количество выбираемых предметов каждого типа не ограничено, поэтому переход состояния при выборе предмета отличается от задачи о рюкзаке 0-1. Поскольку состояние зависит от состояний сверху и слева, при оптимизации пространства следует обходить в прямом порядке.
|
||||
- Задача о размене монет -- это вариант задачи о полном рюкзаке. Она меняет поиск «максимальной» стоимости на поиск «минимального» количества монет, поэтому в уравнении перехода состояния $\max()$ следует заменить на $\min()$. От стремления «не превысить» вместимость рюкзака к стремлению «точно» составить целевую сумму, поэтому используется $amt + 1$ для обозначения недопустимого решения «невозможно составить целевую сумму».
|
||||
- Задача о размене монет II меняет поиск «минимального количества монет» на поиск «количества комбинаций монет», соответственно уравнение перехода состояния меняется с $\min()$ на оператор суммирования.
|
||||
|
||||
**Задача о редакционном расстоянии**
|
||||
|
||||
- Редакционное расстояние (расстояние Левенштейна) используется для измерения сходства между двумя строками и определяется как минимальное количество операций редактирования для преобразования одной строки в другую, операции редактирования включают добавление, удаление и замену.
|
||||
- Состояние в задаче о редакционном расстоянии определяется как минимальное количество операций редактирования, необходимых для преобразования первых $i$ символов $s$ в первые $j$ символов $t$. Когда $s[i] \ne t[j]$, существует три решения: добавление, удаление, замена, каждое из которых имеет соответствующую оставшуюся подзадачу. На основе этого можно найти оптимальную подструктуру и построить уравнение перехода состояния. А когда $s[i] = t[j]$, редактирование текущего символа не требуется.
|
||||
- В задаче о редакционном расстоянии состояние зависит от состояний сверху, слева и слева сверху, поэтому после оптимизации пространства ни прямой, ни обратный обход не позволяют правильно выполнить переход состояния. Для этого мы используем переменную для временного хранения состояния слева сверху, тем самым преобразуя задачу к эквивалентной задаче о полном рюкзаке, что позволяет выполнять прямой обход после оптимизации пространства.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Задача о неограниченном рюкзаке
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:在本节中,我们先求解另一个常见的背包问题:完全背包,再了解它的一种特例:零钱兑换。 -->
|
||||
|
||||
## Задача о неограниченном рюкзаке
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:给定 $n$ 个物品,第 $i$ 个物品的重量为 $wgt[i-1]$、价值为 $val[i-1]$ ,和一个容量为 $cap$ 的背包。**每个物品可以重复选取**,问在限定背包容量下能放入物品的最大价值。示例如下图所示。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失图片 -->
|
||||
<!-- 中文原文: -->
|
||||
|
||||
### Подход динамического программирования
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:完全背包问题和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:- 在 0-1 背包问题中,每种物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。
|
||||
- 在完全背包问题中,每种物品的数量是无限的,因此将物品 $i$ 放入背包后,**仍可以从前 $i$ 个物品中选择**。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:在完全背包问题的规定下,状态 $[i, c]$ 的变化分为两种情况。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:- **不放入物品 $i$** :与 0-1 背包问题相同,转移至 $[i-1, c]$ 。
|
||||
- **放入物品 $i$** :与 0-1 背包问题不同,转移至 $[i, c-wgt[i-1]]$ 。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:从而状态转移方程变为:$$dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])$$ -->
|
||||
|
||||
### Реализация кода
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致: -->
|
||||
|
||||
<!-- 🔴 俄文版缺失代码块 -->
|
||||
<!-- 中文原文:```src
|
||||
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp}
|
||||
``` -->
|
||||
|
||||
### Оптимизация пространства
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:由于当前状态是从左边和上边的状态转移而来的,**因此空间优化后应该对 $dp$ 表中的每一行进行正序遍历**。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:这个遍历顺序与 0-1 背包正好相反。请借助下图来理解两者的区别。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失图片序列 -->
|
||||
<!-- 中文原文:=== "<1>" 到 === "<6>" 的图片序列 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:代码实现比较简单,仅需将数组 `dp` 的第一维删除: -->
|
||||
|
||||
<!-- 🔴 俄文版缺失代码块 -->
|
||||
<!-- 中文原文:```src
|
||||
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp_comp}
|
||||
``` -->
|
||||
|
||||
## Задача размена монет
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:背包问题是一大类动态规划问题的代表,其拥有很多变种,例如零钱兑换问题。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,**每种硬币可以重复选取**,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 $-1$ 。示例如下图所示。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失图片 -->
|
||||
<!-- 中文原文: -->
|
||||
|
||||
### Подход динамического программирования
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:**零钱兑换可以看作完全背包问题的一种特殊情况**,两者具有以下联系与不同点。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:- 两道题可以相互转换,"物品"对应"硬币"、"物品重量"对应"硬币面值"、"背包容量"对应"目标金额"。
|
||||
- 优化目标相反,完全背包问题是要最大化物品价值,零钱兑换问题是要最小化硬币数量。
|
||||
- 完全背包问题是求"不超过"背包容量下的解,零钱兑换是求"恰好"凑到目标金额的解。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:**第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表** -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:状态 $[i, a]$ 对应的子问题为:**前 $i$ 种硬币能够凑出金额 $a$ 的最少硬币数量**,记为 $dp[i, a]$ 。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:二维 $dp$ 表的尺寸为 $(n+1) \times (amt+1)$ 。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:**第二步:找出最优子结构,进而推导出状态转移方程** -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:本题与完全背包问题的状态转移方程存在以下两点差异。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:- 本题要求最小值,因此需将运算符 $\max()$ 更改为 $\min()$ 。
|
||||
- 优化主体是硬币数量而非商品价值,因此在选中硬币时执行 $+1$ 即可。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:$$dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)$$ -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:**第三步:确定边界条件和状态转移顺序** -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:当目标金额为 $0$ 时,凑出它的最少硬币数量为 $0$ ,即首列所有 $dp[i, 0]$ 都等于 $0$ 。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:当无硬币时,**无法凑出任意 $> 0$ 的目标金额**,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令首行所有 $dp[0, a]$ 都等于 $+ \infty$ 。 -->
|
||||
|
||||
### Реализация кода
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:大多数编程语言并未提供 $+ \infty$ 变量,只能使用整型 `int` 的最大值来代替。而这又会导致大数越界:状态转移方程中的 $+ 1$ 操作可能发生溢出。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:为此,我们采用数字 $amt + 1$ 来表示无效解,因为凑出 $amt$ 的硬币数量最多为 $amt$ 。最后返回前,判断 $dp[n, amt]$ 是否等于 $amt + 1$ ,若是则返回 $-1$ ,代表无法凑出目标金额。代码如下所示: -->
|
||||
|
||||
<!-- 🔴 俄文版缺失代码块 -->
|
||||
<!-- 中文原文:```src
|
||||
[file]{coin_change}-[class]{}-[func]{coin_change_dp}
|
||||
``` -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:下图展示了零钱兑换的动态规划过程,和完全背包问题非常相似。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失图片序列 -->
|
||||
<!-- 中文原文:=== "<1>" 到 === "<15>" 的图片序列 -->
|
||||
|
||||
### Оптимизация пространства
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:零钱兑换的空间优化的处理方式和完全背包问题一致: -->
|
||||
|
||||
<!-- 🔴 俄文版缺失代码块 -->
|
||||
<!-- 中文原文:```src
|
||||
[file]{coin_change}-[class]{}-[func]{coin_change_dp_comp}
|
||||
``` -->
|
||||
|
||||
## Задача размена монет II
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,**问凑出目标金额的硬币组合数量**。示例如下图所示。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失图片 -->
|
||||
<!-- 中文原文: -->
|
||||
|
||||
### Подход динамического программирования
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:相比于上一题,本题目标是求组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。 -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为: -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:$$dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]$$ -->
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:当目标金额为 $0$ 时,无须选择任何硬币即可凑出目标金额,因此应将首列所有 $dp[i, 0]$ 都初始化为 $1$ 。当无硬币时,无法凑出任何 $>0$ 的目标金额,因此首行所有 $dp[0, a]$ 都等于 $0$ 。 -->
|
||||
|
||||
### Реализация кода
|
||||
|
||||
<!-- 🔴 俄文版缺失代码块 -->
|
||||
<!-- 中文原文:```src
|
||||
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp}
|
||||
``` -->
|
||||
|
||||
### Оптимизация пространства
|
||||
|
||||
<!-- 🔴 俄文版缺失此段落 -->
|
||||
<!-- 中文原文:空间优化处理方式相同,删除硬币维度即可: -->
|
||||
|
||||
<!-- 🔴 俄文版缺失代码块 -->
|
||||
<!-- 中文原文:```src
|
||||
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp_comp}
|
||||
``` -->
|
||||
Reference in New Issue
Block a user