DP: монеты, рюкзак и LIS

Три эталонные задачи DP, которые спрашивают чаще всего.

Состояние DP — описание подзадачи (например, «минимум монет на сумму k»), а переход — формула, связывающая его с меньшими подзадачами.

Размен монетами

Дан набор номиналов и сумма. Найти минимальное число монет, чтобы её набрать (или -1). Состояние: dp[s] — минимум монет на сумму s. Переход: пробуем каждую монету c и берём dp[s - c] + 1.

def coin_change(coins, amount):
    INF = amount + 1
    dp = [0] + [INF] * amount
    for s in range(1, amount + 1):
        for c in coins:
            if c <= s:
                dp[s] = min(dp[s], dp[s - c] + 1)
    return dp[amount] if dp[amount] != INF else -1

print(coin_change([1, 2, 5], 11))
print(coin_change([2], 3))
print(coin_change([1, 5, 10, 25], 30))

Вывод:

3
-1
2

Рюкзак 0/1

Есть предметы с весом и ценностью и рюкзак вместимости W. Каждый предмет берём целиком или не берём. Максимизировать ценность. Состояние: dp[w] — максимум ценности при вместимости w. Чтобы не использовать предмет дважды, внутренний цикл по весу идёт в обратном порядке.

def knapsack(weights, values, W):
    dp = [0] * (W + 1)
    for i in range(len(weights)):
        for w in range(W, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[W]

print(knapsack([1, 3, 4], [15, 20, 30], 4))
print(knapsack([2, 3, 4, 5], [3, 4, 5, 6], 5))

Вывод:

35
7

Наибольшая возрастающая подпоследовательность (LIS)

Найти длину самой длинной строго возрастающей подпоследовательности. Простой DP — O(n^2): dp[i] = длина LIS, заканчивающейся на i.

def length_of_lis(nums):
    if not nums:
        return 0
    dp = [1] * len(nums)
    for i in range(len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

print(length_of_lis([10, 9, 2, 5, 3, 7, 101, 18]))
print(length_of_lis([0, 1, 0, 3, 2, 3]))

Вывод:

4
4

Как работает под капотом

Везде сначала формулируем состояние, потом переход и базу. В рюкзаке 0/1 обратный порядок внутреннего цикла критичен: при прямом порядке dp[w - weight] уже учёл бы текущий предмет, и он попал бы в рюкзак несколько раз (это была бы задача о неограниченном рюкзаке). В размене монетами порядок прямой именно потому, что монеты можно брать многократно.

Частые ошибки

  • Перепутать направление цикла в рюкзаке 0/1 (обратный) и в размене монет (прямой).
  • Забыть инициализировать dp[0] базовым случаем (0 монет на сумму 0).
  • В LIS брать nums[j] <= nums[i] вместо строгого < — получите неубывающую, а не строго возрастающую.

Итог

  • Алгоритм DP: определи состояние, переход и базу.
  • Рюкзак 0/1 — обратный внутренний цикл (предмет один раз); монеты — прямой (многократно).
  • LIS за O(n^2) через dp[i] = длина возрастающей подпоследовательности до i.
Проверьте себя
1. Почему в рюкзаке 0/1 внутренний цикл по весу идёт в обратном порядке?
AДля скорости
BЧтобы каждый предмет не попал в рюкзак больше одного раза
CЧтобы сэкономить память
DЭто случайный выбор
2. Какова сложность простого DP-решения LIS?
AO(n)
BO(n log n)
CO(n^2)
DO(2^n)