다이나믹 프로그래밍(DP) 패턴별 완전 정복 – Knapsack부터 비트마스킹 DP까지

Updated Feb 6, 2026

다이나믹 프로그래밍이란?

다이나믹 프로그래밍(Dynamic Programming, DP)은 큰 문제를 작은 부분 문제로 나누어 해결하고, 중복 계산을 메모이제이션으로 방지하는 최적화 기법입니다.

DP의 핵심 원리

  1. 최적 부분 구조(Optimal Substructure): 큰 문제의 최적해가 작은 문제들의 최적해로 구성됨
  2. 중복 부분 문제(Overlapping Subproblems): 같은 작은 문제가 여러 번 반복되어 계산됨

DP는 완전 탐색의 시간 복잡도를 지수에서 다항식으로 개선할 수 있는 강력한 기법입니다.

DP 구현 방식

방식 설명 장점 단점
Top-down (재귀 + 메모이제이션) 큰 문제부터 재귀로 분할 직관적, 필요한 부분만 계산 재귀 스택 오버플로우 위험
Bottom-up (반복문) 작은 문제부터 순차적 계산 스택 오버플로우 없음, 빠름 모든 상태를 계산해야 함

패턴 1: 0/1 Knapsack (배낭 문제)

문제 정의

무게 제한이 WW인 배낭에 nn개의 물건을 담되, 가치의 합을 최대화하는 문제입니다. 각 물건 ii는 무게 wiw_i와 가치 viv_i를 가지며, 각 물건을 0개 또는 1개만 선택할 수 있습니다.

점화식

dp[i][w]dp[i][w] = 처음 ii개 물건으로 무게 ww 이하일 때 최대 가치

<br/>dp[i][w]={<br/>0amp;if i=0 or w=0\<br/>max(dp[i1][w],  dp[i1][wwi]+vi)amp;if wiw\<br/>dp[i1][w]amp;if wigt;w<br/><br/><br /> dp[i][w] = \begin{cases}<br /> 0 &amp; \text{if } i = 0 \text{ or } w = 0 \<br /> \max(dp[i-1][w], \; dp[i-1][w – w_i] + v_i) &amp; \text{if } w_i \leq w \<br /> dp[i-1][w] &amp; \text{if } w_i &gt; w<br /> \end{cases}<br />

  • dp[i1][w]dp[i-1][w]: ii번째 물건을 선택하지 않는 경우
  • dp[i1][wwi]+vidp[i-1][w – w_i] + v_i: ii번째 물건을 선택하는 경우 (남은 무게에서 최대값 + 현재 가치)

Python 구현

def knapsack(weights, values, W):
    """
    0/1 Knapsack 문제 해결

    Args:
        weights: 각 물건의 무게 리스트
        values: 각 물건의 가치 리스트
        W: 배낭의 최대 무게

    Returns:
        최대 가치
    """
    n = len(weights)
    # dp[i][w] = 처음 i개 물건으로 무게 w 이하일 때 최대 가치
    dp = [[0] * (W + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(W + 1):
            # i번째 물건을 선택하지 않는 경우
            dp[i][w] = dp[i-1][w]

            # i번째 물건을 선택할 수 있는 경우
            if weights[i-1] <= w:
                # 선택하지 않는 경우 vs 선택하는 경우 중 최댓값
                dp[i][w] = max(dp[i][w], dp[i-1][w - weights[i-1]] + values[i-1])

    return dp[n][W]

# 예제
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
W = 8

print(f"최대 가치: {knapsack(weights, values, W)}")  # 출력: 10

공간 최적화 (1차원 배열)

def knapsack_optimized(weights, values, W):
    """
    공간 복잡도 O(W)로 최적화된 Knapsack
    """
    dp = [0] * (W + 1)

    for i in range(len(weights)):
        # 역순으로 순회해야 이전 값이 덮어씌워지지 않음
        for w in range(W, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[W]

복잡도 분석

  • 시간 복잡도: O(nW)O(nW)nn개 물건, WW개 무게 상태
  • 공간 복잡도: O(nW)O(nW) (기본) / O(W)O(W) (최적화)

동작 과정 시각화

입력: weights = [2, 3, 4, 5], values = [3, 4, 5, 6], W = 8

i\w 0 1 2 3 4 5 6 7 8
0 0 0 0 0 0 0 0 0 0
1(w=2,v=3) 0 0 3 3 3 3 3 3 3
2(w=3,v=4) 0 0 3 4 4 7 7 7 7
3(w=4,v=5) 0 0 3 4 5 7 8 9 9
4(w=5,v=6) 0 0 3 4 5 6 8 9 10

최종 답: 10 (물건 1, 4 선택: 무게 2+5=7, 가치 3+6=9 또는 물건 2, 4 선택: 무게 3+5=8, 가치 4+6=10)

자주 하는 실수

  1. 역순 순회 누락: 1차원 최적화 시 정순 순회하면 같은 물건을 여러 번 선택하게 됨
  2. 인덱스 오류: weights[i-1]dp[i]의 인덱스 불일치 주의
  3. 초기화 누락: dp[0][...]dp[...][0]을 0으로 초기화 필수

패턴 2: Unbounded Knapsack (무제한 배낭)

0/1 Knapsack과 달리 각 물건을 무한히 선택할 수 있는 변형입니다.

점화식

<br/>dp[w]=maxi=1n(dp[wwi]+vi)for wiw<br/><br /> dp[w] = \max_{i=1}^{n} (dp[w – w_i] + v_i) \quad \text{for } w_i \leq w<br />

Python 구현

def unbounded_knapsack(weights, values, W):
    """
    물건을 무한히 선택 가능한 배낭 문제
    """
    dp = [0] * (W + 1)

    # 모든 무게에 대해
    for w in range(1, W + 1):
        # 모든 물건을 시도
        for i in range(len(weights)):
            if weights[i] <= w:
                # 현재 물건을 또 선택할 수 있으므로 dp[w - weights[i]] 사용
                dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

    return dp[W]

# 예제: 동전 교환 문제
coins = [1, 2, 5]  # 동전 종류 (무게)
values = [1, 2, 5]  # 가치 = 액면가
target = 11

print(f"최대 가치: {unbounded_knapsack(coins, values, target)}")  # 출력: 11

0/1 vs Unbounded 차이점

| 구분 | 0/1 Knapsack | Unbounded Knapsack |
|——|————–|——————–||
| 선택 횟수 | 각 물건 최대 1회 | 무제한 |
| 순회 방향 | 역순 (중복 방지) | 정순 (중복 허용) |
| 참조 상태 | dp[i-1][...] | dp[w - weight] |
| 응용 문제 | 부분집합 합 | 동전 교환, 로드 절단 |


패턴 3: LIS (Longest Increasing Subsequence)

문제 정의

수열에서 순서를 유지하며 엄격히 증가하는 부분 수열 중 가장 긴 것의 길이를 구하는 문제입니다.

예제: [10, 9, 2, 5, 3, 7, 101, 18] → LIS = [2, 3, 7, 101] (길이 4)

방법 1: DP (O(n2)O(n^2))

점화식

dp[i]dp[i] = ii번째 원소를 마지막으로 하는 LIS 길이

<br />
dp[i] = \max_{j &lt; i, \; arr[j] &lt; arr[i]} (dp[j] + 1)<br />

def lis_dp(arr):
    """
    O(n^2) DP 방식의 LIS
    """
    n = len(arr)
    if n == 0:
        return 0

    # dp[i] = i번째 원소를 마지막으로 하는 LIS 길이
    dp = [1] * n  # 최소 길이는 자기 자신 1개

    for i in range(1, n):
        for j in range(i):
            # arr[j] < arr[i]이면 arr[j] 뒤에 arr[i]를 붙일 수 있음
            if arr[j] < arr[i]:
                dp[i] = max(dp[i], dp[j] + 1)

    return max(dp)  # 모든 위치 중 최댓값

# 예제
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(f"LIS 길이: {lis_dp(arr)}")  # 출력: 4

방법 2: 이분 탐색 (O(nlogn)O(n \log n))

더 효율적인 방법은 증가 수열을 유지하며 이분 탐색으로 위치를 찾는 것입니다.

import bisect

def lis_binary_search(arr):
    """
    O(n log n) 이분 탐색 방식의 LIS

    tails[i] = 길이 i+1인 증가 수열의 마지막 원소 중 최솟값
    """
    tails = []  # 증가 수열 유지

    for num in arr:
        # num이 들어갈 위치 찾기 (이분 탐색)
        pos = bisect.bisect_left(tails, num)

        if pos == len(tails):
            # num이 가장 크면 수열 확장
            tails.append(num)
        else:
            # 기존 위치를 더 작은 값으로 갱신
            tails[pos] = num

    return len(tails)

# 예제
arr = [10, 9, 2, 5, 3, 7, 101, 18]
print(f"LIS 길이: {lis_binary_search(arr)}")  # 출력: 4

동작 과정 시각화

입력: [10, 9, 2, 5, 3, 7, 101, 18]

단계 num tails 설명
1 10 [10] 첫 원소 추가
2 9 [9] 10을 9로 교체 (더 작은 값)
3 2 [2] 9를 2로 교체
4 5 [2, 5] 2 다음에 5 추가
5 3 [2, 3] 5를 3으로 교체
6 7 [2, 3, 7] 3 다음에 7 추가
7 101 [2, 3, 7, 101] 7 다음에 101 추가
8 18 [2, 3, 7, 18] 101을 18로 교체

최종 길이: 4

주의: tails 배열은 실제 LIS가 아니라, 각 길이별로 가능한 증가 수열의 마지막 원소 중 최솟값을 유지하는 보조 배열입니다. 이를 통해 새 원소가 들어갈 최적의 위치를 이분 탐색으로 빠르게 찾을 수 있습니다.

복잡도 비교

방법 시간 복잡도 공간 복잡도 장점
DP O(n2)O(n^2) O(n)O(n) 구현 간단, 실제 수열 복원 쉬움
이분 탐색 O(nlogn)O(n \log n) O(n)O(n) 대용량 데이터 처리 가능

LIS 수열 복원하기

def lis_with_path(arr):
    """
    LIS 길이와 실제 수열을 함께 반환
    """
    n = len(arr)
    dp = [1] * n
    parent = [-1] * n  # 이전 원소의 인덱스 저장

    for i in range(1, n):
        for j in range(i):
            if arr[j] < arr[i] and dp[j] + 1 > dp[i]:
                dp[i] = dp[j] + 1
                parent[i] = j  # j에서 i로 연결

    # 최댓값 위치 찾기
    max_length = max(dp)
    max_idx = dp.index(max_length)

    # 역추적으로 수열 복원
    lis = []
    idx = max_idx
    while idx != -1:
        lis.append(arr[idx])
        idx = parent[idx]

    return max_length, lis[::-1]  # 역순으로 저장했으므로 뒤집기

# 예제
arr = [10, 9, 2, 5, 3, 7, 101, 18]
length, sequence = lis_with_path(arr)
print(f"LIS 길이: {length}")  # 4
print(f"LIS 수열: {sequence}")  # [2, 3, 7, 101] (또는 다른 가능한 LIS)

패턴 4: LCS (Longest Common Subsequence)

문제 정의

두 문자열(또는 수열)에서 공통으로 나타나는 부분 수열 중 가장 긴 것의 길이를 구합니다.

예제: "ABCDGH", "AEDFHR" → LCS = "ADH" (길이 3)

점화식

dp[i][j]dp[i][j] = 첫 번째 문자열의 처음 ii글자와 두 번째 문자열의 처음 jj글자의 LCS 길이

<br/>dp[i][j]={<br/>0amp;if i=0 or j=0\<br/>dp[i1][j1]+1amp;if s1[i1]=s2[j1]\<br/>max(dp[i1][j],  dp[i][j1])amp;otherwise<br/><br/><br /> dp[i][j] = \begin{cases}<br /> 0 &amp; \text{if } i = 0 \text{ or } j = 0 \<br /> dp[i-1][j-1] + 1 &amp; \text{if } s1[i-1] = s2[j-1] \<br /> \max(dp[i-1][j], \; dp[i][j-1]) &amp; \text{otherwise}<br /> \end{cases}<br />

  • 문자가 같으면: 두 문자를 포함하고 이전 상태에서 +1
  • 문자가 다르면: 한쪽을 제외한 경우 중 최댓값

Python 구현

def lcs(s1, s2):
    """
    두 문자열의 LCS 길이 계산
    """
    m, n = len(s1), len(s2)
    # dp[i][j] = s1[:i]와 s2[:j]의 LCS 길이
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                # 문자가 같으면 대각선 값 + 1
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                # 문자가 다르면 위쪽 또는 왼쪽 중 최댓값
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    return dp[m][n]

# 예제
s1 = "ABCDGH"
s2 = "AEDFHR"
print(f"LCS 길이: {lcs(s1, s2)}")  # 출력: 3

동작 과정 시각화

입력: s1 = "ABCDGH", s2 = "AEDFHR"

A E D F H R
0 0 0 0 0 0 0
A 0 1 1 1 1 1 1
B 0 1 1 1 1 1 1
C 0 1 1 1 1 1 1
D 0 1 1 2 2 2 2
G 0 1 1 2 2 2 2
H 0 1 1 2 2 3 3

최종 답: 3 (LCS = “ADH”)

LCS 문자열 복원

def lcs_with_string(s1, s2):
    """
    LCS 길이와 실제 문자열을 함께 반환
    """
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    # 역추적으로 LCS 복원
    lcs_str = []
    i, j = m, n
    while i > 0 and j > 0:
        if s1[i-1] == s2[j-1]:
            # 문자가 같으면 LCS에 포함
            lcs_str.append(s1[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            # 위쪽에서 온 경우
            i -= 1
        else:
            # 왼쪽에서 온 경우
            j -= 1

    return dp[m][n], ''.join(reversed(lcs_str))

# 예제
s1 = "ABCDGH"
s2 = "AEDFHR"
length, lcs_str = lcs_with_string(s1, s2)
print(f"LCS 길이: {length}")  # 3
print(f"LCS 문자열: {lcs_str}")  # ADH

복잡도 분석

  • 시간 복잡도: O(mn)O(mn) – 두 문자열 길이의 곱
  • 공간 복잡도: O(mn)O(mn) (기본) / O(min(m,n))O(\min(m, n)) (최적화 가능)

응용 문제

  1. Edit Distance: 삽입/삭제/교체 연산 횟수 최소화
  2. Diff 알고리즘: Git에서 변경 내역 추적
  3. DNA 서열 정렬: 생물정보학

패턴 5: 구간 DP (Interval DP)

문제 정의

구간 [i,j][i, j]에 대한 최적값을 구하는 패턴입니다. 대표 문제로 행렬 곱셈 순서 최적화가 있습니다.

예제: 행렬 연쇄 곱셈 (Matrix Chain Multiplication)

nn개의 행렬 A1,A2,...,AnA_1, A_2, …, A_n을 곱할 때, 곱셈 순서에 따라 연산 횟수가 달라집니다.

예제: 행렬 크기가 [10×20, 20×30, 30×40, 40×30]일 때
((A1×A2)×A3)×A4: 26,000번
A1×((A2×A3)×A4): 30,000번
(A1×A2)×(A3×A4): 18,000번 ✅ 최소

점화식

dp[i][j]dp[i][j] = 행렬 AiA_i부터 AjA_j까지 곱하는 최소 연산 횟수

<br />
dp[i][j] = \min_{i \leq k &lt; j} \left( dp[i][k] + dp[k+1][j] + p_{i-1} \times p_k \times p_j \right)<br />

여기서 pp는 행렬 크기 배열 (행렬 AiA_ipi1×pip_{i-1} \times p_i 크기)

Python 구현

def matrix_chain_order(dims):
    """
    행렬 연쇄 곱셈 최소 연산 횟수

    Args:
        dims: 행렬 크기 배열 [p0, p1, ..., pn]
              행렬 Ai는 p[i-1] x p[i] 크기

    Returns:
        최소 곱셈 연산 횟수
    """
    n = len(dims) - 1  # 행렬 개수
    # dp[i][j] = 행렬 i부터 j까지 곱하는 최소 연산 횟수
    dp = [[0] * n for _ in range(n)]

    # length: 곱할 행렬 체인의 길이
    for length in range(2, n + 1):  # 2개부터 n개까지
        for i in range(n - length + 1):
            j = i + length - 1
            dp[i][j] = float('inf')

            # k: 분할 지점 (i부터 j-1까지)
            for k in range(i, j):
                # [i...k] + [k+1...j] + 합치는 비용
                cost = (
                    dp[i][k] +  # 왼쪽 부분 곱셈
                    dp[k+1][j] +  # 오른쪽 부분 곱셈
                    dims[i] * dims[k+1] * dims[j+1]  # 두 결과 행렬 곱셈
                )
                dp[i][j] = min(dp[i][j], cost)

    return dp[0][n-1]

# 예제: 행렬 크기가 10×20, 20×30, 30×40, 40×30
dims = [10, 20, 30, 40, 30]
print(f"최소 곱셈 횟수: {matrix_chain_order(dims)}")  # 출력: 18000

동작 과정 (dims = [10, 20, 30, 40, 30])

length i j 최적 분할 k dp[i][j]
1 0-3 0 (단일 행렬)
2 0 1 0 6,000
2 1 2 1 24,000
2 2 3 2 36,000
3 0 2 0 18,000
3 1 3 2 30,000
4 0 3 0 18,000

복잡도 분석

  • 시간 복잡도: O(n3)O(n^3) – 3중 반복문
  • 공간 복잡도: O(n2)O(n^2) – 2차원 DP 테이블

구간 DP 특징

구간 DP는 작은 구간부터 큰 구간으로 확장하며, 모든 분할 지점을 시도합니다.


패턴 6: 비트마스킹 DP

문제 정의

집합의 상태를 비트로 표현하여 DP를 수행하는 기법입니다. 주로 n20n \leq 20 정도일 때 사용됩니다.

대표 문제: 외판원 순회 (TSP, Traveling Salesman Problem)

nn개 도시를 모두 한 번씩 방문하고 시작 도시로 돌아오는 최소 비용 경로를 찾는 문제입니다.

점화식

dp[mask][i]dp[mask][i] = 방문한 도시 집합이 maskmask이고 현재 도시가 ii일 때 남은 경로의 최소 비용

<br/>dp[mask][i]=minjmask(cost[i][j]+dp[maskj][j])<br/><br /> dp[mask][i] = \min_{j \notin mask} \left( cost[i][j] + dp[mask \cup {j}][j] \right)<br />

Python 구현

def tsp(dist):
    """
    외판원 순회 문제 (비트마스킹 DP)

    Args:
        dist: dist[i][j] = 도시 i에서 j로 가는 비용

    Returns:
        최소 비용 (0번 도시에서 시작하여 모든 도시 방문 후 복귀)
    """
    n = len(dist)
    # dp[mask][i] = mask 상태에서 도시 i에 있을 때 남은 최소 비용
    # mask: 방문한 도시를 비트로 표현 (1 << i가 1이면 도시 i 방문)
    dp = [[float('inf')] * n for _ in range(1 << n)]

    # 기저: 모든 도시를 방문한 상태에서 시작 도시(0)로 복귀
    for i in range(n):
        dp[(1 << n) - 1][i] = dist[i][0]  # 도시 i → 0 비용

    # 역순으로 DP (많이 방문한 상태 → 적게 방문한 상태)
    for mask in range((1 << n) - 2, -1, -1):
        for i in range(n):
            # 도시 i를 이미 방문했는지 확인
            if not (mask & (1 << i)):
                continue

            # 다음 방문할 도시 j 시도
            for j in range(n):
                # 아직 방문하지 않은 도시만
                if mask & (1 << j):
                    continue

                # 도시 i → j로 이동
                next_mask = mask | (1 << j)
                dp[mask][i] = min(dp[mask][i], dist[i][j] + dp[next_mask][j])

    return dp[1][0]  # 0번 도시만 방문한 상태에서 시작

# 예제: 4개 도시
dist = [
    [0, 10, 15, 20],
    [10, 0, 35, 25],
    [15, 35, 0, 30],
    [20, 25, 30, 0]
]
print(f"최소 비용: {tsp(dist)}")  # 출력: 80 (0→1→3→2→0)

비트마스킹 기본 연산

연산 코드 설명
i번째 비트 켜기 mask \| (1 << i) i번 도시 방문 표시
i번째 비트 끄기 mask & ~(1 << i) i번 도시 방문 취소
i번째 비트 확인 if mask & (1 << i): i번 도시 방문 여부 (Truthy/Falsy 체크)
모든 비트 켜기 (1 << n) - 1 n개 도시 모두 방문
비트 개수 세기 bin(mask).count('1') 방문한 도시 수

비트 확인 팁: mask & (1 << i)의 결과는 0(방문 안 함) 또는 2i2^i(방문함)이므로, if mask & (1 << i):처럼 직접 조건문에 사용할 수 있습니다.

복잡도 분석

  • 시간 복잡도: O(2n×n2)O(2^n \times n^2) – 모든 상태(2n2^n)에서 nn개 도시 시도
  • 공간 복잡도: O(2n×n)O(2^n \times n)
  • 실용 범위: n20n \leq 20 (그 이상은 메모리/시간 초과)

비트마스킹 DP 응용

  1. 부분집합 합 문제: 특정 합을 만들 수 있는 부분집합 찾기
  2. 최소 정점 커버: 모든 간선을 커버하는 최소 정점 집합
  3. Hamilton Path: 모든 정점을 한 번씩 방문하는 경로
  4. Assignment Problem: 작업-직원 최적 배정

난이도별 접근 전략

쉬움 (DP 입문)

특징: 1차원 DP, 명확한 점화식

대표 문제:
– 피보나치 수열
– 계단 오르기 (1칸 또는 2칸)
– 1로 만들기 (나누기/빼기 연산)

접근법:
1. 작은 예제로 손으로 직접 풀어보기
2. 규칙 발견 → 점화식 도출
3. 반복문으로 구현 (재귀는 스택 오버플로우 위험)

보통 (2차원 DP)

특징: 2차원 테이블, 두 가지 변수 추적

대표 문제:
– Knapsack (0/1, Unbounded)
– LCS (Longest Common Subsequence)
– Edit Distance
– 격자 경로 개수

접근법:
1. DP 테이블 정의 명확히 (“dp[i][j]가 무엇을 의미하는가?”)
2. 초기 조건 설정 (i=0 또는 j=0)
3. 점화식에서 “이전 상태”가 무엇인지 파악
4. 표를 그려가며 손으로 계산해보기

어려움 (고급 패턴)

특징: 3차원 이상, 비트마스킹, 최적화 필요

대표 문제:
– TSP (비트마스킹 DP)
– 구간 DP (행렬 곱셈, 팰린드롬 분할)
– 트리 DP
– 확률 DP

접근법:
1. 상태 정의가 핵심: 무엇을 추적해야 하는가?
2. 메모이제이션 전략: Top-down이 더 직관적일 수 있음
3. 공간 최적화: 슬라이딩 윈도우, 필요한 상태만 저장
4. 시간 복잡도 계산: O(2n)O(2^n)이면 n20n \leq 20, O(n3)O(n^3)이면 n500n \leq 500 정도


자주 하는 실수와 디버깅 팁

1. 초기화 오류

# ❌ 잘못된 예: 최댓값을 구할 때 0으로 초기화
dp = [0] * n

# ✅ 올바른 예: -inf로 초기화
dp = [float('-inf')] * n
dp[0] = 0  # 기저 조건만 명시

2. 인덱스 오프바이원 (Off-by-One)

# ❌ 문자열 인덱스 혼동
if s1[i] == s2[j]:  # i, j는 1부터 시작하는데 문자열은 0부터
    dp[i][j] = dp[i-1][j-1] + 1

# ✅ 올바른 예
if s1[i-1] == s2[j-1]:  # DP 인덱스와 문자열 인덱스 구분
    dp[i][j] = dp[i-1][j-1] + 1

3. 순회 순서 오류

# ❌ 0/1 Knapsack을 정순으로 순회 (같은 물건을 여러 번 선택)
for i in range(n):
    for w in range(weights[i], W + 1):  # 정순
        dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

# ✅ 역순으로 순회
for i in range(n):
    for w in range(W, weights[i] - 1, -1):  # 역순
        dp[w] = max(dp[w], dp[w - weights[i]] + values[i])

4. 메모리 초과

증상: n=105n = 10^5일 때 2차원 배열 O(n2)O(n^2)는 40GB 메모리 필요

해결책:
– 슬라이딩 윈도우 (이전 행만 저장)
– 딕셔너리로 필요한 상태만 저장
– Top-down 메모이제이션 (필요한 상태만 계산)

# 공간 최적화: O(n^2) → O(n)
def lcs_optimized(s1, s2):
    m, n = len(s1), len(s2)
    prev = [0] * (n + 1)
    curr = [0] * (n + 1)

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                curr[j] = prev[j-1] + 1
            else:
                curr[j] = max(prev[j], curr[j-1])
        prev, curr = curr, prev  # 행 교체

    return prev[n]

5. 시간 복잡도 착각

복잡도 nn 범위 1초 기준
O(n)O(n) 10810^8 가능
O(nlogn)O(n \log n) 10610^6 가능
O(n2)O(n^2) 10410^4 가능
O(n3)O(n^3) 500500 가능
O(2n)O(2^n) 2020 가능
O(n!)O(n!) 1111 가능

실전 문제 풀이 체크리스트

1단계: 문제 분석

  • [ ] 최적 부분 구조가 있는가? (작은 문제의 답으로 큰 문제 해결 가능)
  • [ ] 중복 부분 문제가 있는가? (같은 계산이 반복됨)
  • [ ] 제약 조건 확인 (nn 범위로 시간 복잡도 추정)

2단계: DP 설계

  • [ ] 상태 정의: “dp[i]가 무엇을 의미하는가?” 명확히
  • [ ] 점화식 도출: 현재 상태를 이전 상태로 표현
  • [ ] 초기 조건: 기저 사례 설정
  • [ ] 계산 순서: 의존 관계 파악 (작은 문제 → 큰 문제)

3단계: 구현

  • [ ] Top-down vs Bottom-up 선택
  • [ ] 배열 크기 올바르게 초기화
  • [ ] 인덱스 오류 주의 (특히 문자열/배열 인덱스)
  • [ ] 순회 방향 확인 (0/1 Knapsack은 역순)

4단계: 최적화

  • [ ] 공간 복잡도 개선 가능한가? (슬라이딩 윈도우)
  • [ ] 불필요한 상태 제거 (딕셔너리 사용)
  • [ ] 시간 복잡도가 제한 내인가?

5단계: 검증

  • [ ] 작은 예제로 손으로 계산
  • [ ] 엣지 케이스 테스트 (n=0, n=1, 모든 값이 같을 때)
  • [ ] 경계 조건 확인 (배열 범위 초과 없는지)

마무리

다이나믹 프로그래밍은 최적 부분 구조중복 부분 문제라는 두 가지 핵심 특성을 가진 문제에 적용하는 강력한 기법입니다. 이 글에서 다룬 주요 패턴을 정리하면:

핵심 패턴 요약

패턴 대표 문제 시간 복잡도 핵심 아이디어
Knapsack 배낭 문제 O(nW)O(nW) 선택/비선택 결정
LIS 최장 증가 부분 수열 O(n2)O(n^2) 또는 O(nlogn)O(n \log n) 이전 원소와 비교
LCS 최장 공통 부분 수열 O(mn)O(mn) 두 문자열 비교
구간 DP 행렬 곱셈 순서 O(n3)O(n^3) 작은 구간 → 큰 구간
비트마스킹 TSP O(2n×n2)O(2^n \times n^2) 집합 상태를 비트로 표현

학습 로드맵

  1. 기초: 피보나치, 계단 오르기 → 1차원 DP 감 잡기
  2. 중급: Knapsack, LCS → 2차원 DP와 점화식 설계
  3. 고급: LIS 이분 탐색, 구간 DP → 최적화 기법
  4. 심화: 비트마스킹, 트리 DP → 복잡한 상태 관리

마지막 조언

DP 문제는 처음에는 어렵게 느껴지지만, 패턴을 익히고 많은 문제를 풀어보면 자연스럽게 점화식이 보이기 시작합니다. 포기하지 말고 작은 예제부터 손으로 그려가며 이해하세요!

연습 추천:
– 백준: DP 단계별 문제
– LeetCode: Dynamic Programming
– 프로그래머스: DP 연습 문제

코딩 테스트에서 DP는 고득점의 지름길입니다. 이 글이 여러분의 DP 실력 향상에 도움이 되길 바랍니다!

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 450 | TOTAL 2,673