6-5 偏堆
偏堆(Skew Heap)
偏堆(Skew Heap)是一种自调整的可合并堆(Self-Adjusting Mergeable Heap),是左偏堆(Leftist Heap)的简化版本。它的核心思想是:在每次合并操作之后,无条件地交换左右子节点,而不是像左偏堆那样根据零路径长(Null Path Length, npl)来决定是否交换。
这种"始终交换"的策略看似粗暴,但通过摊还分析(Amortized Analysis)可以证明,所有操作的摊还时间复杂度均为 O(log n),与左偏堆相当。偏堆与左偏堆的关系,类似于 Splay 树与 AVL 树的关系——前者牺牲了最坏情况性能,换取了更简洁的实现代码。
偏堆在实际中的应用包括:
- 可合并优先队列(Mergeable Priority Queue),代码比左偏堆更简洁
- 需要频繁合并操作的图算法
- 作为理解摊还分析的教学数据结构
与左偏堆的区别
偏堆和左偏堆都是基于二叉树的可合并堆,核心操作都是合并(Merge)。它们的主要区别在于合并后如何处理子节点:
| 特性 | 左偏堆(Leftist Heap) | 偏堆(Skew Heap) |
|---|---|---|
| 零路径长(npl)追踪 | 需要,每个节点存储 npl 值 | 不需要 |
| 子节点交换策略 | 条件交换:仅当 npl(left) < npl(right) 时交换 | 无条件交换:每次合并后始终交换 |
| 合并最坏时间复杂度 | O(log n) | O(n) |
| 合并摊还时间复杂度 | O(log n) | O(log n) |
| 插入最坏时间复杂度 | O(log n) | O(n) |
| 插入摊还时间复杂度 | O(log n) | O(log n) |
| 提取最小值最坏时间复杂度 | O(log n) | O(n) |
| 提取最小值摊还时间复杂度 | O(log n) | O(log n) |
| 代码复杂度 | 中等(需维护 npl) | 简单(无额外字段) |
关键区别总结:
- 左偏堆通过 npl 值严格保证右路径长度不超过 O(log n),因此最坏情况也有保障,代价是每个节点需要额外的 npl 字段和条件判断逻辑
- 偏堆放弃了最坏情况保证,始终交换子节点,通过摊还分析获得 O(log n) 的平均性能,代码更加简洁
节点定义
偏堆的节点定义非常简单——只需要存储键值和左右子节点指针,不需要零路径长(npl)字段,这也是它比左偏堆简洁的根本原因。
C++ 节点定义
struct SkewNode {
int key; // key value stored in node
SkewNode* left; // pointer to left child
SkewNode* right; // pointer to right child
// constructor
SkewNode(int val) : key(val), left(nullptr), right(nullptr) {}
};
与左偏堆的节点相比,这里没有 npl 字段。偏堆完全依赖"始终交换子节点"来维持平衡,无需额外信息。
C 节点定义
#include <stdlib.h>
typedef struct SkewNode {
int key; // key value
struct SkewNode* left; // left child
struct SkewNode* right; // right child
} SkewNode;
// create a new node with given key
SkewNode* createNode(int val) {
SkewNode* node = (SkewNode*)malloc(sizeof(SkewNode));
node->key = val;
node->left = NULL;
node->right = NULL;
return node;
}
C 语言版本使用 typedef 定义结构体,通过 malloc 动态分配内存。
Python 节点定义
class SkewNode:
def __init__(self, key):
self.key = key # key value
self.left = None # left child
self.right = None # right child
Python 版本最为简洁,使用 None 表示空指针,无需手动管理内存。
Go 节点定义
// SkewNode represents a node in the skew heap
type SkewNode struct {
key int // key value
left *SkewNode // pointer to left child
right *SkewNode // pointer to right child
}
Go 版本使用结构体定义节点,指针类型 *SkewNode 表示子节点引用。与左偏堆不同,偏堆的节点不需要 npl 字段,这也是偏堆比左偏堆简洁的根本原因。left 和 right 默认为 nil。
合并操作(Merge)-- 核心
合并(Merge)是偏堆的核心操作,所有其他操作(插入、提取最小值)都建立在合并之上。偏堆的合并采用递归方式,步骤如下:
- 如果其中一个堆为空,返回另一个堆
- 比较两个堆的根节点,确保较小的根成为新根(最小堆性质)
- 递归地将新根的右子树与较大的堆合并
- 无条件交换新根的左右子节点
第 4 步是偏堆的关键——不像左偏堆那样检查 npl 值,偏堆在每次合并后都直接交换左右子节点。这种简单的策略通过摊还分析可以保证 O(log n) 的平均性能。
合并示例:合并堆 H1(根为 3)和堆 H2(根为 5)
初始状态:
H1: H2:
3 5
/ \ / \
6 10 8 15
Step 1: 比较 3 和 5,3 更小成为新根
Step 2: 递归合并 H1 的右子树(根 10)与 H2(根 5)
比较 10 和 5,5 更小,递归合并 5 的右子树(15)与 10
比较 15 和 10,10 更小,递归合并到空 → 返回 15
10 的右子树变为 15,交换 10 的左右子树 → 10 变为叶子
返回 10
5 的右子树变为 10,交换 5 的左右子树
返回 5
Step 3: 3 的右子树变为 5,交换 3 的左右子树
结果:
3
/ \
[合并结果] 6
C++ 合并实现
class SkewHeap {
private:
SkewNode* root;
// recursively merge two heaps
SkewNode* mergeNodes(SkewNode* h1, SkewNode* h2) {
// base cases: if either heap is empty
if (h1 == nullptr) return h2;
if (h2 == nullptr) return h1;
// ensure h1 has the smaller root (min-heap)
if (h1->key > h2->key)
std::swap(h1, h2);
// recursively merge h1->right with h2
h1->right = mergeNodes(h1->right, h2);
// always swap left and right children
std::swap(h1->left, h1->right);
return h1;
}
public:
SkewHeap() : root(nullptr) {}
};
mergeNodes 函数是偏堆的核心:先确保 h1 的根更小,然后递归合并 h1 的右子树和 h2,最后无条件交换 h1 的左右子节点。交换操作确保了右路径不会持续增长,从而保证了摊还性能。
C 合并实现
typedef struct {
SkewNode* root;
} SkewHeap;
// recursively merge two heaps
SkewNode* mergeNodes(SkewNode* h1, SkewNode* h2) {
if (!h1) return h2;
if (!h2) return h1;
// ensure h1 has the smaller root
if (h1->key > h2->key) {
SkewNode* temp = h1;
h1 = h2;
h2 = temp;
}
// merge h1->right with h2
h1->right = mergeNodes(h1->right, h2);
// always swap children
SkewNode* temp = h1->left;
h1->left = h1->right;
h1->right = temp;
return h1;
}
// merge two heaps, result stored in q1
void skewMerge(SkewHeap* q1, SkewHeap* q2) {
q1->root = mergeNodes(q1->root, q2->root);
q2->root = NULL;
}
C 语言版本使用临时变量完成交换操作,逻辑与 C++ 版本完全一致。
Python 合并实现
class SkewHeap:
def __init__(self):
self.root = None
def _merge_nodes(self, h1, h2):
"""Recursively merge two heaps."""
if h1 is None:
return h2
if h2 is None:
return h1
# ensure h1 has the smaller root
if h1.key > h2.key:
h1, h2 = h2, h1
# merge h1.right with h2
h1.right = self._merge_nodes(h1.right, h2)
# always swap children
h1.left, h1.right = h1.right, h1.left
return h1
def merge(self, other):
"""Merge another heap into this one."""
self.root = self._merge_nodes(self.root, other.root)
other.root = None
Python 版本利用多重赋值 h1.left, h1.right = h1.right, h1.left 优雅地完成交换,代码最为简洁。三个版本的核心逻辑完全一致:递归合并右子树,然后无条件交换。
Go 合并实现
// SkewHeap represents a skew heap
type SkewHeap struct {
root *SkewNode
}
// mergeNodes recursively merges two heap roots
func mergeNodes(h1, h2 *SkewNode) *SkewNode {
// base cases
if h1 == nil {
return h2
}
if h2 == nil {
return h1
}
// ensure h1 has the smaller root (min-heap)
if h1.key > h2.key {
h1, h2 = h2, h1
}
// merge h1's right subtree with h2
h1.right = mergeNodes(h1.right, h2)
// always swap left and right children
h1.left, h1.right = h1.right, h1.left
return h1
}
Go 版本的合并逻辑与其他语言完全一致。无条件交换子节点通过 h1.left, h1.right = h1.right, h1.left 一行完成。注意与左偏堆不同,这里没有 npl 字段,也不需要条件判断,代码更加简洁。
插入操作
插入(Insert)操作极其简单:创建一个只含单个节点的堆,然后与现有堆合并。这和左偏堆的插入策略完全相同,但实现更简洁。
C++ 插入实现
void insert(int key) {
SkewNode* newNode = new SkewNode(key);
root = mergeNodes(root, newNode);
}
创建单节点后直接调用 mergeNodes,一行代码完成插入。摊还时间复杂度为 O(log n)。
C 插入实现
void skewInsert(SkewHeap* heap, int key) {
SkewNode* newNode = createNode(key);
heap->root = mergeNodes(heap->root, newNode);
}
C 版本同样简洁,创建新节点后合并到堆中。
Python 插入实现
def insert(self, key):
"""Insert a key by merging with a single-node heap."""
new_node = SkewNode(key)
self.root = self._merge_nodes(self.root, new_node)
三个版本的插入操作都是同一个思路:构造单节点再合并。由于合并是偏堆的核心,插入操作只是一个薄包装层。
Go 插入实现
// Insert adds a key to the heap by merging with a single-node heap
func (h *SkewHeap) Insert(key int) {
newNode := &SkewNode{key: key}
h.root = mergeNodes(h.root, newNode)
}
Go 版本使用 &SkewNode{key: key} 创建单节点,然后调用 mergeNodes 完成插入。摊还时间复杂度为 O(log n)。
提取最小值(Extract Min)
提取最小值(Extract Min)的操作步骤:
- 记录根节点的键值(即最小值)
- 删除根节点
- 合并根节点的左右子树,形成新的堆
由于偏堆满足最小堆性质,根节点始终是最小值。
C++ 提取最小值实现
int extractMin() {
if (root == nullptr) throw std::runtime_error("Empty heap");
int minKey = root->key;
SkewNode* oldRoot = root;
root = mergeNodes(root->left, root->right);
delete oldRoot;
return minKey;
}
提取最小值就是移除根节点,然后合并其左右子树。由于合并操作保证摊还 O(log n),提取最小值的摊还复杂度也是 O(log n)。
C 提取最小值实现
int skewExtractMin(SkewHeap* heap) {
if (!heap->root) return -1; // error: empty heap
int minKey = heap->root->key;
SkewNode* oldRoot = heap->root;
heap->root = mergeNodes(oldRoot->left, oldRoot->right);
free(oldRoot);
return minKey;
}
C 版本使用 free 释放被删除的根节点。
Python 提取最小值实现
def extract_min(self):
"""Remove and return the minimum key."""
if self.root is None:
return None
min_key = self.root.key
old_root = self.root
self.root = self._merge_nodes(old_root.left, old_root.right)
# no manual free needed in Python
return min_key
Python 版本无需手动释放内存,垃圾回收器自动处理。三个版本的提取最小值逻辑完全一致:保存最小值,合并左右子树。
Go 提取最小值实现
// ExtractMin removes and returns the minimum key
func (h *SkewHeap) ExtractMin() (int, bool) {
if h.root == nil {
return 0, false // empty heap
}
minKey := h.root.key
oldRoot := h.root
h.root = mergeNodes(oldRoot.left, oldRoot.right)
return minKey, true
}
Go 版本使用多返回值 (int, bool) 处理空堆情况。Go 的垃圾回收器自动回收被删除的根节点,无需手动释放。
完整实现
下面给出 C++、C、Python 三种语言的完整偏堆实现。演示流程为:依次插入 3, 10, 5, 15, 7, 20, 1,然后连续提取最小值(应按升序输出)。
C++ 完整实现
#include <iostream>
#include <algorithm>
#include <stdexcept>
struct SkewNode {
int key;
SkewNode* left;
SkewNode* right;
SkewNode(int val) : key(val), left(nullptr), right(nullptr) {}
};
class SkewHeap {
private:
SkewNode* root;
SkewNode* mergeNodes(SkewNode* h1, SkewNode* h2) {
if (h1 == nullptr) return h2;
if (h2 == nullptr) return h1;
// ensure h1 has the smaller root
if (h1->key > h2->key)
std::swap(h1, h2);
// merge h1->right with h2
h1->right = mergeNodes(h1->right, h2);
// always swap children
std::swap(h1->left, h1->right);
return h1;
}
public:
SkewHeap() : root(nullptr) {}
void insert(int key) {
SkewNode* newNode = new SkewNode(key);
root = mergeNodes(root, newNode);
}
int extractMin() {
if (root == nullptr) throw std::runtime_error("Empty heap");
int minKey = root->key;
SkewNode* oldRoot = root;
root = mergeNodes(root->left, root->right);
delete oldRoot;
return minKey;
}
int getMin() const {
if (root == nullptr) throw std::runtime_error("Empty heap");
return root->key;
}
bool isEmpty() const {
return root == nullptr;
}
};
int main() {
SkewHeap heap;
// insert values
int values[] = {3, 10, 5, 15, 7, 20, 1};
std::cout << "Inserting: ";
for (int v : values) {
std::cout << v << " ";
heap.insert(v);
}
std::cout << std::endl;
std::cout << "Current min: " << heap.getMin() << std::endl;
// extract min multiple times
std::cout << "\nExtracting all elements in order:" << std::endl;
while (!heap.isEmpty()) {
std::cout << " Extracted: " << heap.extractMin() << std::endl;
}
return 0;
}
运行该程序将输出:
Inserting: 3 10 5 15 7 20 1
Current min: 1
Extracting all elements in order:
Extracted: 1
Extracted: 3
Extracted: 5
Extracted: 7
Extracted: 10
Extracted: 15
Extracted: 20
插入 7 个元素后,最小值为 1。连续提取最小值时,所有元素按升序依次弹出,验证了偏堆的最小堆性质。
C 完整实现
#include <stdio.h>
#include <stdlib.h>
typedef struct SkewNode {
int key;
struct SkewNode* left;
struct SkewNode* right;
} SkewNode;
typedef struct {
SkewNode* root;
} SkewHeap;
SkewNode* createNode(int val) {
SkewNode* node = (SkewNode*)malloc(sizeof(SkewNode));
node->key = val;
node->left = NULL;
node->right = NULL;
return node;
}
SkewNode* mergeNodes(SkewNode* h1, SkewNode* h2) {
if (!h1) return h2;
if (!h2) return h1;
// ensure h1 has the smaller root
if (h1->key > h2->key) {
SkewNode* temp = h1; h1 = h2; h2 = temp;
}
// merge h1->right with h2
h1->right = mergeNodes(h1->right, h2);
// always swap children
SkewNode* temp = h1->left;
h1->left = h1->right;
h1->right = temp;
return h1;
}
void skewInsert(SkewHeap* heap, int key) {
SkewNode* newNode = createNode(key);
heap->root = mergeNodes(heap->root, newNode);
}
int skewExtractMin(SkewHeap* heap) {
if (!heap->root) return -1;
int minKey = heap->root->key;
SkewNode* oldRoot = heap->root;
heap->root = mergeNodes(oldRoot->left, oldRoot->right);
free(oldRoot);
return minKey;
}
int skewGetMin(SkewHeap* heap) {
if (!heap->root) return -1;
return heap->root->key;
}
int skewIsEmpty(SkewHeap* heap) {
return heap->root == NULL;
}
int main() {
SkewHeap heap = {NULL};
printf("Inserting: ");
int values[] = {3, 10, 5, 15, 7, 20, 1};
for (int i = 0; i < 7; i++) {
printf("%d ", values[i]);
skewInsert(&heap, values[i]);
}
printf("\n");
printf("Current min: %d\n", skewGetMin(&heap));
printf("\nExtracting all elements in order:\n");
while (!skewIsEmpty(&heap)) {
printf(" Extracted: %d\n", skewExtractMin(&heap));
}
return 0;
}
运行该程序将输出:
Inserting: 3 10 5 15 7 20 1
Current min: 1
Extracting all elements in order:
Extracted: 1
Extracted: 3
Extracted: 5
Extracted: 7
Extracted: 10
Extracted: 15
Extracted: 20
C 语言版本使用结构体包装根指针,通过函数传递 SkewHeap* 操作堆。输出结果与 C++ 版本完全一致。
Python 完整实现
class SkewNode:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
class SkewHeap:
def __init__(self):
self.root = None
def _merge_nodes(self, h1, h2):
"""Recursively merge two heaps."""
if h1 is None:
return h2
if h2 is None:
return h1
# ensure h1 has the smaller root
if h1.key > h2.key:
h1, h2 = h2, h1
# merge h1.right with h2
h1.right = self._merge_nodes(h1.right, h2)
# always swap children
h1.left, h1.right = h1.right, h1.left
return h1
def insert(self, key):
"""Insert a key into the heap."""
new_node = SkewNode(key)
self.root = self._merge_nodes(self.root, new_node)
def extract_min(self):
"""Remove and return the minimum key."""
if self.root is None:
return None
min_key = self.root.key
old_root = self.root
self.root = self._merge_nodes(old_root.left, old_root.right)
return min_key
def get_min(self):
"""Return the minimum key without removing it."""
if self.root is None:
return None
return self.root.key
def is_empty(self):
"""Check if the heap is empty."""
return self.root is None
if __name__ == "__main__":
heap = SkewHeap()
print("Inserting: ", end="")
for v in [3, 10, 5, 15, 7, 20, 1]:
print(v, end=" ")
heap.insert(v)
print()
print(f"Current min: {heap.get_min()}")
print("\nExtracting all elements in order:")
while not heap.is_empty():
print(f" Extracted: {heap.extract_min()}")
运行该程序将输出:
Inserting: 3 10 5 15 7 20 1
Current min: 1
Extracting all elements in order:
Extracted: 1
Extracted: 3
Extracted: 5
Extracted: 7
Extracted: 10
Extracted: 15
Extracted: 20
Python 版本最为简洁直观,三个版本的输出结果完全一致。可以观察到偏堆的核心特征:
- 插入后最小值为所有元素中的最小值 — 偏堆始终维护最小堆性质。
- 提取最小值按升序输出 — 每次提取的都是当前堆中的最小元素。
- 实现极其简洁 — 合并操作仅需递归合并右子树并交换子节点,无需任何额外字段。
Go 完整实现
package main
import "fmt"
// SkewNode represents a node in the skew heap
type SkewNode struct {
key int
left *SkewNode
right *SkewNode
}
// SkewHeap represents a skew heap
type SkewHeap struct {
root *SkewNode
}
// mergeNodes recursively merges two heap roots
func mergeNodes(h1, h2 *SkewNode) *SkewNode {
if h1 == nil {
return h2
}
if h2 == nil {
return h1
}
// ensure h1 has the smaller root (min-heap)
if h1.key > h2.key {
h1, h2 = h2, h1
}
// merge h1's right subtree with h2
h1.right = mergeNodes(h1.right, h2)
// always swap left and right children
h1.left, h1.right = h1.right, h1.left
return h1
}
// Insert adds a key to the heap
func (h *SkewHeap) Insert(key int) {
node := &SkewNode{key: key}
h.root = mergeNodes(h.root, node)
}
// ExtractMin removes and returns the minimum key
func (h *SkewHeap) ExtractMin() (int, bool) {
if h.root == nil {
return 0, false
}
minKey := h.root.key
oldRoot := h.root
h.root = mergeNodes(oldRoot.left, oldRoot.right)
return minKey, true
}
// GetMin returns the minimum key without removing it
func (h *SkewHeap) GetMin() (int, bool) {
if h.root == nil {
return 0, false
}
return h.root.key, true
}
// IsEmpty checks if the heap is empty
func (h *SkewHeap) IsEmpty() bool {
return h.root == nil
}
func main() {
heap := &SkewHeap{}
// insert values
fmt.Print("Inserting: ")
values := []int{3, 10, 5, 15, 7, 20, 1}
for _, v := range values {
fmt.Printf("%d ", v)
heap.Insert(v)
}
fmt.Println()
if min, ok := heap.GetMin(); ok {
fmt.Printf("Current min: %d\n", min)
}
// extract all elements
fmt.Println("\nExtracting all elements in order:")
for !heap.IsEmpty() {
if min, ok := heap.ExtractMin(); ok {
fmt.Printf(" Extracted: %d\n", min)
}
}
}
运行该程序将输出:
Inserting: 3 10 5 15 7 20 1
Current min: 1
Extracting all elements in order:
Extracted: 1
Extracted: 3
Extracted: 5
Extracted: 7
Extracted: 10
Extracted: 15
Extracted: 20
Go 版本使用结构体和指针实现,无需 npl 字段。合并操作中无条件交换子节点的逻辑通过 h1.left, h1.right = h1.right, h1.left 一行完成,与其他三个版本的输出结果完全一致。
偏堆的性质
时间复杂度
偏堆的时间复杂度基于摊还分析(Amortized Analysis)。虽然单次操作的最坏时间复杂度可能达到 O(n)(例如树退化为链表),但通过势函数方法(Potential Method)可以证明,在一系列操作中每次操作的摊还时间复杂度为 O(log n)。
| 操作 | 摊还时间复杂度 | 最坏时间复杂度 |
|---|---|---|
| 合并(Merge) | O(log n) | O(n) |
| 插入(Insert) | O(log n) | O(n) |
| 查找最小值(Find Min) | O(1) | O(1) |
| 提取最小值(Extract Min) | O(log n) | O(n) |
摊还分析的核心思想是:虽然"始终交换子节点"不能保证每次操作都在 O(log n) 内完成,但经过一系列操作后,交换操作会逐渐将树"摊平",使得重右路径(Right-Heavy Path)的长度在总体上保持在 O(log n) 量级。这与 Splay 树的摊还分析思想一脉相承。
与其他堆的对比
| 特性 | 二叉堆(Binary Heap) | 左偏堆(Leftist Heap) | 偏堆(Skew Heap) |
|---|---|---|---|
| 存储方式 | 数组 | 指针(二叉树) | 指针(二叉树) |
| 合并(Merge) | O(n) | O(log n) | O(log n) 摊还 |
| 插入(Insert) | O(log n) | O(log n) | O(log n) 摊还 |
| 查找最小值(Find Min) | O(1) | O(1) | O(1) |
| 提取最小值(Extract Min) | O(log n) | O(log n) | O(log n) 摊还 |
| 最坏情况保证 | 是 | 是 | 否(仅摊还保证) |
| 额外存储空间 | 无 | 每节点一个 npl 字段 | 无 |
| 代码复杂度 | 低 | 中等 | 最低 |
选择建议
- 选择二叉堆:不需要合并操作的场景。二叉堆用数组存储,缓存友好,常数因子最小,是通用优先队列的首选。
- 选择左偏堆:需要合并操作且要求最坏情况保证。左偏堆通过 npl 严格保证右路径长度,适合对延迟敏感的场景。
- 选择偏堆:需要合并操作但可以接受摊还保证,且追求实现简洁性。偏堆的代码比左偏堆更短更易维护,适合大多数实际应用。
应用场景
- 可合并优先队列(Mergeable Priority Queue):偏堆的核心应用,支持高效的堆合并,实现比左偏堆更简洁。
- 图算法:在 Dijkstra、Prim 等算法中,当需要合并多个优先队列时,偏堆提供了一种简洁高效的方案。
- 事件模拟:多个事件队列合并为一个全局队列时,偏堆的合并操作非常高效。
- 教学用途:偏堆是理解摊还分析和自调整数据结构的优秀教材——它用最少的代码实现了可合并堆,同时展示了摊还分析如何为看似"随意"的操作提供性能保证。

浙公网安备 33010602011771号