
분할 정복은 큰 문제를 작은 문제로 나누어 해결한 후 결과를 합치는 효율적인 알고리즘 설계 기법입니다.
분할 정복은 문제를 더 작은 부분 문제로 나누고, 각각을 해결한 뒤, 그 결과를 합쳐서 원래 문제를 해결하는 방법입니다.
실생활 비유:
큰 빌딩 청소하기:
❌ 비효율적: 혼자서 1층부터 10층까지
✅ 분할 정복:
1. 10층을 2개 구역으로 나눔 (분할)
2. 각 구역에 사람 배치 (정복)
3. 결과 합침 (전체 청소 완료)
또 다른 예시:
분할 정복은 항상 다음 3단계를 따릅니다:
1. 분할 (Divide)
└─ 문제를 더 작은 부분 문제로 나눔
2. 정복 (Conquer)
└─ 부분 문제를 재귀적으로 해결 (충분히 작으면 직접 해결)
3. 합치기 (Combine)
└─ 부분 문제의 해답을 합쳐 원래 문제 해결
간단한 예시: 배열의 합 구하기
def array_sum(arr, start, end):
"""
배열의 합을 분할 정복으로 구하기
분할 정복의 3단계:
1. 분할: 배열을 반으로 나눔
2. 정복: 각 절반의 합을 재귀로 구함
3. 합치기: 두 결과를 더함
"""
# 기저 조건: 원소가 1개면 그 값 반환
if start == end:
return arr[start]
# 1. 분할: 중간 지점 찾기
mid = (start + end) // 2
# 2. 정복: 각 절반을 재귀로 해결
left_sum = array_sum(arr, start, mid)
right_sum = array_sum(arr, mid + 1, end)
# 3. 합치기: 두 결과를 더함
return left_sum + right_sum
arr = [1, 2, 3, 4, 5, 6, 7, 8]
print(array_sum(arr, 0, len(arr) - 1)) # 36
# 실행 과정:
# [1,2,3,4,5,6,7,8]
# ↓ 분할
# [1,2,3,4] [5,6,7,8]
# ↓ ↓
# [1,2] [3,4] [5,6] [7,8]
# ↓ ↓ ↓ ↓
# [1][2][3][4][5][6][7][8]
# ↓ 합치기
# 3 7 11 15
# ↓
# 10 26
# ↓
# 36
완전 탐색 vs 분할 정복:
# 완전 탐색: 배열에서 최댓값 찾기
def find_max_brute(arr):
max_val = arr[0]
for num in arr: # n번 비교
if num > max_val:
max_val = num
return max_val
# 시간복잡도: O(n)
# 분할 정복: 배열에서 최댓값 찾기
def find_max_divide(arr, start, end):
if start == end:
return arr[start]
mid = (start + end) // 2
left_max = find_max_divide(arr, start, mid)
right_max = find_max_divide(arr, mid + 1, end)
return max(left_max, right_max)
# 시간복잡도: O(n) - 같음!
위 예시에서는 시간복잡도가 같지만, 정렬 같은 문제에서는 큰 차이가 납니다:
완전 탐색 정렬 (버블 정렬): O(n²)
분할 정복 정렬 (병합 정렬): O(n log n)
n = 1,000일 때:
O(n²) = 1,000,000 연산
O(n log n) = 약 10,000 연산
→ 100배 빠름!
병합 정렬은 분할 정복의 가장 대표적인 예시입니다.
핵심 아이디어:
1. 배열을 반으로 나눔 (Divide)
2. 각 절반을 정렬 (재귀적으로 정렬)
3. 정렬된 두 배열을 합침 (Merge)
시각화:
초기: [38, 27, 43, 3, 9, 82, 10]
분할 단계:
[38, 27, 43, 3, 9, 82, 10]
↓
[38, 27, 43, 3] [9, 82, 10]
↓ ↓
[38, 27] [43, 3] [9, 82] [10]
↓ ↓ ↓ ↓
[38] [27] [43] [3] [9] [82] [10]
합치기 단계:
[27, 38] [3, 43] [9, 82] [10]
↓ ↓
[3, 27, 38, 43] [9, 10, 82]
↓
[3, 9, 10, 27, 38, 43, 82]
def merge_sort(arr):
"""
병합 정렬
시간복잡도: O(n log n)
공간복잡도: O(n) - 추가 배열 필요
"""
# 기저 조건: 원소가 1개 이하면 이미 정렬됨
if len(arr) <= 1:
return arr
# 1. 분할: 중간 지점
mid = len(arr) // 2
# 2. 정복: 각 절반을 재귀로 정렬
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
# 3. 합치기: 정렬된 두 배열 병합
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
# 사용 예시
arr = [38, 27, 43, 3, 9, 82, 10]
sorted_arr = merge_sort(arr)
print(sorted_arr) # [3, 9, 10, 27, 38, 43, 82]
병합(merge) 함수가 어떻게 동작하는지 자세히 봅시다:
# 예시: [3, 27, 38]과 [9, 10, 82]를 병합
left = [3, 27, 38]
right = [9, 10, 82]
# 초기 상태
i = 0, j = 0
result = []
# 1단계: left[0]=3 vs right[0]=9
# 3 < 9 → 3 선택
i=0, j=0: [3]
i=1, j=0:
# 2단계: left[1]=27 vs right[0]=9
# 27 > 9 → 9 선택
i=1, j=0: [3, 9]
i=1, j=1:
# 3단계: left[1]=27 vs right[1]=10
# 27 > 10 → 10 선택
i=1, j=1: [3, 9, 10]
i=1, j=2:
# 4단계: left[1]=27 vs right[2]=82
# 27 < 82 → 27 선택
i=1, j=2: [3, 9, 10, 27]
i=2, j=2:
# 5단계: left[2]=38 vs right[2]=82
# 38 < 82 → 38 선택
i=2, j=2: [3, 9, 10, 27, 38]
i=3, j=2:
# 6단계: left 끝남, right 남음
# right[2:] = [82] 추가
result = [3, 9, 10, 27, 38, 82]
왜 O(n log n)인가?
높이: log n (배열을 반으로 나누는 횟수)
각 레벨에서 비교: n (모든 원소 1번씩)
총 시간: n × log n = O(n log n)
예: n=8
레벨 0: [8개] → 8번 비교
레벨 1: [4개] [4개] → 8번 비교
레벨 2: [2][2] [2][2] → 8번 비교
레벨 3: [1][1][1][1][1][1][1][1] → 0번 비교
총 높이: log₂(8) = 3
총 비교: 3 × 8 = 24 = 8 × log₂(8)
퀵 정렬은 병합 정렬과 다른 방식의 분할 정복입니다.
차이점:
병합 정렬:
- 균등하게 나눔 (항상 반반)
- 합치는 게 핵심 (병합 과정에서 정렬)
퀵 정렬:
- 피벗 기준으로 나눔 (불균등할 수 있음)
- 나누는 게 핵심 (분할 과정에서 정렬)
핵심 아이디어:
1. 피벗(pivot) 선택: 배열의 임의의 원소
2. 피벗보다 작은 것은 왼쪽, 큰 것은 오른쪽
3. 각 부분을 재귀로 정렬
피벗은 세가지 방식 중 하나로 선택
1. 맨 앞/뒤 요소 선택 (기본)
2. 무작위 선택 (Randomized)
3. 중앙값(Median)
초기: [7, 2, 1, 6, 8, 5, 3, 4]
피벗: 4 (마지막 원소)
1단계: 피벗 기준 분할
작은 것(< 4): [2, 1, 3]
피벗(= 4): [4]
큰 것(> 4): [7, 6, 8, 5]
2단계: 각 부분 재귀
[2, 1, 3] 정렬 → [1, 2, 3]
[7, 6, 8, 5] 정렬 → [5, 6, 7, 8]
3단계: 합치기
[1, 2, 3] + [4] + [5, 6, 7, 8]
= [1, 2, 3, 4, 5, 6, 7, 8]
방법 1: 이해하기 쉬운 버전
def quick_sort_simple(arr):
"""
퀵 정렬 - 간단 버전
장점: 이해하기 쉬움
단점: 추가 메모리 사용
"""
# 기저 조건
if len(arr) <= 1:
return arr
# 1. 피벗 선택 (여기서는 마지막 원소)
pivot = arr[-1]
# 2. 분할: 피벗 기준으로 나누기
left = [x for x in arr[:-1] if x <= pivot] # 작거나 같은 것
right = [x for x in arr[:-1] if x > pivot] # 큰 것
# 3. 재귀로 정렬 + 합치기
return quick_sort_simple(left) + [pivot] + quick_sort_simple(right)
# 사용 예시
arr = [7, 2, 1, 6, 8, 5, 3, 4]
print(quick_sort_simple(arr))
# [1, 2, 3, 4, 5, 6, 7, 8]
방법 2: 제자리(In-place) 정렬
def quick_sort(arr, low, high):
"""
퀵 정렬 - 제자리 정렬 버전
장점: 추가 메모리 O(1)
단점: 구현이 복잡
arr: 정렬할 배열
low: 시작 인덱스
high: 끝 인덱스
"""
if low < high:
# 1. 분할: 피벗 위치 찾기
pivot_index = partition(arr, low, high)
# 2. 재귀: 피벗 기준 양쪽 정렬
quick_sort(arr, low, pivot_index - 1) # 왼쪽
quick_sort(arr, pivot_index + 1, high) # 오른쪽
def partition(arr, low, high):
"""
배열을 피벗 기준으로 분할
반환: 피벗의 최종 위치
핵심 아이디어:
- 피벗보다 작은 것들을 왼쪽으로 이동
- 피벗보다 큰 것들을 오른쪽으로 이동
"""
# 피벗 선택 (마지막 원소)
pivot = arr[high]
i = low - 1 # i: 작은 원소들의 마지막 위치
# j: 현재 확인 중인 원소
for j in range(low, high):
if arr[j] <= pivot: # 현재 원소가 피벗보다 작거나 같으면
i += 1 # i를 증가시키고
arr[i], arr[j] = arr[j], arr[i] # arr[i]와 arr[j] 교환 (작은 원소를 왼쪽으로)
# 피벗을 중간에 배치
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
# 사용 예시
arr = [7, 2, 1, 6, 8, 5, 3, 4]
quick_sort(arr, 0, len(arr) - 1)
print(arr) # [1, 2, 3, 4, 5, 6, 7, 8]
partition 함수가 어떻게 동작하는지 단계별로 봅시다:
arr = [7, 2, 1, 6, 8, 5, 3, 4]
pivot = 4 (마지막 원소)
초기: i = -1
j=0: arr[0]=7 > 4 → 교환 안 함
[7, 2, 1, 6, 8, 5, 3, 4]
j (i=-1)
j=1: arr[1]=2 ≤ 4 → 교환!
i = 0
[2, 7, 1, 6, 8, 5, 3, 4]
i j
j=2: arr[2]=1 ≤ 4 → 교환!
i = 1
[2, 1, 7, 6, 8, 5, 3, 4]
i j
j=3: arr[3]=6 > 4 → 교환 안 함
[2, 1, 7, 6, 8, 5, 3, 4]
i j
j=4: arr[4]=8 > 4 → 교환 안 함
[2, 1, 7, 6, 8, 5, 3, 4]
i j
j=5: arr[5]=5 > 4 → 교환 안 함
[2, 1, 7, 6, 8, 5, 3, 4]
i j
j=6: arr[6]=3 ≤ 4 → 교환!
i = 2
[2, 1, 3, 6, 8, 5, 7, 4]
i j
반복 종료, 피벗(4)을 i+1 위치로:
[2, 1, 3, 4, 8, 5, 7, 6]
↑
피벗 위치 (i+1=3)
결과:
- 4 왼쪽 [2,1,3]은 모두 4 이하
- 4 오른쪽 [8,5,7,6]은 모두 4 초과
최선/평균: O(n log n)
- 피벗이 중간값에 가까울 때
- 매번 균등하게 분할
최악: O(n²)
- 피벗이 최솟값이나 최댓값일 때
- 이미 정렬된 배열에서 첫/마지막을 피벗으로
예: [1, 2, 3, 4, 5]에서 항상 마지막을 피벗으로
→ 매번 1개 vs n-1개로 분할
→ n + (n-1) + ... + 1 = O(n²)
해결책: 랜덤 피벗 또는 중간값 피벗
이진 탐색은 정렬된 배열에서 특정 값을 찾는 분할 정복 알고리즘입니다.
핵심 아이디어:
1. 중간 원소 확인
2. 찾는 값이 중간보다 작으면 왼쪽 절반 탐색
3. 찾는 값이 중간보다 크면 오른쪽 절반 탐색
4. 반복
시각화:
배열: [1, 3, 5, 7, 9, 11, 13, 15, 17]
찾기: 11
1단계: 중간(9) 확인
[1, 3, 5, 7, 9, 11, 13, 15, 17]
↑
9 < 11 → 오른쪽 탐색
2단계: 오른쪽 절반의 중간(13) 확인
[11, 13, 15, 17]
↑
13 > 11 → 왼쪽 탐색
3단계: 왼쪽 절반의 중간(11) 확인
[11]
↑
찾았다!
방법 1: 재귀
def binary_search_recursive(arr, target, left, right):
"""
이진 탐색 - 재귀 버전
arr: 정렬된 배열
target: 찾을 값
left: 탐색 범위 시작
right: 탐색 범위 끝
Returns: target의 인덱스 (없으면 -1)
"""
# 기저 조건: 범위가 유효하지 않음
if left > right:
return -1
# 1. 분할: 중간 지점
mid = (left + right) // 2
# 2. 정복: 중간값 확인
if arr[mid] == target:
return mid # 찾았다!
elif arr[mid] > target:
# 왼쪽 절반 탐색
return binary_search_recursive(arr, target, left, mid - 1)
else:
# 오른쪽 절반 탐색
return binary_search_recursive(arr, target, mid + 1, right)
# 사용 예시
arr = [1, 3, 5, 7, 9, 11, 13, 15, 17]
target = 11
index = binary_search_recursive(arr, target, 0, len(arr) - 1)
print(f"{target}의 위치: {index}") # 5
방법 2: 반복문
def binary_search_iterative(arr, target):
"""
이진 탐색 - 반복 버전
장점: 스택 오버플로우 위험 없음
"""
left = 0
right = len(arr) - 1
while left <= right:
mid = (left + right) // 2 # 중간 지점
if arr[mid] == target:
return mid # 찾았다!
elif arr[mid] > target:
right = mid - 1 # 왼쪽으로
else:
left = mid + 1 # 오른쪽으로
return -1 # 못 찾음
# 사용 예시
arr = [1, 3, 5, 7, 9, 11, 13, 15, 17]
print(binary_search_iterative(arr, 11)) # 5 (인덱스)
print(binary_search_iterative(arr, 10)) # -1 (없음)
O(log n)
매번 탐색 범위가 절반으로 줄어듦:
n = 16
1단계: 16개
2단계: 8개
3단계: 4개
4단계: 2개
5단계: 1개
총 단계: log₂(16) = 4
순차 탐색 vs 이진 탐색:
n = 1,000,000
순차: 최악 1,000,000번
이진: 최악 20번 (log₂(1,000,000) ≈ 20)
→ 50,000배 빠름!
분할 정복을 사용할 때
정렬 알고리즘 선택
병합 정렬:
- 안정 정렬 (같은 값의 순서 유지)
- 최악의 경우도 O(n log n) 보장
- 추가 메모리 O(n) 필요
- 외부 정렬(큰 파일)에 적합
퀵 정렬:
- 평균적으로 가장 빠름
- 제자리 정렬 (메모리 O(1))
- 최악 O(n²) 가능
- 캐시 효율적
이진 탐색 주의사항
mid = left + (right - left) // 2bisect 모듈 제공import bisect
arr = [1, 3, 5, 7, 9, 11]
index = bisect.bisect_left(arr, 7) # 7의 위치
print(index) # 3
분할 정복의 본질
주요 알고리즘
알고리즘 시간복잡도 공간복잡도 특징
--------------------------------------------------------
병합 정렬 O(n log n) O(n) 안정, 최악도 보장
퀵 정렬 O(n log n)* O(1)** 평균 가장 빠름
이진 탐색 O(log n) O(1) 정렬 필요
* 평균, 최악은 O(n²)
** 제자리 정렬 시
시간복잡도 비교
n = 1,000일 때:
O(n²) = 1,000,000
O(n log n) = 약 10,000 (100배 빠름)
O(log n) = 약 10 (100,000배 빠름)
분할 정복 vs 완전 탐색
완전 탐색: 모든 경우 확인 → O(n), O(n²), O(2^n)
분할 정복: 절반씩 줄여가며 → O(log n), O(n log n)
분할 정복이 유리한 경우:
- 정렬, 탐색
- 문제를 독립적으로 나눌 수 있을 때
[06-03] 그리디 (Greedy)
이전 글: [06-01] 완전 탐색
다음 글: [06-03] 그리디
시리즈: P1. Computer Science 기초