중복되는 하위 문제(Overlapping Subproblems)#
동적 계획법(Dynamic Programming)은 복잡한 문제를 더 작고 간단한 하위 문제로 나누어 해결하는 알고리즘 패러다임이다.
동적 계획법이 효과적으로 적용되기 위해서는 두 가지 핵심 특성이 필요하다:
- 최적 부분 구조(Optimal Substructure)
- 중복되는 하위 문제(Overlapping Subproblems)
이다.
중복되는 하위 문제(Overlapping Subproblems)의 기본 개념#
중복되는 하위 문제는 동일한 하위 문제가 알고리즘 실행 과정에서 여러 번 반복해서 나타나는 특성을 말한다.
즉, 문제를 해결하는 과정에서 같은 계산이 여러 번 수행되는 경우이다.
중복되는 하위 문제가 있을 때 동적 계획법은 각 하위 문제의 결과를 저장(메모이제이션)하여 중복 계산을 피함으로써 효율성을 크게 향상시킨다.
중복되는 하위 문제의 핵심 특성#
- 반복 계산: 동일한 입력 매개변수에 대해 같은 계산이 여러 번 수행된다.
- 결과 재사용 가능성: 한 번 계산된 하위 문제의 결과를 저장하고 재사용할 수 있다.
- 하위 문제 개수의 다항성: 문제 크기에 대해 하위 문제의 총 개수가 다항식적(polynomial)이다.
- 하위 문제 간 의존성: 여러 상위 문제가 동일한 하위 문제에 의존한다.
재귀적 알고리즘에서의 중복#
중복되는 하위 문제는 재귀적 알고리즘에서 특히 두드러진다.
예를 들어, 피보나치 수열의 단순 재귀 구현을 보면:
1
2
3
4
| def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
|
이 코드에서 fibonacci(n-2)
는 fibonacci(n-1)
계산 과정에서도 호출되므로, 동일한 하위 문제가 중복해서 계산된다.
예를 들어, fibonacci(5)
를 계산할 때:
fibonacci(5)
→ fibonacci(4)
+ fibonacci(3)
fibonacci(4)
→ fibonacci(3)
+ fibonacci(2)
fibonacci(3)
→ fibonacci(2)
+ fibonacci(1)
여기서 fibonacci(3)
과 fibonacci(2)
가 각각 여러 번 계산되는 것을 볼 수 있다.
중복되는 하위 문제 식별 방법#
중복되는 하위 문제를 식별하는 방법은 다음과 같다:
- 재귀 트리 분석: 재귀 호출 트리를 그려보고 동일한 함수 호출이 반복되는지 확인한다.
- 하위 문제 정의 분석: 하위 문제가 소수의 매개변수로 완전히 정의되고, 이 매개변수가 제한된 범위를 가지는지 확인한다.
- 중복 횟수 측정: 간단한 테스트 케이스에서 각 하위 문제가 몇 번 호출되는지 측정한다.
중복되는 하위 문제 해결 방법#
메모이제이션(Memoization) - 하향식(Top-Down) 접근#
메모이제이션은 하위 문제의 결과를 저장하여 중복 계산을 피하는 기법이다.
재귀적 접근에 저장소(주로 배열이나 해시맵)를 추가하여 구현한다.
1
2
3
4
5
6
7
8
9
10
11
12
| def fibonacci_memo(n, memo={}):
# 이미 계산된 결과가 있으면 바로 반환
if n in memo:
return memo[n]
# 기본 사례
if n <= 1:
return n
# 결과 계산 및 저장
memo[n] = fibonacci_memo(n-1, memo) + fibonacci_memo(n-2, memo)
return memo[n]
|
이 방식은 필요한 하위 문제만 계산하고 결과를 저장하여 재사용한다.
테이블링(Tabulation) - 상향식(Bottom-Up) 접근#
테이블링은 작은 하위 문제부터 시작하여 더 큰 문제를 해결하는 방식이다.
결과를 테이블(주로 배열)에 저장하며, 재귀 호출 없이 반복문으로 구현한다.
1
2
3
4
5
6
7
8
9
10
| def fibonacci_tab(n):
# DP 테이블 초기화
dp = [0] * (n + 1)
dp[0], dp[1] = 0, 1
# 작은 문제부터 큰 문제까지 해결
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
|
이 방식은 모든 하위 문제를 순차적으로 해결하며, 메모리 접근 패턴이 더 효율적일 수 있다.
두 접근 방식의 비교#
특성 | 메모이제이션(하향식) | 테이블링(상향식) |
---|
구현 방식 | 재귀 + 메모 저장소 | 반복문 + 테이블 |
계산 순서 | 필요한 하위 문제만 계산 | 모든 하위 문제를 순차적으로 계산 |
재귀 호출 | 있음 (스택 오버플로우 가능성) | 없음 |
공간 효율성 | 필요한 하위 문제만 저장 가능 | 일반적으로 모든 하위 문제 저장 |
호출 오버헤드 | 재귀 호출 오버헤드 존재 | 오버헤드 적음 |
코드 가독성 | 일반적으로 더 직관적 | 때로 더 복잡할 수 있음 |
최적화 기회 | 필요 없는 하위 문제 계산 회피 | 메모리 최적화 용이 |
다양한 중복되는 하위 문제 유형#
선형 의존성(Linear Dependency)#
하위 문제가 일정한 간격으로 이전 하위 문제들에 의존하는 경우.
예시: 피보나치 수열, 계단 오르기 문제
1
2
3
4
5
6
7
8
9
10
11
12
| # 계단 오르기 문제: 한 번에 1단 또는 2단씩 오를 수 있을 때, n개의 계단을 오르는 방법의 수
def climb_stairs(n):
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1], dp[2] = 1, 2
for i in range(3, n + 1):
dp[i] = dp[i-1] + dp[i-2] # 선형 의존성
return dp[n]
|
구간 의존성(Range Dependency)#
하위 문제가 특정 구간 내의 다른 하위 문제들에 의존하는 경우.
예시: 행렬 곱셈 연쇄, 최적 이진 검색 트리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # 행렬 곱셈 연쇄 문제
def matrix_chain_multiplication(dims):
n = len(dims) - 1
# dp[i][j] = i부터 j까지의 행렬을 곱하는 최소 연산 횟수
dp = [[0 for _ in range(n)] for _ in range(n)]
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = float('inf')
# 구간 내 모든 분할점 k에 대해 최소값 찾기 (구간 의존성)
for k in range(i, j):
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + dims[i] * dims[k+1] * dims[j+1])
return dp[0][n-1]
|
상태 전이 의존성(State Transition Dependency)#
하위 문제가 여러 이전 상태에서의 전이에 의존하는 경우.
예시: 배낭 문제, 동전 교환 문제
1
2
3
4
5
6
7
8
9
10
11
| # 동전 교환 문제: 주어진 금액을 만들기 위한 최소 동전 개수
def coin_change(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
# 현재 금액에서 동전 값을 뺀 상태에서 전이 (상태 전이 의존성)
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
|
다차원 의존성(Multi-dimensional Dependency)#
하위 문제가 여러 차원의 상태에 의존하는 경우.
예시: 최장 공통 부분 수열, 편집 거리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # 편집 거리 문제
def edit_distance(word1, word2):
m, n = len(word1), len(word2)
# dp[i][j] = word1[:i]와 word2[:j] 간의 편집 거리
dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
# 기본 케이스: 빈 문자열로 변환
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1] # 문자가 같으면 대각선 값 사용
else:
# 삽입, 삭제, 대체 중 최소값 (다차원 의존성)
dp[i][j] = 1 + min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1])
return dp[m][n]
|
중복되는 하위 문제의 일반적인 예시와 분석#
피보나치 수열(Fibonacci Sequence)#
피보나치 수열은 중복되는 하위 문제의 가장 대표적인 예.
분석:
- F(n) = F(n-1) + F(n-2)의 형태로 하위 문제에 의존.
- F(n)을 계산하기 위해 F(n-2)가 F(n-1)과 F(n-2) 양쪽에서 필요.
- 단순 재귀 구현의 시간 복잡도는 O(2^n)이지만, DP를 적용하면 O(n)으로 개선.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # 하위 문제 중복 횟수 분석
def count_fibonacci_calls(n):
count = [0] * (n + 1)
def fibonacci(n):
count[n] += 1
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
fibonacci(n)
return count
# n=5일 때 호출 횟수: F(0):3번, F(1):5번, F(2):3번, F(3):2번, F(4):1번, F(5):1번
|
최장 증가 부분 수열(Longest Increasing Subsequence)#
배열에서 값이 증가하는 가장 긴 부분 수열을 찾는 문제이다.
분석:
- LIS(i)는 인덱스 i까지의 LIS 길이.
- LIS(i) =
max(LIS(j) + 1)
(j < i 이고 A[j] < A[i]
)의 형태로 하위 문제에 의존. - 각 위치 i에 대해 이전의 모든 위치 j에서의 LIS 값을 참조.
1
2
3
4
5
6
7
8
9
10
11
12
13
| def longest_increasing_subsequence(nums):
if not nums:
return 0
n = len(nums)
dp = [1] * n # dp[i] = 인덱스 i까지의 LIS 길이
for i in range(1, n):
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
|
배낭 문제(Knapsack Problem)#
제한된 무게 내에서 최대 가치를 가지는 물건들을 선택하는 문제.
분석:
DP[i][w]
는 처음 i개 물건으로 무게 w를 채울 때의 최대 가치이다.DP[i][w] = max(DP[i-1][w], DP[i-1][w-wᵢ] + vᵢ)
의 형태로 하위 문제에 의존한다.- 동일한 (i, w) 조합이 여러 경로에서 참조된다.
1
2
3
4
5
6
7
8
9
10
11
12
| def knapsack_01(weights, values, capacity):
n = len(weights)
dp = [[0 for _ in range(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]
|
중복되는 하위 문제의 복잡도 분석#
시간 복잡도 개선#
중복되는 하위 문제에 메모이제이션이나 테이블링을 적용하면 시간 복잡도를 크게 개선할 수 있다.
문제 | 단순 재귀 | 동적 계획법 | 개선 비율 |
---|
피보나치 수열 | O(2^n) | O(n) | 지수적 개선 |
이항 계수 | O(2^n) | O(n²) | 지수적 개선 |
최장 증가 부분 수열 | O(2^n) | O(n²) | 지수적 개선 |
배낭 문제 | O(2^n) | O(n·W) | 지수적 개선 |
편집 거리 | O(3^(m+n)) | O(m·n) | 지수적 개선 |
중복 하위 문제의 수 분석#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| def analyze_subproblem_calls(dp_function, n):
# 하위 문제 호출 추적을 위한 래퍼 함수
call_count = {}
def wrapper(*args):
# 튜플로 변환하여 해시 가능하게 만듦
args_tuple = tuple(args)
if args_tuple in call_count:
call_count[args_tuple] += 1
else:
call_count[args_tuple] = 1
return dp_function(*args)
# 함수 실행
wrapper(n)
# 분석 결과
total_calls = sum(call_count.values())
unique_subproblems = len(call_count)
max_repetition = max(call_count.values())
return {
"total_calls": total_calls,
"unique_subproblems": unique_subproblems,
"max_repetition": max_repetition,
"detailed_counts": call_count
}
|
공간 복잡도 분석#
중복되는 하위 문제 해결을 위한 저장 공간의 크기는 하위 문제의 수에 비례합니다.
문제 | 메모이제이션 공간 | 테이블링 공간 | 최적화된 공간 |
---|
피보나치 수열 | O(n) | O(n) | O(1) |
최장 증가 부분 수열 | O(n) | O(n) | O(n) |
배낭 문제 | O(n·W) | O(n·W) | O(W) |
편집 거리 | O(m·n) | O(m·n) | O(min(m,n)) |
행렬 곱셈 연쇄 | O(n²) | O(n²) | O(n²) |
하위 문제 간 의존성 그래프#
하위 문제 간의 의존성을 방향 그래프로 표현할 수 있다.
노드는 하위 문제, 엣지는 의존성을 나타낸다.
1
2
3
4
5
6
7
8
9
10
11
| def build_dependency_graph(problem_size):
# 예: 피보나치 수열의 의존성 그래프
graph = {}
for i in range(problem_size + 1):
if i <= 1:
graph[i] = [] # 기본 사례는 의존성 없음
else:
graph[i] = [i-1, i-2] # F(i)는 F(i-1)과 F(i-2)에 의존
return graph
|
중복되는 하위 문제가 없는 알고리즘#
모든 문제가 중복되는 하위 문제를 가지는 것은 아니다.
다음은 중복되는 하위 문제가 없는 대표적인 알고리즘들.
분할 정복(Divide and Conquer) 알고리즘#
분할 정복 알고리즘은 문제를 더 작은 하위 문제로 나누지만, 이들 하위 문제가 중복되지 않는 경우가 많다.
병합 정렬(Merge Sort) 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # 독립적인 하위 문제
right = merge_sort(arr[mid:]) # 독립적인 하위 문제
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
|
병합 정렬에서는 배열의 서로 다른 부분을 정렬하므로, 동일한 하위 문제가 중복해서 해결되지 않는다.
다익스트라 알고리즘(Dijkstra’s Algorithm)#
다익스트라 알고리즘은 그래프에서 최단 경로를 찾는 알고리즘으로, 각 노드를 한 번씩만 방문한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import heapq
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
priority_queue = [(0, start)]
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
# 이미 처리된 노드는 건너뛰기
if current_distance > distances[current_node]:
continue
for neighbor, weight in graph[current_node].items():
distance = current_distance + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
heapq.heappush(priority_queue, (distance, neighbor))
return distances
|
다익스트라 알고리즘에서는 각 노드의 최단 거리를 한 번만 계산하므로 중복되는 하위 문제가 없다.
퀵 정렬(Quick Sort)#
퀵 정렬은 피벗을 기준으로 배열을 나누어 정렬하는 알고리즘으로, 하위 문제가 중복되지 않는다.
1
2
3
4
5
6
7
8
9
10
| def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)
|
퀵 정렬에서는 각 하위 배열이 독립적으로 정렬되므로 중복되는 하위 문제가 없다.
중복되는 하위 문제의 식별 및 최적화 방법#
중복되는 하위 문제 식별 체크리스트#
- 재귀 호출 패턴 분석: 동일한 매개변수로 함수가 반복 호출되는지 확인한다.
- 상태 공간 크기 검토: 가능한 하위 문제 상태의 수가 입력 크기에 다항식적인지 확인한다.
- 의존성 그래프 구성: 하위 문제 간의 의존성을 그래프로 표현하여 중복 여부를 확인한다.
- 계산 추적: 간단한 예제에서 각 하위 문제가 몇 번 계산되는지 추적한다.
메모이제이션 최적화 기법#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| def memoization_optimization(func):
"""함수에 메모이제이션을 적용하는 데코레이터"""
memo = {}
def wrapper(*args):
# 인수를 해시 가능한 형태로 변환
args_key = tuple(map(lambda x: tuple(x) if isinstance(x, list) else x, args))
if args_key not in memo:
memo[args_key] = func(*args)
return memo[args_key]
return wrapper
@memoization_optimization
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
|
테이블링 최적화 기법#
1
2
3
4
5
6
7
8
9
10
11
12
13
| def tabulation_optimization(fibonacci, n):
"""상향식 테이블링으로 피보나치 수열 최적화"""
# 기본 사례
if n <= 1:
return n
# 2개의 변수만 사용하여 공간 복잡도 최적화
a, b = 0, 1
for i in range(2, n + 1):
a, b = b, a + b
return b
|
이 방식은 전체 DP 테이블을 유지하는 대신 필수적인 값만 저장하여 공간 복잡도를 O(n)에서 O(1)로 개선한다.
상태 공간 축소 기법#
상태 공간 축소는 문제의 상태 표현을 최소화하여 중복되는 하위 문제의 수를 줄이는 기법.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| # 원래 배낭 문제 구현 (2차원 배열)
def knapsack_original(weights, values, capacity):
n = len(weights)
dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
if weights[i-1] <= w:
dp[i][w] = max(dp[i-1][w], values[i-1] + dp[i-1][w-weights[i-1]])
else:
dp[i][w] = dp[i-1][w]
return dp[n][capacity]
# 상태 공간 축소 버전 (1차원 배열)
def knapsack_optimized(weights, values, capacity):
n = len(weights)
dp = [0 for _ in range(capacity + 1)]
for i in range(n):
# 역순으로 순회하여 이전 상태에 영향을 주지 않도록 함
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], values[i] + dp[w - weights[i]])
return dp[capacity]
|
이 최적화는 공간 복잡도를 O(n·W)에서 O(W)로 줄인다.
실제 응용 사례에서의 중복되는 하위 문제#
문자열 처리 알고리즘#
문자열 처리에서 중복되는 하위 문제를 활용한 알고리즘의 예시.
예시: 최장 회문 부분 문자열(Longest Palindromic Substring)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| def longest_palindromic_substring(s):
n = len(s)
# dp[i][j] = s[i:j+1]이 회문인지 여부
dp = [[False for _ in range(n)] for _ in range(n)]
# 모든 길이 1인 부분 문자열은 회문
for i in range(n):
dp[i][i] = True
start = 0
max_length = 1
# 길이 2인 부분 문자열 확인
for i in range(n - 1):
if s[i] == s[i + 1]:
dp[i][i + 1] = True
start = i
max_length = 2
# 길이 3 이상인 부분 문자열 확인
for length in range(3, n + 1):
for i in range(n - length + 1):
j = i + length - 1
# s[i+1:j]가 회문이고 s[i]와 s[j]가 같은지 확인 (중복되는 하위 문제 활용)
if dp[i + 1][j - 1] and s[i] == s[j]:
dp[i][j] = True
if length > max_length:
start = i
max_length = length
return s[start:start + max_length]
|
이 알고리즘은 더 작은 부분 문자열이 회문인지에 대한 정보를 재사용하여 효율성을 높인다.
그래프 알고리즘#
그래프 알고리즘에서 중복되는 하위 문제를 활용한 예시.
예시: 모든 쌍 최단 경로(All-Pairs Shortest Path) - 플로이드-워셜 알고리즘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| def floyd_warshall(graph):
n = len(graph)
dist = [row[:] for row in graph] # 그래프 복사
# k = 중간 정점
for k in range(n):
# i = 시작 정점
for i in range(n):
# j = 종료 정점
for j in range(n):
# i→k→j 경로가 i→j 직접 경로보다 짧은지 확인 (중복되는 하위 문제 활용)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
return dist
|
이 알고리즘은 노드 k를 통과하는 모든 경로를 계산할 때, 이전에 계산한 k-1까지의 중간 노드를 사용한 최단 경로 정보를 재사용한다.
최적화 문제#
최적화 문제에서 중복되는 하위 문제를 활용한 예시.
예시: 로드 밸런싱(Load Balancing) 문제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| def min_completion_time(jobs, k):
"""
k개의 프로세서에 작업을 할당하여 마지막 작업이 완료되는 시간을 최소화
"""
n = len(jobs)
# dp[mask][i] = 비트마스크 mask로 표현된 작업들을 i개의 프로세서에 할당했을 때의 최소 완료 시간
dp = {}
def solve(mask, processors):
# 모든 작업이 할당됨
if mask == (1 << n) - 1:
return 0
# 이미 계산된 상태
if (mask, processors) in dp:
return dp[(mask, processors)]
# 가능한 모든 미할당 작업 조합 시도
result = float('inf')
# 새 프로세서 시작
if processors > 0:
for i in range(n):
if not (mask & (1 << i)): # 작업 i가 아직 할당되지 않음
new_mask = mask | (1 << i)
# 작업 i를 새 프로세서에 할당 (중복되는 하위 문제 활용)
result = min(result, max(jobs[i], solve(new_mask, processors - 1)))
# 기존 프로세서에 추가
for i in range(n):
if not (mask & (1 << i)): # 작업 i가 아직 할당되지 않음
new_mask = mask | (1 << i)
# 작업 i를 기존 프로세서에 추가 (중복되는 하위 문제 활용)
result = min(result, jobs[i] + solve(new_mask, processors))
dp[(mask, processors)] = result
return result
return solve(0, k)
|
이 알고리즘은 동일한 작업 집합과 프로세서 수에 대한 계산을 재사용하여 효율성을 높인다.
중복되는 하위 문제 비교 분석표#
아래 표는 다양한 알고리즘 문제에서 중복되는 하위 문제의 특성을 비교 분석:
문제 | 중복되는 하위 문제 수 | 하위 문제 의존성 유형 | 메모리 요구량 | 메모이제이션 효과 | 중복 계산 비율 | 재사용 패턴 |
---|
피보나치 수열 | O(n) | 선형 | O(n) | 매우 높음 | >99% | F(n-2)가 F(n-1), F(n) 계산에 재사용 |
최장 증가 부분 수열 | O(n) | 분기 | O(n) | 높음 | ~50% | LIS(j)가 여러 LIS(i)에 재사용 (j < i) |
0-1 배낭 문제 | O(n·W) | 상태 전이 | O(n·W) | 높음 | ~70% | DP[i-1][w], DP[i-1][w-wᵢ]가 재사용 |
편집 거리 | O(m·n) | 다차원 | O(m·n) | 높음 | ~75% | ED[i-1][j], ED[i][j-1], ED[i-1][j-1]이 재사용 |
행렬 곱셈 연쇄 | O(n²) | 구간 | O(n²) | 중간 | ~60% | DP[i][k], DP[k+1][j]가 여러 구간에 재사용 |
최장 공통 부분 수열 | O(m·n) | 다차원 | O(m·n) | 높음 | ~70% | LCS[i-1][j], LCS[i][j-1], LCS[i-1][j-1]이 재사용 |
동전 교환 문제 | O(n·amount) | 상태 전이 | O(amount) | 높음 | ~80% | DP[i-coin]이 여러 금액에 재사용 |
최적 이진 검색 트리 | O(n²) | 구간 | O(n²) | 중간 | ~65% | DP[i][k-1], DP[k+1][j]가 여러 구간에 재사용 |
팰린드롬 파티셔닝 | O(n²) | 구간 | O(n²) | 높음 | ~75% | palindrome[i+1][j-1], cut[i][k]가 재사용 |
외판원 문제(TSP) | O(n·2^n) | 상태/집합 | O(n·2^n) | 매우 높음 | >95% | DP[S-{j}][i]가 여러 상태에 재사용 |
중복되는 하위 문제의 실무 구현 가이드#
하향식(Top-down) 메모이제이션 구현 패턴#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| def problem_solver_memoization(input_data):
# 메모이제이션 캐시 초기화
memo = {}
def solve_recursive(state):
# 상태가 이미 계산되었는지 확인
if state in memo:
return memo[state]
# 기본 사례 체크
if is_base_case(state):
return base_case_value(state)
# 가능한 모든 선택지에 대해 최적값 계산
result = initial_value # 문제에 따라 초기값 설정 (최소값/최대값)
for choice in possible_choices(state):
next_state = apply_choice(state, choice)
subproblem_result = solve_recursive(next_state)
result = combine_results(result, subproblem_result, choice)
# 결과 저장 및 반환
memo[state] = result
return result
# 초기 상태에서 시작
return solve_recursive(initial_state(input_data))
|
상향식(Bottom-up) 테이블링 구현 패턴#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def problem_solver_tabulation(input_data):
# 문제 크기 및 필요한 매개변수 추출
n = extract_problem_size(input_data)
# DP 테이블 초기화
dp = initialize_dp_table(n)
# 기본 사례 설정
set_base_cases(dp)
# 상향식으로 테이블 채우기
for i in range(smallest_non_base_case, n + 1):
for j in range(additional_dimension_if_needed):
dp[i][j] = calculate_value(dp, i, j, input_data)
# 최종 결과 반환
return extract_result(dp, n)
|
메모리 최적화 기법#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| def memory_optimized_dp(input_data):
n = extract_problem_size(input_data)
# 1. 차원 축소: 2차원 → 1차원
# 예: 배낭 문제에서 행(물건)을 기준으로 반복하면서 한 행만 유지
dp = initialize_reduced_dp_table()
for i in range(1, n + 1):
# 2. 순회 방향 최적화: 역순 순회로 이전 값 보존
for j in range(max_value, min_value - 1, -1):
dp[j] = calculate_with_previous_values(dp, j, input_data[i-1])
# 3. 슬라이딩 윈도우: 필요한 이전 상태만 유지
# 예: 피보나치 수열에서 마지막 두 값만 유지
a, b = initial_values
for i in range(2, n + 1):
a, b = b, combine(a, b)
return b
|
효율적인 구현을 위한 자료구조 선택#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| def choose_appropriate_data_structure(problem_type, input_size):
if problem_type == "linear_dependency":
# 배열이나 리스트로 충분
return [0] * (input_size + 1)
elif problem_type == "two_dimensional":
# 2D 배열 또는 딕셔너리
if input_size <= 1000:
return [[0 for _ in range(input_size + 1)] for _ in range(input_size + 1)]
else:
# 희소 행렬의 경우 딕셔너리가 더 효율적
return {}
elif problem_type == "state_based":
# 복잡한 상태는 해시 맵이 적합
return {}
elif problem_type == "bit_manipulation":
# 비트마스크 사용
return [0] * (1 << input_size)
|
참고 및 출처#