堆 (Heap / Priority Queue)

title: ""

章节概述

堆(Heap)是一种特殊的完全二叉树,满足”堆性质”——每个节点的值都不小于(或不大于)
其子节点的值。堆虽然是一棵树,但最精妙的地方在于它可以用数组紧凑存储,
无需指针,极大节省内存并提高缓存命中率。

本节侧重底层实现与内存理解,与 CPP教程对应章节 形成互补——CPP教程侧重 std::priority_queue 的 STL 使用、自定义比较器与算法,本教程侧重手动实现二叉堆的数组存储、sift-up/sift-down 操作、以及堆在优先队列和排序中的应用。

在CPP教程中对应章节侧重 std::priority_queue 的 STL 用法与自定义比较器,本节侧重用纯数组手动实现堆——无指针、无 malloc(节点层面),理解堆的本质。



第一节: 堆的数组表示 —— 为什么可以用数组?


1.1 完全二叉树与数组索引

堆是一棵完全二叉树——除了最后一层,每层都是满的,且最后一层的节点从左到右填充。
这种结构可以用数组紧凑存储:

graph TD
    subgraph "堆的树形表示"
        root["idx0: 16"] --> left["idx1: 14"]
        root --> right["idx2: 10"]
        left --> ll["idx3: 8"]
        left --> lr["idx4: 7"]
        right --> rl["idx5: 9"]
        right --> rr["idx6: 3"]
        ll --> lll["idx7: 2"]
        ll --> llr["idx8: 4"]
        lr --> lrl["idx9: 1"]
    end

    subgraph "堆的数组表示"
        arr["[16│14│10│8│7│9│3│2│4│1│...]"]
        arr_idx["  0   1   2  3 4 5 6 7 8 9"]
    end

父/子索引关系(从 0 开始的索引):

关系公式
父节点索引parent(i) = (i - 1) / 2
左子节点索引left(i) = 2 * i + 1
右子节点索引right(i) = 2 * i + 2

为什么二叉堆可以无指针存储?

完全二叉树的性质保证了数组中不存在”空洞”——前 n 个位置始终代表一棵有效树。
不需要 left/right 指针,索引即逻辑关系。这比二叉搜索树每个节点节省 16 字节(两个指针)。

关于数组索引到地址的汇编转换 [base + index * size],参见 。


1.2 堆的结构定义

#include <stdlib.h>
#include <stdio.h>
#include <stdbool.h>
 
#define MAX_HEAP_SIZE 1024
 
typedef struct {
    int *data;       // 堆的数组
    int size;        // 当前元素个数
    int capacity;    // 数组容量
} MaxHeap;
 
MaxHeap* heap_create(int capacity) {
    MaxHeap *heap = (MaxHeap*)malloc(sizeof(MaxHeap));
    if (!heap) return NULL;
    heap->data = (int*)malloc(capacity * sizeof(int));
    if (!heap->data) {
        free(heap);
        return NULL;
    }
    heap->size = 0;
    heap->capacity = capacity;
    return heap;
}
 
void heap_destroy(MaxHeap *heap) {
    if (heap) {
        free(heap->data);
        free(heap);
    }
}
 
bool heap_is_empty(const MaxHeap *heap) { return heap->size == 0; }
 
int heap_peek(const MaxHeap *heap) {
    if (heap->size == 0) return 0;
    return heap->data[0];  // 堆顶始终在索引 0
}

练习 1.2.1: 证明对于完全二叉树的任意节点 i(索引从 0 开始),其父节点索引 (i-1)/2 确实指向它在树中的父节点。用索引的二进制表示来分析。



第二节: 核心操作 —— Sift-up 与 Sift-down


2.1 sift-up(上滤)—— 用于 insert

新元素插入到数组末尾(完全二叉树的最后一个位置),然后与父节点比较,
如果大于父节点就交换,不断向上”浮”:

static void heap_sift_up(MaxHeap *heap, int idx) {
    while (idx > 0) {
        int parent = (idx - 1) / 2;
        if (heap->data[idx] <= heap->data[parent]) {
            break;  // 已满足堆性质
        }
        // 交换
        int tmp = heap->data[idx];
        heap->data[idx] = heap->data[parent];
        heap->data[parent] = tmp;
        idx = parent;
    }
}
 
bool heap_insert(MaxHeap *heap, int value) {
    if (heap->size >= heap->capacity) {
        return false;  // 堆满(可动态扩容,略)
    }
    heap->data[heap->size] = value;
    heap_sift_up(heap, heap->size);
    heap->size++;
    return true;
}

sift-up 过程示例(插入 15):

sequenceDiagram
    participant heap as 堆数组
    Note over heap: 初始: [16,14,10,8,7,9,3]  (size=7)

    heap->>heap: append(15) → [16,14,10,8,7,9,3,15]  idx=7
    Note over heap: parent(7) = idx3 = 8, 15>8
    heap->>heap: swap idx7↔idx3 → [16,14,10,15,7,9,3,8]  idx=3
    Note over heap: parent(3) = idx1 = 14, 15>14
    heap->>heap: swap idx3↔idx1 → [16,15,10,14,7,9,3,8]  idx=1
    Note over heap: parent(1) = idx0 = 16, 15≤16 → 停止

2.2 sift-down(下滤)—— 用于 extract

取出堆顶元素(index 0)后,把最后一个元素搬到堆顶,然后向下调整:

static void heap_sift_down(MaxHeap *heap, int idx) {
    while (1) {
        int largest = idx;
        int left = 2 * idx + 1;
        int right = 2 * idx + 2;
 
        if (left < heap->size && heap->data[left] > heap->data[largest])
            largest = left;
        if (right < heap->size && heap->data[right] > heap->data[largest])
            largest = right;
 
        if (largest == idx) break;  // 已满足堆性质
 
        // 交换
        int tmp = heap->data[idx];
        heap->data[idx] = heap->data[largest];
        heap->data[largest] = tmp;
        idx = largest;
    }
}
 
int heap_extract(MaxHeap *heap) {
    if (heap->size == 0) return 0;
 
    int root_val = heap->data[0];
    heap->data[0] = heap->data[heap->size - 1];  // 最后一个元素搬到根
    heap->size--;
    if (heap->size > 0) {
        heap_sift_down(heap, 0);
    }
    return root_val;
}

sift-down 过程示例(extract 后调整):

sequenceDiagram
    participant heap as 堆数组
    Note over heap: 初始: [16,14,10,8,7,9,3]

    heap->>heap: extract: 返回16, 将3搬到idx0 → [3,14,10,8,7,9]
    Note over heap: left(0)=14, right(0)=10, largest=14(idx1)
    heap->>heap: swap idx0↔idx1 → [14,3,10,8,7,9]
    Note over heap: left(1)=8, right(1)=7, largest=8(idx3)
    heap->>heap: swap idx1↔idx3 → [14,8,10,3,7,9]
    Note over heap: left(3)=?, right(3)=? 越界 → 停止

练习 2.2.1: 实现一个最小堆(MinHeap)版本的 insert 和 extract。需要修改比较条件。


2.3 堆构建 —— 批量建堆(Heapify)

给定一个无序数组,在 O(n) 时间内构建堆:

// O(n) 建堆 —— 从最后一个非叶节点开始,依次 sift-down
void heap_build(MaxHeap *heap, const int *arr, int n) {
    if (n > heap->capacity) n = heap->capacity;
    for (int i = 0; i < n; i++) {
        heap->data[i] = arr[i];
    }
    heap->size = n;
 
    // 从最后一个非叶节点开始:parent(n-1)
    for (int i = (n - 2) / 2; i >= 0; i--) {
        heap_sift_down(heap, i);
    }
}

为什么建堆是 O(n)?

直觉上每个节点 sift-down 是 O(log n),n 个节点似乎应该是 O(n log n)。
然而:

  • 约 n/2 的节点在最后一层(叶子),sift-down 代价为 0
  • 约 n/4 的节点在倒数第 2 层,代价为 1
  • 约 n/8 的节点在倒数第 3 层,代价为 2

总代价 = Σ (n/2^(h+1)) × h = O(n)

详细数学推导超出本节范围,核心直觉:大多数节点是叶子,不需要调整。

练习 2.3.1: 实现 heap_is_valid(heap) 函数 —— 遍历堆验证每个节点都满足堆性质。



第三节: 堆排序


3.1 堆排序算法

堆排序是原地排序算法,O(n log n),不需要额外数组空间:

void heap_sort(int *arr, int n) {
    // 1. 建堆(在 arr 原地构建最大堆)
    for (int i = (n - 2) / 2; i >= 0; i--) {
        int idx = i;
        while (1) {
            int largest = idx;
            int left = 2 * idx + 1;
            int right = 2 * idx + 2;
            if (left < n && arr[left] > arr[largest]) largest = left;
            if (right < n && arr[right] > arr[largest]) largest = right;
            if (largest == idx) break;
            int tmp = arr[idx]; arr[idx] = arr[largest]; arr[largest] = tmp;
            idx = largest;
        }
    }
 
    // 2. 逐个提取最大值放到数组末尾
    for (int i = n - 1; i > 0; i--) {
        // swap(堆顶, 末尾)
        int tmp = arr[0];
        arr[0] = arr[i];
        arr[i] = tmp;
 
        // 重新调整堆(范围缩小到 i)
        int idx = 0;
        while (1) {
            int largest = idx;
            int left = 2 * idx + 1;
            int right = 2 * idx + 2;
            if (left < i && arr[left] > arr[largest]) largest = left;
            if (right < i && arr[right] > arr[largest]) largest = right;
            if (largest == idx) break;
            tmp = arr[idx]; arr[idx] = arr[largest]; arr[largest] = tmp;
            idx = largest;
        }
    }
}

堆排序过程图解:

graph LR
    subgraph "步骤1: 建堆 (max-heap)"
        build["原始: [4,10,3,5,1]"] --> heapify["建堆后: [10,5,3,4,1]"]
    end
    subgraph "步骤2: 提取排序"
        extract["swap(10,1) → [1,5,3,4,10]\nsift-down → [5,4,3,1,10]"]
        extract2["swap(5,1) → [1,4,3,5,10]\nsift-down → [4,1,3,5,10]"]
        extract3["swap(4,3) → [3,1,4,5,10]\nsift-down → [3,1,4,5,10]"]
        extract4["swap(3,1) → [1,3,4,5,10]\n排序完毕!"]
    end
    heapify --> extract
    extract --> extract2 --> extract3 --> extract4

3.2 堆排序与其他排序的对比

算法时间(平均)空间稳定性原地
堆排序O(n log n)O(1)不稳定
快排O(n log n)O(log n)不稳定
归并排序O(n log n)O(n)稳定
插入排序O(n²)O(1)稳定

堆排序的优势在于 O(1) 额外空间 + O(n log n) 确定性时间复杂度(不像快排有 O(n²) 最坏情况)。
但堆排序的缓存不友好(跳跃访问),通常比快排慢。

练习 3.2.1: 为什么堆排序不稳定?举例说明。提示:考虑值相等的元素在建堆过程中的位置变化。



第四节: 优先队列应用


4.1 Top-K 问题

“找出最大的 K 个数”——用小顶堆维护前 K 个最大值:

#include <string.h>
 
// 用小顶堆(MinHeap)解决 Top-K 问题
void top_k(const int *arr, int n, int k) {
    if (k <= 0 || k > n) return;
 
    // 用前 k 个元素建最小堆
    int *min_heap = (int*)malloc(k * sizeof(int));
    memcpy(min_heap, arr, k * sizeof(int));
    for (int i = (k - 2) / 2; i >= 0; i--) {
        // sift-down on min_heap
        int idx = i;
        while (1) {
            int smallest = idx;
            int left = 2 * idx + 1, right = 2 * idx + 2;
            if (left < k && min_heap[left] < min_heap[smallest]) smallest = left;
            if (right < k && min_heap[right] < min_heap[smallest]) smallest = right;
            if (smallest == idx) break;
            int tmp = min_heap[idx]; min_heap[idx] = min_heap[smallest]; min_heap[smallest] = tmp;
            idx = smallest;
        }
    }
 
    // 处理剩余 n-k 个元素
    for (int i = k; i < n; i++) {
        if (arr[i] > min_heap[0]) {
            min_heap[0] = arr[i];
            // sift-down
            int idx = 0;
            while (1) {
                int smallest = idx;
                int left = 2 * idx + 1, right = 2 * idx + 2;
                if (left < k && min_heap[left] < min_heap[smallest]) smallest = left;
                if (right < k && min_heap[right] < min_heap[smallest]) smallest = right;
                if (smallest == idx) break;
                int tmp = min_heap[idx]; min_heap[idx] = min_heap[smallest]; min_heap[smallest] = tmp;
                idx = smallest;
            }
        }
    }
 
    printf("Top %d elements: ", k);
    for (int i = 0; i < k; i++) printf("%d ", min_heap[i]);
    printf("\n");
 
    free(min_heap);
}

算法分析: 堆大小保持 k,每处理一个元素只需 O(log k) 的 sift-down。
总复杂度 O(n log k),当 k << n 时远优于排序的 O(n log n)。


4.2 动态维护中位数 —— 对顶堆

使用两个堆:大顶堆存小数,小顶堆存大数:

typedef struct {
    MaxHeap *max_heap;  // 存较小的一半(左边)
    MaxHeap *min_heap;  // 存较大的一半(右边)—— 实际上用 MinHeap 更合适
} MedianFinder;
 
// 初始化:大小堆各分配 n/2+1 容量
// insert 逻辑:
//   如果 num <= max_heap 堆顶 → 插入 max_heap
//   否则 → 插入 min_heap
//   然后平衡两个堆使 size 差 ≤ 1
// 中位数:
//   如果两个堆等大 → (max_top + min_top) / 2.0
//   如果 max 多一个 → max_top
graph LR
    subgraph "对顶堆 — 维护中位数"
        maxh["大顶堆 (max-heap)\n[较小的一半数]\n堆顶 = 最大值"]
        minh["小顶堆 (min-heap)\n[较大的一半数]\n堆顶 = 最小值"]
        median["中位数 = 两堆顶的平均值\n(或较长的堆顶)"]
    end
    maxh --> median
    minh --> median

练习 4.2.1: 完整实现对顶堆 MedianFinder。要求支持 addNumfindMedian


4.3 优先级任务调度器

typedef struct {
    int priority;  // 优先级(值越大越优先)
    int task_id;
    char description[64];
} Task;
 
typedef struct {
    Task *tasks;
    int size;
    int capacity;
    // ... 与 MaxHeap 相同的结构
} TaskPriorityQueue;

练习 4.3.1: 基于堆实现一个任务调度器,支持 add_task(priority, desc)execute_next()



章节测试


判断题(10题)

判断题 1

堆必须用树结构(带 left/right 指针)来实现。

判断题 2

对于索引为 i(从 0 开始)的节点,其左子节点索引为 2*i+1

判断题 3

最大堆中,任意节点的值都不大于其子节点。

判断题 4

sift-up 操作用于在堆中插入新元素后恢复堆性质。

判断题 5

堆排序的空间复杂度是 O(n)。

判断题 6

从无序数组构建堆(heapify)的时间复杂度是 O(n log n)。

判断题 7

堆排序是不稳定的排序算法。

判断题 8

用大小为 k 的最小堆可以找出前 K 个最大元素。

判断题 9

堆中最后一个非叶节点的索引是 size/2

判断题 10

sift-down 操作的时间复杂度是 O(log n)。


选择题(10题)

选择题 1

索引为 5 的节点,其父节点、左子节点、右子节点的索引分别是?

  • A. 2, 10, 11

  • B. 2, 11, 12

  • C. 3, 11, 12

  • D. 2, 11, 13

选择题 2

最大堆 [50, 30, 40, 20, 10] 中,插入 45 后的堆是?

  • A. [50, 45, 40, 20, 10, 30]

  • B. [50, 30, 45, 20, 10, 40]

  • C. [50, 45, 30, 20, 10, 40]

  • D. [45, 50, 40, 30, 20, 10]

选择题 3

extract(取堆顶)后,堆的大小会?

  • A. 不变

  • B. 减少 1

  • C. 减少 2

  • D. 取决于堆的类型

选择题 4

堆排序中,升序排列应使用什么类型的堆?

  • A. 最小堆

  • B. 最大堆

  • C. 任意堆

  • D. 需要两个堆

选择题 5

对顶堆(双堆)维护中位数,如果当前元素数为偶数,中位数等于?

  • A. 大顶堆的堆顶

  • B. 小顶堆的堆顶

  • C. 两个堆顶的平均值

  • D. 无法确定

选择题 6

当堆用数组实现,大小为 n 时,有多少个叶子节点?

  • A. n/2

  • B. n - n/2

  • C. n/2

  • D. n/2 + 1

选择题 7

关于建堆(heapify)的时间复杂度,以下说法正确的是?

  • A. O(n log n) 因为每个节点都要 sift-down

  • B. O(n) 因为大多数节点是叶子,sift-down 代价低

  • C. O(log n) 因为堆高度是 log n

  • D. O(n²) 因为在最坏情况下

选择题 8

优先队列(Priority Queue)最底层的数据结构通常是?

  • A. 数组

  • B. 链表

  • C. 二叉堆

  • D. 哈希表

选择题 9

存储 1000 个元素的二叉堆,数组索引 0 到 999,堆的高度是?

  • A. 8

  • B. 9

  • C. 10

  • D. 1000

选择题 10

堆排序与快速排序相比,堆排序的主要优势是?

  • A. 更稳定

  • B. 确定性的 O(n log n) 时间复杂度(无 O(n²) 退化)

  • C. 更节约空间

  • D. 在实际中基本总是更快


编程大题

编程题 1:实现完整的泛型二叉堆

要求

  1. 支持最大堆和最小堆(通过比较函数指针切换)
  2. 支持任意类型(void* + element_size)
  3. 实现所有操作:create、destroy、insert、extract、peek、size、is_empty、heapify
  4. 支持动态扩容(2x 策略)
  5. 用函数指针支持自定义比较器

提示: 泛型堆的核心是用 memcpy 移动元素,比较时通过函数指针回调。

编程题 2:堆排序的实战性能对比

要求

  1. 实现堆排序、快速排序、归并排序
  2. 对 100 万个随机整数排序,记录时间
  3. 对”几乎有序”的 100 万个整数排序,记录时间
  4. 分析为什么堆排序在实践中通常比快排慢(缓存不命中、分支预测等)
  5. 尝试优化堆排序(如减少分支、缓存友好的布局调整)

提示: 用 clock()gettimeofday() 计时。

编程题 3:基于堆的优先级任务调度器

要求

  1. 任务包含:tid、priority(0-255)、arrival_time、burst_time
  2. 实现三种调度算法:
    • 先来先服务(FCFS,用队列)
    • 最短作业优先(SJF,用最小堆按 burst_time)
    • 优先级调度(用最大堆按 priority)
  3. 读取任务文件,输出每种算法的调度序列和平均等待时间

提示: 可以用对顶堆或二叉堆配合链表实现多级反馈队列调度。

推荐练习题(力扣)

知识点题目建议
最小堆力扣堆
贪心+堆力扣哈夫曼
对顶堆力扣数据流中位数
对顶堆力扣堆
堆、多路归并力扣堆


知识网络