Динамическое программирование: рюкзак и другие задачи

Как понять, что задача решается через ДП, чем отличается «сверху вниз» от «снизу вверх», разбор задачи о рюкзаке и восстановление ответа.

Динамическое программирование применимо, когда задача разбивается на подзадачи, эти подзадачи перекрываются, и оптимальное решение целого строится из оптимальных решений частей. Если подзадачи не повторяются — это обычная рекурсия или «разделяй и властвуй».

Два способа записи

# сверху вниз — рекурсия + мемоизация
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

# снизу вверх — таблица
def fib_dp(n):
    dp = [0, 1]
    for i in range(2, n + 1):
        dp.append(dp[i - 1] + dp[i - 2])
    return dp[n]

Без мемоизации наивная рекурсия для Фибоначчи это O(2ⁿ), с ней — O(n). Это самый наглядный пример того, что даёт ДП.

Задача о рюкзаке 0/1

Есть n предметов с весами w и стоимостями c, рюкзак вместимостью W. Каждый предмет берём целиком или не берём. Максимизируем суммарную стоимость.

dp[i][j] = max( dp[i-1][j],  dp[i-1][j - w[i]] + c[i] ),  если j ≥ w[i]
def knapsack(w, c, W):
    n = len(w)
    dp = [[0] * (W + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(W + 1):
            dp[i][j] = dp[i - 1][j]
            if j >= w[i - 1]:
                dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i - 1]] + c[i - 1])
    return dp[n][W]

Восстановление набора предметов

def restore(dp, w, W):
    items, j = [], W
    for i in range(len(w), 0, -1):
        if dp[i][j] != dp[i - 1][j]:   # предмет i брали
            items.append(i - 1)
            j -= w[i - 1]
    return items[::-1]
На защите почти всегда просят не только максимум, но и какие именно предметы вошли. Восстановление по таблице — стандартный ход, готовьте его заранее.

Другие типовые задачи

ЗадачаСостояние dpСложность
Лестница (число способов)dp[i] — способов дойти до ступени iO(n)
Наибольшая общая подпоследовательностьdp[i][j] — НОП префиксов длины i и jO(n·m)
Наибольшая возрастающая подпоследовательностьdp[i] — длина НВП, оканчивающейся в iO(n²) или O(n log n)
Размен монетамиdp[s] — минимум монет на сумму sO(n·S)
Расстояние Левенштейнаdp[i][j] — правок для префиксовO(n·m)

Как оформлять в отчёте

  1. Сформулируйте, что именно означает dp[i][j] — одним предложением. Это половина оценки.
  2. Выпишите рекуррентное соотношение и базу.
  3. Укажите порядок обхода: почему считаем именно в такой последовательности.
  4. Оцените время и память, покажите оптимизацию памяти до одномерного массива, если она возможна.

Частые вопросы

Чем ДП отличается от жадного алгоритма?

Жадный делает локально лучший выбор и не пересматривает его — работает не всегда. ДП перебирает варианты, сохраняя результаты, и гарантирует оптимум там, где выполняется принцип оптимальности.

Как ужать память в рюкзаке?

Хранить только текущую и предыдущую строку либо один массив, обновляя его справа налево — получится O(W) вместо O(n·W). Но тогда сложнее восстановить сам набор.

Читайте также

Сделаем работу по этой теме

Опишите задачу — ответим в течение 15 минут в личных сообщениях ВКонтакте, назовём срок и цену. Предоплаты за оценку нет.

  • Оценка заявки бесплатно
  • Правки по замечаниям преподавателя
  • Работы по всем техническим и IT-дисциплинам

Нажимая кнопку, вы соглашаетесь на обработку указанных данных для ответа на заявку.