힙 정렬heap sort은 최대 힙을 이용해 개의 원소를 시간에 정렬하는 제자리1 비교 정렬이다. 병합 정렬처럼 점근적으로 최적이면서, 퀵 정렬과 달리 최악 시간이 보장되고, 보조 공간이 이라는 장점을 동시에 가진다. 이 글에서는 힙 정렬의 두 단계(힙 구성 → 반복 추출), 루프 불변식에 기반한 정확성 증명, 시간 복잡도 분석, 그리고 다른 정렬과의 차이를 살펴본다.
핵심 아이디어
힙 정렬은 우선순위 큐에서 원소를 하나씩 꺼내면 자동으로 정렬된다는 관찰에서 출발한다. 오름차순 결과를 원한다면 최대 힙2을 만든 뒤 루트를 번 반복 추출하면 된다. 추출된 값을 매번 배열의 "사용하지 않는 꼬리"에 쌓아 두면 별도의 출력 버퍼 없이 같은 배열에서 정렬이 끝난다.
즉, 힙 정렬은 다음 두 단계로 구성된다.
- 힙 구성(
BUILD-MAX-HEAP): 입력 배열 을 최대 힙 속성을 만족하도록 재배치한다. - 반복 추출(
HEAPSORT): 루트 과 마지막 원소 을 교환하여 현재 최댓값을 정렬된 영역으로 밀어내고, 힙 크기를 1 줄인 뒤 에서 sift-down을 수행한다. 힙 크기가 1이 될 때까지 반복한다.
힙의 배열 표현, 부모·자식 인덱스 공식, sift-up·sift-down의 기본 메커니즘은 힙에 대한 포스트를 참고하자. 이 글에서는 정렬에 특화된 관점에서만 설명한다.
알고리즘
MAX-HEAPIFY
부분 트리의 루트 가 최대 힙 속성을 위반할 때, 를 올바른 위치까지 아래로 내려 보내 속성을 복원한다. 두 자식 중 더 큰 쪽과 비교하여, 자식이 더 크면 교환하고 그 자식을 새 로 삼아 재귀한다.
def max_heapify(A, i, heap_size):
left, right = 2 * i, 2 * i + 1
largest = i
if left <= heap_size and A[left] > A[largest]:
largest = left
if right <= heap_size and A[right] > A[largest]:
largest = right
if largest != i:
A[i], A[largest] = A[largest], A[i]
max_heapify(A, largest, heap_size)
현재 노드에서 리프까지 내려가므로 시간 복잡도는 트리 높이에 비례한 이다.
BUILD-MAX-HEAP
배열의 뒷쪽 절반()은 모두 리프이므로 이미 힙 속성을 자명하게 만족한다. 따라서 부터 1까지 역순으로 MAX-HEAPIFY를 호출하면 된다. 역순 호출이 본질적인 이유는, 호출 시점에 의 두 자식을 루트로 하는 부분 트리가 이미 최대 힙이어야 MAX-HEAPIFY가 유효하기 때문이다.
def build_max_heap(A):
n = len(A) - 1 # 1-based 인덱스
for i in range(n // 2, 0, -1):
max_heapify(A, i, n)
전체 비용은 이다3. "각 호출이 , 호출이 번이므로 "이라는 분석은 느슨한 상계일 뿐이며, 실제로는 층별 노드 수가 지수적으로 감소하는 효과가 결합해 선형으로 수렴한다.
HEAPSORT
def heapsort(A):
build_max_heap(A)
heap_size = len(A) - 1
for i in range(heap_size, 1, -1):
A[1], A[i] = A[i], A[1] # 현재 최댓값을 정렬된 꼬리로
heap_size -= 1
max_heapify(A, 1, heap_size)
한 번의 교환으로 현재 최댓값이 배열의 꼬리에 고정된다. 힙 크기를 줄이면 그 위치는 더 이상 힙에 포함되지 않으며, MAX-HEAPIFY는 줄어든 힙에 대해서만 속성을 복원한다. 이 사이클을 번 반복하면 배열 전체가 오름차순으로 정렬된다.
동작 과정
입력 을 예로 들자. 먼저 BUILD-MAX-HEAP이 를 로 재배치한다. 그 뒤 반복 추출 루프의 한 사이클이 어떻게 진행되는지 살펴본다.
이 사이클이 끝나면 가 되고, 뒷쪽 한 칸이 "정렬된 영역"으로 확정된다. 같은 사이클을 반복할 때마다 정렬된 영역이 왼쪽으로 한 칸씩 자라면서 힙은 한 칸씩 줄어들고, 결국 로 마감된다.
정확성 증명
두 개의 루프 불변식으로 알고리즘의 정확성을 보인다. 각 불변식은 초기화 · 유지 · 종료의 3단 구조로 검증한다.
BUILD-MAX-HEAP: 루프 변수 가 에서 1까지 감소할 때, 각 반복의 시작 시점에서 노드 은 모두 어떤 최대 힙의 루트이다.- 초기화: 일 때, 인덱스 은 모두 리프이므로 자명하게 크기 1의 최대 힙이다.
- 유지: 의 두 자식(, )은 가정에 의해 이미 최대 힙의 루트이다. 이 전제에서
MAX-HEAPIFY(A, i, n)은 노드 를 루트로 하는 부분 트리를 최대 힙으로 만든다. 따라서 다음 반복 시작 시점에 이 모두 최대 힙의 루트이다. - 종료: 으로 종료되는 시점에 노드 이 모두 최대 힙의 루트이므로, 특히 전체가 최대 힙이다.
HEAPSORT: 외부 루프가 로 감소할 때, 각 반복 시작 시점에 다음이 성립한다. (i) 는 최대 힙이다. (ii) 은 오름차순으로 정렬되어 있으며, 그 안의 모든 원소는 의 모든 원소보다 크거나 같다.- 초기화: 루프 시작 직전
BUILD-MAX-HEAP으로 이 최대 힙이고, 은 비어 있으므로 자명하다. - 유지: 이 의 최댓값이므로 과 를 교환해도 (ii)의 정렬·크기 조건은 깨지지 않는다. 그 뒤 힙 크기를 로 줄이고
MAX-HEAPIFY(A, 1, i-1)을 호출하면 이 다시 최대 힙이 되어 (i)가 복원된다. - 종료: 일 때 이 오름차순이고 은 그 어떤 원소보다도 작거나 같으므로 전체가 오름차순이다.
- 초기화: 루프 시작 직전
두 불변식이 각각 성립하므로 힙 정렬은 임의 입력에 대해 오름차순 결과를 반환한다.
시간 복잡도
| 단계 | 비용 |
|---|---|
BUILD-MAX-HEAP | |
MAX-HEAPIFY (추출 루프 내부) | |
| 추출 루프 반복 횟수 | |
| 전체 |
힙 구성이 인 본질은 높이 에 있는 노드 수가 최대 이고 각 노드의 sift-down 비용이 라는 사실에서 비롯된다. 두 값을 곱해 모든 높이에 대해 합하면
이 된다(로 수렴한다). 그러나 이 은 뒤따르는 추출 루프의 에 흡수되므로, 알고리즘 전체의 점근 복잡도는 이다.
힙 정렬은 최악·평균·최선 모두 이다. 입력이 이미 정렬된 상태라 해도 힙 구성은 항상 수행되고, 추출 루프의 모든 MAX-HEAPIFY 호출이 적어도 한 레벨은 내려가야 한다. 이는 최선이 이 될 수 있는 삽입 정렬이나 최적화된 버블 정렬과 대조된다.
다른 정렬과의 비교
| 정렬 | 평균 | 최악 | 보조 공간 | 안정성4 | 제자리 |
|---|---|---|---|---|---|
| Heap Sort | 불안정 | 예 | |||
| Merge Sort | 안정 | 아니오 | |||
| Quick Sort | 불안정 | 예 | |||
| Insertion Sort | 안정 | 예 |
힙 정렬의 구조적 강점은 아무리 최악이어도 이 보장된다는 점과 보조 공간을 동시에 제공한다는 점이다. 병합 정렬은 최악 을 달성하지만 의 추가 메모리가 필요하고, 퀵 정렬은 평균 성능이 뛰어나지만 최악이 까지 떨어질 수 있다. 최악 지연이 중요한 실시간 시스템이나 메모리가 제한된 임베디드 환경에서 힙 정렬이 선호되는 이유다.
다만 실무 벤치마크에서 힙 정렬은 잘 튜닝된 퀵 정렬보다 느린 경우가 많다. 힙 정렬의 sift-down 경로는 인덱스 로 지수적 점프를 하므로, 단계마다 멀리 떨어진 캐시 라인을 건드려 캐시 지역성5이 나쁘다. 이런 이유로 C++ STL의 std::sort는 인트로정렬을 채택하여 평상시에는 퀵 정렬로 빠르게 처리하고, 재귀 깊이가 를 넘으면 힙 정렬로 전환해 최악 을 보장한다. 즉 힙 정렬은 하이브리드 정렬의 안전망으로서 현대 표준 라이브러리에 스며들어 있다.
Python 구현
def heapsort(A):
"""1-based 인덱스 배열 A[1..n]을 제자리에서 오름차순 정렬."""
n = len(A) - 1 # A[0]은 사용하지 않음
def max_heapify(i, heap_size):
while 2 * i <= heap_size:
child = 2 * i
if child + 1 <= heap_size and A[child + 1] > A[child]:
child += 1
if A[i] >= A[child]:
break
A[i], A[child] = A[child], A[i]
i = child
# 1. 힙 구성 — O(n)
for i in range(n // 2, 0, -1):
max_heapify(i, n)
# 2. 반복 추출 — O(n log n)
for i in range(n, 1, -1):
A[1], A[i] = A[i], A[1]
max_heapify(1, i - 1)
표준 라이브러리의 heapq를 쓰면 더 짧지만, 원소를 하나씩 heappop하여 새 리스트에 담으므로 이 버전은 엄밀히 제자리 정렬이 아니다.
import heapq
def heapsort_stdlib(data):
heapq.heapify(data)
return [heapq.heappop(data) for _ in range(len(data))]
어디에 쓰이는가
힙 정렬은 지연 시간이 일정 수준을 넘지 않도록 보장되어야 하는 환경에서 특히 가치가 있다. 실시간 시스템에서 최대 응답 시간을 계산해야 할 때, 메모리가 제한적이어서 보조 공간을 허용할 수 없을 때가 대표적이다. 또한 인트로정렬처럼 하이브리드 정렬의 최악 대비 안전망 역할로도 자주 쓰인다.
원소 개 중 상위 개만 필요한 경우에는 전체 정렬 대신 부분 힙 정렬(heapq.nlargest)을 쓰면 에 끝낼 수 있다. 힙을 "정렬 기계"가 아닌 "선택 기계"로 다시 보는 관점이며, 일 때 힙 정렬 전체를 돌리는 것보다 훨씬 빠르다.
출처
- Cormen, T. H. et al. (2022) Introduction to Algorithms. 4th edn. Cambridge, MA: MIT Press, Chapter 6.
- Sedgewick, R. and Wayne, K. (2011) Algorithms. 4th edn. Upper Saddle River, NJ: Addison-Wesley, Section 2.4.
- Musser, D. R. (1997) 'Introspective Sorting and Selection Algorithms', Software: Practice and Experience, 27(8), pp. 983–993.
- Python Software Foundation (2025) heapq — Heap queue algorithm. Available at: https://docs.python.org/3/library/heapq.html.
Footnotes
-
제자리 정렬in-place sort은 입력 배열 외에 입력 크기에 비례하지 않는 상수 수준의 보조 공간만 사용하는 정렬이다. 힙 정렬은 스왑 변수와 인덱스 몇 개만으로 동작하여 공간을 만족한다. ↩
-
최대 힙max-heap은 모든 노드 에 대해 가 성립하는 힙으로, 루트가 항상 집합의 최댓값이다. 자세한 정의와 배열 표현은 Heap 참고. ↩
-
BUILD-MAX-HEAP의 증명은 Heap 포스트의build-heap풋노트에 상세히 기술되어 있다. 요지는 높이 에 있는 노드 수 과 sift-down 비용 의 곱을 합하면 로 수렴해 전체가 이 된다는 것이다. ↩ -
안정 정렬stable sort은 같은 키를 가진 원소들의 입력 순서가 정렬 후에도 보존되는 성질을 뜻한다. 힙 정렬은 sift-down 과정에서 동일 키의 상대 위치가 뒤섞일 수 있어 불안정하다. ↩
-
캐시 지역성cache locality이란 프로그램이 최근 접근한 메모리 근처를 다시 접근하는 경향이다. 힙 정렬의 sift-down은 로 지수적 점프를 하므로 매 단계가 다른 캐시 라인을 건드려, 이웃 원소를 순차 접근하는 퀵/병합 정렬에 비해 캐시 미스가 많다. ↩