포스트

동적계획법 — 같은 계산을 두 번 하지 않는다는 단 하나의 원칙

동적 계획법의 핵심(최적 부분 구조·중복 부분 문제), 메모이제이션 vs 타뷸레이션, LIS·배낭·편집 거리. DP 표가 채워지는 과정을 애니메이션으로 봅니다.

동적계획법 — 같은 계산을 두 번 하지 않는다는 단 하나의 원칙

지수에서 다항으로 — 마법이 아니라 기억이다

피보나치를 재귀로 그냥 짜면 fib(n)을 구하는 데 $O(2^n)$이 듭니다. fib(50)이면 약 10억 번. 그런데 똑같은 문제를 동적계획법(Dynamic Programming, DP)으로 풀면 $O(n)$입니다. 무엇이 달라졌을까요? 알고리즘이 더 똑똑해진 게 아니라, 같은 부분문제를 두 번 계산하지 않기로 했을 뿐입니다.

DP가 성립하려면 문제가 두 성질을 가져야 합니다.

  • 최적 부분구조(optimal substructure): 큰 문제의 최적해가 작은 부분문제의 최적해로 조립된다.
  • 중복 부분문제(overlapping subproblems): 같은 부분문제가 재귀 과정에서 반복해서 등장한다.

이 둘이 있으면, 부분문제의 답을 한 번 구해 저장(기억) 해두고 재사용하면 됩니다. (분할정복은 부분문제가 겹치지 않을 때 쓰는 사촌입니다 — 거기선 저장할 게 없습니다.)

중복 부분문제를 눈으로 보기

fib(5)의 재귀 호출 트리를 펼치면 fib(2)가 세 번, fib(3)이 두 번 등장합니다. 이 중복을 메모이제이션으로 잘라내면(한 번 계산한 노드는 캐시에서 즉시 반환) 트리가 가지치기됩니다. 아래에서 빨간 노드가 “캐시 히트로 더 내려가지 않는” 지점입니다.

f(5) f(4) f(3) f(3) f(2) f(2) f(1) 캐시 히트 → 재계산 안 함

같은 부분문제 수는 fib의 경우 고작 n+1개. 그래서 각 부분문제를 한 번씩만 풀면 $O(n)$이 됩니다. DP의 시간 복잡도 = (부분문제 개수) × (한 부분문제를 푸는 비용) 이라는 공식이 여기서 나옵니다.

Top-down vs Bottom-up — 같은 표를 거꾸로 채울 뿐

DP를 구현하는 두 방식은 본질이 같습니다. 무엇을 어떤 순서로 채우느냐만 다릅니다.

flowchart LR
    P["원래 문제"] -->|재귀 하강| M["메모이제이션<br/>Top-down<br/>필요한 것만 캐시"]
    P -->|반복 상승| T["타뷸레이션<br/>Bottom-up<br/>작은 것부터 표 채움"]
    M --> R["같은 부분문제 집합·같은 답"]
    T --> R
 메모이제이션 (Top-down)타뷸레이션 (Bottom-up)
흐름재귀 + 캐시반복문으로 표를 작은 것부터
계산 범위필요한 부분문제만표 전체(불필요한 칸도)
장점점화식 그대로 직관적함수호출 오버헤드·스택 없음
함정깊은 재귀 → 스택 오버플로채우는 순서(의존성) 를 직접 보장
1
2
3
4
5
6
7
8
9
10
11
# 0/1 배낭 — bottom-up. dp[i][w] = i번째까지 고려, 용량 w일 때 최대 가치
def knapsack(weights, values, W):
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for w in range(W + 1):
            dp[i][w] = dp[i-1][w]                       # i번째 안 담음
            if weights[i-1] <= w:                        # 담을 수 있으면
                cand = dp[i-1][w - weights[i-1]] + values[i-1]
                dp[i][w] = max(dp[i][w], cand)           # 담는 게 이득이면 갱신
    return dp[n][W]

표가 채워지는 방향이 곧 점화식이다

DP에서 가장 중요한 직관은 각 칸이 어떤 칸에 의존하는가입니다. 편집 거리(두 문자열을 같게 만드는 최소 삽입·삭제·교체 횟수)는 각 칸이 위·왼쪽·대각선 세 칸에서 만들어집니다. 아래는 표가 좌상단부터 채워지며 의존 화살표가 흐르는 모습입니다.

dp[i][j] = 위·왼쪽·대각 중 최소 + 1 (대각은 글자 같으면 +0)

이 의존 방향이 채우는 순서를 강제합니다. 의존하는 칸이 먼저 채워져 있어야 하니까요. Bottom-up에서 루프 순서를 잘못 잡으면 아직 안 채운 칸을 읽어 오답이 나는 게 이 때문입니다.

공간 최적화 — 시간을 사고 공간을 줄인다

복잡도 분석에서 본 시간↔공간 트레이드오프가 DP의 핵심입니다. 위 배낭은 dp[i]가 dp[i-1] 행에만 의존하므로, 2차원 표를 1차원 배열로 줄여 $O(nW)$ 공간을 $O(W)$로 낮출 수 있습니다(단, 안쪽 루프를 역순으로 돌려 같은 행을 덮어쓰지 않게).

대표 문제들의 점화식 요약:

문제상태 정의점화식 핵심복잡도
LIS(최장 증가 부분수열)dp[i] = i로 끝나는 LIS 길이max(dp[j])+1, j<i & a[j]<a[i]$O(n^2)$ / 이분탐색 $O(n\log n)$
0/1 배낭dp[i][w] = i까지·용량 w 최대가치담음/안담음 max$O(nW)$
편집 거리dp[i][j] = 접두사 i,j 최소 편집위·왼·대각 min$O(nm)$
행렬 곱 순서dp[i][j] = 구간 곱 최소비용분할점 k로 min$O(n^3)$

LIS를 $O(n\log n)$으로 줄이는 비밀이 바로 이분 탐색입니다 — “길이 k인 증가 수열의 마지막 원소 최솟값” 배열에 lower_bound를 박습니다. DP와 다른 알고리즘이 결합하는 전형입니다.

프로덕션에서 마주치는 함정

함정증상해법
상태 설계 오류부분문제가 미래에 의존(무한 루프/오답)상태에 “지금까지의 결정”을 충분히 포함
메모이제이션 키 충돌다른 상태가 같은 캐시 키모든 차원을 키에 포함(튜플)
깊은 재귀 스택 오버플로top-down에서 n 큼bottom-up 전환 또는 명시적 스택
채우는 순서 오류bottom-up이 빈 칸을 읽음의존성 위상 순서대로 루프
의사 다항(pseudo-poly)배낭 $O(nW)$인데 W가 거대W는 값이라 입력 비트수엔 지수 — 주의

면접/리뷰 단골 질문

  • Q. DP가 성립하는 조건? → 최적 부분구조 + 중복 부분문제. 둘 다 있어야 저장-재사용이 의미 있음.
  • Q. 분할정복과 DP의 차이? → 부분문제가 겹치면 DP(저장), 안 겹치면 분할정복. 겹침 여부가 갈림길.
  • Q. top-down vs bottom-up? → 같은 부분문제 집합. top-down은 필요한 것만·재귀 직관적, bottom-up은 스택 없음·순서 직접 보장.
  • Q. 0/1 배낭이 진짜 다항인가? → 아니다. $O(nW)$는 W(값)에 다항이라 입력 비트수엔 지수 — 의사 다항. NP-hard.
  • Q. DP 시간 복잡도 어림? → (부분문제 수) × (한 부분문제 비용). 표 크기 × 전이 비용.

정리

  • DP는 최적 부분구조 + 중복 부분문제일 때, 부분문제 답을 저장해 지수를 다항으로 낮춘다.
  • 메모이제이션(top-down)과 타뷸레이션(bottom-up)은 같은 표를 다른 순서로 채우는 것뿐.
  • 각 칸의 의존 방향이 점화식이자 채우는 순서다 — 순서를 틀리면 빈 칸을 읽는다.
  • 시간↔공간 트레이드오프로 행 차원을 접어 공간을 줄인다. LIS는 이분탐색과 결합해 $O(n\log n)$.

다음 글은 그리디·허프만 코딩입니다. “전체를 보고 표를 채우는” DP와 달리, 매 순간 눈앞의 최선만 집고도 전역 최적에 도달하는 조건을 따져봅니다. 이전 글은 최소 신장 트리·Union-Find.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 알고리즘 A-Z (20편)

  1. 복잡도 분석 — Big-O, 점근 표기, 그리고 분할상환이라는 거짓말 같은 진실
  2. 배열과 연결 리스트 — 같은 O(n)인데 왜 한쪽이 수십 배 느린가
  3. 스택·큐·힙 — 접근 규칙 하나가 만드는 강력함
  4. 해시 테이블 — 평균 O(1)의 비밀과 그 평균이 깨지는 순간
  5. 비교 정렬 — 퀵·머지·힙, 그리고 Ω(n log n)이라는 넘을 수 없는 벽
  6. 선형 시간 정렬 — 비교를 버리면 Ω(n log n) 벽이 사라진다
  7. 이진 탐색 — 반씩 버리는 마법, 그리고 "답을 이분하라"
  8. 재귀와 분할정복 — 콜스택에 쌓이는 신뢰, 마스터 정리로 푸는 비용
  9. 이진 탐색 트리와 균형 — 한쪽으로 쏠리는 트리를 회전으로 바로 세우기
  10. 그래프 순회 — BFS의 물결, DFS의 깊이, 그리고 위상정렬
  11. 최단 경로 — 다익스트라의 확신, A*의 직감, 벨만-포드의 인내
  12. 최소 신장 트리와 Union-Find — 가장 싸게 잇기, 그리고 거의 O(1)의 마법
  13. 동적계획법 — 같은 계산을 두 번 하지 않는다는 단 하나의 원칙 지금 보는 글
  14. 그리디 — 눈앞의 최선만 집었는데 전체가 최적일 때
  15. 문자열 매칭 — 어긋났을 때 얼마나 똑똑하게 다시 시작하는가
  16. 확률적 자료구조 — 정확함을 메모리와 맞바꾸는 기술 (Bloom·HyperLogLog·Count-Min)
  17. 일관된 해싱 — 노드가 들고날 때 데이터를 통째로 옮기지 않는 법
  18. 합의 알고리즘 — 죽는 서버들 사이에서 하나의 진실을 정하는 법 (Raft·Paxos)
  19. 레이트 리미팅과 스트리밍 알고리즘 — 폭주를 막고, 끝없는 흐름에서 표본을 뽑다
  20. 의미로 검색하다 — 벡터 검색(ANN·HNSW)과 PageRank, 그리고 시리즈의 끝