13 KiB
Задача о рюкзаке 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. - Обрезка: если текущий вес предмета превышает оставшуюся вместимость рюкзака, можно выбрать только вариант не класть предмет в рюкзак.
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
Как показано на рисунке ниже, поскольку каждый предмет порождает две ветви поиска (не выбирать и выбирать), временная сложность составляет O(2^n).
Наблюдая за деревом рекурсии, легко заметить наличие перекрывающихся подзадач, например dp[1, 10] и других. Когда предметов много, вместимость рюкзака велика, особенно когда много предметов одинакового веса, количество перекрывающихся подзадач значительно увеличивается.
Второй метод: мемоизация поиска
Чтобы гарантировать, что перекрывающиеся подзадачи вычисляются только один раз, мы используем список мемоизации mem для записи решений подзадач, где mem[i][c] соответствует dp[i, c].
После введения мемоизации временная сложность зависит от количества подзадач, то есть O(n \times cap). Код реализации приведен ниже:
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
На рисунке ниже показаны ветви поиска, которые были обрезаны при мемоизации поиска.
Третий метод: динамическое программирование
Динамическое программирование по сути представляет собой процесс заполнения таблицы dp при переходе состояний. Код приведен ниже:
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
Как показано на рисунке ниже, временная сложность и пространственная сложность определяются размером массива dp, то есть O(n \times cap).
Оптимизация пространства
Поскольку каждое состояние зависит только от состояний предыдущей строки, мы можем использовать два массива для последовательного продвижения, снижая пространственную сложность с O(n^2) до O(n).
Подумаем дальше: можем ли мы реализовать оптимизацию пространства, используя только один массив? Как видно, каждое состояние переходит из ячейки прямо сверху или слева сверху. Предположим, что есть только один массив: когда начинается обход $i$-й строки, этот массив все еще хранит состояния строки i-1.
- Если использовать прямой порядок обхода, то при достижении
dp[i, j]значения слева сверхуdp[i-1, 1]~dp[i-1, j-1]могут быть уже перезаписаны, и в этом случае невозможно получить правильный результат перехода состояния. - Если использовать обратный порядок обхода, проблема перезаписи не возникнет, и переход состояния может выполняться корректно.
На рисунке ниже показан процесс перехода от строки i = 1 к строке i = 2 в одном массиве. Подумайте о разнице между прямым и обратным порядком обхода.
В реализации кода нам нужно только удалить первое измерение i массива dp и изменить внутренний цикл на обратный порядок обхода:
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}






















