问题背景
在图论算法中,可并堆(Mergeable Heap)是一类支持高效合并操作的数据结构。经典应用包括:
– Dijkstra最短路径算法中的优先队列
– Prim最小生成树算法
– 哈夫曼编码
– 任务调度系统
传统二叉堆的 insert、deleteMin、decreaseKey 操作都是 $O(\log n)$,但合并两个堆需要 $O(n)$ 时间。可并堆(如二项堆、斐波那契堆、配对堆)将合并优化到 $O(1)$(均摊)。
配对堆(Pairing Heap)由Fredman、Sedgewick、Sleator和Tarjan于1986年提出。它在实际运行中表现出与斐波那契堆相当甚至更优的性能,同时实现复杂度远低于斐波那契堆,是现代算法竞赛和工业界的首选可并堆之一。
核心原理
结构特点
配对堆是一棵多叉树,满足堆性质:每个节点的值不大于(或不小于)其子节点的值。
- 根节点:存储堆中的最小值
- 子节点:以双向循环链表组织,便于快速合并和删除
核心操作
1. 合并(Merge)
将两个配对堆合并为一个。比较两个堆的根节点,将根较大的堆作为根较小的堆的子树。
merge(A, B):
if A is null: return B
if B is null: return A
if A.val > B.val: swap(A, B)
// A.val <= B.val,将B作为A的子节点
B.next = A.child
if A.child != null: A.child.prev = B
A.child = B
B.prev = A
return A
时间复杂度:$O(1)$
2. 插入(Insert)
将单个元素视为一个单节点堆,与原堆合并。
时间复杂度:$O(1)$
3. 删除最小值(DeleteMin)
删除根节点后,需要对根的所有子节点重新合并。配对堆采用两趟合并法(Two-pass Merging):
- 第一趟(从左到右配对):将子节点从左到右两两配对合并
- 第二趟(从右到左累积):将配对合并的结果从右到左依次合并
时间复杂度:$O(\log n)$ 均摊
4. 减小键值(DecreaseKey)
将某节点的值减小后,如果破坏了堆性质,直接将该节点从父节点处切断,然后与原堆合并。
时间复杂度:$O(1)$ 均摊
Java实现
import java.util.*;
/**
* 配对堆(Pairing Heap)实现
* 支持O(1)插入/合并,O(log n)均摊删除最小值
* 基于多叉树+双向链表结构
*/
public class PairingHeap<T extends Comparable<T>> {
/**
* 节点类:多叉树节点,子节点用双向循环链表组织
*/
private static class Node<T> {
T key; // 键值
Node<T> parent; // 父节点
Node<T> child; // 第一个子节点
Node<T> next; // 右兄弟
Node<T> prev; // 左兄弟(或父节点,如果是第一个子节点)
Node(T key) {
this.key = key;
this.parent = null;
this.child = null;
this.next = this; // 循环链表初始化
this.prev = this;
}
}
private Node<T> root; // 根节点
private int size; // 节点总数
public PairingHeap() {
this.root = null;
this.size = 0;
}
/**
* 判断堆是否为空
*/
public boolean isEmpty() {
return root == null;
}
/**
* 获取堆中元素个数
*/
public int size() {
return size;
}
/**
* 查看最小值(不删除)
*/
public T findMin() {
if (root == null) return null;
return root.key;
}
/**
* 合并两个配对堆
* 时间复杂度:O(1)
*/
public void merge(PairingHeap<T> other) {
if (other == null || other.root == null) return;
this.root = mergeRoots(this.root, other.root);
this.size += other.size;
other.root = null;
other.size = 0;
}
/**
* 合并两棵子树,返回新根
*/
private Node<T> mergeRoots(Node<T> a, Node<T> b) {
if (a == null) return b;
if (b == null) return a;
// 确保 a.key <= b.key
if (a.key.compareTo(b.key) > 0) {
Node<T> tmp = a;
a = b;
b = tmp;
}
// 将 b 作为 a 的子节点(插入到子节点链表头部)
if (a.child == null) {
a.child = b;
b.parent = a;
b.next = b;
b.prev = b;
} else {
// 将b插入到a.child的循环链表中
Node<T> firstChild = a.child;
b.next = firstChild;
b.prev = firstChild.prev;
firstChild.prev.next = b;
firstChild.prev = b;
b.parent = a;
a.child = b; // b成为新的第一个子节点
}
return a;
}
/**
* 插入元素
* 时间复杂度:O(1)
*/
public void insert(T key) {
Node<T> newNode = new Node<>(key);
if (root == null) {
root = newNode;
} else {
root = mergeRoots(root, newNode);
}
size++;
}
/**
* 删除并返回最小值
* 时间复杂度:O(log n) 均摊
*/
public T deleteMin() {
if (root == null) return null;
T minKey = root.key;
Node<T> firstChild = root.child;
// 根节点被删除
root.child = null;
root = null;
size--;
if (firstChild == null) {
root = null;
return minKey;
}
// 断开循环链表,收集所有子节点
List<Node<T>> children = new ArrayList<>();
Node<T> cur = firstChild;
do {
Node<T> next = cur.next;
cur.parent = null;
cur.next = cur;
cur.prev = cur;
children.add(cur);
cur = next;
} while (cur != firstChild);
// 两趟合并法
root = twoPassMerge(children);
return minKey;
}
/**
* 两趟合并法
* 第一趟:从左到右两两配对合并
* 第二趟:从右到左依次合并结果
*/
private Node<T> twoPassMerge(List<Node<T>> nodes) {
if (nodes.isEmpty()) return null;
if (nodes.size() == 1) return nodes.get(0);
// 第一趟:从左到右两两合并
List<Node<T>> merged = new ArrayList<>();
int i = 0;
while (i + 1 < nodes.size()) {
merged.add(mergeRoots(nodes.get(i), nodes.get(i + 1)));
i += 2;
}
// 如果奇数个,最后一个直接加入
if (i < nodes.size()) {
merged.add(nodes.get(i));
}
// 第二趟:从右到左依次合并
Node<T> result = merged.get(merged.size() - 1);
for (int j = merged.size() - 2; j >= 0; j--) {
result = mergeRoots(result, merged.get(j));
}
return result;
}
/**
* 减小键值(需要知道节点引用)
* 时间复杂度:O(1) 均摊
*/
public void decreaseKey(Node<T> node, T newKey) {
if (newKey.compareTo(node.key) >= 0) {
throw new IllegalArgumentException("New key must be smaller");
}
node.key = newKey;
// 如果node是根节点,直接返回
if (node == root) return;
// 从父节点的子节点链表中移除node
if (node.next == node) {
// node是唯一的子节点
node.parent.child = null;
} else {
// 从循环链表中移除
node.prev.next = node.next;
node.next.prev = node.prev;
if (node.parent.child == node) {
node.parent.child = node.next;
}
}
node.parent = null;
node.next = node;
node.prev = node;
// 将node作为新堆与root合并
root = mergeRoots(root, node);
}
/**
* 获取根节点引用(用于decreaseKey)
*/
public Node<T> getRoot() {
return root;
}
/**
* 打印堆结构(调试用)
*/
public void printHeap() {
System.out.println("PairingHeap (size=" + size + "):");
if (root != null) {
printNode(root, 0);
}
}
private void printNode(Node<T> node, int depth) {
String indent = " ".repeat(depth);
System.out.println(indent + node.key);
if (node.child != null) {
Node<T> cur = node.child;
do {
printNode(cur, depth + 1);
cur = cur.next;
} while (cur != node.child);
}
}
/**
* 主函数:测试配对堆
*/
public static void main(String[] args) {
// 测试1:基本操作
System.out.println("=== 测试1:基本插入与删除 ===");
PairingHeap<Integer> heap = new PairingHeap<>();
int[] values = {5, 3, 8, 1, 9, 2, 7, 4, 6};
for (int v : values) {
heap.insert(v);
System.out.println("插入 " + v + ",当前最小值: " + heap.findMin());
}
System.out.println("\n堆结构:");
heap.printHeap();
System.out.println("\n依次删除最小值:");
while (!heap.isEmpty()) {
System.out.print(heap.deleteMin() + " ");
}
System.out.println();
// 测试2:合并操作
System.out.println("\n=== 测试2:堆合并 ===");
PairingHeap<Integer> heap1 = new PairingHeap<>();
PairingHeap<Integer> heap2 = new PairingHeap<>();
for (int i = 1; i <= 5; i++) heap1.insert(i * 2); // 2,4,6,8,10
for (int i = 1; i <= 5; i++) heap2.insert(i * 2 - 1); // 1,3,5,7,9
System.out.println("堆1最小值: " + heap1.findMin());
System.out.println("堆2最小值: " + heap2.findMin());
heap1.merge(heap2);
System.out.println("合并后最小值: " + heap1.findMin());
System.out.println("合并后大小: " + heap1.size());
// 测试3:与Java优先队列性能对比
System.out.println("\n=== 测试3:性能对比(配对堆 vs PriorityQueue) ===");
int n = 100000;
Random rand = new Random(42);
// PriorityQueue测试
long start1 = System.currentTimeMillis();
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int i = 0; i < n; i++) pq.add(rand.nextInt(n));
while (!pq.isEmpty()) pq.poll();
long time1 = System.currentTimeMillis() - start1;
// 配对堆测试
rand = new Random(42);
long start2 = System.currentTimeMillis();
PairingHeap<Integer> ph = new PairingHeap<>();
for (int i = 0; i < n; i++) ph.insert(rand.nextInt(n));
while (!ph.isEmpty()) ph.deleteMin();
long time2 = System.currentTimeMillis() - start2;
System.out.println("PriorityQueue: " + time1 + " ms");
System.out.println("PairingHeap: " + time2 + " ms");
// 测试4:大量重复元素
System.out.println("\n=== 测试4:大量重复元素 ===");
PairingHeap<Integer> heap3 = new PairingHeap<>();
for (int i = 0; i < 20; i++) heap3.insert(5);
heap3.insert(1);
heap3.insert(10);
System.out.println("最小值: " + heap3.findMin());
System.out.println("大小: " + heap3.size());
while (!heap3.isEmpty()) {
System.out.print(heap3.deleteMin() + " ");
}
System.out.println();
}
}
摊还分析
配对堆的精确时间复杂度分析是一个长期未解决的开放问题。目前公认的结果如下:
| 操作 | 均摊复杂度 | 备注 |
|---|---|---|
| insert | $O(1)$ | 直接合并 |
| merge | $O(1)$ | 直接合并 |
| findMin | $O(1)$ | 直接访问根 |
| deleteMin | $O(\log n)$ | 两趟合并法 |
| decreaseKey | $O(1)$ | 直接切断合并 |
为什么两趟合并法有效?
第一趟将 $k$ 个子节点两两合并,产生约 $k/2$ 棵新树。第二趟将这些树从右到左合并。
这种策略保证:最近插入/合并的节点优先参与合并,使得树结构保持相对平衡。与单次从左到右顺序合并相比,两趟合并法在摊还意义下将树高控制在 $O(\log n)$。
与斐波那契堆的对比
| 特性 | 配对堆 | 斐波那契堆 |
|---|---|---|
| insert | $O(1)$ | $O(1)$ |
| merge | $O(1)$ | $O(1)$ |
| deleteMin | $O(\log n)$ | $O(\log n)$ |
| decreaseKey | $O(1)$(实验) | $O(1)$(理论) |
| 实现复杂度 | 简单 | 复杂(需要标记位、级联切断) |
| 实际性能 | 优秀 | 良好(常数较大) |
应用场景
- Dijkstra最短路径:
decreaseKey优化使总复杂度从 $O(m \log n)$ 提升到 $O(m + n \log n)$ - Prim最小生成树:同上
- 任务调度:支持动态优先级调整和任务合并
- 实时系统:简单实现降低bug风险和内存开销
扩展阅读
配对堆的变种包括:
– Splay堆:基于伸展树实现
– 斜堆(Skew Heap):更简单的自调整堆
– Rank配对堆:引入秩信息优化合并策略
– 最优合并树:哈夫曼编码中的特殊应用