개념
피벗(Pivot)을 기준으로 작은 값은 왼쪽, 큰 값은 오른쪽으로 분할하는 정렬
분할 정복 방식이지만, 병합 과정 없이 제자리에서 정렬
시간복잡도
케이스 | 시간복잡도 |
---|---|
최선 | O(n log n) |
평균 | O(n log n) |
최악 | O(n²) |
공간복잡도 | O(log n) ~ O(n) |
특징
장점
- 평균적으로 가장 빠름: 실전에서 가장 많이 사용
- 제자리 정렬: 추가 메모리 적게 사용
- 캐시 효율적: 메모리 접근 패턴이 좋음
- 분할 정복: 병렬 처리 가능
단점
- 최악 O(n²): 이미 정렬된 경우
- 불안정 정렬: 같은 값의 순서가 바뀔 수 있음
- 피벗 선택: 성능이 피벗에 의존적
구현 방법
기본 구현 (Lomuto Partition)
def quick_sort(arr, low, high):
if low < high:
# 피벗을 기준으로 분할
pivot_index = partition(arr, low, high)
# 피벗 왼쪽 정렬
quick_sort(arr, low, pivot_index - 1)
# 피벗 오른쪽 정렬
quick_sort(arr, pivot_index + 1, high)
return arr
def partition(arr, low, high):
"""Lomuto Partition: 마지막 요소를 피벗으로"""
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
# 테스트
arr = [10, 7, 8, 9, 1, 5]
print(quick_sort(arr, 0, len(arr) - 1))
# [1, 5, 7, 8, 9, 10]
Hoare Partition (더 효율적)
def quick_sort_hoare(arr, low, high):
if low < high:
pivot_index = hoare_partition(arr, low, high)
quick_sort_hoare(arr, low, pivot_index)
quick_sort_hoare(arr, pivot_index + 1, high)
return arr
def hoare_partition(arr, low, high):
"""Hoare Partition: 첫 요소를 피벗으로"""
pivot = arr[low]
i = low - 1
j = high + 1
while True:
# 피벗보다 큰 요소 찾기
i += 1
while arr[i] < pivot:
i += 1
# 피벗보다 작은 요소 찾기
j -= 1
while arr[j] > pivot:
j -= 1
if i >= j:
return j
# 교환
arr[i], arr[j] = arr[j], arr[i]
# 테스트
arr = [10, 7, 8, 9, 1, 5]
print(quick_sort_hoare(arr, 0, len(arr) - 1))
# [1, 5, 7, 8, 9, 10]
Python식 간단 구현
def quick_sort_simple(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_simple(left) + middle + quick_sort_simple(right)
# 테스트
print(quick_sort_simple([10, 7, 8, 9, 1, 5]))
# [1, 5, 7, 8, 9, 10]
# 주의: 추가 메모리 O(n) 사용
동작 과정
초기: [10, 7, 8, 9, 1, 5] (피벗: 5)
1단계: 피벗 5를 기준으로 분할
[1] [5] [10, 7, 8, 9]
↓ ↓ ↓
정렬됨 재귀
2단계: [10, 7, 8, 9] 정렬 (피벗: 9)
[7, 8] [9] [10]
↓ ↓ ↓
재귀 정렬됨 정렬됨
3단계: [7, 8] 정렬 (피벗: 8)
[7] [8]
↓ ↓
정렬됨
최종: [1, 5, 7, 8, 9, 10]
템플릿
def quick_sort_template(arr, low, high):
# 기저 조건
if low >= high:
return
# 1. 분할 (Partition)
pivot_index = partition_template(arr, low, high)
# 2. 정복 (Conquer)
quick_sort_template(arr, low, pivot_index - 1) # 왼쪽
quick_sort_template(arr, pivot_index + 1, high) # 오른쪽
def partition_template(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
최적화 기법
1. 랜덤 피벗 선택
import random
def randomized_partition(arr, low, high):
# 랜덤 인덱스 선택
random_index = random.randint(low, high)
# 마지막과 교환
arr[random_index], arr[high] = arr[high], arr[random_index]
# 일반 partition 수행
return partition(arr, low, high)
def quick_sort_randomized(arr, low, high):
if low < high:
pivot_index = randomized_partition(arr, low, high)
quick_sort_randomized(arr, low, pivot_index - 1)
quick_sort_randomized(arr, pivot_index + 1, high)
return arr
# 최악의 경우 확률 감소
2. 3-Way Partition (중복 값 최적화)
def quick_sort_3way(arr, low, high):
"""중복 값이 많을 때 효율적"""
if low >= high:
return
lt, gt = partition_3way(arr, low, high)
# 피벗과 같은 값들은 정렬 필요 없음
quick_sort_3way(arr, low, lt - 1)
quick_sort_3way(arr, gt + 1, high)
def partition_3way(arr, low, high):
"""피벗과 같은 값들을 중간에 모음"""
pivot = arr[low]
lt = low # arr[low+1:lt] < pivot
i = low + 1 # arr[lt:i] == pivot
gt = high # arr[gt:high+1] > pivot
while i <= gt:
if arr[i] < pivot:
arr[lt], arr[i] = arr[i], arr[lt]
lt += 1
i += 1
elif arr[i] > pivot:
arr[i], arr[gt] = arr[gt], arr[i]
gt -= 1
else:
i += 1
return lt, gt
# 테스트
arr = [4, 9, 4, 4, 1, 9, 4, 4, 9, 4, 4, 1, 4]
quick_sort_3way(arr, 0, len(arr) - 1)
print(arr) # [1, 1, 4, 4, 4, 4, 4, 4, 4, 4, 9, 9, 9]
3. Hybrid Quick Sort (삽입 정렬 결합)
def quick_sort_hybrid(arr, low, high):
# 작은 부분 배열은 삽입 정렬
if high - low < 10:
insertion_sort_range(arr, low, high)
return
if low < high:
pivot_index = partition(arr, low, high)
quick_sort_hybrid(arr, low, pivot_index - 1)
quick_sort_hybrid(arr, pivot_index + 1, high)
def insertion_sort_range(arr, low, high):
"""부분 배열 삽입 정렬"""
for i in range(low + 1, high + 1):
key = arr[i]
j = i - 1
while j >= low and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
# 작은 배열에서 오버헤드 감소
4. Median-of-Three 피벗 선택
def median_of_three(arr, low, high):
"""첫, 중간, 마지막 요소의 중앙값을 피벗으로"""
mid = (low + high) // 2
# 정렬
if arr[low] > arr[mid]:
arr[low], arr[mid] = arr[mid], arr[low]
if arr[low] > arr[high]:
arr[low], arr[high] = arr[high], arr[low]
if arr[mid] > arr[high]:
arr[mid], arr[high] = arr[high], arr[mid]
# 중앙값을 마지막으로 이동
arr[mid], arr[high] = arr[high], arr[mid]
return arr[high]
def quick_sort_median(arr, low, high):
if low < high:
pivot = median_of_three(arr, low, high)
pivot_index = partition(arr, low, high)
quick_sort_median(arr, low, pivot_index - 1)
quick_sort_median(arr, pivot_index + 1, high)
return arr
# 최악의 경우 방지
핵심 요약
개념 | 설명 |
---|---|
원리 | 피벗 기준 분할 정복 |
시간복잡도 | 평균 O(n log n), 최악 O(n²) |
공간복잡도 | O(log n) ~ O(n) |
안정성 | 불안정 정렬 |
특징 | 평균적으로 가장 빠른 정렬 |
다른 정렬과 비교
정렬 | 시간복잡도 (평균) | 최악 | 공간복잡도 | 안정성 | 특징 |
---|---|---|---|---|---|
퀵 정렬 | O(n log n) | O(n²) | O(log n) | 불안정 | 가장 빠름 |
병합 정렬 | O(n log n) | O(n log n) | O(n) | 안정 | 최악 보장 |
힙 정렬 | O(n log n) | O(n log n) | O(1) | 불안정 | 메모리 효율 |
주의사항
1. 최악의 경우 방지
# 이미 정렬된 경우 O(n²)
arr = [1, 2, 3, 4, 5] # 최악!
# 해결책
# 1. 랜덤 피벗
# 2. Median-of-Three
# 3. 3-Way Partition
2. 스택 오버플로우
# 재귀 깊이가 깊어지면 위험
import sys
sys.setrecursionlimit(10**6)
# 또는 꼬리 재귀 최적화
def quick_sort_tail_recursive(arr, low, high):
while low < high:
pivot_index = partition(arr, low, high)
# 작은 쪽을 재귀, 큰 쪽을 반복
if pivot_index - low < high - pivot_index:
quick_sort_tail_recursive(arr, low, pivot_index - 1)
low = pivot_index + 1
else:
quick_sort_tail_recursive(arr, pivot_index + 1, high)
high = pivot_index - 1
3. 불안정 정렬
# 같은 값의 순서가 바뀔 수 있음
arr = [(5, 'a'), (3, 'b'), (5, 'c'), (2, 'd')]
# 정렬 후: (5, 'c')가 (5, 'a')보다 앞에 올 수 있음
# 안정 정렬 필요시 병합 정렬 사용
자주 나오는 문제 유형
1. K번째 작은 수 찾기 (Quick Select)
문제: 정렬 없이 K번째 작은 수 찾기
def quick_select(arr, k):
"""K번째 작은 수 찾기 (0-indexed)"""
if len(arr) == 1:
return arr[0]
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]
if k < len(left):
return quick_select(left, k)
elif k < len(left) + len(middle):
return middle[0]
else:
return quick_select(right, k - len(left) - len(middle))
# 테스트
print(quick_select([7, 10, 4, 3, 20, 15], 2)) # 7 (3번째 작은 수)
# 시간복잡도: 평균 O(n), 최악 O(n²)
핵심: 한쪽만 재귀하므로 O(n)
2. 배열을 특정 값 기준으로 분할
문제: K보다 작은 값들을 왼쪽, 큰 값들을 오른쪽으로
def partition_by_value(arr, k):
"""K를 기준으로 분할"""
left = 0
right = len(arr) - 1
while left <= right:
# 왼쪽에서 K 이상인 요소 찾기
while left <= right and arr[left] < k:
left += 1
# 오른쪽에서 K 미만인 요소 찾기
while left <= right and arr[right] >= k:
right -= 1
# 교환
if left < right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
return arr
# 테스트
print(partition_by_value([3, 5, 8, 5, 10, 2, 1], 5))
# [3, 1, 2, 5, 10, 5, 8] (순서는 다를 수 있음)
# 시간복잡도: O(n)
핵심: Two Pointer로 한 번의 순회
3. 색 정렬 (Dutch National Flag)
문제: 0, 1, 2로만 이루어진 배열 정렬
def sort_colors(arr):
"""3-Way Partition 응용"""
low = 0 # 0의 끝
mid = 0 # 현재 위치
high = len(arr) - 1 # 2의 시작
while mid <= high:
if arr[mid] == 0:
arr[low], arr[mid] = arr[mid], arr[low]
low += 1
mid += 1
elif arr[mid] == 1:
mid += 1
else: # arr[mid] == 2
arr[mid], arr[high] = arr[high], arr[mid]
high -= 1
return arr
# 테스트
print(sort_colors([2, 0, 2, 1, 1, 0]))
# [0, 0, 1, 1, 2, 2]
# 시간복잡도: O(n)
# 공간복잡도: O(1)
핵심: 한 번의 순회로 정렬
4. Top K Frequent Elements
문제: 가장 빈번한 K개의 요소 찾기
from collections import Counter
import random
def top_k_frequent(nums, k):
"""Quick Select로 Top K 찾기"""
count = Counter(nums)
unique = list(count.keys())
def partition(left, right, pivot_idx):
pivot_freq = count[unique[pivot_idx]]
# 피벗을 끝으로
unique[pivot_idx], unique[right] = unique[right], unique[pivot_idx]
store_idx = left
for i in range(left, right):
if count[unique[i]] < pivot_freq:
unique[store_idx], unique[i] = unique[i], unique[store_idx]
store_idx += 1
# 피벗을 제자리에
unique[right], unique[store_idx] = unique[store_idx], unique[right]
return store_idx
def quick_select(left, right, k_smallest):
if left == right:
return
pivot_idx = random.randint(left, right)
pivot_idx = partition(left, right, pivot_idx)
if k_smallest == pivot_idx:
return
elif k_smallest < pivot_idx:
quick_select(left, pivot_idx - 1, k_smallest)
else:
quick_select(pivot_idx + 1, right, k_smallest)
n = len(unique)
quick_select(0, n - 1, n - k)
return unique[n - k:]
# 테스트
print(top_k_frequent([1, 1, 1, 2, 2, 3], 2))
# [1, 2]
# 시간복잡도: 평균 O(n)
핵심: Quick Select로 빈도수 기준 분할
추천 연습 문제
기초
중급
고급
언제 사용할까?
사용 가능
- 일반적인 정렬: 평균적으로 가장 빠름
- 캐시 효율 중요: 메모리 접근 패턴 좋음
- 제자리 정렬 필요: 추가 메모리 적음
- Quick Select: K번째 요소 찾기
사용 불가
- 최악의 경우 보장 필요: 병합 정렬 사용
- 안정 정렬 필요: 병합 정렬 사용
- 이미 정렬된 데이터: 랜덤화 없으면 O(n²)
결론: 실전에서 가장 많이 사용되는 정렬, 최적화 필수!