컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 06

힙

자료구조우선순위 큐정렬

힙(Heap)은 완전 이진 트리 기반의 자료구조로, 부모 노드가 항상 자식 노드보다 크거나(Max-Heap) 작은(Min-Heap) 조건을 만족합니다. 최댓값 또는 최솟값을 O(1)에 조회하고 O(log n)에 삽입·삭제할 수 있어 우선순위 큐의 핵심 구현체로 사용됩니다.

핵심 개념

힙 조건 (Heap Property)

Max-Heap: 모든 부모 노드의 값 ≥ 자식 노드의 값. 루트가 전체 최댓값입니다. Min-Heap: 모든 부모 노드의 값 ≤ 자식 노드의 값. 루트가 최솟값입니다. BST와 달리 형제 노드 사이에는 대소 관계가 없습니다.

핵심 개념

완전 이진 트리와 배열 표현

힙은 완전 이진 트리(마지막 레벨을 제외하고 모두 채워진 트리, 마지막 레벨은 왼쪽부터 채움)입니다. 배열로 표현할 때 인덱스 i의 왼쪽 자식은 2i+1, 오른쪽 자식은 2i+2, 부모는 (i-1)/2입니다.

핵심 개념

삽입 — Heapify-Up

새 원소를 배열 끝(트리의 마지막 위치)에 추가한 뒤, 부모와 비교하며 힙 조건을 만족할 때까지 위로 올립니다. 최대 트리 높이(log n)번 스왑이 발생하므로 O(log n)입니다.

핵심 개념

삭제 — Heapify-Down

루트(최댓값)를 제거하고 배열 마지막 원소를 루트로 이동합니다. 이후 두 자식 중 더 큰 자식과 비교하며 힙 조건을 만족할 때까지 아래로 내립니다. 마찬가지로 O(log n)입니다.

핵심 개념

힙 정렬 (Heap Sort)

① 배열을 Max-Heap으로 만들고 ② 루트(최댓값)를 배열 끝으로 이동한 뒤 힙 크기를 줄이고 Heapify-Down을 반복합니다. n번의 Extract-Max로 오름차순 정렬이 완성됩니다. 시간복잡도 O(n log n), 추가 메모리 O(1)의 제자리 정렬입니다.

핵심 개념

우선순위 큐와 Java PriorityQueue

Java의 PriorityQueue는 Min-Heap 기반입니다. peek()으로 최솟값을 O(1)에 조회, offer()로 O(log n) 삽입, poll()로 O(log n) 삭제. Max-Heap이 필요하면 Collections.reverseOrder()나 람다 Comparator를 사용합니다.

시간복잡도 — peek(최댓값 조회) O(1), offer(삽입) O(log n), poll(삭제) O(log n). Build-Heap(n개 원소로 힙 구성)은 O(n). 힙 정렬 전체는 O(n log n).
힙 vs BST — 힙은 최댓값/최솟값 접근에 특화되어 있고 배열로 표현해 캐시 효율이 좋습니다. BST는 임의 원소 탐색과 정렬 순서 접근이 가능합니다. 우선순위 큐는 힙, 정렬 유지가 필요한 맵/셋은 BST(TreeMap/TreeSet)가 적합합니다.
힙 정렬 vs 다른 정렬 — Quick Sort는 평균 O(n log n)이지만 최악 O(n²). Merge Sort는 O(n log n)이지만 추가 메모리 O(n) 필요. Heap Sort는 최악도 O(n log n)이고 추가 메모리 O(1)이나, 캐시 지역성이 낮아 실제 성능은 Quick Sort보다 느린 경우가 많다고 알려져 있습니다.
활용 사례 — 다익스트라 최단 경로(최소 비용 노드 추출), 프림 MST 알고리즘, 우선순위 기반 스케줄링 같은 작업 선택, K번째 최댓값/최솟값 탐색, 실시간 중앙값 유지(Max-Heap + Min-Heap 조합).

힙이 필요한 이유

힙은 '가장 큰 값(또는 가장 작은 값)을 빠르게 꺼내는' 데 특화된 자료구조입니다. 전체를 정렬해 두는 것보다 약한 규칙(부모가 자식보다 크거나 같다)만 지켜서 삽입과 삭제를 O(log n)에 끝내고, 최댓값 확인은 O(1)로 합니다. 우선순위 큐의 표준 구현이 힙인 이유입니다.

숫자로 확인하기

배열 인덱스 계산 — 인덱스 3인 노드의 왼쪽 자식은 2×3 + 1 = 7, 오른쪽 자식은 8, 부모는 (3 − 1) / 2 = 1입니다(루트인 인덱스 0은 부모가 없습니다).
힙의 높이 — 원소가 1,000,000개이면 높이가 약 19라서 삽입과 삭제가 최대 19번 안팎의 비교와 교환으로 끝납니다.
Top-K의 메모리 — 10억 개의 값 중 가장 큰 100개를 찾을 때 크기 100짜리 최소 힙만 유지하면 메모리는 100개분이고 시간은 O(n log K)입니다. 전부 정렬하면 10억 개를 저장해야 합니다.
힙 만들기 방식 비교 — 원소 n개를 하나씩 삽입하면 O(n log n)이지만, 배열을 한 번에 힙으로 바꾸는 Build-Heap은 O(n)입니다.

자주 하는 오해

힙은 정렬된 배열이다 — 루트가 최댓값(최솟값)이라는 것만 보장합니다. 배열을 순서대로 읽으면 정렬되어 있지 않습니다.
힙에서도 임의의 값을 빨리 찾는다 — 형제 사이에 대소 관계가 없어서 특정 값을 찾으려면 O(n)이 걸립니다. 빠른 것은 최댓값 접근과 삽입, 삭제입니다.
PriorityQueue를 순회하면 우선순위 순서로 나온다 — Java 문서는 iterator가 특별한 순서로 순회한다는 보장이 없다고 설명합니다. 순서대로 꺼내려면 poll을 반복해야 합니다.
같은 우선순위는 먼저 넣은 것이 먼저 나온다 — Java 문서는 같은 우선순위의 순서는 임의로 정해진다고 설명합니다. 순서가 필요하면 입력 순번을 비교 기준에 더합니다.

면접에서 이렇게 물어요

면접 질문

힙과 이진 탐색 트리의 차이는 무엇인가요?

힙은 부모와 자식의 대소만 보장해 최댓값 접근이 O(1)이고 배열로 표현됩니다. BST는 왼쪽 < 현재 < 오른쪽을 보장해 정렬된 순회와 임의 값 탐색이 가능합니다.

면접 질문

Build-Heap이 O(n)인 이유는 무엇인가요?

아래쪽 노드는 내려갈 거리가 짧습니다. 높이가 h인 노드는 최대 h번 내려가는데 그런 노드의 수가 n/2^(h+1) 정도라서 전체 합이 O(n)으로 수렴합니다.

면접 질문

스트림에서 중앙값을 실시간으로 구하려면 어떻게 하나요?

작은 쪽 절반을 최대 힙에, 큰 쪽 절반을 최소 힙에 넣고 두 힙의 크기를 맞춥니다. 두 힙의 루트로 중앙값을 O(1)에 읽고, 값을 추가하는 삽입은 O(log n)입니다.

면접 질문

힙 정렬이 퀵 정렬보다 느린 경우가 많은 이유는 무엇인가요?

힙 정렬은 부모와 자식 사이를 오가며 메모리를 건너뛰어 읽어서 캐시 효율이 떨어지는 편이라, 보통 잘 구현한 퀵 정렬보다 느리다고 알려져 있습니다. 최악의 경우 O(n log n)이 보장되는 점은 장점입니다.

실제 시스템에서는

Java — PriorityQueue 문서는 우선순위 힙 기반의 큐이고 기본은 자연 순서(또는 지정한 Comparator)에서 가장 작은 원소를 꺼내며, offer와 poll은 O(log n), peek는 상수 시간이라고 설명합니다. remove(Object)와 contains는 선형 시간이고 스레드에 안전하지 않습니다.
Python — heapq 모듈은 리스트를 최소 힙으로 다루는 함수를 제공하고(최대 힙 전용 함수는 3.14부터 추가되었습니다), heap[0]이 가장 작은 원소입니다. nlargest와 nsmallest는 상위 K개를 구하는 함수입니다.
그래프 알고리즘 — 다익스트라와 프림이 가장 가까운 노드를 꺼내는 데 쓰는 우선순위 큐의 바탕입니다. 다익스트라 토픽에서 봅니다.

함께 보면 좋은 토픽

트리 — 완전 이진 트리의 구조를 먼저 봅니다.
스택 / 큐 — 우선순위 큐와 일반 큐의 차이를 봅니다.
정렬 비교 — 힙 정렬의 비교 횟수를 다른 정렬과 견줘 봅니다.
다익스트라 — 힙이 최단 경로 계산을 빠르게 하는 방식을 봅니다.
이진 탐색 트리 — 힙과 규칙이 다른 트리를 비교합니다.

참고 자료

Java PriorityQueue — PriorityQueue — Java SE 21 API
원 논문 — Williams, "Algorithm 232: Heapsort", Communications of the ACM, 1964
교재 — Cormen, Leiserson, Rivest, Stein, "Introduction to Algorithms" 4판, "Heapsort" 장

Java — Max-Heap 직접 구현

/* ── Max-Heap 배열 기반 구현 ────────────────────────────── */
public class MaxHeap {
    private int[] data;
    private int   size;

    public MaxHeap(int capacity) { data = new int[capacity]; }

    private int  parent(int i) { return (i - 1) / 2; }
    private int  left(int i)   { return 2 * i + 1; }
    private int  right(int i)  { return 2 * i + 2; }
    private void swap(int i, int j) {
        int t = data[i]; data[i] = data[j]; data[j] = t;
    }

    /* 삽입 + Heapify-Up */
    public void offer(int val) {
        data[size++] = val;
        int i = size - 1;
        // 부모보다 크면 올라간다
        while (i > 0 && data[parent(i)] < data[i]) {
            swap(i, parent(i));
            i = parent(i);
        }
    }

    /* 루트(최댓값) 조회 */
    public int peek()  { return data[0]; }           // O(1)

    /* Extract-Max + Heapify-Down */
    public int poll() {
        int max = data[0];
        data[0] = data[--size];   // 마지막 원소를 루트로
        heapifyDown(0);
        return max;
    }

    private void heapifyDown(int i) {
        while (left(i) < size) {
            int largest = left(i);
            if (right(i) < size && data[right(i)] > data[largest])
                largest = right(i);
            if (data[i] >= data[largest]) break; // 힙 조건 만족
            swap(i, largest);
            i = largest;
        }
    }
}

배열 인덱스 i의 왼쪽 자식은 2i+1, 오른쪽 자식은 2i+2, 부모는 (i-1)/2입니다.
Heapify-Up은 삽입 후 부모보다 크면 계속 올라가고, Heapify-Down은 삭제 후 자식 중 더 큰 쪽으로 내려갑니다.
두 연산 모두 트리 높이(log n)만큼만 수행하므로 O(log n)입니다.

Java — 힙 정렬 (Heap Sort)

/* ── 힙 정렬: 제자리 정렬 O(n log n), 추가 메모리 O(1) ─── */
public static void heapSort(int[] arr) {
    int n = arr.length;

    // 1단계: Build Max-Heap (마지막 내부 노드부터 heapify-down)
    for (int i = n / 2 - 1; i >= 0; i--)
        heapifyDown(arr, i, n);   // O(n)

    // 2단계: 루트(최댓값)를 끝으로 이동하며 정렬
    for (int end = n - 1; end > 0; end--) {
        // 루트(최댓값) ↔ 마지막 원소 교환
        int t = arr[0]; arr[0] = arr[end]; arr[end] = t;
        // 힙 크기를 줄이고 루트 재정렬
        heapifyDown(arr, 0, end);  // O(log n)
    }
    // 오름차순 정렬 완료 — O(n log n) 전체
}

private static void heapifyDown(int[] arr, int i, int n) {
    while (2 * i + 1 < n) {
        int largest = 2 * i + 1; // 왼쪽 자식
        if (2 * i + 2 < n && arr[2 * i + 2] > arr[largest])
            largest = 2 * i + 2; // 오른쪽이 더 크면
        if (arr[i] >= arr[largest]) break;
        int t = arr[i]; arr[i] = arr[largest]; arr[largest] = t;
        i = largest;
    }
}

Build Max-Heap은 배열 절반 지점부터 역순으로 heapifyDown을 호출합니다.
각 호출이 O(log n)처럼 보이지만 전체는 O(n)으로 분석됩니다.
정렬 단계는 루트(최댓값)를 배열 끝으로 보내고 힙 크기를 줄이는 과정을 n-1번 반복합니다.
각 반복이 O(log n)이므로 전체 정렬은 O(n log n).
추가 배열이 필요 없는 제자리(in-place) 정렬이지만, 캐시 지역성이 낮아 실제 성능은 Quick Sort보다 느린 경우가 많습니다.

Java — PriorityQueue 활용

/* ── Java PriorityQueue (Min-Heap 기본) ─────────────────── */
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(15); minHeap.offer(4); minHeap.offer(42);
minHeap.peek();   // 4  — 최솟값 O(1)
minHeap.poll();   // 4  — 최솟값 제거 O(log n)

/* ── Max-Heap: reverseOrder 사용 ─────────────────────────── */
PriorityQueue<Integer> maxHeap =
    new PriorityQueue<>(Collections.reverseOrder());
maxHeap.offer(15); maxHeap.offer(4); maxHeap.offer(42);
maxHeap.peek();   // 42 — 최댓값 O(1)

/* ── K번째 최솟값: Min-Heap 활용 패턴 ────────────────────── */
public int kthSmallest(int[] nums, int k) {
    PriorityQueue<Integer> pq = new PriorityQueue<>();
    for (int n : nums) pq.offer(n);
    while (--k > 0) pq.poll();
    return pq.peek();
}

/* ── 다익스트라에서의 활용 (최소 비용 노드 추출) ─────────── */
PriorityQueue<int[]> pq =
    new PriorityQueue<>((a, b) -> a[1] - b[1]); // 비용 기준 Min-Heap
pq.offer(new int[]{0, 0});  // {노드, 비용}
while (!pq.isEmpty()) {
    int[] cur = pq.poll();     // 현재 최소 비용 노드
    // 인접 노드 탐색 및 거리 갱신...
}

Java PriorityQueue는 기본이 Min-Heap입니다.
Max-Heap이 필요하면 Collections.reverseOrder() 또는 람다 Comparator를 사용합니다.
K번째 최솟값, 다익스트라 최단 경로, CPU 스케줄링처럼 "현재 가장 우선순위 높은 항목을 빠르게 꺼내야 하는" 문제에서 PriorityQueue(힙)가 핵심 도구입니다.

Q1

Max-Heap의 특징은?

'Max'는 최댓값이 루트에 있다는 뜻입니다.
Max-Heap은 부모 노드의 값이 항상 자식 노드보다 크거나 같은 완전 이진 트리로, 루트에 항상 최댓값이 위치합니다.

Q2

힙에 새 원소를 삽입할 때 일반적인 방법은?

완전 이진 트리 구조를 유지하며 힙 속성을 맞춰야 합니다.
힙 삽입은 마지막 위치에 새 원소를 넣고, 부모와 비교하며 힙 속성을 만족할 때까지 위로 올리는(sift-up) 과정을 거칩니다.

Q3

힙 정렬(Heap Sort)의 시간 복잡도는?

삽입/삭제가 O(log n)이고 이걸 n번 반복합니다.
힙 정렬은 힙에서 최댓값(또는 최솟값)을 꺼내는 연산(O(log n))을 n번 반복하므로 전체 시간 복잡도는 O(n log n)입니다.