왜 정렬을 배우는가
내장 sort를 쓰는 법은 이미 익혔습니다. 이번에는 그 내부가 어떻게 동작하는지
들여다봅니다. 정렬 알고리즘의 원리를 이해하면 복잡도 감각이 단단해지고, 안정
정렬·부분 정렬 같은 변형 상황에서 올바른 선택을 할 수 있습니다.
정렬은 비교 기반이라면 이론적으로 \(O(N \log N)\)보다 빠를 수 없다는 하한이
있습니다. 그 한계에 도달하는 대표 알고리즘이 병합 정렬과 퀵 정렬입니다.
1. \(O(N^2)\) 정렬들 — 단순하지만 느림
선택 정렬: 남은 부분에서 최솟값을 골라 맨 앞과 바꿉니다.
for (int i = 0; i < n; i++) {
int m = i;
for (int j = i + 1; j < n; j++)
if (a[j] < a[m]) m = j;
swap(a[i], a[m]);
}
삽입 정렬: 카드를 손에 정렬해 들 듯, 앞쪽 정렬된 부분에 하나씩 끼워 넣습니다.
이미 거의 정렬된 데이터에서는 \(O(N)\)에 가깝게 빨라지는 장점이 있습니다.
for (int i = 1; i < n; i++) {
int key = a[i], j = i - 1;
while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; j--; }
a[j + 1] = key;
}
이들은 모두 \(O(N^2)\)라 \(N \le 1000\) 정도까지만 실용적입니다.
2. 병합 정렬 — 분할 정복의 정석
배열을 반으로 쪼개 각각 정렬한 뒤, 정렬된 두 줄을 한 줄로 합치는(merge)
방식입니다. 합치는 데 \(O(N)\), 쪼개는 깊이가 \(\log N\)이므로 전체 \(O(N \log N)\).
병합 정렬의 두 가지 큰 장점:
- 안정 정렬(stable) — 값이 같으면 원래 순서를 유지합니다.
- 최악에도 보장된 \(O(N \log N)\) — 입력 모양에 흔들리지 않습니다.
분할 정복으로 합치는 과정에서 "왼쪽 원소보다 작은 오른쪽 원소의 개수"를 세면
역순쌍(inversion) 카운팅도 공짜로 됩니다.
3. 퀵 정렬 — 평균적으로 가장 빠름
피벗(pivot) 을 하나 골라 그보다 작은 것은 왼쪽, 큰 것은 오른쪽으로 분할한 뒤
양쪽을 재귀적으로 정렬합니다. 평균 \(O(N \log N)\)이며 상수가 작아 실측이 빠릅니다.
다만 피벗을 운 나쁘게 고르면(이미 정렬된 배열에서 끝값을 피벗으로) 최악
\(O(N^2)\)로 떨어집니다. 그래서 라이브러리는 무작위 피벗이나 중앙값 전략을 씁니다.
4. 비교: 무엇을 언제 쓰나
| 알고리즘 | 평균 | 최악 | 안정성 | 비고 |
|---|---|---|---|---|
| 선택/삽입 | \(O(N^2)\) | \(O(N^2)\) | 삽입은 안정 | 작은 N |
| 병합 | \(O(N \log N)\) | \(O(N \log N)\) | 안정 | 추가 메모리 \(O(N)\) |
| 퀵 | \(O(N \log N)\) | \(O(N^2)\) | 불안정 | 제자리, 빠름 |
실전에서는 거의 항상 내장 sort(보통 퀵+힙+삽입 혼합인 introsort)나
stable_sort(병합 기반)를 씁니다. 원리를 아는 이유는 직접 짜기 위해서가 아니라,
복잡도와 안정성을 정확히 판단하기 위해서입니다.
정리
비교 정렬의 하한은 \(O(N \log N)\)입니다. 단순 정렬은 작은 \(N\)에서만, 큰 \(N\)에서는
\(N \log N\) 정렬을 씁니다. 안정성이 필요하면 병합 계열을 떠올리세요.