跳转至

Chapter 4 Priority Queues (Heaps)

§1 ADT Model

1. Data Objects

【定义】优先队列是零个或多个元素的有限有序列表

2. Core Operations

优先队列的核心操作围绕优先级展开,默认删除优先级最高/最低的元素: - PriorityQueue Initialize(int MaxElements);:初始化一个最大容量为MaxElements的优先队列

  • void Insert(ElementType X, PriorityQueue H);:向优先队列H中插入元素X

  • ElementType DeleteMin(PriorityQueue H);:删除并返回优先队列H中优先级最低(值最小)的元素

  • ElementType FindMin(PriorityQueue H);:返回优先队列H中优先级最低(值最小)的元素

§2 Simple Implementations

分别介绍几种简单的实现方式及其时间复杂度:

1. Unordered Array

  • 插入:在数组末尾添加元素,时间复杂度 Θ(1)

  • 删除:先遍历找到最小/最大元素(Θ(n)),再删除并移动数组元素(O(n)),总时间复杂度 O(n)

2. Unordered Linked List

  • 插入:在链表头部添加元素,时间复杂度 Θ(1)

  • 删除:先遍历找到最小/最大元素(Θ(n)),再删除该节点(Θ(1)),总时间复杂度 O(n)

3. Ordered Array

  • 插入:先找到合适的插入位置(O(n)),再移动数组元素并插入(O(n)),总时间复杂度 O(n)

  • 删除:直接删除数组第一个/最后一个元素,时间复杂度 Θ(1)

    优势:实际场景中删除操作次数通常不超过插入操作次数,整体性能优于无序数组/链表

4. Ordered Linked List

  • 插入:先找到合适的插入位置(O(n)),再添加节点(Θ(1)),总时间复杂度 O(n)

  • 删除:直接删除链表第一个/最后一个节点,时间复杂度 Θ(1)

5. Binary Search Tree

  • 理论上插入和删除操作的时间复杂度均为 O(log N)

  • 问题:优先队列仅需删除最小元素,会导致频繁删除左子树节点,可能使树失衡

  • 改进:使用平衡二叉树(如AVL树),但会增加常数因子开销,且指针操作存在风险,同时AVL树的很多操作对优先队列来说是冗余的

五种简单实现对比:

实现方式 插入 删除最小值
无序数组 Θ(1) O(n)
无序链表 Θ(1) O(n)
有序数组 O(n) Θ(1)
有序链表 O(n) Θ(1)
二叉搜索树 O(log N)* O(log N)*

*BST非平衡时退化为 O(N)

§3 Binary Heap

二叉堆是优先队列最常用的实现方式,同时满足结构性质堆序性质

1. Structure Property

【定义】完全二叉树(Complete Binary Tree):一棵高度为h、包含n个节点的二叉树是完全二叉树,当且仅当其节点与高度为h的完美二叉树中编号从1到n的节点一一对应。

完全二叉树的性质

  • 高度为h的完全二叉树的节点数范围:\(2ʰ ≤ n ≤ 2ʰ⁺¹ - 1\)

  • 包含n个节点的完全二叉树的高度:\(h = ⌊log N⌋\)

Array Representation

  • 完全二叉树可以用数组高效表示,通常使用BT[n+1]BT[0]不使用或作为哨兵)

  • 【引理】对于数组中索引为i1 ≤ i ≤ n)的节点:

  • 父节点索引:⌊i/2⌋

  • 左孩子索引:2i

  • 右孩子索引:2i + 1

以10个节点的完全二叉树为例,树与数组的对应关系:

         [1]
        /   \
     [2]     [3]
     /  \     /  \
   [4]  [5] [6]  [7]
   / \   /
 [8] [9] [10]

  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
  │  1 │  2 │  3 │  4 │  5 │  6 │  7 │  8 │  9 │ 10 │ ···│
  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
    根  根的  根的
        左孩  右孩

  父子关系:
    parent[i] = ⌊i/2⌋    例: parent[4] = 2, parent[5] = 2
    left[i]   = 2i       例: left[2] = 4
    right[i]  = 2i + 1   例: right[2] = 5

Initialize Operation

PriorityQueue Initialize(int MaxElements) 
{ 
    PriorityQueue H; 
    if (MaxElements < MinPQSize) 
        return Error("Priority queue size is too small"); 
    H = malloc(sizeof(struct HeapStruct)); 
    if (H == NULL) 
        return FatalError("Out of space!!!"); 
    /* Allocate the array plus one extra for sentinel */ 
    H->Elements = malloc((MaxElements + 1) * sizeof(ElementType)); 
    if (H->Elements == NULL) 
        return FatalError("Out of space!!!"); 
    H->Capacity = MaxElements; 
    H->Size = 0; 
    H->Elements[0] = MinData;  /* set the sentinel */
    return H; 
}

说明:H->Elements[0]作为哨兵,值小于堆中所有元素,用于简化插入操作的边界判断

2. Heap Order Property

【定义】最小树(Min Tree):树中每个节点的键值不大于其所有子节点(若存在)的键值。 最小堆(Min Heap):既是完全二叉树,又是最小树的二叉堆。

类比:最大堆(Max Heap) 只需将堆序性质改为"每个节点的键值不小于其所有子节点的键值",最大元素位于根节点。

最小堆示例(元素: 3, 5, 8, 10, 12, 11, 9, 14, 15, 13):

               [1]=3
           /           \
        [2]=5          [3]=8
       /      \        /    \
    [4]=10  [5]=12  [6]=11 [7]=9
    /   \    /
[8]=14 [9]=15 [10]=13

验证: 每个父节点 ≤ 子节点
  3≤5,8  ✓    5≤10,12 ✓    8≤11,9 ✓
  10≤14,15 ✓  12≤13 ✓

3. Basic Heap Operations

1. Insert Operation

  • 核心思想:先将新元素插入到完全二叉树的最后一个位置(保持结构性质),再通过上滤(Percolate Up) 调整元素位置,恢复堆序性质

  • 上滤过程:将新元素与其父节点比较,若新元素更小,则交换位置,直到父节点更小或到达根节点

/* H->Element[0] is a sentinel */ 
void Insert(ElementType X, PriorityQueue H) 
{ 
    int i; 

    if (IsFull(H)) { 
        Error("Priority queue is full"); 
        return; 
    } 

    for (i = ++H->Size; H->Elements[i/2] > X; i /= 2) 
        H->Elements[i] = H->Elements[i/2]; 

    H->Elements[i] = X; 
}

优化:使用赋值代替交换操作,减少内存访问次数 时间复杂度:O(log N)

Insert(4) 上滤过程(接续上面10元素最小堆):

数组: [0]=-∞(哨兵)  [1]=3  [2]=5  [3]=8  [4]=10  [5]=12  [6]=11  [7]=9  [8]=14  [9]=15  [10]=13

① 新元素4放入Size+1位置:   [11]=4
② 4 < [11/2]=[5]=12  →  12 下移到 [11], hole 移至 [5]
③ 4 <  [5/2]=[2]=5   →   5 下移到 [5],  hole 移至 [2]
④ 4 >  [2/2]=[1]=3   →  停止, 4 填入 [2]

上滤路径: [11] → [5] → [2]    比较次数: 3

      [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11]
最终:  -∞   3   4   8  10   5  11   9  14  15  13   12
              ↑新位置

2. DeleteMin Operation

  • 核心思想:根节点即为最小值,删除根节点后,将最后一个节点移到根位置(保持结构性质),再通过下滤(Percolate Down) 调整元素位置,恢复堆序性质

  • 下滤过程:将当前节点与其两个子节点中较小的一个比较,若当前节点更大,则交换位置,直到所有子节点更大或到达叶子节点

ElementType DeleteMin(PriorityQueue H) 
{ 
    int i, Child; 
    ElementType MinElement, LastElement; 
    if (IsEmpty(H)) { 
         Error("Priority queue is empty"); 
         return H->Elements[0];   
    } 
    MinElement = H->Elements[1];  /* save the min element */
    LastElement = H->Elements[H->Size--];  /* take last and reset size */
    for (i = 1; i * 2 <= H->Size; i = Child) {  /* Find smaller child */ 
         Child = i * 2; 
         if (Child != H->Size && H->Elements[Child+1] < H->Elements[Child]) 
               Child++;     
         if (LastElement > H->Elements[Child])   /* Percolate one level */ 
               H->Elements[i] = H->Elements[Child]; 
         else     break;   /* find the proper position */
    } 
    H->Elements[i] = LastElement; 
    return MinElement; 
}

思考:若省略Child != H->Size的判断会发生什么?能否通过添加另一个哨兵来避免该判断?

: 当节点仅有左孩子而无右孩子时(出现在堆的最后一个非叶子节点),H->Elements[Child+1] 将越界访问堆外元素(可能是旧数据或未初始化的值),若该值碰巧小于左孩子,则会选错比较对象,破坏堆序。可以用哨兵避免: 在 Size+1 位置放置 +∞(最大堆则放 -∞),即使越界比较也不会选中。但哨兵需在每次操作后更新位置,开销比直接做 Child != H->Size 判断更大,故实际代码不采用。 时间复杂度:O(log N)

DeleteMin() 下滤过程(接续 Insert 后的堆,删除最小值3):

删除前: [0]=-∞ | 3 | 4 | 8 | 10 | 5 | 11 | 9 | 14 | 15 | 13 | 12
               [1] [2]  [3] [4]  [5] [6]  [7] [8]  [9]  [10] [11]

① 取出 min=[1]=3,  取出末尾 last=[11]=12,  Size减为10
② last=12 放到空出的 [1], 开始下滤:

   [1]=□: 孩子 [2]=4, [3]=8 → min=4,  12>4  → 4上移, hole到[2]
   [2]=□: 孩子 [4]=10,[5]=5 → min=5,  12>5  → 5上移, hole到[5]
   [5]=□: 孩子仅[10]=13   → min=13, 12<13 → 停止, 12填入[5]

下滤路径: [1] → [2] → [5]

      [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]
最终:  -∞   4   5   8  10  12  11   9  14  15  13
              ↑新根        ↑12最终位置

4. Other Heap Operations(其他堆操作)

注意:除最小值外,查找堆中任意其他键值需要线性扫描整个堆,时间复杂度O(N)

1. DecreaseKey(P, Δ, H)

  • 功能:将堆H中位置P的节点的键值减少Δ(Δ>0)

  • 应用:提高进程的优先级

  • 实现:修改键值后,执行上滤操作

2. IncreaseKey(P, Δ, H)

  • 功能:将堆H中位置P的节点的键值增加Δ(Δ>0)

  • 应用:降低占用过多CPU时间的进程的优先级

  • 实现:修改键值后,执行下滤操作

3. Delete(P, H)

  • 功能:删除堆H中位置P的节点

  • 应用:删除用户异常终止的进程

  • 实现:先执行DecreaseKey(P, ∞, H)将该节点移到根,再执行DeleteMin(H)

4. BuildHeap(H)

  • 功能:将N个输入元素构建成一个堆

  • 朴素方法:执行N次连续插入操作,时间复杂度O(N log N)

  • 高效方法:从最后一个非叶子节点(索引⌊N/2⌋)开始,向前依次对每个节点执行下滤操作

  • 【定理】对于包含2ʰ⁺¹ - 1个节点的完美二叉树,所有节点的高度之和为2ʰ⁺¹ - 1 - (h + 1)

  • 时间复杂度:O(N)

BuildHeap 线性时间建堆过程(以 {10, 5, 3, 4, 1} 为例):

原始数组(视为未排序完全二叉树):    last_non_leaf = ⌊5/2⌋ = 2, 从 [2] 向前下滤

        [1]=10                   ① 下滤 [2]=5:
       /      \                    孩子 [4]=4, [5]=1 → min=1
    [2]=5    [3]=3                 5 > 1 → swap: [2]=1, [5]=5
    /   \                         [5]为叶子, 停止
 [4]=4  [5]=1
                                 ② 下滤 [1]=10:
        [1]=10                     孩子 [2]=1, [3]=3 → min=1
       /      \                    10 > 1 → swap: [1]=1, [2]=10
    [2]=1    [3]=3                 [2]=10 继续: 孩子 [4]=4, [5]=5 → min=4
    /   \                          10 > 4 → swap: [2]=4, [4]=10
 [4]=4  [5]=5                      [4]为叶子, 停止

        [1]=1         ← 最终最小堆, 仅 O(N) 时间
       /      \
    [2]=4    [3]=3
    /   \
 [4]=10 [5]=5

§4 Applications of Priority Queues

Example: Find the kth Largest Element

问题:给定N个元素的列表和整数k,找出第k大的元素。

: 四种常见解法及复杂度:

方法 思路 时间复杂度
① 全排序 排序后取 A[N-k] O(N log N)
② 最大堆 BuildHeap O(N) + k次DeleteMax O(k log N) O(N + k log N)
③ 最小堆(大小k) 维护k个最大元素的堆,遍历时替换堆顶 O(N log k)
④ QuickSelect 类快排分治,仅递归含第k大的那半 O(N) 平均, O(N²) 最坏

当 k << N 时,方法③ O(N log k) 最优(接近线性);当 k 适中时,方法② 的 O(N + k log N) 更合适;方法④ 在理论平均意义上最快。

§5 d-Heaps(d叉堆)

【定义】d叉堆是二叉堆的推广,其中每个节点最多有d个子节点。

Key Notes

  1. 时间复杂度
  2. DeleteMin操作需要d-1次比较来找到最小的子节点,总时间复杂度为O(d log_d N)

  3. Insert操作时间复杂度仍为O(log_d N)

  4. 运算效率:二叉堆中2和/2操作可通过位移位快速实现,而d叉堆中的d和/d操作效率较低
  5. 适用场景:当优先队列过大无法完全装入主存时,d叉堆可以减少磁盘I/O次数,此时更具优势

思考:是否应该将d设置得尽可能大?

: 不应该。 d 增大带来两方面影响:

  • 有利: 树高 h = ⌈log_d N⌉ 减小,Insert 的复杂度 O(log_d N) 降低
  • 不利: DeleteMin 每层需做 d-1 次比较找最小孩子,总复杂度 O(d · log_d N)

定量对比:

d Insert DeleteMin
2 (二叉) 1 · log₂ N 2 · log₂ N
4 1 · log₄ N = 0.5·log₂ N 4 · log₄ N = 2·log₂ N
8 1 · log₈ N ≈ 0.33·log₂ N 8 · log₈ N ≈ 2.67·log₂ N

DeleteMin 在 d=4 时与 d=2 持平(都是 2·log₂ N),d 再大反而退化。最优 d 通常在 2~8 之间,取决于 Insert/DeleteMin 操作的比例。此外,二叉堆的 ×2/÷2 可用位运算加速,d叉堆则需实际乘除,常数因子更大。

d叉堆 vs 二叉堆示意:

二叉堆 (d=2):          d叉堆 (d=4):

       [1]                   ┌──[1]──┐
      /   \               ┌─┐  ┌┼┐  ┌─┐
    [2]   [3]           [2][3][4][5]
   /  \   /  \         /|\   ...
 [4] [5] [6] [7]

树高 ≈ log₂N          树高 ≈ log₄N (更矮)
DeleteMin: 1次比较/层   DeleteMin: 3次比较/层