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 字段,这也是偏堆比左偏堆简洁的根本原因。leftright 默认为 nil


合并操作(Merge)-- 核心

合并(Merge)是偏堆的核心操作,所有其他操作(插入、提取最小值)都建立在合并之上。偏堆的合并采用递归方式,步骤如下:

  1. 如果其中一个堆为空,返回另一个堆
  2. 比较两个堆的根节点,确保较小的根成为新根(最小堆性质)
  3. 递归地将新根的右子树与较大的堆合并
  4. 无条件交换新根的左右子节点

第 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)的操作步骤:

  1. 记录根节点的键值(即最小值)
  2. 删除根节点
  3. 合并根节点的左右子树,形成新的堆

由于偏堆满足最小堆性质,根节点始终是最小值。

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 版本最为简洁直观,三个版本的输出结果完全一致。可以观察到偏堆的核心特征:

  1. 插入后最小值为所有元素中的最小值 — 偏堆始终维护最小堆性质。
  2. 提取最小值按升序输出 — 每次提取的都是当前堆中的最小元素。
  3. 实现极其简洁 — 合并操作仅需递归合并右子树并交换子节点,无需任何额外字段。

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 等算法中,当需要合并多个优先队列时,偏堆提供了一种简洁高效的方案。
  • 事件模拟:多个事件队列合并为一个全局队列时,偏堆的合并操作非常高效。
  • 教学用途:偏堆是理解摊还分析和自调整数据结构的优秀教材——它用最少的代码实现了可合并堆,同时展示了摊还分析如何为看似"随意"的操作提供性能保证。
posted @ 2026-04-17 02:19  游翔  阅读(4)  评论(0)    收藏  举报