Algorithm|Javascript Algorithm

비교하지 않는 정렬의 마법

0

크리스는 지금까지 배운 정렬 알고리즘들(퀵, 힙, 합병)을 복습하다가 한 가지 공통점을 발견했다.

모두 "두 수를 비교(Compare)"해서 자리를 바꾼다는 점이다.

"A가 B보다 크니? 그럼 뒤로 가."

수학적으로 비교 기반 정렬(Comparison Sort)은 아무리 알고리즘을 최적화해도 O(N \log N)보다 빨라질 수 없다는 것이 증명되어 있다. 이것이 속도의 한계선이다.

그런데, 이 한계를 깨부수는 알고리즘이 있다.

비교를 전혀 하지 않고, 오직 숫자의 자릿수(Radix) 속성만을 이용해 데이터를 줄 세우는 마법. 기수 정렬(Radix Sort)이다.

1. 비교하지 않고 어떻게 정렬해?

기수 정렬의 아이디어는 우체국에서 편지를 분류하는 방식과 비슷하다.

우체부는 편지 두 통을 잡고 "어느 주소가 더 뒤쪽이지?"라고 비교하지 않는다. 대신 우편번호를 보고 해당 지역 바구니에 휙 던져 넣을 뿐이다.

기수 정렬도 똑같다.

  • 0번부터 9번까지 10개의 바구니(Bucket)를 준비한다.
  • 숫자의 일의 자리를 보고 해당 바구니에 넣는다.
  • 바구니 순서대로 다시 꺼낸다.
  • 이번엔 십의 자리를 보고 바구니에 넣는다.
  • (반복)
  • 이 과정을 가장 큰 자릿수만큼 반복하면, 신기하게도 숫자가 완벽하게 정렬된다.

    예시: [170, 45, 75, 90, 802, 24, 2, 66]

  • 일의 자리 기준:
  • 십의 자리 기준: (위 결과를 가지고 다시 분류)
  • 백의 자리 기준:
  • 누가 누구보다 큰지 단 한 번도 비교하지 않았다. 그저 자릿수에 맞춰 넣고 뺐을 뿐이다.

    2. 자바스크립트 구현을 위한 도구들

    기수 정렬을 구현하려면 3가지 도우미 함수가 필요하다.

  • getDigit(num, place): 숫자에서 특정 자릿수의 값을 가져온다.
  • digitCount(num): 숫자가 몇 자리인지 센다.
  • mostDigits(nums): 배열에서 가장 긴 자릿수를 찾는다. (몇 번 반복할지 결정)
  • javascript
    // 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)

    javascript
    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)

  • N: 데이터의 개수
  • K: 데이터의 최대 자릿수 (길이)
  • 해석: 만약 N이 엄청나게 많고 K가 작다면(예: 100만 개의 전화번호 정렬), O(NK)는 O(N log N)보다 훨씬 빠르다. 사실상 선형 시간(O(N))에 가깝다.
  • 공간 복잡도: O(N + K)

  • 바구니에 데이터를 저장해야 하므로 추가 메모리가 필요하다.
  • 치명적 단점: 정수(Integer) 전용

    기수 정렬은 자릿수가 딱 떨어지는 정수에 특화되어 있다. 소수점이나 문자열을 정렬하려면 코드를 매우 복잡하게 변형해야 한다. 범용성이 떨어진다.

    핵심 정리

  • 원리: 비교(Comparison)를 하지 않고, 자릿수(Radix)별로 버킷에 분류하여 정렬한다.
  • 속도: O(NK). 데이터가 정수이고 자릿수가 짧다면 퀵 정렬을 압도하는 속도를 낸다.
  • 한계: 데이터 타입에 제약이 있으며(주로 양의 정수), 추가 메모리 공간이 필요하다.
  • 교훈: "비교해야만 정렬할 수 있다"는 고정관념을 깨면, 특정 상황에서 압도적인 성능을 낼 수 있다.
  • 이제 우리는 O(N^2) 거북이부터 O(N log N) 토끼, 그리고 O(NK) 치타까지 모든 정렬 알고리즘을 섭렵했다.

    하지만 실무에서 우리가 매일 쓰는 건 결국 Array.prototype.sort()다.

    이 내장 함수는 과연 어떤 알고리즘을 쓰고 있을까? 그리고 숫자 정렬할 때 왜 [1, 10, 2]처럼 이상하게 정렬되는 걸까?


    🔗 참고 링크

  • VisuAlgo - Radix Sort
  • Radix Sort in JavaScript
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...