코딩 · 알고리즘
퀵 정렬 알고리즘 원리, 코드, 시간 복잡도
핵심 답변
퀵 정렬은 분할 정복 정렬 알고리즘입니다. 한 요소를 피벗으로 고르고, 피벗보다 작은 요소는 앞에, 큰 요소는 뒤에 오도록 배열을 재배치(분할)한 다음, 양쪽을 재귀적으로 정렬합니다. 평균 시간은 O(n log n)이고 O(log n) 스택 공간만 쓰는 제자리 정렬이지만, 피벗이 계속 나쁘게 선택되면 O(n²)로 떨어집니다. 안정 정렬은 아닙니다. 무작위 피벗이나 세 값의 중앙값(median-of-three) 피벗을 쓰면 실제로 가장 빠른 범용 정렬 중 하나입니다.
줄 정렬 열기쉬운 말로 설명하는 원리
- 피벗 요소를 고릅니다. 예를 들어 마지막 요소나 무작위 요소입니다.
- 분할(Partition): 피벗보다 작은 요소는 모두 왼쪽으로, 큰 요소는 모두 오른쪽으로 옮깁니다. 이제 피벗은 정렬된 최종 위치에 있습니다.
- 피벗의 왼쪽 부분과 오른쪽 부분을 각각 재귀적으로 정렬합니다. 이후에 합칠 것은 없습니다.
병합 정렬은 나누기는 쉽고 합치기가 어렵습니다. 퀵 정렬은 반대로 분할에서 일을 다 하고, 같은 배열 안에서 이미 올바른 위치에 있으므로 두 절반을 합치는 비용이 없습니다.
예제: Lomuto 분할 한 번
[10, 80, 30, 90, 40, 50, 70]을 마지막 요소 70을 피벗으로 분할해 봅시다. 인덱스 i는 피벗보다 작은 요소 영역의 끝을 나타내고, j는 왼쪽에서 오른쪽으로 훑습니다.
| j | items[j] | 70보다 작은가? | 동작 | 이후 배열 |
|---|---|---|---|---|
| 0 | 10 | 예 | i = 0, 0↔0 교환 | [10, 80, 30, 90, 40, 50, 70] |
| 1 | 80 | 아니요 | 없음 | [10, 80, 30, 90, 40, 50, 70] |
| 2 | 30 | 예 | i = 1, 1↔2 교환 | [10, 30, 80, 90, 40, 50, 70] |
| 3 | 90 | 아니요 | 없음 | [10, 30, 80, 90, 40, 50, 70] |
| 4 | 40 | 예 | i = 2, 2↔4 교환 | [10, 30, 40, 90, 80, 50, 70] |
| 5 | 50 | 예 | i = 3, 3↔5 교환 | [10, 30, 40, 50, 80, 90, 70] |
| 끝 | 피벗을 i + 1 = 4 위치에 놓음 | [10, 30, 40, 50, 70, 90, 80] |
70은 이제 인덱스 4, 즉 최종 위치에 있습니다. 이어서 퀵 정렬은 [10, 30, 40, 50]과 [90, 80]을 따로 정렬합니다.
파이썬 구현
import random
def partition(a, lo, hi):
"""Lomuto partition of a[lo..hi] around a random pivot."""
r = random.randint(lo, hi)
a[r], a[hi] = a[hi], a[r] # move the pivot to the end
pivot = a[hi]
i = lo - 1
for j in range(lo, hi):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[hi] = a[hi], a[i + 1]
return i + 1
def quicksort(a, lo=0, hi=None):
"""Sort list a in place."""
if hi is None:
hi = len(a) - 1
while lo < hi:
p = partition(a, lo, hi)
if p - lo < hi - p: # recurse on the smaller side
quicksort(a, lo, p - 1)
lo = p + 1
else:
quicksort(a, p + 1, hi)
hi = p - 1
return a
print(quicksort([10, 80, 30, 90, 40, 50, 70])) # [10, 30, 40, 50, 70, 80, 90]
print(quicksort([3, 3, 3, 1])) # [1, 3, 3, 3]작은 쪽으로 재귀하고 큰 쪽은 반복문으로 처리하면 최악의 경우에도 스택 깊이가 O(log n)으로 유지됩니다. 원리를 설명할 때는 짧은 비(非)제자리 버전이 인기 있지만, 레벨마다 새 리스트를 할당합니다.
def quicksort_simple(items):
if len(items) <= 1:
return items
pivot = items[len(items) // 2]
less = [x for x in items if x < pivot]
equal = [x for x in items if x == pivot]
greater = [x for x in items if x > pivot]
return quicksort_simple(less) + equal + quicksort_simple(greater)
print(quicksort_simple([5, 2, 9, 2, 7])) # [2, 2, 5, 7, 9]시간 복잡도와 공간 복잡도
| 경우 | 시간 | 이유 |
|---|---|---|
| 최선 | O(n log n) | 모든 피벗이 범위를 정확히 반으로 나눔 |
| 평균 | O(n log n) | 무작위 피벗은 충분히 균형 잡힌 분할을 만들며, 비교는 약 1.39 n log₂ n번 |
| 최악 | O(n²) | 모든 피벗이 가장 작거나 가장 큰 요소 |
| 추가 공간 | O(log n) | 작은 쪽으로 재귀할 때의 재귀 스택 |
고전적인 최악의 경우는 이미 정렬되었거나 역순인 입력에 첫 번째 또는 마지막 요소를 피벗으로 고정해 쓰는 것입니다. 분할할 때마다 피벗 하나만 빠지므로 O(n) 작업이 n레벨 쌓입니다. 무작위 피벗을 쓰면 어떤 입력에서도 이런 일이 일어날 가능성은 극히 낮습니다.
퀵 정렬과 다른 정렬 비교
| 알고리즘 | 최선 | 평균 | 최악 | 추가 공간 | 안정 정렬 |
|---|---|---|---|---|---|
| 퀵 정렬 | O(n log n) | O(n log n) | O(n²) | O(log n) | 아니요 |
| 병합 정렬 | O(n log n) | O(n log n) | O(n log n) | O(n) | 예 |
| 힙 정렬 | O(n log n) | O(n log n) | O(n log n) | O(1) | 아니요 |
| 삽입 정렬 | O(n) | O(n²) | O(n²) | O(1) | 예 |
| 버블 정렬 | O(n) | O(n²) | O(n²) | O(1) | 예 |
| 선택 정렬 | O(n²) | O(n²) | O(n²) | O(1) | 아니요 |
Timsort (파이썬 sorted) | O(n) | O(n log n) | O(n log n) | O(n) | 예 |
퀵 정렬은 안정 정렬이 아닙니다. 분할 중의 교환이 같은 값의 요소를 지나쳐 버릴 수 있기 때문입니다. 메모리 안의 배열에서는 보통 병합 정렬과 힙 정렬보다 빠릅니다. 안쪽 반복문이 단순하고, 제자리에서 동작하며, 메모리를 대체로 순차적으로 접근하기 때문입니다. 많은 C++ 표준 라이브러리 std::sort 구현이 쓰는 introsort는 퀵 정렬로 시작해 재귀가 너무 깊어지면 힙 정렬로 전환하며, 이로써 O(n log n)이 보장됩니다.
피벗 선택, 변형, 흔한 실수
- 피벗 선택: 첫 번째 또는 마지막 요소(간단하지만 정렬된 입력에서 O(n²)), 무작위 요소, 세 값(처음, 가운데, 끝)의 중앙값.
- Hoare 분할: 두 인덱스가 서로를 향해 움직이며 잘못된 쪽에 있는 쌍을 교환합니다. 평균적으로 Lomuto보다 교환이 적습니다.
- 3-way 분할(네덜란드 국기 문제): 작은 쪽, 같은 쪽, 큰 쪽으로 나눕니다. 중복 키가 많은 배열도 빠르게 처리합니다. 일반 Lomuto는 모든 키가 같으면 O(n²)로 떨어집니다.
- 듀얼 피벗 퀵 정렬은 피벗 두 개와 세 부분을 사용하며, Java가 기본 자료형 배열을 정렬할 때 씁니다.
- 퀵셀렉트(Quickselect)는 분할을 한 번 하고 한쪽만 재귀하여 k번째로 작은 요소를 평균 O(n) 시간에 찾습니다.
퀵 정렬을 쓰는 경우
안정성이 필요 없는 메모리 내 배열 정렬에서 퀵 정렬은 좋은 기본 선택입니다. 파이썬에서는 대신 sorted()나 list.sort()를 쓰세요. 이들은 부분적으로 정렬된 데이터에서 빠르고 안정적인 Timsort를 사용합니다. 퀵 정렬 구현은 매우 흔한 면접 과제이며, 최악의 경우, 피벗 선택, 안정성, 또는 퀵셀렉트로 k번째로 큰 요소 찾기에 대한 질문이 이어지는 경우가 많습니다.
자주 묻는 질문
퀵 정렬은 안정 정렬인가요?
아니요. 분할은 멀리 떨어진 요소를 교환하므로 같은 값의 요소의 상대적 순서가 바뀔 수 있습니다. 안정 버전도 있지만 추가 메모리가 필요합니다.
퀵 정렬의 최악의 경우는 무엇인가요?
모든 피벗이 해당 범위의 최솟값이나 최댓값일 때 O(n²)입니다. 예를 들어 이미 정렬된 입력에서 마지막 요소를 피벗으로 쓰는 경우입니다. 무작위 피벗이나 세 값의 중앙값 피벗을 쓰면 실제로는 피할 수 있습니다.
퀵 정렬이 실제로 빠른 이유는 무엇인가요?
제자리에서 정렬하고, 안쪽 반복문이 단순한 비교와 교환이며, 메모리를 순차적으로 훑어 CPU 캐시를 잘 활용하기 때문입니다. 평균 상수 계수가 힙 정렬보다 작고, 병합 정렬처럼 버퍼가 필요하지도 않습니다.
퀵 정렬과 병합 정렬은 무엇이 다른가요?
퀵 정렬은 먼저 분할하고 병합이 필요 없으며, 병합 정렬은 먼저 나누고 병합 단계에서 일을 합니다. 퀵 정렬은 제자리 정렬이지만 최악의 경우가 O(n²)이고 안정 정렬이 아닙니다. 병합 정렬은 안정 정렬이고 항상 O(n log n)이지만 O(n) 추가 공간이 필요합니다.
퀵 정렬은 메모리를 얼마나 쓰나요?
제자리에서 정렬합니다. 재귀 스택은 평균적으로 O(log n)이며, 항상 작은 쪽을 먼저 재귀하면 최악의 경우에도 O(log n)입니다.