정렬 알고리즘을 외우는 것은 의미가 없다. 왜 퀵 정렬은 평균 O(n log n)이지만 최악이 O(n²)인지, 왜 머지 정렬은 안정적이고 퀵 정렬은 그렇지 않은지, 왜 파이썬은 내부적으로 Timsort를 쓰는지 — 원리를 이해하면 절대 잊지 않는다.

핵심 요약 (TL;DR)

알고리즘 평균 최악 공간 안정 특징
버블 정렬 O(n²) O(n²) O(1) 교육용, 거의 정렬된 배열에서 O(n)
선택 정렬 O(n²) O(n²) O(1) 교환 횟수 최소 (n-1번)
삽입 정렬 O(n²) O(n²) O(1) 거의 정렬된 배열에서 매우 빠름
퀵 정렬 O(n log n) O(n²) O(log n) 실무 최속, 캐시 효율적
머지 정렬 O(n log n) O(n log n) O(n) 최악 보장, Linked List에 최적
힙 정렬 O(n log n) O(n log n) O(1) in-place + 최악 보장
기수 정렬 O(nk) O(nk) O(n+k) 비교 없이 O(n) (정수/문자열)
Timsort O(n log n) O(n log n) O(n) Python/Java 내장, 실무 표준

안정 정렬(Stable Sort): 같은 키를 가진 요소들의 원래 순서를 유지.


정렬 알고리즘 분류

graph TD
    Sort["정렬 알고리즘"]

    Sort --> Comp["비교 기반\n하한: O(n log n)"]
    Sort --> NonComp["비교 불필요\nO(n) 가능"]

    Comp --> Simple["단순 정렬 O(n²)\n버블, 선택, 삽입"]
    Comp --> Fast["효율 정렬 O(n log n)\n퀵, 머지, 힙, Timsort"]

    NonComp --> Counting["계수 정렬\nO(n + k)"]
    NonComp --> Radix["기수 정렬\nO(nk)"]
    NonComp --> Bucket["버킷 정렬\nO(n) 평균"]

    Simple --> Bubble["버블 정렬\n✅ 안정\n인접 요소 교환"]
    Simple --> Selection["선택 정렬\n❌ 불안정\n최솟값 선택"]
    Simple --> Insertion["삽입 정렬\n✅ 안정\n적절한 위치 삽입"]

    Fast --> Quick["퀵 정렬\n❌ 불안정\n피벗 기반 분할"]
    Fast --> Merge["머지 정렬\n✅ 안정\n분할 후 병합"]
    Fast --> Tim["Timsort\n✅ 안정\n삽입 + 머지 하이브리드\nPython/Java 내장"]

    style Tim fill:#c8e6c9,stroke:#388e3c
    style Quick fill:#bbdefb,stroke:#1565c0
    style Merge fill:#fff9c4,stroke:#f9a825
    style Radix fill:#f3e5f5,stroke:#7b1fa2

퀵 정렬 (Quick Sort)

핵심 아이디어: 피벗(pivot)을 선택해 작은 것은 왼쪽, 큰 것은 오른쪽으로 분할. 재귀적으로 반복.

왜 빠른가? 평균적으로 피벗이 배열을 절반으로 나누면 깊이 O(log n), 각 레벨에서 O(n) 작업 → O(n log n). 캐시 지역성(locality)이 좋아 실제로는 머지 정렬보다 빠른 경우 많음.

왜 최악이 O(n²)인가? 피벗이 항상 최솟값/최댓값이면 분할이 1:n-1 → 깊이가 O(n).

# Python 퀵 정렬 — 3-way partition (중복값 처리 포함)
from typing import List
import random


def quick_sort(arr: List[int]) -> List[int]:
    """
    재귀 퀵 정렬 (함수형 스타일 — 이해용)
    시간: O(n log n) 평균, O(n²) 최악
    공간: O(log n) 재귀 스택
    안정성: ❌
    """
    if len(arr) <= 1:
        return arr

    pivot = arr[len(arr) // 2]  # 중간값 피벗 (최악 방지)
    left  = [x for x in arr if x < pivot]
    mid   = [x for x in arr if x == pivot]  # 중복값 처리
    right = [x for x in arr if x > pivot]

    return quick_sort(left) + mid + quick_sort(right)


def quick_sort_inplace(arr: List[int], lo: int = 0, hi: int = None) -> None:
    """
    in-place 퀵 정렬 — 공간 O(1) (스택 제외)
    Lomuto partition scheme
    """
    if hi is None:
        hi = len(arr) - 1

    if lo >= hi:
        return

    # 랜덤 피벗: 최악 케이스 방지
    pivot_idx = random.randint(lo, hi)
    arr[pivot_idx], arr[hi] = arr[hi], arr[pivot_idx]

    pivot = arr[hi]
    i = lo - 1  # 작은 영역의 경계

    for j in range(lo, hi):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]

    arr[i + 1], arr[hi] = arr[hi], arr[i + 1]
    partition_idx = i + 1

    quick_sort_inplace(arr, lo, partition_idx - 1)
    quick_sort_inplace(arr, partition_idx + 1, hi)


# 실행
arr = [64, 34, 25, 12, 22, 11, 90]
print(quick_sort(arr))                 # [11, 12, 22, 25, 34, 64, 90]
quick_sort_inplace(arr)
print(arr)                              # [11, 12, 22, 25, 34, 64, 90]
// Java 퀵 정렬 — in-place, 랜덤 피벗
import java.util.Random;

public class QuickSort {
    private static final Random RAND = new Random();

    public static void sort(int[] arr) {
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(int[] arr, int lo, int hi) {
        if (lo >= hi) return;

        int pivotIdx = partition(arr, lo, hi);
        sort(arr, lo, pivotIdx - 1);
        sort(arr, pivotIdx + 1, hi);
    }

    private static int partition(int[] arr, int lo, int hi) {
        // 랜덤 피벗으로 최악 케이스 방지
        int randIdx = lo + RAND.nextInt(hi - lo + 1);
        swap(arr, randIdx, hi);

        int pivot = arr[hi];
        int i = lo - 1;

        for (int j = lo; j < hi; j++) {
            if (arr[j] <= pivot) {
                i++;
                swap(arr, i, j);
            }
        }
        swap(arr, i + 1, hi);
        return i + 1;
    }

    private static void swap(int[] arr, int i, int j) {
        int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
    }

    public static void main(String[] args) {
        int[] arr = {64, 34, 25, 12, 22, 11, 90};
        sort(arr);
        System.out.println(java.util.Arrays.toString(arr));
        // [11, 12, 22, 25, 34, 64, 90]
    }
}

머지 정렬 (Merge Sort)

핵심 아이디어: 배열을 반으로 계속 나눈 후, 정렬된 두 부분을 병합. 분할 정복의 전형.

왜 안정적인가? 두 배열을 병합할 때 같은 값이면 왼쪽 배열의 값을 먼저 가져오므로 원래 순서 유지.

def merge_sort(arr: List[int]) -> List[int]:
    """
    머지 정렬
    시간: O(n log n) 항상 보장
    공간: O(n) 보조 배열
    안정성: ✅
    """
    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: List[int], right: List[int]) -> List[int]:
    """두 정렬된 배열 병합"""
    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


# ── 병합 정렬 응용: 역순 쌍 개수 (inversion count) ─────────
# LeetCode 315. Count of Smaller Numbers After Self
def count_inversions(arr: List[int]) -> int:
    """
    역전 쌍 개수: arr[i] > arr[j] 이고 i < j 인 쌍의 수
    머지 정렬로 O(n log n)에 해결
    """
    inversions = [0]

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

    def merge_and_count(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:
                # left[i] > right[j]: left[i:] 모두 right[j]보다 큼
                inversions[0] += len(left) - i
                result.append(right[j])
                j += 1
        result.extend(left[i:])
        result.extend(right[j:])
        return result

    merge_count(arr)
    return inversions[0]


arr = [5, 3, 1, 4, 2]
print(count_inversions(arr))  # 6 → (5,3),(5,1),(5,4),(5,2),(3,1),(3,2)

기수 정렬 (Radix Sort)

핵심 아이디어: 각 자릿수를 기준으로 계수 정렬을 반복. 비교 없이 정렬 가능.

왜 O(n)인가? k자리 정수를 정렬할 때 k번의 O(n) 계수 정렬 = O(nk). 정수 범위가 고정이면 k도 상수.

def radix_sort(arr: List[int]) -> List[int]:
    """
    기수 정렬 (LSD - Least Significant Digit 우선)
    시간: O(nk) — k: 최대 자릿수
    공간: O(n + 10) = O(n)
    안정성: ✅ (계수 정렬이 안정적이므로)
    """
    if not arr:
        return arr

    max_val = max(arr)
    exp = 1  # 현재 자릿수 (1, 10, 100, ...)

    while max_val // exp > 0:
        arr = counting_sort_by_digit(arr, exp)
        exp *= 10

    return arr


def counting_sort_by_digit(arr: List[int], exp: int) -> List[int]:
    """특정 자릿수 기준 계수 정렬 (안정 정렬)"""
    n = len(arr)
    output = [0] * n
    count = [0] * 10  # 0~9

    # 각 자릿수 빈도 계산
    for num in arr:
        digit = (num // exp) % 10
        count[digit] += 1

    # 누적합으로 위치 계산
    for i in range(1, 10):
        count[i] += count[i - 1]

    # 뒤에서부터 채워야 안정 정렬
    for i in range(n - 1, -1, -1):
        digit = (arr[i] // exp) % 10
        count[digit] -= 1
        output[count[digit]] = arr[i]

    return output


# 실행
arr = [170, 45, 75, 90, 802, 24, 2, 66]
print(radix_sort(arr))  # [2, 24, 45, 66, 75, 90, 170, 802]

버블 정렬과 최적화

def bubble_sort_optimized(arr: List[int]) -> List[int]:
    """
    최적화된 버블 정렬
    - 이미 정렬된 경우 O(n)으로 조기 종료
    시간: O(n²) 평균, O(n) 최선
    공간: O(1)
    안정성: ✅
    """
    arr = arr.copy()
    n = len(arr)

    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True

        if not swapped:
            break  # 교환 없으면 이미 정렬 완료 → O(n) 종료

    return arr

Deep Dive: Python의 Timsort

# Python의 list.sort()와 sorted()는 Timsort를 사용
# Tim Peters가 2002년 Python 2.3을 위해 개발
# 삽입 정렬 + 머지 정렬의 하이브리드

# Timsort 핵심:
# 1. 실제 데이터는 대부분 부분적으로 정렬되어 있음
# 2. 자연적으로 오름차순/내림차순인 "run"을 감지
# 3. 짧은 run은 삽입 정렬로 최소 크기(minrun, 보통 32~64)까지 확장
# 4. run들을 머지 정렬로 병합

# 실무에서 정렬 키 커스터마이징
from functools import cmp_to_key
from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: int
    stock: int

products = [
    Product("꿀A", 25000, 50),
    Product("꿀B", 25000, 30),  # 가격 같음 → 재고 많은 순으로 정렬
    Product("꿀C", 15000, 100),
]

# key 함수로 다중 키 정렬 (안정 정렬이라 순서 보장)
sorted_products = sorted(products, key=lambda p: (p.price, -p.stock))
# → 꿀C(15000), 꿀A(25000,50), 꿀B(25000,30)

# Java: Comparable, Comparator
# List.sort()도 Timsort 사용 (Java 8+)

복잡도 분석 상세

비교 기반 정렬의 이론적 하한:
  - n개 요소를 정렬하는 비교 기반 알고리즘은 Ω(n log n)이 최선
  - 증명: 결정 트리의 리프 노드 수 = n! (순열 수)
  - 트리 높이 = log₂(n!) ≈ n log n (스털링 근사)
  - 즉 어떤 비교 기반 알고리즘도 O(n log n)보다 빠를 수 없음

기수 정렬이 이를 깨는 이유:
  - 비교를 하지 않음 → 결정 트리 모델 밖
  - k가 작을 때 (예: 32비트 정수 k=10자리) O(n) 수렴
  - 단, k가 크면 O(nk)가 O(n log n)보다 느릴 수 있음

실무 선택 가이드

# 상황별 정렬 선택

1. 일반 정렬:
   Python → list.sort() / sorted()  (Timsort, 최적화됨)
   Java   → Arrays.sort() / Collections.sort()  (Timsort)

2. 거의 정렬된 배열:
   삽입 정렬 or Timsort (자동 감지)

3. 안정 정렬 필수 (같은 값의 순서 유지):
   머지 정렬, Timsort, 기수 정렬

4. 메모리 제약 (in-place 필요):
   퀵 정렬 (평균 O(n log n), O(log n) 스택)
   힙 정렬 (최악도 O(n log n), O(1) in-place)

5. 정수 범위가 제한된 대용량 데이터:
   기수 정렬 or 계수 정렬 (O(n))

6. Linked List 정렬:
   머지 정렬 (랜덤 접근 없이 정렬 가능)

관련 알고리즘 문제

문제 플랫폼 난이도 핵심
912. Sort an Array LeetCode Medium 정렬 직접 구현
148. Sort List LeetCode Medium Linked List 머지 정렬
315. Count of Smaller Numbers After Self LeetCode Hard 역전 쌍 (머지 정렬 응용)
75. Sort Colors LeetCode Medium 3-way 파티셔닝 (Dutch National Flag)
백준 2750 수 정렬하기 백준 Bronze 버블/삽입 정렬 기본
백준 2751 수 정렬하기 2 백준 Silver O(n log n) 필요
백준 10989 수 정렬하기 3 백준 Bronze 계수 정렬 (메모리 제한)
백준 1427 소트인사이드 백준 Bronze 자릿수 정렬

레퍼런스

영상

문서 & 기사


이 포스트는 HoneyByte CS Study 시리즈의 일부입니다.