Skip to content
Published on

FAANG コーディング面接完全攻略: 動的計画法パターン

Authors

FAANG コーディング面接完全攻略: 動的計画法パターン

動的計画法(DP)は FAANG 面接で最も頻繁に出題されるトピックの一つです。このガイドでは、面接で必ず知っておくべき DP パターンを体系的に解説します。

1. DP アプローチの体系

DP 問題の見分け方

動的計画法の問題には二つの核心的な特性があります。

最適部分構造 (Optimal Substructure)

  • 全体問題の最適解が部分問題の最適解から構成される
  • 例: 最短経路 A→C は A→B の最短 + B→C の最短の合計

重複する部分問題 (Overlapping Subproblems)

  • 同じ部分問題が複数回繰り返し現れる
  • フィボナッチ数列: fib(5) = fib(4) + fib(3)、fib(4) = fib(3) + fib(2) → fib(3) が重複

DP 問題の認識シグナル

  • 「最大」「最小」「最長」「最短」を求める問題
  • 「何通りの方法があるか」といったカウント問題
  • 「可能かどうか」という判定問題
  • 配列や文字列で部分構造を探索する問題

トップダウン vs ボトムアップの比較

# トップダウン (メモ化) - 再帰 + キャッシュ
def fib_topdown(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fib_topdown(n-1, memo) + fib_topdown(n-2, memo)
    return memo[n]

# ボトムアップ (表形式) - 繰り返し + テーブル
def fib_bottomup(n):
    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]
特性トップダウンボトムアップ
アプローチ再帰 + メモ化繰り返し + テーブル
直感性問題を自然に分解順序を先に把握する必要あり
空間コールスタックのオーバーヘッドテーブル空間のみ
速度必要な部分のみ計算すべての状態を計算
最適化難しい空間最適化が可能

DP 設計 4 ステップ

  1. 状態定義: dp[i] が何を意味するかを明確に定義
  2. 漸化式: dp[i] を以前の状態で表現
  3. 基底ケース: 初期値の設定
  4. 計算順序: 依存関係に基づく計算順序の決定

2. 1D DP パターン

Climbing Stairs (LeetCode 70)

def climbStairs(n: int) -> int:
    if n <= 2:
        return n
    # dp[i] = i 段目に到達する方法の数
    dp = [0] * (n + 1)
    dp[1] = 1
    dp[2] = 2
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

# 空間最適化: O(n) -> O(1)
def climbStairs_optimized(n: int) -> int:
    if n <= 2:
        return n
    prev, curr = 1, 2
    for _ in range(3, n + 1):
        prev, curr = curr, prev + curr
    return curr
  • 時間計算量: O(n)
  • 空間計算量: O(1) (最適化バージョン)

House Robber (LeetCode 198)

def rob(nums: list) -> int:
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]
    # dp[i] = 0 から i 番目の家まで盗める最大金額
    dp = [0] * len(nums)
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, len(nums)):
        dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    return dp[-1]

# 空間最適化
def rob_optimized(nums: list) -> int:
    prev2, prev1 = 0, 0
    for num in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + num)
    return prev1

House Robber II (LeetCode 213)

円形配列: 最初と最後の家を同時に選択できません。

def rob2(nums: list) -> int:
    def rob_linear(houses):
        prev2, prev1 = 0, 0
        for h in houses:
            prev2, prev1 = prev1, max(prev1, prev2 + h)
        return prev1

    if len(nums) == 1:
        return nums[0]
    # 最初の家を含む (最後を除く) vs 最後の家を含む (最初を除く)
    return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))

Maximum Product Subarray (LeetCode 152)

負の数があるため、最小値も追跡する必要があります。

def maxProduct(nums: list) -> int:
    max_prod = min_prod = result = nums[0]
    for num in nums[1:]:
        # 負の数 * 負の最小値 = 正の最大値になる可能性
        candidates = (num, max_prod * num, min_prod * num)
        max_prod = max(candidates)
        min_prod = min(candidates)
        result = max(result, max_prod)
    return result
  • 時間計算量: O(n)
  • 空間計算量: O(1)

Jump Game I および II (LeetCode 55, 45)

# Jump Game I: 最後のインデックスに到達できるか
def canJump(nums: list) -> bool:
    max_reach = 0
    for i, jump in enumerate(nums):
        if i > max_reach:
            return False
        max_reach = max(max_reach, i + jump)
    return True

# Jump Game II: 最少ジャンプ回数
def jump(nums: list) -> int:
    jumps = current_end = farthest = 0
    for i in range(len(nums) - 1):
        farthest = max(farthest, i + nums[i])
        if i == current_end:
            jumps += 1
            current_end = farthest
    return jumps

Longest Increasing Subsequence (LeetCode 300)

# O(n^2) DP アプローチ
def lengthOfLIS(nums: list) -> int:
    n = len(nums)
    # dp[i] = nums[i] で終わる LIS の長さ
    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)

# O(n log n) 二分探索アプローチ
import bisect

def lengthOfLIS_nlogn(nums: list) -> int:
    tails = []
    for num in nums:
        pos = bisect.bisect_left(tails, num)
        if pos == len(tails):
            tails.append(num)
        else:
            tails[pos] = num
    return len(tails)

3. 2D DP パターン

Unique Paths (LeetCode 62)

def uniquePaths(m: int, n: int) -> int:
    # dp[i][j] = (0,0) から (i,j) までの経路数
    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]

# 空間最適化: 1D 配列
def uniquePaths_optimized(m: int, n: int) -> int:
    dp = [1] * n
    for _ in range(1, m):
        for j in range(1, n):
            dp[j] += dp[j-1]
    return dp[-1]

Minimum Path Sum (LeetCode 64)

def minPathSum(grid: list) -> int:
    m, n = len(grid), len(grid[0])
    dp = [[0] * n for _ in range(m)]
    dp[0][0] = grid[0][0]
    # 最初の行を初期化
    for j in range(1, n):
        dp[0][j] = dp[0][j-1] + grid[0][j]
    # 最初の列を初期化
    for i in range(1, m):
        dp[i][0] = dp[i-1][0] + grid[i][0]
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
    return dp[m-1][n-1]

Longest Common Subsequence (LeetCode 1143)

def longestCommonSubsequence(text1: str, text2: str) -> int:
    m, n = len(text1), len(text2)
    # dp[i][j] = text1[:i] と text2[:j] の LCS の長さ
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i-1] == text2[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[m][n]
  • 時間計算量: O(m * n)
  • 空間計算量: O(m * n)、最適化すると O(min(m, n))

Edit Distance (LeetCode 72)

def minDistance(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    # dp[i][j] = word1[:i] を word2[:j] に変換する最小操作数
    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]

Coin Change (LeetCode 322)

def coinChange(coins: list, amount: int) -> int:
    # dp[i] = 金額 i を作るのに必要な最小コイン数
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for coin in coins:
        for i in range(coin, amount + 1):
            dp[i] = min(dp[i], dp[i - coin] + 1)
    return dp[amount] if dp[amount] != float('inf') else -1

Coin Change 2 (LeetCode 518)

def change(amount: int, coins: list) -> int:
    # dp[i] = 金額 i を作る方法の数 (非有界ナップサック問題)
    dp = [0] * (amount + 1)
    dp[0] = 1
    for coin in coins:
        for i in range(coin, amount + 1):
            dp[i] += dp[i - coin]
    return dp[amount]

0/1 ナップサック問題

def knapsack(weights: list, values: list, capacity: int) -> int:
    n = len(weights)
    # dp[i][w] = i 番目のアイテムまで考慮し重さ w 以下での最大価値
    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]  # アイテム 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][capacity]

# 空間最適化: 逆順走査
def knapsack_optimized(weights: list, values: list, capacity: int) -> int:
    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]

4. 区間 DP (Interval DP)

区間 DP は連続した部分配列や部分文字列 [i, j] に対して最適解を求めるパターンです。 重要な洞察: dp[i][j] = i から j の区間における最適値

Burst Balloons (LeetCode 312)

def maxCoins(nums: list) -> int:
    # 両端に仮想の 1 を追加
    nums = [1] + nums + [1]
    n = len(nums)
    # dp[i][j] = nums[i+1..j-1] の風船をすべて割って得られる最大コイン
    dp = [[0] * n for _ in range(n)]

    # 長さ 2 から開始 (隣接する境界の間に風船がない)
    for length in range(2, n):
        for left in range(n - length):
            right = left + length
            for k in range(left + 1, right):
                # k を区間 [left, right] で最後に割る風船とする
                coins = nums[left] * nums[k] * nums[right]
                dp[left][right] = max(
                    dp[left][right],
                    dp[left][k] + coins + dp[k][right]
                )
    return dp[0][n-1]
  • 時間計算量: O(n^3)
  • 空間計算量: O(n^2)

Palindromic Substrings (LeetCode 647)

def countSubstrings(s: str) -> int:
    n = len(s)
    # dp[i][j] = s[i..j] が回文なら True
    dp = [[False] * n for _ in range(n)]
    count = 0

    # 長さ 1: 常に回文
    for i in range(n):
        dp[i][i] = True
        count += 1

    # 長さ 2
    for i in range(n - 1):
        if s[i] == s[i+1]:
            dp[i][i+1] = True
            count += 1

    # 長さ 3 以上
    for length in range(3, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j] and dp[i+1][j-1]:
                dp[i][j] = True
                count += 1
    return count

Longest Palindromic Subsequence (LeetCode 516)

def longestPalindromeSubseq(s: str) -> int:
    n = len(s)
    # dp[i][j] = s[i..j] における最長回文部分列の長さ
    dp = [[0] * n for _ in range(n)]

    # 長さ 1: 常に 1
    for i in range(n):
        dp[i][i] = 1

    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i+1][j-1] + 2
            else:
                dp[i][j] = max(dp[i+1][j], dp[i][j-1])
    return dp[0][n-1]

行列チェーン乗算の概念

行列 A1 _ A2 _ ... * An の積の演算回数を最小化する問題です。

  • dp[i][j] = 行列 i から j を掛け合わせる最小演算数
  • 漸化式: dp[i][j] = min(dp[i][k] + dp[k+1][j] + dims[i-1] * dims[k] * dims[j]) with i <= k < j

5. 木とグラフの DP

House Robber III (LeetCode 337)

隣接するノードを同時に選択できない木での最大合計を求めます。

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def rob3(root: TreeNode) -> int:
    def dfs(node):
        if not node:
            return (0, 0)  # (選択しない, 選択する)

        left_no, left_yes = dfs(node.left)
        right_no, right_yes = dfs(node.right)

        # 現在のノードを選択しない: 子は選択してもしなくてもよい
        not_rob = max(left_no, left_yes) + max(right_no, right_yes)
        # 現在のノードを選択する: 子は選択不可
        rob = node.val + left_no + right_no

        return (not_rob, rob)

    return max(dfs(root))

Unique Binary Search Trees (LeetCode 96)

def numTrees(n: int) -> int:
    # dp[i] = i ノードで構成できる構造的に異なる BST の数 (カタラン数)
    dp = [0] * (n + 1)
    dp[0] = 1
    dp[1] = 1

    for nodes in range(2, n + 1):
        for root in range(1, nodes + 1):
            left_count = dp[root - 1]
            right_count = dp[nodes - root]
            dp[nodes] += left_count * right_count

    return dp[n]

6. FAANG DP 出題傾向

Google

Hard 難易度の DP 問題を好む傾向があります。

  • Burst Balloons、Regular Expression Matching、Wildcard Matching
  • 複雑な状態空間を持つ問題 (3D DP など)
  • 区間 DP やビットマスク DP

準備のポイント: 再帰から始めてメモ化を追加し、最後にボトムアップに変換する練習をしましょう。

Meta (Facebook)

実用的な Medium 難易度の DP 問題が多いです。

  • Coin Change、Longest Common Subsequence
  • Decode Ways、Word Break
  • 製品に関連する DP 問題

準備のポイント: コードの簡潔さと説明能力が重要です。漸化式を先に説明してからコードを書きましょう。

Amazon

実用的な Medium DP 問題が中心です。

  • House Robber シリーズ、Jump Game
  • Longest Increasing Subsequence、Maximum Subarray
  • システム設計に連携した最適化問題

準備のポイント: アルゴリズムの説明をリーダーシップ原則と結びつけると良いです。

状態空間の最適化

多くの 2D DP を 1D に最適化できます。

# LCS: 2D から 1D への最適化
def lcs_optimized(text1: str, text2: str) -> int:
    m, n = len(text1), len(text2)
    if m < n:
        text1, text2 = text2, text1
        m, n = n, m
    # 前の行のみを保持
    prev = [0] * (n + 1)
    for i in range(1, m + 1):
        curr = [0] * (n + 1)
        for j in range(1, n + 1):
            if text1[i-1] == text2[j-1]:
                curr[j] = prev[j-1] + 1
            else:
                curr[j] = max(prev[j], curr[j-1])
        prev = curr
    return prev[n]

最適化の原則:

  • 現在の行が前の行にのみ依存 → ローリング配列 (2D → 1D)
  • 現在の値が直前の値にのみ依存 → 変数 2 つ (1D → O(1))
  • 逆順走査で 0/1 ナップサックの空間を節約

7. 練習問題クイズ

クイズ 1: dp[i] = max(dp[i-1], dp[i-2] + nums[i]) という漸化式を使う問題はどれですか?

答え: House Robber

解説: dp[i] は 0 から i 番目の家まで考慮したときに盗める最大金額を表します。i 番目の家を盗む場合は i-1 番目は選べないので dp[i-2] + nums[i]、盗まない場合は dp[i-1] となり、この 2 つの最大値を取ります。

クイズ 2: Coin Change 問題で dp[0] = 0 に初期化する理由は?

答え: 金額 0 を作るのに必要なコインの枚数が 0 だからです。

解説: dp[0] = 0 は基底ケースで、金額 0 はコインを使わなくても達成できます。dp[1]、dp[2] などはすべてこの基底ケースをもとに計算されます。dp[0] が 0 でない場合、すべての計算値が誤りになります。

クイズ 3: LCS で dp[i][j] = dp[i-1][j-1] + 1 が成立する条件は?

答え: text1[i-1] == text2[j-1] のとき、つまり現在比較している 2 文字が同じとき

解説: 2 文字が一致する場合、これまでの LCS に現在の文字を追加できるので dp[i-1][j-1] + 1 となります。一致しない場合は max(dp[i-1][j], dp[i][j-1]) を取ります。

クイズ 4: Burst Balloons で k を「最後に割る風船」と定義する理由は?

答え: 部分問題を独立に分離するためです。

解説: k を「最初に割る風船」と定義すると、割った後に隣接関係が変わり、2 つの部分問題が独立でなくなります。k を「最後の風船」と定義することで、[left, k] と [k, right] の区間がそれぞれ独立に計算でき、k が最後に割られるときのスコアは常に nums[left] _ nums[k] _ nums[right] に固定されます。

クイズ 5: O(n log n) の LIS アルゴリズムで tails 配列が表すものは?

答え: tails[i] は長さ i+1 の増加部分列で取り得る最小の末尾値です。

解説: tails 配列は実際の LIS を表さず、各長さの LIS が持てる最小の末尾値を格納します。これにより、新しい数がどこに挿入されるかを二分探索で O(log n) に見つけられます。tails 配列の最終的な長さが LIS の長さになります。


まとめ

動的計画法はパターンを習得すれば体系的に取り組めるようになります。

推奨学習順序:

  1. Climbing Stairs、Fibonacci (DP の基礎)
  2. House Robber シリーズ (1D DP)
  3. Unique Paths、Minimum Path Sum (2D DP)
  4. LCS、Edit Distance (文字列 DP)
  5. Coin Change (ナップサック問題)
  6. Burst Balloons (区間 DP)
  7. House Robber III (木の DP)

面接のポイント:

  • 常に状態定義から始める
  • 漸化式を言葉で説明してからコードを書く
  • ブルートフォース → メモ化 → 表形式の順に最適化する
  • 時間計算量と空間計算量を常に分析する