에듀테크 프로젝트 대시보드
동적 계획법 (Dynamic Programming)
큰 문제를 작은 부분문제로 쪼갰더니, 같은 부분문제가 몇 번이고 다시 등장한다.
매번 다시 푸는 대신 한 번 푼 답을 표(table)에 적어두고 필요할 때 꺼내 쓰면
지수 시간이 다항 시간으로 줄어든다. 이것이 동적 계획법이다.
두 조건이 필요하다 — 겹치는 부분문제(overlapping subproblems) 와
최적 부분구조(optimal substructure).
fib(n) = fib(n-1) + fib(n-2) 를 그대로 재귀로 계산하면
fib(n-2) 는 두 번, fib(n-3) 은 세 번… 호출된다.
호출 수는 대략 1.6ⁿ 로 늘어난다. 이미 계산한 값을 배열에 저장(메모이제이션)하면
각 fib(k) 를 딱 한 번만 계산한다.
n=30 이면 단순 재귀는 260만 회, 메모이제이션은 59회. n을 1씩 올려보며 격차를 확인해 보라.
주어진 동전들로 금액 A 를 만들 때 필요한 최소 동전 개수는?
dp[x] = 금액 x 를 만드는 최소 개수.
dp[x] = min(dp[x - coin] + 1) — 마지막에 낸 동전이 coin 이라고 가정하고
남은 금액 x-coin 의 답에 1을 더한다. dp[0]=0 에서 시작해 작은 금액부터 채운다.
주황 = 지금 계산 중인 dp[x] · 파랑 = 참조 중인 dp[x-coin] · 초록 = 최종 답.
탐욕법(제일 큰 동전부터)은 1,3,4 로 6을 만들 때 4+1+1=3개 를 내지만
DP는 3+3=2개 를 찾는다.
무게 한도 W 의 배낭에 물건을 담아 가치 합을 최대로. 각 물건은 넣거나 안 넣거나(0/1).
dp[i][w] = 앞에서 i 개 물건만 고려하고 한도가 w 일 때 최대 가치.
dp[i][w] = max( dp[i-1][w], dp[i-1][w - wᵢ] + vᵢ ) —
i번째를 안 넣기 vs 넣기 중 큰 값.
행 = 물건을 하나씩 추가, 열 = 무게 한도 0…W. 각 칸은 바로 윗칸(안 넣기)과 왼쪽 위 어딘가(넣기)만 참조한다 → 이미 푼 부분문제 재활용.
재귀를 그대로 두되, 결과를 memo 에 저장. 처음 보는 부분문제만 실제로 계산.
필요한 부분문제만 방문한다는 장점.
가장 작은 부분문제부터 반복문으로 표를 채운다. 재귀 호출 스택이 없어 빠르고 스택 오버플로가 없다.
dp[...] 가 정확히 무엇을 의미하는지 한 문장으로 정의할 수 있어야 한다.
그다음 점화식과 초기값이 따라온다.
부분문제가 겹치지 않으면(예: 병합 정렬) 그냥 분할 정복이다. 부분 최적이 전체 최적을 보장하지 않으면(예: 일부 그래프) DP를 못 쓴다.
# 피보나치 — Top-Down 메모이제이션 from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) # 같은 n은 한 번만 실제 계산 # 동전 교환 — Bottom-Up 1차원 DP, O(A · 동전수) def min_coins(coins, amount): INF = float('inf') dp = [0] + [INF] * amount # dp[0]=0, 나머지는 아직 모름 for x in range(1, amount + 1): for c in coins: if c <= x: dp[x] = min(dp[x], dp[x - c] + 1) return dp[amount] if dp[amount] != INF else -1 # 0/1 배낭 — Bottom-Up 2차원 DP, O(n · W) def knapsack(items, W): # items = [(weight, value), ...] n = len(items) dp = [[0] * (W + 1) for _ in range(n + 1)] for i in range(1, n + 1): wi, vi = items[i - 1] for w in range(W + 1): dp[i][w] = dp[i - 1][w] # i번째 안 넣기 if wi <= w: dp[i][w] = max(dp[i][w], dp[i - 1][w - wi] + vi) # 넣기 return dp[n][W]
세 문제 모두 패턴이 같다 — ① dp 의 의미를 정의하고 ② 점화식을 세우고
③ 초기값을 넣고 ④ 작은 것부터 채운다.