0Pricing
DSA Interview Prep · レッスン

DPを見抜く:重複する部分問題

総当たりの再帰が同じ部分問題を繰り返し解いている場面を見抜き、Fibonacciの再帰木を描いて指数関数的な増大を確認します。

「DPを見抜く:重複する部分問題」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン1/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

動的計画法とは

動的計画法(DP)は、複雑な問題を重複するより単純な部分問題に分解し、各部分問題を一度だけ解いて、その結果を保存することで重複計算を避けます。DPが適用できる問題には、2つの要素があります。重複する部分問題(単純な再帰では同じ部分問題を何度も解くこと)と、最適部分構造(部分問題の最適解から全体の最適解を構築できること)です。この両方がなければ、DPは役に立ちません。

# Two ingredients of DP:
# 1. Overlapping sub-problems:
#    fib(5) -> fib(4) + fib(3)
#    fib(4) -> fib(3) + fib(2)  <- fib(3) computed twice!
#    Without caching: O(2^n) calls for Fibonacci

# 2. Optimal substructure:
#    Shortest path from A to C through B:
#    shortest(A,C) = shortest(A,B) + shortest(B,C)
#    The sub-path A->B must itself be the shortest

# Contrast with greedy: greedy makes one locally optimal
# choice; DP tries all choices and picks the best.
print('DP = overlapping sub-problems + optimal substructure')

フィボナッチ:DPの古典的な入り口

フィボナッチ数列(fib(n) = fib(n-1) + fib(n-2))は、重複する部分問題の代表的な例です。単純な再帰では、同じ値を繰り返し再計算するため、時間計算量が指数関数的なO(2^n)になります。fib(6)の再帰木を見ると、fib(3)が3回、fib(2)が5回計算されていることなどが分かります。この指数関数的な増大を、計算済みの結果を保存することで解消するのがDPです。

import time

def fib_naive(n):
    if n <= 1:
        return n
    return fib_naive(n-1) + fib_naive(n-2)

# Count the calls:
call_count = [0]
def fib_count(n):
    call_count[0] += 1
    if n <= 1: return n
    return fib_count(n-1) + fib_count(n-2)

fib_count(10)
print(f'Calls for fib(10): {call_count[0]}')  # 177 calls for n=10!

call_count[0] = 0
fib_count(20)
print(f'Calls for fib(20): {call_count[0]}')  # 21891 calls
# n=30 -> ~2.7 million calls: exponential growth

再帰木を可視化する

fib(5)の再帰木を描くと、無駄が明らかになります。各ノードが2つの子を生成し、同一の部分木が繰り返し現れます。木に含まれるノード数はO(2^n)です。このように、同じ引数による同じ関数呼び出しが木の中で繰り返されているパターンを見たら、結果をキャッシュすることでDPが役立つことを示しています。この可視化のスキルは重要です。繰り返される部分木を特定できれば、DPを適用できると判断できます。

# fib(5) recursion tree (simplified):
#                fib(5)
#               /       \
#           fib(4)     fib(3)
#           /    \     /    \
#       fib(3) fib(2) fib(2) fib(1)
#       /   \       \       
#   fib(2) fib(1) fib(1)   
#   /   \
# fib(1) fib(0)

# fib(3) appears TWICE
# fib(2) appears THREE TIMES
# Each redundant call wastes exponential time

# Key insight: fib(n) only has O(n) DISTINCT sub-problems
# (fib(0), fib(1), ..., fib(n))
# DP computes each ONCE -> O(n) total
print('Distinct sub-problems: O(n) but naive calls: O(2^n)')

重複する部分問題を特定する

重複する部分問題を見つけるには、まず総当たりの再帰を書き、次に「同じ引数による再帰呼び出しが複数あるか」と考えます。あれば、DPが役立ちます。問題文でよく使われる手がかりには、「Xの最小/最大個数」、「Yする方法の数」、「Zを達成できるか」があります。このような表現はほぼ常に、最適部分構造を持つ問題を示しており、位置iでの答えがそれより前の位置の答えに依存します。

# DP signal phrases in problem statements:
# 'minimum number of coins to make amount X'
# 'maximum profit from stock trades'
# 'number of ways to climb n stairs'
# 'can you reach the last index?'
# 'longest common subsequence'
# 'edit distance between two strings'

# All have this shape:
# solve(input) = f(solve(smaller_input_1), solve(smaller_input_2), ...)
# And multiple branches end up calling solve with the same argument.

# If the recursion tree has repeated nodes: DP
# If subproblems are all independent: divide-and-conquer (no DP needed)
print('Repeated arguments in recursion tree -> DP')

最適部分構造を理解する

最適部分構造とは、問題全体の最適解を部分問題の最適解から構築できることです。たとえば、Bを経由するAからCへの最短経路が最適になるのは、部分経路A→BとB→Cがそれぞれ個別に最適である場合に限ります。この性質が成り立つなら、局所的な最適解から全体の最適解をボトムアップに構築できます。最適部分構造を持たない問題(たとえば、サイクルを含む一般グラフにおける最長経路)は、DPでは解決できません。

# Optimal substructure examples:

# SHORTEST PATH: shortest(A,C) = min over all B: shortest(A,B) + w(B,C)
# -> Sub-paths must be optimal: YES, has optimal substructure

# LONGEST PATH (no cycles, DAG): can also use DP
# -> Longer path through node B means sub-path A->B must be longest

# LONGEST PATH (with cycles): NO optimal substructure
# -> Best path from A to C might reuse nodes: sub-problems not independent

# COIN CHANGE: min coins for amount n = 1 + min(min coins for n-coin_i)
# -> YES: optimal for n-coin_i is needed for optimal n

print('Optimal substructure: build global optimum from local optima')

階段を上る:最初のDP

階段を上る(LeetCode #70)は、1回に1段または2段ずつ上るとき、n段の階段を上る方法が何通りあるかを求める問題です。dp[i]を、階段i段目に到達する方法の数とします。階段i段目には、i-1段目から1段で到達するか、i-2段目から2段で到達できるため、dp[i] = dp[i-1] + dp[i-2]となります。これはフィボナッチ数列です。基底条件はdp[1] = 1、dp[2] = 2です。「階段を上る」問題がフィボナッチ数列に帰着することを見抜くのは、面接で役立つ典型的な洞察です。

def climb_stairs(n):
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1  # 1 way to reach step 1
    dp[2] = 2  # 2 ways to reach step 2: (1+1) or (2)
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]  # come from i-1 or i-2
    return dp[n]

for n in range(1, 8):
    print(f'climb_stairs({n}) = {climb_stairs(n)}')
# 1, 2, 3, 5, 8, 13, 21 -- Fibonacci sequence!

DPの3ステップ・フレームワーク:状態定義・漸化式・計算順序

信頼できるDPの3ステップ・フレームワークです。1. 状態を定義します — dp[i](または dp[i][j])は何を表しますか。英語で書き出します。2. 漸化式を書きます — dp[i]を、より小さな部分問題で表します。すべてのケースを含めます。3. 計算順序を決めます — dp[i-1](およびその他の依存先)がdp[i]より先に計算されるようにします。基底ケースで境界を初期化します。このフレームワークにより、曖昧なDPの直感を具体的な実装計画に変換できます。

# Framework applied to climbing stairs:
# Step 1 - Define state:
#   dp[i] = number of distinct ways to reach step i
# Step 2 - Recurrence:
#   dp[i] = dp[i-1] + dp[i-2]  (come from step i-1 or i-2)
# Step 3 - Fill order:
#   Compute dp[1], dp[2], dp[3], ..., dp[n] in order
#   Because dp[i] depends on dp[i-1] and dp[i-2] (smaller)
# Base cases: dp[1]=1, dp[2]=2

# Framework applied to coin change:
# Step 1: dp[amount] = minimum coins to make that amount
# Step 2: dp[i] = 1 + min(dp[i-coin] for coin in coins if i >= coin)
# Step 3: Fill i from 1 to amount
# Base: dp[0] = 0 (zero coins for zero amount)
print('DP framework: define state -> recurrence -> fill order')

DPを使わない場合

DPが常に正解とは限りません。1つの局所最適な選択が常に全体最適な解につながる場合は、貪欲法を使います(アクティビティ選択、Jump Game Iなど)。部分問題が重複しない場合は、分割統治法を使います(マージソート、二分探索など)。重みなしグラフにおける最短経路の問題には、BFSを使います。貪欲法やより単純な方法が存在する場合、DPは正しいものの過剰なことがよくあります。面接では、他の方法ではなくDPを選んだ理由を説明してください。

# DP vs alternatives:
# Problem: can you jump to the end of the array?
#   Greedy: track max reachable index -> O(n) O(1) BETTER than DP
# Problem: shortest path unweighted graph?
#   BFS: O(V+E) BETTER than DP on general graph
# Problem: sort an array?
#   Comparison sort: O(n log n), no DP needed

# DP IS the right choice when:
# - Greedy fails (choices interact)
# - Need to count/enumerate all possibilities
# - Problem has 'how many ways' or 'minimum/maximum' flavor
# - Recursion tree clearly shows overlapping sub-problems
print('Ask: does greedy fail? If yes, consider DP.')

異なる部分問題の数え上げ

異なる部分問題の数によって、DPの時間計算量と空間計算量が決まります。サイズnの入力に対する1次元DPでは、O(n)個の部分問題があります。サイズmとnの2つの入力に対する2次元DPでは、O(mn)個の部分問題があります。各部分問題をO(k)時間で解く場合(各ステップでk個の選択肢がある場合)、全体の時間計算量はO(n*k)またはO(mn*k)になります。まず異なる部分問題の数を数えてください。コードを書く前に、これでDPの時間計算量が分かります。

# Sub-problem count examples:
# Problem          | Sub-problems  | Each costs | Total
# Fibonacci        | O(n)          | O(1)       | O(n)
# Coin change      | O(amount)     | O(coins)   | O(amount * coins)
# LCS (m,n chars) | O(m*n)        | O(1)       | O(m*n)
# Edit distance    | O(m*n)        | O(1)       | O(m*n)
# 0/1 Knapsack    | O(n*W)        | O(1)       | O(n*W)
# Matrix chain     | O(n^2)        | O(n)       | O(n^3)

# Rule: DP time = (# distinct sub-problems) * (time per sub-problem)
print('Time = subproblems * work-per-subproblem')

House Robber:重複する選択肢

House Robber(LeetCode #198)では、隣り合う家を同時に盗まないという条件で、一直線に並んだ家から盗める金額の最大値を求めます。各家で、盗む(その家の金額を加え、1つ前の家を飛ばす)か、盗まない(1つ前までの最適値を使う)かを選びます。dp[i] = max(dp[i-1], dp[i-2] + nums[i])です。この各ステップで選択するパターンは、最も基本的な1次元DPの漸化式であり、面接問題に数多く登場します。

def rob(nums):
    if not nums: return 0
    if len(nums) == 1: return nums[0]
    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],          # skip house i
                    dp[i-2] + nums[i]) # rob house i
    return dp[-1]

print(rob([1, 2, 3, 1]))   # 4: rob house 0 and 2 (1+3)
print(rob([2, 7, 9, 3, 1]))# 12: rob house 0, 2, 4 (2+9+1)
print(rob([2, 1, 1, 2]))   # 4: rob house 0 and 3

健全性チェック:総当たり法とDPの比較

小さな入力に対する総当たり法の解とDPを必ず照合してください。総当たり法が正解の基準になります。すべてのテストケースでDPが総当たり法と一致すれば、漸化式が正しいと分かります。その後で初めて、メモリ使用量を最適化します。このテスト駆動のアプローチ、つまり総当たり法 → トップダウンDP → ボトムアップDP → メモリ最適化DPという流れが、面接中にDP解法を開発・検証するためのプロフェッショナルな方法です。

# Brute-force for house robber (exponential)
def rob_brute(nums, i=0):
    if i >= len(nums):
        return 0
    # Option 1: rob house i
    rob_it = nums[i] + rob_brute(nums, i + 2)
    # Option 2: skip house i
    skip_it = rob_brute(nums, i + 1)
    return max(rob_it, skip_it)

# Verify on small inputs:
test_cases = [[1,2,3,1], [2,7,9,3,1], [2,1,1,2]]
for tc in test_cases:
    bf = rob_brute(tc)
    dp = rob(tc)
    print(f'{tc}: brute={bf}, dp={dp}, match={bf==dp}')

理解度チェック

このレッスンで扱ったData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認します。

レッスンのまとめ

このレッスンでは、DPの2つの要素(重複する部分問題と最適部分構造)、繰り返し呼び出される箇所を見つけるために再帰木を可視化する方法、DPの3ステップ・フレームワーク(状態定義、漸化式、計算順序)、そしてフィボナッチ数列、階段の上り方、House Robberの最初の例を学びました。次は、メモ化を使ったトップダウンDPを実装します。

よくある質問

「DPを見抜く:重複する部分問題」レッスンは無料ですか?

はい。「DPを見抜く:重複する部分問題」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「DPを見抜く:重複する部分問題」で何を学びますか?

総当たりの再帰が同じ部分問題を繰り返し解いている場面を見抜き、Fibonacciの再帰木を描いて指数関数的な増大を確認します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン1/4です。

「DPを見抜く:重複する部分問題」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. DPを見抜く:重複する部分問題
  2. メモ化によるトップダウンDP
  3. 表形式によるボトムアップDP
  4. Coin Changeと最小コストの階段
← DSA Interview Prepに戻る