Home 분할 정복 알고리즘이란?
Post
Cancel
--> -->

분할 정복 알고리즘이란?

개요

  • 분할 정복은 큰 문제를 작은 부분 문제로 나누어 각각을 해결한 후, 그 결과를 결합하여 원래 문제를 해결하는 알고리즘 설계 패러다임임
  • 복잡한 문제를 관리 가능한 작은 부분으로 분해함으로써 해결 과정을 단순화함
  • 재귀적 접근 방식을 활용하여 문제를 효율적으로 해결함



분할 정복의 기본 개념

분할

  • 원래 문제를 더 이상 분할할 수 없을 때까지 작은 하위 문제들로 나눔
  • 일반적으로 문제를 절반으로 나누는 방식을 많이 사용함
  • 분할된 각 문제는 원래 문제와 같은 형태를 가짐

정복

  • 분할된 각 하위 문제를 재귀적으로 해결함
  • 문제의 크기가 충분히 작으면 직접 해결함 (기저 조건)
  • 재귀 호출을 통해 더 작은 문제로 계속 분할함

결합

  • 하위 문제들의 해결책을 모아서 원래 문제의 최종 해를 구함
  • 부분 해들을 통합하여 전체 문제의 답을 도출함
  • 경우에 따라 결합 단계가 매우 간단하거나 필요 없을 수도 있음

image



분할 정복의 조건

분할 정복이 효과적으로 동작하려면 다음 조건들이 충족되어야 함

부분 문제의 독립성

  • 분할된 부분 문제들이 서로 독립적이어야 함
  • 한 부분 문제의 해결이 다른 부분 문제에 영향을 주지 않아야 함
  • 각 부분 문제를 독립적으로 해결할 수 있어야 병렬 처리가 가능함

같은 형태의 문제

  • 분할된 작은 문제가 원래 문제와 같은 형태를 가져야 함
  • 이것이 재귀 호출을 가능하게 함
  • 동일한 알고리즘을 반복적으로 적용할 수 있어야 함

기저 조건의 존재

  • 재귀를 종료할 수 있는 명확한 기저 조건(base case)이 있어야 함
  • 문제가 충분히 작아졌을 때 직접 해결할 수 있어야 함
  • 기저 조건이 없으면 무한 재귀에 빠질 수 있음



분할 정복 vs 동적계획법

  • 분할 정복과 동적계획법은 자주 혼동되지만 중요한 차이가 있음
항목분할 정복동적계획법
부분 문제 특성부분 문제들이 독립적임부분 문제들이 겹침
중복 계산중복 계산 발생 가능메모이제이션으로 중복 제거
구현 방식주로 재귀 방식반복문 또는 재귀 + 메모이제이션
활용 분야정렬, 탐색 알고리즘최적화 문제, 최단 경로
예시병합 정렬, 퀵 정렬, 이진 탐색피보나치 수열, 배낭 문제
시간 복잡도일반적으로 $O(n \log n)$일반적으로 $O(n^2)$ 또는 $O(n)$
  • 분할 정복
    • 부분 문제들이 중복되지 않으므로 각각을 한 번씩만 해결함
  • 동적계획법
    • 부분 문제들이 중복되므로 한 번 계산한 결과를 저장하여 재사용함



분할 정복의 장점

빠른 속도

  • 큰 문제를 작은 문제들로 분할하여 전체 실행 시간을 크게 줄일 수 있음
  • 대부분 $O(n \log n)$의 시간 복잡도를 가져 효율적임
  • $O(n^2)$보다 훨씬 빠른 성능을 제공함

쉬운 병렬화

  • 분할된 부분 문제들이 독립적이므로 멀티코어 시스템에서 병렬 처리가 가능함
  • 병렬 처리를 통해 성능을 크게 향상시킬 수 있음
  • 대규모 데이터 처리에 유리함

유연성

  • 여러 응용 분야에서 사용 가능함
  • 문제의 복잡도와 데이터 크기와 무관하게 적용할 수 있음
  • 다양한 문제에 동일한 패러다임을 적용할 수 있음

명확한 문제 해결 전략

  • 문제를 분할하고 각각 해결하는 방식이 명확함
  • 문제 해결 과정이 단순화됨
  • 재귀적 구조로 인해 코드가 간결하고 이해하기 쉬움



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

마스터 정리

  • 분할 정복의 시간 복잡도는 마스터 정리를 사용하여 계산함

  • 일반적인 재귀 관계식

    \[T(n) = aT(n/b) + f(n)\]
    • $a$
      • 분할 후 생기는 부분 문제의 개수
    • $b$
      • 문제를 나누는 비율
    • $f(n)$
      • 분할과 결합에 소요되는 시간
  • 병합 정렬
    • $T(n) = 2T(n/2) + O(n)$
      • 두 개의 부분 문제로 분할 ($a = 2$)
      • 각 부분은 절반 크기 ($b = 2$)
      • 병합에 $O(n)$ 시간 소요
      • 최종 시간 복잡도
        • $O(n \log n)$
  • 이진 탐색
    • $T(n) = T(n/2) + O(1)$
      • 한 개의 부분 문제만 해결 ($a = 1$)
      • 절반으로 분할 ($b = 2$)
      • 비교에 $O(1)$ 시간 소요
      • 최종 시간 복잡도
        • $O(\log n)$



병합 정렬

  • 병합 정렬은 분할 정복을 가장 잘 보여주는 대표적인 예임

동작 원리

  • 배열을 중간에서 쪼개 비슷한 크기의 두 배열로 분할
  • 각 배열을 재귀적으로 정렬 (정복)
  • 정렬된 두 배열을 병합하여 최종 정렬된 배열 생성 (결합)

재귀 트리 구조

image

특징

  • 각 단계마다 반으로 나눈 부분 문제를 처리한 후 결과를 합쳐서 원래 문제의 답을 계산함
  • 시간 복잡도
    • $O(n \log n)$ (모든 경우)
  • 공간 복잡도
    • $O(n)$ (추가 메모리 필요 - In-place가 아님)
  • 안정한 정렬 알고리즘임
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
void mergeSort(int[] array, int left, int right) {
    if (left < right) {
        int mid = (left + right) / 2;
        
        // 분할 (Divide)
        mergeSort(array, left, mid);
        mergeSort(array, mid + 1, right);
        
        // 결합 (Combine)
        merge(array, left, mid, right);
    }
}

void merge(int[] array, int left, int mid, int right) {
    // 임시 배열 생성
    int[] temp = new int[right - left + 1];
    int i = left, j = mid + 1, k = 0;
    
    // 두 부분 배열을 비교하며 병합
    while (i <= mid && j <= right) {
        if (array[i] <= array[j]) {
            temp[k++] = array[i++];
        } else {
            temp[k++] = array[j++];
        }
    }
    
    // 남은 요소 복사
    while (i <= mid) temp[k++] = array[i++];
    while (j <= right) temp[k++] = array[j++];
    
    // 원본 배열에 복사
    System.arraycopy(temp, 0, array, left, temp.length);
}



퀵 정렬

  • 퀵 정렬도 분할 정복을 사용하지만, 병합 정렬과는 다른 방식을 취함

동작 원리

  • 피벗(pivot)이라는 기준값을 선택
  • 피벗을 기준으로 배열을 두 부분으로 분할 (분할 단계에서 대부분의 작업 수행)
  • 각 부분을 재귀적으로 정렬
  • 결합 단계는 거의 아무것도 하지 않음

병합 정렬과의 차이

  • 병합 정렬
    • 분할: 단순히 중간에서 나눔 (간단)
    • 결합: 두 정렬된 배열을 병합 (복잡)

퀵 정렬

  • 분할: 피벗 기준으로 요소들을 재배치 (복잡)
  • 결합: 아무것도 하지 않음 (간단)

특징

  • 모든 중요한 작업이 분할 단계에서 일어남
  • 파티션 과정에는 주어진 수열의 길이에 비례하는 시간이 소요됨
  • 시간 복잡도
    • 평균: $O(n \log n)$
    • 최악: $O(n^2)$ (피벗을 잘못 선택했을 때 - 이미 정렬된 배열에서 첫 번째나 마지막 요소를 피벗으로 선택한 경우 등)
  • 공간 복잡도
    • $O(\log n)$ (재귀 스택, In-place 정렬 가능)
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
void quickSort(int[] array, int left, int right) {
    if (left < right) {
        // 분할 (Divide) - 여기서 대부분의 작업 수행
        int pivot = partition(array, left, right);
        
        // 정복 (Conquer)
        quickSort(array, left, pivot - 1);
        quickSort(array, pivot + 1, right);
        
        // 결합 (Combine) - 아무것도 하지 않음
    }
}

int partition(int[] array, int left, int right) {
    int pivot = array[right];
    int i = left - 1;
    
    // 피벗보다 작은 요소는 왼쪽으로
    for (int j = left; j < right; j++) {
        if (array[j] < pivot) {
            i++;
            swap(array, i, j);
        }
    }
    
    // 피벗을 중간 위치로 이동
    swap(array, i + 1, right);
    return i + 1;
}

void swap(int[] array, int i, int j) {
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}



이진 탐색

  • 이진 탐색도 분할 정복 기법을 활용함

동작 원리

  • 정렬된 배열을 반으로 나눔 (분할)
  • 찾으려는 값이 어느 절반에 위치하는지 확인 (정복)
  • 해당 절반에서 다시 같은 과정을 반복
  • 결합 단계는 필요 없음 (값을 찾거나 못 찾거나)

특징

  • 시간 복잡도
    • $O(\log n)$
  • 공간 복잡도
    • $O(1)$ (반복문)
    • $O(\log n)$ (재귀)
  • 반드시 정렬된 배열에서만 사용 가능함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 재귀 방식
int binarySearch(int[] array, int target) {
    return binarySearchHelper(array, target, 0, array.length - 1);
}

int binarySearchHelper(int[] array, int target, int left, int right) {
    // 기저 조건: 찾지 못한 경우
    if (left > right) return -1;
    
    // 분할: 중간 인덱스 계산
    int mid = left + (right - left) / 2;
    
    // 정복: 찾은 경우
    if (array[mid] == target) return mid;
    
    // 정복: 왼쪽 절반 탐색
    if (array[mid] > target) {
        return binarySearchHelper(array, target, left, mid - 1);
    }
    
    // 정복: 오른쪽 절반 탐색
    return binarySearchHelper(array, target, mid + 1, right);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 반복문 방식 (더 효율적)
int binarySearchIterative(int[] array, int target) {
    int left = 0, right = array.length - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        
        if (array[mid] == target) {
            return mid;
        } else if (array[mid] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    
    return -1;  // 찾지 못함
}



거듭제곱 연산

  • 큰 수의 거듭제곱을 계산할 때도 분할 정복을 사용함

동작 원리

  • $n^{100}$을 계산할 때
    • 일반적인 방법
      • $n$을 100번 곱함 → $O(n)$
    • 분할 정복
      • $n^{100} = n^{50} \times n^{50}$ → $O(\log n)$

특징

  • 시간 복잡도
    • $O(\log n)$
  • 곱셈 횟수를 크게 줄일 수 있음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 재귀 방식
long power(long base, int exp) {
    // 기저 조건
    if (exp == 0) return 1;
    if (exp == 1) return base;
    
    // 분할: 지수를 절반으로
    long half = power(base, exp / 2);
    
    // 결합: 절반 결과를 제곱
    if (exp % 2 == 0) {
        return half * half;
    } else {
        return half * half * base;
    }
}



최대 부분 배열 합

  • 배열에서 연속된 요소들의 최대 합을 구하는 문제

동작 원리

  • 배열을 반으로 나눔
  • 왼쪽 절반의 최대 부분 배열 합 계산
  • 오른쪽 절반의 최대 부분 배열 합 계산
  • 중간을 가로지르는 최대 부분 배열 합 계산
  • 세 값 중 최댓값 반환

특징

  • 시간 복잡도
    • $O(n \log n)$
  • 카데인 알고리즘($O(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
30
31
32
33
34
35
36
int maxSubarraySum(int[] array, int left, int right) {
    // 기저 조건: 요소가 하나만 있을 때
    if (left == right) {
        return array[left];
    }
    
    int mid = (left + right) / 2;
    
    // 분할 정복
    int leftMax = maxSubarraySum(array, left, mid);
    int rightMax = maxSubarraySum(array, mid + 1, right);
    int crossMax = maxCrossingSum(array, left, mid, right);
    
    // 결합: 세 값 중 최댓값 반환
    return Math.max(Math.max(leftMax, rightMax), crossMax);
}

int maxCrossingSum(int[] array, int left, int mid, int right) {
    // 중간에서 왼쪽으로 최대 합
    int sum = 0;
    int leftSum = Integer.MIN_VALUE;
    for (int i = mid; i >= left; i--) {
        sum += array[i];
        leftSum = Math.max(leftSum, sum);
    }
    
    // 중간에서 오른쪽으로 최대 합
    sum = 0;
    int rightSum = Integer.MIN_VALUE;
    for (int i = mid + 1; i <= right; i++) {
        sum += array[i];
        rightSum = Math.max(rightSum, sum);
    }
    
    return leftSum + rightSum;
}



분할 정복 구현 시 주의사항

기저 조건 명확히 설정

  • 재귀가 언제 멈춰야 할지 명확하게 정의해야 함
  • 기저 조건이 없으면 무한 재귀에 빠질 수 있음
  • 문제가 충분히 작아졌을 때의 직접 해결 방법을 정의해야 함
1
2
3
4
5
6
7
8
9
10
11
// 나쁜 예: 기저 조건 누락
void badRecursion(int n) {
    if (n == 0) return;  // n이 음수가 되면?
    badRecursion(n - 1);
}

// 좋은 예: 명확한 기저 조건
void goodRecursion(int n) {
    if (n <= 0) return;  // 음수도 처리
    goodRecursion(n - 1);
}

부분 문제의 크기 감소 확인

  • 재귀 호출 시 부분 문제의 크기가 계속 감소해야 함
  • 크기가 감소하지 않으면 무한 루프에 빠질 수 있음
1
2
3
4
5
6
7
8
9
10
11
12
13
// 나쁜 예: 크기가 감소하지 않음
void badDivide(int[] array, int left, int right) {
    if (left >= right) return;
    badDivide(array, left, right);  // 크기가 그대로!
}

// 좋은 예: 크기가 감소함
void goodDivide(int[] array, int left, int right) {
    if (left >= right) return;
    int mid = (left + right) / 2;
    goodDivide(array, left, mid);      // 크기 감소
    goodDivide(array, mid + 1, right); // 크기 감소
}

스택 오버플로우 방지

  • 깊은 재귀 호출로 인한 스택 오버플로우를 고려해야 함
  • 필요시 반복문으로 변환하거나 꼬리 재귀 최적화를 고려함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 재귀 깊이가 너무 깊을 수 있음
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);  // O(n) 스택 공간
}

// 반복문으로 변환
int factorialIterative(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;  // O(1) 스택 공간
}

시간 복잡도 정확히 분석

  • 마스터 정리를 사용하여 시간 복잡도를 정확히 분석해야 함
  • 분할 횟수와 각 단계의 작업량을 고려해야 함



정리

  • 분할 정복은 큰 문제를 작은 부분 문제로 나누어 해결한 후 결합하는 알고리즘 설계 패러다임임
  • 분할(Divide), 정복(Conquer), 결합(Combine)의 세 단계로 구성됨
  • 동적계획법과 달리 부분 문제들이 독립적이어서 중복 계산이 발생하지 않음
  • 대표적인 예시로 병합 정렬($O(n \log n)$), 퀵 정렬(평균 $O(n \log n)$), 이진 탐색($O(\log n)$)이 있음



Reference

Contents