查找与排序
5.1 查找的基本概念与 ASL
5.1.1 一组术语
查找(搜索) 就是在一堆数据里,找出「关键字」等于某个给定值的那个记录。几个术语必须分清:
- 查找表:被查找的那一堆数据元素的集合。
- 关键字(key):数据元素里用来标识/区分它的那个数据项(比如学生的学号)。能唯一标识一个元素的叫主关键字,不能唯一标识(可能重复,如「姓名」)的叫次关键字。
- 静态查找表:只查、不增删(表是固定的)。顺序查找、二分查找针对它。
- 动态查找表:查找的同时可能插入、删除(表会变)。BST、AVL、哈希表针对它。
5.1.2 ASL——衡量查找快慢的核心指标
衡量一个查找算法好不好,核心指标是 ASL(Average Search Length,平均查找长度)——平均要做多少次「关键字比较」才能找到(或确定找不到)一个元素。注意单位是「比较次数」,不是时间秒数,这样和机器无关,纯粹反映算法本身。ASL 越小越快。这是这一章贯穿始终、必考必算的量。
ASL = Σ (Pᵢ × Cᵢ) (i 从 1 到 n)
其中 Pᵢ 是查找第 i 个元素的概率,Cᵢ 是找到它所需的比较次数。
通常假设每个元素被查的概率相等,即 Pᵢ = 1/n,于是 ASL = (1/n)·Σ Cᵢ。
ASL 必须分两种情况,考试常分开问,务必都会算:
- 查找成功的 ASL:要找的元素确实在表里,平均比较多少次找到它。
- 查找不成功的 ASL:要找的元素不在表里,平均比较多少次才能确定它不在(得一直比到没法再比为止)。
为什么要分开?因为「找到」和「确认找不到」的代价往往不同。比如顺序查找一个不存在的元素,必须把整张表比完才能下结论,代价比平均找到一个存在的元素更大。
5.2 顺序查找(线性查找)
5.2.1 思想与代码
最朴素的方法:从头到尾一个个比,直到找到或走完。对存储、对数据是否有序都没有要求(无序也行,链式也行),这是它最大的优点。
// 在数组 a[0..n-1] 中顺序查找 key,返回下标,找不到返回 -1
int SeqSearch(int a[], int n, int key) {
for (int i = 0; i < n; i++)
if (a[i] == key) return i; // 逐个比较
return -1; // 走完没找到
}
5.2.2 哨兵优化
上面循环每次都要判断两件事:i < n(没越界)和 a[i] == key(是不是目标)。可以用「哨兵」省掉边界判断:把要找的 key 先放到数组的 0 号位置(或 n 号位置)当哨兵,然后从另一端往回找。这样循环一定会在碰到目标或哨兵时停下,不需要每次判断越界,比较次数省了近一半。教材常用这种带哨兵的版本:
// 哨兵版:a[0] 当哨兵位,实际数据存在 a[1..n]
int SeqSearchGuard(int a[], int n, int key) {
a[0] = key; // 在 0 号位放哨兵
int i = n;
while (a[i] != key) i--; // 从后往前找,不用判越界——最差也会停在 a[0]=key
return i; // 返回 0 表示没找到(停在了哨兵处),否则返回下标
}
5.2.3 ASL 分析(必考)
假设每个元素等概率被查(Pᵢ = 1/n)。在上面正向版本里,找第 1 个比 1 次,第 2 个比 2 次……第 n 个比 n 次:
查找成功 ASL = (1 + 2 + ... + n) / n = (n+1)/2 ≈ O(n)
查找不成功 ASL = n+1(带哨兵)或 n(不带),都要把全表比完才能确定 → O(n)
如果各元素的查找概率不等,则应把查找概率大的元素放在表的前面,能减小 ASL(常用的先排到前面,少用的放后面),这是一个常考的优化结论。
优点:简单,对数据无任何要求(无序、链式都行)。缺点:慢,O(n)。适用:数据量小、或无序又不值得专门排序时。
5.3 折半查找(二分查找,必考)
5.3.1 大前提与思想
两个大前提,缺一不可:表必须① 顺序存储(数组)② 已经有序! 二分的全部威力都建立在「有序」上,无序绝对不能用,链表也不能用(链表无法 O(1) 跳到中间)。
思想:每次和正中间那个元素比。如果 key 比中间的小,那它只可能在左半边(右半边连同中间都排除);比中间大就在右半边;相等就找到了。每比一次就砍掉一半的范围,所以极快。这正是前面说的「不断对半 → O(log n)」。
// 在有序数组 a[0..n-1] 中二分查找 key,返回下标,找不到返回 -1
int BinarySearch(int a[], int n, int key) {
int low = 0, high = n - 1;
while (low <= high) { // 注意是 <= ,区间 [low, high] 含两端
int mid = (low + high) / 2; // 取中间(整除,相当于向下取整)
if (a[mid] == key) return mid; // 命中
else if (key < a[mid]) high = mid - 1; // key 在左半边,砍掉右边(含 mid)
else low = mid + 1; // key 在右半边,砍掉左边(含 mid)
}
return -1; // low > high,区间空了,没找到
}
两个易错点:① 循环条件是 low <= high(区间含两端,相等时还有一个元素要查,不能漏);② 更新边界是 mid-1 / mid+1(mid 已经比过了,要排除掉,否则可能死循环)。
复杂度:每次范围减半,最多比 ⌊log₂n⌋+1 次,O(log n)。比顺序查找快一个数量级(n=1000 时,顺序平均比 500 次,二分最多 10 次)。
5.3.2 判定树(必考工具)
二分查找的整个比较过程,可以画成一棵二叉判定树:根是第一次比的中间元素,它的左孩子是「左半区的中间元素」、右孩子是「右半区的中间元素」,递归下去,直到每个区间只剩一个或空。这棵树的形状和二分过程一一对应——在判定树里找到某个节点要比较的次数 = 它所在的层数(根是第 1 层)。
判定树有几个重要性质:
- 它一定是一棵平衡的二叉树(左右子树高度差不超过 1),因为每次都尽量对半分。
- 树的高度 = 最多比较次数 = ⌊log₂n⌋ + 1。
- n 个元素的判定树有 n 个内部(圆形)节点,加上若干个表示「查找失败」的外部(方形)节点。
5.3.3 ASL 完整计算示例(务必练熟)
画判定树算 ASL 是必考的机械送分题。以 n = 11(下标 1~11 的有序表)为例,构造判定树并算两种 ASL。
每次取 mid = ⌊(low+high)/2⌋。区间 [1,11] → mid=6(根,第1层)。
- 左区间 [1,5] → mid=3(第2层);其左 [1,2]→mid=1(第3层,1的右孩子是2,第4层);其右 [4,5]→mid=4(第3层,4的右孩子是5,第4层)。
- 右区间 [7,11] → mid=9(第2层);其左 [7,8]→mid=7(第3层,右孩子8第4层);其右 [10,11]→mid=10(第3层,右孩子11第4层)。
各节点所在层数(=比较次数)统计:
- 第1层:1 个节点(元素6)→ 比 1 次
- 第2层:2 个节点(元素3、9)→ 各比 2 次
- 第3层:4 个节点(元素1、4、7、10)→ 各比 3 次
- 第4层:4 个节点(元素2、5、8、11)→ 各比 4 次
查找成功 ASL = (1×1 + 2×2 + 3×4 + 4×4) / 11 = (1 + 4 + 12 + 16) / 11 = 33/11 = 3。
查找不成功 ASL:在判定树每个空指针处补上「方形外部节点」(落到这里就是查找失败),n=11 有 12 个外部节点(n+1 个失败区间)。某个外部节点的比较次数 = 它所在层数 − 1(=它父内部节点的层数)。本例外部节点分布:
- 4 个外部节点挂在第 3 层的节点(元素 1、4、7、10)下方 → 各需比 3 次;
- 8 个外部节点挂在第 4 层的节点(元素 2、5、8、11)下方 → 各需比 4 次。
查找不成功 ASL = (3×4 + 4×8) / 12 = (12 + 32) / 12 = 44/12 = 11/3 ≈ 3.67。
考点固定:给 n 或一个有序表,画出判定树并算成功/不成功的 ASL。务必动手画几个不同 n(如 7、10、11、13)练熟。注意:折半判定树的形状唯一(mid 取法固定),所以 ASL 是确定值;而 BST 的形状依赖插入顺序,同样的数不同插入顺序 ASL 不同——这点常被拿来对比出判断题。
5.4 分块查找(索引顺序查找,折中方案)
5.4.1 思想
介于顺序和二分之间的折中。把数据分成若干块,要求块间有序(前一块的所有元素都小于后一块的所有元素,即「块间递增」),但块内可以无序。再建一张索引表,每一项记录一块的最大关键字和该块的起始位置。
查找分两步:① 先在索引表里确定 key 落在哪一块(索引表是有序的,可用顺序查找或二分查找);② 再在那一块里顺序查找。
5.4.2 ASL 与最佳分块
设总共 n 个元素,均分成 b 块,每块 s 个(n = b×s)。
ASL = L_索引(查索引表确定块)+ L_块内(在块里顺序找)
若两步都用顺序查找:ASL ≈ (b+1)/2 + (s+1)/2
用均值不等式可证:当 s = √n(每块大小取 √n、共 √n 块)时,ASL 取最小值约 √n + 1。所以分块查找的效率介于顺序 O(n) 和二分 O(log n) 之间,约 O(√n)。
优点:插入删除比二分灵活(只动一块,不用整体保持严格有序)。适用:数据量大、又需要兼顾查找和频繁插删的场景。
5.5 树表查找(归纳,详见第3章)
这部分在第3章(树)已详细讲过,这里从「查找视角」归纳,需要复习就回看第3章树表相关小节:
- 二叉搜索树 BST:中序得升序;查找/插入/删除平均 O(log n),最坏(退化成链表)O(n)。BST 的查找成功 ASL = 各节点层数的平均;不成功 ASL = 补外部节点后各失败位置的平均。
- 平衡二叉树 AVL:通过旋转维持平衡,始终 O(log n),杜绝了退化。
- B 树 / B+ 树:多路平衡查找树,适合磁盘、海量数据,靠「矮胖」减少磁盘 IO。
它们都属于动态查找(边查边能高效增删),查找效率和「树高」直接挂钩。
5.6 哈希表(散列,必考重点,加厚)
5.6.1 革命性思想:不比较,直接算地址
前面所有查找——顺序、二分、树——本质都是「通过比较关键字来缩小范围」,再快也得 O(log n)。哈希表换了一个全新思路:不比较,直接用关键字「算」出它该待的位置。
具体说,设计一个哈希函数(散列函数)H,输入关键字 key,输出一个数组下标 H(key)。存的时候把 key 放到 H(key) 这个位置;查的时候再用同一个 H 算出 H(key),直接到那个位置去拿——一步到位,不用比较,平均 O(1)! 这是哈希表惊人速度的来源,是「用空间换时间」的极致。
类比:图书馆规定「书名拼音首字母 A 的全放 1 号架、B 放 2 号架……」,找书时按首字母直接走到对应架子,不用一本本翻。哈希函数就是这个「按规律算位置」的规则。
5.6.2 哈希函数的构造方法
好的哈希函数要让不同 key 尽量均匀地散布、少「撞车」,且计算简单。常见方法:
- 除留余数法(最常用,重点):
H(key) = key % p。p 一般取小于等于表长的最大质数。为什么取质数?因为用质数取模能让结果分布更均匀、减少冲突(合数取模容易让有规律的 key 扎堆)。考试默认用这个。 - 直接定址法:
H(key) = a·key + b,线性变换。不会冲突,但要求 key 连续、范围不大,否则空间浪费。 - 数字分析法:分析 key 各位的分布,取分布均匀的几位。
- 平方取中法:取 key 平方后的中间几位(中间位受所有位影响,更随机)。
- 折叠法:把 key 分成几段相加,取结果的若干位。
考试基本只用除留余数法,记死「
H(key)=key%p,p 取不超过表长的最大质数」。
5.6.3 冲突及其处理(核心考点)
冲突(碰撞):不同的 key 算出了相同的地址(H(k1)==H(k2))。这几乎不可避免(key 多、位置有限,鸽巢原理)。两大类处理方法:
方法一:开放定址法(冲突了就另找空位)
发生冲突时,按某种规则探测下一个位置,直到找到空位。通用公式 Hᵢ = (H(key) + dᵢ) % m(m 是表长,dᵢ 是增量序列):
- 线性探测:dᵢ = 1, 2, 3, ...,即
Hᵢ = (H(key)+i) % m。一个一个往后找空位,到末尾绕回开头。- 致命缺点:堆积(聚集 / clustering)。连续被占的位置会越积越长,新元素更容易撞进这片拥挤区,使它继续变长,恶性循环,查找效率显著下降。这是线性探测最著名的问题,必考。
- 二次探测(平方探测):dᵢ = 1², −1², 2², −2², ...,即在原位置左右跳着找。能缓解堆积(不再扎堆相邻),但可能探测不到表中所有位置。
- 再哈希法(双重散列):用第二个哈希函数算步长 dᵢ = i × H₂(key),散布更均匀。
方法二:链地址法(拉链法,最常用)
思想:算出同一个地址的所有元素,用一个链表串在这个地址下面。哈希表的每个槽位存一个链表头,冲突的元素就往对应链表上挂(通常头插)。查找时算出地址、再在那条(通常很短的)链表里顺序找。
优点:实现简单;不会因为表「满」而无处可放(链表能一直挂);删除方便(链表删节点,不像开放定址删除会破坏探测链);没有堆积问题。这是实际工程最常用的方法(Java HashMap、各种语言字典底层都是拉链或其变体)。
5.6.4 装填因子与 ASL 的关系
装填因子 α = 表中已有元素个数 n / 哈希表长度 m。 它衡量表的「拥挤程度」。α 越大(越满),冲突越多、查找越慢;α 越小越快但越浪费空间。这又是「时间 vs 空间」的权衡,一般控制 α 在 0.7~0.8。
关键结论(必考):哈希表的 ASL 只和装填因子 α 有关,和元素总数 n 本身无关。 这是哈希表能维持 O(1) 的根本——只要控制好 α,不管表里有 100 个还是 100 万个元素,平均查找时间都差不多。理论上的近似公式(了解):
- 线性探测:成功 ASL ≈ ½(1 + 1/(1−α)),不成功 ASL ≈ ½(1 + 1/(1−α)²)。
- 拉链法:成功 ASL ≈ 1 + α/2,不成功 ASL ≈ α。
5.6.5 ASL 计算——线性探测完整示例
经典题型:「给定一组 key、表长、哈希函数、冲突处理方法,画出哈希表,计算成功/不成功 ASL。」这是哈希章节的头号送分大题,必须练到稳。
例 1(线性探测):关键字 {19, 14, 23, 1, 68, 20, 84, 27, 55, 11, 10, 79},表长 m=13,H(key)=key%13,线性探测。
先算各 key 的哈希地址(key % 13):
| key | 19 | 14 | 23 | 1 | 68 | 20 | 84 | 27 | 55 | 11 | 10 | 79 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| H | 6 | 1 | 10 | 1 | 3 | 7 | 6 | 1 | 3 | 11 | 10 | 1 |
依次放入,冲突就线性往后探测(每探测一个位置记 1 次比较):
| key | 起始地址 | 实际落位(探测过程) | 比较次数 |
|---|---|---|---|
| 19 | 6 | 6 空,放 | 1 |
| 14 | 1 | 1 空,放 | 1 |
| 23 | 10 | 10 空,放 | 1 |
| 1 | 1 | 1被占→2空,放 | 2 |
| 68 | 3 | 3 空,放 | 1 |
| 20 | 7 | 7 空,放 | 1 |
| 84 | 6 | 6占→7占→8空,放 | 3 |
| 27 | 1 | 1占→2占→3占→4空,放 | 4 |
| 55 | 3 | 3占→4占→5空,放 | 3 |
| 11 | 11 | 11 空,放 | 1 |
| 10 | 10 | 10占→11占→12空,放 | 3 |
| 79 | 1 | 1→2→3→4→5→6→7→8→9空,放 | 9 |
最终表(下标0~12):[_, 14, 1, 68, 27, 55, 19, 20, 84, 79, 23, 11, 10]。
查找成功 ASL = (1+1+1+2+1+1+3+4+3+1+3+9) / 12 = 30 / 12 = 2.5。
查找不成功 ASL:注意——不成功是按哈希函数的值域算(这里 H 值是 key%13,值域 0~12,共 13 个可能的起始地址)。对每个起始地址,数「从这里开始线性探测,一直比到碰见第一个空位为止」的比较次数(碰到空位本身也算 1 次比较来确认它空)。
照最终表逐个数(地址0是空的):
- 地址0:空,比 1 次(直接确认空)
- 地址1:1占→2占→...→9占→10?10有23... 从1开始一路占到地址9(79在9),地址10是23占,继续?不对——要数到第一个空位。表中空位在地址0。从地址1开始:1,2,...,12 都被占(看最终表 1~12 全满),到地址12是10占,再绕到地址0空——所以从地址1起要比到绕回地址0,比较次数 = 12(地址1到12)+1(地址0确认空)= 13。
- 地址2:从2比到12(11个)+绕回0(1个)= 12。
- 地址3:从3到12(10个)+0(1)= 11。
- 依此类推到地址12:从12(1个被占)+0(1个空)= 2。
- 地址0本身:1(空)。
把 13 个地址的比较次数平均即为不成功 ASL。(这一步要照最终表仔细数,本例因为表几乎填满,不成功 ASL 偏大。考试给的表通常没这么满,照样按「从该地址数到第一个空位」的规则数即可。)
数不成功 ASL 的口诀:对每个哈希地址(按 H 值域 0~p−1),从该地址往后数,直到遇到第一个空位为止的格子数(含那个空位),就是从这个地址查找失败的比较次数;把所有地址的次数求平均。
5.6.6 ASL 计算——拉链法完整示例
例 2(拉链法):同样的 key {19,14,23,1,68,20,84,27,55,11,10,79},H(key)=key%13,拉链法(每个地址挂一条链表,新元素头插或尾插,这里按尾插,链上比较次数从1开始数)。
按地址归类(key%13):
- 地址1:14, 1, 27, 79(4个,链上比较次数依次 1,2,3,4)
- 地址3:68, 55(比较 1,2)
- 地址6:19, 84(比较 1,2)
- 地址7:20(比较 1)
- 地址10:23, 10(比较 1,2)
- 地址11:11(比较 1)
- 其余地址:空链
查找成功 ASL = 所有元素比较次数之和 / 元素个数 = [ (1+2+3+4) + (1+2) + (1+2) + 1 + (1+2) + 1 ] / 12 = [ 10 + 3 + 3 + 1 + 3 + 1 ] / 12 = 21 / 12 = 1.75。
查找不成功 ASL:对每个哈希地址(0~12,共13个),不成功的比较次数 = 该地址链表上的节点个数(要把整条链比完才能确定不在;空链算 0 次比较,有的教材算遇到空链就是0)。 各地址链长:地址1长4、地址3长2、地址6长2、地址7长1、地址10长2、地址11长1,其余9个地址长0。
不成功 ASL = (4+2+2+1+2+1+0×7) / 13 = 12 / 13 ≈ 0.92。
对比线性探测,拉链法的 ASL 明显更小(成功 1.75 vs 2.5),因为没有堆积。这印证了拉链法的优势。两种方法各算一遍,体会差别。
5.6.7 开放定址法的删除问题(易忽略的考点)
开放定址法不能简单地把要删的元素位置直接清空!因为查找是靠「探测链」一路找下去的,如果中途清空了一个位置,会让探测链「断掉」,后面本来能找到的元素就找不到了。正确做法是给被删位置打一个「已删除」的标记(懒删除),查找时遇到标记继续往后探测、插入时可以覆盖标记位。这是开放定址法相比拉链法的又一个麻烦(拉链法删除就是链表删节点,干净利落)。
5.6.8 哈希表小结
- 优点:平均查找/插入/删除都 O(1),极快。
- 缺点:① 丢失「顺序」(哈希表里元素乱序,无法范围查询、无法有序输出);② 有冲突要处理;③ 费空间(α 不能太高);④ 最坏情况(大量冲突)退化到 O(n)。
- 适用:需要快速「按 key 精确查找 / 判重 / 去重」、不需要顺序的场景(字典、缓存、集合)。
5.7 各查找方法对比(汇总)
| 方法 | 前提 | 平均时间 | 是否支持动态增删 | 特点 |
|---|---|---|---|---|
| 顺序查找 | 无 | O(n) | 是 | 简单,对数据无要求 |
| 二分查找 | 有序+顺序存储 | O(log n) | 否(增删要保序,代价高) | 快但静态 |
| 分块查找 | 块间有序 | O(√n) | 较灵活 | 兼顾查找与增删 |
| BST | 树结构 | O(log n),最坏 O(n) | 是 | 可能退化 |
| AVL | 平衡树 | O(log n) | 是 | 防退化,旋转维护 |
| 哈希表 | 哈希函数 | O(1) | 是 | 最快,但无序、费空间 |
5.8 查找自测
- 什么是 ASL?为什么要分「成功」和「不成功」两种?
- 顺序查找成功 ASL 是多少?查找概率不等时如何优化?哨兵的作用?
- 二分查找的两个前提?复杂度?循环条件为什么是
low<=high? - 画出 n=10 的二分判定树,算成功与不成功 ASL。
- 分块查找「块间有序、块内无序」是什么意思?最佳块大小是多少,对应 ASL?
- 哈希表的核心思想(和前面所有查找的本质区别)?
- 除留余数法的 p 怎么取?为什么取质数?
- 线性探测的堆积是什么?拉链法相比有哪些优势?
- 装填因子是什么?哈希 ASL 和 n 有关还是和 α 有关?
- 自己出一组 key,分别用线性探测和拉链法(H=key%7),画表并算成功+不成功 ASL,对比两者。
- 开放定址法删除元素为什么不能直接清空?怎么办?
排序是分值最高的一章,几乎必出大题,考点很「死」(对比表、一趟/多趟模拟),属于只要练到位就能拿满分的章节,务必重视并多动手模拟。
5.9 排序的基本概念
排序:把一组记录按关键字的大小,重新排成递增(或递减)顺序。关键概念贯穿全章:
- 稳定性(重点):若两个元素关键字相等,排序后它们的相对前后位置不变,就叫稳定;若可能被交换顺序,就不稳定。
- 为什么重要?例:先按「成绩」排好的名单,再按「班级」排序——若稳定,同班内部仍按成绩有序;不稳定就乱了。多关键字排序时稳定性很关键。
- 内部排序 / 外部排序:数据全部装进内存里排叫内排序(8.2~8.6);数据太大、内存装不下、要借助外存分批处理叫外排序(8.9)。
- 就地排序:只用 O(1) 额外空间的排序(原地)。
- 比较的衡量:主要看比较次数、移动(交换)次数,归结为时间复杂度;以及额外空间(空间复杂度)。
- 趟(pass):很多排序是一趟趟进行的,「第 k 趟之后数组变成什么样」是必考题,所以每种算法的「一趟特征」要记牢。
下面按「类别」讲,每个算法都讲清 思想 + 完整多趟模拟 + 复杂度 + 稳定性。统一以待排数组 49 38 65 97 76 13 27(7 个数)做模拟,方便对比。
5.10 插入排序类
5.10.1 直接插入排序
思想:像理扑克牌。把数组分成「前面已排好的有序区」和「后面待排区」。每次从待排区取第一个元素,从后往前在有序区里找到它该插的位置插进去(沿途比它大的元素都往后挪一格腾位置)。有序区从只含第 1 个元素开始,逐步扩大到整个数组。
void InsertSort(int a[], int n) {
for (int i = 1; i < n; i++) { // a[0] 看作已排好,从 a[1] 开始往里插
if (a[i] >= a[i-1]) continue; // 小优化:已经≥前一个就不用插
int temp = a[i]; // 暂存待插元素(它的位置要被覆盖)
int j = i - 1;
while (j >= 0 && a[j] > temp) { // 从后往前:比 temp 大的都往后挪
a[j + 1] = a[j];
j--;
}
a[j + 1] = temp; // 找到位置,插入
}
}
完整多趟模拟(49 38 65 97 76 13 27,方括号是有序区):
初始: [49] 38 65 97 76 13 27
插38: [38 49] 65 97 76 13 27 (38<49,49后移)
插65: [38 49 65] 97 76 13 27 (65>49,原位)
插97: [38 49 65 97] 76 13 27 (97最大,原位)
插76: [38 49 65 76 97] 13 27 (76插到65和97之间)
插13: [13 38 49 65 76 97] 27 (13最小,插到最前)
插27: [13 27 38 49 65 76 97] (27插到13和38之间)完成
复杂度:最好(已有序)每个只比 1 次、不挪,O(n);最坏(逆序)第 i 个要挪 i 次,总 O(n²);平均 O(n²)。空间 O(1)。稳定(a[j] > temp 才挪,相等不挪,相等元素不会越过彼此)。
5.10.2 折半插入排序
直接插入里「在有序区找插入位置」用的是顺序比较。既然有序区已经有序,找位置可以用二分!这就是折半插入——用折半查找确定插入位置,把比较次数从 O(n) 降到 O(log n)。但移动元素的次数没变(找到位置后照样要挪后面的元素腾位)。所以时间复杂度仍是 O(n²)(瓶颈在移动),只是比较次数降到 O(n log n)。空间 O(1),稳定。
考点辨析:折半插入相比直接插入,减少的是比较次数,不是移动次数,总时间仍 O(n²)。这个细节常考。
5.10.3 希尔排序(缩小增量排序)
思想:直接插入在「基本有序」时很快(接近 O(n)),但在「乱序」时慢,且每次只能挪一格、效率低。希尔的巧思:先让数组变得「基本有序」,再做插入排序,并且让元素能大步跨越式移动。
具体:取一个增量 gap(如 n/2),把相隔 gap 的元素分成一组,对每组分别做直接插入排序(这是一趟);然后缩小 gap(如再减半)再来一趟;直到 gap=1(此时就是对整个已基本有序的数组做一次普通插入排序,很快)。大增量时元素能「跳跃式」大步移动到大致位置,避免了一格格挪。
完整模拟(49 38 65 97 76 13 27,n=7,增量取 gap=3,1):
gap=3: 分3组(下标0,3,6 | 1,4 | 2,5)
组1: 49,97,27 → 排序 → 27,49,97
组2: 38,76 → 38,76(不变)
组3: 65,13 → 13,65
结果: 27 38 13 49 76 65 97
gap=1: 对 27 38 13 49 76 65 97 做普通插入排序
→ 13 27 38 49 65 76 97 完成
复杂度:和增量序列选取有关,平均约 O(n^1.3)(希尔增量),最坏 O(n²),比纯 O(n²) 好。空间 O(1)。不稳定(相等元素可能被分到不同组,相对位置被打乱)。
希尔是第一个突破 O(n²) 的排序。记住:分组靠增量、增量逐渐缩到 1、不稳定。
5.11 交换排序类
5.11.1 冒泡排序
思想:相邻两元素比较,前者比后者大就交换。一趟从头扫到尾,最大的元素像气泡「冒」到最后。下趟范围缩小(末尾已归位),重复,直到某趟没有发生交换(已有序,可提前结束)。
void BubbleSort(int a[], int n) {
for (int i = 0; i < n - 1; i++) { // 最多 n-1 趟
int swapped = 0;
for (int j = 0; j < n - 1 - i; j++) { // 每趟把当前最大冒到末尾,范围递减
if (a[j] > a[j + 1]) { // 相邻逆序就交换
int t = a[j]; a[j] = a[j+1]; a[j+1] = t;
swapped = 1;
}
}
if (!swapped) break; // 一趟没交换=已有序,提前结束
}
}
完整多趟模拟(49 38 65 97 76 13 27,每趟后最大值归位,下划线表示已归位区):
初始: 49 38 65 97 76 13 27
第1趟: 38 49 65 76 13 27 |97 (97冒到末尾)
第2趟: 38 49 65 13 27 |76 97 (76归位)
第3趟: 38 49 13 27 |65 76 97
第4趟: 38 13 27 |49 65 76 97
第5趟: 13 27 |38 49 65 76 97
第6趟: 13 |27 38 49 65 76 97 (此趟无交换或仅一次,已有序)完成
复杂度:最好(已有序,有 swapped 优化)一趟发现无交换,O(n);最坏(逆序)O(n²);平均 O(n²)。空间 O(1)。稳定(a[j] > a[j+1] 才换,相等不换)。
一趟特征(考点):冒泡一趟后,当前最大值一定归位到末尾(若从后往前冒则最小值归位到开头)。
5.11.2 快速排序(重点中的重点)
思想(分治):选一个元素作基准(pivot),通过一趟划分(partition)把比基准小的甩到左边、比它大的甩到右边——基准就落到了它最终的位置。然后对左右两部分递归地快排。分而治之。
// 一趟划分:以 a[low] 为基准,小的放左、大的放右,返回基准最终位置
int Partition(int a[], int low, int high) {
int pivot = a[low]; // 选第一个作基准(暂存,low 位空出)
while (low < high) {
while (low < high && a[high] >= pivot) high--; // 右指针左移,找比基准小的
a[low] = a[high]; // 填到左边空位
while (low < high && a[low] <= pivot) low++; // 左指针右移,找比基准大的
a[high] = a[low]; // 填到右边空位
}
a[low] = pivot; // low==high,基准归位
return low;
}
void QuickSort(int a[], int low, int high) {
if (low < high) {
int pos = Partition(a, low, high); // 划分,基准归位
QuickSort(a, low, pos - 1); // 递归排左半
QuickSort(a, pos + 1, high); // 递归排右半
}
}
一趟 partition 完整模拟(49 38 65 97 76 13 27,pivot=49,low/high 双指针):
初始: 49 38 65 97 76 13 27 pivot=49,low=0 high=6
右找<49: 27(下标6) → 填到low: 27 38 65 97 76 13 _ low=0,high=6→...
左找>49: 65(下标2) → 填到high: 27 38 _ 97 76 13 65 low=2
右找<49: 13(下标5) → 填到low: 27 38 13 97 76 _ 65 high=5
左找>49: 97(下标3) → 填到high: 27 38 13 _ 76 97 65 low=3
右找<49: 无(high移到low) → low==high==3
基准归位: 27 38 13 [49] 76 97 65
一趟后 49 归位,左边 27 38 13 全 <49,右边 76 97 65 全 >49。再对左右递归。
递归全过程(简示):
[49 38 65 97 76 13 27]
→ 27 38 13 [49] 76 97 65
左[27 38 13] → 13 [27] 38(27归位)→ ... → 13 27 38
右[76 97 65] → 65 [76] 97(76归位)→ 65 76 97
→ 13 27 38 49 65 76 97 完成
复杂度:平均 O(n log n)(每次大致均分,log n 层、每层 partition 共 O(n)),是平均最快的排序(常数因子小,实际跑得最快)。最坏 O(n²):当每次划分极不均匀——典型是数组已经有序(或逆序)且总取端点作基准,每次只分出一个元素,退化成 O(n²)。空间 O(log n)(递归栈深度,平均;最坏 O(n))。不稳定。
快排考点:① 一趟 partition 结果(必考手动模拟);② 最坏 O(n²) 何时发生、怎么避免(随机基准 / 三数取中);③ 平均 O(n log n)、不稳定、空间 O(log n)。
5.12 选择排序类
5.12.1 简单选择排序
思想:每趟从未排序区里挑出最小元素,放到已排序区末尾(和未排序区第一个交换)。第 1 趟选全局最小放第 1 位,第 2 趟选剩下最小放第 2 位……
void SelectSort(int a[], int n) {
for (int i = 0; i < n - 1; i++) {
int min = i; // 假设未排序区第一个最小
for (int j = i + 1; j < n; j++) // 在剩下的里找真正最小的
if (a[j] < a[min]) min = j;
if (min != i) { // 把最小的换到位置 i
int t = a[i]; a[i] = a[min]; a[min] = t;
}
}
}
完整模拟(49 38 65 97 76 13 27,竖线左为已排序区):
初始: 49 38 65 97 76 13 27 选最小13,与49换
第1趟: 13 |38 65 97 76 49 27 选最小27,与38换
第2趟: 13 27 |65 97 76 49 38 选最小38
第3趟: 13 27 38 |97 76 49 65 选最小49
第4趟: 13 27 38 49 |76 97 65 选最小65
第5趟: 13 27 38 49 65 |97 76 选最小76
第6趟: 13 27 38 49 65 76 |97 完成
复杂度:不管数据啥样都要全扫描比较,最好/平均/最坏都是 O(n²)(比较次数固定 n(n−1)/2,与输入无关)。空间 O(1)。不稳定(交换可能打乱相等元素顺序,如 5ₐ 5ᵦ 2,第一趟把 2 和 5ₐ 交换,两个 5 顺序就反了)。
特点:移动次数极少(每趟最多 1 次交换),但比较次数固定且多。
5.12.2 堆排序(重点)
第3章讲过堆,这里讲它怎么排序。思想:① 把数组建成大顶堆(堆顶=最大值);② 把堆顶(最大值)和数组最后一个元素交换——最大值落到最终位置;③ 堆范围缩小 1,对新堆顶做一次下沉恢复大顶堆;④ 重复②③,每次「沉淀」一个最大值到后面,直到全部有序。
// 下沉:把 a[i] 在范围 [0,n) 内向下调整,维持大顶堆(下标从0开始:左孩2i+1,右孩2i+2)
void SiftDown(int a[], int i, int n) {
int temp = a[i];
for (int child = 2*i+1; child < n; child = 2*child+1) {
if (child+1 < n && a[child+1] > a[child]) child++; // 取较大的孩子
if (a[child] <= temp) break; // 已满足堆序,停
a[i] = a[child]; i = child; // 大孩子上移,继续下沉
}
a[i] = temp;
}
void HeapSort(int a[], int n) {
for (int i = n/2 - 1; i >= 0; i--) SiftDown(a, i, n); // 1.建大顶堆
for (int i = n - 1; i > 0; i--) { // 2.反复换堆顶到末尾+下沉
int t = a[0]; a[0] = a[i]; a[i] = t;
SiftDown(a, 0, i);
}
}
建堆过程模拟(49 38 65 97 76 13 27,对应完全二叉树,从最后一个非叶节点 i=2 开始下沉):
初始树: 49
/ \
38 65
/ \ / \
97 76 13 27
下沉i=2(65): 65的孩子13,27都小,不动
下沉i=1(38): 孩子97,76,97最大>38 → 换 → 38和97换位
下沉i=0(49): 孩子97(已上来),65 → 97最大>49 → 49与97换 → 49下沉到原97位,
49再和它的孩子76比 → 76>49 → 换
建堆结果(大顶堆): 97 76 65 38 49 13 27 (堆顶97是最大值)
排序阶段:把堆顶 97 和末尾 27 交换→97归位→对前6个下沉恢复堆→再换堆顶到倒数第二位……每趟沉淀一个最大值。
复杂度:建堆 O(n)(反直觉但可证,底层大量节点下沉代价小),之后 n−1 次下沉每次 O(log n),整体 O(n log n),且最好/平均/最坏都是 O(n log n)(性能稳定,无退化)。空间 O(1)(原地)。不稳定。
堆排独特优势:O(n log n) 且原地 O(1) 空间——归并是 nlogn 但要 O(n) 空间,快排 nlogn 但最坏退化。空间紧张又要保证 nlogn时选堆排。考点:建堆过程、一趟(堆顶换末尾+下沉)后的序列。
5.13 归并排序
思想(分治):把数组对半分成两半,分别递归排好,再把两个有序半边合并(merge)成完整有序数组。「分」到每段剩一个元素(天然有序),再一层层「合并」回去。核心子操作是合并两个有序序列:双指针各扫一段,每次取较小的放进结果。
void Merge(int a[], int low, int mid, int high) {
int *temp = (int*)malloc((high-low+1)*sizeof(int)); // 辅助数组(O(n)空间来源)
int i = low, j = mid+1, k = 0;
while (i <= mid && j <= high)
temp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; // <= 保证稳定:相等取左边
while (i <= mid) temp[k++] = a[i++]; // 左段剩余接上
while (j <= high) temp[k++] = a[j++]; // 右段剩余接上
for (k = 0, i = low; i <= high; i++) a[i] = temp[k++]; // 拷回
free(temp);
}
void MergeSort(int a[], int low, int high) {
if (low < high) {
int mid = (low + high) / 2;
MergeSort(a, low, mid);
MergeSort(a, mid+1, high);
Merge(a, low, mid, high);
}
}
完整分治模拟(49 38 65 97 76 13 27):
分解(一直对半分到单个):
[49 38 65 97 76 13 27]
[49 38 65 97] [76 13 27]
[49 38][65 97] [76][13 27]
[49][38][65][97] [76][13][27]
合并(两两有序合并往上):
[38 49][65 97] [76][13 27]
[38 49 65 97] [13 27 76]
[13 27 38 49 65 76 97] 完成
复杂度:分 log n 层、每层合并共 O(n),最好/平均/最坏都是 O(n log n)(稳定的性能)。空间 O(n)(合并要辅助数组,最大缺点)。稳定(合并时相等取左边,保持相对顺序——a[i] <= a[j] 取左是稳定的关键)。
5.14 基数排序(分配排序类)
思想:不比较大小,而是按「位」分配和收集。以整数为例,从最低位(个位)开始:把所有数按个位数字分到 0~9 共 10 个「桶」(队列)里,再按桶顺序依次收集起来;然后按十位再分一遍、收集;再按百位……分配到最高位后,序列就有序了。这叫最低位优先(LSD)。
为什么能行:每轮按某一位分桶+收集是稳定的,低位先排、高位后排,高位相同的数会保持低位排好的相对顺序,逐位下来整体有序。
完整模拟(329 457 657 839 436 720 355,3位数,分两位太长,这里走个位+十位+百位):
按个位分桶收集: 720 | 355 | 436 | 457 657 | 329 839 → 720 355 436 457 657 329 839
按十位分桶收集: 720 329 436 839 355 457 657 → 720 329 436 839 355 457 657
按百位分桶收集: 329 355 436 457 657 720 839 → 329 355 436 457 657 720 839 完成
复杂度:设 d=最大数位数、r=基数(十进制 r=10)、n=元素个数,则 O(d(n+r))。d 不大时接近线性,比 O(n log n) 还快。空间 O(r) 或 O(n+r)(桶开销)。稳定。
适用:关键字位数不多、范围有限的整数/定长字符串。考点:理解「按位分配-收集、低位优先、不比较」,知道它稳定、O(d(n+r))。
5.15 计数排序(非比较,了解)
思想:也不比较大小,而是统计每个值出现的次数。设元素取值在 0~k 范围内:① 开一个大小 k+1 的计数数组,扫一遍原数组数出每个值出现几次;② 求计数数组的前缀和(确定每个值在结果里的最终位置);③ 从后往前把元素放到对应位置(从后往前是为了保持稳定)。
复杂度:时间 O(n+k)、空间 O(n+k),稳定。适用:n 个元素但取值范围 k 不大(如分数 0~100)时,能做到接近线性、比 O(n log n) 还快;k 很大(值域稀疏)时浪费空间,不适合。基数排序内部每一趟分桶用的就是计数排序的思想。
5.16 排序算法总对比表(必背,直接考!)
排序章节的头号送分点,背到能默写:
| 排序算法 | 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|---|
| 直接插入 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 折半插入 | O(n)(移动仍O(n²)) | O(n²) | O(n²) | O(1) | 稳定 |
| 希尔排序 | — | O(n^1.3) | O(n²) | O(1) | 不稳定 |
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn) | 不稳定 |
| 简单选择 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O(r) | 稳定 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(n+k) | 稳定 |
5.16.1 记忆技巧(口诀+结论)
- 不稳定的有哪些:口诀 「快选堆希」不稳定(快速、选择、堆、希尔)。其余(插入、折半插入、冒泡、归并、基数)都稳定。
- 平均 O(n log n) 的三个:快排、堆排、归并(口诀「快堆归」)。其余基本 O(n²)。
- 最坏会退化到 O(n²) 的:只有快速排序(唯一软肋,重点)。堆排、归并最坏仍 O(n log n)。
- 空间例外:大多 O(1) 原地;快排 O(log n)(递归栈)、归并 O(n)(辅助数组)、基数 O(r) 是例外,必记。
- 最好能到 O(n) 的:直接插入、冒泡(数据已基本有序时)。
- 比较次数与初始序列无关的:简单选择(永远 n(n−1)/2 次比较)、折半插入(比较次数固定)。
- 移动次数最少的:简单选择(每趟最多 1 次交换)。
5.17 怎么选排序算法(考点逻辑)
- 数据量小 → 直接插入/冒泡(简单,常数小)。
- 数据基本有序 → 插入/冒泡(能到 O(n))。
- 要稳定 → 归并/插入/冒泡/基数。
- 要快且综合最优(平均) → 快速排序(实际最快)。
- 要稳定的 O(n log n)、不在乎空间 → 归并排序。
- 空间紧张 + 要保证 O(n log n) 不退化 → 堆排序。
- 整数、位数少 → 基数排序(可达线性)。
5.18 外部排序(按大纲了解,常考概念)
前面都是内部排序(数据全在内存)。当数据量巨大、内存装不下(比如要排 100GB 的文件,内存只有 1GB)时,就需要外部排序——数据存在外存(磁盘),排序过程要在内存和磁盘间反复读写。外排序的瓶颈是磁盘 IO(远慢于 CPU 和内存),所以核心目标是减少 IO 次数。
5.18.1 基本流程:归并排序的思想
外排序通常基于归并,分两个阶段:
- 生成初始归并段(顺串):把大文件分成若干段,每段小到能装进内存,在内存里用某种内排序(如快排)排好,写回磁盘。这些有序的段叫初始归并段(run)。
- 多路归并:把这些有序段两两(或 k 路)归并成更大的有序段,反复归并,直到合成一个完整有序的文件。
5.18.2 关键概念(考点)
- k 路归并:一次归并 k 个有序段(而不是 2 个)。归并的趟数 = ⌈log_k(初始段数)⌉。k 越大,归并趟数越少,IO 越少——所以增大归并路数 k 能减少磁盘访问。但 k 太大时,每次从 k 个段里选最小元素的内存比较开销增大。
- 败者树:k 路归并时,每次要从 k 个段的当前元素里选最小的。朴素比较要 k−1 次。用败者树(一种特殊的完全二叉树)能把每次选最小降到 O(log k) 次比较,提高 k 路归并的内部效率。
- 置换-选择排序:一种生成更长初始归并段的技巧(初始段越长、段数越少、归并趟数越少)。它能生成平均长度为内存容量 2 倍的归并段。
- 最佳归并树(哈夫曼思想):当各初始归并段长度不等时,归并的顺序会影响总 IO 量。把「段长」看作权值,构造哈夫曼树来决定归并顺序,可使总 IO(带权路径长度)最小——这就是第3章哈夫曼树的又一应用。
外部排序考试一般考概念:为什么用归并、初始归并段怎么来、k 路归并减少趟数、败者树/置换选择/最佳归并树各解决什么问题。理解「减少 IO 是核心目标」这条主线即可。
5.19 排序大题的两类必考题型
- 「写出某趟排序后的序列」:给初始序列,问某算法第一趟(或第 k 趟)后数组变成什么样。每种算法的「一趟特征」要记牢:
- 冒泡一趟:最大值冒到末尾(或最小值到开头)。
- 快排一趟:基准归位,左小右大(partition 结果)。
- 简单选择一趟:最小值放到最前。
- 直接插入第 i 趟:前 i+1 个元素有序。
- 堆排序:建堆后的序列、或一次「堆顶换末尾+下沉」后的序列。
- 希尔一趟:按当前增量分组插入后的序列。
- 归并一趟:相邻有序段两两合并后的序列。
- 默写对比表:时间/空间/稳定性(见 8.7)。
这两类题是排序章节的全部分数所在,手动模拟练熟 + 背熟对比表 = 满分。强烈建议刷题时给每种算法找一个初始序列,亲手把每趟都写出来对答案。
5.20 排序自测
- 什么是排序稳定性?举例说明它有什么用。
- 直接插入的思想?最好/最坏复杂度?为什么稳定?
- 折半插入相比直接插入,减少的是比较次数还是移动次数?总时间变了吗?
- 希尔排序的核心巧思?为什么不稳定?模拟
49 38 65 97 76 13 27在 gap=3,1 下的过程。 - 冒泡一趟后什么元素一定归位?
- 写出
49 38 65 97 76 13 27以首元素为基准的快排一趟划分结果。 - 快排平均/最坏复杂度?最坏何时发生?怎么避免?快排稳定吗?空间多少?
- 简单选择为什么最好情况也 O(n²)?它稳定吗?比较/移动次数特点?
- 堆排两大步骤?建堆复杂度?整体复杂度和空间?相比归并的独特优势?模拟一遍建堆。
- 归并为什么稳定?空间为什么 O(n)?
- 基数排序为什么不比较也能排?它稳定吗?复杂度?
- 默写排序对比表(9 种算法的最好/平均/最坏/空间/稳定性)。
- 外部排序为什么用归并?k 路归并为什么能减少 IO?败者树、最佳归并树各解决什么?