Engineer Quiz

← 記事一覧

動的計画法とグリーディ法の使い分け - DP・貪欲法・グラフ探索

動的計画法とグリーディ法の使い分け - DP・貪欲法・グラフ探索

📚 概要

コーディングテストの中級~上級問題では、動的計画法(DP)グリーディ法(貪欲法)グラフ探索が頻出します。これらは一見似ているように見えますが、適用条件が異なり、誤った手法を選ぶと正解にたどり着けません。

この記事では、DP とグリーディの本質的な違い、代表的な DP パターン(ナップサック、コイン問題、編集距離、最長増加部分列)、グラフ探索アルゴリズム(BFS/DFS、ダイクストラ、トポロジカルソート)、そしてバックトラッキングまでを体系的に解説します。

🕰️ 歴史的背景

動的計画法の誕生

動的計画法は 1950年代 に数学者 Richard Bellman によって命名されました。「Dynamic Programming」という名前は、当時の国防省で「プログラミング」が良い印象を持たれていたため、研究に予算をつけやすくする意図もあったと Bellman 本人が述べています。

グリーディ法の形式化

グリーディアルゴリズムの理論的基盤は、1971年Jack Edmonds のマトロイド理論によって確立されました。「局所的に最適な選択を繰り返せば大域的に最適になる」という条件が数学的に明確化されました。

グラフアルゴリズムの発展

  • 1956年: ダイクストラ法(Edsger Dijkstra)
  • 1958年: Bellman-Ford 法
  • 1962年: Floyd-Warshall 法
  • 1972年: Tarjan の強連結成分分解
  • 現在: これらのアルゴリズムは経路探索、ネットワーク最適化、SNS 分析など広範に応用

🔧 技術解説

1. 動的計画法(DP)

DP が適用できる2つの条件

graph TB
    A["問題に DP を適用できるか?"] --> B{"最適部分構造?"}
    B -->|No| C["DP は使えない"]
    B -->|Yes| D{"部分問題の重なり?"}
    D -->|No| E["分割統治法
(マージソートなど)"] D -->|Yes| F["DP が有効!"] style C fill:#ff6b6b style E fill:#ffa94d style F fill:#51cf66
  1. 最適部分構造(Optimal Substructure): 問題全体の最適解が、部分問題の最適解から構成できる
  2. 部分問題の重なり(Overlapping Subproblems): 同じ部分問題が何度も繰り返し登場する

DP の2つのアプローチ

アプローチ 方向 実装方法 特徴
トップダウン(メモ化再帰) 大→小 再帰 + メモ化テーブル 直感的、必要な部分だけ計算
ボトムアップ(漸化式) 小→大 ループ + テーブル 再帰のオーバーヘッドなし

パターン1: フィボナッチ数列(DP 入門)

// 素朴な再帰: O(2^n) — 指数時間で非常に遅い
function fib_naive(n):
    if n <= 1: return n
    return fib_naive(n - 1) + fib_naive(n - 2)
graph TB
    A["fib(5)"] --> B["fib(4)"]
    A --> C["fib(3)"]
    B --> D["fib(3)"]
    B --> E["fib(2)"]
    C --> F["fib(2)"]
    C --> G["fib(1)"]
    D --> H["fib(2)"]
    D --> I["fib(1)"]

    style D fill:#ff6b6b
    style F fill:#ff6b6b
    style H fill:#ff6b6b

fib(3) が2回、fib(2) が3回計算されている(部分問題の重なり)。

// トップダウン(メモ化再帰): O(n)
function fib_memo(n, memo={}):
    if n in memo: return memo[n]
    if n <= 1: return n
    memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
    return memo[n]

// ボトムアップ(漸化式): O(n) 時間, O(n) 空間
function fib_dp(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(1) 空間
function fib_optimized(n):
    if n <= 1: return n
    prev2 = 0
    prev1 = 1
    for i in range(2, n + 1):
        curr = prev1 + prev2
        prev2 = prev1
        prev1 = curr
    return prev1

パターン2: ナップサック問題

重さと価値を持つ N 個のアイテムから、重さ制限 W 以内で価値を最大化する。

0-1 ナップサック(各アイテムは1回だけ使用可能)

// dp[i][w] = 最初の i 個のアイテムで、重さ上限 w の場合の最大価値
function knapsack_01(weights, values, W):
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(W + 1):
            // アイテム i を入れない場合
            dp[i][w] = dp[i - 1][w]
            // アイテム i を入れる場合
            if weights[i - 1] <= w:
                dp[i][w] = max(dp[i][w],
                    dp[i - 1][w - weights[i - 1]] + values[i - 1])

    return dp[n][W]
graph TB
    A["アイテム: (重さ, 価値)
A(2, 3), B(3, 4), C(4, 5), D(5, 6)
重さ上限: 7"] --> B["dp テーブルを埋める"] B --> C["dp[4][7] = 9
アイテム A(3) + D(6) = 9"] style C fill:#51cf66

無制限ナップサック(各アイテムを何回でも使用可能)

// dp[w] = 重さ上限 w の場合の最大価値
function knapsack_unbounded(weights, values, W):
    dp = [0] * (W + 1)

    for w in range(1, W + 1):
        for i in range(len(weights)):
            if weights[i] <= w:
                dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[W]
ナップサック 遷移元 遷移式 計算量
0-1 dp[i-1][w] dp[i][w] = max(dp[i-1][w], dp[i-1][w-wi] + vi) O(nW)
無制限 dp[w] dp[w] = max(dp[w-wi] + vi) for all i O(nW)

パターン3: コイン問題

与えられた種類のコインで金額 amount を作るための最小枚数を求める。

// dp[i] = 金額 i を作るための最小コイン枚数
function coin_change(coins, amount):
    dp = [infinity] * (amount + 1)
    dp[0] = 0

    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i and dp[i - coin] + 1 < dp[i]:
                dp[i] = dp[i - coin] + 1

    return dp[amount] if dp[amount] != infinity else -1

// 例: coins = [1, 5, 10], amount = 13
// dp[0]=0, dp[1]=1, ..., dp[5]=1, ..., dp[10]=1, dp[11]=2, dp[12]=3, dp[13]=3
// 答え: 3 (10 + 1 + 1 + 1 ではなく 10 + 1 + 1 + 1 = 3枚)
// 修正: 答え: 3枚 (5 + 5 + 1 + 1 + 1 = 5枚ではなく 10 + 1 + 1 + 1 = 4枚でもなく)
// 正解: coins=[1,5,10], amount=13 → 10+1+1+1 = 4枚

パターン4: 編集距離(Levenshtein Distance)

2つの文字列を一致させるために必要な操作(挿入、削除、置換)の最小回数。

// dp[i][j] = s1[0..i) と s2[0..j) の編集距離
function edit_distance(s1, s2):
    m = len(s1)
    n = len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    // ベースケース
    for i in range(m + 1):
        dp[i][0] = i  // s1 の最初の i 文字を全削除
    for j in range(n + 1):
        dp[0][j] = j  // s2 の最初の j 文字を全挿入

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i - 1] == s2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]  // 一致、コスト0
            else:
                dp[i][j] = 1 + min(
                    dp[i - 1][j],      // 削除
                    dp[i][j - 1],      // 挿入
                    dp[i - 1][j - 1]   // 置換
                )

    return dp[m][n]
graph LR
    A["'kitten'"] --> B["sitten
(k→s 置換)"] B --> C["sittin
(e→i 置換)"] C --> D["sitting
(末尾に g 挿入)"] style A fill:#ff6b6b style D fill:#51cf66

edit_distance("kitten", "sitting") = 3

パターン5: グリッド上の経路数

左上から右下まで、右か下にしか移動できない場合の経路数。

// dp[i][j] = (0,0) から (i,j) までの経路数
function unique_paths(m, n):
    dp = [[1] * n for _ in range(m)]

    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

    return dp[m - 1][n - 1]

// 障害物がある場合
function unique_paths_with_obstacles(grid):
    m = len(grid)
    n = len(grid[0])
    dp = [[0] * n for _ in range(m)]

    // 1行目
    for j in range(n):
        if grid[0][j] == 1: break  // 障害物以降は到達不可
        dp[0][j] = 1

    // 1列目
    for i in range(m):
        if grid[i][0] == 1: break
        dp[i][0] = 1

    for i in range(1, m):
        for j in range(1, n):
            if grid[i][j] == 1:
                dp[i][j] = 0  // 障害物
            else:
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

    return dp[m - 1][n - 1]
graph TB
    subgraph Grid["3x3 グリッドの経路数"]
    A["1"] --- B["1"] --- C["1"]
    A --- D["1"] --- E["2"] --- F["3"]
    D --- G["1"] --- H["3"] --- I["6"]
    end

    style I fill:#51cf66

パターン6: 最長増加部分列(LIS)

// O(n^2) の DP 解法
// dp[i] = arr[i] で終わる LIS の長さ
function lis_dp(arr):
    n = len(arr)
    dp = [1] * n

    for i in range(1, n):
        for j in range(i):
            if arr[j] < arr[i]:
                dp[i] = max(dp[i], dp[j] + 1)

    return max(dp)

// O(n log n) の二分探索解法
function lis_binary_search(arr):
    tails = []  // tails[i] = 長さ i+1 の LIS の末尾の最小値

    for x in arr:
        pos = lower_bound(tails, x)
        if pos == len(tails):
            tails.append(x)
        else:
            tails[pos] = x

    return len(tails)

// 例: arr = [10, 9, 2, 5, 3, 7, 101, 18]
// LIS: [2, 3, 7, 18] → 長さ 4

DP の空間最適化(ローリング配列)

2次元 DP テーブルで、遷移が「前の行」だけに依存する場合、1次元に圧縮できます。

// 0-1 ナップサック: O(nW) 空間 → O(W) 空間
function knapsack_optimized(weights, values, W):
    dp = [0] * (W + 1)

    for i in range(len(weights)):
        // 後ろから走査(0-1 の場合)
        for w in range(W, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[W]

// 注意: 無制限ナップサックの場合は前から走査する
// 0-1 で後ろから走査する理由: 各アイテムを2回使うのを防ぐため
graph LR
    A["2次元 DP
dp[i][w]
空間 O(nW)"] --> B["ローリング配列
dp[w]
空間 O(W)"] style A fill:#ff8787 style B fill:#51cf66

2. グリーディ法(貪欲法)

グリーディが正しく動く条件

貪欲選択性質(Greedy Choice Property): 各ステップで局所的に最適な選択をすれば、全体として最適解に到達できる。

graph TB
    A["問題にグリーディを適用できるか?"] --> B{"貪欲選択性質?"}
    B -->|No| C["DP を検討"]
    B -->|Yes| D{"最適部分構造?"}
    D -->|Yes| E["グリーディで解ける!"]
    D -->|No| C

    style C fill:#ffa94d
    style E fill:#51cf66

パターン1: 活動選択問題(区間スケジューリング)

終了時間が早い順に選ぶと、最大数の活動を実行できる。

// 終了時間でソートして、重ならない活動を貪欲に選ぶ
function activity_selection(activities):
    // activities = [(start, end), ...]
    sorted_acts = sort(activities, key=lambda x: x[1])  // 終了時間でソート

    selected = [sorted_acts[0]]
    last_end = sorted_acts[0][1]

    for i in range(1, len(sorted_acts)):
        start, end = sorted_acts[i]
        if start >= last_end:
            selected.append(sorted_acts[i])
            last_end = end

    return selected

// 例: [(1,4), (3,5), (0,6), (5,7), (3,9), (5,9), (6,10), (8,11), (8,12), (2,14), (12,16)]
// 選択: (1,4), (5,7), (8,11), (12,16) → 4つ
gantt
    title 区間スケジューリング
    dateFormat X
    axisFormat %s

    section 選択
    活動A (1-4)    :done, 1, 4
    活動D (5-7)    :done, 5, 7
    活動H (8-11)   :done, 8, 11
    活動K (12-16)  :done, 12, 16

    section 不選択
    活動B (3-5)    :crit, 3, 5
    活動C (0-6)    :crit, 0, 6
    活動E (3-9)    :crit, 3, 9

パターン2: コイン問題(グリーディ版)

特定の硬貨体系(例: 1, 5, 10, 50, 100, 500 円)では、大きい額面から使うグリーディが最適。

// グリーディが正しい場合(日本の硬貨体系など)
function coin_greedy(coins, amount):
    coins = sort(coins, reverse=True)  // 大きい順
    count = 0
    for coin in coins:
        count += amount // coin
        amount = amount % coin
    return count if amount == 0 else -1

// 注意: 任意のコインセットではグリーディは正しくない!
// 例: coins = [1, 3, 4], amount = 6
// グリーディ: 4 + 1 + 1 = 3枚
// 最適解: 3 + 3 = 2枚
// → DP が必要

パターン3: ハフマン符号化

頻度に基づく最適な可変長符号を構築する。

// 最も頻度の低い2つのノードを繰り返し結合
function huffman_coding(frequencies):
    // 優先度付きキューを使用
    heap = min_heap(frequencies)

    while len(heap) > 1:
        left = heap.pop()    // 最小頻度
        right = heap.pop()   // 2番目に小さい頻度
        merged = Node(left.freq + right.freq, left, right)
        heap.push(merged)

    return heap.pop()  // ルートノード = ハフマン木

3. DP vs グリーディ vs 分割統治法

graph TB
    A["最適化問題"] --> B{"部分問題の重なり?"}
    B -->|Yes| C{"貪欲選択性質?"}
    B -->|No| D["分割統治法"]
    C -->|Yes| E["グリーディ法
O(n) or O(n log n)"] C -->|No| F["動的計画法
O(nW), O(n²) etc."] style D fill:#74c0fc style E fill:#51cf66 style F fill:#ffa94d
特性 動的計画法 (DP) グリーディ法 分割統治法
戦略 全ての部分問題を解く 局所最適を選ぶ 分割して個別に解く
最適性保証 常に最適 条件付きで最適 問題依存
部分問題の重なり あり あり なし
時間計算量 高め(テーブル全体) 低め(1パス) 問題依存
空間計算量 テーブル分必要 通常 O(1) 再帰スタック
実装の難易度 中~高 低~中
代表例 ナップサック、編集距離 区間スケジューリング マージソート

判断フローチャート:

  1. 問題に「最大」「最小」「最適」のキーワードがあるか? → 最適化問題
  2. 局所的な最適選択が全体の最適解を導くか? → グリーディ
  3. 同じ計算を何度もするか? → DP
  4. 部分問題が独立か? → 分割統治法

4. グラフ探索アルゴリズム

BFS と DFS の比較

graph TB
    subgraph BFS_order["BFS: レベル順"]
    B1["1"] --> B2["2"]
    B1 --> B3["3"]
    B2 --> B4["4"]
    B2 --> B5["5"]
    B3 --> B6["6"]
    B3 --> B7["7"]
    end

    style B1 fill:#51cf66
    style B2 fill:#74c0fc
    style B3 fill:#74c0fc
    style B4 fill:#ffa94d
    style B5 fill:#ffa94d
    style B6 fill:#ffa94d
    style B7 fill:#ffa94d

BFS 訪問順: 1 → 2, 3 → 4, 5, 6, 7

graph TB
    subgraph DFS_order["DFS: 深さ優先"]
    D1["1"] --> D2["2"]
    D1 --> D3["3"]
    D2 --> D4["4"]
    D2 --> D5["5"]
    D3 --> D6["6"]
    D3 --> D7["7"]
    end

    style D1 fill:#51cf66
    style D2 fill:#74c0fc
    style D4 fill:#ffa94d
    style D5 fill:#b197fc
    style D3 fill:#ff8787
    style D6 fill:#e599f7
    style D7 fill:#ffe066

DFS 訪問順: 1 → 2 → 4 → 5 → 3 → 6 → 7

島の数を数える(BFS/DFS の典型問題)

// グリッド上で連結した '1' の塊(島)の数を数える
function count_islands(grid):
    if not grid:
        return 0

    rows = len(grid)
    cols = len(grid[0])
    count = 0

    function dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return
        if grid[r][c] != '1':
            return
        grid[r][c] = '0'  // 訪問済みマーク
        dfs(r + 1, c)
        dfs(r - 1, c)
        dfs(r, c + 1)
        dfs(r, c - 1)

    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                count += 1
                dfs(r, c)

    return count

トポロジカルソート

有向非巡回グラフ(DAG)のノードを、辺の方向に矛盾しない順序で並べる。

// カーンのアルゴリズム(BFS ベース)
function topological_sort(graph, num_nodes):
    in_degree = [0] * num_nodes
    for u in graph:
        for v in graph[u]:
            in_degree[v] += 1

    queue = []
    for i in range(num_nodes):
        if in_degree[i] == 0:
            queue.append(i)

    result = []
    while queue:
        node = queue.pop(0)
        result.append(node)
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    if len(result) != num_nodes:
        return None  // サイクルが存在する
    return result
graph LR
    A["コンパイラ"] --> B["リンカ"]
    C["テスト"] --> B
    A --> D["テスト"]
    E["ソースコード"] --> A

    style E fill:#51cf66

トポロジカル順序: ソースコード → コンパイラ → テスト → リンカ

最短経路アルゴリズムの比較

アルゴリズム ダイクストラ Bellman-Ford Floyd-Warshall
用途 単一始点最短経路 単一始点最短経路 全頂点間最短経路
負の辺 不可 可能 可能
負の閉路検出 不可 可能 可能
計算量 O((V+E) log V) O(VE) O(V^3)
データ構造 優先度付きキュー 配列 2次元配列
適するケース 辺の重みが非負 負の辺がある 全ペアの距離が必要

ダイクストラ法

// 優先度付きキューを使ったダイクストラ法
function dijkstra(graph, start):
    dist = {node: infinity for node in graph}
    dist[start] = 0
    pq = [(0, start)]  // (距離, ノード)

    while pq:
        d, u = heappop(pq)
        if d > dist[u]:
            continue  // より短い経路が既に見つかっている

        for v, weight in graph[u]:
            new_dist = dist[u] + weight
            if new_dist < dist[v]:
                dist[v] = new_dist
                heappush(pq, (new_dist, v))

    return dist
graph LR
    A["A (0)"] -->|4| B["B (4)"]
    A -->|1| C["C (1)"]
    C -->|2| B
    B -->|3| D["D (7)"]
    C -->|5| D
    C -->|8| E["E (9)"]
    D -->|1| E

    style A fill:#51cf66
    style C fill:#74c0fc
    style B fill:#74c0fc
    style D fill:#ffa94d
    style E fill:#ffa94d

A→C(1) → C→B(3) → B→D(7) → D→E(8)

Bellman-Ford 法

// 負の辺にも対応できる単一始点最短経路
function bellman_ford(graph, start, num_nodes):
    dist = [infinity] * num_nodes
    dist[start] = 0

    // V-1 回の緩和
    for _ in range(num_nodes - 1):
        for u, v, weight in all_edges:
            if dist[u] + weight < dist[v]:
                dist[v] = dist[u] + weight

    // 負の閉路チェック
    for u, v, weight in all_edges:
        if dist[u] + weight < dist[v]:
            return None  // 負の閉路が存在

    return dist

Floyd-Warshall 法

// 全頂点間の最短距離を求める
function floyd_warshall(dist_matrix):
    n = len(dist_matrix)
    // dist_matrix[i][j] = 辺 (i, j) の重み。辺がなければ infinity

    for k in range(n):        // 経由点
        for i in range(n):    // 始点
            for j in range(n):  // 終点
                if dist_matrix[i][k] + dist_matrix[k][j] < dist_matrix[i][j]:
                    dist_matrix[i][j] = dist_matrix[i][k] + dist_matrix[k][j]

    return dist_matrix

5. バックトラッキング(枝刈り付き全探索)

解の候補を再帰的に構築し、条件を満たさない枝を早期に刈り取る(枝刈り)手法です。

graph TB
    A["開始"] --> B["選択1"]
    A --> C["選択2"]
    A --> D["選択3"]
    B --> E["選択1-1"]
    B --> F["選択1-2 ✂️ 枝刈り"]
    C --> G["選択2-1 ✂️ 枝刈り"]
    C --> H["選択2-2"]
    D --> I["選択3-1"]
    H --> J["解発見!"]

    style F fill:#ff6b6b
    style G fill:#ff6b6b
    style J fill:#51cf66
// N-Queens: N x N のチェス盤に N 個のクイーンを互いに攻撃しないように配置
function solve_n_queens(n):
    solutions = []
    board = [-1] * n  // board[row] = col

    function is_safe(row, col):
        for prev_row in range(row):
            prev_col = board[prev_row]
            // 同じ列 or 対角線上にクイーンがあるか
            if prev_col == col or abs(prev_row - row) == abs(prev_col - col):
                return False
        return True

    function backtrack(row):
        if row == n:
            solutions.append(board.copy())
            return

        for col in range(n):
            if is_safe(row, col):
                board[row] = col
                backtrack(row + 1)
                board[row] = -1  // バックトラック

    backtrack(0)
    return solutions
// 部分集合の列挙
function subsets(nums):
    result = []

    function backtrack(start, current):
        result.append(current.copy())

        for i in range(start, len(nums)):
            current.append(nums[i])
            backtrack(i + 1, current)
            current.pop()  // バックトラック

    backtrack(0, [])
    return result

// subsets([1, 2, 3])
// → [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

💡 実践例

例題1: 最小コスト階段上り

階段の各段にコストがあり、1段か2段ずつ上れる。頂上に到達するための最小コストは?

function min_cost_climbing_stairs(cost):
    n = len(cost)
    dp = [0] * (n + 1)

    for i in range(2, n + 1):
        dp[i] = min(
            dp[i - 1] + cost[i - 1],
            dp[i - 2] + cost[i - 2]
        )

    return dp[n]

// 空間最適化版
function min_cost_optimized(cost):
    prev2 = 0
    prev1 = 0

    for i in range(2, len(cost) + 1):
        curr = min(prev1 + cost[i - 1], prev2 + cost[i - 2])
        prev2 = prev1
        prev1 = curr

    return prev1

例題2: 最大正方形

0 と 1 からなる2次元行列で、1 だけで構成される最大の正方形の面積を求める。

// dp[i][j] = (i, j) を右下隅とする最大正方形の辺の長さ
function maximal_square(matrix):
    if not matrix:
        return 0

    m = len(matrix)
    n = len(matrix[0])
    dp = [[0] * n for _ in range(m)]
    max_side = 0

    for i in range(m):
        for j in range(n):
            if matrix[i][j] == '1':
                if i == 0 or j == 0:
                    dp[i][j] = 1
                else:
                    dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
                max_side = max(max_side, dp[i][j])

    return max_side * max_side

例題3: 迷路の最短経路(BFS)

// グリッド上の (0,0) から (m-1, n-1) への最短経路(0が通路、1が壁)
function shortest_path(grid):
    m = len(grid)
    n = len(grid[0])

    if grid[0][0] == 1 or grid[m-1][n-1] == 1:
        return -1

    queue = [(0, 0, 1)]  // (行, 列, 距離)
    visited = set()
    visited.add((0, 0))
    directions = [(0,1), (0,-1), (1,0), (-1,0)]

    while queue:
        r, c, dist = queue.pop(0)

        if r == m - 1 and c == n - 1:
            return dist

        for dr, dc in directions:
            nr, nc = r + dr, c + dc
            if 0 <= nr < m and 0 <= nc < n and grid[nr][nc] == 0 and (nr, nc) not in visited:
                visited.add((nr, nc))
                queue.append((nr, nc, dist + 1))

    return -1  // 到達不可能

📊 DP パターン早見表

パターン 状態定義 遷移 計算量 典型問題
線形 DP dp[i] dp[i-1], dp[i-2] O(n) フィボナッチ、階段
ナップサック dp[i][w] dp[i-1][w], dp[i-1][w-wi] O(nW) 0-1ナップサック
区間 DP dp[i][j] dp[i][k] + dp[k][j] O(n^3) 行列積、回文分割
文字列 DP dp[i][j] dp[i-1][j-1] etc. O(mn) 編集距離、LCS
グリッド DP dp[i][j] dp[i-1][j], dp[i][j-1] O(mn) 経路数、最小コスト
ビット DP dp[mask] dp[mask ^ (1<<i)] O(2^n * n) 巡回セールスマン

🎯 ベストプラクティス

1. DP の問題を解く手順

// ステップ1: 状態を定義する
// 「dp[i] は何を表すか?」を明確にする

// ステップ2: 遷移式を立てる
// dp[i] が dp[i-1] や dp[i-2] からどう計算されるか

// ステップ3: ベースケースを設定する
// dp[0], dp[1] など初期値を決める

// ステップ4: 計算順序を決める
// ボトムアップなら小さい方から、トップダウンなら再帰

// ステップ5: 答えを返す
// dp[n] が答えか、max(dp) が答えかを確認

2. グリーディが正しいか検証する

グリーディが正しいか不安なときは、小さい反例を探してみましょう。

// 反例が見つかった → DP を使う
// 反例が見つからない → グリーディの証明を試みる(交換法など)

3. グラフ問題の選択指針

// 最短経路が必要?
//   重みなし → BFS
//   重みあり(非負) → ダイクストラ
//   負の辺あり → Bellman-Ford
//   全頂点間 → Floyd-Warshall

// 順序関係が必要? → トポロジカルソート
// 連結成分が必要? → Union-Find or DFS
// サイクル検出? → DFS(バックエッジ)

4. 空間最適化を常に検討する

DP テーブル全体が不要で、直前の行(または数個の変数)だけで十分な場合は、空間計算量を削減できます。

// Before: O(n) 空間
dp = [0] * (n + 1)
for i in range(2, n + 1):
    dp[i] = dp[i-1] + dp[i-2]

// After: O(1) 空間
prev2, prev1 = 0, 1
for i in range(2, n + 1):
    curr = prev1 + prev2
    prev2 = prev1
    prev1 = curr

🔍 関連する問題

この記事に関連するクイズ問題:

  • Q21: 動的計画法の基本(フィボナッチ、階段問題)
  • Q22: ナップサック問題
  • Q24: コイン問題(DP vs グリーディ)
  • Q26: 編集距離
  • Q31: グリッド上の経路数
  • Q33: 最長増加部分列(LIS)
  • Q35: BFS / DFS の使い分け
  • Q36: 島の数(連結成分)
  • Q38: トポロジカルソート
  • Q39: ダイクストラ法(最短経路)
  • Q40: バックトラッキング(N-Queens)
  • Q48: 区間スケジューリング(グリーディ)

📝 まとめ

  • DP の適用条件: 最適部分構造 + 部分問題の重なり。状態定義と遷移式が鍵
  • DP パターン: フィボナッチ(線形DP)、ナップサック、コイン、編集距離、グリッド、LIS を押さえる
  • 空間最適化: ローリング配列で O(n) や O(mn) を O(W) や O(n) に削減できる
  • グリーディの条件: 貪欲選択性質が成り立つときのみ使える。反例が見つかれば DP に切り替える
  • グラフ探索: BFS は最短経路(重みなし)、DFS は全探索に向く
  • 最短経路: ダイクストラ(非負辺)、Bellman-Ford(負辺あり)、Floyd-Warshall(全頂点間)
  • バックトラッキング: 枝刈りで探索空間を削減し、全探索を効率化する

次のステップ: まずフィボナッチとナップサックの DP を自力で書けるようにし、次にグラフ問題に挑戦しましょう!