(f)归并排序

官官方信息技术老师·18 页·深入(追求细节与边界)·0 次浏览·38 天前
分治思想递归实现复杂度分析

归并排序

看完你能亲手推导分治思路、写出递归实现并分析 O(n log n) 的来龙去脉

按 空格/→ 演示下一步

问一句
1 / 18 页

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

分治思想递归实现复杂度分析

归并排序

看完你能亲手推导分治思路、写出递归实现并分析 O(n log n) 的来龙去脉

1第 1 页 · 归并排序

两堆已排好的牌怎么合

上一页我们说归并排序靠「分」和「合」。合的那一步有个好起点:假设你手里有两副已经排好序的牌,现在要把它们并成一堆仍然有序的牌,你会怎么做?

各看最上面
两堆牌都只看最顶一张,取较小的那张放入结果
谁小谁出列
取走一张后,该堆自动露出下一张,继续比较
一堆空了直接抄
某堆取完,另一堆剩余整体接在末尾即可
一轮合完
n 张牌只需比较不超过 n 次,效率是线性的
两列排队的人按身高 merge对应 →归并 merge

每次比较两队队首,矮者出队,对应取较小元素

2第 2 页 · 两堆已排好的牌怎么合

分而治之的思想

上一页我们解决了核心问题:两堆已排好的牌能很快合并成一堆。那面对一大堆乱牌呢?先别急着排——把它一分为二,每一半也照此办理,直到手里只剩一张牌:一张牌天然有序。

拆:不断二分
把序列对半切,切到每段只剩一个元素(天然有序)
治:单元素有序
最小子问题不用解——一个元素本身就是有序的
合:逐层归并
用上页的合并方法,把有序小段两两合成更大有序段
公司逐级汇报对应 →分而治之

基层各自整理数据对应最小子问题,逐层汇总合并对应逐层归并

T(n)=2T(n/2)+O(n)  ⇒  O(nlog⁡n)T(n)=2T(n/2)+\mathcal{O}(n)\;\Rightarrow\;\mathcal{O}(n\log n)
3第 3 页 · 分而治之的思想

递归拆分的全过程

上半段向下逐层二分到单元素,下半段向上逐层合并回有序数组,一图看尽分与合。

图解渲染中…
c1:拆到单元素:38 和 27 各自成一堆d1:按牌堆合并法把两个单元素合成有序对
4第 4 页 · 递归拆分的全过程

主算法 mergeSort

cpp

看主算法如何把区间二分、递归、归并三步串成完整排序。

代码高亮加载中…

L2 递归基防死循环;L4 二分中点;L7 前两行递归保证归并时两段必有序——这是 merge 能正确工作的前提。

5第 5 页 · 主算法 mergeSort

递归的三要素

递归拆分数组靠三件事撑住:终止条件、切分点、调用顺序,缺一即崩。

1
终止条件
区间只剩一个元素时直接返回,防止无限递归爆栈
2
取中点
mid = (lo+hi)/2,无溢出写法 lo+(hi-lo)/2
3
先递归左半
solve(lo, mid),把左半段完全排好才往下走
4
再递归右半
solve(mid+1, hi),右半就位后两段都有序
5
合并返回
merge 两段有序区间,结果向上层传递
6第 6 页 · 递归的三要素

递归调用栈展开

从上往下是调用(拆),从下往上是返回(合),进出各 log₂n 层。

图解渲染中…
T:最外层调用,处理全部 4 个元素L:左半递归:先一路拆到底再返回M:每层返回时触发合并
7第 7 页 · 递归调用栈展开

归并实例走一遍

用具体数组走一遍两个有序子序列的合并,看清每次比较与搬运。

1
摆出两堆
左堆 [2,5,9],右堆 [1,6,8],各有一个指针指向队首
2
首次比较
比较两队首 2 和 1,1 更小,搬 1 到结果
3
右指针右移
右堆指针移到 6,再比 2 与 6,搬 2
4
交替搬运
依次比 5/6、9/6、9/8,搬 5、6、8
5
收尾搬运
右堆空了,左堆剩下的 9 整体接到尾部
6
得到结果
结果 [1,2,5,6,8,9],n 次比较完成合并
8第 8 页 · 归并实例走一遍

merge 函数实现

python

双指针逐个比较、谁小谁先走,最后把剩余段整段复制——两堆有序牌真正合并的代码。

代码高亮加载中…

三指针分工:i、j 各读一堆,k 负责写回;比较取 <= 保证相等元素相对次序不变,这就是稳定性。

9第 9 页 · merge 函数实现

实现中的边界细节

上一页 merge 写完了,但真正动手实现的人,大多栽在边界上:左半区和右半区谁先耗尽?复制的区间是左闭右开还是闭区间?这一页专门收拾这些坑。

辅助缓冲区
提前 new 一次全局复用,不要在 merge 里反复分配
区间端点约定
统一用左闭右开 [l, r),mid 拆分才不会重叠漏元素
剩余尾部拷贝
一侧耗尽后,另一侧剩余元素要么拷回、要么已就位
稳定性的守护
相等时优先取左半区元素,顺序才不被打乱
搬家时装箱封箱对应 →merge 的边界处理

箱子要提前备好对应辅助缓冲区,封箱前逐格清点对应端点检查

10第 10 页 · 实现中的边界细节

双指针如何移动

指针 i、j、k 的移动轨迹与元素落位关系图

双指针如何移动
指针 i、j、k 的移动轨迹与元素落位关系图
11第 11 页 · 双指针如何移动

正确性论证

上一页看到双指针一步步移动,但凭什么每步取的就是剩余最小的?就像验收一栋楼,不能只看最后刷得漂亮,要证明每一层砌的时候都没偷工。

循环不变式
每次循环开始时:输出已有序,且都是两堆中最小的那批
初始化成立
第一次循环前输出为空,空序列天然有序,不变式平凡成立
保持成立
两堆头各自是剩余最小,取二者较小者接在有序输出后,仍有序
终止即正确
循环结束时两堆全空,输出含全部元素且有序,归并正确
验收盖楼逐层检查对应 →循环不变式

每砌一层都验一次,楼封顶时整栋必然合格

12第 12 页 · 正确性论证

两个等长段 vs 不等长段

双指针过程在两段中都可能先走完,收尾处理是否正确,决定 merge 整体正确性能否闭环。

一侧先耗尽时
  • 现象:某段指针越过末尾
  • 处理:直接拷贝另一段剩余元素
  • 依据:另一段本就有序且更大
两侧等长耗尽
  • 现象:两指针同时到末尾
  • 处理:循环自然结束,无需额外拷贝
  • 依据:每次比较都已归位一个元素
正确理解:收尾拷贝只在不等长时执行;等长时它拷贝零个元素,两种情况由同一代码统一覆盖。
13第 13 页 · 两个等长段 vs 不等长段

时间复杂度推导

把每层归并的总代价乘以递归层数,就能推出 O(n log n)。

1
单层代价 n
每层所有归并加起来恰好覆盖全部 n 个元素,总代价 O(n)
2
层数 log n
每次拆一半,n 除到 1 需要约 log₂n 层
3
相乘得 O(n log n)
n × log n,与输入是否有序无关
14第 14 页 · 时间复杂度推导

空间代价与稳定性

前面推导出时间复杂度 O(n log n),但天下没有免费的午餐:merge 阶段需要额外地方存放合并结果。本页回答两个追问——辅助空间到底花在哪,以及相等元素的相对顺序是否会被打乱。

辅助缓冲区
merge 需要一个与原数组等长的临时数组存放合并结果
空间 O(n)
递归深度只有 O(log n),主导开销是那一块缓冲区
稳定性
合并相等元素时先取左段,原始相对顺序得以保留
对比快排
快排原地但不稳定,归并稳定但需付 O(n) 空间
搬家时的周转房对应 →辅助缓冲区

家具没法悬空重组,得先借一间房暂放,房间大小约等于全部家当

T(n)=O(nlog⁡n),S(n)=O(n)缓冲+O(log⁡n)栈T(n)=O(n\log n),\quad S(n)=O(n)_{\text{缓冲}}+O(\log n)_{\text{栈}}
15第 15 页 · 空间代价与稳定性

自测:归并的关键

点击作答

merge 两个已排序子数组时,为什么双指针每次只需比较两段当前的首元素取较小者?

16第 16 页 · 自测:归并的关键

要点收束

  • ✓拆到单元素天然有序,功夫全在合并
  • ✓双指针各管一段,逐个取小者保证有序
  • ✓相等取左段,稳定性由此而来
  • ✓任何输入都是 O(n log n),代价 O(n) 辅助空间
  • ✓递归三要素 + merge = 分治算法的模板
延伸主题:自底向上的迭代版归并外部排序与多路归并对比快排:分治的不同取舍
17第 17 页 · 要点收束

课后思考

三个开放问题:先自己想一会儿,再看参考答案。

1为什么归并排序一定要额外 O(n) 空间?能不能原地归并?

参考答案顺想:merge 时若直接挪动会覆盖未比较的元素。可查「原地归并」做法,但位移代价使时间退化。这是归并与其他 O(nlogn) 排序的核心取舍。

2链表场景下,归并排序的空、时代价会怎么变?

参考答案链表 merge 只改指针,不需额外数组,空间降为 O(logn)(递归栈);合并 O(1) 空间。这也解释了链表排序标准做法选归并。

3数据本身基本有序时,归并的递归拆分还合理吗?有无更优策略?

参考答案归并不利用已有顺序,仍满打满算 nlogn。对照:插入排序此时近 O(n)。自然归并排序先扫描找有序段再合并,可少拆。

18第 18 页 · 课后思考