メモ化:再帰結果をキャッシュする
@functools.lru_cacheと手動のmemo辞書をFibonacciやclimbing-stairsに適用し、指数関数的な再計算をなくします。
「メモ化:再帰結果をキャッシュする」はCoddyKit上の無料Coding Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはCoding Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Coding Interview Prepコースには全4レッスンが含まれています。
重複した再帰の問題
素朴な再帰によるフィボナッチ数列では、同じ値が何度も計算されます。fib(5) は fib(4) と fib(3) を呼び出し、fib(4) は fib(3) と fib(2) を呼び出すため、fib(3) は 2 回計算されます。この重複は指数関数的に増加し、fib(40) では 10 億回を超える関数呼び出しが発生します。メモ化では、各結果を最初に計算したときに保存することでこの問題を解決します。そのため、後続の呼び出しでは再計算せず O(1) で結果を取得できます。
# Count calls without memoisation
call_count = [0]
def fib_plain(n):
call_count[0] += 1
if n <= 1: return n
return fib_plain(n-1) + fib_plain(n-2)
fib_plain(20)
print(f'fib(20) without memo: {call_count[0]:,} calls')
# ~21,891 calls for n=20; ~1 billion for n=40Dictを使った手動メモ化
パラメータ(またはクロージャ)として memo 辞書を追加します。計算する前に、答えがすでに memo にあるか確認します。あれば直ちに返し、なければ計算して memo に保存してから返します。これにより、異なる各部分問題は正確に 1 回だけ計算され、計算量は O(2^n) から、memo 辞書に O(n) の空間、さらにスタック空間に O(n) を使う O(n) の時間へ変わります。
def 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]
print(fib_memo(10)) # 55
print(fib_memo(50)) # 12586269025
print(fib_memo(100)) # huge number — still fast!functools.lru_cache デコレーター
Python には、メモ化を自動化する @functools.lru_cache(maxsize=None)(Python 3.9 以降では @functools.cache としても利用可能)が用意されています。このデコレーターを関数の上に追加すると、引数ごとにすべての呼び出しがキャッシュされます。maxsize=None はキャッシュサイズが無制限であることを意味し、異なる引数の組み合わせがすべてキャッシュされます。これにより、どのような再帰関数でも 1 行のコードでメモ化版に変換できます。
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)) # 354224848179261915075
print(fib.cache_info()) # CacheInfo(hits=..., misses=..., maxsize=None, currsize=...)階段を上る(LeetCode 70)
LeetCode 70「階段を上る」では、1 回に 1 段または 2 段上ることができます。段 n に到達する方法は何通りあるでしょうか。これは形を変えたフィボナッチ数列です。ways(n) = ways(n-1) + ways(n-2)。ベースケースは ways(0) = 1(地上にとどまる方法が 1 通り)と ways(1) = 1 です。メモ化すると、時間計算量は O(n)、空間計算量は O(n) です。
import functools
@functools.lru_cache(maxsize=None)
def climbStairs(n):
if n <= 1:
return 1
return climbStairs(n-1) + climbStairs(n-2)
for i in range(1, 8):
print(f'climbStairs({i}) = {climbStairs(i)}')
# 1,2,3,5,8,13,21コイン交換(LeetCode 322)
LeetCode 322「コイン交換」では、額面の集合と目標金額が与えられたとき、必要なコインの最小枚数を求めます。トップダウンのメモ化再帰では、有効な各コインについて dp(amount) = 1 + min(dp(amount - coin)) を計算します。ベースケースは dp(0) = 0 です。各部分金額をキャッシュします。部分金額を作れない場合は無限大を返します。メモ化により、指数時間の総当たり法を O(amount × len(coins)) 時間に変換できます。
import functools
def coinChange(coins, amount):
@functools.lru_cache(maxsize=None)
def dp(rem):
if rem == 0:
return 0
if rem < 0:
return float('inf')
return 1 + min(dp(rem - c) for c in coins)
result = dp(amount)
return result if result != float('inf') else -1
print(coinChange([1, 5, 11], 15)) # 3 (5+5+5)
print(coinChange([1, 2, 5], 11)) # 3 (5+5+1)
print(coinChange([2], 3)) # -1メモ化を用いた単語分割(LeetCode 139)
LeetCode 139「単語分割」では、文字列を辞書にある単語へ分割できるか判定します。トップダウンの再帰では、can_break(s, start) がすべての接頭辞 s[start:end] を試します。その接頭辞が辞書に含まれていて、can_break(s, end) が true なら true を返します。メモ化しない場合は O(2^n) ですが、メモ化(各開始インデックスをキャッシュ)すると、最大単語長を L として O(n² × L) になります。
import functools
def wordBreak(s, wordDict):
word_set = set(wordDict)
@functools.lru_cache(maxsize=None)
def can_break(start):
if start == len(s):
return True
for end in range(start + 1, len(s) + 1):
if s[start:end] in word_set and can_break(end):
return True
return False
return can_break(0)
print(wordBreak('leetcode', ['leet', 'code'])) # True
print(wordBreak('applepenapple', ['apple','pen'])) # True
print(wordBreak('catsandog', ['cats','dog','sand','and','cat'])) # Falseメモ化とタブレーション
メモ化(トップダウン)は元の問題から始め、再帰で発見した答えをキャッシュします。実際に必要な部分問題だけを解きます。タブレーション(ボトムアップ)は、小さな部分問題から大きな部分問題へ向かって表をあらかじめ埋め、必要かどうかにかかわらずすべての部分問題を解きます。メモ化は再帰解から導出しやすく、タブレーションは再帰の深さ制限と関数呼び出しのオーバーヘッドを回避できます。
# Memoisation (top-down)
import functools
@functools.lru_cache(maxsize=None)
def fib_td(n):
if n <= 1: return n
return fib_td(n-1) + fib_td(n-2)
# Tabulation (bottom-up)
def fib_bu(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]
print(fib_td(20), fib_bu(20)) # 6765 6765
# Both O(n) time; fib_bu avoids recursion limit空間計算量の最適化:変数のローリング
メモ化再帰で O(n) の空間を使う多くの DP 問題は、必要な過去の部分問題の答えが一定数だけであれば、空間計算量を O(1) までさらに最適化できます。フィボナッチ数列では、直近 2 つの値だけが重要です。階段を上る問題でも同じです。2 つの変数を使って値を順に更新することで、memo 辞書や表全体を置き換えられます。
# Fibonacci with O(1) space
def fib_o1(n):
if n <= 1:
return n
prev2, prev1 = 0, 1
for _ in range(2, n + 1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
for i in range(8):
print(f'fib({i})={fib_o1(i)}', end=' ')
print()
# Climbing stairs O(1) space
def climbStairs_o1(n):
if n <= 1: return 1
a, b = 1, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
print(climbStairs_o1(10)) # 89lru_cache、クロージャ、グローバル辞書の比較
メモ化を手動で実装する方法は 3 つあります。グローバル辞書は単純ですが、モジュールのスコープを汚染します。クロージャは関数内にキャッシュをカプセル化するため、外部への漏出を防げますが、ラッパーが必要です。@lru_cacheは最もすっきりした方法で、すべての定型コードを 1 つのデコレーターに置き換えます。面接で手動実装を特に求められていない限り、まずは @lru_cache を使ってください。
import functools
# 1. Global dict (messy)
memo_global = {}
def fib_global(n):
if n in memo_global: return memo_global[n]
if n <= 1: return n
memo_global[n] = fib_global(n-1) + fib_global(n-2)
return memo_global[n]
# 2. Closure (cleaner scope)
def make_fib():
cache = {}
def fib(n):
if n in cache: return cache[n]
if n <= 1: return n
cache[n] = fib(n-1) + fib(n-2)
return cache[n]
return fib
fib_closure = make_fib()
# 3. lru_cache (best)
@functools.lru_cache(maxsize=None)
def fib_cached(n):
if n <= 1: return n
return fib_cached(n-1) + fib_cached(n-2)
print(fib_global(30), fib_closure(30), fib_cached(30)) # all 832040メモ化が役立たない場合
メモ化が高速化できるのは、重複する部分問題、つまり同じ部分問題が複数回計算される問題だけです。すべての部分問題が一意である場合(各ノードを正確に 1 回ずつ訪問する単純な木の走査など)、メモ化はメリットなしにオーバーヘッドを追加します。また、再帰木の指数性が再利用ではなく異なる部分問題の数に対して生じている問題を、メモ化で解決することはできません。その場合は、まったく別のアルゴリズムが必要です。
# Memoisation DOES help: overlapping sub-problems (Fibonacci)
# fib(n) reuses fib(n-2), fib(n-3), etc.
# Memoisation does NOT help: distinct sub-problems (permutations)
# Each unique (remaining_elements, target) pair is truly distinct
# The exponential complexity comes from the state space itself
print('Memoisation: useful when SAME sub-problem recurs multiple times')
print('Not useful: when every sub-problem is unique to one recursive path')まとめ:メモ化チェックリスト
次の条件を満たす場合はメモ化を適用してください。正しいものの重複した再計算が原因で遅い再帰解があること、異なる引数の組み合わせが少ないこと、そして戻り値が引数だけに依存すること(副作用やグローバル状態がない純粋関数)。部分問題の状態空間を確認してください。異なる状態が最大で O(n) または O(n²) 個であれば、メモ化によって指数時間を多項式時間に変換できます。
理解度チェック
このレッスンで扱った Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。
レッスンのまとめ
このレッスンでは、メモ化によって部分問題の結果を保存し、再計算を避けることで、指数時間の再帰を多項式時間に変換できること、@functools.lru_cache は 1 行だけで使える Python の標準的なツールであること、そしてメモ化(トップダウン)とタブレーション(ボトムアップ)は DP の 2 つの方式であり、メモ化は導出しやすく、タブレーションはスタックの深さに関する問題を回避できることを学びました。おめでとうございます。これで再帰とハッシュマップのモジュールは完了です。
よくある質問
「メモ化:再帰結果をキャッシュする」レッスンは無料ですか?
はい。「メモ化:再帰結果をキャッシュする」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Coding Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Coding Interview Prepコースには全4レッスンが含まれています。
「メモ化:再帰結果をキャッシュする」で何を学びますか?
@functools.lru_cacheと手動のmemo辞書をFibonacciやclimbing-stairsに適用し、指数関数的な再計算をなくします。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
Coding Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのCoding Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。
「メモ化:再帰結果をキャッシュする」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このCoding Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのCoding Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- 再帰の枠組み:基底ケース、信頼、構築
- コールスタックを可視化する
- 再帰と反復のトレードオフ
- メモ化:再帰結果をキャッシュする