1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9

# теория · шаг 1 из 9

Целый рюкзак: таблица, переход и восстановление набора

Коротко

Задача, на которой жадность сломалась в разделе 14, динамикой решается точно. Состояние - пара «сколько предметов рассмотрено» и «сколько места осталось»: общий ресурс внесён в состояние, и подзадачи стали независимыми. Переход на каждом предмете один из двух - не берём или берём: dp[i][c] = max(dp[i−1][c], цена[i] + dp[i−1][c − вес[i]]). Цена Θ(n·C), и буква C здесь вместимость, а не число предметов - это так называемая псевдополиномиальная оценка. Из таблицы читается не только число: обратным проходом восстанавливается сам НАБОР предметов.

Если ты можешь написать переход рюкзака по памяти и восстановить набор обратным проходом, листай до «Теперь сам».

Как думаешь, что мешало жадности

В разделе 14 на предметах (10, 60), (20, 100), (30, 120) и вместимости 50 жадность по удельной цене дала 160, а оптимум - 220. Причина была названа: взятый предмет меняет не только ЗАПАС места, но и ПРАВИЛА для остальных - после предмета весом 10 вместимость 40, и предметы 20 и 30 вместе уже не влезают.

Прежде чем читать дальше: как превратить эту помеху в параметр?

На что это похоже

Заполнение чемодана с линейкой в руках. Ты не пытаешься угадать весь набор сразу - ты идёшь по вещам по порядку и для каждой отвечаешь на один вопрос: «кладу или нет». Чтобы ответить, достаточно знать, сколько места осталось. Таблица рюкзака - это записанные ответы на этот вопрос для всех вещей и всех возможных остатков места.

Механизм: состояние, переход, таблица

СОСТОЯНИЕ
dp[i][c] = максимальная цена, которую можно набрать,
           рассматривая первые i предметов при вместимости c

состояний (n + 1) × (C + 1), переход O(1) ⇒ цена Θ(n·C)
ПЕРЕХОД: для i-го предмета всего два варианта

не берём:  dp[i−1][c]
берём:     cena[i] + dp[i−1][c − ves[i]]        только если ves[i] <= c

dp[i][c] = max(этих двух)

база: dp[0][c] = 0 - из нуля предметов не набрать ничего
i = 1
while i <= n:
    c = 0
    while c <= C:
        dp[i][c] = dp[i - 1][c]                        # не берём
        if ves[i - 1] <= c:
            s = cena[i - 1] + dp[i - 1][c - ves[i - 1]]
            if s > dp[i][c]:
                dp[i][c] = s                           # берём
        c = c + 1
    i = i + 1
ВОССТАНОВЛЕНИЕ НАБОРА (а не только числа)
идём от dp[n][C] назад:
    dp[i][c] == dp[i−1][c]  → i-й предмет НЕ брали, c не меняем
    иначе                   → брали: записываем его, c уменьшаем на вес

Правило

Целый рюкзак: состояние dp[i][c] - лучшая цена на первых i предметах при вместимости c; переход - максимум из «не берём» и «берём плюс ответ для меньшей вместимости»; база - нулевая строка. Цена Θ(n·C) - это ПСЕВДОПОЛИНОМ: оценка линейна по значению C, а не по длине его записи, поэтому при вместимости в миллиард таблица не помещается ни в память, ни во время. Общий ресурс (место) внесён в состояние - именно поэтому подзадачи независимы. Подчеркнём: оптимальная подструктура у рюкзака есть всегда, это свойство ЗАДАЧИ; жадности не хватало не её, а права передумать. Число лежит в dp[n][C], а сам набор восстанавливается обратным проходом: если dp[i][c] равно dp[i−1][c], предмет не брали.

Разбор: таблица на тех самых трёх предметах

Предметы: A (вес 10, цена 60), B (20, 100), C (30, 120), вместимость 50.

В строке таблицы на самом деле 51 клетка - вместимости от 0 до 50; ниже показаны только круглые столбцы, остальные повторяют соседа слева.

         c=0   c=10  c=20  c=30  c=40  c=50
i=0       0     0     0     0     0     0
i=1 (A)   0    60    60    60    60    60
i=2 (B)   0    60   100   160   160   160
i=3 (C)   0    60   100   160   180   220

Разберём две клетки руками.

dp[2][30]: предмет B (вес 20, цена 100)
    не берём → dp[1][30] = 60
    берём    → 100 + dp[1][10] = 100 + 60 = 160
    max = 160    ← это A + B

dp[3][50]: предмет C (вес 30, цена 120)
    не берём → dp[2][50] = 160
    берём    → 120 + dp[2][20] = 120 + 100 = 220
    max = 220    ← это B + C

Вот где видно, чего не хватало жадности: в клетке dp[3][50] алгоритм СРАВНИВАЕТ набор A + B (160) с набором B + C (220) и выбирает второй. Жадность начала с A - самого «выгодного по удельной цене» - и уже не могла передумать. Динамика рассматривает оба варианта на каждом предмете, и именно за это платит временем.

Теперь восстановим НАБОР:

старт: i = 3, c = 50
dp[3][50] = 220, dp[2][50] = 160 - не равны ⇒ предмет C взят
    c = 50 − 30 = 20, i = 2
dp[2][20] = 100, dp[1][20] = 60 - не равны ⇒ предмет B взят
    c = 20 − 20 = 0, i = 1
dp[1][0] = 0, dp[0][0] = 0 - равны ⇒ предмет A не брали

ответ: B и C, суммарный вес 50, цена 220

Это и есть вторая половина работы: заказчику обычно нужен не «220», а список того, что положить.

Разбор: почему Θ(n·C) - это не полином

Оценка Θ(n·C) выглядит как полином, но считает не то, что обычно: C - это ЗНАЧЕНИЕ вместимости, а длина входа - число цифр в этом значении.

вместимость      таблица (при n = 100)        влезает?
        50               5 000 клеток          мгновенно
    10 000             1 000 000              да
10 000 000         1 000 000 000              нет: это гигабайты и минуты

Вместимость 10 миллионов записывается восемью цифрами - вход крошечный, а таблица гигантская. Такие оценки называют ПСЕВДОПОЛИНОМИАЛЬНЫМИ, и целый рюкзак остаётся NP-трудной задачей: быстрого алгоритма, полиномиального по ДЛИНЕ входа, для него не известно.

Это стоит состыковать с критерием из второй главы. Там сказано: если общий ресурс вносится в состояние только как 2^n-маска, задача скорее всего NP-трудна. У рюкзака ресурс вносится обычным числом - и задача всё равно NP-трудна, потому что само это число может быть астрономическим. Значит критерий звучит аккуратнее: дешёвая динамика получается, когда состояний мало ОТНОСИТЕЛЬНО ДЛИНЫ ВХОДА, а не просто «ресурс выражается числом».

практический вывод
веса в граммах и бюджет в копейках - верный способ сделать задачу неподъёмной.
Если единицы можно огрубить (округлить до 100 грамм), таблица сжимается
во столько же раз - это стандартный приём и честный компромисс

Отдельно стоит знать, что у рюкзака есть и вторая форма таблицы - по ЦЕНЕ вместо веса (dp[i][v] = минимальный вес, которым набирается цена v). Она выгодна, когда цены маленькие, а вместимость огромная: цена становится Θ(n·V). Выбор между двумя формами - это выбор, какой из двух ресурсов внести в состояние.

Что ломается без этого

Первое: перепутать порядок циклов и писать dp[i][c] раньше, чем готова строка i−1. Второе, более коварное: свернуть таблицу в одну строку и обходить вместимость по ВОЗРАСТАНИЮ - тогда один предмет можно взять дважды, и задача молча превращается в другую (рюкзак с повторениями). Про это - отдельный урок главы про память. Третье: забыть проверку ves[i] <= c и прочитать клетку по отрицательному индексу: в Python это чтение С КОНЦА массива, то есть мусор без всякой ошибки. Четвёртое: восстанавливать набор, выбирая «где в строке максимум», вместо обратного прохода по равенству клеток: максимум в строке не говорит, какие предметы в него вошли. (Предметы нулевой цены обратный проход тоже не покажет - для него «взяли» и «не взяли» неразличимы; если такие предметы важны, рядом с таблицей значений держат таблицу решений.) Пятое: считать, что Θ(n·C) всегда приемлемо - вместимость в миллион уже даёт сотни миллионов клеток. Шестое: применять эту таблицу к ДРОБНОМУ рюкзаку: там достаточно жадности по удельной цене, и динамика - лишняя работа.

Зачем это в работе

Рюкзак - это шаблон, под который подходит огромный класс практических задач: выбрать набор работ в релиз при ограниченном времени, набор контейнеров на сервер при ограниченной памяти, набор рекламных позиций при ограниченном бюджете. Полезно узнавать его в условии: «выбрать подмножество», «ограничение на сумму одного ресурса», «максимизировать другую сумму» - это рюкзак, и дальше вопрос только в том, влезает ли n·C. Второе, что стоит унести, - про восстановление: в проде почти всегда нужен не оптимум, а решение, и таблица без обратного прохода отвечает лишь на половину вопроса. Третье - огрубление единиц. Фраза «давайте считать не в рублях, а в сотнях рублей» звучит как небрежность, а на самом деле это осознанное сжатие состояния, уменьшающее таблицу в сто раз при потере точности, которую обычно никто не замечает.

Теперь сам

Предметы: (вес 3, цена 4), (вес 4, цена 5), (вес 2, цена 3), вместимость 6. Заполни таблицу и восстанови набор.

Ответ.

         c=0  c=1  c=2  c=3  c=4  c=5  c=6
i=0       0    0    0    0    0    0    0
i=1 (3,4) 0    0    0    4    4    4    4
i=2 (4,5) 0    0    0    4    5    5    5
i=3 (2,3) 0    0    3    4    5    7    8

Разбор клетки dp[3][6]: не берём третий - dp[2][6] = 5; берём - 3 + dp[2][4] = 3 + 5 = 8. Максимум 8.

Восстановление:

i = 3, c = 6: dp[3][6] = 8 ≠ dp[2][6] = 5  ⇒ третий взят, c = 4
i = 2, c = 4: dp[2][4] = 5 ≠ dp[1][4] = 4  ⇒ второй взят, c = 0
i = 1, c = 0: dp[1][0] = 0 = dp[0][0]      ⇒ первый не брали

ответ: предметы (4,5) и (2,3) - вес 6, цена 8

Проверка перебором - выписываем все наборы, влезающие в 6, как «суммарный вес / суммарная цена»:

{}                   0 / 0
{(3,4)}              3 / 4
{(4,5)}              4 / 5
{(2,3)}              2 / 3
{(3,4), (2,3)}       5 / 7
{(4,5), (2,3)}       6 / 8      ← максимум
{(3,4), (4,5)}       7 - не влезает

Максимум 8, и набор тот же, что восстановила таблица.

Главное

Целый рюкзак: dp[i][c] - лучшая цена на первых i предметах при вместимости c; переход - максимум из «не берём» (dp[i−1][c]) и «берём» (цена + dp[i−1][c − вес]); база - нулевая строка; ответ в dp[n][C]. Общий ресурс внесён в состояние - отсюда и независимость подзадач, которой не хватало жадности: в клетке dp[3][50] алгоритм сравнивает A+B (160) с B+C (220) и выбирает второе, а жадность передумать не могла. Цена Θ(n·C) ПСЕВДОПОЛИНОМИАЛЬНА - линейна по значению вместимости, а не по длине её записи, поэтому вместимость в миллионы делает задачу неподъёмной; лечится огрублением единиц или второй формой таблицы (по цене, Θ(n·V)). Набор восстанавливают обратным проходом: если dp[i][c] == dp[i−1][c], предмет не брали.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.