힙 변형 — 이중 힙, 피보나치 힙, 그리고 실무의 선택
최솟값도 빠르게, 최댓값도 빠르게 꺼내야 한다면? 그리고 이론상 가장 빠른 힙은 왜 실무에서 잘 안 쓸까요?
이중 우선순위 큐
이중 우선순위 큐(Double-Ended Priority Queue, DEPQ)는 최솟값과 최댓값을 모두 효율적으로 조회하고 삭제할 수 있는 자료구조입니다.
왜 필요한가
- 일반 최소 힙: 최솟값 O(1) 조회, 최댓값 O(n) 조회
- 일반 최대 힙: 최댓값 O(1) 조회, 최솟값 O(n) 조회
- ** 이중 힙 **: 최솟값과 최댓값 모두 O(1) 조회
방법 1: 두 개의 힙 사용
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으로 간결하게 구현할 수 있습니다.
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)이지만, 구현이 간결하고 안정적입니다.
활용 사례
- ** 슬라이딩 윈도우 중앙값 **: 최소 힙과 최대 힙으로 중앙값을 유지
- ** 캐시 교체 정책 **: 가장 오래된 것과 가장 최근 것 모두 접근 필요
- ** 범위 필터링 **: 최솟값과 최댓값 기준 이상치 제거
중앙값 유지 — 대표적 패턴
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년에 발표된, ** 이론적으로 가장 효율적인 힙** 중 하나입니다.
시간 복잡도 비교
| 연산 | 이진 힙 | 피보나치 힙 |
|---|---|---|
| insert | O(log n) | O(1) amortized |
| peek | O(1) | O(1) |
| poll (delete-min) | O(log n) | O(log n) amortized |
| decrease-key | O(log n) | O(1) amortized |
| merge | O(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)
간선이 많은 밀집 그래프에서 이론적 차이가 큽니다.
구조
피보나치 힙은 여러 개의 트리로 구성된 "느슨한 숲" 입니다.
피보나치 힙:
min → [3] ↔ [7] ↔ [18]
| |
[8] [24]
|
[21]
- 최소 노드를 가리키는 포인터만 유지
- 삽입은 새 트리를 추가하기만 하면 됨 → O(1)
- delete-min에서 트리들을 합치며 정리 (consolidation)
왜 "피보나치"인가
트리를 합칠 때 같은 차수(degree)의 트리끼리 합치는데, 이때 차수 d인 트리의 최소 노드 수가 피보나치 수 F(d+2)와 관련됩니다. 이 성질이 amortized 분석의 핵심입니다.
간략한 동작 원리
1. Insert: 새 트리를 루트 리스트에 추가 (O(1))
[3] ↔ [7] ↔ [1] ← 1 추가
2. Delete-Min: 최소 노드 제거 → 자식들을 루트 리스트에 추가
→ Consolidation: 같은 차수의 트리를 합침
3. Decrease-Key: 값 감소 → 부모보다 작아지면 잘라서 루트로 올림 (O(1))
→ 부모가 자식 2개 이상 잃으면 부모도 잘라냄 (Cascading Cut)
실무에서 단순 힙을 선호하는 이유
피보나치 힙이 이론적으로 뛰어남에도 실무에서 거의 사용되지 않는 이유가 있습니다.
1. 상수 계수
이진 힙 삽입: 배열에 값 하나 추가 + sift up
피보나치 힙 삽입: 노드 객체 생성 + 포인터 여러 개 조정
실제 시간: 이진 힙이 5~10배 빠를 수 있음
2. 캐시 효율
이진 힙: [1, 3, 5, 7, 9, 11] ← 연속 메모리, 캐시 적중률 높음
피보나치 힙: 각 노드가 개별 객체 ← 메모리 분산, 캐시 미스 빈번
3. 구현 복잡도
- 이진 힙: 50줄 이내
- 피보나치 힙: 수백 줄, 디버깅 어려움
4. 실제 벤치마크
대부분의 실제 시나리오에서 이진 힙이 피보나치 힙보다 빠릅니다. 이론적 우위는 n이 수백만 이상 이고 decrease-key가 매우 빈번한 극단적 상황에서만 나타납니다.
다른 힙 변형들
d-ary Heap
이진 힙을 일반화하여 각 노드가 d개의 자식을 가지는 힙입니다.
4-ary 힙:
1
/ | | \
3 5 7 9
- d를 키우면 트리 높이가 줄어들어 decrease-key가 빨라짐
- 하지만 sift-down에서 d개 자식을 비교해야 하므로 상수 계수 증가
- 보통 d=4가 캐시 효율 면에서 최적이라는 실험 결과가 있습니다
Pairing Heap
피보나치 힙의 단순화 버전입니다.
// 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 힙 |
// 스레드 안전한 우선순위 큐
PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
// 프로듀서 스레드
queue.put(new Task(1, "긴급 작업"));
// 컨슈머 스레드
Task task = queue.take(); // 없으면 블로킹
정리
- ** 이중 우선순위 큐 **는 최솟값과 최댓값을 모두 효율적으로 처리하며, 두 개의 힙이나 TreeMap으로 구현할 수 있습니다.
- ** 피보나치 힙 **은 이론적으로 decrease-key O(1)을 달성하지만, 상수 계수와 캐시 효율 문제로 실무에서는 거의 사용되지 않습니다.
- ** 실무에서는 이진 힙(PriorityQueue)**이 최선의 선택인 경우가 대부분입니다. 간결하고, 빠르고, 검증되었습니다.
- 중앙값 유지, Top-K 같은 패턴에서는 ** 두 개의 힙을 조합 **하는 기법이 유용합니다.
댓글 로딩 중...