Задача про рюкзак
Необхідні попередні знання: Вступ до динамічного програмування
Вступ
Розгляньмо такий приклад:
[USACO07 Dec] Charm Bracelet
Є різних предметів і рюкзак місткості . Кожен предмет має 2 атрибути: вагу () і цінність (). Потрібно вибрати підмножину предметів, які покласти в рюкзак так, щоб сумарна вага не перевищувала місткість , а сумарна цінність була максимальною.
У наведеному вище прикладі кожен предмет має лише два можливі стани (взятий або не взятий), що відповідає двійковим 0 і 1. Тому цей тип задачі називають «задачею про рюкзак 0-1».
- Чи потрібно вибрати підмножину предметів, що максимізує сумарну цінність за обмеженням на сумарну вагу (місткість )?
- Чи вага (та ваги предметів) — цілі й достатньо малі, щоб таблиця розміру вмістилася в пам'ять і час?
- Чи кожен предмет береться щонайбільше один раз (0-1)? (якщо предмети повторюються необмежено — це варіант «необмеженого рюкзака» з прямим, а не зворотним порядком за )
Рюкзак 0-1
Пояснення
У наведеному вище прикладі вхідними даними задачі є: вага -го предмета , цінність -го предмета і загальна місткість рюкзака .
Нехай — стан динамічного програмування, що зберігає максимальну сумарну цінність, яку може нести рюкзак місткості , коли ми розглядаємо лише перші предметів.
Припускаючи, що всі стани для перших предметів вже опрацьовано, які варіанти ми маємо для -го предмета?
- Коли його не кладемо в рюкзак, залишкова місткість не змінюється і сумарна цінність теж не змінюється. Тому максимальна цінність у цьому випадку дорівнює
- Коли його кладемо в рюкзак, залишкова місткість зменшується на , а сумарна цінність зростає на , тож максимальна цінність у цьому випадку дорівнює
Звідси ми можемо вивести рівняння переходу ДП:
Далі, оскільки залежить лише від , ми можемо позбутися першого виміру. Отримуємо правило переходу
яке має виконуватися у порядку спадання (щоб неявно відповідало , а не ).
Важливо зрозуміти це правило переходу, бо більшість переходів у задачах про рюкзак виводяться подібним чином.
Реалізація
Описаний алгоритм можна реалізувати за так:
- C++
- Python
- TypeScript
- Go
for (int i = 1; i <= n; i++)
for (int j = W; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);
for i in range(1, n + 1):
# Йдемо у зворотному порядку, щоб кожен предмет узяти не більше одного разу
for j in range(W, w[i] - 1, -1):
f[j] = max(f[j], f[j - w[i]] + v[i])
for (let i = 1; i <= n; i++)
// Йдемо у зворотному порядку, щоб кожен предмет узяти не більше одного разу
for (let j = W; j >= w[i]; j--)
f[j] = Math.max(f[j], f[j - w[i]] + v[i]);
for i := 1; i <= n; i++ {
// Йдемо у зворотному порядку, щоб кожен предмет узяти не більше одного разу
for j := W; j >= w[i]; j-- {
f[j] = max(f[j], f[j-w[i]]+v[i])
}
}
Знову ж таки, зверніть увагу на порядок виконання. Його слід суворо дотримуватися, щоб забезпечити такий інваріант: безпосередньо перед опрацюванням пари значення відповідає для , але для . Це гарантує, що береться з -го кроку, а не з -го.
Повний рюкзак
Модель повного рюкзака схожа на рюкзак 0-1, єдина відмінність від рюкзака 0-1 полягає в тому, що предмет можна вибрати необмежену кількість разів, а не лише один раз.
Ми можемо скористатися ідеєю рюкзака 0-1, щоб означити стан: — максимальна цінність, яку може отримати рюкзак, використовуючи перші предметів за максимальної місткості .
Слід зауважити, що хоча означення стану схоже на означення для рюкзака 0-1, його правило переходу відрізняється від правила для рюкзака 0-1.
Пояснення
Тривіальний підхід полягає в тому, щоб для перших предметів перебрати, скільки разів узяти кожен предмет. Часова складність цього — .
Це дає таке рівняння переходу:
Водночас воно спрощується до «плоского» рівняння:
Причина, чому це працює, полягає в тому, що вже було оновлено через і так далі.
Аналогічно до рюкзака 0-1, ми можемо позбутися першого виміру, щоб оптимізувати складність за пам'яттю. Це дає нам те саме правило переходу, що й для рюкзака 0-1.
Реалізація
Описаний алгоритм можна реалізувати за так:
- C++
- Python
- TypeScript
- Go
for (int i = 1; i <= n; i++)
for (int j = w[i]; j <= W; j++)
f[j] = max(f[j], f[j - w[i]] + v[i]);
for i in range(1, n + 1):
# Йдемо у прямому порядку, щоб дозволити брати предмет кілька разів
for j in range(w[i], W + 1):
f[j] = max(f[j], f[j - w[i]] + v[i])
for (let i = 1; i <= n; i++)
// Йдемо у прямому порядку, щоб дозволити брати предмет кілька разів
for (let j = w[i]; j <= W; j++)
f[j] = Math.max(f[j], f[j - w[i]] + v[i]);
for i := 1; i <= n; i++ {
// Йдемо у прямому порядку, щоб дозволити брати предмет кілька разів
for j := w[i]; j <= W; j++ {
f[j] = max(f[j], f[j-w[i]]+v[i])
}
}
Попри те, що правило переходу те саме, наведений вище код є неправильним для рюкзака 0-1.
Уважно придивившись до коду, ми бачимо, що для предмета , який зараз опрацьовуємо, і поточного стану , коли , на впливатиме . Це рівносильно можливості покласти предмет у рюкзак кілька разів, що відповідає задачі про повний рюкзак, а не задачі про рюкзак 0-1.
Кратний рюкзак
Кратний рюкзак — також варіант рюкзака 0-1. Головна відмінність у тому, що кожного предмета є штук, а не лише .
Пояснення
Дуже проста ідея така: «вибрати кожен предмет разів» рівносильно тому, що « однакових предметів вибираються по одному». Таким чином ми зводимо це до моделі рюкзака 0-1, яку можна описати функцією переходу:
Часова складність цього процесу —
Оптимізація бінарним групуванням
Ми так само розглядаємо зведення моделі кратного рюкзака до моделі рюкзака 0-1 для оптимізації. Часову складність не можна оптимізувати далі описаним вище підходом, тож зосередимося на складовій .
Нехай позначає -й предмет, виділений із -го предмета. У тривіальному підході, розглянутому вище, позначає той самий предмет для всіх . Головна причина нашої низької ефективності в тому, що ми робимо багато повторюваної роботи. Наприклад, розгляньмо вибір і вибір . Ці дві ситуації цілком рівносильні. Тому оптимізація способу виділення значно зменшить часову складність.
Групування роблять ефективнішим, застосовуючи бінарне групування.
А саме, містить окремих предметів (). Якщо не є цілим степенем , то для компенсації використовують ще один набір розміру .
За допомогою описаного способу виділення можна отримати будь-яку суму предметів, вибравши кілька . Після виділення кожного предмета описаним способом достатньо застосувати метод рюкзака 0-1, щоб розв'язати нове формулювання задачі.
Ця оптимізація дає нам часову складність .
Реалізація
- C++
- Python
- TypeScript
- Go
index = 0;
for (int i = 1; i <= n; i++) {
int c = 1, p, h, k;
cin >> p >> h >> k;
while (k > c) {
k -= c;
list[++index].w = c * p;
list[index].v = c * h;
c *= 2;
}
list[++index].w = p * k;
list[index].v = h * k;
}
items = [] # список згрупованих предметів (вага, цінність)
for i in range(n):
p, h, k = read_item() # вага, цінність, кількість штук
c = 1
while k > c:
k -= c
# Група з c копій предмета
items.append((c * p, c * h))
c *= 2
# Залишковий набір розміру k
items.append((p * k, h * k))
const items: [number, number][] = []; // список згрупованих предметів (вага, цінність)
for (let i = 0; i < n; i++) {
let [p, h, k] = readItem(); // вага, цінність, кількість штук
let c = 1;
while (k > c) {
k -= c;
// Група з c копій предмета
items.push([c * p, c * h]);
c *= 2;
}
// Залишковий набір розміру k
items.push([p * k, h * k]);
}
type item struct{ w, v int } // вага, цінність
items := []item{} // список згрупованих предметів
for i := 0; i < n; i++ {
p, h, k := readItem() // вага, цінність, кількість штук
c := 1
for k > c {
k -= c
// Група з c копій предмета
items = append(items, item{c * p, c * h})
c *= 2
}
// Залишковий набір розміру k
items = append(items, item{p * k, h * k})
}
Оптимізація монотонною чергою
У цій оптимізації ми прагнемо звести задачу про рюкзак до задачі про чергу з максимумом.
Для зручності опису покладемо . Тоді правило переходу можна записати так:
Далі покладемо . Тоді правило переходу можна виразити так:
Це перетворюється на класичну форму оптимізації монотонною чергою. можна обчислити за , тож для фіксованого ми можемо обчислити за час . Отже, складність знаходження всіх дорівнює . У такий спосіб загальна складність алгоритму зменшується до .
Змішаний рюкзак
Задача про змішаний рюкзак поєднує три описані вище задачі. Тобто деякі предмети можна взяти лише раз, деякі — необмежено, а деякі — не більше ніж разів.
Задача може видаватися складною, але доки ви розумієте основні ідеї попередніх задач про рюкзак і поєднуєте їх разом, ви впораєтеся. Псевдокод розв'язку такий:
- C++
- Python
- TypeScript
- Go
for (each item) {
if (0-1 knapsack)
Apply 0-1 knapsack code;
else if (complete knapsack)
Apply complete knapsack code;
else if (multiple knapsack)
Apply multiple knapsack code;
}
for item in items:
if item.kind == "0-1": # рюкзак 0-1
apply_01_knapsack(item)
elif item.kind == "complete": # повний рюкзак
apply_complete_knapsack(item)
elif item.kind == "multiple": # кратний рюкзак
apply_multiple_knapsack(item)
for (const item of items) {
if (item.kind === "0-1") // рюкзак 0-1
apply01Knapsack(item);
else if (item.kind === "complete") // повний рюкзак
applyCompleteKnapsack(item);
else if (item.kind === "multiple") // кратний рюкзак
applyMultipleKnapsack(item);
}
for _, item := range items {
switch item.kind {
case "0-1": // рюкзак 0-1
apply01Knapsack(item)
case "complete": // повний рюкзак
applyCompleteKnapsack(item)
case "multiple": // кратний рюкзак
applyMultipleKnapsack(item)
}
}
Задачі для практики
- Atcoder: Knapsack-1
- Atcoder: Knapsack-2
- LeetCode - 494. Target Sum
- LeetCode - 416. Partition Equal Subset Sum
- CSES: Book Shop II
- DMOJ: Knapsack-3
- DMOJ: Knapsack-4