오늘은 다이나믹 프로그래밍을 사용한 문제해결 방법론 중 Top-down 방식, 그 중에서도 완전탐색이 필요한 상황에서 짜여진 Backtracking 로직을 Top-down DP 로직으로 개선하는 방법에 대해 정리해 보고자 한다.
다이나믹 프로그래밍이란 어떤 큰 문제의 해가 작은 문제의 해로부터 유도되고, 작은 문제가 불필요하게 반복되는 상황에서 작은 문제의 해를 테이블(메모리)에 저장함으로써 시간 복잡도를 획기적으로 개선하는 문제해결 방법론이다.
나는 원래 다이나믹 프로그래밍과 관련된 문제를 모두 Bottom-up 방식으로 해결하는 것을 선호했다. 문제는 배낭 문제, 타일 문제처럼 Bottom-up 방식으로 해결하는 것이 편하거나, 애초에 그 방식이 잘 알려진 문제들도 있는 반면 그렇지 않은 문제도 많다는 것이었다.
잘 알려진 문제인 피보나치 수열 문제를 Bottom-up 로직, Top-down 로직으로 각각 구현해 보면 아래와 같다.
// Bottom-up Fibonacci Pseudo code
vector<unsigned long long> table(N, -1);
table[0] = 0;
table[1] = 1;
for (int i = 2; i < N; i++)
table[i] = table[i-1] + table[i-2];
// Top-down Fibonacci Pseudo code
vector<unsigned long long> table(N, -1);
table[0] = 0;
table[1] = 1;
unsigned long long Fibonacci(int N) {
if (table[N] != -1)
return table[N];
table[N] = Fibonacci(N-1) + Fibonacci(N-2);
return table[N];
}
이처럼 Bottom-up 로직은 for loop를 기반으로 이루어지고, Top-down 로직은 재귀 함수를 기반으로 이루어진다.
개개인의 문제풀이 스타일에 따라 Bottom-up 방식이 편한 사람도 있을 것이고, Top-down 방식이 편한 사람도 있을 것이다. 그러나 내가 경험한 바로는 잘 알지 못하는 문제는 Top-down 방식으로 시도하는 것이 편하다. 그 이유는 DP 문제 자체가 완전탐색을 전제로 하고 있기 때문에 일단 완전탐색 로직을 구현한 후 DP 로직으로 전환하면 실수도 덜하고 점화식을 떠올리기도 쉽기 때문이다.

먼저 백트래킹 로직을 구현해야 한다. 문제 조건을 보면 당연히 입력이 클 때 백트래킹 로직은 통과 불가능하지만, 작은 입력에 대해선 충분히 답을 낼 수 있다. 우리는 예제로 주어지는 작은 입력들로 백트래킹 로직을 검증하면 된다.
흔히 백트래킹 로직을 구현할 때 상태 값, 이 문제로 예를 들면 최대 모금액이나 현재까지 보낸 시간과 같은 값을 전역 변수로 관리하는 코드도 있는데 주의할 점은 상태 값을 외부의 변수에 담지 말고 재귀 함수의 매개변수나 반환 값에 담아야 한다는 것이다. 코드를 보면 이해가 될 것이다.
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define INF 100000000
class Interval {
public:
int walkCost;
int walkMoney;
int bicycleCost;
int bicycleMoney;
};
int N, K;
vector<Interval> interval;
ll DFS(int i, int remainingTime);
int main(int argc, char* argv[]) {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
cin >> N >> K;
interval.resize(N);
for (int i = 0; i < N; i++)
cin >> interval[i].walkCost >> interval[i].walkMoney >> interval[i].bicycleCost >> interval[i].bicycleMoney;
cout << DFS(0, K);
return 0;
}
ll DFS(int i, int remainingTime) {
if (remainingTime < 0)
return -INF;
if (i == N)
return 0;
ll ret = max(
DFS(i + 1, remainingTime - interval[i].walkCost) + interval[i].walkMoney,
DFS(i + 1, remainingTime - interval[i].bicycleCost) + interval[i].bicycleMoney
);
return ret;
}

코드를 제출하면 예상했던 대로 N > 20으로 제약 조건이 없는 상황에서 시간 초과가 걸리게 된다. N <= 20인 경우엔 잘 작동한다는 걸 확인했으므로 이제 DP 로직으로 개선해 볼 차례이다.
점화식과 테이블을 구성하는 과정은 매우 간단하다. 이것 때문에 Top-down DP 로직을 사용하는 것이다. 재귀 함수의 매개변수를 인덱스로 테이블을 구성하고, 반환 값을 테이블에 저장하도록 하면 된다.
앞서 나는 재귀 함수의 매개변수를 현재 탐색하고 있는 구간(i), 현재까지 남은 시간(remainingTime)으로 구성하였고, 최대 모금액(ret)을 반환하도록 하였다.
...
vector<vector<ll>> table; // [curr interval][remaining time]
...
table.resize(N, vector<ll>(K + 1, -INF));
...
그러므로 테이블을 이렇게 구성해 주면 된다.
기존의 종료 조건에 하나를 더 둔다. 바로 테이블에 문제의 해가 저장되어 있는지의 여부를 검사하는 내용이다.
ll DFS(int i, int remainingTime) {
if (remainingTime < 0)
return -INF;
if (i == N)
return 0;
if (table[i][remainingTime] != -INF) // 추가 종료 조건
return table[i][remainingTime];
...
만약 해당 종료 조건을 통과하지 않는다면 아직 테이블에 해가 저장되어 있지 않은 것이다. 이제 기존의 코드를 다음과 같이 바꾼다.
ll DFS(int i, int remainingTime) {
if (remainingTime < 0)
return -INF;
if (i == N)
return 0;
if (table[i][remainingTime] != -INF)
return table[i][remainingTime];
ll& ret = table[i][remainingTime];
ret = max(
DFS(i + 1, remainingTime - interval[i].walkCost) + interval[i].walkMoney,
DFS(i + 1, remainingTime - interval[i].bicycleCost) + interval[i].bicycleMoney
);
return ret;
}
기존에는 ret을 ll type으로 선언했지만, ll& type으로 선언함으로써 테이블을 참조하도록 하였다. 이제 똑같이 재귀 함수를 사용해 ret에 최대 모금액을 저장하게 한 후 반환하면 끝이다.

제약 조건이 없는 상황에서도 잘 통과한 것을 확인할 수 있다.
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define INF 100000000
class Interval {
public:
int walkCost;
int walkMoney;
int bicycleCost;
int bicycleMoney;
};
int N, K;
vector<Interval> interval;
vector<vector<ll>> table; // [curr interval][remaining time]
ll DFS(int i, int remainingTime);
int main(int argc, char* argv[]) {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
cin >> N >> K;
interval.resize(N);
for (int i = 0; i < N; i++)
cin >> interval[i].walkCost >> interval[i].walkMoney >> interval[i].bicycleCost >> interval[i].bicycleMoney;
table.resize(N, vector<ll>(K + 1, -INF));
cout << DFS(0, K);
return 0;
}
ll DFS(int i, int remainingTime) {
if (remainingTime < 0)
return -INF;
if (i == N)
return 0;
if (table[i][remainingTime] != -INF)
return table[i][remainingTime];
ll& ret = table[i][remainingTime];
ret = max(
DFS(i + 1, remainingTime - interval[i].walkCost) + interval[i].walkMoney,
DFS(i + 1, remainingTime - interval[i].bicycleCost) + interval[i].bicycleMoney
);
return ret;
}
마지막으로 앞서 설명한 완전탐색이 필요한 문제에 대한 Backtracking 로직을 DP 로직으로 바꾸는 과정을 나열하고 마무리하겠다.