引言
在算法竞赛和工程实践中,优先队列(堆)是一种极为常用的数据结构。然而,标准的二叉堆不支持高效的两个堆合并操作——合并两个二叉堆需要 O(n) 的时间。左偏树(Leftist Tree)是一种可并堆(Mergeable Heap),它能在 O(log n) 时间内完成两个堆的合并,同时还能高效支持插入、删除最值等操作。本文将用 Java 完整实现左偏树,并展示其在多路归并等场景中的应用。
左偏树的核心概念
什么是左偏树
左偏树是一种满足堆性质(大根堆或小根堆)的二叉树,同时它还满足左偏性质:对于任意节点,其左儿子的零距离(null path length)不小于右儿子的零距离。
零距离(NPL, Null Path Length)
零距离是指从一个节点到其最近的空子节点的距离。具体来说:
– 空节点的 NPL 为 -1
– 叶子节点的 NPL 为 0
– 非叶子节点的 NPL = min(左儿子NPL, 右儿子NPL) + 1
左偏性质
对于左偏树中的每个节点,都有:
npl(leftChild) >= npl(rightChild)
这一性质保证了树的右路径(从根节点一直往右走的路径)长度被限制在 O(log n) 内,从而使合并操作能在 O(log n) 时间内完成。
算法核心操作
合并(Merge)
合并是左偏树最核心的操作,其他操作都可以基于合并实现:
- 若其中一个堆为空,返回另一个堆
- 比较两个堆的根节点,将值较大的(假设是大根堆)作为新根
- 递归合并新根的右子树与另一个堆
- 维护左偏性质:若左子树的NPL小于右子树,交换左右子树
- 更新当前节点的NPL
基于合并的其他操作
- 插入:将新节点视为单节点堆,与原堆合并 — O(log n)
- 删除最大值:合并根节点的左右子树 — O(log n)
- 获取最大值:直接读取根节点值 — O(1)
Java 完整实现
import java.util.*;
/**
* 左偏树(Leftist Tree)实现 —— 可并堆
* 支持 O(log n) 时间复杂度的堆合并操作
*/
public class LeftistTree {
/**
* 左偏树节点
*/
static class Node {
int value; // 节点值
int npl; // 零距离(Null Path Length)
Node left; // 左子节点
Node right; // 右子节点
public Node(int value) {
this.value = value;
this.npl = 0; // 新节点的NPL初始为0
this.left = null;
this.right = null;
}
}
private Node root; // 根节点
private int size; // 堆中元素数量
public LeftistTree() {
this.root = null;
this.size = 0;
}
/**
* 获取堆的大小
*/
public int size() {
return size;
}
/**
* 判断堆是否为空
*/
public boolean isEmpty() {
return root == null;
}
/**
* 获取最大值(大根堆)
* 时间复杂度:O(1)
*/
public Integer peekMax() {
return root == null ? null : root.value;
}
/**
* 合并两个左偏树
* 这是左偏树的核心操作,时间复杂度:O(log n)
* @param h1 第一个堆的根
* @param h2 第二个堆的根
* @return 合并后的堆的根
*/
private Node merge(Node h1, Node h2) {
// 若其中一个为空,返回另一个
if (h1 == null) return h2;
if (h2 == null) return h1;
// 确保 h1 的值较大(大根堆)
if (h1.value < h2.value) {
Node temp = h1;
h1 = h2;
h2 = temp;
}
// 递归合并 h1 的右子树与 h2
h1.right = merge(h1.right, h2);
// 维护左偏性质:左子树的NPL应大于等于右子树
int leftNpl = (h1.left == null) ? -1 : h1.left.npl;
int rightNpl = (h1.right == null) ? -1 : h1.right.npl;
if (leftNpl < rightNpl) {
// 交换左右子树
Node temp = h1.left;
h1.left = h1.right;
h1.right = temp;
}
// 更新当前节点的NPL
h1.npl = (h1.right == null) ? 0 : h1.right.npl + 1;
return h1;
}
/**
* 将另一个左偏树合并到当前堆
*/
public void merge(LeftistTree other) {
if (other == null || other.root == null) return;
this.root = merge(this.root, other.root);
this.size += other.size;
other.root = null;
other.size = 0;
}
/**
* 插入一个新元素
* 将新元素视为单节点堆,与当前堆合并
* 时间复杂度:O(log n)
*/
public void insert(int value) {
Node newNode = new Node(value);
this.root = merge(this.root, newNode);
this.size++;
}
/**
* 删除并返回最大值
* 合并根节点的左右子树
* 时间复杂度:O(log n)
*/
public Integer extractMax() {
if (root == null) return null;
int maxValue = root.value;
this.root = merge(root.left, root.right);
this.size--;
return maxValue;
}
/**
* 中序遍历(用于调试和验证)
*/
public void inorderTraversal() {
System.out.print("Inorder: ");
inorder(root);
System.out.println();
}
private void inorder(Node node) {
if (node == null) return;
inorder(node.left);
System.out.print(node.value + " ");
inorder(node.right);
}
/**
* 验证左偏性质(用于调试)
*/
public boolean validateLeftistProperty() {
return validateNode(root);
}
private boolean validateNode(Node node) {
if (node == null) return true;
int leftNpl = (node.left == null) ? -1 : node.left.npl;
int rightNpl = (node.right == null) ? -1 : node.right.npl;
// 检查左偏性质
if (leftNpl < rightNpl) {
System.err.println("Leftist property violated at node " + node.value);
return false;
}
// 检查NPL计算是否正确
int expectedNpl = (node.right == null) ? 0 : node.right.npl + 1;
if (node.npl != expectedNpl) {
System.err.println("NPL incorrect at node " + node.value);
return false;
}
return validateNode(node.left) && validateNode(node.right);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("LeftistTree(size=").append(size).append("): ");
List<Integer> elements = new ArrayList<>();
collectElements(root, elements);
sb.append(elements);
return sb.toString();
}
private void collectElements(Node node, List<Integer> list) {
if (node == null) return;
list.add(node.value);
collectElements(node.left, list);
collectElements(node.right, list);
}
// ==================== 应用示例 ====================
/**
* K路归并:合并K个已排序的数组
* 使用左偏树维护K个数组的当前最小值
*/
public static int[] kWayMerge(int[][] sortedArrays) {
int k = sortedArrays.length;
int totalLen = 0;
for (int[] arr : sortedArrays) totalLen += arr.length;
int[] result = new int[totalLen];
int[] indices = new int[k]; // 每个数组当前的读取位置
// 自定义比较器,用于小根堆
// 这里我们使用多个单节点左偏树实现优先队列
PriorityQueue<Element> pq = new PriorityQueue<>(Comparator.comparingInt(e -> e.value));
// 初始化:将每个数组的第一个元素加入优先队列
for (int i = 0; i < k; i++) {
if (sortedArrays[i].length > 0) {
pq.offer(new Element(sortedArrays[i][0], i));
}
}
int pos = 0;
while (!pq.isEmpty()) {
Element min = pq.poll();
result[pos++] = min.value;
int arrIdx = min.arrayIndex;
indices[arrIdx]++;
if (indices[arrIdx] < sortedArrays[arrIdx].length) {
pq.offer(new Element(sortedArrays[arrIdx][indices[arrIdx]], arrIdx));
}
}
return result;
}
static class Element {
int value;
int arrayIndex;
Element(int value, int arrayIndex) {
this.value = value;
this.arrayIndex = arrayIndex;
}
}
/**
* 演示:多个左偏树的合并操作
*/
public static void demoMerge() {
System.out.println("=== Leftist Tree Demo ===\n");
// 创建第一个堆
LeftistTree heap1 = new LeftistTree();
heap1.insert(10);
heap1.insert(20);
heap1.insert(5);
heap1.insert(30);
System.out.println("Heap1: " + heap1);
System.out.println("Heap1 max: " + heap1.peekMax());
// 创建第二个堆
LeftistTree heap2 = new LeftistTree();
heap2.insert(15);
heap2.insert(25);
heap2.insert(8);
System.out.println("Heap2: " + heap2);
// 合并两个堆
heap1.merge(heap2);
System.out.println("After merge: " + heap1);
System.out.println("Merged heap size: " + heap1.size());
System.out.println("Valid leftist property: " + heap1.validateLeftistProperty());
// 依次取出最大值
System.out.print("Extract max sequence: ");
while (!heap1.isEmpty()) {
System.out.print(heap1.extractMax() + " ");
}
System.out.println();
}
public static void main(String[] args) {
demoMerge();
// K路归并示例
System.out.println("\n=== K-Way Merge Demo ===");
int[][] arrays = {
{1, 4, 7, 10},
{2, 5, 8, 11},
{3, 6, 9, 12}
};
int[] merged = kWayMerge(arrays);
System.out.println("Merged array: " + Arrays.toString(merged));
}
}
算法复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 合并(Merge) | O(log n) | 核心操作,沿右路径递归 |
| 插入(Insert) | O(log n) | 单节点堆与原堆合并 |
| 删除最大值 | O(log n) | 合并左右子树 |
| 查询最大值 | O(1) | 直接读取根节点 |
| 多堆合并 | O(log Σnᵢ) | 逐个合并K个堆 |
左偏树 vs 其他堆结构
| 特性 | 二叉堆 | 左偏树 | 斐波那契堆 | 配对堆 |
|---|---|---|---|---|
| 合并 | O(n) | O(log n) | O(1) 均摊 | O(log n) 均摊 |
| 插入 | O(log n) | O(log n) | O(1) 均摊 | O(1) |
| 删除最值 | O(log n) | O(log n) | O(log n) 均摊 | O(log n) 均摊 |
| 实现复杂度 | 简单 | 中等 | 复杂 | 中等 |
| 常数因子 | 小 | 中等 | 大 | 小 |
左偏树在实现复杂度与性能之间取得了良好的平衡,特别适合需要频繁合并堆的场景。
应用场景
左偏树在以下场景中表现出色:
- K路归并:合并多个有序序列时,用左偏树维护各序列当前最小值
- 动态图算法:如最小生成树的动态维护,需要合并连通分量的边集
- 并行计算:多线程环境下各自维护局部堆,最后合并结果
- 哈夫曼编码优化:在批量构建哈夫曼树时,可并行构建子树再合并
总结
左偏树通过巧妙的左偏性质,将堆合并的时间复杂度从 O(n) 降低到 O(log n)。其结构简单、实现优雅,是处理可合并堆问题的理想选择。掌握左偏树,将帮助你在需要频繁合并优先队列的场景中写出更高效的代码。