본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

테이블화: Bottom-Up DP

~11 min · dynamic-programming, tabulation, bottom-up

Level 0호기심 많은 입문자
0 XP0/85 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"메모이제이션은 아래로 재귀하면서 기억해. 테이블화는 그걸 뒤집어. 가장 작은 답에서 시작해 위로 쌓고, 큰 답이 나올 때까지 테이블을 채워. 같은 DP를 반대 방향으로, 재귀는 하나도 없이."

아래로 재귀하는 대신 위로 짓기

테이블화(tabulation)는 bottom-up 동적 계획법이야. 큰 문제에서 base case로 재귀해 내려가는 대신, base case에서 출발해 테이블을 앞으로 채워 나가. 각 항목은 이미 채운 항목들로 계산하고, 원하는 답에 닿으면 멈춰. 재귀는 없어. 테이블 위를 도는 평범한 루프야. base case가 씨앗이고 마지막 칸이 결과야. 피보나치를 bottom-up으로 하면 dp[0]=0, dp[1]=1로 시작해서 dp[i] = dp[i-1] + dp[i-2]를 n까지 돌리는 거지.

새로운 도전은 딱 하나, 채우는 순서

테이블화의 함정은 순서야. 어떤 항목을 계산하는 시점에 그 항목이 의존하는 값들이 전부 이미 채워져 있도록 순서를 잡아야 해. 피보나치야 왼쪽에서 오른쪽으로, 자명하지. 편집 거리 같은 2D DP라면 행 단위로, 혹은 의존성만 존중한다면 어떤 순서든 좋아. 메모이제이션에서는 이 순서를 신경 쓸 일이 없었어. 재귀가 의존성을 필요한 시점에 알아서 계산해 줬으니까. 테이블화는 그 순서를 직접 생각하게 만들어. 그게 거래야. 재귀를 덜어내는 대가로, 설계 노력을 앞쪽에 더 들이는 거지.

테이블화 = bottom-up DP. base case를 씨앗으로 심고, 각 항목을 이미 계산된 항목들로 채우면서 답에 닿을 때까지 테이블을 앞으로 밀어. 재귀가 없으니 스택 한계도 없고 종종 더 빠르지만, 의존성을 존중하는 채우기 순서를 직접 골라야 하고 필요 없는 상태까지 전부 계산해.

공간의 승리: 필요한 만큼만 들고 가기

테이블화는 메모이제이션이 쉽게 따라오지 못하는 아름다운 최적화 하나를 열어 줘. 테이블 항목이 마지막 행이나 마지막 몇 칸만 읽는 문제가 수두룩하거든. 그렇다면 테이블 전체를 메모리에 둘 이유가 없어. 그 슬라이딩 윈도우만 있으면 돼. 피보나치는 앞의 두 값만 필요하니 O(n) 테이블이 변수 두 개, O(1) 공간으로 주저앉아. O(n×m) 격자가 필요해 보이는 2D DP도 막상 열어 보면 바로 앞 행만 읽는 놈이 태반이라 공간이 O(m)으로 떨어져. '마지막 K칸에만 의존하네'를 알아보는 눈이 DP 공간 비용을 극적으로 줄이는 방법이야. 매번 찾아볼 가치가 있는 반복 트릭이지.

메모이제이션이냐 테이블화냐?

둘은 같은 답을 계산해. 그러니 제약으로 골라. 메모이제이션은 쓰기 쉽고(재귀에 캐시만 얹으면 되니까), 지연이라 필요한 상태만 계산하지만, 재귀 깊이에 묶여. 테이블화는 재귀 한계가 없고 종종 더 빠르고 위의 공간 최적화까지 되지만, 채우기 순서를 설계해야 하고 모든 상태를 계산해. 흔한 작업 흐름은 이래. 메모이제이션으로 프로토타입을 만들어 점화식부터 맞게 잡고, 깊이 안전이나 공간 절약이 필요해지면 테이블화로 변환해. 같은 DP고, 상황 따라 골라 쓰는 거야.

피파의 고백

큰 입력만 들어오면 재귀 한계에 부딪히는 메모이제이션 DP가 있었어. 내 본능은 한계를 올리는 쪽이었지. 재귀 트랙에서 배운 바로 그 함정. 아빠가 방향을 돌려 줬어. bottom-up 테이블로 변환하니 재귀가 없고, 한계도 없었어. 그런데 다시 쓰는 과정이 채우기 순서를 난생처음 생각하게 만들었고, 그제야 DP가 온전히 손에 잡혔어. 메모이제이션은 의존성 구조를 나한테서 감추고 있었던 거야. 테이블화는 재귀가 알아서 찾아 주리라 믿는 대신, 계산의 모양을 직접 보게 했어.

Code

테이블화, 공간 트릭, 그리고 동전 교환·python
# 테이블화: base case 에서 답을 위로 짓기, 재귀 없음.
def fib_table(n):
    if n < 2: return n
    dp = [0] * (n + 1)
    dp[0], dp[1] = 0, 1            # base case 가 테이블 씨앗
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]  # 각 항목을 이미 채운 것으로
    return dp[n]

# 공간-최적화: 각 항목이 마지막 둘만 필요, 그래서 테이블 버림.
def fib_two_vars(n):              # O(n) 대신 O(1) 공간
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b           # 두 값의 윈도우를 앞으로 밀어
    return a

print(fib_table(10), fib_two_vars(10))   # 55 55

# 동전 교환, bottom-up: dp[a] = 금액 a 만드는 최소 동전.
def coin_change(coins, amount):
    dp = [0] + [float('inf')] * amount      # dp[0]=0. 나머지 미지
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a:
                dp[a] = min(dp[a], dp[a-c] + 1)   # 순서대로 앞으로 채움
    return dp[amount] if dp[amount] != float('inf') else -1

print(coin_change([1, 3, 4], 6))   # 2 — 메모이제이션 버전이랑 같은 답

External links

Exercise

'계단 오르기' DP(ways(n) = ways(n-1) + ways(n-2))를 메모이제이션에서 bottom-up 테이블화로 변환해 봐. 채우기 순서와 base case를 적어. 그다음 공간을 O(1)로 최적화해 봐. 테이블 전체 대신 뭘 유지하면 되고, 왜 그걸로 충분해?
Hint
테이블화는 dp[0]=1, dp[1]=1로 시작해서 i를 2부터 n까지 dp[i]=dp[i-1]+dp[i-2]로 왼쪽에서 오른쪽으로 채워. 각 항목이 자기보다 앞 칸에만 의존하니까. 공간 최적화는 dp[i]가 바로 앞 두 값만 읽는다는 점을 이용해 변수 두 개(a, b)를 앞으로 밀면 돼. 마지막 둘보다 오래된 값은 다시 읽을 일이 없으니 O(1) 공간으로 충분해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.