Что такое задача о рюкзаке?
Задача о рюкзаке — это классическая задача динамического программирования.
Есть n предметов. У каждого предмета есть:
- weight[i] — вес предмета
- value[i] — ценность предмета
Есть рюкзак вместимостью W. Нужно выбрать некоторые предметы так, чтобы:
общая ценность была максимальной
Простой пример
Пусть вместимость рюкзака:
Предметы:
| Предмет | Вес | Ценность |
|---|---|---|
| 1 | 3 | 10 |
| 2 | 2 | 7 |
| 3 | 4 | 12 |
Лучший выбор:
вес = 3 + 2 = 5
ценность = 10 + 7 = 17
Ответ:
17
Главная идея
Для каждого предмета у нас есть два варианта:
- не брать предмет
- взять предмет, если хватает места
вариант без предмета,
вариант с предметом
)
Рекурсивный метод
Сначала удобно думать рекурсивно.
Пусть:
f(i, w)
Это максимальная ценность, которую можно получить, если мы рассматриваем первые i предметов и у нас осталось место w.
w — текущая вместимость рюкзака
Рекурсивный переход
Рассмотрим предмет i.
Вариант 1: не брать предмет i.
f(i - 1, w)
Вариант 2: взять предмет i. Это можно сделать только если его вес не больше текущей вместимости.
f(i - 1, w - weight[i]) + value[i]
Тогда формула:
f(i, w) = max(f(i - 1, w), f(i - 1, w - weight[i]) + value[i]);
Базовый случай
Если предметов нет, то ценность равна 0.
if (i == 0) {
return 0;
}
Еще если вместимость стала 0, больше ничего взять нельзя.
if (w == 0) {
return 0;
}
Обычная рекурсия
int solve(int i, int w) {
if (i == 0 || w == 0) {
return 0;
}
int ans = solve(i - 1, w);
if (weight[i] <= w) {
ans = max(ans, solve(i - 1, w - weight[i]) + value[i]);
}
return ans;
}
Этот код правильный по идее, но медленный. Он много раз пересчитывает одинаковые состояния.
Рекурсия + мемоизация
Чтобы не пересчитывать одинаковые состояния, сохраняем ответы:
dp[i][w]
Это ответ для состояния i, w.
Если ответ уже посчитан, возвращаем его:
if (dp[i][w] != -1) {
return dp[i][w];
}
Полная реализация: рекурсия + мемоизация
#include <bits/stdc++.h>
using namespace std;
const int N = 110;
const int M = 10010;
int n, W;
int weight[N];
int value[N];
int dp[N][M];
int solve(int i, int w) {
if (i == 0 || w == 0) {
return 0;
}
if (dp[i][w] != -1) {
return dp[i][w];
}
int ans = solve(i - 1, w);
if (weight[i] <= w) {
ans = max(ans, solve(i - 1, w - weight[i]) + value[i]);
}
dp[i][w] = ans;
return dp[i][w];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> W;
for (int i = 1; i <= n; i++) {
cin >> weight[i] >> value[i];
}
memset(dp, -1, sizeof(dp));
cout << solve(n, W);
return 0;
}
Как перевести в итеративный DP?
Было рекурсивно:
solve(i, w)
Станет:
dp[i][w]
Где:
если рассматриваем первые i предметов
и вместимость рюкзака равна w
Базовые значения
Если предметов 0, ответ 0:
dp[0][w] = 0;
Если вместимость 0, ответ 0:
dp[i][0] = 0;
Обычно глобальный массив уже заполнен нулями, поэтому отдельно можно не писать.
Итеративный переход
Для каждого предмета i и каждой вместимости w:
Не берем предмет:
dp[i][w] = dp[i - 1][w];
Если можем взять предмет:
dp[i][w] = max(dp[i][w], dp[i - 1][w - weight[i]] + value[i]);
Полная реализация 2D DP
#include <bits/stdc++.h>
using namespace std;
const int N = 110;
const int M = 10010;
int n, W;
int weight[N];
int value[N];
int dp[N][M];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> W;
for (int i = 1; i <= n; i++) {
cin >> weight[i] >> value[i];
}
for (int i = 1; i <= n; i++) {
for (int w = 0; w <= W; w++) {
dp[i][w] = dp[i - 1][w];
if (weight[i] <= w) {
dp[i][w] = max(dp[i][w], dp[i - 1][w - weight[i]] + value[i]);
}
}
}
cout << dp[n][W];
return 0;
}
Пример входных данных
Input
3 5
3 10
2 7
4 12
Output
17
Здесь:
предмет 2: вес 2, ценность 7
предмет 3: вес 4, ценность 12
W = 5
Лучший выбор — предмет 1 и предмет 2.
Как читать dp[i][w]?
Например:
dp[3][5]
Это означает:
вместимость рюкзака равна 5
какое максимальное value можно получить?
Ответ лежит в:
dp[n][W]
Оптимизация памяти
В формуле:
dp[i][w]
зависит только от предыдущей строки:
dp[i - 1][...]
Поэтому можно использовать одномерный массив:
dp[w]
Где dp[w] — лучший ответ для вместимости w.
Почему цикл идет справа налево?
Для 0/1 knapsack каждый предмет можно взять только один раз.
Поэтому при обновлении dp[w] нужно идти справа налево:
for (int w = W; w >= weight[i]; w--)
Если идти слева направо, один и тот же предмет может использоваться несколько раз.
Полная реализация 1D DP
#include <bits/stdc++.h>
using namespace std;
const int N = 10010;
int n, W;
int dp[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> W;
for (int i = 1; i <= n; i++) {
int weight, value;
cin >> weight >> value;
for (int w = W; w >= weight; w--) {
dp[w] = max(dp[w], dp[w - weight] + value);
}
}
cout << dp[W];
return 0;
}
2D DP vs 1D DP
| Метод | Память | Плюс | Минус |
|---|---|---|---|
| 2D DP | O(nW) | легко понять | больше памяти |
| 1D DP | O(W) | экономит память | важен порядок цикла |
Восстановление выбранных предметов
Иногда нужно вывести не только максимальную ценность, но и какие предметы были выбраны.
Для этого удобнее использовать 2D DP.
Идем назад:
если dp[i][w] == dp[i - 1][w], предмет i не взяли
иначе предмет i взяли
Код восстановления
vector<int> items;
int w = W;
for (int i = n; i >= 1; i--) {
if (dp[i][w] == dp[i - 1][w]) {
continue;
}
items.push_back(i);
w -= weight[i];
}
reverse(items.begin(), items.end());
Полная реализация с восстановлением
#include <bits/stdc++.h>
using namespace std;
const int N = 110;
const int M = 10010;
int n, W;
int weight[N];
int value[N];
int dp[N][M];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> W;
for (int i = 1; i <= n; i++) {
cin >> weight[i] >> value[i];
}
for (int i = 1; i <= n; i++) {
for (int w = 0; w <= W; w++) {
dp[i][w] = dp[i - 1][w];
if (weight[i] <= w) {
dp[i][w] = max(dp[i][w], dp[i - 1][w - weight[i]] + value[i]);
}
}
}
cout << "Max value: " << dp[n][W] << '\n';
vector<int> items;
int w = W;
for (int i = n; i >= 1; i--) {
if (dp[i][w] == dp[i - 1][w]) {
continue;
}
items.push_back(i);
w -= weight[i];
}
reverse(items.begin(), items.end());
cout << "Items: ";
for (int x : items) {
cout << x << ' ';
}
return 0;
}
Unbounded Knapsack
Есть другая версия рюкзака: каждый предмет можно брать сколько угодно раз.
Это называется:
полный рюкзак
Здесь цикл по весу идет слева направо:
for (int w = weight; w <= W; w++)
Полный рюкзак: реализация
#include <bits/stdc++.h>
using namespace std;
const int N = 10010;
int n, W;
int dp[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> W;
for (int i = 1; i <= n; i++) {
int weight, value;
cin >> weight >> value;
for (int w = weight; w <= W; w++) {
dp[w] = max(dp[w], dp[w - weight] + value);
}
}
cout << dp[W];
return 0;
}
0/1 vs полный рюкзак
| Тип | Сколько раз можно взять предмет? | Цикл по w |
|---|---|---|
| 0/1 Knapsack | 0 или 1 раз | от W вниз |
| Unbounded Knapsack | сколько угодно раз | от weight вверх |
Типичные ошибки
- Не понять, что означает dp[i][w]
- Перепутать вес и ценность
- В 0/1 knapsack идти циклом слева направо
- В полном рюкзаке идти циклом справа налево
- Забыть проверить weight[i] <= w
- Восстанавливать предметы по 1D DP, когда проще использовать 2D DP
- Поставить слишком маленький размер массива
Асимптотика
| Метод | Время | Память |
|---|---|---|
| Обычная рекурсия | O(2ⁿ) | O(n) |
| Мемоизация | O(nW) | O(nW) |
| 2D DP | O(nW) | O(nW) |
| 1D DP | O(nW) | O(W) |
Как думать на задачах?
2. Что является весом?
3. Что является ценностью?
4. Предмет можно брать один раз или много раз?
5. Нужно максимум, минимум или количество способов?
6. Какой размер W?
7. Можно ли использовать O(nW)?
Короткий шаблон 0/1 Knapsack
int dp[W + 1];
for (int i = 1; i <= n; i++) {
cin >> weight >> value;
for (int w = W; w >= weight; w--) {
dp[w] = max(dp[w], dp[w - weight] + value);
}
}
cout << dp[W];
Короткий шаблон полного рюкзака
int dp[W + 1];
for (int i = 1; i <= n; i++) {
cin >> weight >> value;
for (int w = weight; w <= W; w++) {
dp[w] = max(dp[w], dp[w - weight] + value);
}
}
cout << dp[W];