First version.

This commit is contained in:
krahets
2026-01-20 15:08:42 +08:00
parent 2213a59ff6
commit 8071daddaa
106 changed files with 11790 additions and 0 deletions

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