다이나믹 프로그래밍 뭔지 알겠다!
→ 코테 박살내러 간다!
→ 하지만 박살나는 건 나였다
아는 것 같은데 항상 시도할 때마다 벽을 느꼈던 다이나믹 프로그래밍
기본기가 부족하다고 항상 느껴왔던 터라 이참에 알고리즘의 기본들을 하나하나 박살내보기로 마음먹었다.
LeetCode 에서 문제를 풀던 중 좋은 참고 문서를 찾아 직접 번역해가며 공부해보기로 함.
GeeksforGeeks - Overlapping Subproblems Property
GeeksforGeeks - Optimal Substructure Property
DP는 기본적으로 recursion
을 활용한 최적화 방법이다. 문제를 여러 개의 부분 문제로 나누어 해결하고 이를 저장해 둠으로써 다시 계산할 필요 없이 나중에 사용할 수 있게 한다. 가장 간단한 예로는 피보나치 문제
가 있다.
recursion을 사용한 경우
int fib(int n) { if (n <= 1) return n; return fib(n-1) + fib(n-2); }
fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) / \ fib(1) fib(0)
실행 단계에 따라 계산 횟수가 기하급수적으로 증가함.
T(n) = T(n-1) + T(n-2)
DP를 사용한 경우
f[0] = f[1] = 0; for(i = 2; i <= n; i++) { f[i] = f[i-1] + f[i-2]; } return f[n];
시간 복잡도가 linear하게 증가함.
DP가 뭔지는 알겠는데 어떤 문제의 경우 DP를 사용할 수 있을까?
DP의 장점은 부분 문제를 재계산할 필요가 없음에서 나온다. 즉 공통의, 겹치는 부분 문제가 문제에서 보이지 않는다면 DP를 사용할 이유가 없다.
당장 위에서 예시로 든 피보나치 문제
의 경우에도 같은 계산이 여러 번 수행되는 것을 확인할 수 있다.
이를 해결하기 위해 값을 저장해두는데 총 두 가지의 방식이 존재한다.
1.1 Memoization(Top down)
계산을 수행하기 전 값이 저장된 테이블(lookup table)을 살펴본 뒤 값이 존재하면 이를 사용하고, 값이 존재하지 않으면 추후에 재사용할 수 있도록 계산된 결과를 저장하는 방식이다.
def fib(n, lookup):
if n <= 1:
lookup[n] = n
# 계산된 값이 존재하지 않는다면
if lookup[n] is None:
lookup[n] = fib(n-1 , lookup) + fib(n-2 , lookup)
return lookup[n]
1.2 Tabulation(Bottom up)
원하는 계산 결과를 얻기 위해 가장 아래부터 채워나가는 방식이다.
피보나치 문제
에서 먼저 fib(0)
을 구하고 fib(1)
, fib(2)
··· 을 구해나가는 방식이다.
def fib(n):
f = [0] * n
f[1] = 1
for i in range(2, n+1):
f[i] = f[i-1] + f[i-2]
return f[n]
두 방식이 얼핏 보기에는 비슷하지만 그 접근 방식에서 차이가 있다.
Memoization
방식은fib(n)
,fib(n-1)
···fib(0)
:Top-down
방향으로 진행되고,
Tabulation
방식은fib(0)
,fib(1)
, ···fib(n)
:Bottom-up
방향으로 진행된다.
주어진 문제의 최적의 해결 방안이 부분 문제의 최적의 해결 방안으로부터 얻어지는 경우이다.
최단 경로 문제의 경우 노드 a
에서 노드 c
로 가는 경로에 노드 b
가 있다면, 이 경로는 노드 a에서 노드 b로 가는 경로
+노드 b에서 노드 c로 가는 경로
와 같게 된다.
이와 반대로 최장 경로 문제의 경우 위와 같은 조건이 성립하지 않게 된다.
DP가 무엇인지 어떤 문제에서 활용할 수 있는지 이제 어느 정도 감이 잡힌다. 이제 어떻게 DP스럽게 생각하고 해결할 수 있는지 알아봐야지.