Coin Changeと最小コストの階段
coin-changeとmin-cost-climbing-stairsの漸化式を定式化し、適切なDPの方向を選び、テーブルを手作業で追跡します。
「Coin Changeと最小コストの階段」はCoddyKit上の無料Coding Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはCoding Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Coding Interview Prepコースには全4レッスンが含まれています。
Coin Change:問題
Coin Change(LeetCode #322)では、コインの額面と目標金額が与えられます。ちょうどその金額を作るために必要なコインの最小枚数を求めます。各額面のコインは無制限に使用できます。これは典型的な無制限ナップサック問題の変種で、各アイテム(コイン)を何度でも使用できます。漸化式を一から定式化する力が試されるため、最も重要なDP問題の1つです。
# 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')Coin Change:漸化式の導出
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]=2Coin Change:貪欲法が失敗する理由
貪欲法(入る中で常に最も大きいコインを選ぶ)は、コインチェンジでは失敗します。例として、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)Coin Change II:方法の数え上げ
Coin Change II(LeetCode #518)では、金額を作る方法の数(最小枚数ではありません)を求めます。漸化式も変わり、min の代わりに sum を使います。各コインについてdp[i] += dp[i-coin]を計算します。埋める順序が重要です。各組み合わせを1回だけ数えるには、外側のループでコインを、内側のループで金額を反復します。ループを逆にすると、組み合わせではなく順列を数えることになります(別の問題です)。
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)最小コスト階段:問題
Min Cost Climbing Stairs(LeetCode #746)では、各段にコストが設定された階段が与えられます。一度に1段または2段上れます。最後の段の1つ先にある頂上まで到達するための最小コストを求めます。段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 には、段 i-1 から来て cost[i-1] を支払うか、段 i-2 から来て cost[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]だけに依存するため、Fibonacciと同様に2つの変数を使って空間をO(1)に削減できます。配列をprev2とprev1に置き換え、各ステップで更新します。これは、O(n)の表による解法を示した後に面接官が期待する、標準的な一行の最適化です。「直前の2つの値だけが必要なので、空間を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]を、段 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つを選び、その一連の選択に対する目的を最適化します。違いは表面的なものです。コインチェンジでは枚数を追跡し、コインごとに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')最小個数の完全平方数
Perfect Squares(LeetCode #279)では、nになるように和を取る完全平方数(1、4、9、16、...)の最小個数を求めます。これは「コイン」が完全平方数であるコインチェンジそのものです。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: 1DPのデバッグ:よくある間違い
DPでよくあるバグには、基本ケースの誤り(dp[0]を誤って設定する)、埋める順序の誤り(まだ計算されていない値にアクセスする)、状態定義における境界のずれ(dp[i]が i に到達するコストなのか、i から離れるコストなのか)、そして無限大が残っている場合に-1を返さないこと(不可能なケース)があります。大きな入力を試す前に、必ず最も単純なケース(空の入力、要素が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()理解度チェック
このレッスンで学んだData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認しましょう。
レッスンのまとめ
このレッスンでは、コインチェンジの最小枚数DP(無制限ナップサック)と貪欲法が失敗する理由、コインを外側・金額を内側の順序で組み合わせを数えるCoin Change II、さらに左から右と右から左の両方の定式化による最小コスト階段を学びました。次は、House Robber、Kadane's algorithm、word breakを使って1D DPのパターンを探ります。
よくある質問
「Coin Changeと最小コストの階段」レッスンは無料ですか?
はい。「Coin Changeと最小コストの階段」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Coding Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Coding Interview Prepコースには全4レッスンが含まれています。
「Coin Changeと最小コストの階段」で何を学びますか?
coin-changeとmin-cost-climbing-stairsの漸化式を定式化し、適切なDPの方向を選び、テーブルを手作業で追跡します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
Coding Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのCoding Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。
「Coin Changeと最小コストの階段」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このCoding Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのCoding Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- DPを見抜く:重複する部分問題
- メモ化によるトップダウンDP
- 表形式によるボトムアップDP
- Coin Changeと最小コストの階段