메모이제이션을 이용한 하향식 DP
재귀 해법에 메모 딕셔너리를 추가해 중복 호출을 가지치기하고, @lru_cache로 최소한의 코드로 메모이제이션을 적용합니다.
메모이제이션을 이용한 하향식 DP은(는) CoddyKit의 무료 Coding Interview Prep 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Coding Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Coding 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)이 필요하므로 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 사용하기
파이썬의 @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하향식 동전 교환
동전 교환(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))입니다. 즉, 두 문자열 중 하나에서 문자 하나를 건너뜁니다. (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: 무엇을 선택할까요
함수 인수가 해시 가능한 기본 자료형(정수, 문자열, 튜플)일 때는 @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하향식 타깃 합
타깃 합(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하향식과 상향식: 장단점
하향식(메모이제이션)의 장점은 자연스럽게 작성할 수 있고(재귀 풀이에서 시작하므로), 실제로 필요한 부분 문제만 계산하며(지연 계산), 캐시를 점진적으로 추가하기 쉽다는 점입니다. 상향식(표 작성법)의 장점은 호출 스택 오버헤드가 없고(파이썬 재귀 제한에 걸리지 않음), 메모리 접근이 캐시에 더 친화적이며, 공간을 최적화하기 쉽다는 점입니다. 두 방식의 점근적 복잡도는 같습니다. 면접에서는 먼저 하향식으로 정답성을 확인한 다음, 더 나은 공간 복잡도를 요구받으면 상향식으로 변환하세요.
# 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를 사용한 단어 분할
단어 분할(LeetCode #139)은 문자열 s를 사전에 있는 단어들로 나눌 수 있는지 묻는 문제입니다. 상태는 dp(i)이며, s[i:]를 분할할 수 있는지를 나타냅니다. 인덱스 i부터 모든 단어를 시도합니다. s[i:i+len(w)] == w이면 남은 접미 문자열에 대해 재귀 호출을 수행합니다. 시작 인덱스를 기준으로 메모이제이션하면 집합 포함 여부 확인과 함께 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재귀 제한과 반복 도구
파이썬의 기본 재귀 제한은 1000이며 sys.getrecursionlimit()로 설정된 값을 확인할 수 있습니다. 큰 입력(n = 10,000 이상)에 대한 DP 문제에서는 하향식 메모이제이션이 이 제한에 도달합니다. 방법은 두 가지입니다. 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빠른 확인
이번 레슨에서 다룬 자료 구조 및 알고리즘 & 코딩 면접 준비 개념에 대한 이해도를 확인해 보세요.
레슨 요약
이번 레슨에서는 메모 사전과 @lru_cache 데코레이터를 사용한 하향식 DP, 피보나치, 동전 교환, LCS, 타깃 합, 단어 분할의 메모이제이션 풀이, 그리고 하향식과 상향식 중 선택하는 기준을 배웠습니다. 다음에는 표 작성법과 공간 최적화를 사용한 상향식 DP를 구현합니다.
자주 묻는 질문
“메모이제이션을 이용한 하향식 DP” 강의는 무료인가요?
네 — “메모이제이션을 이용한 하향식 DP” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Coding Interview Prep 강의 전체를 잠금 해제할 수 있습니다. Coding Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“메모이제이션을 이용한 하향식 DP”에서 뭘 배우나요?
재귀 해법에 메모 딕셔너리를 추가해 중복 호출을 가지치기하고, @lru_cache로 최소한의 코드로 메모이제이션을 적용합니다. 브라우저에서 직접 실행하는 실습 코드로 Coding Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Coding Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Coding Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.
“메모이제이션을 이용한 하향식 DP” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Coding Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Coding Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- DP 알아보기: 중복되는 하위 문제
- 메모이제이션을 이용한 하향식 DP
- 표 작성을 이용한 상향식 DP
- 동전 교환과 최소 비용 계단