출시·고도화 중
정렬 안내서 · 2/6
이 장에서는 작은 배열을 손으로 따라가며 대표적인 정렬 알고리즘이 어떻게 움직이는지 봅니다. 단순한 정렬 세 가지를 짧게 훑은 뒤, 실무와 시험에 가장 많이 나오는 병합 정렬, 퀵 정렬, 힙 정렬, 그리고 비교하지 않는 계수 정렬을 차례로 추적합니다.
n - 1번으로 적지만 비교는 항상 약 n²/2번입니다.세 가지 모두 평균 O(n²)이지만, 삽입 정렬은 거의 정렬된 입력에서 O(n)에 가깝게 끝나고 작은 배열에서 매우 빠르기 때문에 실제 라이브러리 정렬 안에서 작은 구간을 처리하는 부품으로 쓰입니다. [6, 3, 8, 2, 5]를 삽입 정렬로 따라가 봅니다.
| 단계 | 끼워 넣는 값 | 결과 |
|---|---|---|
| i = 1 | 3 | [3, 6, 8, 2, 5] |
| i = 2 | 8 | [3, 6, 8, 2, 5] |
| i = 3 | 2 | [2, 3, 6, 8, 5] |
| i = 4 | 5 | [2, 3, 5, 6, 8] |
def insertion_sort(a):
for i in range(1, len(a)):
key = a[i]
j = i - 1
while j >= 0 and a[j] > key: # 더 큰 값만 오른쪽으로 민다 → 안정 정렬
a[j + 1] = a[j]
j -= 1
a[j + 1] = key
return a
print(insertion_sort([6, 3, 8, 2, 5])) # [2, 3, 5, 6, 8]병합 정렬은 분할 정복의 대표 예입니다. 배열을 반으로 나누어 각각 재귀로 정렬한 다음, 정렬된 두 절반을 하나로 합칩니다. 합치기(merge)는 두 목록의 맨 앞을 비교해 작은 쪽을 꺼내는 일을 반복하는 것이고, 길이에 비례하는 시간이 듭니다. [38, 27, 43, 3, 9, 82, 10]을 정렬해 봅니다.
| 단계 | 상태 |
|---|---|
| 나누기 | [38, 27, 43] / [3, 9, 82, 10] |
| 더 나누기 | [38] / [27, 43] , [3, 9] / [82, 10] |
| 작은 조각 합치기 | [27, 38, 43] , [3, 9, 10, 82] |
| 마지막 합치기 | [3, 9, 10, 27, 38, 43, 82] |
마지막 합치기에서는 두 목록의 맨 앞 27과 3을 비교해 3을 꺼내고, 이어서 27과 9, 27과 10, 27과 82를 비교하는 식으로 진행합니다. 값이 같을 때 왼쪽 것을 먼저 꺼내면 안정 정렬이 됩니다. 나누는 깊이는 log n, 각 깊이에서 합치는 일은 모두 합쳐 n이므로 전체는 O(n log n)입니다.
퀵 정렬은 기준값(pivot)을 하나 골라 그보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 보내는 분할(partition)을 한 뒤, 양쪽을 재귀로 정렬합니다. 병합 정렬과 달리 합치는 단계가 없고, 추가 배열 없이 제자리에서 동작합니다. 가장 단순한 로무토(Lomuto) 분할로 [5, 2, 8, 1, 9, 3]을 기준값 3(마지막 원소)으로 나누어 봅니다. i는 "작은 값이 들어갈 다음 자리"입니다.
| j | a[j] | 3보다 작은가 | 동작 | 배열 | i |
|---|---|---|---|---|---|
| 0 | 5 | 아니오 | 없음 | [5, 2, 8, 1, 9, 3] | 0 |
| 1 | 2 | 예 | a[0]과 교환 | [2, 5, 8, 1, 9, 3] | 1 |
| 2 | 8 | 아니오 | 없음 | [2, 5, 8, 1, 9, 3] | 1 |
| 3 | 1 | 예 | a[1]과 교환 | [2, 1, 8, 5, 9, 3] | 2 |
| 4 | 9 | 아니오 | 없음 | [2, 1, 8, 5, 9, 3] | 2 |
| 끝 | - | - | 기준값을 a[2]로 | [2, 1, 3, 5, 9, 8] | 2 |
이제 3은 최종 자리(인덱스 2)에 있고, 왼쪽 [2, 1]과 오른쪽 [5, 9, 8]을 각각 같은 방법으로 정렬하면 끝납니다.
def partition(a, lo, hi):
pivot = a[hi]
i = lo
for j in range(lo, hi):
if a[j] < pivot:
a[i], a[j] = a[j], a[i]
i += 1
a[i], a[hi] = a[hi], a[i]
return i
data = [5, 2, 8, 1, 9, 3]
print(partition(data, 0, len(data) - 1), data) # 2 [2, 1, 3, 5, 9, 8]기준값이 매번 한쪽 끝 값이 되면(예: 이미 정렬된 배열에서 마지막 원소를 고를 때) 분할이 n - 1과 0으로 갈라져 O(n²)이 됩니다. 그래서 실제 구현은 기준값을 무작위로 고르거나 세 값의 중앙값을 씁니다.
힙 정렬은 배열을 최대 힙(부모가 자식보다 크거나 같은 완전 이진 트리)으로 만든 뒤, 루트(최댓값)를 맨 뒤로 보내고 남은 부분을 다시 힙으로 고치는 일을 반복합니다. 인덱스 i의 자식은 2i + 1, 2i + 2입니다. [4, 10, 3, 5, 1]은 힙으로 만들면 [10, 5, 3, 4, 1]이 되고, 이후 과정은 다음과 같습니다.
| 꺼낸 값 | 교환 후 내리기(sift down) 결과 |
|---|---|
| 10 | [5, 4, 3, 1, 10] |
| 5 | [4, 1, 3, 5, 10] |
| 4 | [3, 1, 4, 5, 10] |
| 3 | [1, 3, 4, 5, 10] |
힙 정렬은 최악에도 O(n log n)이고 추가 메모리가 O(1)이지만, 안정 정렬이 아니고 메모리 접근이 흩어져 있어 실제로는 퀵 정렬보다 느린 경우가 많습니다.
값이 0부터 k - 1 사이의 정수라면 비교 없이 정렬할 수 있습니다. 각 값이 몇 번 나오는지 세고, 누적 합으로 각 값이 들어갈 마지막 자리를 구한 다음, 입력을 뒤에서부터 훑으며 제자리에 놓습니다. 뒤에서부터 놓기 때문에 안정 정렬이 되고, 이 성질 덕분에 기수 정렬의 부품으로 쓸 수 있습니다.
def counting_sort(a, k):
count = [0] * k
for x in a:
count[x] += 1
for v in range(1, k):
count[v] += count[v - 1] # 누적 합: 값 v가 끝나는 위치 + 1
out = [0] * len(a)
for x in reversed(a):
count[x] -= 1
out[count[x]] = x
return out
print(counting_sort([3, 1, 2, 3, 0, 1], 4)) # [0, 1, 1, 2, 3, 3]기수 정렬은 이 계수 정렬을 낮은 자릿수부터 높은 자릿수까지 차례로 적용합니다. 각 단계가 안정적이므로 앞 단계에서 만든 순서가 깨지지 않습니다.
O(n log n)인 제자리 정렬이고, 계수 정렬은 값을 세어 비교 없이 정렬합니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.