Skip to content

为什么把"堆"单独拎出来讲

很多同学第一次见到"堆"是在堆排序里,于是默认堆只是排序的副产品。这是 408 的一个理解误区——堆是一种独立的数据结构,堆排序只是它的一个应用,其它应用包括优先队列、TopK 问题、哈夫曼树的合并过程等。

408 真题里直接考"堆操作"的频次也不低:给一个数组问是不是堆、给序列让你画建堆过程、对一个堆做插入或删除后画出形态——这些都不是"排序题",而是"数据结构题"。

核心思想

(Heap)是一棵满足堆序性的完全二叉树。所谓堆序性,指的是:

  • 大根堆(Max Heap):每个结点的值都大于等于其孩子的值
  • 小根堆(Min Heap):每个结点的值都小于等于其孩子的值

注意三点:

  1. 堆不是有序的——堆只保证"双亲优于孩子",不保证左右孩子之间或同一层之间有序
  2. 堆是完全二叉树——这是它能用数组顺序存储的根本原因
  3. 根永远是最值——大根堆的根是最大值、小根堆的根是最小值,这是 O(1) 取最值的来源
    大根堆                  小根堆
      9                       1
     / \                     / \
    7   8                   3   2
   / \ / \                 / \ / \
  4  6 5  3               7  4 8  6

观察大根堆:每个三角(双亲 + 两孩子)内,双亲都最大;但 7 < 8、4 < 6 这种"同层比较"或"跨子树比较"是不要求的。

交互可视化

下方动画演示数组建堆 + 堆排序的完整过程,可以观察每次下沉调整时的元素交换路径:

加载可视化中...

操作详解

一、堆的存储——为什么必须是数组

堆是完全二叉树,所以可以用数组按层序编号顺序存储,无需指针。给定结点下标 i,双亲和孩子的下标可以直接算出来:

下标从 1 开始A[1..n],408 主流约定,下面所有代码均按此):

关系公式
双亲 parent(i)⌊i / 2⌋
左孩子 left(i)2i
右孩子 right(i)2i + 1
最后一个非叶结点⌊n / 2⌋
叶子结点范围⌊n/2⌋ + 1 ~ n

下标从 0 开始A[0..n-1],工业语言通常约定):

关系公式
双亲 parent(i)⌊(i - 1) / 2⌋
左孩子 left(i)2i + 1
右孩子 right(i)2i + 2
最后一个非叶结点⌊n / 2⌋ - 1

⚠️ 易错:408 真题下标多数从 1 开始,但部分题目(特别是程序填空)会切换到 0。第一步永远先看清题目约定,否则双亲孩子算错一格。

二、下沉调整(Sift Down / 向下调整)

下沉是堆操作的最核心动作。场景是:某个结点 i 不满足堆序性(值比孩子小,以大根堆为例),需要把它"沉"下去。

算法

  1. 比较 i 与它的左右孩子,找到最大的那个(设下标为 largest
  2. 如果 largest == i,已经满足堆序,结束
  3. 否则交换 A[i]A[largest],然后对 largest 位置递归下沉
c
// 大根堆下沉调整:从下标 i 开始,堆大小为 n(A[1..n])
void SiftDown(int A[], int i, int n) {
    int temp = A[i];                // 暂存当前结点,避免每次都交换
    for (int k = 2 * i; k <= n; k *= 2) {
        // 选左右孩子中较大的
        if (k < n && A[k] < A[k + 1]) {
            k++;
        }
        if (temp >= A[k]) {         // 已满足堆序,停止下沉
            break;
        }
        A[i] = A[k];                // 较大孩子上移
        i = k;                      // i 跟着沉下来
    }
    A[i] = temp;                    // 最终位置写入
}

复杂度:O(log n),下沉路径最长 = 树的高度。

优化点:这里用 temp 暂存 + 单赋值取代三次赋值的"完整交换",避免重复写入。408 应试代码可以写成标准 swap,但课本上的"调整"伪代码经常采用这种"单边搬移"写法,识图能看懂即可。

三、上浮调整(Sift Up / 向上调整)

上浮是下沉的逆操作。场景:某个结点的值比双亲大(大根堆),需要"浮"上去。典型用在堆插入:新元素先放到数组末尾,然后从末尾向上调整。

c
// 大根堆上浮调整:从下标 i 向根方向调整
void SiftUp(int A[], int i) {
    int temp = A[i];
    while (i > 1 && temp > A[i / 2]) {
        A[i] = A[i / 2];            // 双亲下移
        i = i / 2;
    }
    A[i] = temp;
}

复杂度:O(log n)。

四、建堆(Heapify)——为什么是 O(n)

把一个无序数组变成堆,有两种思路:

思路 A:自顶向下插入(O(n log n))

把数组看作"逐个插入"的过程,对每个新元素做上浮。每次插入 O(log n),n 个元素总 O(n log n)。

思路 B:自底向上下沉(O(n))

直接把数组看成完全二叉树。从最后一个非叶结点 i = n/2 开始,依次往前对每个非叶结点做下沉调整:

c
// 大根堆建堆:O(n)
void BuildMaxHeap(int A[], int n) {
    for (int i = n / 2; i >= 1; i--) {
        SiftDown(A, i, n);
    }
}

为什么思路 B 是 O(n) 而不是 O(n log n)

虽然每次 SiftDown 最坏是 O(log n),但绝大多数结点的下沉路径都很短。设树高为 h,则:

  • 第 h 层(叶子,约 n/2 个):高度 0,根本不下沉
  • 第 h-1 层(约 n/4 个):下沉最多 1 层
  • 第 h-2 层(约 n/8 个):下沉最多 2 层
  • ...
  • 根(1 个):下沉最多 h 层

总代价 T(n)=k=0hn2k+1k=O(n)(这是个收敛级数,常用结论:k=0k/2k=2,所以 T(n)n)。

直觉记忆:思路 B 高效是因为"大部分结点是叶子,叶子不用下沉"。而思路 A 把每个新结点都假定要爬到根,所以慢。

手算示例:用思路 B 对 [4, 1, 3, 2, 16, 9, 10, 14, 8, 7] 建大根堆(下标从 1 开始)。

初始(线性看):A = [_, 4, 1, 3, 2, 16, 9, 10, 14, 8, 7]
对应完全二叉树:
              4(1)
           /        \
        1(2)         3(3)
        / \         /  \
      2(4) 16(5) 9(6) 10(7)
      / \   /
   14(8) 8(9) 7(10)

n = 10,最后一个非叶结点 i = n/2 = 5。

i = 5(值 16): 孩子是 7(下标 10),16 > 7,不下沉。
i = 4(值 2):  孩子是 14(下标 8)和 8(下标 9),最大是 14。
                2 < 14,交换。A[4] = 14, A[8] = 2。
i = 3(值 3):  孩子是 9(下标 6)和 10(下标 7),最大是 10。
                3 < 10,交换。A[3] = 10, A[7] = 3。
i = 2(值 1):  孩子是 14(下标 4)和 16(下标 5),最大是 16。
                1 < 16,交换。A[2] = 16, A[5] = 1。
                继续下沉 i = 5,孩子只有 A[10] = 7,1 < 7,交换。
                A[5] = 7, A[10] = 1。
i = 1(值 4):  孩子是 16(下标 2)和 10(下标 3),最大是 16。
                4 < 16,交换。A[1] = 16, A[2] = 4。
                继续下沉 i = 2,孩子是 14 和 7,最大是 14。
                4 < 14,交换。A[2] = 14, A[4] = 4。
                继续下沉 i = 4,孩子是 2 和 8,最大是 8。
                4 < 8,交换。A[4] = 8, A[9] = 4。

最终大根堆 A = [_, 16, 14, 10, 8, 7, 9, 3, 2, 4, 1]

五、堆的插入

算法:把新元素 x 放到数组末尾(即完全二叉树的最后一个位置),然后对它做 SiftUp。

c
void HeapInsert(int A[], int *n, int x) {
    (*n)++;
    A[*n] = x;
    SiftUp(A, *n);
}

复杂度:O(log n)。

手算示例:在大根堆 [_, 16, 14, 10, 8, 7, 9, 3, 2, 4, 1] 中插入 15。

末尾追加:A = [_, 16, 14, 10, 8, 7, 9, 3, 2, 4, 1, 15],n = 11
15 在下标 11,双亲下标 = ⌊11/2⌋ = 5,A[5] = 7。15 > 7,交换:
                 A = [_, 16, 14, 10, 8, 15, 9, 3, 2, 4, 1, 7]
15 在下标 5,双亲下标 = 2,A[2] = 14。15 > 14,交换:
                 A = [_, 16, 15, 10, 8, 14, 9, 3, 2, 4, 1, 7]
15 在下标 2,双亲下标 = 1,A[1] = 16。15 < 16,停止。

最终:A = [_, 16, 15, 10, 8, 14, 9, 3, 2, 4, 1, 7]

六、堆的删除(删除堆顶)

堆的常规删除指的是删除堆顶元素(即最值),这是优先队列出队的语义。

算法

  1. 取出堆顶 A[1](这就是要返回的值)
  2. 把最后一个元素 A[n] 搬到堆顶位置
  3. 数组大小减 1(n--
  4. 对新堆顶做 SiftDown
c
int HeapDeleteTop(int A[], int *n) {
    int top = A[1];
    A[1] = A[*n];
    (*n)--;
    SiftDown(A, 1, *n);
    return top;
}

复杂度:O(log n)。

为什么不是直接"删根然后两个子堆合并":那样会破坏完全二叉树的形态(最后一层不再连续)。把末尾元素提上来再下沉是保证形态的最简洁方案。

七、堆作为优先队列

优先队列(Priority Queue)是一个 ADT,要求支持:

  • Insert(x):插入一个带优先级的元素
  • ExtractTop():取出并删除优先级最高的元素

用大根堆实现优先队列,两个操作都是 O(log n)。这就是它在系统底层(操作系统进程调度、Dijkstra/Prim 等图算法、哈夫曼合并)被广泛使用的原因。

复杂度分析

操作时间复杂度空间复杂度
建堆(自底向上下沉)O(n)O(1)(原地)
建堆(自顶向下插入)O(n log n)O(1)
插入一个元素O(log n)O(1)
删除堆顶O(log n)O(1)
查找最大/最小(堆顶)O(1)O(1)
查找任意元素O(n)O(1)

易错:在堆中"查找任意元素"是 O(n)——堆只保证根是最值,不保证任何其它的有序性。如果题目问"在大根堆中查找第 k 大的元素效率",O(1) 仅对 k = 1 成立。

考研高频考点

  • 建堆过程的画图题——给定数组用自底向上方法建堆,要求画出每一步的形态(应用题/选择题双高频)
  • 堆插入后的形态——给一个堆和一个新值,问插入后的结果
  • 堆删除(删除堆顶)后的形态——给一个堆,问出队一次后的结果
  • 判定是否是堆——给一个数组(或一个完全二叉树),问是否为大/小根堆
  • 顺序存储下标关系——双亲、孩子的下标公式(选择题/填空题高频,区分 0-base 和 1-base)
  • 建堆 O(n) 的推导(综合题、概念辨析)
  • 堆 vs 二叉排序树:堆只保证双亲优于孩子,BST 保证整棵树有序(易错点)
  • 优先队列的应用场景(Dijkstra、Prim、哈夫曼合并)

易错:很多同学把"堆"和"堆排序"混在一起。请记住:建堆是 O(n),堆排序是 O(n log n)——堆排序的总复杂度是"O(n) 建堆 + n 次 O(log n) 删除"= O(n log n),建堆本身并不慢。

易错:堆的形态不是唯一的——同一组数据可以建出多个合法的大根堆(只要满足堆序,左右孩子顺序无所谓)。但用"自底向上下沉"算法建出来的堆是确定的,画图题以这个为标准答案。

相关知识

  • 堆排序:堆作为排序算法的直接应用,建堆 + 反复删除堆顶
  • 树与二叉树基本概念:堆基于完全二叉树,复习完全二叉树的性质对理解堆的下标关系很有帮助
  • 哈夫曼树:哈夫曼树的构建过程本质上是用最小堆(优先队列)反复合并最小的两棵子树
  • Dijkstra 算法Prim 算法:标准实现都依赖堆来加速"取最小未确定结点"

真题练习

相关真题(7题)

2024Q9选择题2分

堆排序:大根堆连续删除两个最大元素后的堆结构

2022Q42综合题8分

算法设计:从大量数据中求最小的 10 个数(大根堆维护)

2021Q11选择题2分

大根堆构建:逐个插入元素后的堆结构

2020Q9选择题2分

堆的性质:完全二叉树结构与次大值位置的判断

顺序表二叉树基础(性质+存储)
2015Q10选择题2分

堆的删除操作:删除指定元素后调整堆需要的比较次数

2011Q11选择题2分

堆的调整操作:删除堆顶后重新调整的过程

2009Q9选择题2分

小根堆插入:插入元素后的向上调整过程