DP1란, 문제를 재귀구조를 가지는 부분 문제들로 분할한 뒤, 작은 문제들부터 해결하고 그 답으로 더 큰 부분 문제들을 해결해나가며 최종 답을 구해내는 알고리즘이다.
이 때, 더 이상 분할할 수 없는 문제를 기저 사례라고 하며, 각 부분 문제와 그것들을 모아서 해결 할 수 있는 상하방 부분 문제들 간의 관계식을 점화식이라고 한다.
부분 문제들의 답들을 계산할 때 마다 저장해두고 같은 문제를 계산할 때, 미리 저장해둔 답을 불러와 중복 계산을 회피할 수 있다. 이를 메모이제이션 기법 이라고 한다.
이 메모이제이션 기법 덕분에 주어진 부분 문제들이 중복되는 경우, 이미 배웠던 백트래킹/완전탐색 류 알고리즘들에 비해 DP는 훨씬 빠르게 답을 구할 수 있다.
또한 부분 문제들을 구분하기 위한 수단으로 인자라는 단어를 사용한다.
부분 문제의 정답에 직접 영향을 끼치는 요인들을 인자라고 묶어 부른다.
같은 인자를 가진 부분 문제는 반드시 같은 답을 도출해야 한다.
예를들어 피보나치 수열에서는 인덱스가 인자가 된다.
만약 같은 인자를 가진 부분 문제에서 다른 답이 도출될 경우, 인자를 잘못 설정한 것이다.
문제에 따라 인자는 하나일 수도, 여러 개 일 수도 있다.
DP 문제를 해결할 때 인자를 정확하게 파악하는 것이 가장 중요하다.
정리하면 DP는
주어진 문제를 적절한 인자를 기준으로 재귀 구조를 가지는 부분 문제들로 분할
기저 사례 확인
점화식 수립
기저 사례로부터 점화식을 통해 더 큰 부분 문제 해결 → 이 때 부분 문제 중복 소거
최종 답 계산
이라는 공식을 가진 알고리즘이다.
알고리즘의 개념이 단순한만큼 문제의 바리에이션이 매우 다양하며, 난이도 또한 천차만별이고, Well-Known 알고리즘도 많은 카테고리이다.
그리고 여러 알고리즘과 섞기 쉬워서 별의 별 문제에 태그로 붙어있다.
아직 이 재귀 구조의 부분 문제를 합쳐가며 문제를 해결한다는 말이 직관적으로 와닿지는 않을테니 간단하게 피보나치 수열을 구현하며 DP에 대해 더 알아보자.
2. 피보나치 수열의 3가지 구현
피보나치 수열은 ni=ni−1+ni−2의 점화식을 가지는 유명한 수열이다.
여기서 인덱스 i가 위에서 설명한 ==인자==가 된다.
수열은 1,1,2,3,5,8,13,21,34... 식으로 진행된다.
피보나치 수열은 이전 항들의 합, 즉 부분 문제의 조합으로 다음 항을 구할 수 있기 때문에 DP로 해결할 수 있는 대표적인 문제이다.
피보나치 수열의 n번째 항을 구하는 프로그램을 작성해보자.
r(7)의 호출에서 r(int n)함수가 무려 25번이나 호출되었다.
이 실행 트리를 보면 알 수 있는 사실이 하나 있다. 같은 수의 호출이 여러 번 반복된다. r(3)호출만 5번 반복되었고 r(5) 또한 2번 호출되었다.
이것은 이미 r(3)을 호출해서 r(3)의 결과를 알고 있음에도 불구하고 같은 함수의 호출을 5번이나 반복했다는 의미이다.
특히 5처럼 큰 수가 반복 호출될 경우, 그 아래 과정들도 모두 똑같이 반복해 호출하게 되므로 비용이 더 커진다.
각각의 함수가 2번씩 재귀하므로 최초 호출되는 n의 크기가 커질수록 이 반복 호출 횟수는 2n에 비례해 커진다.
이건 정말 어마어마한 비용이다.
이러한 부분 문제의 중복 계산을 회피하기 위해 ==이미 계산해둔 부분 문제의 계산 결과를 저장하고 재사용하는 것을 메모이제이션(memoization) 기법==이라고 하며, DP의 핵심이다.
3. 재귀 함수 최적화
메모이제이션 기법을 적용해 재귀 피보나치 함수를 최적화 해보자.
각각의 n들을 계산했을 때 그 결과를 저장해뒀다가 이미 저장된 n을 다시 탐색하려할 때 탐색을 멈추고 저장된 결과를 리턴하는 식으로 최적화 할 수 있다.
#include <iostream>using namespace std;vector<int> cache;int r(int n) { if(cache[n] != -1) return cache[n]; // 이미 계산했던 n이라면 저장된 계산 결과 리턴 if(n == 1 || n == 2) return cache[n] = 1; // 계산 결과 저장 return cache[n] = r(n - 1) + r(n - 2); // 계산 결과 저장}int main() { int n; cin >> n; cache = vector<int>(n + 1, -1); cout << r(n); return 0;}
이제 우리는 같은 r(n)를 반복 계산할 일이 없다.
다시 한번 r(7)에 대한 실행 트리를 살펴보며 계산 횟수가 얼마나 줄어들었는지 확인해보자.
25번에서 11번으로 호출 횟수를 줄이는데 성공했다.
DP의 개념을 적용하면 이렇게 중복 계산하는 비용을 줄여 시간 소요를 줄일 수 있다.
단원의 첫 문제를 풀어보며 n이 커졌을 때 단순 재귀호출과 DP의 차이가 얼마나 크게 벌어지는지 직접 확인해보자.
그리고 다른 알고리즘들과는 다르게 DP 는 반드시 모든 문제를 정주행할 것을 추천한다. 알고리즘 특성 상 응용의 폭이 넓기 때문에 Well-Known 알고리즘들이 많은 편이기 때문에 공부할 양이 많다.