コーディング · アルゴリズム
動的計画法(DP)とは?仕組み・コード・例題をわかりやすく解説
結論
動的計画法(DP)は、問題を小さな部分問題に分け、各部分問題を1回だけ解き、その結果を保存して再利用することで問題を解く手法です。最適部分構造(最良の答えが部分問題の最良の答えから作られる)と、重複する部分問題(同じ部分問題が何度も現れる)をもつ問題で有効です。DPは、メモ化を使ったトップダウンでも、テーブルを使ったボトムアップでも書けます。どちらも、指数時間かかる多くの全探索の解法を多項式時間にします。
動的計画法が解く問題
多くの問題は、最良、最安、または場合の数を求めます。ある金額を作る最少のコイン枚数、2つの文字列の最長共通部分列、袋に入る最も価値の高い品物の組、グリッドを通る経路の数、などです。素朴な再帰による解法は、同じ小さなケースを指数的な回数も再計算することがよくあります。DPはその繰り返しをなくします。
名前の由来は、1950年代にこの手法を開発したリチャード・ベルマン(Richard Bellman)です。ここでの「プログラミング」は、計画を立てること、または表を埋めることを指し、コードを書くことではありません。
考え方を言葉で説明すると
素朴な再帰によるフィボナッチ数の計算が、問題点をよく表しています。fib(5) は fib(4) と fib(3) を呼び、fib(4) はまた fib(3) を呼び、と続いて、呼び出しの回数が指数的に増えます。各結果を初めて計算したときに保存しておけば、以降の呼び出しは即座に終わります。
- 状態を定義します。部分問題はどのような形をしているか。たとえば、
best[a]= 金額 a を作る最少のコイン枚数。 - 漸化式を書きます。状態は、より小さな状態からどう計算されるか。
best[a] = 1 + min(best[a - c] for each coin c)。 - 基底ケースを決めます。
best[0] = 0。 - 順序を決めます。メモ化によるトップダウンか、小さな状態が必ず先に埋まるボトムアップか。
- 適切な状態から答えを読み取り、必要なら実際の選択を復元します。
| 方式 | 仕組み | 長所 | 短所 |
|---|---|---|---|
| トップダウン(メモ化) | 再帰関数と結果のキャッシュ。 | 漸化式にいちばん近く、必要な状態だけを計算します。 | 再帰の深さの制限と、呼び出しのオーバーヘッド。 |
| ボトムアップ(テーブル法) | ループで、最も小さな状態から順に表を埋めます。 | 再帰が不要で、メモリを減らしやすい。 | 有効な埋める順序を見つける必要があり、使わない状態も計算することがあります。 |
トップダウン:functoolsによるメモ化
Pythonでは、functools.cache(Python 3.9以降)または functools.lru_cache で、1行で関数にメモ化を追加できます。
from functools import cache
@cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(80)) # 23416728348467685デコレータがなければ、fib(80) は 10¹⁶ 回を超える再帰呼び出しを行います。デコレータがあれば、0 から 80 までの各値は1回だけ計算されます。
例題:ボトムアップによるコイン問題
コインは 1、3、4、金額は 6 です。貪欲法ではまず 4 を取り、4 + 1 + 1 で3枚必要ですが、3 + 3 なら2枚で済みます。DPは、a = 0 から 6 まで best[a] を埋めることで、真の最小値を見つけます。
| 金額 a | 選択肢:1 + best[a − c] | best[a] |
|---|---|---|
| 0 | 基底ケース | 0 |
| 1 | c=1: 1 + best[0] = 1 | 1 |
| 2 | c=1: 1 + best[1] = 2 | 2 |
| 3 | c=1: 3, c=3: 1 + best[0] = 1 | 1 |
| 4 | c=1: 2, c=3: 2, c=4: 1 | 1 |
| 5 | c=1: 2, c=3: 3, c=4: 2 | 2 |
| 6 | c=1: 3, c=3: 1 + best[3] = 2, c=4: 1 + best[2] = 3 | 2 |
def min_coins(coins, amount):
INF = float("inf")
best = [0] + [INF] * amount
for a in range(1, amount + 1):
for c in coins:
if c <= a and best[a - c] + 1 < best[a]:
best[a] = best[a - c] + 1
return best[amount] if best[amount] != INF else -1
print(min_coins([1, 3, 4], 6)) # 2
print(min_coins([2], 3)) # -1二次元の例:最長共通部分列
2つの系列に対する多くのDP問題は、グリッドを使います。dp[i][j] は、一方の文字列の最初の i 文字と、もう一方の最初の j 文字に対する答えです。最長共通部分列(LCS)では、文字が一致すれば対角線方向に延ばし、そうでなければどちらかの文字列から1文字を除いた場合のうち良いほうを取ります。
def lcs(a, b):
dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[len(a)][len(b)]
print(lcs("ABCBDAB", "BDCABA")) # 4長さ4のLCSの1つは BCBA です。同じ表の構造で編集距離も解けます。diffツールが2つの文字列がどれだけ異なるかを測るのは、この方法です。
時間計算量と空間計算量
一般的な規則は、時間 = 状態の数 × 状態ごとの作業量、空間 = 保存する状態の数、です。
| 問題 | 状態数 | 時間 | 空間(削減後) |
|---|---|---|---|
| フィボナッチ数 | n | O(n) | O(n)。2つの変数を使えば O(1) |
| コイン問題(最少枚数) | 金額 | O(amount × coins) | O(amount) |
| LCS/編集距離 | n × m | O(n × m) | O(n × m)。長さだけが必要なら O(min(n, m)) |
| 0/1ナップサック問題 | 品物の数 × 容量 | O(n × W) | 1行を使い、容量を降順に走査すれば O(W) |
ナップサック問題の O(n × W) は擬多項式時間です。入力のビット数ではなく数値 W そのものに依存するため、W が非常に大きいと遅くなります。
よくある間違いとDPを使う場面
- 状態の誤り:漸化式が、状態に含まれていない情報(たとえば、どの品物がすでに使われたか)を必要とする場合、答えが間違います。その情報を状態に加えてください。
- 埋める順序の誤り:ボトムアップのループは、状態が読み取られる前に、その状態を必ず計算していなければなりません。
- テーブルサイズの1つのずれ:長さ n の文字列では、空の接頭辞を含めるために n + 1 行が必要です。
- 再帰が深すぎる:10,000個の状態に対するメモ化再帰は、Pythonの再帰上限を超えることがあります。ボトムアップに切り替えてください。
- ハッシュできない引数:
functools.cacheにはハッシュ可能な引数が必要なので、リストの代わりにタプルを渡してください。
面接や実務での典型的な用途:階段の上り方、House Robber、最長増加部分列、単語分割、グリッドの経路数、編集距離、ナップサック問題、連鎖行列積。実務では、バイオインフォマティクスの配列アラインメント、diffツール、スペルチェッカー、音声認識(ビタビアルゴリズム)、ベルマン–フォード法などの最短経路があります。
よくある質問
メモ化とテーブル法の違いは何ですか?
メモ化はトップダウンで、再帰関数が計算しながら結果をキャッシュします。テーブル法はボトムアップで、ループが基底ケースから順に表を埋めます。ほとんどの問題で、どちらも計算量は同じです。
問題に動的計画法が必要かどうかは、どうやって判断しますか?
最良、最安、または場合の数を問う問題で、同じ部分問題を繰り返す全探索の再帰がある場合です。状態と、状態間の漸化式を定義できれば、たいていDPが使えます。
動的計画法は再帰と同じですか?
いいえ。再帰はコードの書き方で、DPは各部分問題を1回だけ解いて結果を再利用するという考え方です。DPの解法は、メモ化を使った再帰でも、テーブルを使った反復でも書けます。
動的計画法と貪欲法の違いは何ですか?
貪欲法は、各ステップで局所的に最良の選択を1つ行い、後から見直しません。DPは、各部分問題で関連するすべての選択を考えて最良のものを残すため、貪欲法が失敗するケースでも正しい答えが得られます。
動的計画法と分割統治法の違いは何ですか?
どちらも問題を部分問題に分けます。分割統治法は独立した部分問題に使い、DPは重複する部分問題に使います。結果を保存することで作業を節約できるためです。