# Задача о рюкзаке 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} ```