Algorithm|Javascript Algorithm

빠르지만 까칠한 천재

0

크리스는 합병 정렬(Merge Sort)의 속도(O(N log N))에 감탄했지만, 한 가지 불만이 남았다.

"속도는 빠른데, 배열을 자꾸 새로 만드니까(Slice) 메모리를 너무 많이 써. 메모리 공간은 안 쓰면서 속도는 그대로인 알고리즘은 없을까?"

그때 시니어 개발자가 말했다.

"그래서 실무에서는 퀵 정렬(Quick Sort)을 많이 쓰지. 이 녀석은 이름값 하는 천재야. 평균적으로 가장 빠르거든. 하지만 성격이 좀 까칠해서 다루기 조심해야 해."

도대체 어떤 점이 '까칠하다'는 걸까? 오늘은 정렬 알고리즘의 표준이자 가장 널리 쓰이는 퀵 정렬을 파헤쳐 본다.

1. 피벗(Pivot): "나를 기준으로 헤쳐 모여!"

합병 정렬이 "일단 반으로 쪼개고 나중에 정렬(합병)하자"는 주의라면,

퀵 정렬은 "기준(Pivot)을 하나 잡고, 걔보다 작은 건 왼쪽, 큰 건 오른쪽으로 몰아넣자"는 주의다.

동작 과정

배열 [5, 2, 1, 8, 4, 7, 6, 3]이 있다고 치자.

  • 피벗 선택: 아무거나 고른다. 보통 맨 앞의 요소(5)를 잡는다.
  • 파티션(Partition): 배열을 훑으면서 5보다 작은 숫자는 전부 5의 왼쪽으로, 큰 숫자는 오른쪽으로 보낸다.
  • 확정: 이제 5는 자기 자리를 찾았다. (정렬된 상태에서도 5는 저 위치에 있어야 한다.)
  • 재귀: 5를 기준으로 나뉜 왼쪽([3, 2, 1, 4])과 오른쪽([7, 6, 8]) 덩어리에 대해 똑같은 작업을 반복한다.
  • 2. 자바스크립트로 구현하기 (In-place)

    퀵 정렬의 진가는 추가 메모리를 거의 쓰지 않고(In-place), 원본 배열 안에서 위치만 샥샥 바꾸는 데 있다. 이를 위해 '피벗 도우미(Partition Helper)' 함수를 먼저 만든다.

    1) Partition 함수

    이 함수는 피벗보다 작은 값들을 왼쪽으로 몰아넣고, 피벗이 위치해야 할 최종 인덱스를 반환한다.

    javascript
    function pivot(arr, start = 0, end = arr.length - 1) {
      // 편의상 맨 앞 요소를 피벗으로 설정
      let pivotValue = arr[start];
      let swapIdx = start;
    
      for (let i = start + 1; i <= end; i++) {
        if (pivotValue > arr[i]) {
          swapIdx++;
          // 피벗보다 작은 값을 발견하면, swapIdx 위치의 값과 교환해서 앞으로 땡겨온다.
          [arr[swapIdx], arr[i]] = [arr[i], arr[swapIdx]];
        }
      }
    
      // 마지막으로 피벗을 자신의 진짜 위치(swapIdx)로 이동시킨다.
      [arr[start], arr[swapIdx]] = [arr[swapIdx], arr[start]];
      
      return swapIdx; // 피벗의 최종 인덱스 반환
    }

    2) QuickSort 함수

    이제 재귀를 이용해 전체 로직을 완성한다.

    javascript
    function quickSort(arr, left = 0, right = arr.length - 1) {
      if (left < right) {
        // 1. 피벗을 제자리에 두고, 그 위치(index)를 받아온다.
        let pivotIndex = pivot(arr, left, right);
    
        // 2. 피벗 기준 왼쪽 부분 정렬
        quickSort(arr, left, pivotIndex - 1);
    
        // 3. 피벗 기준 오른쪽 부분 정렬
        quickSort(arr, pivotIndex + 1, right);
      }
      return arr;
    }
    
    quickSort([4, 8, 2, 1, 5, 7, 6, 3]);

    3. 성능 분석: 왜 '까칠한 천재'인가?

    장점: 평균적인 속도왕 (O(N log N))

  • 합병 정렬처럼 O(N log N)의 속도를 낸다.
  • 하지만 실제로는 합병 정렬보다 조금 더 빠르다. 배열을 새로 생성(Slice)하는 오버헤드가 없고, 컴퓨터 하드웨어 구조상(캐시 적중률) 인접한 인덱스를 접근하는 방식이 유리하기 때문이다.
  • 공간 복잡도: O(log N). (재귀 호출 스택만 사용하고 배열을 복사하지 않는다.)
  • 단점: 최악의 시나리오 (O(N^2))

    이것이 퀵 정렬이 '까칠한' 이유다.

    만약 이미 정렬된 배열 [1, 2, 3, 4, 5]에 대해 맨 앞(1)을 피벗으로 잡으면 어떻게 될까?

  • 1은 가장 작으니까 이동할 게 없다.
  • 나머지 [2, 3, 4, 5]를 다시 정렬해야 한다.
  • 2를 피벗으로 잡는다. 이동할 게 없다.
  • ...
  • 배열이 반으로 쪼개지지 않고, 한 개씩만 줄어든다. 이러면 깊이가 N만큼 깊어져서 버블 정렬(O(N^2))과 똑같이 느려진다.

    해결책: 피벗을 맨 앞이 아니라 랜덤(Random)하게 고르거나, 중간값(Median)을 고르면 이 문제를 99% 해결할 수 있다.

    4. 합병 정렬 vs 퀵 정렬 비교

    특징합병 정렬 (Merge Sort)퀵 정렬 (Quick Sort)
    시간 복잡도항상 O(N log N)평균 O(N log N), 최악 O(N^2)
    공간 복잡도O(N) (배열 복사함)O(log N) (제자리 정렬)
    안정성Stable (순서 유지됨)Unstable (뒤섞임)
    실무 사용안정성이 중요할 때 (DB 등)속도가 최우선일 때 (일반 배열 등)

    핵심 정리

  • 원리: 피벗(Pivot)을 기준으로 작은 값과 큰 값을 분리(Partition)한다.
  • 속도: 평균적으로 가장 빠른 정렬 알고리즘이다.
  • 공간 효율: 추가적인 배열을 만들지 않고(In-place), 재귀 스택 메모리만 소량 사용한다.
  • 주의: 이미 정렬된 데이터에 대해서는 최악의 성능(O(N^2))을 낼 수 있으므로 피벗 선택에 신중해야 한다. 불안정 정렬이다.
  • 지금까지 배운 정렬들은 모두 데이터를 "비교(Comparison)"해서 정렬했다.

    그런데 트리(Tree) 구조를 이용하면, 최솟값이나 최댓값을 기가 막히게 빨리 뽑아낼 수 있다.

    이진 힙(Binary Heap)이라는 자료구조를 활용한 우아한 정렬법, 힙 정렬(Heap Sort)을 만나보자.


    🔗 참고 링크

  • VisuAlgo - Quick Sort
  • Quick Sort Implementation Details
  • 댓글 (0)

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