0Pricing
Coding Interview Prep · Урок

Размен монет и подъём по лестнице с минимальной стоимостью

Формулируйте рекуррентные соотношения для задач о размене монет и подъёме по лестнице с минимальной стоимостью, выбирайте направление DP и вручную прослеживайте заполнение таблицы

«Размен монет и подъём по лестнице с минимальной стоимостью» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.

Задача о размене монет

Размен монет (LeetCode № 322): Вам даны номиналы монет и целевая сумма. Найдите минимальное количество монет, необходимое для получения ровно этой суммы. Монет каждого номинала доступно неограниченное количество. Это классический вариант неограниченного рюкзака — каждый предмет, то есть монету, можно использовать любое количество раз. Это одна из важнейших задач на DP, поскольку она проверяет Ваше умение самостоятельно выводить рекуррентное соотношение.

# Problem examples:
# coins=[1,5,6,9], amount=11 -> 2 (5+6 or 2+9? no: 5+6=11 YES)
# coins=[2],       amount=3  -> -1 (impossible)
# coins=[1,2,5],   amount=11 -> 3 (5+5+1)
# coins=[186,419,83,408], amount=6249 -> 20

# Key choices:
# - Try each coin denomination at each step
# - Minimum coins = 1 + minimum(coins to make amount - coin)
# - If amount < 0: impossible
# - If amount = 0: done (0 coins)

print('Coin change: unbounded knapsack, find minimum count')

Размен монет: вывод рекуррентного соотношения

Определим dp[i] как минимальное количество монет для получения суммы i. Для каждой суммы i попробуем использовать каждую монету c: если i >= c, то dp[i] = min(dp[i], 1 + dp[i-c]). Число «1» означает монету, которую мы только что использовали; dp[i-c] — оптимальное решение для оставшейся суммы. Предполагается, что монет бесконечно много. Базовый случай: dp[0] = 0. Все остальные элементы изначально равны бесконечности, обозначая «пока недостижимо».

def coin_change(coins, amount):
    # dp[i] = min coins to make amount i
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0  # base: 0 coins for amount 0

    for i in range(1, amount + 1):
        for coin in coins:
            if i >= coin and dp[i - coin] != float('inf'):
                dp[i] = min(dp[i], 1 + dp[i - coin])

    return dp[amount] if dp[amount] != float('inf') else -1

print(coin_change([1, 5, 6, 9], 11))  # 2
print(coin_change([2], 3))             # -1
print(coin_change([1, 2, 5], 11))      # 3

# Trace dp for coins=[1,5] amount=6:
# dp[0]=0, dp[1]=1, dp[2]=2, dp[3]=3, dp[4]=4, dp[5]=1, dp[6]=2

Размен монет: почему жадный подход не работает

Жадный подход, при котором всегда выбирается самая крупная подходящая монета, не работает для задачи о размене монет. Пример: coins=[1, 3, 4], amount=6. Жадный подход выбирает 4, затем 1+1 — всего 3 монеты. Оптимальное решение — 3+3, то есть 2 монеты. Для стандартных номиналов (1, 5, 10, 25 центов) жадный подход работает, поскольку они случайно обладают свойством жадного выбора. Но для произвольных наборов монет требуется DP. Это классический вопрос на собеседованиях: утверждение, что жадный подход не работает, и объяснение причины демонстрируют сильное аналитическое мышление.

# Greedy failure example:
# coins=[1,3,4], amount=6
# Greedy: 4 (rem=2), 1 (rem=1), 1 (rem=0) -> 3 coins
# Optimal: 3 (rem=3), 3 (rem=0) -> 2 coins

def coin_change_greedy_wrong(coins, amount):
    coins_sorted = sorted(coins, reverse=True)
    count = 0
    for coin in coins_sorted:
        while amount >= coin:
            amount -= coin
            count += 1
    return count if amount == 0 else -1

print('Greedy:', coin_change_greedy_wrong([1,3,4], 6))  # 3 (WRONG)
print('DP:    ', coin_change([1,3,4], 6))               # 2 (CORRECT)

Размен монет II: подсчёт способов

Размен монет II (LeetCode № 518) — задача на подсчёт количества способов получить сумму, а не минимального количества монет. Рекуррентное соотношение меняется: вместо минимума используется сумма. Для каждой монеты выполняется dp[i] += dp[i-coin]. Порядок заполнения важен: чтобы посчитать каждую комбинацию ровно один раз, перебирайте монеты во внешнем цикле, а суммы — во внутреннем. Если поменять циклы местами, будут подсчитываться перестановки, а не комбинации, то есть решаться другая задача.

def coin_change_ii(coins, amount):
    # dp[i] = number of ways to make amount i
    dp = [0] * (amount + 1)
    dp[0] = 1  # one way to make amount 0: use no coins

    # Outer loop: coins -- ensures each coin type processed once
    for coin in coins:
        # Inner loop: amounts
        for i in range(coin, amount + 1):
            dp[i] += dp[i - coin]

    return dp[amount]

print(coin_change_ii([1, 2, 5], 5))   # 4: [1,1,1,1,1],[1,1,1,2],[1,2,2],[5]
print(coin_change_ii([2], 3))          # 0: impossible
print(coin_change_ii([10], 10))        # 1

# Key: coin outer, amount inner = COMBINATIONS (unordered)
# Reverse (amount outer, coin inner) = PERMUTATIONS (ordered)

Лестница с минимальной стоимостью: задача

Лестница с минимальной стоимостью подъёма (LeetCode № 746): дана лестница, у каждой ступеньки есть стоимость. За один раз можно подняться на 1 или 2 ступеньки. Найдите минимальную стоимость достижения вершины, находящейся на одну ступеньку выше последней. Начать со ступеньки 0 или 1 можно бесплатно. Эта задача элегантно объединяет рекуррентное соотношение для подъёма по лестнице с шаблоном минимизации стоимости из задачи о размене монет, становясь естественным переходом от одной задачи к другой.

# cost = [10, 15, 20]
# Pay cost[i] to leave step i
# You can step to i+1 or i+2
# Goal: reach top (index 3) with minimum cost

# Path options:
# Start at 0: cost 10, go to 2: cost 20, done -> 30
# Start at 1: cost 15, go to 3: done -> 15  <- OPTIMAL
# Start at 0: cost 10, go to 1: cost 15 -> 25

cost = [10, 15, 20]
# Optimal: start at step 1, pay 15, jump to top -> cost = 15
print('Expected:', 15)

Лестница с минимальной стоимостью: рекуррентное соотношение

Определим dp[i] как минимальную стоимость достижения ступеньки i. На ступеньку i можно прийти, заплатив cost[i-1] со ступеньки i-1 или cost[i-2] со ступеньки i-2. Поэтому dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]). Базовые случаи: dp[0] = 0 — начало перед лестницей, бесплатно; dp[1] = 0 — также можно бесплатно начать со ступеньки 1. Ответом будет dp[n], где n = len(cost).

def min_cost_climbing_stairs(cost):
    n = len(cost)
    # dp[i] = minimum cost to reach step i
    # Steps 0 to n; step n is the top (goal)
    dp = [0] * (n + 1)
    # dp[0] = 0 (free to start here)
    # dp[1] = 0 (free to start here)
    for i in range(2, n + 1):
        dp[i] = min(dp[i-1] + cost[i-1],   # step from i-1
                    dp[i-2] + cost[i-2])    # jump from i-2
    return dp[n]

print(min_cost_climbing_stairs([10, 15, 20]))      # 15
print(min_cost_climbing_stairs([1,100,1,1,1,100,1,1,100,1]))  # 6

Лестница с минимальной стоимостью: оптимизация памяти

Поскольку dp[i] зависит только от dp[i-1] и dp[i-2], занимаемую память можно уменьшить до O(1) с помощью двух переменных, как в задаче о числах Фибоначчи. Замените массив переменными prev2 и prev1. Обновляйте их на каждом шаге. Это стандартная оптимизация в одну строку, которую ожидают от Вас на собеседовании после представления решения с таблицей размера O(n). Всегда упоминайте её заранее: «Мы можем уменьшить занимаемую память до O(1), поскольку нам нужны только два последних значения».

def min_cost_optimised(cost):
    n = len(cost)
    prev2, prev1 = 0, 0  # dp[0] and dp[1]
    for i in range(2, n + 1):
        curr = min(prev1 + cost[i-1], prev2 + cost[i-2])
        prev2, prev1 = prev1, curr
    return prev1

print(min_cost_optimised([10, 15, 20]))  # 15
print(min_cost_optimised([1,100,1,1,1,100,1,1,100,1]))  # 6

# Alternative: directly use cost array as rolling storage
def min_cost_v2(cost):
    n = len(cost)
    for i in range(2, n):
        cost[i] += min(cost[i-1], cost[i-2])
    return min(cost[-1], cost[-2])

from copy import deepcopy
cost_test = [10,15,20]
print(min_cost_v2(deepcopy(cost_test)))  # 15

Альтернативная формулировка DP

У некоторых задач есть несколько корректных формулировок DP. Для лестницы с минимальной стоимостью можно определить dp[i] как минимальную стоимость операции LEAVE для ступеньки i: заплатить cost[i] и выбрать переход на i+1 или i+2. Тогда dp[i] = cost[i] + min(dp[i+1], dp[i+2]) при заполнении справа налево, а ответом будет min(dp[0], dp[1]). Обе формулировки корректны. Практикуйтесь объяснять, какую формулировку Вы выбрали и почему: это демонстрирует свободное владение DP.

def min_cost_alternative(cost):
    n = len(cost)
    # dp[i] = min cost when starting FROM step i
    # Fill right to left
    dp = cost[:] + [0]  # dp[n] = 0 (already at top)
    for i in range(n - 1, -1, -1):
        # Pay cost[i], then choose i+1 or i+2
        if i + 2 <= n:
            dp[i] = cost[i] + min(dp[i+1], dp[i+2])
        else:
            dp[i] = cost[i] + dp[i+1]
    # Can start at step 0 or step 1
    return min(dp[0], dp[1])

print(min_cost_alternative([10, 15, 20]))  # 15
print(min_cost_alternative([1,100,1,1,1,100,1,1,100,1]))  # 6

Связь между разменом монет и лестницей

Размен монет и лестница с минимальной стоимостью — примеры одного и того же шаблона DP: на каждом шаге нужно выбрать один вариант из конечного набора и оптимизировать целевую величину по последовательности выборов. Различия лишь внешние: в задаче о размене монет отслеживается количество, то есть за каждую монету прибавляется 1, а в задаче о лестнице — стоимость, то есть за каждый шаг прибавляется cost[i]. Распознавание общей структуры позволяет решать новые задачи на DP, сопоставляя их со знакомыми шаблонами.

# Shared pattern:
# dp[state] = optimise(dp[prev_state_1] + cost_1,
#                      dp[prev_state_2] + cost_2, ...)

# Coin change:  dp[amount] = min(1 + dp[amount - coin] for coin in coins)
# Min stair:    dp[step]   = min(cost[step-1]+dp[step-1], cost[step-2]+dp[step-2])
# Max path sum: dp[cell]   = max(dp[top], dp[left]) + grid[cell]
# House robber: dp[house]  = max(dp[house-1], dp[house-2] + value[house])

# All four are the SAME pattern with different:
# - State representation
# - Number of choices per state
# - Objective (min/max)
# - Transition cost
print('DP pattern: state + choices + objective + cost = template')

Минимальное количество полных квадратов

Полные квадраты (LeetCode № 279): требуется найти минимальное количество полных квадратов (1, 4, 9, 16, ...), сумма которых равна n. Это в точности задача о размене монет, где «монетами» являются числа — полные квадраты. Сначала сгенерируйте все полные квадраты до n, затем примените размен монет. DP даёт время O(n * sqrt(n)). Теорема Лагранжа о четырёх квадратах утверждает, что ответ не превышает 4, что также позволяет использовать математический подход за время O(sqrt(n)), но ожидаемым решением является DP.

import math

def num_squares(n):
    # Generate all perfect squares up to n
    squares = [i*i for i in range(1, int(math.sqrt(n)) + 1)]
    # Coin change with squares as 'coins'
    dp = [float('inf')] * (n + 1)
    dp[0] = 0
    for i in range(1, n + 1):
        for sq in squares:
            if i >= sq:
                dp[i] = min(dp[i], 1 + dp[i - sq])
    return dp[n]

print(num_squares(12))  # 3: 4+4+4
print(num_squares(13))  # 2: 4+9
print(num_squares(1))   # 1: 1

Отладка DP: распространённые ошибки

Распространённые ошибки в DP: неверный базовый случай (dp[0] задан неправильно), неверный порядок заполнения (обращение к значению, которое ещё не вычислено), ошибка на единицу в определении состояния (dp[i] — стоимость достижения i или стоимость, чтобы LEAVE i) и отсутствие возврата -1, когда остаётся бесконечность (невозможные случаи). Всегда проверяйте решение на простейших случаях — пустом вводе, одном элементе и target=0 — прежде чем переходить к большим входным данным.

# Common DP debugging checklist:
# 1. Base case: what is dp[0]? dp[1]? Are they correct?
# 2. State definition: write it in English before coding
# 3. Recurrence: trace manually on a 3-element example
# 4. Fill order: dependency arrows point left/up? Fill left/up first
# 5. Infinity check: return -1 or 0 when dp[target] == inf?
# 6. Array bounds: dp has size n+1 for 0..n, or n for 0..n-1?

# Quick test template:
def test_coin_change():
    assert coin_change([1], 0) == 0     # base case
    assert coin_change([1], 1) == 1     # single coin
    assert coin_change([2], 3) == -1    # impossible
    assert coin_change([1,5,6,9], 11) == 2
    print('All tests passed!')

test_coin_change()

Проверка знаний

Проверьте своё понимание концепций «Структуры данных & алгоритмы — подготовка к собеседованию по программированию» из этого урока.

Итоги урока

В этом уроке Вы изучили: DP для минимального количества монет в задаче о размене монет, являющейся задачей о неограниченном рюкзаке, и причины неудачи жадного подхода; размен монет II для подсчёта комбинаций с внешним циклом по монетам и внутренним циклом по суммам; а также лестницу с минимальной стоимостью с формулировками слева направо и справа налево. Далее Вы изучите шаблоны одномерного DP на примерах грабителя, алгоритма Кадане и разбиения строки на слова.

Часто задаваемые вопросы

Урок «Размен монет и подъём по лестнице с минимальной стоимостью» бесплатный?

Да — полный текст урока «Размен монет и подъём по лестнице с минимальной стоимостью» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Размен монет и подъём по лестнице с минимальной стоимостью»?

Формулируйте рекуррентные соотношения для задач о размене монет и подъёме по лестнице с минимальной стоимостью, выбирайте направление DP и вручную прослеживайте заполнение таблицы Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать Coding Interview Prep?

Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.

Сколько времени занимает урок «Размен монет и подъём по лестнице с минимальной стоимостью»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке Coding Interview Prep?

Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Распознавание DP: пересекающиеся подзадачи
  2. DP сверху вниз с мемоизацией
  3. DP снизу вверх с табуляцией
  4. Размен монет и подъём по лестнице с минимальной стоимостью
← Назад к Coding Interview Prep