(b4)完全二叉堆:批量建堆

官官方信息技术老师·17 页·深入(追求细节与边界)·2 次浏览·38 天前
数据结构优先队列建堆复杂度

批量建堆:一次建成一个堆

看完你能说清 O(n) 建堆为何成立,并亲手推一遍自底向上下沉的全过程

按 空格/→ 演示下一步

问一句
1 / 17 页

全部页面点击任意一页,跳回舞台从这页播放

数据结构优先队列建堆复杂度

批量建堆:一次建成一个堆

看完你能说清 O(n) 建堆为何成立,并亲手推一遍自底向上下沉的全过程

1第 1 页 · 批量建堆:一次建成一个堆

问题设定:从空堆出发

前面说过堆的增删都要维护堆序。那如果手里有 n 个乱序元素,怎么得到一个堆?最直觉的做法:想象一个空堆,把元素一个接一个插进去,每插一个就向上调整一次——插完全部,自然就是个堆。

逐个插入
n 个元素依次调用插入操作,插入时维持堆序不变
向上调整
每次插入只需沿父链上浮一次,单次代价 O(log n)
总代价粗估
n 次 × 每次 O(log n),得到上界 O(n log n)
往书架逐本插书对应 →逐个插入建堆

每插一本都插到当前合适位置,书架始终保持有序

2第 2 页 · 问题设定:从空堆出发

自上而下的上滤:算法

逐个插入建堆:每个新元素从底部出发,一路与父结点比较上滤到位。

1
追加到末尾
新元素先放进数组尾部,保持完全二叉树形状不破坏
2
与父比较
计算父结点下标 ⌊(i-1)/2⌋,比较二者优先级
3
父更小则交换
父小于新元素就交换位置,堆序在局部得以恢复
4
继续上滤
交换后新元素坐上父位,继续与新的父结点比较
5
停止条件
父不更小或已到根,上滤结束,堆序全场成立
3第 3 页 · 自上而下的上滤:算法

上滤的图解过程

数组 [9,4,7] 建成堆后插入 1,绿色是 1 沿父链逐层上滤的路径,逐帧看它怎么爬到根。

图解渲染中…
n:刚插入的 1,放在数组末尾即最底层最左空位l:父节点 4,第一轮比较对象r:祖父 9,上滤终点x:兄弟子树 7,与上滤无关,永远不动
4第 4 页 · 上滤的图解过程

自上而下的上滤:代码

python

逐个 insert 的循环,配上上滤的核心代码,看每个新元素如何逐层上浮。

代码高亮加载中…

外层循环逐个 insert;percolateUp 只与新元素 x 反复比较:父大则父下移,最后 x 一次性落位,而非逐步交换。

5第 5 页 · 自上而下的上滤:代码

上滤的效率分析

前面几页我们用上滤逐个插入元素建堆。方法是对的,但每插一个都可能一路换到根。到底一共要换多少次?把每个元素 worst case 上升的层数加起来,代价会惊人。

单个最坏代价
第 k 个插入的元素最坏上滤 O(log k),接近当前树高
求和下界
log1+log2+…+logn,用换底公式约化为 O(log(n!))
结论
总量 O(n·logn),代价乘上了树高这一因子
∑k=1nO(log⁡k)=O(log⁡(n!))=O(nlog⁡n)\sum_{k=1}^{n} O(\log k)=O(\log(n!))=O(n\log n)
6第 6 页 · 上滤的效率分析

为什么 O(n·logn) 不划算

上滤建堆已是 O(nlogn),看似不差——但和直接排序同阶,作为预处理毫无意义,这是最容易忽略的对比基准。

上滤批量建堆
  • 复杂度 O(n·logn),n 大时 ≈ n log n
  • 只得到一个堆,内部仍无序
  • 之后还得再排序才能输出全序
直接堆排序
  • 复杂度 O(n·logn),同样 n log n
  • 建堆只是排序的第一步
  • 一次流程直接输出有序结果
正确的理解是:O(nlogn) 的建堆被排序下界吸收,毫无改进余地;只有 Floyd 的 O(n) 建堆才算真的赚。
7第 7 页 · 为什么 O(n·logn) 不划算

换个思路:自下而上

上一页我们算清了:从空堆逐个插入要 O(n·logn),不划算。那换个角度——不如先把 n 个元素原封不动按完全二叉树的形状摆好,形状不用花钱,再想办法把堆序补上。

先摆形状
把元素按层序填入完全二叉树,此时还不是堆
从最后一个内部结点起
下标 ⌊n/2⌋−1 开始,往前逐个处理
逐个下滤
每个结点与孩子比较并下沉,直到满足堆序
整理散落的书按高矮归位对应 →批量建堆

先大致摆放书架再逐格调整,对应先摆形状再下滤

8第 8 页 · 换个思路:自下而上

自下而上的下滤:算法

从最后一个内部结点倒着做下滤,子堆逐层合并成完整堆。

1
定位起点
从 ⌊n/2⌋ 号结点开始,它是最后一个有孩子的内部结点
2
执行下滤
对结点 i 调用 percolateDown,使其与左右子堆合成局部堆
3
i 递减重复
i 减一,继续下滤;右兄弟的子树已各自成堆
4
根结点收尾
处理到 i=1 时,两个规模 n/2 的子堆合并为整堆
9第 9 页 · 自下而上的下滤:算法

下滤建堆:完整实例

从右往左看五个状态框:每框是数组的当前形态,箭头标注本次下滤发生在哪个结点、和谁交换。

图解渲染中…
a0:下标 1..5,i=2 是最后一个内部结点g1:每个父结点都不小于孩子,建堆完成
10第 10 页 · 下滤建堆:完整实例

实例续:关键步骤拆细

看根节点4的一次下滤:每层只在两个孩子里取大者比较交换,直到堆性恢复。

图解渲染中…
d1:判断当前下滤位置是否仍有孩子可比较e1:两个孩子中选较大者,保证换上来的是最大孩子
11第 11 页 · 实例续:关键步骤拆细

自下而上的下滤:代码

python

用真实的 Python 代码看 heapify 的核心循环:从最后一个内部节点倒着做下滤。

代码高亮加载中…

第 13 行取 ⌊n/2⌋:叶子无需下滤;第 14 行倒序遍历,先建好子堆,大节点才能一路沉底。

12第 12 页 · 自下而上的下滤:代码

下滤建堆:效率 O(n)

前面实例里下滤建堆看着也是逐个下滤,似乎仍是 n 次 O(log n)?关键区别在于:下滤的代价由结点高度决定,而堆里大多数结点根本没那么高。

按高度分层
高度为 h 的结点约 n/2^(h+1) 个,越低层结点越多
多数结点很矮
近半数是叶结点,下滤代价为 0,直接免费
级数求和
∑ h·n/2^(h+1),h 越大项越小,收敛为 O(n)
公司开会对应 →下滤建堆

占多数的基层员工随口附和即可,管理层少数人才需要长篇发言

∑h=0log⁡nh⋅n2h+1  =  O ⁣(n∑h≥0h2h)=O(n)\sum_{h=0}^{\log n} h\cdot\frac{n}{2^{h+1}} \;=\; O\!\left(n\sum_{h\ge 0}\frac{h}{2^h}\right) = O(n)
13第 13 页 · 下滤建堆:效率 O(n)

两种策略正面对比

同样是建堆,代价为何差出整整一个 log n?关键在多数结点离根近还是离底近。

上滤:自上而下
  • 方向:从浅到深,逐个插入
  • 代价集中:深层结点多,滤得远
  • 总计:O(n·logn),不划算
下滤:自下而上
  • 方向:从深到浅,逐个下滤
  • 代价集中:底层结点多,滤得少
  • 总计:O(n),线性完成
选堆排序等一次性建堆场景,当下滤建堆:多数结点在底层只需下滤 0~1 层,代价天然小。
14第 14 页 · 两种策略正面对比

自测:建堆策略的选择

点击作答

对同一组 n 个元素批量建成完全二叉堆,自下而上下滤通常优于自上而上滤,根本原因是什么?

15第 15 页 · 自测:建堆策略的选择

要点收束

  • ✓上滤逐个插入,成本Σlogi≈O(nlogn)
  • ✓下滤只对非叶结点工作,省一半
  • ✓建堆下滤从⌊n/2⌋−1逆序到0
  • ✓下滤成本按高度求和,总和O(n)
  • ✓n越大,O(n)建堆优势越显著
延伸主题:堆的插入与删除:O(logn)操作堆排序:建堆之后怎么排优先级队列的应用场景
16第 16 页 · 要点收束

课后思考

先自己推一推、画一画,再对照参考答案,看看思路差在哪。

1把下滤建堆的 O(n) 复杂度逐项求和写出来,各层求和时为什么越往后系数越小?

参考答案按高度分层:第 h 层约 n/2^(h+1) 个节点,各下滤 h 步。求和得 Σ n·h/2^(h+1),是常数倍收敛级数,故总量 O(n)。关键在多数节点下滤距离极短。

2对完全填满的满二叉堆和最坏退化情形分别建堆,耗时差多少?这说明了什么?

参考答案满堆与退化堆的节点数固定时,逐层高度结构不变,O(n) 几乎不变;对比上滤的 O(n·logn) 差距才是重点。说明下滤建堆对输入分布不敏感,稳定性好。

3下滤的起点为什么取 ⌊n/2⌋ 而不是 n 或 ⌊n/2⌋−1?如果取错了会怎样?

参考答案内部节点编号最大为 ⌊n/2⌋,其后全是叶子,叶子无需下滤。从 n 开始会对叶子空做下滤,从更小开始会漏掉某个内部节点,堆性质不完整。

17第 17 页 · 课后思考