정렬 비교
자료구조 토픽(ds-09)에서는 정렬 과정 자체를 막대그래프 애니메이션으로 살펴봤다면, 여기서는 병합 정렬 · 퀵 정렬 · 힙 정렬이 같은 배열을 정렬하는 데 실제로 몇 번 비교하는지를 숫자로 직접 비교합니다. 세 알고리즘 모두 평균적으로 O(n log n)이지만, 구체적인 비교 횟수와 동작 방식은 서로 다릅니다.
핵심 개념
병합 정렬 — 분할 정복
배열을 절반으로 계속 나눈 뒤(분할), 정렬된 두 부분을 다시 합칩니다(병합). 항상 절반씩 나누기 때문에 최선·평균·최악 모두 O(n log n)으로 일정합니다.
핵심 개념
퀵 정렬 — 피벗 기준 분할
기준값(피벗)을 하나 정해 그보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 나눕니다. 평균 O(n log n)이지만, 피벗이 계속 한쪽으로 치우치면 최악의 경우 O(n²)까지 느려질 수 있습니다.
핵심 개념
힙 정렬 — 힙 자료구조 활용
배열을 최대 힙(ds-06 힙 참고)으로 만든 뒤, 가장 큰 값을 하나씩 꺼내 정렬 영역으로 옮깁니다. 최악의 경우에도 O(n log n)입니다.
핵심 개념
비교 횟수는 왜 다른가
같은 O(n log n)이라도 상수 배와 실제 비교 횟수는 알고리즘 구현 방식, 데이터 배치, 피벗 선택 방법에 따라 달라집니다. 이 토픽에서는 원소 8개짜리 동일한 배열에 대해 세 알고리즘의 실제 비교 횟수를 직접 셉니다.
핵심 개념
제자리 정렬 여부
퀵 정렬과 힙 정렬은 추가 배열 없이 원본 배열 안에서 교환만으로 정렬하는 제자리(in-place) 정렬입니다. 병합 정렬은 병합 과정에서 임시 배열이 필요해 추가 공간 O(n)이 듭니다.
핵심 개념
안정 정렬 여부
일반적인 병합 정렬은 같은 값의 상대적 순서를 유지하는 안정 정렬입니다. 일반적인 퀵 정렬과 힙 정렬은 교환 과정에서 같은 값의 순서가 바뀔 수 있는 불안정 정렬입니다.
세 정렬의 유래
퀵 정렬은 토니 호어(C. A. R. Hoare)가 만들어 1961년 CACM의 알고리즘 64(Quicksort)로 발표한 것으로 알려져 있고, 힙 정렬은 1964년 J. W. J. 윌리엄스(Williams)가 CACM에 발표한 알고리즘 232(Heapsort)로 알려져 있습니다. 병합 정렬은 1945년 폰 노이만이 쓴 프로그램이 가장 오래된 사례로 알려져 있습니다. 모두 입력의 원소끼리 비교하는 정렬이라서 최악의 경우 O(n log n)보다 적은 비교로는 정렬할 수 없다는 하한(Ω(n log n))이 같이 적용됩니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
안정 정렬이 왜 필요한가요?
같은 키를 가진 원소의 원래 순서를 유지해야 하는 경우에 필요합니다. 예를 들어 이름순으로 정렬한 목록을 학년으로 다시 정렬할 때 안정 정렬이면 같은 학년 안에서 이름순이 유지됩니다.
면접 질문
퀵 정렬의 최악을 피하는 방법은 무엇인가요?
피벗을 무작위로 고르거나 첫·중간·끝 세 값의 중간값으로 고르면 정렬된 입력에서의 최악을 피할 수 있습니다. 이미 정렬된 입력에 항상 끝 값을 피벗으로 쓰는 구현이 최악의 전형입니다. 다만 중간값 방식만으로 최악 O(n²)이 없어지는 것은 아닙니다.
면접 질문
병합 정렬과 힙 정렬 중 언제 무엇을 쓰나요?
병합 정렬은 안정적이고 항상 O(n log n)이지만 임시 공간 O(n)이 듭니다. 힙 정렬은 제자리로 최악에도 O(n log n)이지만 안정 정렬이 아니고 메모리 접근이 흩어집니다.
면접 질문
비교 정렬은 O(n log n)보다 빨라질 수 없나요?
원소끼리 비교만으로 정렬하는 알고리즘은 최악의 경우 Ω(n log n)이 필요합니다. 계수 정렬처럼 비교를 쓰지 않는 정렬은 입력의 범위 같은 조건이 있으면 이보다 빠를 수 있습니다.
실제 시스템에서는
Arrays.sort는 기본형 배열에는 듀얼 피벗 퀵 정렬을, 객체 배열에는 안정적인 병합 계열 정렬을 씁니다. 안정성이 필요한 객체 정렬에 이 차이가 의미를 가집니다.
sorted()와 list.sort()는 안정 정렬이 보장됩니다. 여러 번 정렬해 다중 키 정렬을 만들 수 있습니다.
std::sort가 최악에도 O(N log N)번의 비교를 하도록 요구하고, 안정 정렬이 필요하면 std::stable_sort를 씁니다.
함께 보면 좋은 토픽
참고 자료
Java — 병합 정렬 (Merge Sort)
/* ── 절반으로 나누고, 정렬된 두 절반을 다시 합침 ───────────── */ public void mergeSort(int[] arr, int left, int right) { if (left >= right) return; // 기저 사례: 원소 1개는 이미 정렬됨 int mid = left + (right - left) / 2; mergeSort(arr, left, mid); // 왼쪽 절반 정렬 mergeSort(arr, mid + 1, right); // 오른쪽 절반 정렬 merge(arr, left, mid, right); // 정렬된 두 절반을 합침 }
배열을 계속 절반으로 나누면 깊이는 log₂n, 각 깊이에서 merge가 전체 원소를 한 번씩 비교·복사하므로 O(n)입니다.
깊이(log n) × 각 단계 작업량(n) = O(n log n)이며, 항상 정확히 절반씩 나누기 때문에 입력에 상관없이 이 시간이 보장됩니다.
Java — 퀵 정렬 (Quick Sort, Lomuto 파티션)
/* ── 피벗보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 모음 ──────── */ public void quickSort(int[] arr, int lo, int hi) { if (lo >= hi) return; 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); // 피벗을 최종 위치로 이동 int p = i + 1; quickSort(arr, lo, p - 1); quickSort(arr, p + 1, hi); }
피벗이 매번 배열을 절반에 가깝게 나누면 병합 정렬처럼 O(n log n)이지만, arr[j] < pivot 비교 결과에 따라 분할 비율이 달라지는 것이 병합 정렬과의 차이입니다.
평균적으로는 O(n log n)이지만, 이미 정렬된 배열에서 항상 마지막 값을 피벗으로 고르면 매번 원소 1개씩만 떼어내는 최악의 경우 O(n²)가 될 수 있습니다.
Java — 힙 정렬 (Heap Sort)
/* ── 최대 힙을 만든 뒤, 루트(최댓값)를 하나씩 꺼내 정렬 ────── */ public void heapSort(int[] arr) { int n = arr.length; for (int i = n / 2 - 1; i >= 0; i--) { siftDown(arr, i, n); // 배열 전체를 최대 힙으로 구성 — O(n) } for (int end = n - 1; end > 0; end--) { swap(arr, 0, end); // 루트(최댓값)를 정렬 영역 맨 앞으로 siftDown(arr, 0, end); // 줄어든 힙을 다시 정리 } }
siftDown 한 번은 힙의 높이(log n)만큼만 비교하면 되므로, n개를 하나씩 꺼내며 매번 siftDown을 호출해도 전체는 O(n log n)입니다.
병합 정렬처럼 최선·평균·최악이 모두 O(n log n)으로 안정적이면서도, 퀵 정렬처럼 추가 배열 없이 원본 배열 안에서 정렬을 끝내는 제자리 정렬이라는 점이 특징입니다.
Q1
병합 정렬, 퀵 정렬, 힙 정렬의 평균 시간 복잡도는 공통적으로?
Q2
같은 O(n log n)이어도 정렬 알고리즘마다 실제 비교 횟수가 다를 수 있는 이유는?
Q3
퀵 정렬이 평균적으로 빠르지만 최악의 경우 O(n^2)이 될 수 있는 이유는?