一、堆是什么?先说说我的理解

堆(Heap)这东西,说白了就是一种特殊的完全二叉树。我当年刚学数据结构时,总觉得堆和「堆栈」是一回事,后来被面试官问得哑口无言才搞清楚——堆是一种树形结构,不是内存里的那个堆。

堆有两个核心特征:

  • 结构上:它是一棵完全二叉树。什么叫完全二叉树?就是除了最后一层,其他层都是满的,最后一层的节点都靠左排列。
  • 值上:每个节点的值,要么大于等于(或小于等于)它的左右孩子节点的值。

根据这个「大于等于」还是「小于等于」,堆分成了两种:

大顶堆(Max Heap)

每个节点的值都 大于等于 它的左右孩子。换句话说,根节点是整个堆里最大的元素。

大顶堆示例:

        90
       /  \
     70    80
    /  \  /  \
   60  50 40  30

你看,90 > 70 且 90 > 80,70 > 60 且 70 > 50……每一层都满足这个关系。

小顶堆(Min Heap)

每个节点的值都 小于等于 它的左右孩子。根节点是最小的。

小顶堆示例:

        10
       /  \
     20    30
    /  \  /  \
   40  50 60  70

我在项目中遇到过一个问题:用堆来实现定时器任务调度。每次取最近要执行的任务,其实就是从小顶堆里取根节点。嗯,这个场景后面还会细讲。

二、堆的存储结构——数组才是王道

你可能会想:树嘛,用链表存不就行了?

但堆不一样。因为它是完全二叉树,用数组存反而更省空间、更快。我个人的习惯是:只要能用数组,绝不用链表——数组的缓存局部性好,访问快,而且没有指针开销。

数组下标与节点的对应关系

假设数组下标从 0 开始(C语言里就是这样),那么:

  • 下标为 i 的节点,它的左孩子下标是 2*i + 1
  • 右孩子下标是 2*i + 2
  • 它的父节点下标是 (i - 1) / 2(整数除法)

举个例子,上面那个大顶堆用数组存就是:

int heap[] = {90, 70, 80, 60, 50, 40, 30};
// 下标:    0   1   2   3   4   5   6

检查一下:下标 1(值70)的父节点是 (1-1)/2 = 0,也就是 90,没错。左孩子是 2*1+1 = 3,也就是 60,确实 70 > 60,符合大顶堆。

小技巧:如果你习惯下标从1开始,公式会变成:左孩子 2*i,右孩子 2*i+1,父节点 i/2。我个人更推荐从0开始,因为C语言数组天然从0开始,省去一个浪费的空间。

三、堆的核心操作:向下调整与向上调整

堆的插入和删除,本质上就是这两个调整操作。你只要掌握了它们,堆排序、优先队列什么的都不在话下。

1. 向下调整(Heapify Down / Sift Down)

这个操作通常用在 删除根节点 之后。比如我们删掉大顶堆的根节点(最大值),然后把最后一个元素放到根的位置,这时候堆的性质被破坏了,就需要从根开始往下调整。

思路:

  1. 从当前节点开始,找到它和它的左右孩子中最大的那个
  2. 如果最大的不是当前节点,就交换,然后继续向下调整
  3. 直到当前节点比左右孩子都大,或者没有孩子了
void siftDown(int arr[], int n, int i) {
    int largest = i;          // 先假设当前节点最大
    int left = 2 * i + 1;
    int right = 2 * i + 2;

    if (left < n && arr[left] > arr[largest])
        largest = left;
    if (right < n && arr[right] > arr[largest])
        largest = right;

    if (largest != i) {
        // 交换,然后继续向下调整
        int tmp = arr[i];
        arr[i] = arr[largest];
        arr[largest] = tmp;
        siftDown(arr, n, largest);
    }
}

我曾经在写堆排序时,因为忘记检查 left < n 这个边界条件,导致数组越界访问。嗯,这种低级错误,犯过一次就再也不会忘了。

2. 向上调整(Sift Up)

这个操作用在 插入新元素 的时候。我们把新元素放到数组末尾,然后一路往上「冒泡」。

思路:

  1. 从当前节点开始,比较它和父节点
  2. 如果当前节点比父节点大(大顶堆),就交换
  3. 继续向上,直到当前节点不比父节点大,或者到达根节点
void siftUp(int arr[], int i) {
    while (i > 0) {
        int parent = (i - 1) / 2;
        if (arr[i] <= arr[parent])
            break;   // 已经满足堆性质
        // 交换
        int tmp = arr[i];
        arr[i] = arr[parent];
        arr[parent] = tmp;
        i = parent;  // 继续向上
    }
}

注意:向上调整和向下调整的时间复杂度都是 O(log n),因为堆的高度是 log₂n。但如果你用数组存堆,插入和删除的常数非常小,比链表实现快得多。

四、堆的完整操作流程

我把堆的常用操作整理成了表格,方便你对照:

操作 方法 时间复杂度 核心调整
插入 先放数组末尾,再向上调整 O(log n) siftUp
删除根节点 用最后一个元素替换根,再向下调整 O(log n) siftDown
建堆 从最后一个非叶子节点开始,逐个向下调整 O(n) siftDown
堆排序 建堆 + 反复删除根 O(n log n) siftDown

你可能会问:建堆为什么是 O(n) 而不是 O(n log n)?

原因很简单:大部分节点在底层,调整的深度很小。具体数学推导我就不展开了,你记住结论就行——建堆是线性的,这在面试里经常被问到。

五、知识体系总览

下面这张图是我画的堆知识结构图,帮你理清脉络:

堆(Heap) 定义 完全二叉树 大顶堆 / 小顶堆 存储结构 数组存储 下标公式:2i+1, 2i+2 核心操作 向下调整(siftDown) 向上调整(siftUp) 插入 → siftUp 删除根 → siftDown 建堆 → siftDown 典型应用 堆排序 优先队列 Top K 问题 定时器调度 堆的核心:完全二叉树 + 数组存储 + 两种调整

六、避坑指南

最后,分享几个我踩过的坑:

  • 数组越界:向下调整时,一定要先检查左右孩子的下标是否在数组范围内。我刚开始写堆排序时,经常因为 left >= n 没判断而崩溃。
  • 建堆的顺序:建堆要从最后一个非叶子节点开始向下调整,而不是从根开始。从根开始的话,只能保证根节点最大,下面的子树可能还是乱的。
  • 大顶堆 vs 小顶堆:比较符号别搞反了。我建议你写代码时,把比较逻辑单独抽成一个函数,比如 bool compare(int a, int b),这样切换大顶堆和小顶堆只需要改一处。

我的习惯:在写堆相关的代码时,我会先画一个简单的完全二叉树在纸上,然后手动模拟一遍调整过程。代码写完后,再用几个小数据跑一遍,确保每一步都符合预期。这个方法虽然土,但非常有效。

好了,堆的定义、存储结构和两种调整操作就讲到这里。记住一句话:堆就是用数组实现的完全二叉树,核心就两个操作——向上调整和向下调整。把这个搞明白了,后面堆排序、优先队列什么的,都是水到渠成的事。


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