본문 바로가기
알고리즘/이론

[알고리즘 이론] 분할 정복(Divide and Conquer)

by 응애~ 개발자 2025. 5. 14.
728x90
반응형

분할 정복(Divide and Conquer) 알고리즘 완벽 가이드

1. 분할 정복이란?

분할 정복(Divide and Conquer)은 복잡한 문제를 더 작고 관리하기 쉬운 하위 문제들로 나누어 해결하는 알고리즘 패러다임입니다. 이 방법론은 컴퓨터 과학의 여러 분야에서 광범위하게 사용되며, 많은 효율적인 알고리즘의 기초가 됩니다.

분할 정복은 다음 세 가지 주요 단계로 구성됩니다:

  1. 분할(Divide): 원래 문제를 여러 개의 작은 하위 문제로 나눕니다.
  2. 정복(Conquer): 하위 문제들을 재귀적으로 해결합니다. 하위 문제가 충분히 작으면 직접 해결합니다.
  3. 결합(Combine): 하위 문제들의 해결책을 결합하여 원래 문제의 해결책을 만듭니다.

2. 분할 정복의 장점

분할 정복 접근법은 다음과 같은 여러 가지 장점을 제공합니다:

  • 효율성: 많은 경우에 분할 정복은 시간 복잡도를 크게 개선할 수 있습니다. 예를 들어, 정렬 알고리즘의 경우 O(n²)에서 O(n log n)으로 개선할 수 있습니다.
  • 병렬화: 하위 문제들이 독립적으로 해결될 수 있다면, 멀티 코어 또는 분산 시스템에서 병렬 처리가 가능합니다.
  • 메모리 접근 효율성: 일부 분할 정복 알고리즘은 캐시 지역성(cache locality)을 더 잘 활용할 수 있어 실제 실행 시간이 더 빠를 수 있습니다.
  • 문제 단순화: 복잡한 문제를 더 간단한 문제들로 분해함으로써 이해하고 해결하기 쉬워집니다.

3. 대표적인 분할 정복 알고리즘

3.1 병합 정렬(Merge Sort)

병합 정렬은 분할 정복의 대표적인 예입니다. 배열을 두 개의 하위 배열로 분할하고, 각 하위 배열을 재귀적으로 정렬한 다음, 정렬된 하위 배열들을 병합합니다.

def merge_sort(arr):
    # 기본 케이스: 배열의 길이가 1 이하면 이미 정렬된 상태
    if len(arr) <= 1:
        return arr
    
    # 분할(Divide): 배열을 두 부분으로 나눔
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])  # 왼쪽 하위 배열을 재귀적으로 정렬
    right = merge_sort(arr[mid:])  # 오른쪽 하위 배열을 재귀적으로 정렬
    
    # 결합(Combine): 정렬된 하위 배열들을 병합
    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

병합 정렬의 시간 복잡도는 O(n log n)이며, 공간 복잡도는 O(n)입니다.

3.2 퀵 정렬(Quick Sort)

퀵 정렬도 분할 정복을 사용하는 효율적인 정렬 알고리즘입니다. 피벗(pivot)을 선택하고, 배열을 피벗보다 작은 요소들과 큰 요소들로 분할한 다음, 각 부분을 재귀적으로 정렬합니다.

def quick_sort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    
    if low < high:
        # 분할(Divide): 배열을 피벗을 기준으로 두 부분으로 나눔
        pivot_index = partition(arr, low, high)
        
        # 정복(Conquer): 분할된 부분들을 재귀적으로 정렬
        quick_sort(arr, low, pivot_index - 1)  # 피벗 왼쪽 부분 정렬
        quick_sort(arr, pivot_index + 1, high)  # 피벗 오른쪽 부분 정렬
    
    return arr

def partition(arr, low, high):
    pivot = arr[high]  # 마지막 요소를 피벗으로 선택
    i = low - 1  # 피벗보다 작은 요소들의 마지막 인덱스
    
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]  # 요소 교환
    
    arr[i + 1], arr[high] = arr[high], arr[i + 1]  # 피벗을 올바른 위치로 이동
    return i + 1  # 피벗의 최종 위치 반환

퀵 정렬의 평균 시간 복잡도는 O(n log n)이지만, 최악의 경우 O(n²)가 될 수 있습니다. 공간 복잡도는 O(log n)입니다.

3.3 이진 검색(Binary Search)

이진 검색은 정렬된 배열에서 특정 값을 효율적으로 찾는 알고리즘입니다. 중간 요소를 확인하고, 찾는 값이 중간 값보다 작으면 왼쪽 부분에서, 크면 오른쪽 부분에서 검색을 계속합니다.

def binary_search(arr, target, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    
    # 기본 케이스: 범위가 유효하지 않으면 타겟을 찾지 못함
    if low > high:
        return -1
    
    # 분할(Divide): 중간 지점을 계산
    mid = (low + high) // 2
    
    # 정복(Conquer): 중간 값과 타겟을 비교하여 검색 범위를 좁힘
    if arr[mid] == target:
        return mid  # 타겟을 찾음
    elif arr[mid] > target:
        return binary_search(arr, target, low, mid - 1)  # 왼쪽 부분에서 검색
    else:
        return binary_search(arr, target, mid + 1, high)  # 오른쪽 부분에서 검색

이진 검색의 시간 복잡도는 O(log n)입니다.

3.4 최대 부분 배열 합 문제(Maximum Subarray Sum)

분할 정복을 사용하여 배열에서 연속된 부분 배열의 최대 합을 찾는 문제입니다.

def max_subarray_sum(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    
    # 기본 케이스: 배열에 요소가 하나만 있는 경우
    if low == high:
        return arr[low]
    
    # 분할(Divide): 배열을 두 부분으로 나눔
    mid = (low + high) // 2
    
    # 정복(Conquer): 왼쪽, 오른쪽, 그리고 중간을 가로지르는 최대 부분 배열 합을 찾음
    left_sum = max_subarray_sum(arr, low, mid)  # 왼쪽 부분의 최대 합
    right_sum = max_subarray_sum(arr, mid + 1, high)  # 오른쪽 부분의 최대 합
    cross_sum = max_crossing_sum(arr, low, mid, high)  # 중간을 가로지르는 최대 합
    
    # 결합(Combine): 세 경우 중 최대값을 반환
    return max(left_sum, right_sum, cross_sum)

def max_crossing_sum(arr, low, mid, high):
    # 왼쪽 부분에서 mid에서 시작하여 왼쪽으로 확장하는 최대 합
    left_sum = float('-inf')
    curr_sum = 0
    for i in range(mid, low - 1, -1):
        curr_sum += arr[i]
        left_sum = max(left_sum, curr_sum)
    
    # 오른쪽 부분에서 mid+1에서 시작하여 오른쪽으로 확장하는 최대 합
    right_sum = float('-inf')
    curr_sum = 0
    for i in range(mid + 1, high + 1):
        curr_sum += arr[i]
        right_sum = max(right_sum, curr_sum)
    
    # 왼쪽과 오른쪽의 최대 합을 결합
    return left_sum + right_sum

이 알고리즘의 시간 복잡도는 O(n log n)입니다.

3.5 스트라센 행렬 곱셈(Strassen's Matrix Multiplication)

스트라센 알고리즘은 분할 정복을 사용하여 n×n 행렬 곱셈을 O(n^3)보다 빠르게 수행합니다.

import numpy as np

def strassen(A, B):
    n = A.shape[0]
    
    # 기본 케이스: 1x1 행렬인 경우 직접 곱함
    if n == 1:
        return A * B
    
    # 행렬을 4개의 하위 행렬로 분할
    mid = n // 2
    A11 = A[:mid, :mid]
    A12 = A[:mid, mid:]
    A21 = A[mid:, :mid]
    A22 = A[mid:, mid:]
    
    B11 = B[:mid, :mid]
    B12 = B[:mid, mid:]
    B21 = B[mid:, :mid]
    B22 = B[mid:, mid:]
    
    # 7개의 곱셈 계산 (스트라센 최적화)
    M1 = strassen(A11 + A22, B11 + B22)
    M2 = strassen(A21 + A22, B11)
    M3 = strassen(A11, B12 - B22)
    M4 = strassen(A22, B21 - B11)
    M5 = strassen(A11 + A12, B22)
    M6 = strassen(A21 - A11, B11 + B12)
    M7 = strassen(A12 - A22, B21 + B22)
    
    # 결과 행렬의 하위 행렬 계산
    C11 = M1 + M4 - M5 + M7
    C12 = M3 + M5
    C21 = M2 + M4
    C22 = M1 - M2 + M3 + M6
    
    # 결과 행렬 결합
    C = np.zeros((n, n))
    C[:mid, :mid] = C11
    C[:mid, mid:] = C12
    C[mid:, :mid] = C21
    C[mid:, mid:] = C22
    
    return C

스트라센 알고리즘의 시간 복잡도는 O(n^log₂7) ≈ O(n^2.81)입니다.

4. 분할 정복 알고리즘 설계하기

분할 정복 알고리즘을 설계할 때 고려해야 할 주요 사항들은 다음과 같습니다:

4.1 하위 문제 분할 방법 결정

문제를 어떻게 더 작은 하위 문제로 나눌지 결정해야 합니다. 일반적으로:

  • 배열 문제: 중간 지점을 기준으로 두 부분으로 나눔
  • 행렬 문제: 4개의 사분면으로 나눔
  • 그래프 문제: 연결 요소나 정점 집합으로 나눔

4.2 기본 케이스 정의

재귀가 종료되는 조건, 즉 더 이상 분할하지 않고 직접 해결할 수 있을 만큼 문제가 작아진 경우를 정의해야 합니다. 예를 들어:

  • 배열의 길이가 0 또는 1인 경우
  • 트리의 노드가 리프 노드인 경우

4.3 하위 문제 해결 방법 결정

하위 문제들을 어떻게 해결할지 결정해야 합니다. 대부분의 경우 동일한 알고리즘을 재귀적으로 적용하지만, 때로는 다른 알고리즘이 더 효율적일 수 있습니다.

4.4 하위 문제 해결책 결합 방법 결정

하위 문제들의 해결책을 어떻게 결합하여 원래 문제의 해결책을 만들지 결정해야 합니다. 이 단계는 알고리즘마다 크게 다를 수 있으며, 종종 가장 복잡한 부분입니다.

5. 분할 정복의 시간 복잡도 분석

분할 정복 알고리즘의 시간 복잡도를 분석할 때는 일반적으로 마스터 정리(Master Theorem)를 사용합니다. 분할 정복 알고리즘의 시간 복잡도는 다음 재귀 관계로 표현될 수 있습니다:

T(n) = aT(n/b) + f(n)

여기서:

  • a는 생성되는 하위 문제의 수
  • b는 각 하위 문제의 크기가 원래 문제에 비해 줄어드는 비율
  • f(n)은 분할과 결합 단계에서 수행되는 작업의 비용

예를 들어:

  • 병합 정렬: T(n) = 2T(n/2) + O(n), 따라서 O(n log n)
  • 이진 검색: T(n) = T(n/2) + O(1), 따라서 O(log n)
  • 스트라센 행렬 곱셈: T(n) = 7T(n/2) + O(n²), 따라서 O(n^log₂7)

6. 분할 정복 vs 동적 프로그래밍

분할 정복과 동적 프로그래밍은 모두 큰 문제를 작은 하위 문제로 나누어 해결하는 접근법이지만, 중요한 차이점이 있습니다:

  • 하위 문제의 중복: 분할 정복은 중복된 하위 문제들을 독립적으로 해결하는 반면, 동적 프로그래밍은 중복된 하위 문제의 해결책을 저장하고 재사용합니다.
  • 하위 문제의 의존성: 분할 정복에서 하위 문제들은 일반적으로 독립적이지만, 동적 프로그래밍에서는 하위 문제들 사이에 의존성이 있을 수 있습니다.
  • 해결 순서: 분할 정복은 하위 문제를 재귀적으로 해결하지만, 동적 프로그래밍은 작은 하위 문제부터 시작하여 점진적으로 큰 문제를 해결합니다.

7. 분할 정복의 실제 응용 사례

7.1 FFT(Fast Fourier Transform)

FFT는 O(n²)의 나이브한 접근법 대신 O(n log n)의 시간 복잡도로 이산 푸리에 변환을 계산하는 분할 정복 알고리즘입니다. 신호 처리, 데이터 압축, 다항식 곱셈 등에 사용됩니다.

7.2 가장 가까운 점 쌍 찾기

2D 평면에서 가장 가까운 두 점을 찾는 문제도 분할 정복으로 해결할 수 있습니다. 평면을 수직선으로 분할하고, 각 영역에서 재귀적으로 가장 가까운 점 쌍을 찾은 다음, 경계를 걸쳐 있는 점 쌍도 고려합니다.

7.3 병렬 알고리즘

분할 정복은 병렬 컴퓨팅에 적합합니다. 하위 문제들은 독립적으로 해결될 수 있으므로, 다른 프로세서에 할당하여 병렬로 처리할 수 있습니다.

7.4 외부 정렬

외부 정렬은 메모리에 다 들어가지 않는 대용량 데이터를 정렬할 때 사용됩니다. 데이터를 더 작은 청크로 나누어 각각 정렬한 다음, 병합하는 분할 정복 접근법을 사용합니다.

8. 분할 정복 구현 시 주의사항

8.1 재귀 스택 오버플로우

분할 정복 알고리즘은 재귀를 사용하므로, 입력이 매우 크면 스택 오버플로우가 발생할 수 있습니다. 이를 방지하기 위해 꼬리 재귀 최적화나 반복적 구현으로 전환할 수 있습니다.

8.2 하위 문제 크기 균형

하위 문제의 크기가 균형을 이루지 않으면(예: 퀵 정렬에서 피벗 선택이 불균형한 경우) 알고리즘의 성능이 저하될 수 있습니다. 균형 잡힌 분할을 보장하는 전략을 사용해야 합니다.

8.3 병합 단계 최적화

병합 단계가 비효율적이면 전체 알고리즘의 성능이 저하될 수 있습니다. 병합을 위한 추가 메모리 사용을 최소화하거나, 캐시 친화적인 구현을 고려해야 합니다.

9. 결론

분할 정복은 복잡한 문제를 더 작고 관리하기 쉬운 하위 문제로 나누어 해결하는 강력한 알고리즘 패러다임입니다. 병합 정렬, 퀵 정렬, 이진 검색과 같은 많은 효율적인 알고리즘의 기초가 되며, 다양한 실제 문제에 적용될 수 있습니다.

분할 정복 접근법은 올바르게 사용하면 성능을 크게 향상시킬 수 있지만, 하위 문제 분할, 기본 케이스 정의, 하위 문제 해결책 결합과 같은 주요 측면을 신중하게 고려해야 합니다. 또한, 재귀 스택 오버플로우, 하위 문제 크기 불균형, 비효율적인 병합과 같은 잠재적인 문제를 피하기 위한 전략을 구현해야 합니다.

분할 정복은 알고리즘 설계 및 분석의 필수적인 도구이며, 많은 실제 문제를 효율적으로 해결하는 데 활용될 수 있습니다.

728x90
반응형