Theme:

최솟값도 빠르게, 최댓값도 빠르게 꺼내야 한다면? 그리고 이론상 가장 빠른 힙은 왜 실무에서 잘 안 쓸까요?

이중 우선순위 큐

이중 우선순위 큐(Double-Ended Priority Queue, DEPQ)는 최솟값과 최댓값을 모두 효율적으로 조회하고 삭제할 수 있는 자료구조입니다.

왜 필요한가

  • 일반 최소 힙: 최솟값 O(1) 조회, 최댓값 O(n) 조회
  • 일반 최대 힙: 최댓값 O(1) 조회, 최솟값 O(n) 조회
  • ** 이중 힙 **: 최솟값과 최댓값 모두 O(1) 조회

방법 1: 두 개의 힙 사용

JAVA
public class DoubleEndedPQ<T extends Comparable<T>> {
    private PriorityQueue<T> minHeap = new PriorityQueue<>();
    private PriorityQueue<T> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
    private Map<T, Integer> deleted = new HashMap<>(); // 지연 삭제용

    public void add(T value) {
        minHeap.offer(value);
        maxHeap.offer(value);
    }

    public T peekMin() {
        cleanup(minHeap);
        return minHeap.peek();
    }

    public T peekMax() {
        cleanup(maxHeap);
        return maxHeap.peek();
    }

    public T pollMin() {
        cleanup(minHeap);
        T min = minHeap.poll();
        // maxHeap에서 즉시 삭제하지 않고, 나중에 정리 (지연 삭제)
        deleted.merge(min, 1, Integer::sum);
        return min;
    }

    public T pollMax() {
        cleanup(maxHeap);
        T max = maxHeap.poll();
        deleted.merge(max, 1, Integer::sum);
        return max;
    }

    // 지연 삭제: 꺼내려는 값이 이미 삭제된 것이면 스킵
    private void cleanup(PriorityQueue<T> heap) {
        while (!heap.isEmpty() && deleted.getOrDefault(heap.peek(), 0) > 0) {
            T top = heap.poll();
            deleted.merge(top, -1, Integer::sum);
            if (deleted.get(top) == 0) deleted.remove(top);
        }
    }
}

방법 2: TreeMap 활용

Java에서는 TreeMap으로 간결하게 구현할 수 있습니다.

JAVA
public class TreeMapDEPQ<T> {
    private TreeMap<T, Integer> map = new TreeMap<>();

    public void add(T value) {
        map.merge(value, 1, Integer::sum);
    }

    public T peekMin() { return map.firstKey(); }
    public T peekMax() { return map.lastKey(); }

    public T pollMin() {
        T min = map.firstKey();
        removeOne(min);
        return min;
    }

    public T pollMax() {
        T max = map.lastKey();
        removeOne(max);
        return max;
    }

    private void removeOne(T key) {
        int count = map.get(key);
        if (count == 1) map.remove(key);
        else map.put(key, count - 1);
    }
}

TreeMap 기반은 모든 연산이 O(log n)이지만, 구현이 간결하고 안정적입니다.

활용 사례

  • ** 슬라이딩 윈도우 중앙값 **: 최소 힙과 최대 힙으로 중앙값을 유지
  • ** 캐시 교체 정책 **: 가장 오래된 것과 가장 최근 것 모두 접근 필요
  • ** 범위 필터링 **: 최솟값과 최댓값 기준 이상치 제거

중앙값 유지 — 대표적 패턴

JAVA
public class MedianFinder {
    // 작은 쪽 절반: 최대 힙
    PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
    // 큰 쪽 절반: 최소 힙
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();

    public void addNum(int num) {
        maxHeap.offer(num);
        minHeap.offer(maxHeap.poll()); // 균형 유지

        // maxHeap의 크기가 같거나 1개 더 많게 유지
        if (minHeap.size() > maxHeap.size()) {
            maxHeap.offer(minHeap.poll());
        }
    }

    public double findMedian() {
        if (maxHeap.size() > minHeap.size()) {
            return maxHeap.peek();
        }
        return (maxHeap.peek() + minHeap.peek()) / 2.0;
    }
}

피보나치 힙

피보나치 힙(Fibonacci Heap)은 1987년에 발표된, ** 이론적으로 가장 효율적인 힙** 중 하나입니다.

시간 복잡도 비교

연산이진 힙피보나치 힙
insertO(log n)O(1) amortized
peekO(1)O(1)
poll (delete-min)O(log n)O(log n) amortized
decrease-keyO(log n)O(1) amortized
mergeO(n)O(1)

decrease-key가 중요한 이유

Dijkstra 알고리즘에서 정점의 거리가 갱신될 때 decrease-key가 호출됩니다.

  • 이진 힙 Dijkstra: O((V + E) log V) — decrease-key마다 O(log V)
  • 피보나치 힙 Dijkstra: O(V log V + E) — decrease-key가 O(1)

간선이 많은 밀집 그래프에서 이론적 차이가 큽니다.

구조

피보나치 힙은 여러 개의 트리로 구성된 "느슨한 숲" 입니다.

PLAINTEXT
피보나치 힙:
  min → [3] ↔ [7] ↔ [18]
         |         |
        [8]      [24]
         |
        [21]
  • 최소 노드를 가리키는 포인터만 유지
  • 삽입은 새 트리를 추가하기만 하면 됨 → O(1)
  • delete-min에서 트리들을 합치며 정리 (consolidation)

왜 "피보나치"인가

트리를 합칠 때 같은 차수(degree)의 트리끼리 합치는데, 이때 차수 d인 트리의 최소 노드 수가 피보나치 수 F(d+2)와 관련됩니다. 이 성질이 amortized 분석의 핵심입니다.

간략한 동작 원리

PLAINTEXT
1. Insert: 새 트리를 루트 리스트에 추가 (O(1))
   [3] ↔ [7] ↔ [1]  ← 1 추가

2. Delete-Min: 최소 노드 제거 → 자식들을 루트 리스트에 추가
   → Consolidation: 같은 차수의 트리를 합침

3. Decrease-Key: 값 감소 → 부모보다 작아지면 잘라서 루트로 올림 (O(1))
   → 부모가 자식 2개 이상 잃으면 부모도 잘라냄 (Cascading Cut)

실무에서 단순 힙을 선호하는 이유

피보나치 힙이 이론적으로 뛰어남에도 실무에서 거의 사용되지 않는 이유가 있습니다.

1. 상수 계수

PLAINTEXT
이진 힙 삽입:   배열에 값 하나 추가 + sift up
피보나치 힙 삽입: 노드 객체 생성 + 포인터 여러 개 조정

실제 시간: 이진 힙이 5~10배 빠를 수 있음

2. 캐시 효율

PLAINTEXT
이진 힙:     [1, 3, 5, 7, 9, 11]  ← 연속 메모리, 캐시 적중률 높음
피보나치 힙:  각 노드가 개별 객체    ← 메모리 분산, 캐시 미스 빈번

3. 구현 복잡도

  • 이진 힙: 50줄 이내
  • 피보나치 힙: 수백 줄, 디버깅 어려움

4. 실제 벤치마크

대부분의 실제 시나리오에서 이진 힙이 피보나치 힙보다 빠릅니다. 이론적 우위는 n이 수백만 이상 이고 decrease-key가 매우 빈번한 극단적 상황에서만 나타납니다.

다른 힙 변형들

d-ary Heap

이진 힙을 일반화하여 각 노드가 d개의 자식을 가지는 힙입니다.

PLAINTEXT
4-ary 힙:
        1
    / | | \
   3  5  7  9
  • d를 키우면 트리 높이가 줄어들어 decrease-key가 빨라짐
  • 하지만 sift-down에서 d개 자식을 비교해야 하므로 상수 계수 증가
  • 보통 d=4가 캐시 효율 면에서 최적이라는 실험 결과가 있습니다

Pairing Heap

피보나치 힙의 단순화 버전입니다.

JAVA
// Pairing Heap 노드
class PairingNode<T> {
    T value;
    PairingNode<T> child;   // 첫 번째 자식
    PairingNode<T> sibling; // 형제
}
  • 구현이 피보나치 힙보다 훨씬 간단
  • 실제 성능도 피보나치 힙과 비슷하거나 더 나음
  • decrease-key가 amortized O(log log n)으로 추정 (아직 정확한 증명은 없음)

실무에서의 선택 가이드

상황추천
일반적인 우선순위 큐PriorityQueue (이진 힙)
최솟값과 최댓값 모두 필요TreeMap 또는 두 개의 힙
중앙값 실시간 유지** 최소 힙 + 최대 힙**
스레드 안전PriorityBlockingQueue
대규모 그래프 알고리즘 (학술)피보나치 힙 고려
대규모 그래프 알고리즘 (실무)** 이진 힙** 또는 d-ary 힙
JAVA
// 스레드 안전한 우선순위 큐
PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();

// 프로듀서 스레드
queue.put(new Task(1, "긴급 작업"));

// 컨슈머 스레드
Task task = queue.take(); // 없으면 블로킹

정리

  • ** 이중 우선순위 큐 **는 최솟값과 최댓값을 모두 효율적으로 처리하며, 두 개의 힙이나 TreeMap으로 구현할 수 있습니다.
  • ** 피보나치 힙 **은 이론적으로 decrease-key O(1)을 달성하지만, 상수 계수와 캐시 효율 문제로 실무에서는 거의 사용되지 않습니다.
  • ** 실무에서는 이진 힙(PriorityQueue)**이 최선의 선택인 경우가 대부분입니다. 간결하고, 빠르고, 검증되었습니다.
  • 중앙값 유지, Top-K 같은 패턴에서는 ** 두 개의 힙을 조합 **하는 기법이 유용합니다.
댓글 로딩 중...