Counting Sort와 Radix Sort — 비교 없이 정렬하는 법
비교 기반 정렬은 아무리 잘해도 O(n log n)을 넘을 수 없습니다. 하지만 비교 없이 정렬하면? 그 벽을 넘을 수 있습니다.
비교 기반 정렬의 한계
모든 비교 기반 정렬 알고리즘의 시간 복잡도 하한은 Ω(n log n) 입니다.
왜 O(n log n)인가?
n개 원소의 가능한 순서(순열)는 n!가지입니다. 비교 연산의 결과는 이진(두 가지)이므로, 이를 이진 트리(결정 트리)로 표현하면 리프가 n!개 필요합니다.
이진 트리의 높이 ≥ log₂(n!) = Θ(n log n)
따라서 비교만으로는 O(n log n)보다 빠를 수 없습니다.
Counting Sort
값의 범위가 제한적일 때, ** 각 값의 출현 횟수를 세어** 정렬합니다.
동작 원리
원본: [4, 2, 2, 8, 3, 3, 1]
값 범위: 0~8
1. 카운트: count = [0, 1, 2, 2, 1, 0, 0, 0, 1]
0 1 2 3 4 5 6 7 8
2. 누적합: count = [0, 1, 3, 5, 6, 6, 6, 6, 7]
3. 뒤에서부터 배치 (안정 정렬을 위해):
arr[6]=1 → count[1]=1 → result[0]=1, count[1]=0
arr[5]=3 → count[3]=5 → result[4]=3, count[3]=4
...
구현
public static int[] countingSort(int[] arr, int maxVal) {
int[] count = new int[maxVal + 1];
int[] result = new int[arr.length];
// 1. 각 값의 출현 횟수 세기
for (int num : arr) {
count[num]++;
}
// 2. 누적합 계산
for (int i = 1; i <= maxVal; i++) {
count[i] += count[i - 1];
}
// 3. 뒤에서부터 결과 배열에 배치 (안정 정렬)
for (int i = arr.length - 1; i >= 0; i--) {
result[count[arr[i]] - 1] = arr[i];
count[arr[i]]--;
}
return result;
}
시간/공간 복잡도
- 시간: O(n + k) (n: 원소 수, k: 값 범위)
- 공간: O(n + k)
제한 사항
- 값의 범위 k가 매우 크면 메모리 낭비 (예: 0~10억)
- 실수(float/double)에는 직접 적용 불가
- 음수를 처리하려면 오프셋 추가 필요
Radix Sort
각 ** 자릿수별로** 정렬을 반복하는 방식입니다. 각 자릿수 정렬에 Counting Sort를 사용합니다.
LSD (Least Significant Digit) 방식
낮은 자릿수부터 정렬합니다. 안정 정렬을 사용해야 이전 정렬 결과가 유지됩니다.
원본: [329, 457, 657, 839, 436, 720, 355]
1의 자리 정렬: [720, 355, 436, 457, 657, 329, 839]
10의 자리 정렬: [720, 329, 436, 839, 355, 457, 657]
100의 자리 정렬: [329, 355, 436, 457, 657, 720, 839]
구현
public static void radixSort(int[] arr) {
int max = Arrays.stream(arr).max().getAsInt();
// 각 자릿수에 대해 Counting Sort 수행
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(arr, exp);
}
}
private static void countingSortByDigit(int[] arr, int exp) {
int n = arr.length;
int[] output = new int[n];
int[] count = new int[10]; // 0~9
// 해당 자릿수 기준으로 카운트
for (int num : arr) {
int digit = (num / exp) % 10;
count[digit]++;
}
// 누적합
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 뒤에서부터 배치 (안정 정렬)
for (int i = n - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
System.arraycopy(output, 0, arr, 0, n);
}
시간/공간 복잡도
- 시간: O(d × (n + k)) (d: 최대 자릿수, k: 자릿수별 값 범위(보통 10))
- 공간: O(n + k)
d가 상수이면 O(n)에 가깝습니다.
Bucket Sort
값의 분포가 균등할 때, ** 버킷으로 분류 **한 후 각 버킷을 정렬합니다.
public static void bucketSort(float[] arr) {
int n = arr.length;
List<List<Float>> buckets = new ArrayList<>();
for (int i = 0; i < n; i++) {
buckets.add(new ArrayList<>());
}
// 값을 버킷에 분배 (0 이상 1 미만의 실수 가정)
for (float num : arr) {
int bucketIdx = (int) (num * n);
buckets.get(bucketIdx).add(num);
}
// 각 버킷을 정렬
for (List<Float> bucket : buckets) {
Collections.sort(bucket);
}
// 버킷을 합치기
int idx = 0;
for (List<Float> bucket : buckets) {
for (float num : bucket) {
arr[idx++] = num;
}
}
}
- 균등 분포: O(n) (각 버킷에 상수 개의 원소)
- 최악: O(n²) (한 버킷에 모든 원소가 몰림)
세 정렬의 비교
| 알고리즘 | 시간 복잡도 | 공간 | 안정성 | 적합한 경우 |
|---|---|---|---|---|
| Counting Sort | O(n + k) | O(n + k) | 안정 | 값 범위가 작은 정수 |
| Radix Sort | O(d(n + k)) | O(n + k) | 안정 | 자릿수가 고정된 정수 |
| Bucket Sort | O(n) 평균 | O(n + k) | 안정 | 균등 분포 실수 |
문자열 정렬에서의 Radix Sort
문자열을 사전순으로 정렬할 때도 Radix Sort를 사용할 수 있습니다. MSD(Most Significant Digit) 방식을 사용합니다.
// MSD Radix Sort: 높은 자릿수(첫 문자)부터 정렬
// 같은 첫 문자를 가진 문자열끼리 그룹을 만들고
// 각 그룹 내에서 두 번째 문자로 재귀적 정렬
백엔드/실무에서의 연결
데이터베이스 인덱스 정렬
DB의 B-Tree 인덱스 구축 시, 대량의 키를 정렬해야 합니다. 키의 분포 특성에 따라 Radix Sort가 사용되기도 합니다.
IP 주소 정렬
IP 주소를 정렬할 때, 각 옥텟(0~255)을 자릿수로 보면 Radix Sort가 효과적입니다.
// IP를 정수로 변환 후 Radix Sort
// 192.168.1.1 → 3232235777
분산 정렬의 셔플 단계
MapReduce의 셔플(Shuffle) 단계에서 키를 파티셔닝할 때 Bucket Sort의 아이디어가 사용됩니다. 해시값 범위를 버킷으로 나누어 각 리듀서에 분배합니다.
로그 분석에서의 활용
로그 레벨(ERROR=0, WARN=1, INFO=2, DEBUG=3)로 분류할 때 Counting Sort가 자연스럽습니다. 레벨이 4가지뿐이므로 O(n)에 정렬됩니다.
히스토그램 생성
사용자의 나이별 분포, 요청 응답 시간별 분포 등을 계산하는 것은 Counting Sort의 1단계(카운트)와 동일합니다.
주의할 점
값의 범위가 클 때 Counting Sort를 적용하는 실수
Counting Sort는 값의 범위(k)만큼 배열을 생성합니다. k가 n보다 훨씬 크면 메모리 낭비가 심하고 오히려 비교 기반 정렬보다 느려집니다. k = O(n)일 때만 효율적입니다.
Radix Sort에서 Most Significant Digit(MSD)과 Least Significant Digit(LSD)을 혼동
LSD Radix Sort는 가장 낮은 자릿수부터 정렬하며, 안정 정렬이 전제입니다. MSD는 가장 높은 자릿수부터 정렬하며, 재귀적으로 처리해야 합니다.
정리
기억할 포인트:
- ** 비교 기반 정렬의 하한은 O(n log n)**입니다. Counting, Radix, Bucket Sort는 비교를 하지 않으므로 이 한계를 넘을 수 있습니다.
- Counting Sort는 ** 값 범위 k가 작을 때** 적합합니다 (O(n + k)).
- Radix Sort는 ** 자릿수 d가 고정 **일 때 사실상 O(n)입니다.
- Bucket Sort는 ** 균등 분포 **일 때 O(n)이지만, 최악은 O(n²)입니다.
- 실무에서는 IP 정렬, 로그 분류, 히스토그램 등에서 이 알고리즘들의 아이디어가 활용됩니다.