Files
hello-algo/ru/docs/chapter_dynamic_programming/knapsack_problem.md
2026-01-20 15:08:42 +08:00

13 KiB
Raw Blame History

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

Мы можем рассматривать задачу о рюкзаке 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.
  • Обрезка: если текущий вес предмета превышает оставшуюся вместимость рюкзака, можно выбрать только вариант не класть предмет в рюкзак.
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}

Как показано на рисунке ниже, поскольку каждый предмет порождает две ветви поиска (не выбирать и выбирать), временная сложность составляет O(2^n).

Наблюдая за деревом рекурсии, легко заметить наличие перекрывающихся подзадач, например dp[1, 10] и других. Когда предметов много, вместимость рюкзака велика, особенно когда много предметов одинакового веса, количество перекрывающихся подзадач значительно увеличивается.

Дерево рекурсии полного перебора для задачи о рюкзаке 0-1

Второй метод: мемоизация поиска

Чтобы гарантировать, что перекрывающиеся подзадачи вычисляются только один раз, мы используем список мемоизации mem для записи решений подзадач, где mem[i][c] соответствует dp[i, c].

После введения мемоизации временная сложность зависит от количества подзадач, то есть O(n \times cap). Код реализации приведен ниже:

[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}

На рисунке ниже показаны ветви поиска, которые были обрезаны при мемоизации поиска.

Дерево рекурсии мемоизации поиска для задачи о рюкзаке 0-1

Третий метод: динамическое программирование

Динамическое программирование по сути представляет собой процесс заполнения таблицы dp при переходе состояний. Код приведен ниже:

[file]{knapsack}-[class]{}-[func]{knapsack_dp}

Как показано на рисунке ниже, временная сложность и пространственная сложность определяются размером массива dp, то есть O(n \times cap).

=== "<1>" Процесс динамического программирования для задачи о рюкзаке 0-1

=== "<2>" knapsack_dp_step2

=== "<3>" knapsack_dp_step3

=== "<4>" knapsack_dp_step4

=== "<5>" knapsack_dp_step5

=== "<6>" knapsack_dp_step6

=== "<7>" knapsack_dp_step7

=== "<8>" knapsack_dp_step8

=== "<9>" knapsack_dp_step9

=== "<10>" knapsack_dp_step10

=== "<11>" knapsack_dp_step11

=== "<12>" knapsack_dp_step12

=== "<13>" knapsack_dp_step13

=== "<14>" knapsack_dp_step14

Оптимизация пространства

Поскольку каждое состояние зависит только от состояний предыдущей строки, мы можем использовать два массива для последовательного продвижения, снижая пространственную сложность с 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

=== "<2>" knapsack_dp_comp_step2

=== "<3>" knapsack_dp_comp_step3

=== "<4>" knapsack_dp_comp_step4

=== "<5>" knapsack_dp_comp_step5

=== "<6>" knapsack_dp_comp_step6

В реализации кода нам нужно только удалить первое измерение i массива dp и изменить внутренний цикл на обратный порядок обхода:

[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}