동적 계획법은 복잡한 문제를 간단한 하위 문제로 나눈 후, 하위 문제를 해결함으로써 최종적으로 복잡한 문제의 해답을 구하는 알고리즘 설계 기법이다. 동적 계획법은 중복된 계산을 제거함으로써 시간 복잡도를 낮출 수 있다는 장점이 있지만, 부분 문제의 해답을 모두 저장해야 한다는 점에서 공간 복잡도가 높다는 단점이 있다.
동적 계획법을 적용하여 풀이할 수 있는 문제는 반드시 아래의 조건을 만족한다.
① 최적 부분 구조(Optimal Substructure)
② 중복 부분 문제(Overlapping Subproblems)
피보나치 수열은 최적 부분 구조와 중복 부분 문제 조건을 만족하므로, DP를 적용하여 구할 수 있다.
① Top-Down Approach(하향식 접근)
import sys
input = sys.stdin.readline
N = int(input())
D = [-1] * (N + 1) # Memoization 되지 않은 원소를 구분한기 위해 0이 아닌 -1로 초기화
D[0] = 0
D[1] = 1
def fibo(n):
if D[n] != -1:
return D[n] # 이미 저장되어 있는 데이터인 경우 재사용
D[n] = fibo(n-2) + fibo(n-1) # 재귀 호출 및 Memoization 활용
return D[n] # return fibo[n-2] + fibo[n-1]의 형식으로 작성했다면, 매번 중복된 계산을 반복했을 것임
fibo(N)
print(D[N])
② Bottom-Up Approach(상향식 접근)
import sys
input = sys.stdin.readline
N = int(input())
D = [0] * (N + 1)
# D[0] = 0
D[1] = 1
for n in range(2, N + 1):
D[n] = D[n - 2] + D[n - 1] # 반복문 및 Tabulation 활용
print(D[N])
참고로, Memoization과 Tabulation은 모두 중복 계산을 회피하기 위한 목적은 동일하지만, 약간의 차이가 있다. Memoization은 실제 계산을 수행해야 할 때 데이터를 저장하는 반면, Tabulation은 모든 하위 문제에 대한 해답을 미리(계산이 필요한 시점보다 먼저) 테이블에 저장해둔다.
일반적으로 재귀 호출보다는 반복문의 성능이 더 좋기 때문에, DP에서는 주로 Bottom-Up Approach를 사용한다.
동적 계획법은 복잡한 문제를 하위 문제로 분할한다는 점에서 분할 정복법과 비슷하다. 그러나, 동적 계획법과 분할 정복법에는 아래와 같은 차이점이 존재한다.
① 하위 문제의 중복성
② Meomiazation의 필요성
③ 접근법
문제를 보자마자 떠올리기 쉬운 접근법은 아무래도 BFS일 것이다.
import sys
input = sys.stdin.readline
from collections import deque
N = int(input())
visited = [-1] * (N + 1)
deq = deque()
def bfs(V):
deq.append(V)
visited[V] = 0
while deq:
V = deq.popleft()
if V == 1:
break
if V % 3 == 0 and visited[V // 3] == -1:
deq.append(V // 3)
visited[V // 3] = visited[V] + 1
if V % 2 == 0 and visited[V // 2] == -1:
deq.append(V // 2)
visited[V // 2] = visited[V] + 1
if visited[V - 1] == -1:
deq.append(V - 1)
visited[V - 1] = visited[V] + 1
bfs(N)
print(visited[1])
그러나 이 문제는 DP를 활용하여 풀이할 수도 있다. 동적 계획법 문제를 자주 풀다보면, DP를 이용해 푸는 문제임을 알아차리는 감각이 생긴다. 하지만, 처음 문제를 보자마자 DP를 떠올리기는 쉽지 않을 수 있다. 이럴 때에는 원시적인 접근법으로 풀이법에 대한 힌트를 찾아내야 한다. 일단은, 10을 1로 만드는 데 필요한 연산 횟수의 최소 값을 직접 계산해보기로 하자.
먼저 10에서 1을 빼거나, 2로 나눌 수 있을 것이다. 그러면 이제 9 또는 5를 1로 만들기 위해 9를 3으로 나누거나, 5에서 1을 빼야 한다. 또 다음에는 3을 3으로 나누거나, 4를 2로 2번 나눔으로써, 최종적으로 1을 얻게 된다.
10번째 원소를 구하기 위해 5번째 원소 또는 9번째 원소가 활용된다는 점에서, 위 문제가 최적 부분 구조를 만족한다는 사실을 확인할 수 있다. 또한, DP 테이블을 채워가는 과정에서 동일한 부분 문제가 반복된다는 사실도 쉽게 확인해볼 수 있다. 따라서, 위 문제는 DP를 이용해 풀이할 수 있는 문제이다.
DP 풀이를 결정했다면 가장 먼저 해야 할 일은 점화식을 도출하는 것이다. D[N]을 N을 1로 만드는 데 필요한 연산 횟수의 최소 값이라 정의하면, 아래와 같은 점화식을 도출해낼 수 있다.
D[n] = D[n-1] + 1 # 1을 빼는 연산
if (n % 2 == 0) D[n] = min(D[n], D[n // 2] + 1) # 2로 나누는 연산
if (n % 3 == 0) D[n] = min(D[n], D[n // 3] + 1) # 3으로 나누는 연산
위 점화식은 1을 빼는 경우, 2로 나누는 경우, 3으로 나누는 경우 중 최소가 되는 값으로 DP 테이블을 업데이트 한다. 비록 D[10]을 바로 구하는 일은 복잡하고 어려운 일이지만, D[2]부터 차근차근 DP 테이블을 채워나가다보면(Bottom-Up Approach), 자연스레 D[10]의 값이 구해질 것이다.
import sys
input = sys.stdin.readline
N = int(input())
D = [0] * (N + 1) # D[1] = 0
for n in range(2, N + 1):
D[n] = D[n - 1] + 1
if n % 2 == 0: # n이 2로 나누어 떨어지면
D[n] = min(D[n], D[n // 2] + 1) # D[n]을 최소 값으로 업데이트
if n % 3 == 0: # n이 3으로 나누어 떨어지면
D[n] = min(D[n], D[n // 3] + 1) # D[n]을 최소 값으로 업데이트
print(D[N])
대부분의 DP 문제가 그러하듯 점화식 도출에만 성공한다면, 어렵지 않게 풀 수 있는 문제이다.
이 문제도 최적 부분 구조와 중복 부분 문제를 만족한다. 그 이유는 D[n]을 n일차까지 얻을 수 있는 최대 이익이라 할 때, D[7]은 그 전까지의 이익을 고려하여 계산되어야 하며, DP 테이블을 채워가는 과정에서 동일한 부분 문제가 반복되기 때문이다.
이제 점화식을 세울 차례이다. n일차에서 백준이는 아래의 두 가지 경우 중 하나를 선택할 수 있다.
D[n + time] = max(D[n + time], D[n] + profit)
D[i + 1] = max(D[i + 1], D[i])
이제 각각의 경우에 대해 점화식을 활용하여 최대 이익을 계산하면 된다.
import sys
input = sys.stdin.readline
N = int(input())
schedule = [list(map(int, input().split())) for _ in range(N)] # 상담 일정표
D = [0 for i in range(N + 1)] # i일차까지 얻을 수 있는 최대 이익
for i in range(N):
time, profit = schedule[i][0], schedule[i][1]
# 현재 상담을 선택하는 경우
if i + time <= N: # 상담 일정이 N일을 초과할 경우 진행 불가
D[i + time] = max(D[i + time], D[i] + profit)
# 현재 상담을 선택하지 않는 경우
D[i + 1] = max(D[i + 1], D[i]) # 현재까지의 최대 이익 또는 기존 D[i + 1] 중 더 큰 값으로 업데이트
print(D[N])