동적 프로그래밍 기초
동적 프로그래밍(DP)은 코딩테스트에서 출제 비중(약 6%)은 낮지만, Lv3 이상의 고난도 문제에서 자주 등장합니다. 기초적인 DP 유형만 익혀도 많은 문제를 해결할 수 있습니다. 이 챕터에서는 코딩테스트에 자주 나오는 기초 DP 패턴만 다룹니다.
1. 동적 프로그래밍이란?
동적 프로그래밍(Dynamic Programming, DP)은 큰 문제를 작은 문제로 나누어 해결하고, 작은 문제의 결과를 저장해두었다가 재활용하는 기법입니다.
1.1 DP가 필요한 이유
피보나치 수열을 재귀로 구현하면 같은 계산을 반복합니다.
// 비효율적인 재귀 (O(2^n))
function fibRecursive(n) {
if (n <= 1) {
return n;
}
return fibRecursive(n - 1) + fibRecursive(n - 2);
}
// fib(5)를 계산하면:
// fib(3)이 2번 호출
// fib(2)가 3번 호출
// fib(1)이 5번 호출
DP를 사용하면 이미 계산한 값을 저장해두고 재사용합니다.
1.2 DP의 두 가지 조건
-
최적 부분 구조 (Optimal Substructure)
- 큰 문제의 최적해가 작은 문제의 최적해로 구성됨
- 예:
fib(n) = fib(n-1) + fib(n-2)
-
중복되는 부분 문제 (Overlapping Subproblems)
- 동일한 작은 문제가 반복적으로 등장
- 예:
fib(3)이 여러 번 필요
2. DP 구현 방식
2.1 탑다운 (Top-Down, 메모이제이션)
재귀 + 저장 방식입니다. 큰 문제에서 시작해서 작은 문제로 내려갑니다.
// 메모이제이션 (탑다운)
function fibTopdown(n, memo = {}) {
if (n in memo) {
return memo[n];
}
if (n <= 1) {
return n;
}
memo[n] = fibTopdown(n - 1, memo) + fibTopdown(n - 2, memo);
return memo[n];
}
console.log(fibTopdown(50)); // 12586269025 (빠르게 계산)
// 클로저를 활용한 메모이제이션
function memoize(fn) {
const cache = {};
return function(...args) {
const key = args.toString();
if (key in cache) {
return cache[key];
}
cache[key] = fn.apply(this, args);
return cache[key];
};
}
const fibCached = memoize(function(n) {
if (n <= 1) return n;
return fibCached(n - 1) + fibCached(n - 2);
});
console.log(fibCached(50)); // 12586269025
2.2 바텀업 (Bottom-Up, 타뷸레이션)
반복문 + 테이블 방식입니다. 작은 문제부터 시작해서 큰 문제로 올라갑니다.
// 타뷸레이션 (바텀업)
function fibBottomup(n) {
if (n <= 1) {
return n;
}
const dp = new Array(n + 1).fill(0);
dp[0] = 0;
dp[1] = 1;
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
console.log(fibBottomup(50)); // 12586269025
// 공간 최적화 (변수 2개만 사용)
function fibOptimized(n) {
if (n <= 1) {
return n;
}
let prev = 0;
let curr = 1;
for (let i = 2; i <= n; i++) {
[prev, curr] = [curr, prev + curr];
}
return curr;
}
console.log(fibOptimized(50)); // 12586269025
2.3 탑다운 vs 바텀업
| 특성 | 탑다운 (메모이제이션) | 바텀업 (타뷸레이션) |
|---|---|---|
| 구현 | 재귀 + 캐시 | 반복문 + 테이블 |
| 순서 | 큰 문제 → 작은 문제 | 작은 문제 → 큰 문제 |
| 필요한 부분만 계산 | O | X (모든 부분 계산) |
| 스택 오버플로우 위험 | O | X |
| 코딩테스트 추천 | 직관적일 때 | 안정적 |
3. 코딩테스트 필수 DP 유형
3.1 계단 오르기 (1차원 DP)
function climbStairs(n) {
// 계단을 오르는 방법의 수
// 한 번에 1칸 또는 2칸 오를 수 있음
if (n <= 2) {
return n;
}
const dp = new Array(n + 1).fill(0);
dp[1] = 1; // 1칸: 1가지
dp[2] = 2; // 2칸: 2가지 (1+1, 2)
for (let i = 3; i <= n; i++) {
// i번째 계단 = (i-1)에서 1칸 + (i-2)에서 2칸
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
console.log(climbStairs(5)); // 8
// 1+1+1+1+1, 1+1+1+2, 1+1+2+1, 1+2+1+1, 2+1+1+1,
// 1+2+2, 2+1+2, 2+2+1
점화식: dp[i] = dp[i-1] + dp[i-2]
3.2 동전 교환 (최소 개수)
function minCoins(coins, amount) {
// 주어진 동전으로 amount를 만들기 위한 최소 동전 개수
// 만들 수 없으면 -1 반환
// dp[i] = i원을 만들기 위한 최소 동전 개수
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0; // 0원은 동전 0개 필요
for (let i = 1; i <= amount; i++) {
for (const coin of coins) {
if (coin <= i && dp[i - coin] !== Infinity) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] !== Infinity ? dp[amount] : -1;
}
console.log(minCoins([1, 2, 5], 11)); // 3 (5+5+1)
console.log(minCoins([2], 3)); // -1 (불가능)
점화식: dp[i] = min(dp[i], dp[i - coin] + 1) for each coin
3.3 최장 증가 부분 수열 (LIS)
function longestIncreasingSubsequence(arr) {
// 가장 긴 증가하는 부분 수열의 길이
// 예: [10, 20, 10, 30, 20, 50] → [10, 20, 30, 50] → 4
const n = arr.length;
if (n === 0) {
return 0;
}
// dp[i] = arr[i]로 끝나는 LIS의 길이
const dp = new Array(n).fill(1);
for (let i = 1; i < n; i++) {
for (let j = 0; j < i; j++) {
if (arr[j] < arr[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp);
}
console.log(longestIncreasingSubsequence([10, 20, 10, 30, 20, 50])); // 4
console.log(longestIncreasingSubsequence([3, 1, 4, 1, 5, 9, 2, 6])); // 4
점화식: dp[i] = max(dp[j] + 1) where arr[j] < arr[i] and j < i
3.4 배낭 문제 (0/1 Knapsack)
function knapsack(weights, values, capacity) {
// 배낭의 용량 내에서 가치의 합이 최대가 되도록
// 물건을 선택할 때의 최대 가치
// 각 물건은 한 번만 선택 가능
const n = weights.length;
// dp[i][w] = i번째 물건까지 고려했을 때, 용량 w에서의 최대 가치
const dp = Array.from({ length: n + 1 }, () => new Array(capacity + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let w = 0; w <= capacity; w++) {
// i번째 물건을 넣지 않는 경우
dp[i][w] = dp[i - 1][w];
// i번째 물건을 넣는 경우 (넣을 수 있다면)
if (weights[i - 1] <= w) {
dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - weights[i - 1]] + values[i - 1]);
}
}
}
return dp[n][capacity];
}
const weights = [2, 3, 4, 5];
const values = [3, 4, 5, 6];
const capacity = 5;
console.log(knapsack(weights, values, capacity)); // 7 (물건 1, 2 선택)
점화식: dp[i][w] = max(dp[i-1][w], dp[i-1][w-weight[i]] + value[i])
3.5 2차원 격자 경로
function uniquePaths(m, n) {
// m x n 격자에서 좌상단에서 우하단까지
// 오른쪽 또는 아래로만 이동할 때의 경로 수
const dp = Array.from({ length: m }, () => new Array(n).fill(1));
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
// 위에서 오거나 왼쪽에서 오는 경우의 합
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
console.log(uniquePaths(3, 3)); // 6
console.log(uniquePaths(3, 7)); // 28
점화식: dp[i][j] = dp[i-1][j] + dp[i][j-1]
3.6 최소 경로 합
function minPathSum(grid) {
// 격자에서 좌상단에서 우하단까지
// 경로 합이 최소가 되는 값
const m = grid.length;
const n = grid[0].length;
// dp[i][j] = (0,0)에서 (i,j)까지의 최소 경로 합
const dp = Array.from({ length: m }, () => new Array(n).fill(0));
dp[0][0] = grid[0][0];
// 첫 행: 왼쪽에서만 올 수 있음
for (let j = 1; j < n; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 첫 열: 위에서만 올 수 있음
for (let i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 나머지: 위 또는 왼쪽 중 작은 값 + 현재 값
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
const grid = [
[1, 3, 1],
[1, 5, 1],
[4, 2, 1]
];
console.log(minPathSum(grid)); // 7 (1→3→1→1→1)
4. DP 문제 접근법
4.1 DP 문제 판별
4.2 DP 문제 풀이 단계
- 상태 정의:
dp[i]가 무엇을 의미하는지 정의 - 점화식 도출:
dp[i]를 이전 상태로 표현 - 초기값 설정: 가장 작은 문제의 답 설정
- 계산 순서: 작은 문제부터 큰 문제 순서로
DP 문제 키워드
- "~의 최솟값/최댓값"
- "~의 경우의 수"
- "~가 가능한가?"
- "가장 긴/짧은 ~"
- "연속된 ~"
4.3 자주 사용하는 상태 정의
| 유형 | 상태 정의 | 예시 |
|---|---|---|
| 1차원 | dp[i]: i번째까지의 최적해 | 계단 오르기, 피보나치 |
| 부분 배열 | dp[i]: i에서 끝나는 최적해 | LIS, 최대 부분 합 |
| 2차원 격자 | dp[i][j]: (i,j)까지의 최적해 | 경로 수, 최소 비용 |
| 배낭 | dp[i][w]: i번째까지, 용량 w | 0/1 배낭 문제 |
| 문자열 | dp[i][j]: s1[0:i], s2[0:j] | LCS, 편집 거리 |
5. DP 심화 문제
5.1 숫자로 목표값 만들기
function minUses(n, target) {
// 숫자 n을 사칙연산으로 조합하여 target을 만들 때
// n을 최소 몇 번 사용해야 하는지 반환합니다.
// 8번 초과면 -1 반환
//
// 예: n=5, target=12 → (55+5)/5=12 → 4번 사용
if (n === target) {
return 1;
}
// dp[i] = n을 i번 사용해서 만들 수 있는 모든 수
const dp = Array.from({ length: 9 }, () => new Set());
for (let i = 1; i <= 8; i++) {
// n을 i번 연속 사용 (예: 5, 55, 555, ...)
dp[i].add(parseInt(String(n).repeat(i), 10));
// j번 사용 + (i-j)번 사용 조합
for (let j = 1; j < i; j++) {
for (const a of dp[j]) {
for (const b of dp[i - j]) {
dp[i].add(a + b);
dp[i].add(a - b);
dp[i].add(a * b);
if (b !== 0) {
dp[i].add(Math.floor(a / b));
}
}
}
}
if (dp[i].has(target)) {
return i;
}
}
return -1;
}
console.log(minUses(5, 12)); // 4
console.log(minUses(2, 11)); // 3
5.2 삼각형 경로 최대합
function maxTrianglePath(triangle) {
// 삼각형 형태의 숫자 배열에서 꼭대기부터 바닥까지
// 이동하며 거쳐간 숫자의 최대 합을 구합니다.
// 각 단계에서 바로 아래 또는 오른쪽 아래로만 이동할 수 있습니다.
const n = triangle.length;
// 바닥부터 올라가기 (바텀업)
const dp = [...triangle[n - 1]]; // 마지막 행 복사
for (let i = n - 2; i >= 0; i--) { // 아래에서 두 번째 행부터
for (let j = 0; j < triangle[i].length; j++) {
// 아래 두 값 중 큰 값 선택
dp[j] = triangle[i][j] + Math.max(dp[j], dp[j + 1]);
}
}
return dp[0];
}
const triangle = [[7], [3, 8], [8, 1, 0], [2, 7, 4, 4], [4, 5, 2, 6, 5]];
console.log(maxTrianglePath(triangle)); // 30
6. 연습문제
- (DP) 마라탕: https://jsalgo.co.kr/?page=12
- (DP) 연속 수열 최대합: https://100.jsalgo.co.kr/?page=24
- (DP) 계단 오르기: https://100.jsalgo.co.kr/?page=25