メモ化によるトップダウンDP
再帰解にmemo辞書を追加して重複呼び出しを削減し、@lru_cacheを使って最小限のコードでメモ化します。
「メモ化によるトップダウンDP」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン2/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。
トップダウンDP:メモ化の考え方
トップダウンDPは、元の再帰解にメモ化を追加する方法です。メモ化とは、各部分問題の結果を最初に計算したときに保存しておくキャッシュです。同じ引数で後から呼び出された場合は、再帰せずにキャッシュした結果をすぐ返します。これにより、素朴なO(2^n)の再帰を、コードをほとんど変更せずにO(n)へ変換できます。既存の再帰解に2〜3行追加するだけで済むこともよくあります。
# Top-down approach:
# 1. Write the recursive solution (natural but slow)
# 2. Add a memo dict to cache results
# 3. Before recursing, check if the result is cached
# 4. Before returning, store the result in the cache
# This is also called 'memoization' (US spelling)
# 'memoize' means 'to remember', not 'memorize'
# The cache key is the function arguments
# For fib: key is n
# For 2D DP: key is (i, j)
# For 3D DP: key is (i, j, k)
print('Top-down = recursion + memo cache')メモ化したフィボナッチ数列
素朴なフィボナッチ数列の再帰にメモ辞書を追加すると、時間計算量をO(2^n)からO(n)に削減できます。fib(k)の最初の呼び出しで結果を計算して保存します。同じkに対する以降の呼び出しでは、キャッシュされた値を即座に返します。空間計算量は、メモ辞書にO(n)、呼び出しスタックにO(n)です。呼び出し回数を比較してみましょう。メモ化なしではfib(30)で約200万回呼び出されますが、メモ化ありではちょうど30回です。
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n in memo:
return memo[n] # return cached result
if n <= 1:
return n
memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
return memo[n]
# Verify speed improvement:
print(fib_memo(30)) # fast!
print(fib_memo(50)) # still fast
print(fib_memo(100)) # no problem
# Without memo, fib_naive(50) would take minutes
# With memo: each of the 50 sub-problems computed once@functools.lru_cacheの使い方
Pythonの@functools.lru_cache(maxsize=None)デコレーター(またはPython 3.9以降の別名@cache)を使うと、引数に基づいて関数を自動的にメモ化できます。面接でトップダウンDPを追加する最も簡潔な方法です。再帰解を書き、デコレーターを付ければ完了です。このデコレーターは、関数の引数をキーとする辞書にすべての結果をキャッシュします。そのため、引数はハッシュ可能でなければなりません(リストではなくタプルを使います)。
import functools
@functools.lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
print(fib(50)) # 12586269025
print(fib(100)) # works instantly
# Clear cache between tests if needed:
fib.cache_clear()
# Python 3.9+ shorthand:
# from functools import cache
# @cache
# def fib(n): ...
print(fib.cache_info()) # shows hits, misses, maxsize, currsizeトップダウンのCoin Change
Coin Change(LeetCode #322)では、コインの額面と目標金額が与えられたとき、必要なコインの最小枚数を求めます。再帰的には、各コインについてそのコインを使い、残りの金額を解いて、最小値を取ります。金額をキーにしてメモ化し、同じ計算を避けます。基底ケースでは、amount=0に必要なコインは0枚です。作成できない金額には無限大を返します(再帰の後で-1に変換しても構いません)。
import functools
def coin_change_top_down(coins, amount):
@functools.lru_cache(maxsize=None)
def dp(remaining):
if remaining == 0:
return 0 # no coins needed
if remaining < 0:
return float('inf') # impossible
# Try each coin and take the minimum
return 1 + min(dp(remaining - c) for c in coins)
result = dp(amount)
return result if result != float('inf') else -1
print(coin_change_top_down([1, 5, 6, 9], 11)) # 2: (5+6) or (2*5+1?no: 9+2?no) 5+6=11 YES
print(coin_change_top_down([2], 3)) # -1: impossible
print(coin_change_top_down([1, 2, 5], 11)) # 3: 5+5+1k段まで許可したトップダウンの階段問題
階段問題を一般化し、1段からk段まで一度に上れるようにします。状態は現在の段であり、段iからはi+1、i+2、…、i+k段へ進めます。漸化式はdp(i) = sum of dp(i-j) for j in 1..k if i-j >= 0です。メモ化により、計算量をO(k^n)からO(n*k)にできます。この一般化は、「最後の段までの最小コスト」や「グリッドを埋める方法の数」といった問題に登場します。
import functools
def climb_k_steps(n, k):
@functools.lru_cache(maxsize=None)
def dp(i):
if i == 0:
return 1 # base: one way to stay at ground
if i < 0:
return 0 # impossible
# From stair i, you could have come from i-1, i-2, ..., i-k
return sum(dp(i - j) for j in range(1, k+1) if i - j >= 0)
return dp(n)
# k=2 (original): should match fib-like sequence
print([climb_k_steps(n, 2) for n in range(7)]) # [1,1,2,3,5,8,13]
# k=3: more options
print([climb_k_steps(n, 3) for n in range(7)]) # [1,1,2,4,7,13,24]トップダウンLCS:2次元メモ化
最長共通部分列(LCS)には2次元の状態が必要です。dp(i, j)はs1[:i]とs2[:j]のLCSの長さを表します。s1[i-1] == s2[j-1]なら文字が一致するため、dp(i,j) = 1 + dp(i-1, j-1)です。それ以外の場合は、dp(i,j) = max(dp(i-1,j), dp(i,j-1))です。どちらかの文字列から1文字飛ばします。(i, j)をキーにメモ化すると、計算量をO(2^(m+n))からO(mn)にできます。
import functools
def lcs_top_down(s1, s2):
m, n = len(s1), len(s2)
@functools.lru_cache(maxsize=None)
def dp(i, j):
if i == 0 or j == 0:
return 0 # empty prefix has LCS of 0
if s1[i-1] == s2[j-1]:
return 1 + dp(i-1, j-1) # characters match
return max(dp(i-1, j), dp(i, j-1)) # skip one
return dp(m, n)
print(lcs_top_down('abcde', 'ace')) # 3: 'ace'
print(lcs_top_down('abc', 'abc')) # 3: 'abc'
print(lcs_top_down('abc', 'def')) # 0: no common charsメモ辞書とlru_cache:選び方
関数の引数がハッシュ可能なプリミティブ型(int、str、tuple)の場合は、@lru_cacheを使います。次のような場合は手動のメモ辞書を使います。可変な状態(リストや辞書)をタプルに変換して渡す必要がある場合、どのキーが計算済みかを追跡する必要がある場合、またはselfをキャッシュすべきでないクラスメソッドの場合です。手動のメモ辞書はより明示的で、再帰ヘルパー関数で発生する微妙なクロージャの問題も避けられます。
# @lru_cache: clean, automatic, O(1) overhead
# Use when: arguments are simple (int, str, tuple)
import functools
@functools.lru_cache(maxsize=None)
def simple_dp(n):
if n <= 1: return n
return simple_dp(n-1) + simple_dp(n-2)
# Manual memo dict: explicit, flexible
# Use when: complex state, need to inspect memo, class methods
def manual_memo_dp(s1, s2):
memo = {}
def dp(i, j):
if (i,j) in memo: return memo[(i,j)]
if i == 0 or j == 0:
return 0
if s1[i-1] == s2[j-1]:
memo[(i,j)] = 1 + dp(i-1, j-1)
else:
memo[(i,j)] = max(dp(i-1,j), dp(i,j-1))
return memo[(i,j)]
return dp(len(s1), len(s2))
print(manual_memo_dp('abcde', 'ace')) # 3トップダウンのTarget Sum
Target Sum(LeetCode #494)では、各数値に+または-を割り当て、目標の合計値になる割り当ての数を数えます。状態はdp(index, current_sum)です。各インデックスで、現在の数値を加算(+)する場合と減算(-)する場合を試します。(index, current_sum)をメモ化することで、O(2^n)の総当たり法をO(n * sum_range)に変換できます。合計値の範囲はすべての数値の総和によって制限されるため、状態数全体はO(n * S)です。
import functools
def find_target_sum_ways(nums, target):
@functools.lru_cache(maxsize=None)
def dp(index, current_sum):
if index == len(nums):
return 1 if current_sum == target else 0
# Try adding the number
add = dp(index + 1, current_sum + nums[index])
# Try subtracting the number
subtract = dp(index + 1, current_sum - nums[index])
return add + subtract
return dp(0, 0)
print(find_target_sum_ways([1,1,1,1,1], 3)) # 5
print(find_target_sum_ways([1], 1)) # 1
print(find_target_sum_ways([1], -1)) # 1トップダウンとボトムアップ:長所と短所
トップダウン(メモ化)の利点は、元の再帰解から始めるため自然に書けること、実際に必要な部分問題だけを計算すること(遅延評価)、キャッシュを段階的に追加しやすいことです。ボトムアップ(表形式化)の利点は、呼び出しスタックのオーバーヘッドがなく(Pythonの再帰上限にも達せず)、メモリアクセスのキャッシュ効率がよく、メモリ使用量を最適化しやすいことです。漸近的な計算量はどちらも同じです。面接では、まずトップダウンで正しさを検証し、より少ないメモリ使用量を求められたらボトムアップに変換してください。
# Top-down advantages:
# + Natural: write recursive, add @cache
# + Lazy: only computes needed sub-problems
# + Easy to reason about correctness
# - Uses call stack (recursion limit in Python)
# - Higher constant factor (function call overhead)
# Bottom-up advantages:
# + No recursion limit
# + Better cache performance (sequential memory)
# + Easier to space-optimise (rolling array)
# - Must compute all sub-problems in order
# - Less intuitive for complex 2D/3D problems
# Interview strategy:
# Start with top-down to verify recurrence,
# convert to bottom-up only if asked.
print('Top-down: easy to write | Bottom-up: efficient for large n')トップダウンDPによるWord Break
Word Break(LeetCode #139)では、文字列sを辞書に含まれる単語に分割できるかを判定します。状態は、dp(i)がs[i:]を分割できるかどうかを表します。インデックスiから、すべての単語を試します。s[i:i+len(w)] == wなら、残りの接尾辞に対して再帰します。開始インデックスをキーにメモ化することで、setによる包含確認を含め、O(2^n)の総当たり法をO(n^2)(またはO(n * max_word_len))に変換できます。
import functools
def word_break(s, word_dict):
word_set = set(word_dict)
@functools.lru_cache(maxsize=None)
def dp(start):
if start == len(s):
return True # successfully segmented entire string
for end in range(start + 1, len(s) + 1):
if s[start:end] in word_set and dp(end):
return True
return False
return dp(0)
print(word_break('leetcode', ['leet', 'code'])) # True
print(word_break('applepenapple', ['apple', 'pen'])) # True
print(word_break('catsandog', ['cats', 'dog', 'and', 'cat', 'san', 'andog'])) # False再帰上限とItertools
Pythonのデフォルトの再帰上限は1000です(sys.getrecursionlimit()で設定されています)。大きな入力(n = 10,000以上)を扱うDP問題では、トップダウンのメモ化がこの上限に達します。方法は2つあります。sys.setrecursionlimit(100000)で上限を増やすか、ボトムアップDPに変換します。競技プログラミングでは上限を増やすことが一般的ですが、本番コードでは信頼性のため、常にボトムアップまたは反復的な解法を優先してください。
import sys
print('Default recursion limit:', sys.getrecursionlimit()) # 1000
# For large DP problems, increase if needed:
# sys.setrecursionlimit(100000)
# Better: convert to bottom-up DP for large n
def fib_bottom_up(n):
if n <= 1: return n
a, b = 0, 1
for _ in range(2, n+1):
a, b = b, a + b
return b
# No recursion limit issue:
print(fib_bottom_up(10000)) # works fine, no recursion理解度チェック
このレッスンで扱ったData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認します。
レッスンのまとめ
このレッスンでは、メモ辞書と@lru_cacheデコレーターを使ったトップダウンDP、フィボナッチ数列、Coin Change、LCS、Target Sum、Word Breakのメモ化解法、そしてトップダウンとボトムアップのどちらを選ぶべきかを学びました。次は、表形式化とメモリ使用量の最適化を使ったボトムアップDPを実装します。
よくある質問
「メモ化によるトップダウンDP」レッスンは無料ですか?
はい。「メモ化によるトップダウンDP」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。
「メモ化によるトップダウンDP」で何を学びますか?
再帰解にmemo辞書を追加して重複呼び出しを削減し、@lru_cacheを使って最小限のコードでメモ化します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
DSA Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン2/4です。
「メモ化によるトップダウンDP」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このDSA Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- DPを見抜く:重複する部分問題
- メモ化によるトップダウンDP
- 表形式によるボトムアップDP
- Coin Changeと最小コストの階段