Add ru version (#1865)

* Add Russian docs site baseline

* Add Russian localized codebase

* Polish Russian code wording

* Update ru code translation.

* Update code translation and chapter covers.

* Fix pythontutor extraction.

* Add README and landing page.

* placeholder of profiles

* Use figures of English version

* Remove chapter paperbook
This commit is contained in:
Yudong Jin
2026-03-28 04:24:07 +08:00
committed by GitHub
parent 2ca570cc33
commit 772183705e
1958 changed files with 108186 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,101 @@
# Свойства задач динамического программирования
В предыдущем разделе мы увидели, как динамическое программирование решает исходную задачу через разложение на подзадачи. На самом деле разложение на подзадачи - это общий алгоритмический подход, но в divide and conquer, динамическом программировании и backtracking акценты расставлены по-разному.
- Алгоритмы divide and conquer рекурсивно раскладывают исходную задачу на несколько независимых подзадач, пока не будет достигнута наименьшая подзадача, а затем в процессе возврата объединяют решения подзадач в решение исходной задачи.
- Динамическое программирование тоже раскладывает задачу рекурсивно, но его главное отличие от divide and conquer в том, что подзадачи здесь зависят друг от друга и в процессе разложения возникает много перекрывающихся подзадач.
- Алгоритм backtracking перебирает все возможные решения через попытки и откат и с помощью обрезки избегает ненужных ветвей поиска. Решение исходной задачи состоит из последовательности решений, и подзадачей можно считать префикс этой последовательности решений.
На практике динамическое программирование часто применяется для задач оптимизации. Такие задачи не только содержат перекрывающиеся подзадачи, но и обладают еще двумя важными свойствами: оптимальной подструктурой и отсутствием последствий.
## Оптимальная подструктура
Немного изменим задачу о подъеме по лестнице, чтобы нагляднее показать понятие оптимальной подструктуры.
!!! question "Минимальная стоимость подъема по лестнице"
Дана лестница, по которой можно подниматься на $1$ или на $2$ ступени за раз. На каждой ступени указано неотрицательное целое число, обозначающее цену попадания на эту ступень. Дан массив неотрицательных целых чисел $cost$ , где $cost[i]$ - это цена для ступени $i$ , а $cost[0]$ соответствует земле (начальной позиции). Найдите минимальную суммарную стоимость, необходимую для достижения вершины.
Как показано на рисунке ниже, если цены для ступеней $1$ , $2$ и $3$ равны соответственно $1$ , $10$ и $1$ , то минимальная стоимость подъема с земли на третью ступень равна $2$ .
![Минимальная стоимость подъема на 3-ю ступень](dp_problem_features.assets/min_cost_cs_example.png)
Пусть $dp[i]$ обозначает накопленную стоимость подъема на ступень $i$ . Поскольку на ступень $i$ можно прийти только со ступени $i - 1$ или со ступени $i - 2$ , значение $dp[i]$ может быть либо $dp[i - 1] + cost[i]$ , либо $dp[i - 2] + cost[i]$ . Чтобы минимизировать стоимость, нужно выбрать меньший из этих двух вариантов:
$$
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
$$
Отсюда и возникает смысл оптимальной подструктуры: **оптимальное решение исходной задачи строится из оптимальных решений подзадач**.
Очевидно, что эта задача обладает оптимальной подструктурой: мы берем лучшее из двух оптимальных решений подзадач $dp[i-1]$ и $dp[i-2]$ и на его основе строим оптимальное решение исходной задачи $dp[i]$ .
А обладает ли оптимальной подструктурой исходная задача о числе способов подъема по лестнице из прошлого раздела? Формально она не про оптимум, а про подсчет количества. Но если переформулировать ее как "найдите максимальное количество способов", мы неожиданно увидим, что **хотя исходная задача осталась по сути той же, оптимальная подструктура стала явной**: максимальное число способов добраться до ступени $n$ равно сумме максимальных чисел способов добраться до ступеней $n-1$ и $n-2$ . То есть объяснение оптимальной подструктуры в разных задачах может быть довольно гибким.
Зная уравнение перехода состояния, а также начальные состояния $dp[1] = cost[1]$ и $dp[2] = cost[2]$ , мы можем сразу написать код динамического программирования:
```src
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp}
```
На рисунке ниже показан процесс динамического программирования для этой задачи.
![Процесс динамического программирования для минимальной стоимости подъема](dp_problem_features.assets/min_cost_cs_dp.png)
В этой задаче тоже можно оптимизировать пространство, сжав одномерное состояние в нулевое измерение и тем самым уменьшив пространственную сложность с $O(n)$ до $O(1)$ :
```src
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
```
## Отсутствие последствий
Отсутствие последствий - одно из ключевых свойств, благодаря которому динамическое программирование вообще может эффективно работать. Его определение таково: **если текущее состояние задано однозначно, то его дальнейшее развитие зависит только от него самого и не зависит от всей истории предыдущих состояний**.
Для примера снова рассмотрим задачу о лестнице. Если дано состояние $i$ , то из него можно перейти в состояния $i+1$ и $i+2$ , соответствующие прыжкам на $1$ и на $2$ ступени. Чтобы сделать один из этих выборов, не нужно знать, какими были состояния до $i$ ; на будущее влияет только текущее состояние $i$ .
Однако если добавить в задачу дополнительное ограничение, ситуация изменится.
!!! question "Подъем по лестнице с ограничением"
Дана лестница из $n$ ступеней. За один шаг можно подняться на $1$ или на $2$ ступени, **но нельзя два раунда подряд прыгать на $1$ ступень**. Сколькими способами можно добраться до вершины?
Как показано на рисунке ниже, на третью ступень теперь существует только $2$ допустимых способа добраться: вариант с тремя последовательными прыжками на $1$ не удовлетворяет ограничению и потому отбрасывается.
![Число способов подняться на 3-ю ступень при наличии ограничения](dp_problem_features.assets/climbing_stairs_constraint_example.png)
В этой задаче, если в предыдущем раунде был сделан прыжок на $1$ ступень, то в следующем раунде уже обязательно нужно прыгнуть на $2$ ступени. Иными словами, **следующий выбор уже нельзя определить только по текущему состоянию (текущему номеру ступени) - он зависит еще и от предыдущего состояния (с какой ступени мы пришли в прошлый раз)**.
Нетрудно заметить, что в таком виде задача больше не удовлетворяет свойству отсутствия последствий, а уравнение перехода состояния $dp[i] = dp[i-1] + dp[i-2]$ перестает работать, потому что $dp[i-1]$ соответствует прыжку на $1$ ступень, но при этом включает множество вариантов, где предыдущий раунд тоже был прыжком на $1$ ступень. Такие варианты уже нельзя напрямую учитывать в $dp[i]$ , если мы хотим соблюдать ограничение.
Поэтому нам нужно расширить определение состояния: **состояние $[i, j]$ означает, что мы находимся на ступени $i$ и в предыдущем раунде прыгнули на $j$ ступеней**, где $j \in \{1, 2\}$ . Такое определение состояния эффективно различает, был ли в прошлом раунде прыжок на $1$ или на $2$ ступени, и позволяет корректно определить, откуда произошло текущее состояние.
- Если в предыдущем раунде был прыжок на $1$ ступень, то в раунде перед ним мог быть только прыжок на $2$ ступени, то есть $dp[i, 1]$ может перейти только из $dp[i-1, 2]$ .
- Если в предыдущем раунде был прыжок на $2$ ступени, то еще шагом раньше можно было прыгнуть либо на $1$ , либо на $2$ ступени, то есть $dp[i, 2]$ может переходить из $dp[i-2, 1]$ или из $dp[i-2, 2]$ .
Как показано на рисунке ниже, при таком определении $dp[i, j]$ обозначает число способов для состояния $[i, j]$ . Тогда уравнение перехода состояния имеет вид:
$$
\begin{cases}
dp[i, 1] = dp[i-1, 2] \\
dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
\end{cases}
$$
![Рекуррентная связь с учетом ограничения](dp_problem_features.assets/climbing_stairs_constraint_state_transfer.png)
В конце достаточно вернуть $dp[n, 1] + dp[n, 2]$ ; эта сумма и представляет общее число способов добраться до ступени $n$ :
```src
[file]{climbing_stairs_constraint_dp}-[class]{}-[func]{climbing_stairs_constraint_dp}
```
В этом примере достаточно дополнительно учитывать только одно предыдущее состояние, поэтому после расширения определения состояния задача снова начинает удовлетворять свойству отсутствия последствий. Однако в некоторых задачах "зависимость от прошлого" бывает гораздо серьезнее.
!!! question "Подъем по лестнице с порождением препятствий"
Дана лестница из $n$ ступеней. За один шаг можно подняться на $1$ или на $2$ ступени. **При этом, если вы попали на ступень $i$ , система автоматически создает препятствие на ступени $2i$ , и на всех последующих шагах становиться на ступень $2i$ уже нельзя**. Например, если в первых двух раундах вы попали на ступени $2$ и $3$ , то после этого нельзя будет попадать на ступени $4$ и $6$ . Сколько существует способов добраться до вершины?
В этой задаче следующий прыжок зависит от всех предыдущих состояний, потому что каждый прыжок порождает новое препятствие на более высокой ступени и тем самым влияет на все будущие прыжки. Для задач такого типа динамическое программирование обычно оказывается непригодным.
Вообще, многие сложные задачи комбинаторной оптимизации (например, задача коммивояжера) не обладают свойством отсутствия последствий. Для таких задач обычно выбирают другие методы - например, эвристический поиск, генетические алгоритмы, обучение с подкреплением и т.д., - чтобы за ограниченное время получить пригодное локально оптимальное решение.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,183 @@
# Подход к решению задач динамического программирования
В двух предыдущих разделах были рассмотрены основные свойства задач динамического программирования. Теперь исследуем два более практических вопроса.
1. Как определить, является ли некоторая задача задачей динамического программирования?
2. С чего начинать решение такой задачи и как выглядит полный процесс решения?
## Определение задачи
В целом, если задача содержит перекрывающиеся подзадачи, оптимальную подструктуру и удовлетворяет свойству отсутствия последствий, то она обычно подходит для решения с помощью динамического программирования. Однако извлечь все эти свойства напрямую из формулировки задачи бывает трудно. Поэтому на практике мы обычно ослабляем требования и **сначала смотрим, подходит ли задача для решения методом backtracking (полного перебора)**.
**Задачи, подходящие для backtracking, обычно удовлетворяют "модели дерева решений"**. Такие задачи можно описать деревом, где каждый узел представляет одно решение, а каждый путь представляет последовательность решений.
Иначе говоря, если в задаче есть четко выраженные решения и ответ порождается последовательностью таких решений, то она удовлетворяет модели дерева решений и обычно допускает решение через backtracking.
Поверх этого у задач динамического программирования есть и некоторые дополнительные "плюсы".
- В условии задачи фигурируют слова "максимальный", "минимальный", "наибольший", "наименьший" и другие формулировки оптимизации.
- Состояния задачи можно описать списком, многомерной матрицей или деревом, и между состоянием и соседними состояниями существует рекуррентная зависимость.
Соответственно, существуют и некоторые "минусы".
- Цель задачи состоит в поиске всех возможных решений, а не одного оптимального решения.
- В формулировке явно присутствуют признаки комбинаторного перечисления, и требуется вернуть сразу много конкретных вариантов.
Если задача удовлетворяет модели дерева решений и имеет достаточно явные "плюсы", мы можем предположить, что это задача динамического программирования, а затем проверить это предположение уже в процессе решения.
## Этапы решения задачи
Конкретный процесс решения задач динамического программирования зависит от природы и сложности задачи, но обычно включает следующие шаги: описание решений, определение состояний, построение таблицы $dp$ , вывод уравнения перехода состояния, определение граничных условий и порядка переходов.
Чтобы нагляднее показать этот процесс, рассмотрим классическую задачу "минимальная сумма пути".
!!! question
Дана двумерная сетка `grid` размера $n \times m$ , в каждой клетке которой записано неотрицательное целое число, означающее стоимость прохождения через эту клетку. Робот стартует из левой верхней клетки и за один шаг может двигаться только вправо или вниз, пока не достигнет правой нижней клетки. Верните минимальную сумму пути от левой верхней клетки до правой нижней.
На рисунке ниже показан пример, в котором минимальная сумма пути равна $13$ .
![Пример данных для задачи о минимальной сумме пути](dp_solution_pipeline.assets/min_path_sum_example.png)
**Шаг 1: понять решения на каждом раунде, определить состояние и тем самым получить таблицу $dp$**
В этой задаче на каждом раунде решение состоит в том, чтобы из текущей клетки сделать один шаг вниз или вправо. Пусть индексы строки и столбца текущей клетки равны $[i, j]$ ; тогда после шага вниз или вправо индексы становятся равными $[i+1, j]$ или $[i, j+1]$ . Значит, состояние должно включать два переменных индекса: строки и столбца, то есть состояние обозначается как $[i, j]$ .
Подзадача, соответствующая состоянию $[i, j]$ , такова: минимальная сумма пути от стартовой клетки $[0, 0]$ до клетки $[i, j]$ . Ее решение обозначается через $dp[i, j]$ .
На этом этапе мы получаем двумерную матрицу $dp$ , показанную на рисунке ниже, размер которой совпадает с размером входной сетки `grid` .
![Определение состояния и таблицы dp](dp_solution_pipeline.assets/min_path_sum_solution_state_definition.png)
!!! note
Как в динамическом программировании, так и в backtracking, решение задачи можно описать как последовательность решений, а состояние образуется всеми переменными решений. Оно должно содержать всю информацию, достаточную для вывода следующего состояния.
Каждому состоянию соответствует некоторая подзадача, и для хранения решений всех подзадач мы определяем таблицу $dp$ ; каждая независимая переменная состояния становится одним измерением таблицы $dp$ . По сути таблица $dp$ - это отображение от состояния к решению соответствующей подзадачи.
**Шаг 2: найти оптимальную подструктуру и на ее основе вывести уравнение перехода состояния**
Для состояния $[i, j]$ возможны только два источника: клетка сверху $[i-1, j]$ и клетка слева $[i, j-1]$ . Следовательно, оптимальная подструктура выглядит так: минимальная сумма пути до $[i, j]$ определяется меньшим из двух значений - минимальной суммы пути до $[i-1, j]$ и минимальной суммы пути до $[i, j-1]$ .
По этому рассуждению получается уравнение перехода состояния, показанное на рисунке ниже:
$$
dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
$$
![Оптимальная подструктура и уравнение перехода состояния](dp_solution_pipeline.assets/min_path_sum_solution_state_transition.png)
!!! note
Опираясь на уже определенную таблицу $dp$ , нужно продумать отношение между исходной задачей и подзадачами и найти способ построить оптимальное решение исходной задачи из оптимальных решений подзадач, то есть найти оптимальную подструктуру.
Как только оптимальная подструктура найдена, на ее основе можно построить уравнение перехода состояния.
**Шаг 3: определить граничные условия и порядок переходов**
В этой задаче состояния в первой строке могут переходить только из клетки слева, а состояния в первом столбце - только из клетки сверху, поэтому первая строка $i = 0$ и первый столбец $j = 0$ образуют граничные условия.
Как показано на рисунке ниже, поскольку каждая клетка получается из клетки слева и клетки сверху, мы можем проходить матрицу циклами: внешний цикл по строкам, внутренний - по столбцам.
![Граничные условия и порядок перехода состояний](dp_solution_pipeline.assets/min_path_sum_solution_initial_state.png)
!!! note
В динамическом программировании граничные условия используются для инициализации таблицы $dp$ , а в поиске - для обрезки.
Смысл порядка перехода состояния в том, чтобы к моменту вычисления текущей подзадачи все более мелкие подзадачи, от которых она зависит, уже были вычислены корректно.
После этого анализа мы уже можем напрямую написать код динамического программирования. Однако разложение на подзадачи - это мышление "сверху вниз", поэтому с точки зрения мышления более естественно реализовывать задачу в порядке "полный перебор $\rightarrow$ поиск с мемоизацией $\rightarrow$ динамическое программирование".
### Метод 1: полный перебор
Начав со состояния $[i, j]$ , мы непрерывно раскладываем его на меньшие состояния $[i-1, j]$ и $[i, j-1]$ . Рекурсивная функция при этом имеет следующие элементы.
- **Параметры рекурсии**: состояние $[i, j]$ .
- **Возвращаемое значение**: минимальная сумма пути до $[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` .
По своей сути причина появления перекрывающихся подзадач такова: **существует много разных путей от левого верхнего угла до одной и той же клетки**.
![Дерево рекурсии полного перебора](dp_solution_pipeline.assets/min_path_sum_dfs.png)
У каждого состояния есть два выбора - вниз и вправо, а от левого верхнего угла до правого нижнего нужно сделать всего $m + n - 2$ шагов, поэтому худшая временная сложность равна $O(2^{m + n})$ , где $n$ и $m$ - число строк и столбцов сетки соответственно. Заметим, что в этой оценке не учитывается близость к границам сетки: у граничных клеток остается только один выбор, так что фактическое число путей будет несколько меньше.
### Метод 2: поиск с мемоизацией
Введем список памяти `mem` того же размера, что и сетка `grid` , для хранения решений всех подзадач и отсечения перекрывающихся подзадач:
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
```
Как показано на рисунке ниже, после добавления мемоизации решение каждой подзадачи вычисляется только один раз, поэтому временная сложность определяется общим числом состояний, то есть равна $O(nm)$ .
![Дерево рекурсии для поиска с мемоизацией](dp_solution_pipeline.assets/min_path_sum_dfs_mem.png)
### Метод 3: динамическое программирование
Итеративная реализация динамического программирования выглядит так:
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
```
На рисунке ниже показан процесс переходов состояний в задаче о минимальной сумме пути. Он проходит по всей сетке, **поэтому временная сложность равна $O(nm)$** .
Размер массива `dp` равен $n \times m$ , **поэтому пространственная сложность равна $O(nm)$** .
=== "<1>"
![Процесс динамического программирования для минимальной суммы пути](dp_solution_pipeline.assets/min_path_sum_dp_step1.png)
=== "<2>"
![min_path_sum_dp_step2](dp_solution_pipeline.assets/min_path_sum_dp_step2.png)
=== "<3>"
![min_path_sum_dp_step3](dp_solution_pipeline.assets/min_path_sum_dp_step3.png)
=== "<4>"
![min_path_sum_dp_step4](dp_solution_pipeline.assets/min_path_sum_dp_step4.png)
=== "<5>"
![min_path_sum_dp_step5](dp_solution_pipeline.assets/min_path_sum_dp_step5.png)
=== "<6>"
![min_path_sum_dp_step6](dp_solution_pipeline.assets/min_path_sum_dp_step6.png)
=== "<7>"
![min_path_sum_dp_step7](dp_solution_pipeline.assets/min_path_sum_dp_step7.png)
=== "<8>"
![min_path_sum_dp_step8](dp_solution_pipeline.assets/min_path_sum_dp_step8.png)
=== "<9>"
![min_path_sum_dp_step9](dp_solution_pipeline.assets/min_path_sum_dp_step9.png)
=== "<10>"
![min_path_sum_dp_step10](dp_solution_pipeline.assets/min_path_sum_dp_step10.png)
=== "<11>"
![min_path_sum_dp_step11](dp_solution_pipeline.assets/min_path_sum_dp_step11.png)
=== "<12>"
![min_path_sum_dp_step12](dp_solution_pipeline.assets/min_path_sum_dp_step12.png)
### Оптимизация пространства
Поскольку каждая клетка зависит только от клетки слева и клетки сверху, таблицу $dp$ можно реализовать с помощью одномерного массива, соответствующего одной строке.
Обратите внимание: поскольку массив `dp` теперь может представлять только одну строку состояний, мы не можем заранее инициализировать состояния первого столбца, а должны обновлять их по мере обхода каждой строки:
```src
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,129 @@
# Задача о расстоянии редактирования
Расстояние редактирования, также называемое расстоянием Левенштейна, обозначает минимальное число правок, необходимых для взаимного преобразования двух строк. Обычно оно используется для измерения сходства двух последовательностей в информационном поиске и обработке естественного языка.
!!! question
Даны две строки $s$ и $t$ . Верните минимальное число шагов редактирования, необходимое для преобразования $s$ в $t$ .
Для строки допускаются три операции редактирования: вставка одного символа, удаление одного символа и замена одного символа на произвольный другой символ.
Как показано на рисунке ниже, для преобразования `kitten` в `sitting` требуется 3 шага редактирования: 2 операции замены и 1 операция вставки; для преобразования `hello` в `algo` также требуется 3 шага: 2 замены и 1 удаление.
![Пример данных для задачи о расстоянии редактирования](edit_distance_problem.assets/edit_distance_example.png)
**Задачу о расстоянии редактирования можно очень естественно описать через модель дерева решений**. Строки соответствуют узлам дерева, а один раунд решения (одна операция редактирования) соответствует одному ребру дерева.
Как показано на рисунке ниже, если не ограничивать число операций, то каждый узел может порождать множество ребер, и каждое из них соответствует одному из вариантов преобразования. Это означает, что преобразовать `hello` в `algo` можно множеством разных путей.
С точки зрения дерева решений цель этой задачи - найти кратчайший путь между узлом `hello` и узлом `algo` .
![Представление задачи о расстоянии редактирования через дерево решений](edit_distance_problem.assets/edit_distance_decision_tree.png)
### Идея динамического программирования
**Шаг 1: продумать решения на каждом раунде, определить состояние и тем самым получить таблицу $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$ . Поэтому состояние определяется текущими позициями рассматриваемых символов в $s$ и $t$ , то есть состоянием $[i, j]$ .
Подзадача, соответствующая состоянию $[i, j]$ , такова: **минимальное число операций редактирования, необходимое для преобразования первых $i$ символов строки $s$ в первые $j$ символов строки $t$**.
Отсюда получается двумерная таблица $dp$ размера $(i+1) \times (j+1)$ .
**Шаг 2: найти оптимальную подструктуру и на ее основе вывести уравнение перехода состояния**
Рассмотрим подзадачу $dp[i, j]$ . Ее последние символы - это $s[i-1]$ и $t[j-1]$ . В зависимости от операции редактирования возможны три случая, показанные на рисунке ниже.
1. Вставить после $s[i-1]$ символ $t[j-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]$ .
![Переходы состояния в задаче о расстоянии редактирования](edit_distance_problem.assets/edit_distance_state_transfer.png)
Согласно этому анализу оптимальная подструктура такова: минимальное число шагов редактирования для $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]
$$
**Шаг 3: определить граничные условия и порядок переходов**
Когда обе строки пусты, число операций редактирования равно $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>"
![Процесс динамического программирования для расстояния редактирования](edit_distance_problem.assets/edit_distance_dp_step1.png)
=== "<2>"
![edit_distance_dp_step2](edit_distance_problem.assets/edit_distance_dp_step2.png)
=== "<3>"
![edit_distance_dp_step3](edit_distance_problem.assets/edit_distance_dp_step3.png)
=== "<4>"
![edit_distance_dp_step4](edit_distance_problem.assets/edit_distance_dp_step4.png)
=== "<5>"
![edit_distance_dp_step5](edit_distance_problem.assets/edit_distance_dp_step5.png)
=== "<6>"
![edit_distance_dp_step6](edit_distance_problem.assets/edit_distance_dp_step6.png)
=== "<7>"
![edit_distance_dp_step7](edit_distance_problem.assets/edit_distance_dp_step7.png)
=== "<8>"
![edit_distance_dp_step8](edit_distance_problem.assets/edit_distance_dp_step8.png)
=== "<9>"
![edit_distance_dp_step9](edit_distance_problem.assets/edit_distance_dp_step9.png)
=== "<10>"
![edit_distance_dp_step10](edit_distance_problem.assets/edit_distance_dp_step10.png)
=== "<11>"
![edit_distance_dp_step11](edit_distance_problem.assets/edit_distance_dp_step11.png)
=== "<12>"
![edit_distance_dp_step12](edit_distance_problem.assets/edit_distance_dp_step12.png)
=== "<13>"
![edit_distance_dp_step13](edit_distance_problem.assets/edit_distance_dp_step13.png)
=== "<14>"
![edit_distance_dp_step14](edit_distance_problem.assets/edit_distance_dp_step14.png)
=== "<15>"
![edit_distance_dp_step15](edit_distance_problem.assets/edit_distance_dp_step15.png)
### Оптимизация пространства
Поскольку $dp[i,j]$ зависит от значения сверху $dp[i-1, j]$ , слева $dp[i, j-1]$ и слева сверху $dp[i-1, j-1]$ , прямой обход после оптимизации памяти теряет значение слева сверху, а обратный обход не позволяет заранее построить значение слева $dp[i, j-1]$ . Значит, оба наивных варианта обхода здесь непригодны.
Чтобы решить эту проблему, можно использовать переменную `leftup` для временного сохранения значения слева сверху $dp[i-1, j-1]$ ; после этого остается учитывать только верхнее и левое значения. Тогда ситуация становится эквивалентной задаче о полном рюкзаке, и можно выполнять прямой обход. Код приведен ниже:
```src
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp_comp}
```
@@ -0,0 +1,9 @@
# Динамическое программирование
![Динамическое программирование](../assets/covers/chapter_dynamic_programming.jpg)
!!! abstract
Ручьи впадают в реки, а реки вливаются в море.
Динамическое программирование собирает решения малых задач в ответ на большую задачу и шаг за шагом ведет нас к ее решению.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,110 @@
# Первое знакомство с динамическим программированием
<u>Динамическое программирование (dynamic programming)</u> - это важная алгоритмическая парадигма, которая разбивает задачу на последовательность более мелких подзадач и за счет хранения их решений избегает повторных вычислений, тем самым резко повышая эффективность по времени.
В этом разделе мы начнем с классического примера: сначала запишем его грубое решение через backtracking, увидим в нем перекрывающиеся подзадачи, а затем постепенно выведем более эффективное решение на основе динамического программирования.
!!! question "Подъем по лестнице"
Дана лестница из $n$ ступеней. За один шаг можно подняться на $1$ или на $2$ ступени. Сколькими способами можно добраться до вершины?
Как показано на рисунке ниже, для лестницы из $3$ ступеней существует $3$ способа добраться до вершины.
![Число способов подняться на 3-ю ступень](intro_to_dynamic_programming.assets/climbing_stairs_example.png)
Цель этой задачи - вычислить количество способов. **Поэтому можно попробовать грубо перебрать все варианты с помощью backtracking**. Если представить подъем по лестнице как последовательность решений, то мы начинаем от земли и на каждом раунде выбираем прыжок на $1$ или на $2$ ступени; всякий раз, когда достигаем вершины, увеличиваем число способов на $1$ , а если перескакиваем вершину, обрезаем эту ветвь. Код выглядит так:
```src
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
```
## Метод 1: полный перебор
Backtracking обычно не раскладывает задачу явно на подзадачи; вместо этого он рассматривает решение как последовательность решений, используя попытки и обрезку для поиска всех возможных ответов.
Попробуем посмотреть на задачу именно как на разложение подзадач. Пусть число способов добраться до ступени $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]
$$
Это означает, что в задаче о подъеме по лестнице между подзадачами существует рекуррентная зависимость, и **решение исходной задачи может быть построено на основе решений подзадач**. Эта связь показана на рисунке ниже.
![Рекуррентная связь числа способов](intro_to_dynamic_programming.assets/climbing_stairs_state_transfer.png)
По рекуррентной формуле можно получить решение полного перебора. Начиная с $dp[n]$ , **мы рекурсивно разлагаем большую задачу в сумму двух меньших задач** , пока не дойдем до наименьших подзадач $dp[1]$ и $dp[2]$ . Их решения уже известны: $dp[1] = 1$ и $dp[2] = 2$ , что означает $1$ и $2$ способа подняться соответственно на $1$-ю и $2$-ю ступени.
Посмотрите на следующий код: как и стандартный backtracking, он относится к поиску в глубину, но выглядит более компактно:
```src
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
```
На рисунке ниже показано дерево рекурсии, возникающее при полном переборе. Для задачи $dp[n]$ глубина дерева рекурсии равна $n$ , а временная сложность равна $O(2^n)$ . Экспоненциальный рост взрывообразен: если подать на вход достаточно большое значение $n$ , ожидание станет очень долгим.
![Дерево рекурсии для подъема по лестнице](intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png)
Если посмотреть на рисунок выше, то видно, что **экспоненциальная временная сложность порождается "перекрывающимися подзадачами"**. Например, $dp[9]$ раскладывается в $dp[8]$ и $dp[7]$ , а $dp[8]$ - в $dp[7]$ и $dp[6]$ ; обе ветви содержат подзадачу $dp[7]$ .
Продолжая это рассуждение, мы видим, что подзадачи порождают все более мелкие перекрывающиеся подзадачи без конца. Подавляющая часть вычислительных ресурсов уходит именно на них.
## Метод 2: поиск с мемоизацией
Чтобы ускорить алгоритм, **мы хотим, чтобы каждая перекрывающаяся подзадача вычислялась только один раз**. Для этого объявим массив `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)$** . Это огромный скачок в эффективности.
![Дерево рекурсии для поиска с мемоизацией](intro_to_dynamic_programming.assets/climbing_stairs_dfs_memo_tree.png)
## Метод 3: динамическое программирование
**Поиск с мемоизацией - это метод "сверху вниз"** : мы начинаем с исходной задачи (корня), рекурсивно раскладываем более крупные подзадачи на меньшие, пока не достигнем наименьших подзадач с уже известным ответом (листьев). Затем в процессе возврата постепенно собираем решения подзадач и тем самым получаем решение исходной задачи.
Напротив, **динамическое программирование - это метод "снизу вверх"** : начиная с решений наименьших подзадач, мы итеративно строим решения для более крупных подзадач, пока не получим ответ на исходную задачу.
Поскольку в динамическом программировании нет этапа возврата, для его реализации достаточно обычных циклов, без рекурсии. В приведенном ниже коде мы инициализируем массив `dp` для хранения решений подзадач; он выполняет ту же роль, что и массив `mem` в мемоизированном поиске:
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
```
На рисунке ниже смоделирован процесс выполнения этого кода.
![Процесс динамического программирования для подъема по лестнице](intro_to_dynamic_programming.assets/climbing_stairs_dp.png)
Как и в backtracking, в динамическом программировании используется понятие "состояние" для обозначения некоторого этапа решения задачи; каждое состояние соответствует одной подзадаче и ее локально оптимальному решению. Например, в задаче о лестнице состояние определяется текущим номером ступени $i$ .
На основе сказанного можно подвести несколько часто используемых терминов динамического программирования.
- Массив `dp` называют <u>таблицей dp</u>, а $dp[i]$ обозначает решение подзадачи, соответствующей состоянию $i$ .
- Состояния, соответствующие наименьшим подзадачам (первая и вторая ступени), называют <u>начальными состояниями</u>.
- Рекуррентную формулу $dp[i] = dp[i-1] + dp[i-2]$ называют <u>уравнением перехода состояния</u>.
## Оптимизация пространства
Внимательный читатель мог заметить, что **поскольку $dp[i]$ зависит только от $dp[i-1]$ и $dp[i-2]$ , нам не нужен весь массив `dp` для хранения ответов всех подзадач** ; достаточно двух переменных, которые будут "перекатываться" вперед. Код имеет вид:
```src
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
```
Из кода видно, что после отказа от массива `dp` пространственная сложность уменьшается с $O(n)$ до $O(1)$ .
Во многих задачах динамического программирования текущее состояние зависит лишь от ограниченного числа предыдущих состояний. Тогда можно сохранять только действительно нужные состояния и за счет "уменьшения размерности" экономить память. **Этот прием оптимизации памяти называют "скользящими переменными" или "скользящим массивом"**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -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](knapsack_problem.assets/knapsack_example.png)
На задачу о рюкзаке 0-1 можно смотреть как на процесс из $n$ раундов решений: для каждого предмета есть два решения - не класть его в рюкзак или положить в рюкзак. Поэтому задача удовлетворяет модели дерева решений.
Цель задачи - найти "максимальную суммарную стоимость при ограниченной вместимости рюкзака", значит, с большой вероятностью это задача динамического программирования.
**Шаг 1: продумать решения на каждом раунде, определить состояние и тем самым получить таблицу $dp$**
Для каждого предмета возможны два случая: не класть его в рюкзак, тогда вместимость не меняется; или положить его в рюкзак, тогда оставшаяся вместимость уменьшается. Отсюда получается определение состояния: текущий номер предмета $i$ и текущая вместимость рюкзака $c$ , то есть состояние обозначается как $[i, c]$ .
Подзадача, соответствующая состоянию $[i, c]$ , такова: **максимальная стоимость, которую можно получить, используя первые $i$ предметов и рюкзак вместимости $c$**. Ее решение обозначается через $dp[i, c]$ .
Искомым значением является $dp[n, cap]$ , значит, нам нужна двумерная таблица $dp$ размера $(n+1) \times (cap+1)$ .
**Шаг 2: найти оптимальную подструктуру и на ее основе вывести уравнение перехода состояния**
После того как мы принимаем решение по предмету $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$ , то предмет можно только не брать.
**Шаг 3: определить граничные условия и порядок переходов**
Когда предметов нет или вместимость рюкзака равна $0$ , максимальная стоимость равна $0$ ; то есть весь первый столбец $dp[i, 0]$ и вся первая строка $dp[0, c]$ заполняются нулями.
Текущее состояние $[i, c]$ зависит от состояния сверху $[i-1, c]$ и состояния слева сверху $[i-1, c-wgt[i-1]]$ , поэтому достаточно двумя вложенными циклами пройти по всей таблице $dp$ в прямом порядке.
После этого анализа реализуем по порядку: полный перебор, поиск с мемоизацией и динамическое программирование.
### Метод 1: полный перебор
Код поиска содержит следующие элементы.
- **Параметры рекурсии**: состояние $[i, c]$ .
- **Возвращаемое значение**: решение подзадачи $dp[i, c]$ .
- **Условие завершения**: когда номер предмета выходит за границу, то есть $i = 0$ , или оставшаяся вместимость равна $0$ , рекурсия завершается и возвращается стоимость $0$ .
- **Обрезка**: если вес текущего предмета превышает оставшуюся вместимость рюкзака, то можно только не класть этот предмет.
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
```
Как показано на рисунке ниже, поскольку каждый предмет создает две ветви поиска - "не брать" и "брать", временная сложность равна $O(2^n)$ .
Посмотрев на дерево рекурсии, легко заметить наличие перекрывающихся подзадач, например $dp[1, 10]$ и подобных. Когда число предметов растет, вместимость рюкзака велика, а особенно когда много предметов с одинаковым весом, количество перекрывающихся подзадач быстро увеличивается.
![Дерево полного перебора для задачи о рюкзаке 0-1](knapsack_problem.assets/knapsack_dfs.png)
### Метод 2: поиск с мемоизацией
Чтобы каждая перекрывающаяся подзадача вычислялась только один раз, используем таблицу памяти `mem` для хранения решений подзадач, где `mem[i][c]` соответствует $dp[i, c]$ .
После введения мемоизации **временная сложность определяется числом подзадач** , то есть равна $O(n \times cap)$ . Код выглядит так:
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
```
На рисунке ниже показаны ветви поиска, которые были отсечены благодаря мемоизации.
![Дерево поиска с мемоизацией для задачи о рюкзаке 0-1](knapsack_problem.assets/knapsack_dfs_mem.png)
### Метод 3: динамическое программирование
По своей сути динамическое программирование здесь - это процесс последовательного заполнения таблицы $dp$ в соответствии с переходами состояний. Код приведен ниже:
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
```
Как показано на рисунке ниже, и временная сложность, и пространственная сложность определяются размером массива `dp` , то есть равны $O(n \times cap)$ .
=== "<1>"
![Процесс динамического программирования для задачи о рюкзаке 0-1](knapsack_problem.assets/knapsack_dp_step1.png)
=== "<2>"
![knapsack_dp_step2](knapsack_problem.assets/knapsack_dp_step2.png)
=== "<3>"
![knapsack_dp_step3](knapsack_problem.assets/knapsack_dp_step3.png)
=== "<4>"
![knapsack_dp_step4](knapsack_problem.assets/knapsack_dp_step4.png)
=== "<5>"
![knapsack_dp_step5](knapsack_problem.assets/knapsack_dp_step5.png)
=== "<6>"
![knapsack_dp_step6](knapsack_problem.assets/knapsack_dp_step6.png)
=== "<7>"
![knapsack_dp_step7](knapsack_problem.assets/knapsack_dp_step7.png)
=== "<8>"
![knapsack_dp_step8](knapsack_problem.assets/knapsack_dp_step8.png)
=== "<9>"
![knapsack_dp_step9](knapsack_problem.assets/knapsack_dp_step9.png)
=== "<10>"
![knapsack_dp_step10](knapsack_problem.assets/knapsack_dp_step10.png)
=== "<11>"
![knapsack_dp_step11](knapsack_problem.assets/knapsack_dp_step11.png)
=== "<12>"
![knapsack_dp_step12](knapsack_problem.assets/knapsack_dp_step12.png)
=== "<13>"
![knapsack_dp_step13](knapsack_problem.assets/knapsack_dp_step13.png)
=== "<14>"
![knapsack_dp_step14](knapsack_problem.assets/knapsack_dp_step14.png)
### Оптимизация пространства
Поскольку каждое состояние зависит только от состояния в предыдущей строке, можно использовать два массива, которые будут "перекатываться" вперед, и тем самым уменьшить пространственную сложность с $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>"
![Процесс динамического программирования после оптимизации памяти для рюкзака 0-1](knapsack_problem.assets/knapsack_dp_comp_step1.png)
=== "<2>"
![knapsack_dp_comp_step2](knapsack_problem.assets/knapsack_dp_comp_step2.png)
=== "<3>"
![knapsack_dp_comp_step3](knapsack_problem.assets/knapsack_dp_comp_step3.png)
=== "<4>"
![knapsack_dp_comp_step4](knapsack_problem.assets/knapsack_dp_comp_step4.png)
=== "<5>"
![knapsack_dp_comp_step5](knapsack_problem.assets/knapsack_dp_comp_step5.png)
=== "<6>"
![knapsack_dp_comp_step6](knapsack_problem.assets/knapsack_dp_comp_step6.png)
В коде для этого достаточно удалить первое измерение массива `dp` , а внутренний цикл заменить на обратный обход:
```src
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}
```
@@ -0,0 +1,25 @@
# Резюме
### Ключевые выводы
- Динамическое программирование раскладывает задачу на подзадачи и повышает вычислительную эффективность за счет хранения решений этих подзадач и устранения повторных вычислений.
- Если не учитывать затраты времени, то любую задачу динамического программирования можно решить с помощью backtracking (полного перебора), однако в дереве рекурсии возникает множество перекрывающихся подзадач, из-за чего эффективность крайне низка. После введения таблицы памяти можно хранить решения всех уже вычисленных подзадач и гарантировать, что каждая перекрывающаяся подзадача будет вычисляться только один раз.
- Поиск с мемоизацией - это рекурсивный метод "сверху вниз", а соответствующее ему динамическое программирование - это итеративный метод "снизу вверх", похожий на заполнение таблицы. Поскольку текущее состояние обычно зависит только от части локальных состояний, можно убрать одно измерение таблицы $dp$ и тем самым снизить пространственную сложность.
- Разложение на подзадачи - это общий алгоритмический подход, но в divide and conquer, динамическом программировании и backtracking он имеет разные свойства.
- Для задач динамического программирования характерны три главных свойства: перекрывающиеся подзадачи, оптимальная подструктура и отсутствие последствий.
- Если оптимальное решение исходной задачи можно построить из оптимальных решений подзадач, то задача обладает оптимальной подструктурой.
- Отсутствие последствий означает, что для данного состояния его дальнейшее развитие определяется только этим состоянием и не зависит от всех прошлых состояний. Многие задачи комбинаторной оптимизации этим свойством не обладают и потому не могут эффективно решаться с помощью динамического программирования.
**Задачи о рюкзаке**
- Задача о рюкзаке - один из самых типичных классов задач динамического программирования; она включает варианты 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]$ , то редактировать текущий символ не нужно.
- В задаче о расстоянии редактирования состояние зависит от значений сверху, слева и слева сверху. Поэтому после оптимизации памяти ни прямой, ни обратный обход сам по себе не дает корректного перехода состояния. Для решения этой проблемы значение слева сверху временно сохраняется в отдельной переменной, что делает ситуацию эквивалентной задаче о полном рюкзаке и позволяет использовать прямой обход.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,207 @@
# Задача о полном рюкзаке
В этом разделе сначала решим еще один распространенный вариант задачи о рюкзаке - полный рюкзак, а затем рассмотрим одну из его типичных специальных форм: задачу о размене монет.
## Задача о полном рюкзаке
!!! question
Даны $n$ предметов. Вес $i$-го предмета равен $wgt[i-1]$ , стоимость равна $val[i-1]$ . Также дан рюкзак вместимости $cap$ . **Каждый предмет можно выбирать многократно**. Найдите максимальную суммарную стоимость, которую можно поместить в рюкзак при заданной вместимости. Пример показан на рисунке ниже.
![Пример данных для задачи о полном рюкзаке](unbounded_knapsack_problem.assets/unbounded_knapsack_example.png)
### Идея динамического программирования
Задача о полном рюкзаке очень похожа на задачу о рюкзаке 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])
$$
### Реализация кода
Если сравнить код этой задачи с кодом задачи о рюкзаке 0-1, то окажется, что в переходе состояний меняется только одна деталь: вместо $i-1$ появляется $i$ ; все остальное остается таким же:
```src
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp}
```
### Оптимизация пространства
Поскольку текущее состояние переходит из состояния слева и состояния сверху, **после оптимизации памяти каждую строку таблицы $dp$ нужно обходить слева направо**.
Этот порядок обхода как раз противоположен задаче о рюкзаке 0-1. Разницу удобно понять по рисунку ниже.
=== "<1>"
![Процесс динамического программирования после оптимизации памяти для полного рюкзака](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step1.png)
=== "<2>"
![unbounded_knapsack_dp_comp_step2](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step2.png)
=== "<3>"
![unbounded_knapsack_dp_comp_step3](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step3.png)
=== "<4>"
![unbounded_knapsack_dp_comp_step4](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step4.png)
=== "<5>"
![unbounded_knapsack_dp_comp_step5](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step5.png)
=== "<6>"
![unbounded_knapsack_dp_comp_step6](unbounded_knapsack_problem.assets/unbounded_knapsack_dp_comp_step6.png)
Код реализации здесь довольно прост: достаточно просто убрать первое измерение массива `dp` :
```src
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp_comp}
```
## Задача о размене монет
Задача о рюкзаке представляет собой целый класс задач динамического программирования, у которого есть множество вариантов, и одной из таких вариаций является задача о размене монет.
!!! question
Даны $n$ видов монет, номинал монеты $i$ равен $coins[i - 1]$ , а целевая сумма равна $amt$ . **Монеты каждого вида можно брать многократно**. Требуется найти минимальное число монет, которыми можно набрать целевую сумму. Если набрать сумму невозможно, верните $-1$ . Пример показан на рисунке ниже.
![Пример данных для задачи о размене монет](unbounded_knapsack_problem.assets/coin_change_example.png)
### Идея динамического программирования
**Задачу о размене монет можно рассматривать как частный случай задачи о полном рюкзаке** ; между ними существует следующая связь и следующие различия.
- Эти две задачи можно взаимно переводить друг в друга: "предмет" соответствует "монете", "вес предмета" соответствует "номиналу монеты", а "вместимость рюкзака" соответствует "целевой сумме".
- Цель оптимизации противоположна: в задаче о полном рюкзаке нужно максимизировать стоимость предметов, а в задаче о размене монет - минимизировать число монет.
- В задаче о полном рюкзаке ищется решение, не превышающее вместимость, а в задаче о размене монет требуется **ровно** набрать целевую сумму.
**Шаг 1: продумать решения на каждом раунде, определить состояние и тем самым получить таблицу $dp$**
Подзадача, соответствующая состоянию $[i, a]$ , выглядит так: **минимальное число монет из первых $i$ видов, которыми можно набрать сумму $a$**. Решение этой подзадачи обозначается как $dp[i, a]$ .
Размер двумерной таблицы $dp$ равен $(n+1) \times (amt+1)$ .
**Шаг 2: найти оптимальную подструктуру и на ее основе вывести уравнение перехода состояния**
По сравнению с задачей о полном рюкзаке здесь есть два отличия в уравнении перехода состояния.
- Нужно искать минимум, а не максимум, поэтому оператор $\max()$ заменяется на $\min()$ .
- Оптимизируемое значение - это число монет, а не суммарная стоимость, поэтому при выборе монеты нужно просто прибавить $1$ .
$$
dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
$$
**Шаг 3: определить граничные условия и порядок переходов**
Когда целевая сумма равна $0$ , минимальное число монет для ее набора равно $0$ , то есть весь первый столбец $dp[i, 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>"
![Процесс динамического программирования для задачи о размене монет](unbounded_knapsack_problem.assets/coin_change_dp_step1.png)
=== "<2>"
![coin_change_dp_step2](unbounded_knapsack_problem.assets/coin_change_dp_step2.png)
=== "<3>"
![coin_change_dp_step3](unbounded_knapsack_problem.assets/coin_change_dp_step3.png)
=== "<4>"
![coin_change_dp_step4](unbounded_knapsack_problem.assets/coin_change_dp_step4.png)
=== "<5>"
![coin_change_dp_step5](unbounded_knapsack_problem.assets/coin_change_dp_step5.png)
=== "<6>"
![coin_change_dp_step6](unbounded_knapsack_problem.assets/coin_change_dp_step6.png)
=== "<7>"
![coin_change_dp_step7](unbounded_knapsack_problem.assets/coin_change_dp_step7.png)
=== "<8>"
![coin_change_dp_step8](unbounded_knapsack_problem.assets/coin_change_dp_step8.png)
=== "<9>"
![coin_change_dp_step9](unbounded_knapsack_problem.assets/coin_change_dp_step9.png)
=== "<10>"
![coin_change_dp_step10](unbounded_knapsack_problem.assets/coin_change_dp_step10.png)
=== "<11>"
![coin_change_dp_step11](unbounded_knapsack_problem.assets/coin_change_dp_step11.png)
=== "<12>"
![coin_change_dp_step12](unbounded_knapsack_problem.assets/coin_change_dp_step12.png)
=== "<13>"
![coin_change_dp_step13](unbounded_knapsack_problem.assets/coin_change_dp_step13.png)
=== "<14>"
![coin_change_dp_step14](unbounded_knapsack_problem.assets/coin_change_dp_step14.png)
=== "<15>"
![coin_change_dp_step15](unbounded_knapsack_problem.assets/coin_change_dp_step15.png)
### Оптимизация пространства
Оптимизация пространства для задачи о размене монет выполняется так же, как и для полного рюкзака:
```src
[file]{coin_change}-[class]{}-[func]{coin_change_dp_comp}
```
## Задача о размене монет II
!!! question
Даны $n$ видов монет, номинал монеты $i$ равен $coins[i - 1]$ , а целевая сумма равна $amt$ . Монеты каждого вида можно брать многократно. **Найдите число различных комбинаций монет, которыми можно набрать целевую сумму**. Пример показан на рисунке ниже.
![Пример данных для задачи о размене монет II](unbounded_knapsack_problem.assets/coin_change_ii_example.png)
### Идея динамического программирования
По сравнению с предыдущей задачей теперь целью является число комбинаций. Поэтому подзадача меняется на следующую: **число комбинаций из первых $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]$ нужно инициализировать единицами. Когда монет нет, невозможно набрать никакую сумму $>0$ , поэтому вся первая строка $dp[0, a]$ должна быть заполнена нулями.
### Реализация кода
```src
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp}
```
### Оптимизация пространства
При оптимизации памяти способ остается тем же самым: достаточно убрать измерение, отвечающее за виды монет:
```src
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp_comp}
```