나뭇잎 교과서

동적 계획법

계단 오르기의 방법 수를 재귀 함수로 구하면 n이 40만 되어도 한참 기다려야 합니다. 같은 계산을 수없이 되풀이하기 때문입니다. 한 번 구한 답을 표에 적어 두기만 해도 계산은 눈 깜짝할 사이에 끝납니다.

본문

동적 계획법

본문

거스름돈을 줄 때 흔히 가장 큰 동전부터 줄 수 있는 만큼 준다. 우리나라 동전(10원, 50원, 100원, 500원)으로는 이 방법이 늘 동전 수를 가장 적게 한다. 그런데 어느 나라에 1원, 4원, 5원짜리 동전만 있다고 하자. 8원을 거슬러 주려면 큰 것부터 고르면 5원 하나와 1원 셋으로 동전 4개가 든다. 그런데 4원 둘이면 2개로 충분하다. 큰 것부터 고르는 방법은 왜 틀릴까? 어떤 금액이든 가장 적은 동전 수를 확실하게 찾는 방법은 무엇일까?

그림 1. 동전 1원, 4원, 5원으로 각 금액을 만드는 최소 동전 수(파랑, 동적 계획법)와, 큰 동전부터 고르는 방법이 더 많이 쓰는 금액(주황).
먼저 알아야 할 것문제 분해(문제 분해), 반복과 재귀(반복과 재귀 비교), 분할 정복(분할 정복).

3.5.1욕심쟁이 방법의 함정

지금 당장 가장 좋아 보이는 선택을 거듭하는 방법을 욕심쟁이(탐욕) 알고리즘이라 한다. 빠르고 간단하지만, 지금의 최선이 전체의 최선을 보장하지는 않는다. 동전 {1, 4, 5}로 8원을 만들 때 5원을 먼저 고르면 남은 3원은 1원 셋으로만 채울 수 있다. 처음의 '좋아 보이는' 선택이 남은 문제를 나쁘게 만든 것이다. 확실한 답을 찾으려면 마지막 동전이 1원, 4원, 5원인 경우를 모두 따져 보아야 한다. 마지막 동전이 4원이면 남은 4원을 가장 적게 만드는 문제가 되고, 그 답은 이미 구해 두었을 수 있다.

정리 3.5.1 거스름돈의 동적 계획법

금액 \(a\)를 만드는 최소 동전 수를 \(d[a]\)라 하면 \(d[0] = 0\)이고 \[d[a] = 1 + \min_{c \le a} d[a - c]\]이다(\(c\)는 동전의 액면). \(a = 1, 2, 3, \dots\)의 순서로 표를 채우면, 각 칸을 계산할 때 필요한 더 작은 금액의 답이 이미 표에 있다. 금액 \(A\)까지 채우는 데 \(A \times (\text{동전 종류 수})\)번 계산한다.

그림 1에서 금액을 바꾸어 보자. 8원은 동적 계획법으로 4원 둘, 곧 2개이고 욕심쟁이 방법은 4개이다. 12원은 4원 셋으로 3개인데 욕심쟁이 방법은 5 + 5 + 1 + 1로 4개이다. 주황 점이 찍힌 금액마다 욕심쟁이 방법이 틀린다. 동적 계획법은 표를 한 칸씩 채우기만 하면 어떤 금액이든 확실한 최솟값을 준다. 한 번 구한 작은 금액의 답을 표에 적어 두고 다시 쓰기 때문에, 같은 계산을 되풀이하지 않는다.1

3.5.2동적 계획법을 쓰는 조건

동적 계획법은 두 조건이 맞을 때 쓴다. 첫째, 큰 문제의 최적해가 작은 문제의 최적해로 이루어져야 한다. 8원을 가장 적게 만드는 방법에서 마지막 동전을 빼면, 남은 금액도 가장 적은 방법이어야 한다. 이것을 최적 부분 구조라 한다. 둘째, 같은 작은 문제가 여러 번 쓰여야 한다. 3원을 만드는 방법은 7원, 8원, 4원을 만들 때 모두 쓰인다. 이것을 중복 부분 문제라 한다. 두 조건이 맞으면, 작은 문제부터 표를 채우는 상향식이나 재귀에 표를 더한 메모이제이션으로 지수적인 계산을 선형으로 줄일 수 있다.

금액012345678 d[금액]012311232 욕심쟁이012311234
그림 2. 표를 왼쪽부터 채우면 각 칸은 이미 채운 칸 세 개만 보고 정해진다.

3.5.3예제 풀이

예제 3.5.2

동전 1원, 4원, 5원으로 13원을 만드는 최소 동전 수를 동적 계획법으로 구하여라. 그림 2의 표에 9원부터 13원까지를 이어 채우고, 욕심쟁이 방법과 비교하여라.

\(d[9] = 1 + \min(d[8], d[5], d[4]) = 1 + 1 = 2\)(5 + 4)이다. \(d[10] = 1 + \min(d[9], d[6], d[5]) = 2\)(5 + 5), \(d[11] = 1 + \min(d[10], d[7], d[6]) = 3\), \(d[12] = 1 + \min(d[11], d[8], d[7]) = 3\)(4 + 4 + 4)이다.

\(d[13] = 1 + \min(d[12], d[9], d[8]) = 1 + 2 = 3\)이고, 5 + 4 + 4가 한 방법이다. 욕심쟁이 방법은 5 + 5 + 1 + 1 + 1로 5개이다. 동적 계획법은 13원까지 표를 채우는 데 13 × 3 = 39번 비교했을 뿐이다. 즉 작은 문제의 답을 표에 모아 두면, 모든 경우를 따지면서도 계산은 금액에 비례할 뿐이다. 그래서 동적 계획법은 최단 경로, 가방 문제, 문자열 비교 같은 많은 최적화 문제의 기본 도구이다.

아래 탐구 카드에서 계단 오르기와 거스름돈 문제를 메모이제이션과 표 채우기로 풀어 보고, 실행 시간을 비교해 보자.

  1. 동적 계획법이라는 이름은 1950년대 벨먼이 지었다. 그는 연구비를 주는 기관이 '수학 연구'를 싫어해서 일부러 그럴듯한 이름을 골랐다고 회고했다. 여기서 '계획'은 표를 채워 결정을 정한다는 뜻이다.
마무리

지금 가장 좋아 보이는 선택을 거듭하는 욕심쟁이 방법은 빠르지만 전체의 최선을 보장하지 않는다. 동적 계획법은 최적 부분 구조와 중복 부분 문제가 있을 때, 작은 문제의 답을 표에 채워 두고 다시 써서 모든 경우를 따지면서도 계산을 크게 줄인다. 그래서 처음 질문의 동전 {1, 4, 5}에서는 큰 동전부터 고르면 8원에 4개가 들지만, 표를 채우는 동적 계획법은 4원 둘로 2개라는 확실한 답을 찾는다.

탐구 1

같은 계산을 두 번 하지 않으면 얼마나 빨라질까?

동적 계획법 · 메모이제이션과 표 채우기

              
ways(n) 호출 수
—
그중 다시 계산한 호출
—
오르는 방법의 수
—

한 번에 1칸 또는 2칸 오를 때 n칸 계단을 오르는 방법의 수 ways(n)이다. n ≤ 7이면 호출 과정을 한 단계씩 볼 수 있고, 더 크면 n에 따른 호출 수를 그래프(세로축 로그 눈금)로 보여 준다.

  1. 질문계단을 한 번에 1칸이나 2칸씩 오른다. 30칸을 오르는 방법은 몇 가지일까? 식은 간단한데, 그대로 재귀 함수로 짜면 n이 40 근처만 되어도 답이 한참 늦게 나온다. 무엇이 시간을 잡아먹을까?
  2. 복원마지막 한 걸음이 1칸이면 그 전까지 n−1칸, 2칸이면 n−2칸을 올라온 것이다. 그래서 ways(n) = ways(n−1) + ways(n−2)이고, ways(0) = ways(1) = 1이다. 큰 문제를 작은 부분 문제로 나누어 그 답을 조합한다는 점은 분할 정복과 같다.
  3. 설명단순 재귀의 호출 트리를 보면 ways(3), ways(2) 같은 같은 부분 문제가 여러 번 나온다(중복 부분 문제). 한 번 구한 답을 배열이나 사전에 저장해 두고 다시 필요하면 꺼내 쓰는 방법이 동적 계획법이다. 재귀 구조를 그대로 두고 저장만 덧붙이면 메모이제이션(하향식), 작은 답부터 차례로 표를 채우면 타뷸레이션(상향식)이다.
  4. 연결단순 재귀의 호출 수는 n이 1 늘 때마다 약 1.6배(황금비)씩 늘어나 지수적으로 커지고, 메모이제이션은 2n−1번, 표 채우기는 n−1번의 덧셈이면 끝난다. 동적 계획법이 통하려면 큰 문제의 최적해가 부분 문제의 최적해로 만들어지고(최적 부분 구조), 부분 문제가 겹쳐야 한다. 병합 정렬처럼 부분 문제가 겹치지 않으면 저장해도 얻는 것이 없다.
  5. 적용n = 5에서 세 방법을 한 단계씩 실행해 보자. 단순 재귀에서 주황색(이미 구한 값을 다시 계산)인 호출은 몇 개인가? 메모이제이션에서는 그 호출이 어떻게 바뀌는가? 그다음 n을 30까지 올려 두 재귀 방법의 호출 수를 비교해 보자.
  6. 판단메모이제이션은 시간을 줄이는 대신 답을 저장할 메모리를 쓴다. 하향식은 필요한 부분 문제만 풀지만 재귀 깊이가 n까지 깊어져, 파이썬에서는 기본 재귀 한도(보통 1000) 때문에 RecursionError가 날 수 있다. 상향식은 재귀가 없지만, 어떤 순서로 표를 채워야 하는지 직접 정해야 한다.

확인조건이 달라지면

이번에는 한 번에 1칸, 2칸, 3칸 중 하나를 오를 수 있다. ways(n) = ways(n−1) + ways(n−2) + ways(n−3)을 메모이제이션으로 구할 때, n이 커지면 함수 호출 수는 어떻게 늘어날까?

탐구 2

가방에 담을 물건을 고르는 표는 어떻게 채울까?

동적 계획법 · 0-1 배낭 문제

              
동적 계획법의 최대 가치
—
가치/무게 큰 것부터 담기 (탐욕)
—
채운 칸 / 완전 탐색 경우의 수
—

물건의 무게와 가치는 계산 연습을 위한 가상의 값이다. items는 (무게, 가치) 쌍의 리스트이다. 표를 다 채우면 오른쪽 아래 칸에서 거꾸로 올라가며 고른 물건을 주황색으로 표시한다.

  1. 질문무게 7까지 버티는 가방에 물건 4개 중 몇 개를 골라 담아 가치의 합을 가장 크게 하려 한다. 물건은 통째로 담거나 두고 가야 한다(0-1). 가치가 무게에 비해 큰 것부터 담으면 언제나 최선일까?
  2. 복원모든 조합을 검사하는 완전 탐색은 물건이 n개이면 2ⁿ가지를 봐야 한다. 동적 계획법은 겹치는 부분 문제의 답을 표에 저장해 두고 다시 쓴다.
  3. 설명부분 문제를 “앞의 i개 물건만으로, 허용 무게 w일 때의 최대 가치” dp[i][w]로 정한다. i번째 물건(무게 wt, 가치 val)을 넣지 않으면 dp[i−1][w], 넣으면 dp[i−1][w−wt] + val이므로, dp[i][w] = max(dp[i−1][w], dp[i−1][w−wt] + val)이다(wt ≤ w일 때만 넣을 수 있음). 이 식이 점화식이고, 위 줄만 있으면 아래 줄을 채울 수 있다.
  4. 연결표의 칸은 (n+1)(W+1)개이고 칸마다 비교를 한 번 하므로 시간은 O(nW)이다. 물건이 4개뿐이면 2⁴ = 16가지라 완전 탐색이 더 간단하지만, 물건이 40개이면 조합은 2⁴⁰ ≈ 1.1조 가지인 반면 표는 41 × (W+1)칸이다. 오른쪽 아래 칸 dp[n][W]가 답이며, 값이 바로 위 칸과 다르면 그 물건을 넣은 것이므로 거꾸로 따라가면 고른 물건을 알 수 있다. 최장 공통 부분 수열(LCS)도 같은 방식으로 2차원 표를 채운다.
  5. 적용묶음 1에서 ‘한 칸’을 누르며 초록 테두리 두 칸(넣지 않을 때, 넣을 때) 중 무엇이 선택되는지 보자. 끝까지 채운 뒤 동적 계획법과 탐욕 방법의 결과를 비교해 보자. 묶음 3에서 W를 바꾸면 두 결과가 같아지는 W와 달라지는 W는 각각 무엇인가?
  6. 판단탐욕 방법은 빠르지만 0-1 배낭에서는 최적을 보장하지 못한다(물건을 쪼갤 수 있는 배낭이라면 탐욕 방법이 최적이다). 또 O(nW)는 W에 비례하므로, 허용 무게가 10억처럼 큰 수이면 표가 너무 커져 이 방법도 쓰기 어렵다. 동적 계획법은 부분 문제를 어떻게 정의하느냐가 핵심이며, 정의가 틀리면 표를 아무리 채워도 답이 맞지 않다.

확인조건이 달라지면

이번에는 같은 물건을 여러 개 담을 수 있다(개수 제한 없음). 점화식을 어떻게 바꿔야 할까?

읽기 3

분할 정복과 동적 계획법은 무엇이 다를까?

읽기 · 두 설계 방법의 비교 · 최장 공통 부분 수열

병합 정렬도, 계단 오르기도 큰 문제를 작은 문제로 나누어 푼다. 그런데 병합 정렬에는 메모를 붙이지 않고, 계단 오르기에는 붙인다. 둘을 가르는 기준은 무엇일까?

1공통점, 부분 문제의 답으로 전체를 만든다

두 방법 모두 문제를 같은 모양의 작은 부분 문제로 나누고, 부분 문제의 답을 조합해 전체의 답을 만든다. 그래서 둘 다 재귀식(점화식)으로 쓸 수 있다. 차이는 부분 문제들이 서로 겹치는가에 있다. 병합 정렬은 배열의 왼쪽 절반과 오른쪽 절반을 따로 정렬하므로, 같은 부분 배열을 두 번 정렬하는 일이 없다. 계단 오르기에서는 ways(5)를 구하려고 부른 ways(4)와 ways(3)이 둘 다 다시 ways(2)를 부른다.

측면분할 정복동적 계획법
부분 문제의 관계서로 독립 (겹치지 않음)같은 부분 문제가 여러 번 필요함 (중복 부분 문제)
답의 저장보통 저장하지 않음배열·사전(표)에 저장하고 다시 씀
계산 방향위에서 아래로 나눈 뒤 결과를 합침 (재귀)하향식(재귀 + 메모이제이션) 또는 상향식(작은 답부터 표 채우기)
시간 복잡도를 셈하는 법나누기·합치기 비용의 점화식 (병합 정렬: T(n) = 2T(n/2) + O(n) → O(n log n))(부분 문제의 수) × (부분 문제 하나를 푸는 비용) (배낭: (n+1)(W+1)칸 × O(1))
메모리재귀 호출 스택, 합치기용 임시 공간 (병합 정렬은 O(n))표의 크기 (필요한 줄만 남기면 줄일 수 있음)
주로 쓰는 문제정렬, 탐색, 큰 수의 곱셈, 거듭제곱최댓값·최솟값을 묻는 최적화, 경우의 수 세기
예병합 정렬, 퀵 정렬, 이진 탐색계단 오르기, 0-1 배낭, 최장 공통 부분 수열, 편집 거리, 플로이드-워셜 최단 경로

단순 재귀로 짠 계단 오르기는 분할 정복처럼 생겼지만 부분 문제가 겹치기 때문에 지수 시간이 걸린다. 반대로 이진 탐색이나 병합 정렬에 메모를 붙여도 같은 부분 문제를 다시 만나지 않으므로 메모리만 더 쓴다.

2예, 두 DNA 서열이 얼마나 닮았을까

생명과학에서는 두 DNA 서열이 얼마나 닮았는지 비교한다. 가장 간단한 척도가 최장 공통 부분 수열(LCS)이다. 순서는 지키되 중간을 건너뛰어도 되는 공통 문자열 가운데 가장 긴 것이다. GATTACA와 TACGCA의 LCS는 길이 4인 TACA이다.

X의 앞 i글자와 Y의 앞 j글자의 LCS 길이를 L[i][j]라 하면, 두 마지막 글자가 같을 때 L[i][j] = L[i−1][j−1] + 1, 다를 때 L[i][j] = max(L[i−1][j], L[i][j−1])이다. 한 글자를 빼고 비교하는 부분 문제들이 서로 겹치므로 표에 저장한다.

∅TACGCA
∅0000000
G0000111
A0011112
T0111112
T0111112
A0122222
C0123333
A0123334
def lcs(X, Y):
          n, m = len(X), len(Y)
          L = [[0] * (m + 1) for _ in range(n + 1)]
          for i in range(1, n + 1):
              for j in range(1, m + 1):
                  if X[i - 1] == Y[j - 1]:
                      L[i][j] = L[i - 1][j - 1] + 1
                  else:
                      L[i][j] = max(L[i - 1][j], L[i][j - 1])
          return L[n][m]

      print(lcs("GATTACA", "TACGCA"))   # 4

행은 GATTACA, 열은 TACGCA의 글자이다. 주황 칸은 두 글자가 같아 대각선 위 칸에서 1을 더한 곳으로, 오른쪽 아래에서 거꾸로 따라가면 T, A, C, A를 얻는다. 표의 칸은 (n+1)(m+1)개이므로 시간은 O(nm)이다. 모든 부분 수열을 비교하는 완전 탐색이라면 길이 n인 서열의 부분 수열만 2ⁿ개이다.

3동적 계획법으로 설계하는 순서

부분 문제 정하기

“앞의 i개만 볼 때”, “허용 무게가 w일 때”처럼 표의 한 칸이 무엇을 뜻하는지 문장으로 쓴다.

점화식 세우기

마지막 선택(마지막 물건을 넣는가, 마지막 글자가 같은가)으로 경우를 나누어 더 작은 칸으로 표현한다.

기저 조건

빈 서열, 물건 0개처럼 바로 답을 아는 칸을 채운다.

계산 순서

어떤 칸을 계산할 때 필요한 칸이 이미 채워져 있도록 순서를 정한다.

답과 역추적

답이 들어 있는 칸을 찾고, 필요하면 표를 거꾸로 따라가 실제 선택을 복원한다.

판단‘동적 계획법(dynamic programming)’의 programming은 코드를 짜는 일이 아니라 표를 만들어 계획한다는 뜻으로, 1950년대에 리처드 벨먼이 붙인 이름이다. 이름에 ‘동적’이 들어 있다고 실행 중에 무언가가 움직이는 방법은 아니다. 또 동적 계획법은 최적 부분 구조가 있을 때만 맞는 답을 준다. 예를 들어 같은 정점을 두 번 지나지 않는 가장 긴 경로 문제는 부분 경로의 최적해를 이어 붙여도 전체 최적해가 되지 않아 이 방법을 그대로 쓸 수 없다.

확인조건이 달라지면

정렬된 배열에서 이진 탐색을 재귀 함수로 짜고, 같은 구간을 다시 탐색하지 않도록 (왼쪽 끝, 오른쪽 끝)마다 결과를 사전에 저장했다. 효과는?

AI 출제 문항연습문제 풀어보기13문항
Esc

↑↓ 고르기Enter 열기