동적 계획법(Dynamic Programming)
기술노트
동적 계획법(Dynamic Programming)
동적 계획법(DP)은 복잡한 문제를 간단한 하위 문제로 분할하여 해결하는 알고리즘 설계 기법입니다. 이 방법은 중복되는 하위 문제의 결과를 저장하고 재사용하여 계산 효율성을 크게 향상시킵니다.
동적 계획법의 핵심 원리
1. 최적 부분 구조(Optimal Substructure)
- 큰 문제의 최적해가 작은 문제의 최적해로 구성되는 성질입니다.
- 즉, 문제의 최적 솔루션에는 하위 문제의 최적 솔루션이 포함되어 있습니다.
2. 중복되는 부분 문제(Overlapping Subproblems)
- 같은 작은 문제가 반복해서 발생하는 특성입니다.
- 이 결과를 저장하여 재계산을 피하는 것이 DP의 핵심입니다.
동적 계획법 구현 방식
1. 하향식 접근법(Top-Down): 메모이제이션(Memoization)
- 재귀 호출을 사용하여 문제를 더 작은 하위 문제로 분할합니다.
- 계산된 결과를 메모리(일반적으로 배열이나 해시 테이블)에 저장합니다.
- 이미 계산된 하위 문제의 값이 필요하면 저장된 값을 재사용합니다.
# 피보나치 수열의 하향식 접근법 (메모이제이션)
def fibonacci_top_down(n, memo={}):
if n in memo:
return memo[n] # 이미 계산된 값을 사용
if n <= 2:
return 1
# 결과 계산 및 저장
memo[n] = fibonacci_top_down(n-1, memo) + fibonacci_top_down(n-2, memo)
return memo[n]
2. 상향식 접근법(Bottom-Up): 타뷸레이션(Tabulation)
- 작은 하위 문제부터 시작하여 큰 문제를 해결해 나가는 방식입니다.
- 결과를 테이블(일반적으로 배열)에 저장하며 진행합니다.
- 반복문을 사용하여 구현하므로 스택 오버플로우 위험이 없습니다.
# 피보나치 수열의 상향식 접근법 (타뷸레이션)
def fibonacci_bottom_up(n):
if n <= 2:
return 1
# DP 테이블 초기화
dp = [0] * (n + 1)
dp[1] = dp[2] = 1
# 작은 문제부터 해결
for i in range(3, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
동적 계획법 문제 해결 단계
- 문제가 DP로 해결 가능한지 확인: 최적 부분 구조와 중복되는 부분 문제 특성을 가지는지 분석
- 상태(State) 정의: 문제의 변수를 명확히 정의 (예: dp[i]는 i번째 요소까지의 최적값)
- 점화식(Recurrence Relation) 수립: 상태 간의 관계 정의 (예: dp[i] = max(dp[i-1] + arr[i], dp[i-2] + arr[i]))
- 기저 사례(Base Case) 설정: 가장 작은 하위 문제의 해답 정의 (예: dp[0] = 0, dp[1] = arr[0])
- 계산 순서 결정: 하향식 또는 상향식 접근법 선택
- 결과 계산: 정의된 방식으로 문제 해결
대표적인 동적 계획법 문제
피보나치 수열(Fibonacci Sequence)
- 문제: n번째 피보나치 수를 계산
- 점화식: F(n) = F(n-1) + F(n-2), F(1) = F(2) = 1
- 시간 복잡도: O(n)
- 공간 복잡도: O(n)
최장 공통 부분 수열(Longest Common Subsequence, LCS)
- 문제: 두 문자열의 가장 긴 공통 부분 수열 찾기
- 접근법: dp[i][j]는 문자열 X의 i번째 문자까지와 Y의 j번째 문자까지의 LCS 길이
def longest_common_subsequence(X, Y):
m, n = len(X), len(Y)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i-1] == Y[j-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]
배낭 문제(Knapsack Problem)
- 문제: 주어진 무게 제한 내에서 최대 가치를 갖는 물건들의 조합 찾기
- 접근법: dp[i][w]는 i번째 물건까지 고려했을 때, 무게 w의 배낭에 담을 수 있는 최대 가치
def knapsack(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(1, capacity + 1):
if weights[i-1] <= w:
dp[i][w] = max(values[i-1] + dp[i-1][w-weights[i-1]], dp[i-1][w])
else:
dp[i][w] = dp[i-1][w]
return dp[n][capacity]
최단 경로 문제(Shortest Path)
- 문제: 그래프에서 두 노드 간의 최단 경로 찾기
- 알고리즘: 다익스트라, 벨만-포드, 플로이드-워셜 알고리즘
- 플로이드-워셜 예시:
def floyd_warshall(graph):
n = len(graph)
dist = [row[:] for row in graph] # 그래프 복사
for k in range(n):
for i in range(n):
for j in range(n):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
return dist
최대 부분 배열 합(Maximum Subarray Sum)
- 문제: 연속된 부분 배열의 최대 합 찾기
- 접근법: dp[i]는 i번째 요소까지의 최대 부분 배열 합
def max_subarray_sum(arr):
n = len(arr)
dp = [0] * n
dp[0] = arr[0]
max_sum = dp[0]
for i in range(1, n):
dp[i] = max(arr[i], dp[i-1] + arr[i])
max_sum = max(max_sum, dp[i])
return max_sum
동적 계획법의 최적화 기법
1. 공간 복잡도 최적화
- 많은 DP 문제에서 전체 dp 배열이 아닌 마지막 몇 개의 상태만 필요한 경우가 있습니다.
- 이러한 경우 공간 복잡도를 O(n)에서 O(1) 또는 O(k)로 줄일 수 있습니다.
# 공간 최적화된 피보나치 계산
def fibonacci_optimized(n):
if n <= 2:
return 1
a, b = 1, 1
for _ in range(3, n + 1):
a, b = b, a + b
return b
2. 상태 압축(State Compression)
- 큰 상태 공간을 작은 비트마스크로 표현하여 메모리 사용을 최적화합니다.
- 특히 집합이나 부분집합을 다루는 문제에서 유용합니다.
3. 분할 정복 최적화(Divide and Conquer Optimization)
- 특정 조건을 만족하는 DP 문제에서 상태 전이를 더 효율적으로 계산할 수 있습니다.
- O(n²)에서 O(n log n)으로 시간 복잡도를 개선할 수 있습니다.
동적 계획법 vs 다른 알고리즘 기법
1. 동적 계획법 vs 분할 정복(Divide and Conquer)
- 공통점: 문제를 더 작은 하위 문제로 분할
- 차이점:
- 분할 정복은 하위 문제가 중복되지 않음
- DP는 중복되는 하위 문제의 결과를 저장하고 재사용
2. 동적 계획법 vs 그리디 알고리즘(Greedy Algorithm)
- 공통점: 최적화 문제 해결
- 차이점:
- 그리디는 각 단계에서 지역적 최적해를 선택
- DP는 모든 가능한 하위 문제를 계산하여 전역 최적해 보장
동적 계획법의 한계와 도전
- 상태 공간이 너무 큰 경우: 메모리 제한에 걸릴 수 있습니다.
- 점화식 도출이 어려운 복잡한 문제: 문제의 구조를 이해하기 어려울 수 있습니다.
- 최적 부분 구조가 없는 문제: DP로 해결할 수 없습니다.
실전 응용 분야
- 컴퓨터 과학: 문자열 처리, 그래프 알고리즘, 자원 할당
- 경제학: 최적 투자 전략, 자원 분배
- 생물정보학: DNA 서열 정렬, 단백질 구조 예측
- 로봇공학: 경로 계획, 모션 계획
- 게임 이론: 최적 전략 계산