복잡한 문제를 여러 개의 간단한 문제로 분리하여 부분의 문제들을 해결함으로써
최종적으로 복잡한 문제의 답을 구하는 방법
다음과 같은 조건에서 DP를 적용할 수 있다.
Optimal substructure (최적 부분 구조)
-> 큰 문제의 최적해가 작은 문제들의 최적해로부터 만들어짐
Overlapping subproblems (중복되는 하위 문제)
-> 작은 문제들이 여러 경로에서 반복적으로 등장
특히 문제를 보고 dp를 적용할 수 있을지 예측하는 것은 쉽지 않다.
그래서 다음과 같은 규칙을 가지고 dp를 적용할 수 있을 지 가늠해본다.
부분 문제로 나눌 수 있는가?
-> 큰 문제를 작은 단위로 쪼갤 수 있을 때
같은 하위 문제가 반복되는가?
-> fib(3) 처럼 같은 걸 여러번 구하진 않는지
부분 문제의 해를 저장하고 재활용할 수 있는가?
-> 이전 계산을 활용해서 다음 답을 만들 수 있는지
이 단계 까지 거치게 된다면 커다란 문제를 재귀 형식을 작은 문제로 분해할 수 있게 된다.
1단계: 완전 탐색으로 먼저 생각한다.
int lcs(i, j) {
if (str1[i] == str2[j]) return lcs(i-1, j-1) + 1;
else return max(lcs(i-1, j), lcs(i, j-1));
}
흔한 재귀 문제인 lcs를 예시로 가져왔다.
lcs(i, j)는 str1[0..i]와 str2[0..j] 사이의 LCS 길이를 의미한다.
완전 탐색은 O(2^n)으로 모든 경우를 재귀 호출을 통해서 구하기 때문에 시간 초과가 나게 된다.
2단계: 중복되는 하위 문제를 찾는다.
3단계: 메모이제이션으로 중복 제거
cache[i][j]와 같은 테이블로 결과를 저장한다.cache[i][j]에 이전에 계산한 값이 있다면 배열에서 가져와서 계산을 이어나간다.전형적인 dp의 예시를 가져왔다. 각 문제에 위에 설명한 나만의 방식을 적용하여 접근해본다.
피보나치 수는 0과 1로 시작하여, 2번째 수부터는 바로 앞 두 피보나치 수의 합이 된다.
1. 완전 탐색으로 접근
가장 먼저 완전탐색 재귀 함수를 생각해보자
// n번 째 피보나치 수를 찾는 함수
long fib(int n) {
if (n == 0) return 0;
if (n == 1) return 1;
return fib(n - 1) + fib(n - 2);
}
n번째 피보나치 수를 찾는 방법은 (n-1)번째 피보나치 수와 (n-2)번째 피보나치 수를 찾는 것이다.
2. 중복되는 부분 찾기
하지만 이 경우
fib(5) -> fib(4) + fib(3)
fib(4) -> fib(3) + fib(2)
fib(3) -> fib(2) + fib(1)
fib(2) -> 1
fib(1) -> 1
중복 호출되는 함수가 발생하게 된다.
-> fib(n)을 구할 때 fib(n - 1)과 fib(n - 2)는 중복적으로 계속 호출된다
-> 따라서 이 값들을 메모이제이션으로 저장할 수 있다.
3. 메모이제이션 적용
static long fib(int n) {
if (n == 0) return 0;
if (n == 1) return 1;
if (memo[n] != 0) return memo[n];
memo[n] = fib(n - 1) + fib(n - 2);
return memo[n];
}
cache라는 배열을 도입하여 이전 호출된 함수의 결과를 저장하게 한다.
정수 n이 주어졌을 때 길이가 n인 2진 문자열 중에서 1과 00만 사용해서 만들 수 있는 문자열의 개수를 구하라.
1. 완전 탐색으로 접근
n인 문자열을 만들려고 할 때문자열을 만들 때 칸을 채운다고 생각하자.
예를 들어, n = 4 이면 4칸짜리 블록을 채워야한다.
앞에서부터 1칸 혹은 2칸을 차지하며 나아간다면
현재 위치에서
-> 1칸을 채우는 경우 (1)
-> 2칸을 채우는 경우 (00)
이 두 가지 경우를 각각 재귀적으로 시도해본다는 것이다.
countWays(n - 1) -> 바로 앞자리가 1
countWays(n - 2) -> 바로 앞자리가 00
이런 방법이 재귀적으로 이루어 진다.
// n번째 수열의 경우의 수
int countWays(int n) {
if (n == 0) return 1; // 길이 딱 맞게 끝냄
if (n < 0) return 0; // 길이 초과 → 잘못된 경우
return countWays(n - 1) + countWays(n - 2);
}
2. 중복되는 부분 찾기
피보나치 수열과 같은 재귀 함수가 만들어졌다.
결국 곂치는 부분이 똑같이 생긴다는 것이다.
3. 메모이제이션 적용
static int tile(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
if (memo[n] != 0) return memo[n];
memo[n] = (tile(n - 1) + tile(n - 2)) % MOD;
return memo[n];
}
여러 종류의 동전이 주어졌을 때 해당 동전들로 금액 M을 만드는 경우의 수를 구하라.
1. 완전탐색으로 문제 해결
coins = [1, 2, 5], target = 10을 가정한다.
다음과 같은 선택을 계속 할 수 있다.
1원을 사용할 것인가?2원을 사용할 것인가?5원을 사용할 것인가?int countWays(int idx, int sum) {
if (sum > target) return 0;
if (sum == target) return 1;
if (idx == coins.length) return 0;
// 1. 현재 코인을 사용하지 않는 경우
int without = countWays(idx + 1, sum);
// 2. 현재 코인을 사용하는 경우 (중복 사용 가능 → idx는 그대로)
int with = countWays(idx, sum + coins[idx]);
return with + without;
}
2. 중복되는 부분 찾기
같은 idx와 sum 조합이 여러 번 등장한다.
-> 중복 호출
-> cache[idx][sum]식으로 메모이제이션해서 최적화 한다.
3. 메모이제이션 적용
public static int countWays(int idx, int sum) {
if (sum > target) return 0;
if (sum == target) return 1;
if (idx == n) return 0;
if (memo[idx][sum] != -1) return memo[idx][sum];
// 현재 동전을 쓰지 않는 경우 + 쓰는 경우
memo[idx][sum] = countWays(idx + 1, sum) + countWays(idx, sum + coins[idx]);
return memo[idx][sum];
}
두 분자열 str1, str2가 주어졌을 때,두 문자열의 공통으로 존재하는 부분 수열 중 가장 긴 길이를 구한다.
1. 완전탐색 접근
str1[i] == str2[j] -> 두 문자를 LCS에 포함시키고, 다음 문자로 이동 (i+1, j+1)max(LCS(i+1, j), LCS(i, j+1))int lcs(int i, int j) {
if (i == str1.length || j == str2.length) return 0;
if (str1.charAt(i) == str2.charAt(j))
return 1 + lcs(i + 1, j + 1);
else
return Math.max(lcs(i + 1, j), lcs(i, j + 1));
}
2. 중복 확인
재귀 호출 트리를 그려보면 같은 lcs(i, j) 호출이 여러 경로로 반복해서 호출된다.
3. 메모이제이션 적용
public static int lcs(int i, int j) {
if (i == str1.length() || j == str2.length()) return 0;
if (memo[i][j] != -1) return memo[i][j];
if (str1.charAt(i) == str2.charAt(j)) {
memo[i][j] = 1 + lcs(i + 1, j + 1);
} else {
memo[i][j] = Math.max(lcs(i + 1, j), lcs(i, j + 1));
}
return memo[i][j];
}
1. 완전 탐색
행렬이 4개까지 있다면, 문제를 재귀적으로 분할 할 수 있다.
minCost(1, 4) =
min(
minCost(1,1) + minCost(2,4) + cost(1,1,4),
minCost(1,2) + minCost(3,4) + cost(1,2,4),
minCost(1,3) + minCost(4,4) + cost(1,3,4)
)
이 아이디어를 재귀로 구현한다면
int minCost(i, j) {
if (i == j) return 0; // 한 개짜리 행렬은 계산 필요 없음
int res = INF;
for (int k = i; k < j; k++) {
int left = minCost(i, k); // 왼쪽 부분
int right = minCost(k+1, j); // 오른쪽 부분
int cost = dims[i] * dims[k+1] * dims[j+1]; // i~k와 k+1~j 곱하기 비용
res = Math.min(res, left + right + cost);
}
return res;
}
2. 중복 확인
행렬 곱셈을 진행할 때 쪼개지는 곱셈 순서에 반복 호출되는 함수가 있다.
3. 메모이제이션 적용
int minCost(int i, int j) {
if (i == j) return 0; // 하나의 행렬은 곱할 필요 없음
if (dp[i][j] != 0) return dp[i][j];
dp[i][j] = INF;
for (int k = i; k < j; k++) {
int cost = minCost(i, k) + minCost(k + 1, j)
+ (r[i] * c[k] * c[j]); // 곱셈 비용 계산
dp[i][j] = Math.min(dp[i][j], cost);
}
return dp[i][j];
}
n개의 물건과 각 물건 i의 무게 w, 가치 v가 주어지고 가방 용량이 C일 때, 가방에 담을 최대 값을 찾는 문제 (각 문제는 1개만 존재한다.)
1. 완전 탐색
해당 인덱스의 물건을 고려한 경우와
해당 인덱스의 물건을 고려하지 않은 경우 중 작은 값을 골라가면 된다.

static int sol(int index, int freeWeight) {
int ret = sol(index + 1, freeWeight);
// 해당 인덱스의 물건을 고려하지 않은 경우와
// 해당 인덱스의 물건을 고려한 경우 중 작은 값
if (freeWeight >= items[index].weight) {
ret = Math.max(ret, sol(index + 1, freeWeight - items[index].weight) + items[index]value);
}
return ret;
}
2. 중복되는 부분 찾기
그래프 상으로 나오지 않았지만 값이 커지는 경우에 호출되어가면서 중복되는 함수 호출이 생기게 된다.
3. 메모이제이션 적용
이를 cache[index][freeWeight] 배열로 값들을 저장해 나간다면 다음 그래프와 같이 배열 접근이 가능하다.

static int sol(int index, int freeWeight) {
if (index == ItemSize) {
return 0;
}
if (cache[index][freeWeight] != -1) {
return cache[index][freeWeight];
}
// 해당 인덱스 물건 선택 안함
int ret = sol(index + 1, freeWeight);
if (freeWeight >= items[index].weight) {
ret = Math.max(ret, sol(index + 1, freeWeight - items[index].weight) + items[index]value);
}
cache[index][freeWeight] = ret;
return ret;
}
DP를 처음 바텀 업 방식으로 풀 때 단순히 이전 값을 저장하는 테크닉으로 생각하고 문제에 적용했다.
그러다 보니 문제를 작은 부분 문제로 나누는 본질을 놓치지 않았나 싶다.
위의 문제를 풀 때 구조가 거의 비슷한 것을 알 수 있다. 이는 DFS(완전탐색)으로 시작해서 곂치는 부분만 메모이제이션으로 최적화하는 방법이었다.
DP 더 이상 무섭지 않다.
https://medium.com/quick-code/leetcode-casual-to-competitors-guide-to-dfs-memoization-3667cdbabf68
https://stackoverflow.com/questions/54016101/how-to-solve-algorithm-problems-in-both-dfs-and-dp
https://codeforces.com/blog/entry/43256
갓성광님🔥