[Daily morning study] 정렬 알고리즘 비교 (Quick Sort, Merge Sort, Heap Sort)

#daily morning study

Image


왜 정렬 알고리즘을 비교해야 하나

정렬은 알고리즘에서 가장 기본이 되는 연산 중 하나다. 언어 표준 라이브러리의 sort 함수가 내부적으로 어떤 알고리즘을 쓰는지, 왜 그 선택을 했는지를 이해하면 성능 문제를 마주쳤을 때 더 잘 대응할 수 있다.


Quick Sort

동작 원리

피벗(pivot)을 하나 고르고, 피벗보다 작은 값들은 왼쪽, 큰 값들은 오른쪽으로 분리(파티셔닝)한 뒤 재귀적으로 반복한다.

def quick_sort(arr, low, high):
    if low < high:
        pivot_idx = partition(arr, low, high)
        quick_sort(arr, low, pivot_idx - 1)
        quick_sort(arr, pivot_idx + 1, high)

def partition(arr, low, high):
    pivot = arr[high]
    i = low - 1
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

시간 복잡도

케이스복잡도
평균O(n log n)
최선O(n log n)
최악O(n²)

최악은 이미 정렬된 배열에서 피벗을 항상 최솟값이나 최댓값으로 고를 때 발생한다. 이를 피하기 위해 랜덤 피벗 선택이나 median-of-three 전략을 쓴다.

특징

  • In-place: 추가 메모리가 O(log n) 스택 공간뿐이다.
  • Not stable: 같은 값의 원소들이 원래 순서를 보장하지 않는다.
  • 캐시 친화적: 연속된 메모리를 순차 접근하므로 실제 성능이 이론보다 훨씬 좋다.
  • C의 qsort, Java의 Arrays.sort(int[]), Python의 timsort(실질적으로 merge sort 기반이지만 작은 파티션엔 insertion sort 사용)가 퀵 정렬 계열을 활용한다.

Merge Sort

동작 원리

분할 정복(divide and conquer) 방식. 배열을 절반씩 쪼개다가 원소가 1개가 되면, 올라오면서 두 정렬된 배열을 합병(merge)한다.

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result

시간 복잡도

케이스복잡도
평균O(n log n)
최선O(n log n)
최악O(n log n)

항상 O(n log n)을 보장한다.

특징

  • Stable: 같은 값의 원소 순서를 보장한다. 객체를 여러 키로 정렬할 때 중요하다.
  • Not in-place: 합병 과정에서 O(n) 추가 메모리가 필요하다.
  • 외부 정렬(External Sort)에 적합: 디스크에 저장된 대용량 데이터를 정렬할 때 병합 정렬 방식이 기본이다. 메모리에 다 올릴 수 없어도 청크 단위로 정렬 후 합칠 수 있다.
  • Java의 Collections.sort(), Python의 timsort는 merge sort 기반이다. (객체 정렬에서 stability가 필요하기 때문)

Heap Sort

동작 원리

최대 힙(Max Heap)을 구성한 뒤, 루트(최댓값)를 배열 끝과 교환하고 힙 크기를 줄여가며 반복한다.

def heap_sort(arr):
    n = len(arr)

    # Build max heap
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)

    # Extract elements one by one
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]
        heapify(arr, i, 0)

def heapify(arr, n, i):
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2

    if left < n and arr[left] > arr[largest]:
        largest = left
    if right < n and arr[right] > arr[largest]:
        largest = right

    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)

시간 복잡도

케이스복잡도
평균O(n log n)
최선O(n log n)
최악O(n log n)

특징

  • In-place: 추가 메모리가 O(1)이다.
  • Not stable: 힙 구성 과정에서 순서가 뒤바뀐다.
  • 캐시 비친화적: 힙 구조 특성상 메모리 접근 패턴이 불규칙하다. 이론 복잡도는 quick sort와 같지만 실제 성능은 보통 더 느리다.
  • 메모리가 극도로 제한된 환경이나 최악 케이스를 반드시 O(n log n)으로 보장해야 할 때 사용한다.

세 알고리즘 한눈에 비교

항목Quick SortMerge SortHeap Sort
평균 시간 복잡도O(n log n)O(n log n)O(n log n)
최악 시간 복잡도O(n²)O(n log n)O(n log n)
공간 복잡도O(log n)O(n)O(1)
StableNoYesNo
캐시 친화성높음중간낮음
실제 성능가장 빠름중간느린 편
주 사용처일반 목적객체 정렬, 외부 정렬우선순위 큐, 제한된 메모리

Introsort — 실전의 선택

대부분의 표준 라이브러리 정렬은 단일 알고리즘을 쓰지 않는다. C++의 std::sort는 Introsort를 사용한다.

  • 기본은 Quick Sort (캐시 친화적, 빠름)
  • 재귀 깊이가 2 * log(n)을 초과하면 → Heap Sort로 전환 (최악 케이스 O(n²) 방지)
  • 원소 수가 작으면(보통 16 이하) → Insertion Sort로 전환 (오버헤드가 없어서 소규모에서 빠름)

이렇게 세 알고리즘의 장점만 조합해서 평균도 빠르고 최악도 O(n log n)을 보장한다.


Timsort — Python, Java의 선택

Python과 Java(객체 배열)는 Timsort를 사용한다.

  • 실제 데이터는 부분적으로 정렬된 경우가 많다는 관찰에서 출발
  • 이미 정렬된 run(연속 구간)을 찾아내고
  • Merge Sort로 run들을 합병
  • 작은 run은 Insertion Sort로 확장

Timsort의 특징:

  • Stable (객체 정렬에 필요)
  • 이미 정렬된 데이터에서 O(n) 성능
  • 최악 O(n log n) 보장

언제 어떤 걸 써야 하나

  • 일반 목적: 언어의 기본 sort 함수를 쓰면 된다. 대부분 Introsort나 Timsort 기반이다.
  • stability가 필요: Merge Sort 또는 Timsort.
  • 메모리 제한이 심하고 최악 보장도 필요: Heap Sort.
  • 알고리즘 문제에서 커스텀 정렬이 필요: Quick Sort를 직접 구현하거나 언어 내장 sort에 comparator를 넘기면 된다.
  • 외부 정렬 (디스크 데이터): Merge Sort 계열.