비교하지 않는 정렬의 마법
크리스는 지금까지 배운 정렬 알고리즘들(퀵, 힙, 합병)을 복습하다가 한 가지 공통점을 발견했다.
모두 "두 수를 비교(Compare)"해서 자리를 바꾼다는 점이다.
"A가 B보다 크니? 그럼 뒤로 가."
수학적으로 비교 기반 정렬(Comparison Sort)은 아무리 알고리즘을 최적화해도 O(N \log N)보다 빨라질 수 없다는 것이 증명되어 있다. 이것이 속도의 한계선이다.
그런데, 이 한계를 깨부수는 알고리즘이 있다.
비교를 전혀 하지 않고, 오직 숫자의 자릿수(Radix) 속성만을 이용해 데이터를 줄 세우는 마법. 기수 정렬(Radix Sort)이다.
1. 비교하지 않고 어떻게 정렬해?
기수 정렬의 아이디어는 우체국에서 편지를 분류하는 방식과 비슷하다.
우체부는 편지 두 통을 잡고 "어느 주소가 더 뒤쪽이지?"라고 비교하지 않는다. 대신 우편번호를 보고 해당 지역 바구니에 휙 던져 넣을 뿐이다.
기수 정렬도 똑같다.
이 과정을 가장 큰 자릿수만큼 반복하면, 신기하게도 숫자가 완벽하게 정렬된다.
예시: [170, 45, 75, 90, 802, 24, 2, 66]
누가 누구보다 큰지 단 한 번도 비교하지 않았다. 그저 자릿수에 맞춰 넣고 뺐을 뿐이다.
2. 자바스크립트 구현을 위한 도구들
기수 정렬을 구현하려면 3가지 도우미 함수가 필요하다.
// 1. 자릿수 가져오기 (수학적 트릭 사용)
function getDigit(num, i) {
return Math.floor(Math.abs(num) / Math.pow(10, i)) % 10;
}
// 2. 자릿수 세기
function digitCount(num) {
if (num === 0) return 1;
return Math.floor(Math.log10(Math.abs(num))) + 1;
}
// 3. 최대 자릿수 찾기
function mostDigits(nums) {
let maxDigits = 0;
for (let i = 0; i < nums.length; i++) {
maxDigits = Math.max(maxDigits, digitCount(nums[i]));
}
return maxDigits;
}3. 기수 정렬 구현 (Radix Sort)
function radixSort(nums) {
let maxDigitCount = mostDigits(nums); // 가장 긴 녀석이 4자리면 4번 돔
for (let k = 0; k < maxDigitCount; k++) {
// 10개의 빈 바구니 생성 (0 ~ 9)
let digitBuckets = Array.from({ length: 10 }, () => []);
for (let i = 0; i < nums.length; i++) {
// k번째 자릿수를 알아내서 해당 바구니에 넣는다 (Push)
let digit = getDigit(nums[i], k);
digitBuckets[digit].push(nums[i]);
}
// 바구니에 담긴 숫자들을 순서대로 다시 합친다 (Concat)
// [].concat(...digitBuckets)와 같음
nums = [].concat(...digitBuckets);
}
return nums;
}
radixSort([23, 345, 5467, 12, 2345, 9852]);4. 성능 분석: 퀵 정렬보다 빠를까?
시간 복잡도: O(NK)
공간 복잡도: O(N + K)
치명적 단점: 정수(Integer) 전용
기수 정렬은 자릿수가 딱 떨어지는 정수에 특화되어 있다. 소수점이나 문자열을 정렬하려면 코드를 매우 복잡하게 변형해야 한다. 범용성이 떨어진다.
핵심 정리
이제 우리는 O(N^2) 거북이부터 O(N log N) 토끼, 그리고 O(NK) 치타까지 모든 정렬 알고리즘을 섭렵했다.
하지만 실무에서 우리가 매일 쓰는 건 결국 Array.prototype.sort()다.
이 내장 함수는 과연 어떤 알고리즘을 쓰고 있을까? 그리고 숫자 정렬할 때 왜 [1, 10, 2]처럼 이상하게 정렬되는 걸까?