動的計画法 ── 「同じ計算を二度しない」
扱う概念:重複部分問題、最適部分構造、メモ化(Top-Down)、テーブル法(Bottom-Up)、状態遷移式、空間最適化

この章で何ができるようになるか:DP 問題を見たとき「状態」と「遷移」を定義し、メモ化またはテーブル法で実装できるようになる。
DP が効くための2つの条件
1. 重複部分問題(Overlapping Subproblems)
→ 同じ部分問題を何度も解く
→ 例: fib(5) = fib(4) + fib(3)、fib(4) = fib(3) + fib(2)
fib(3) が2回計算される
2. 最適部分構造(Optimal Substructure)
→ 全体の最適解が、部分問題の最適解から構築できる
→ 例: 最短経路の一部は、やはり最短経路
フィボナッチ数列で理解する3つのアプローチ
再帰(素朴)— O(2^n)
def fib_naive(n: int) -> int:
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
# fib(40) で既に数秒かかる
# 呼び出しツリーが指数的に爆発
メモ化(Top-Down)— O(n)
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n: int) -> int:
if n <= 1:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
# O(n) 時間、O(n) 空間
テーブル法(Bottom-Up)— O(n)
def fib_table(n: int) -> int:
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
# O(n) 時間、O(n) 空間
空間最適化 — O(1)
def fib_optimized(n: int) -> int:
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
# O(n) 時間、O(1) 空間
# dp[i] が dp[i-1] と dp[i-2] にしか依存しないため
DP 問題の解き方フレームワーク
Step 1: 状態を定義する
dp[i] = 「i 番目の要素まで考えたときの最適解」
Step 2: 遷移式を書く
dp[i] = f(dp[j], ...) (j < i)
Step 3: 初期条件を設定する
dp[0] = ...
Step 4: 計算順序を決める
Bottom-Up: 小さい方から大きい方へ
Step 5: 答えを返す
dp[n] または max(dp)
典型パターン1:ナップサック問題
「重さ制限 W のバッグに、価値が最大になるようにアイテムを詰める」
def knapsack_01(weights: list[int], values: list[int], capacity: int) -> int:
"""
0-1 ナップサック: 各アイテムは1つだけ使える
状態: dp[i][w] = i番目のアイテムまで見て、残り容量wのときの最大価値
遷移: dp[i][w] = max(dp[i-1][w], # i番目を入れない
dp[i-1][w-weights[i]] + values[i]) # i番目を入れる
"""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
dp[i][w] = dp[i-1][w] # 入れない場合
if w >= weights[i-1]:
dp[i][w] = max(dp[i][w],
dp[i-1][w - weights[i-1]] + values[i-1])
return dp[n][capacity]
# 空間最適化: dp[i] は dp[i-1] にしか依存しない → 1次元に圧縮
def knapsack_01_optimized(weights, values, capacity):
dp = [0] * (capacity + 1)
for i in range(len(weights)):
for w in range(capacity, weights[i] - 1, -1): # 逆順(上書き防止)
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
典型パターン2:最長増加部分列(LIS)
def lis_dp(nums: list[int]) -> int:
"""
O(n²) 解法
状態: dp[i] = nums[i] で終わる LIS の長さ
遷移: dp[i] = max(dp[j] + 1) for all j < i where nums[j] < nums[i]
"""
n = len(nums)
dp = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
def lis_binary_search(nums: list[int]) -> int:
"""
O(n log n) 解法: 二分探索で「末尾が最小の LIS」を維持
"""
from bisect import bisect_left
tails = [] # tails[i] = 長さ i+1 の LIS の末尾の最小値
for num in nums:
pos = bisect_left(tails, num)
if pos == len(tails):
tails.append(num)
else:
tails[pos] = num
return len(tails)
# [10, 9, 2, 5, 3, 7, 101, 18]
# LIS = [2, 3, 7, 18] → 長さ 4
典型パターン3:コイン問題(完全ナップサック)
def coin_change(coins: list[int], amount: int) -> int:
"""
最小枚数で amount を作る
状態: dp[a] = 金額 a を作るための最小コイン数
遷移: dp[a] = min(dp[a - coin] + 1) for coin in coins
"""
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for coin in coins:
if a >= coin and dp[a - coin] != float('inf'):
dp[a] = min(dp[a], dp[a - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
典型パターン4:編集距離(Edit Distance)
def edit_distance(word1: str, word2: str) -> int:
"""
word1 を word2 に変換する最小操作数(挿入/削除/置換)
状態: dp[i][j] = word1[:i] を word2[:j] に変換する最小操作数
"""
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # 全削除
for j in range(n + 1):
dp[0][j] = j # 全挿入
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1] # 一致 → コストなし
else:
dp[i][j] = 1 + min(
dp[i-1][j], # 削除
dp[i][j-1], # 挿入
dp[i-1][j-1], # 置換
)
return dp[m][n]
Top-Down vs Bottom-Up の選択
| Top-Down(メモ化) | Bottom-Up(テーブル) | |
|---|---|---|
| 実装 | 再帰 + キャッシュ | ループ + 配列 |
| 思考の方向 | 自然(問題を分解) | 逆(小さい問題から積み上げ) |
| 不要な部分問題 | 計算しない(遅延) | 全て計算する |
| スタックオーバーフロー | あり得る(再帰深度) | なし |
| 空間最適化 | 難しい | しやすい |
| 定数倍 | やや遅い(再帰オーバーヘッド) | 速い |
まとめ
| パターン | 状態 | 遷移 | 計算量 |
|---|---|---|---|
| フィボナッチ型 | dp[i] | dp[i-1] + dp[i-2] | O(n) |
| ナップサック | dp[i][w] | 入れる/入れない | O(n × W) |
| LIS | dp[i] | max(dp[j]+1) | O(n²) / O(n log n) |
| コイン問題 | dp[amount] | min(dp[a-coin]+1) | O(n × amount) |
| 編集距離 | dp[i][j] | 挿入/削除/置換の min | O(m × n) |
| 最長共通部分列 | dp[i][j] | 一致/不一致 | O(m × n) |
DP のコツは「まず状態と遷移を日本語で書く」こと。コードは後。