다이나믹 프로그래밍이란?
다이나믹 프로그래밍(Dynamic Programming, DP)은 큰 문제를 작은 부분 문제로 나누어 해결하고, 중복 계산을 메모이제이션으로 방지하는 최적화 기법입니다.
DP의 핵심 원리
- 최적 부분 구조(Optimal Substructure): 큰 문제의 최적해가 작은 문제들의 최적해로 구성됨
- 중복 부분 문제(Overlapping Subproblems): 같은 작은 문제가 여러 번 반복되어 계산됨
DP는 완전 탐색의 시간 복잡도를 지수에서 다항식으로 개선할 수 있는 강력한 기법입니다.
DP 구현 방식
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Top-down (재귀 + 메모이제이션) | 큰 문제부터 재귀로 분할 | 직관적, 필요한 부분만 계산 | 재귀 스택 오버플로우 위험 |
| Bottom-up (반복문) | 작은 문제부터 순차적 계산 | 스택 오버플로우 없음, 빠름 | 모든 상태를 계산해야 함 |
패턴 1: 0/1 Knapsack (배낭 문제)
문제 정의
무게 제한이 인 배낭에 개의 물건을 담되, 가치의 합을 최대화하는 문제입니다. 각 물건 는 무게 와 가치 를 가지며, 각 물건을 0개 또는 1개만 선택할 수 있습니다.
점화식
= 처음 개 물건으로 무게 이하일 때 최대 가치
- : 번째 물건을 선택하지 않는 경우
- : 번째 물건을 선택하는 경우 (남은 무게에서 최대값 + 현재 가치)
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]
복잡도 분석
- 시간 복잡도: – 개 물건, 개 무게 상태
- 공간 복잡도: (기본) / (최적화)
동작 과정 시각화
입력: 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차원 최적화 시 정순 순회하면 같은 물건을 여러 번 선택하게 됨
- 인덱스 오류:
weights[i-1]과dp[i]의 인덱스 불일치 주의 - 초기화 누락:
dp[0][...]과dp[...][0]을 0으로 초기화 필수
패턴 2: Unbounded Knapsack (무제한 배낭)
0/1 Knapsack과 달리 각 물건을 무한히 선택할 수 있는 변형입니다.
점화식
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 ()
점화식
= 번째 원소를 마지막으로 하는 LIS 길이
<br />
dp[i] = \max_{j < i, \; arr[j] < 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: 이분 탐색 ()
더 효율적인 방법은 증가 수열을 유지하며 이분 탐색으로 위치를 찾는 것입니다.
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 | 구현 간단, 실제 수열 복원 쉬움 | ||
| 이분 탐색 | 대용량 데이터 처리 가능 |
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)
점화식
= 첫 번째 문자열의 처음 글자와 두 번째 문자열의 처음 글자의 LCS 길이
- 문자가 같으면: 두 문자를 포함하고 이전 상태에서 +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
복잡도 분석
- 시간 복잡도: – 두 문자열 길이의 곱
- 공간 복잡도: (기본) / (최적화 가능)
응용 문제
- Edit Distance: 삽입/삭제/교체 연산 횟수 최소화
- Diff 알고리즘: Git에서 변경 내역 추적
- DNA 서열 정렬: 생물정보학
패턴 5: 구간 DP (Interval DP)
문제 정의
구간 에 대한 최적값을 구하는 패턴입니다. 대표 문제로 행렬 곱셈 순서 최적화가 있습니다.
예제: 행렬 연쇄 곱셈 (Matrix Chain Multiplication)
개의 행렬 을 곱할 때, 곱셈 순서에 따라 연산 횟수가 달라집니다.
예제: 행렬 크기가 [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번 ✅ 최소
점화식
= 행렬 부터 까지 곱하는 최소 연산 횟수
<br />
dp[i][j] = \min_{i \leq k < j} \left( dp[i][k] + dp[k+1][j] + p_{i-1} \times p_k \times p_j \right)<br />
여기서 는 행렬 크기 배열 (행렬 는 크기)
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 |
복잡도 분석
- 시간 복잡도: – 3중 반복문
- 공간 복잡도: – 2차원 DP 테이블
구간 DP 특징
구간 DP는 작은 구간부터 큰 구간으로 확장하며, 모든 분할 지점을 시도합니다.
패턴 6: 비트마스킹 DP
문제 정의
집합의 상태를 비트로 표현하여 DP를 수행하는 기법입니다. 주로 정도일 때 사용됩니다.
대표 문제: 외판원 순회 (TSP, Traveling Salesman Problem)
개 도시를 모두 한 번씩 방문하고 시작 도시로 돌아오는 최소 비용 경로를 찾는 문제입니다.
점화식
= 방문한 도시 집합이 이고 현재 도시가 일 때 남은 경로의 최소 비용
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(방문 안 함) 또는 (방문함)이므로,if mask & (1 << i):처럼 직접 조건문에 사용할 수 있습니다.
복잡도 분석
- 시간 복잡도: – 모든 상태()에서 개 도시 시도
- 공간 복잡도:
- 실용 범위: (그 이상은 메모리/시간 초과)
비트마스킹 DP 응용
- 부분집합 합 문제: 특정 합을 만들 수 있는 부분집합 찾기
- 최소 정점 커버: 모든 간선을 커버하는 최소 정점 집합
- Hamilton Path: 모든 정점을 한 번씩 방문하는 경로
- 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. 시간 복잡도 계산: 이면 , 이면 정도
자주 하는 실수와 디버깅 팁
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. 메모리 초과
증상: 일 때 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. 시간 복잡도 착각
| 복잡도 | 범위 | 1초 기준 |
|---|---|---|
| 가능 | ||
| 가능 | ||
| 가능 | ||
| 가능 | ||
| 가능 | ||
| 가능 |
실전 문제 풀이 체크리스트
1단계: 문제 분석
- [ ] 최적 부분 구조가 있는가? (작은 문제의 답으로 큰 문제 해결 가능)
- [ ] 중복 부분 문제가 있는가? (같은 계산이 반복됨)
- [ ] 제약 조건 확인 ( 범위로 시간 복잡도 추정)
2단계: DP 설계
- [ ] 상태 정의: “dp[i]가 무엇을 의미하는가?” 명확히
- [ ] 점화식 도출: 현재 상태를 이전 상태로 표현
- [ ] 초기 조건: 기저 사례 설정
- [ ] 계산 순서: 의존 관계 파악 (작은 문제 → 큰 문제)
3단계: 구현
- [ ] Top-down vs Bottom-up 선택
- [ ] 배열 크기 올바르게 초기화
- [ ] 인덱스 오류 주의 (특히 문자열/배열 인덱스)
- [ ] 순회 방향 확인 (0/1 Knapsack은 역순)
4단계: 최적화
- [ ] 공간 복잡도 개선 가능한가? (슬라이딩 윈도우)
- [ ] 불필요한 상태 제거 (딕셔너리 사용)
- [ ] 시간 복잡도가 제한 내인가?
5단계: 검증
- [ ] 작은 예제로 손으로 계산
- [ ] 엣지 케이스 테스트 (n=0, n=1, 모든 값이 같을 때)
- [ ] 경계 조건 확인 (배열 범위 초과 없는지)
마무리
다이나믹 프로그래밍은 최적 부분 구조와 중복 부분 문제라는 두 가지 핵심 특성을 가진 문제에 적용하는 강력한 기법입니다. 이 글에서 다룬 주요 패턴을 정리하면:
핵심 패턴 요약
| 패턴 | 대표 문제 | 시간 복잡도 | 핵심 아이디어 |
|---|---|---|---|
| Knapsack | 배낭 문제 | 선택/비선택 결정 | |
| LIS | 최장 증가 부분 수열 | 또는 | 이전 원소와 비교 |
| LCS | 최장 공통 부분 수열 | 두 문자열 비교 | |
| 구간 DP | 행렬 곱셈 순서 | 작은 구간 → 큰 구간 | |
| 비트마스킹 | TSP | 집합 상태를 비트로 표현 |
학습 로드맵
- 기초: 피보나치, 계단 오르기 → 1차원 DP 감 잡기
- 중급: Knapsack, LCS → 2차원 DP와 점화식 설계
- 고급: LIS 이분 탐색, 구간 DP → 최적화 기법
- 심화: 비트마스킹, 트리 DP → 복잡한 상태 관리
마지막 조언
DP 문제는 처음에는 어렵게 느껴지지만, 패턴을 익히고 많은 문제를 풀어보면 자연스럽게 점화식이 보이기 시작합니다. 포기하지 말고 작은 예제부터 손으로 그려가며 이해하세요!
연습 추천:
– 백준: DP 단계별 문제
– LeetCode: Dynamic Programming
– 프로그래머스: DP 연습 문제
코딩 테스트에서 DP는 고득점의 지름길입니다. 이 글이 여러분의 DP 실력 향상에 도움이 되길 바랍니다!
Did you find this helpful?
☕ Buy me a coffee
Leave a Reply