완전이진트리 기반 힙 정렬: 원리와 구현 완벽 정복

힙 정렬(Heap Sort)은 완전이진트리 구조를 활용하여 최대값 또는 최소값을 효율적으로 추출하는 비교 기반 정렬 알고리즘이다. 이 글에서는 힙의 개념부터 실제 구현, 그리고 면접에서 자주 등장하는 핵심 포인트까지 심도 있게 다룬다.

힙의 본질: 완전이진트리와 힙 속성

힙은 메모리 관리의 '힙 영역'과 전혀 무관한 자료구조로, 다음 두 가지 조건을 만족하는 특수한 형태의 트리이다.

구조적 특성: 마지막 레벨을 제외한 모든 레벨이 완전히 채워져 있고, 마지막 레벨의 노드는 왼쪽부터 순차적으로 배치된다.

값 기반 특성: 부모 노드와 자식 노드 간의 대소 관계가 일정하게 유지된다.

  • 최대 힙(Max Heap): 부모 노드 값 ≥ 자식 노드 값 (루트가 전체 최대값)
  • 최소 힙(Min Heap): 부모 노드 값 ≤ 자식 노드 값 (루트가 전체 최소값)

배열로 힙을 표현하는 인덱스 공식

실제 구현에서는 포인터 기반 트리 대신 배열을 사용하여 공간 효율성을 확보한다. 0-based 인덱스를 가정할 때, 임의의 노드 i에 대해:

부모 노드: (i - 1) / 2
왼쪽 자식: 2 * i + 1
오른쪽 자식: 2 * i + 2

예를 들어 배열 {7, 3, 9, 1, 5}은 다음과 같이 매핑된다:

  • 인덱스 0 (값 7): 자식은 인덱스 1 (3), 인덱스 2 (9)
  • 인덱스 1 (값 3): 부모는 인덱스 0 (7), 자식은 인덱스 3 (1), 인덱스 4 (5)
  • 인스 2 (값 9): 부모는 인덱스 0 (7), 자식 없음

힙 정렬의 핵심 메커니즘

힙 정렬은 최대 힙의 루트(최대값)를 반복적으로 추출하여 배열 뒤쪽부터 채워 나가는 방식으로 동작한다. 전체 프로세스는 세 단계로 구분된다.

  1. 힙 구성 단계: 무작위 배열을 최대 힙으로 변환
  2. 추출 및 교환 단계: 루트(최대값)를 배열 마지막 위치와 교환
  3. 재구성 단계: 남은 요소들로 새로운 최대 힙을 구성하고 단계 2를 반복

이 과정을 거치면 배열의 뒷부분부터 정렬된 상태로 채워지며, 최종적으로 전체 배열이 오름차순 정렬된다.

상세 동작 과정: 예제로 이해하기

배열 {7, 3, 9, 1, 5}을 힙 정렬하는 과정을 단계별로 살펴본다.

단계 1: 최대 힙 구성

마지막 비단말 노드부터 역순으로 sift-down 연산을 수행한다. 마지막 비단말 노드의 인덱스는 (n - 2) / 2로 계산한다.

n = 5이므로, 시작 인덱스는 (5-2)/2 = 1이다.

인덱스 1 처리 (값 3): 자식들은 인덱스 3 (1), 인덱스 4 (5). 최대값은 5이므로 3과 5를 교환 → 배열: {7, 5, 9, 1, 3}

인덱스 0 처리 (값 7): 자식들은 인덱스 1 (5), 인덱스 2 (9). 최대값은 9이므로 7과 9를 교환 → 배열: {9, 5, 7, 1, 3}

최대 힙 구성 완료. 루트 값 9가 전체 최대값이다.

단계 2: 정렬 진행

회차교환 전 상태교환 대상교환 후 (힙 영역)정렬된 영역
1{9, 5, 7, 1, 3}9 ↔ 3{3, 5, 7, 1}{9}
2{7, 5, 3, 1}7 ↔ 1{1, 5, 3}{7, 9}
3{5, 1, 3}5 ↔ 3{3, 1}{5, 7, 9}
4{3, 1}3 ↔ 1{1}{3, 5, 7, 9}

최종 정렬 결과: {1, 3, 5, 7, 9}

완전한 C++ 구현

다음은 힙 정렬의 표준적인 C++ 구현이다. 변수명과 구조를 달리하여 원본과 차별화하였다.

#include <iostream>
#include <vector>

using std::vector;
using std::swap;
using std::cout;

// 하향 조정: 노드 idx를 루트로 하는 서브트리를 힙 속성에 맞게 재구성
void siftDown(vector<int>& data, int heapSize, int idx) {
    int dominant = idx;           // 현재 서브트리에서 최대값을 가진 노드
    int lChild = 2 * idx + 1;   // 왼쪽 자식
    int rChild = 2 * idx + 2;   // 오른쪽 자식
    
    // 왼쪽 자식이 존재하고 더 큰 경우
    if (lChild < heapSize && data[lChild] > data[dominant]) {
        dominant = lChild;
    }
    
    // 오른쪽 자식이 존재하고 더 큰 경우
    if (rChild < heapSize && data[rChild] > data[dominant]) {
        dominant = rChild;
    }
    
    // 최대값이 현재 노드가 아니라면 교환 후 재귀 처리
    if (dominant != idx) {
        swap(data[idx], data[dominant]);
        siftDown(data, heapSize, dominant);
    }
}

// 힙 정렬 메인 함수
void heapSort(vector<int>& data) {
    int n = data.size();
    if (n <= 1) return;
    
    // 1단계: 상향식으로 최대 힙 구성
    for (int i = (n - 2) / 2; i >= 0; --i) {
        siftDown(data, n, i);
    }
    
    // 2단계: 추출-교환-재구성 반복
    for (int end = n - 1; end > 0; --end) {
        swap(data[0], data[end]);      // 루트(최대값)를 끝으로 이동
        siftDown(data, end, 0);         // 남은 영역으로 힙 재구성
    }
}

// 검증용 메인 함수
int main() {
    vector<int> sample = {7, 3, 9, 1, 5, 2, 8, 4, 6};
    
    cout << "원본: ";
    for (int v : sample) cout << v << " ";
    cout << "\n";
    
    heapSort(sample);
    
    cout << "정렬: ";
    for (int v : sample) cout << v << " ";
    cout << "\n";
    
    return 0;
}

구현의 핵심은 siftDown 함수의 재귀적 호출로, 교환된 자식 노드 아래에서도 힙 속성이 유지되도록 보장한다.

복잡도 분석 및 특성

항목결과설명
시간 잡도 (최악)O(n log n)힙 구성 O(n), n-1회 추출 각각 O(log n)
시간 복잡도 (평균)O(n log n)입력 분포에 무관하게 일정
공간 복잡도O(1)재귀 스택 외 추가 메모리 불필요
안정성불안정같은 값의 상대적 순서가 보장되지 않음

힙 정렬의 가장 큰 장점은 제자리 정렬(in-place sorting)이 가능하며, 최악의 경우에도 O(n log n)을 보장한다는 점이다. 이는 퀵 정렬의 O(n²) 최악 케이스와 대조된다.

실전 활용: Top-K 문제 해결

힙 정렬의 대표적인 응용은 대용량 데이터에서 상위 K개를 추출하는 문제이다. 전체 정렬 O(n log n) 대신 O(n log K)로 해결 가능하다.

// 크기 K의 최소 힙을 유지하여 상위 K개 최대값 찾기
vector<int> findTopK(const vector<int>& stream, int K) {
    // 최소 힙 (C++ 기본 priority_queue는 최대 힙이므로 greater 사용)
    priority_queue<int, vector<int>, greater<int>> minHeap;
    
    for (int val : stream) {
        if (minHeap.size() < K) {
            minHeap.push(val);
        } else if (val > minHeap.top()) {
            minHeap.pop();
            minHeap.push(val);
        }
    }
    
    vector<int> result;
    while (!minHeap.empty()) {
        result.push_back(minHeap.top());
        minHeap.pop();
    }
    return result;
}

K가 n보다 훨씬 작을 때, 이 방식은 메모리와 시간 모두에서 큰 이점을 제공한다.

흔한 실수와 해결책

범위 검증 누락: 자식 노드 인덱스가 현재 힙 크기를 초과하는지 반드시 확인해야 한다.

잘못된 시작점: 힙 구성 시 0번부터 순방향 순회하면 안 된다. 마지막 비단말 노드에서 역순으로 시작해야 한다.

힙 크기 갱신 실패: 매 추출 후 힙 크기를 1 감소시켜야 이미 정렬된 영역이 재조정에 포함되지 않는다.

최대/최소 힙 혼동: 오름차순 정렬에는 최대 힙을, 내림차순에는 최소 힙을 사용해야 한다. 반대로 하면 의도와 다른 결과가 나온다.

타 정렬 알고리즘과의 비교

알고리즘평균 시간최악 시간공간안정성적합한 상황
힙 정렬O(n log n)O(n log n)O(1)불안정메모리 제한, 실시간 시스템
퀵 정렬O(n log n)O(n²)O(log n)불안정일반적인 범용 정렬
병합 정렬O(n log n)O(n log n)O(n)안정외부 정렬, 안정성 필요 시
팀 정렬O(n log n)O(n log n)O(n)안정실제 데이터 패턴 활용

힙 정렬은 공간 효율성과 최악 케이스 보장이라는 두 가지 강점을 가진다. 다만 캐시 효율성은 인접 요소 접근이 잦은 퀵 정렬이나 병합 정렬에 비해 다소 떨어질 수 있다.

핵심 정리

힙 정렬을 완전히 이해하려면 다음 세 가지를 숙지해야 한다: 완전이진트리의 배열 표현, sift-down 연산의 재귀적 구조, 그리고 추출-재구성 반복 패턴. 이 구조를 바탕으로 다양한 변형(예: 부분 정렬, 중위값 추출, 우선순위 큐 구현)으로 확장할 수 있다.

태그: Heap Sort Priority Queue Complete Binary Tree Sift-down In-place Sorting

6월 26일 16:22에 게시됨