중복되는 하위 문제(Overlapping Subproblems)

동적 계획법(Dynamic Programming)은 복잡한 문제를 더 작고 간단한 하위 문제로 나누어 해결하는 알고리즘 패러다임이다.
동적 계획법이 효과적으로 적용되기 위해서는 두 가지 핵심 특성이 필요하다:

  • 최적 부분 구조(Optimal Substructure)
  • 중복되는 하위 문제(Overlapping Subproblems)
    이다.

중복되는 하위 문제(Overlapping Subproblems)의 기본 개념

중복되는 하위 문제는 동일한 하위 문제가 알고리즘 실행 과정에서 여러 번 반복해서 나타나는 특성을 말한다.
즉, 문제를 해결하는 과정에서 같은 계산이 여러 번 수행되는 경우이다.
중복되는 하위 문제가 있을 때 동적 계획법은 각 하위 문제의 결과를 저장(메모이제이션)하여 중복 계산을 피함으로써 효율성을 크게 향상시킨다.

중복되는 하위 문제의 핵심 특성

  1. 반복 계산: 동일한 입력 매개변수에 대해 같은 계산이 여러 번 수행된다.
  2. 결과 재사용 가능성: 한 번 계산된 하위 문제의 결과를 저장하고 재사용할 수 있다.
  3. 하위 문제 개수의 다항성: 문제 크기에 대해 하위 문제의 총 개수가 다항식적(polynomial)이다.
  4. 하위 문제 간 의존성: 여러 상위 문제가 동일한 하위 문제에 의존한다.

재귀적 알고리즘에서의 중복

중복되는 하위 문제는 재귀적 알고리즘에서 특히 두드러진다.
예를 들어, 피보나치 수열의 단순 재귀 구현을 보면:

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)가 각각 여러 번 계산되는 것을 볼 수 있다.

중복되는 하위 문제 식별 방법

중복되는 하위 문제를 식별하는 방법은 다음과 같다:

  1. 재귀 트리 분석: 재귀 호출 트리를 그려보고 동일한 함수 호출이 반복되는지 확인한다.
  2. 하위 문제 정의 분석: 하위 문제가 소수의 매개변수로 완전히 정의되고, 이 매개변수가 제한된 범위를 가지는지 확인한다.
  3. 중복 횟수 측정: 간단한 테스트 케이스에서 각 하위 문제가 몇 번 호출되는지 측정한다.

중복되는 하위 문제 해결 방법

메모이제이션(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. 계산 추적: 간단한 예제에서 각 하위 문제가 몇 번 계산되는지 추적한다.

메모이제이션 최적화 기법

 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)

참고 및 출처