(c)堆排序

官官方信息技术老师·19 页·深入(追求细节与边界)·1 次浏览·38 天前
排序算法二叉堆原地排序O(n log n)

堆排序

看完你能手推建堆与每次下沉的全部步骤,并说清为何是就地排序

按 空格/→ 演示下一步

问一句
1 / 19 页

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

排序算法二叉堆原地排序O(n log n)

堆排序

看完你能手推建堆与每次下沉的全部步骤,并说清为何是就地排序

1第 1 页 · 堆排序

回顾:完全二叉堆

上一页说堆排序要靠「堆」这个结构。你可能已经见过优先级队列:每次取出的总是最优先的那个。堆正是它的底层实现——关键在两条规则:谁在上、怎么摆。

序性:父不小于子
最大堆中每个父节点的值 ≥ 其子节点,根就是全局最大值
形态:完全二叉树
除最后一层外全满,最后一层左对齐,可无缝塞进数组
数组即树
下标 i 的孩子是 2i+1 与 2i+2,父是 ⌊(i−1)/2⌋,无需指针
公司汇报层级对应 →最大堆结构

层级里的上级权限对应节点值更大,但同层不做比较

parent(i)=⌊(i−1)/2⌋,left=2i+1, right=2i+2\text{parent}(i)=\lfloor(i-1)/2\rfloor,\quad \text{left}=2i+1,\ \text{right}=2i+2
2第 2 页 · 回顾:完全二叉堆

算法总体思路

上一页我们建好了一个大顶堆:堆顶永远是最大值。现在换个角度想——每一轮把最大值「选」出来放到末尾,这不就是选择排序吗?堆只是给了我们一个找最大值的高效结构。

选排的痛点
朴素选择排序每轮线性扫描找最大值,O(n) 一轮,总共 O(n²)
堆顶即最大
大顶堆把最大值直接顶在堆顶,取出只需 O(1),不用扫全表
尾部交换
堆顶与末尾元素交换,最大值就位;堆规模减一,新堆顶下沉修复
重复取顶
每轮放好一个最大值,n-1 轮后整个数组有序
锦标赛决出冠军对应 →堆排序

冠军(堆顶)领奖后退场,剩下的选手重赛一轮定新冠军

T(n)=O(nlog⁡n)T(n)=O(n\log n)
3第 3 页 · 算法总体思路

两阶段:建堆 + 反复摘顶

从无序数组出发,先花一次代价把数组调成大顶堆,再反复交换堆顶与末位并缩堆,直到全部有序。

图解渲染中…
a1:从 n/2-1 号节点倒序逐个下沉,叶节点天然合规a4:缩堆后新堆顶可能违规,下沉一次恢复堆性a3:与 a3 之间的环表示摘顶重复 n-1 次
4第 4 页 · 两阶段:建堆 + 反复摘顶

为什么用堆做选择

上一页我们知道堆排序就是反复摘顶。可为什么要先费力建堆?回忆选择排序:每轮线性扫描找最大值要 O(n),重复 n 轮就是 O(n²)。堆正是为了解决「找最值太慢」这个瓶颈。

选择排序瓶颈
每轮扫全表找最值 O(n),n 轮累计 O(n²)
堆的优势
最值永远在堆顶,摘顶+下沉只需 O(logn)
复杂度对比
n 次 O(logn) 摘顶 = O(nlogn),最坏情况也成立
边界优势
无退化输入:无论数据多乱,logn 稳定不变
锦标赛淘汰赛对应 →堆摘顶

冠军只需打赢 logn 场对手,对应堆顶下沉一层只比较一次

T(n)=O(n)⏟建堆+n⋅O(log⁡n)=O(nlog⁡n)T(n)=\underbrace{O(n)}_{\text{建堆}}+n\cdot O(\log n)=O(n\log n)
5第 5 页 · 为什么用堆做选择

什么是就地排序

前面几页我们说了:堆排序=建堆+反复摘顶。你可能注意到,摘出来的元素往哪放了?答案是和堆尾交换——整个排序就在原数组里腾挪,几乎不借外部空间,这就是就地排序。

额外空间
排序所需的、与输入规模成正比的辅助内存
交换式摘顶
堆顶与堆尾互换,堆缩小一位,无需新数组
O(1) 辅助
只用常数个临时变量,堆排序天然就地
对比归并
归并需 O(n) 辅助数组,堆排序此处占优
在抽屉里整理纸牌对应 →就地排序

不倒到桌上另铺一层,直接在原抽屉内换位完成整理

6第 6 页 · 什么是就地排序

堆区间与已排序区间

把数组横着看:左段仍是堆,右段是已排好的最大值,虚线边界随每轮交换左移一格。

图解渲染中…
a3:堆区末尾,每轮与堆顶交换后边界左移a4:交换后堆顶最大值落到此处,永不回堆
7第 7 页 · 堆区间与已排序区间

交换—下滤—收缩循环

摘顶循环每轮三步:交换、下滤、收缩,缺一不可。

1
堆顶换末尾
当前最大值放到区间末尾,它从此归入已排序区
2
堆规模减一
逻辑上排除末尾元素,堆不再包含已排序区
3
新顶下滤
换上来的末元素偏小,与较大孩子逐层交换下沉
4
循环至空
重复上述三步,堆区间缩到 0,数组整体有序
8第 8 页 · 交换—下滤—收缩循环

大顶堆还是小顶堆

上一页的循环把最大值换到数组尾部——「最大」从哪来?答案取决于堆型:升序要每次摘出最大,所以用大顶堆。如果误用小顶堆,摘出的是最小值,放到尾部就成了降序,边界必须辨析清楚。

升序 → 大顶堆
堆顶是最大值,摘顶后放到数组末尾,从后往前填出升序
小顶堆的后果
每次摘最小放尾部,得到降序数组,且无法原地改成升序
为什么不能翻转
尾部放的是最小值时,交换会破坏已排好区间的有序性
方向记忆法
堆顶出什么,什么去尾部;升序从尾部往回从小到大
倒茶从杯底垒起对应 →升序填充方向

先出的是最重的沉在底部(数组尾),轻的留在上层

9第 9 页 · 大顶堆还是小顶堆

自上而下 vs 自下而上建堆

上一页定了要用大顶堆,但堆不是天生的——拿到一个乱序数组,得先把它「堆化」。怎么建这个堆?有两条路线:一个个往里插,或者从下往上整批调整。代价差别大得惊人。

自上而下建堆
逐个插入并上滤,第 k 个插入最多上滤 k 层,总代价 O(nlogn)
自下而上建堆
Floyd 算法:从最后一个内部节点起倒序下滤,总代价 O(n)
为什么倒序更快
多数节点在底层、下滤距离短;只有少数高层节点才付 logn 的代价
开会逐个通知对应 →自上而下 vs 自下而上建堆

一个人一个人通知像逐个插入 O(nlogn),按层级群发像 Floyd 建堆 O(n)

T(n)=∑k=1log⁡nn2k⋅O(k)=O(n)T(n)=\sum_{k=1}^{\log n} \frac{n}{2^{k}}\cdot O(k)=O(n)
10第 10 页 · 自上而下 vs 自下而上建堆

Floyd 建堆:逐个下滤

Floyd 建堆从最后一个内部节点起逆序下滤,把零散子树合并成整体堆。

1
定位起点
最后一个内部节点是 ⌊n/2⌋−1,叶子天然已是合法的单元堆
2
逆序遍历
从该节点倒着走向下标 0,保证处理每点时子树已是堆
3
逐个下滤
每个节点与较大孩子比较,若更小则交换并继续下沉
4
合并成堆
处理到根即完成,整体复杂度 O(n) 而非 O(n log n)
11第 11 页 · Floyd 建堆:逐个下滤

下滤 siftDown 实现

python

一段可运行的下滤代码:父亲不断与较大的孩子交换,直到恢复堆序。

代码高亮加载中…

四个关键点:没有孩子就停、选较大的孩子比较、父亲够大就提前终止、否则交换并继续下沉——每一处都决定下滤是否正确。

12第 12 页 · 下滤 siftDown 实现

堆排序主体实现

python

Floyd 建堆 + 反复交换下滤,完整的就地堆排序落地。

代码高亮加载中…

L4-5 是 Floyd 建堆:从最后一个非叶节点倒序下滤;L9-11 摘顶循环:交换后堆区间收缩,end 同时是已排序区间的左边界。

13第 13 页 · 堆排序主体实现

复杂度与稳定性分析

前面已完整实现堆排序:Floyd 建堆加上反复摘顶。现在把账算清楚——为什么整体是 O(nlogn)?这个算法省下了什么,又付出了什么代价?

时间 O(nlogn)
建堆 O(n),n−1 次摘顶各 O(logn),总计 O(nlogn)
空间 O(1)
全部交换与下滤都在原数组内,只需常数个临时变量
不稳定
摘顶时的交换把堆底元素挪到前端,长距离移动破坏相等元素的相对次序
权衡本质
就地省内存是收益,跨越式交换破坏稳定是代价
原地倒腾书架对应 →就地排序

不借助新书架直接换书位置对应 O(1) 空间,但书被甩来甩去就乱了先后

T(n)=O(n)⏟Floyd 建堆+(n−1)⋅O(log⁡n)⏟摘顶下滤=O(nlog⁡n)T(n)=\underbrace{O(n)}_{\text{Floyd 建堆}}+\underbrace{(n-1)\cdot O(\log n)}_{\text{摘顶下滤}}=O(n\log n)
14第 14 页 · 复杂度与稳定性分析

实例:建堆阶段演示

以数组 [4, 6, 8, 5, 9] 为例,从最后一个非叶结点出发逐个下滤,把乱序数组建成大顶堆。

1
定位起点
n=5,最后一个非叶结点是下标 1(值 6),从它开始而不是从根开始
2
下滤 6
6 与孩子 5、9 中最大者 9 比较,9 更大则交换,子树 6-9 成为合法堆
3
下滤 4
下标 0 的 4 与孩子 6、8 比,8 最大,交换后 4 落在底层,无需继续
4
建堆完成
数组变为 [8, 9, 4, 5, 6],满足父≥子的堆序性,可进入摘顶阶段
15第 15 页 · 实例:建堆阶段演示

实例:反复摘顶阶段

每轮三连:交换、下滤、收缩,最大值逐个落到尾部。

1
交换堆顶
堆顶(当前最大值)与堆区间末尾元素互换
2
堆长收缩
已就位的最大值移出堆区间,归入已排序区
3
下滤修复
新堆顶下沉到合适位置,恢复大顶堆性质
4
循环往复
区间缩到1即结束,数组整体升序就地完成
16第 16 页 · 实例:反复摘顶阶段

自测:下一步是什么

给定堆快照,判断下一轮交换与下滤的结果

自测:下一步是什么
给定堆快照,判断下一轮交换与下滤的结果
17第 17 页 · 自测:下一步是什么

要点收束

  • ✓两阶段缺一不可:Floyd 建堆 O(n),摘顶 O(n log n)
  • ✓大顶堆配升序:摘顶后最大值沉到尾部
  • ✓就地排序的本质:堆区间不断收缩,无需辅助数组
  • ✓交换后必须下滤恢复堆序,再收缩边界
  • ✓不稳定:长距离交换会打乱相等元素的相对次序
延伸主题:Top K 问题与部分排序优先队列的实际应用多路堆与 d 叉堆
18第 18 页 · 要点收束

课后思考

三个问题先自己想五分钟,再看参考答案。

1Floyd 建堆从倒数第一个非叶节点开始,为什么从后往前下滤,而不是从前往后?

参考答案关键在依赖:下滤要求数据已是堆。从后往前时,每个节点的左右子树都已完成建堆,下滤才有意义;从前往后则子树还未处理,无法保证正确性。

2如果用小顶堆做升序排序,每次摘最小值放哪?需要额外空间吗?

参考答案摘出的最小值与堆尾交换,堆尾落在堆序的最深处,但它是当前最小值,方向就反了。若想就地放数组前端再整体翻转可行;否则需要额外数组,这也是大顶堆更自然的原因。

3堆排序交换堆顶堆尾会打乱远距离元素,哪些场景下这种「跳着访问」会成为短板?

参考答案元素远大于内存或存在外存/磁盘时,堆排序的随机跳跃访问会频繁换页,局部性差;相比之下归并排序顺序访问更友好。这时倾向外部排序或多路归并。

19第 19 页 · 课后思考