Computing · Data Structures

Heaps / Priority Queues

堆 / 优先队列

~ 16 min · v15

堆 / 优先队列

cs-ds/heap · COMP2521
0 / 5

1 一句话定义

堆(heap)专门解决一个问题:反复又快又省事地拿出"当前最大的那个"。它靠一条极简单的规则做到——每个父节点都 ≥ 它的孩子,于是根永远是全场最大,随手就能取走。

先只记一句话就够了:每位上司都比自己的直接下属"大"。这样塔尖那位(根)必然是最大的。但堆只管这一条——同级之间、隔代之间都不保证谁大谁小,所以堆不是排好序的,它只是把"最大的顶在最上面"。这种"反复取最大"的用法,就叫优先队列。
(把规则反过来——父 ≤ 子——就是最小堆,根是最小值。本节统一讲最大堆。)
90i=0 70i=1 80i=2 40i=3 60i=4 30i=5 75i=6 根 = 最大值 90[0] 70[1] 80[2] 40[3] 60[4] 30[5] 75[6]
同一个最大堆,上面是树、下面是数组——它们是一回事。按"从上到下、从左到右"编号,index i 的孩子就在 2i+1 与 2i+2,父亲在 (i−1)/2。这套示例堆会贯穿全篇。

2 模型 · 看堆怎么"上浮"和"下沉"

这一节分三小步,来:第 1 步先弄明白"数组怎么就成了树";第 2 步看它在代码里怎么写;第 3 步再看堆被插入 / 删除打乱后,怎么靠"上浮 / 下沉"把自己修回来。

第 1 步 · 数组怎么就"是"树?(点点看)

堆是完全二叉树——一层一层、从左到右填满,中间不留洞。正因为没有洞,把节点按"从上到下、从左到右"编号 0,1,2,…,就能一个不漏地塞进数组。编完号会冒出一个规律:谁的孩子都在 2i+1 和 2i+2。下面点任意一个节点或格子,看它的父亲、孩子分别落在哪个下标

点一个节点 / 格子,看它的父亲和孩子分别在哪个下标。
你点的 index i 父亲 = (i−1)/2 孩子 = 2i+1 / 2i+2

试着点几个不同的格子,你会发现:越往下下标越大,但父子永远差着 2 倍左右的距离。这就是堆能用纯数组、不用任何指针的原因——"亲戚"全靠这三个算式现算。

第 2 步 · 堆在代码里怎么写出来?

不需要 struct、不需要指针——一个数组 h[] 加一个当前大小 n 就是一个堆。父子关系全靠下标算。下面这段建出的,正是上面那个示例堆。

// ① 一个堆 = 一个数组 + 当前元素个数 n(完全二叉树,无需指针)
int h[100];
int n = 0;

// ② 下标关系:靠算术算出"亲戚",不用任何指针
//    父亲(i)   = (i - 1) / 2
//    左孩子(i) = 2*i + 1
//    右孩子(i) = 2*i + 2

// ③ 把示例堆装进去(已满足"父 ≥ 子",所以它就是合法最大堆)
int init[] = { 90, 70, 80, 40, 60, 30, 75 };
for (int i = 0; i < 7; i++) h[n++] = init[i];
// h[0]=90 是根(最大);h[1]=70、h[2]=80 是它的两个孩子……

对照上面的图:h[0]=90 是塔尖(最大);h[1]、h[2](70、80)是它的左右孩子;h[3..6] 是更下面一层。想知道谁是 h[6]=75 的父亲?算 (6-1)/2 = 2 → 就是 h[2]=80。下面就点 ▶ 插入 / ▶ 删除堆顶,看上浮、下沉怎么一步步修复堆。

第 3 步 · 看堆怎么自我修复。堆只需要两个动作:插入时把新人放末尾、和父亲比着往上浮;删除堆顶时把末尾搬上来、和较大的孩子比着往下沉。关键直觉是——每次只有一个地方会违反"父≥子",你只是把这个"违反点"沿着一条路一路推走。每一步我都会标出违反点在哪、这次交换怎么修。点 ▶ 慢慢看(先点插入/删除,再点播放)。

堆操作演示(数组 ↔ 树 同步)

当前操作的代码(当前行高亮)
颜色说明
当前节点(正在处理) 正在比较 刚刚交换
底层数组(树里的每个节点 = 数组里的一格,同步高亮)

父 = (i−1)/2  ·  左孩子 = 2i+1  ·  右孩子 = 2i+2

提示
点 ▶ 插入并上浮 / ▶ 删除堆顶并下沉,看堆怎么自我修复。也可以改输入框里的数字,插入任意值。
0
堆大小 n
—
堆顶(最大)
0
树高 ≈ log₂n

3 核心要点 · 4 句人话看懂堆

每张卡片先用一句大白话说清"它到底在讲什么",再配最小例子。第 ① 条是堆的灵魂,记牢"只管父子、不管全局"。

① 堆性质:只管父子,不管全局
一句话:每个父亲都 ≥ 自己的孩子(最大堆)。仅此而已——兄弟之间、隔代之间都不保证顺序,所以堆不是排好序的数组。
正因为只要求"父 ≥ 子",根必然是全场最大。但你不能指望 h[1] 比 h[2] 大,也不能指望 h[3] 比 h[2] 小。最小堆则把规则反过来:父 ≤ 子,根是最小值。
   90
  /  \
 70   80
↑ 80 在 70 右边却更大,合法
② 用数组存:下标算亲戚,省掉指针
一句话:堆是完全二叉树(一层层从左往右填满),所以能塞进数组,靠下标算术找父子,一个指针都不用。
0-indexed 下:父 = (i−1)/2,左孩子 = 2i+1,右孩子 = 2i+2。这也是堆省内存、缓存友好的原因——数据紧挨着放,没有散落在堆上的节点。
下标 idx : 0 1 2 3 4 5 6
值   val : 90 70 80 40 60 30 75
父(6) = (6−1)/2 = 2 → 值 80
③ 两个动作:上浮 & 下沉,都走一条路
一句话:插入就把新人放末尾、和父亲比着往上浮;删堆顶就把末尾搬上来、和较大的孩子比着往下沉。都只走一条到根/到底的路。
路径最长 = 树高 ≈ log₂n,所以插入和删除都是 O(log n)。上浮只跟"父亲"一个人比;下沉要在"两个孩子"里挑最大的再比——这点最容易写错。
插入:放末尾 → 上浮 ⟹ O(log n)
删最大:填堆顶 → 下沉 ⟹ O(log n)
取最大:直接读 h[0] ⟹ O(1)
④ 招牌应用:优先队列
一句话:凡是要反复"拿出当前最大/最小"的场景,就用堆——这就是优先队列。比"排好序的数组"或"每次扫一遍"都快。
堆让 插入 和 取出极值 都稳定在 O(log n)。任务调度、Dijkstra 最短路、堆排序、Top-K 都靠它。COMP2521 24T2 Q6 考的就是"按复杂度选数据结构"——答案正是堆。
排序数组:取最大 O(1)、插入 O(n)
无序数组:插入 O(1)、取最大 O(n)
堆:插入 / 取最大 都 O(log n)

4 错误实验室 · 点一下亲眼看 bug

下面 4 个是写堆最常翻车的坑——第 ① 个是考试爱考的"建堆复杂度"误区,后 3 个是 sift-down 与下标的经典错误。点击卡片,控制台会模拟运行效果。

① "逐个插入"和"自底向上建堆"一样快?
逐个 insert 建堆是 O(n log n),自底向上 heapify 才是 O(n)
② sift-down 只跟左孩子比
忘了在左右两个孩子里挑最大的 → 堆性质被破坏
③ 下标公式用错(1-indexed 混用)
0-indexed 应是 2i+1 / 2i+2,写成 2i / 2i+1 会自指、越界
④ 删除后忘了缩小 size
末尾填顶后没 (*n)-- → 旧末尾变"幽灵元素"又冒出来
// 点击上方任一卡片,查看错误代码与运行效果
$ ./a.out (等待运行...)

5 即时小测 · 5 题 + 动手例题

这 5 题都卡在初学者最常错的点上——堆性质(不是全局有序)、下标算术、上浮/下沉的落点、删堆顶后谁上位、以及优先队列的选型。点错也别灰心,提示会告诉你错在哪。最后到练习系统亲手把上浮写出来。

去做例题:堆 · 插入并上浮
去做例题

读完这节,你应该明白:

堆 = 完全二叉树 + "父 ≥ 子"一条规则 + 用数组存。这条规则很弱(兄弟、隔代都不保证顺序),所以堆不是排好序的,但它足以让根永远是最大值。维护它只需两招:插入时新人放末尾、和父亲比着上浮;删堆顶时末尾搬上来、和较大的孩子比着下沉——两招都只走"一条到根/到底的路",长度 = 树高 ≈ log n,所以 insert 和 delMax 都是 O(log n),取最大是 O(1)。正因如此,要反复"取当前极值"就用堆,这就是优先队列,也是堆排序、Dijkstra 的引擎 ↓

⌨ 键盘快捷键

→
单步前进
←
单步后退
Space
播放 / 暂停
R
重置示例堆
?
打开本帮助
Esc
关闭帮助

提示:先把鼠标点进上方演示区再按键。