第14章 堆与优先队列:从底层原理到工程实践

堆(Heap)这个东西,说实话我刚开始学的时候觉得挺绕的。明明叫“堆”,却跟内存里的堆区没啥关系。它其实是一种特殊的完全二叉树,专门用来解决一类问题——快速找到一堆数里的最大值或最小值。

我在项目中遇到过好几次这样的场景:系统里有一堆任务,每个任务有优先级,我需要每次都取出优先级最高的那个去执行。如果用普通数组,每次找最大值都要遍历一遍,数据量大了根本扛不住。这时候,堆就派上用场了。

堆的定义与性质

堆本质上是一棵完全二叉树,但它多了两条硬性规定:

  • 结构性质:它必须是一棵完全二叉树。也就是说,除了最后一层,其他层都是满的,最后一层的节点都靠左排列。
  • 堆序性质:任意节点的值,都大于等于(或小于等于)它的子节点。

根据堆序性质,堆分为两种:

  • 大顶堆(最大堆):父节点 ≥ 子节点。根节点是最大值。
  • 小顶堆(最小堆):父节点 ≤ 子节点。根节点是最小值。

核心要点:堆只保证父子节点之间的大小关系,不保证兄弟节点之间的大小关系。这一点跟二叉搜索树完全不同,别搞混了。

你想想看,如果我要找最大值,大顶堆直接把根节点拿出来就行了,时间复杂度 O(1)。这就是堆最迷人的地方。

堆的存储结构

堆虽然逻辑上是二叉树,但实际存储用的是数组。为什么?因为完全二叉树天然适合用数组存,没有空间浪费。

我用一个数组来存堆,下标从 0 开始。对于下标为 i 的节点:

  • 父节点下标:(i - 1) / 2
  • 左子节点下标:2 * i + 1
  • 右子节点下标:2 * i + 2

举个例子,数组 [50, 30, 40, 10, 20, 35] 对应的大顶堆长这样:

50 30 40 10 20 35 数组下标: 0 1 2 3 4 5

个人习惯:我一般用下标 0 作为第一个元素。有些教材从 1 开始,父节点是 i/2,这样位运算更快。但我觉得从 0 开始更符合 C 语言的习惯,代码也更直观。

堆的插入与删除:上浮与下沉

堆的两个核心操作就是插入和删除。这两个操作分别依赖两个关键过程——上浮(Shift Up)下沉(Shift Down)

插入操作(上浮)

插入一个新元素时,我先把元素放到数组末尾(也就是完全二叉树的最后一个位置)。然后让它跟父节点比较,如果比父节点大(大顶堆),就交换位置,一直往上走,直到满足堆序性质。

这个过程就叫「上浮」。代码实现很简单:

void shiftUp(int heap[], int i) {
    while (i > 0 && heap[i] > heap[(i - 1) / 2]) {
        swap(&heap[i], &heap[(i - 1) / 2]);
        i = (i - 1) / 2;
    }
}

void insert(int heap[], int *size, int val) {
    heap[*size] = val;
    (*size)++;
    shiftUp(heap, *size - 1);
}

时间复杂度 O(log n),因为树的高度就是 log n。

删除操作(下沉)

删除操作通常指的是删除根节点(最大值或最小值)。做法是:把根节点跟最后一个元素交换,然后删除最后一个元素(size--)。接着从根节点开始,跟它较大的子节点比较,如果比子节点小,就交换,一直往下走。

这个过程叫「下沉」。

void shiftDown(int heap[], int size, int i) {
    int largest = i;
    int left = 2 * i + 1;
    int right = 2 * i + 2;

    if (left < size && heap[left] > heap[largest])
        largest = left;
    if (right < size && heap[right] > heap[largest])
        largest = right;

    if (largest != i) {
        swap(&heap[i], &heap[largest]);
        shiftDown(heap, size, largest);
    }
}

int deleteRoot(int heap[], int *size) {
    int root = heap[0];
    heap[0] = heap[*size - 1];
    (*size)--;
    shiftDown(heap, *size, 0);
    return root;
}

我曾经踩过的坑:下沉操作里,一定要先判断子节点下标是否越界。我刚开始写的时候忘了检查 left < size,结果数组越界,查了半天 bug。记住,完全二叉树虽然紧凑,但最后一层可能不满。

堆排序

堆排序的思路其实很直接:先把数组构建成一个大顶堆,然后反复把根节点(最大值)取出来,放到数组末尾,再调整堆。

具体步骤:

  1. 建堆:从最后一个非叶子节点开始,依次执行下沉操作。
  2. 排序:交换根节点和最后一个元素,堆大小减 1,然后对根节点下沉。
  3. 重复步骤 2,直到堆为空。

建堆的代码:

void buildHeap(int heap[], int size) {
    for (int i = size / 2 - 1; i >= 0; i--) {
        shiftDown(heap, size, i);
    }
}

为什么从 size/2 - 1 开始?因为最后一个非叶子节点的下标就是 size/2 - 1。叶子节点不需要下沉,它们已经满足堆序性质了。

完整的堆排序:

void heapSort(int arr[], int n) {
    buildHeap(arr, n);
    for (int i = n - 1; i > 0; i--) {
        swap(&arr[0], &arr[i]);
        shiftDown(arr, i, 0);
    }
}

堆排序的时间复杂度是 O(n log n),空间复杂度 O(1)。它是不稳定的排序算法——因为下沉操作可能改变相同元素的相对顺序。

性能对比:堆排序在实际工程中不如快速排序常用,因为它的缓存局部性较差(数组访问跳跃较大)。但在需要稳定 O(n log n) 最坏时间复杂度的场景下,堆排序是可靠的选择。

优先队列的实现

优先队列说白了就是一个队列,但出队的时候不是先进先出,而是优先级最高的先出。堆是实现优先队列最经典的数据结构。

用堆实现优先队列,核心接口就几个:

操作 描述 时间复杂度
push 插入一个元素 O(log n)
pop 删除优先级最高的元素 O(log n)
top 获取优先级最高的元素 O(1)
empty 判断队列是否为空 O(1)

一个简单的优先队列实现:

typedef struct {
    int *data;
    int size;
    int capacity;
} PriorityQueue;

PriorityQueue* createQueue(int cap) {
    PriorityQueue *q = malloc(sizeof(PriorityQueue));
    q->data = malloc(cap * sizeof(int));
    q->size = 0;
    q->capacity = cap;
    return q;
}

void push(PriorityQueue *q, int val) {
    if (q->size >= q->capacity) {
        // 扩容处理,这里省略
        return;
    }
    q->data[q->size] = val;
    q->size++;
    shiftUp(q->data, q->size - 1);
}

int pop(PriorityQueue *q) {
    if (q->size == 0) return -1;
    return deleteRoot(q->data, &q->size);
}

int top(PriorityQueue *q) {
    if (q->size == 0) return -1;
    return q->data[0];
}

工程建议:在实际项目中,我一般不会手写优先队列,直接用现成的库。但理解底层原理很重要——有一次我在嵌入式系统里需要自己实现,因为标准库不支持。那时候就庆幸自己当初把堆搞明白了。

堆的核心操作流程图

下面这张图总结了堆的整个操作流程,从插入到删除,再到堆排序:

堆的核心操作流程 插入新元素 放到数组末尾 上浮调整 删除根节点 末尾元素补位 下沉调整 建堆 交换根与末尾 堆大小减1 下沉调整

堆这个数据结构,说难不难,说简单也不简单。它的核心就两个动作——上浮和下沉。搞懂了这两个,堆排序和优先队列就水到渠成了。

我个人觉得,堆是那种「看起来简单,但写起来容易出 bug」的数据结构。边界条件、下标计算、递归深度……稍不注意就翻车。但只要你亲手实现过两三遍,这些细节就会变成肌肉记忆。


公众号:蓝海资料掘金营,微信deep3321