DP 알아보기: 중복되는 하위 문제
무차별 대입 재귀가 같은 하위 문제를 다시 푸는 경우를 식별하고, 피보나치 재귀 트리를 그리며 지수적 계산 증가를 확인합니다.
DP 알아보기: 중복되는 하위 문제은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DSA Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
동적 계획법이란 무엇인가
동적 계획법 (DP)은 복잡한 문제를 서로 겹치는 더 간단한 부분 문제로 나누고, 각 부분 문제를 한 번만 해결한 뒤 그 결과를 저장하여 중복 계산을 피하는 방법입니다. DP를 적용하려면 두 가지 요소가 필요합니다. 겹치는 부분 문제(단순한 재귀에서 같은 부분 문제가 여러 번 해결됨)와 최적 부분 구조(최적 해를 부분 문제의 최적 해로 구성할 수 있음)입니다. 두 요소가 모두 없으면 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)의 재귀 트리를 그리면 낭비가 드러납니다. 각 노드는 두 개의 자식을 만들고, 동일한 하위 트리가 반복해서 나타납니다. 트리의 전체 노드 수는 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)')겹치는 부분 문제 식별하기
겹치는 부분 문제를 알아보려면 먼저 완전 탐색 재귀를 작성한 다음, '여러 재귀 호출이 동일한(SAME) 인수를 사용하는가?'라고 질문해 보십시오. 그렇다면 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칸 또는 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 프레임워크입니다. 1. 상태를 정의합니다 — dp[i](또는 dp[i][j])는 무엇을 나타내나요? 영어로 작성합니다. 2. 점화식을 작성합니다 — dp[i]를 더 작은 부분 문제로 표현합니다. 모든 경우를 포함합니다. 3. 계산 순서를 정합니다 — dp[i]를 계산하기 전에 dp[i-1](및 다른 의존 상태)이 먼저 계산되도록 합니다. 기저 사례는 경계 값을 초기화합니다. 이 프레임워크를 사용하면 막연한 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가 항상 정답은 아닙니다. 하나의 국소 최적 선택이 항상 전역 최적해로 이어지는 경우에는 탐욕법을 사용합니다(활동 선택, 점프 게임 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차원 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')하우스 로버: 겹치는 선택
하우스 로버(LeetCode #198)는 일렬로 늘어선 집들에서 인접한 집을 털지 않고 훔칠 수 있는 최대 금액을 구하는 문제입니다. 각 집에서 다음 중 하나를 선택합니다. 집을 털거나(그 집의 금액을 더하고 이전 집은 건너뜁니다), 집을 건너뛰고(이전까지의 최선의 결과를 선택합니다) 다음으로 진행합니다. 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}')빠른 확인
이번 레슨에서 다룬 자료 구조 및 알고리즘 & 코딩 면접 준비 개념에 대한 이해도를 확인해 보세요.
레슨 요약
이번 레슨에서는 DP의 두 가지 핵심 요소(겹치는 부분 문제와 최적 부분 구조), 재귀 트리 시각화를 통한 반복 호출 식별 방법, 3단계 DP 프레임워크(상태 정의, 점화식, 계산 순서), 그리고 피보나치, 계단 오르기, 하우스 로버를 비롯한 첫 번째 예제들을 배웠습니다. 다음에는 메모이제이션을 사용한 하향식 DP를 구현합니다.
자주 묻는 질문
“DP 알아보기: 중복되는 하위 문제” 강의는 무료인가요?
네 — “DP 알아보기: 중복되는 하위 문제” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“DP 알아보기: 중복되는 하위 문제”에서 뭘 배우나요?
무차별 대입 재귀가 같은 하위 문제를 다시 푸는 경우를 식별하고, 피보나치 재귀 트리를 그리며 지수적 계산 증가를 확인합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.
“DP 알아보기: 중복되는 하위 문제” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- DP 알아보기: 중복되는 하위 문제
- 메모이제이션을 이용한 하향식 DP
- 표 작성을 이용한 상향식 DP
- 동전 교환과 최소 비용 계단