1. 复杂度分析:递归式才是期末卷上的第一道门槛
我先说个反直觉的结论:算法设计与分析这门课挂科的人,绝大多数不是栽在动态规划或者图算法上,而是栽在最前面那几道看着最人畜无害的复杂度题上。原因很简单,后面的大题你写不出最优解,好歹能拿过程分;可复杂度分析是一道对就是对、错就是错的硬题,主定理套错一个条件,扣的就是整道题的分。所以复习"算法设计与分析期末题"的时候,我建议你先把复杂度这根钉子钉死,再去看别的。
渐进记号这块,很多人背得住定义却用不出来。大O描述的是上界,也就是"最坏不会超过这个量级";大Ω描述下界;大Θ是紧确界,上下界同阶。考试里最常见的陷阱是让你判断某个关系成不成立,比如 n = O(n²) 成立,但 n² = O(n) 不成立;再比如 2^(n+1) = O(2^n) 成立,因为常数因子在渐进意义下被吞掉了。记住一条经验:渐进分析里所有常数系数和低阶项都可以丢,但指数上的 n 不能丢,它决定了算法能不能跑得动。
递归式求解是这块的绝对重点,而主定理能覆盖大概七成的考题。主定理处理的是形如 T(n) = aT(n/b) + f(n) 的递归式,核心是拿 f(n) 和 n^(log_b a) 去比大小。理解它的逻辑其实不难:aT(n/b) 这一项是"把问题切成 a 个子问题、每个规模是原来的 1/b",n^(log_b a) 就是子问题数量的增长速度;f(n) 是划分和合并这些子问题的额外开销。谁嗓门大谁说了算,这就是主定理的直觉。
具体分三种情况,我给你一张表对照着记,比死背文字靠谱得多。
| 情况 | 判断条件 | 结果 | 典型例子 |
|---|---|---|---|
| 情况一 | f(n) 比 n^(log_b a) 小多项式量级 | T(n) = Θ(n^(log_b a)) | T(n)=8T(n/2)+n²,得 Θ(n³) |
| 情况二 | f(n) 与 n^(log_b a) 同阶 | T(n) = Θ(n^(log_b a) · log n) | 归并排序 T(n)=2T(n/2)+n,得 Θ(n log n) |
| 情况三 | f(n) 比 n^(log_b a) 大多项式量级且满足正则条件 | T(n) = Θ(f(n)) | T(n)=2T(n/2)+n²,得 Θ(n²) |
这里有个细节必须提醒:情况一和情况三要求的是"多项式量级"的差距,也就是说差的是 n 的某个正数次幂,而不是差一个 log 因子。像 T(n) = 2T(n/2) + n log n 这种,f(n) 虽然比 n 大了,但只大了一个 log,不满足多项式差距,主定理三种情况全都套不上,这时候必须改用递归树或者直接猜答案再代入验证。这是我在期末卷上见过最多的一个坑,出题老师就爱拿这种"主定理失效"的题来筛人。
递归树法适合主定理搞不定的场景。画法是把每一层的总代价写出来,然后把所有层加起来。比如 T(n) = 2T(n/2) + n log n,第一层是 n log n,第二层是两个 (n/2)log(n/2) 加起来约等于 n(log n - 1),第三层再减一点,层数一共 log n 层,累加起来就是 Θ(n log²n)。你把这棵树画出来,答案基本就自己浮出来了,比硬猜靠谱。
提示:考试时间紧的时候,先判断递归式能不能套主定理,能套就直接套,不要画树浪费时间;套不上的再动笔。这个判断习惯能帮你省下至少十分钟。
代入验证法(有些教材叫替换法)是最后的兜底手段。步骤是先根据经验猜一个解,比如猜 T(n) = O(n log n),然后假设 T(k) ≤ ck log k 对所有 k < n 成立,把这个假设代进递归式,验证 T(n) ≤ cn log n 能不能推出来。推不出来就把猜测调高一个量级再试。这个方法看着笨,但万能,尤其在主定理失效的题里是唯一稳的路子。
2. 分治法:看到"两半""合并"就要条件反射
分治法的识别信号特别明显,题干里只要出现"把序列从中间分开""递归处理左半和右半""最后合并结果"这类描述,基本就是分治。复习的时候别满足于会写归并排序,你得能识别出哪些问题天然适合分治——一句话总结:子问题相互独立、可以并行解决、合并操作比原问题便宜,这三条同时满足,分治才有意义。
归并排序是分治的模板答案,但期末卷上很少有人直接考归并排序的代码,更常见的是考它的复杂度推导和稳定性。归并排序的递归式 T(n) = 2T(n/2) + Θ(n),套主定理情况二得到 Θ(n log n),而且它在最坏情况下也是这个复杂度,这是它比快排硬气的地方。合并两个有序数组的操作是线性的,每个元素最多被比较和移动一次,这就是 Θ(n) 那项的来源。
二分查找是分治的另一个典型,但它更特殊——它每次只递归一个子问题,递归式是 T(n) = T(n/2) + Θ(1),套主定理得到 Θ(log n)。这里要特别注意:二分查找本身是 O(log n),但前提是数组已经有序。如果题目先让你排序再二分,那总复杂度就是排序的 O(n log n) 加上查询的 O(log n)。我见过有人在这类题上只写 O(log n),把前面排序的代价漏掉了,直接丢一半分。
最大子数组问题是分治里最值得琢磨的一道经典题。它的分治思路是:最大子数组要么完全落在左半,要么完全落在右半,要么横跨中点。前两种递归求解,第三种从中点向两边扫一遍找最大和,扫描是线性的。所以递归式是 T(n) = 2T(n/2) + Θ(n),答案 Θ(n log n)。有意思的是这道题用动态规划能做到 O(n),考卷上经常让你把两种方法都写出来并比较,这时候你要能说清楚:分治法存在重复访问,DP 通过记录以每个位置结尾的最大和避免了重复计算。
快速排序严格说也是分治,但它的划分不是按位置切,而是按基准值分。它的平均复杂度 Θ(n log n),最坏 O(n²),最坏发生在每次选的基准都是极值的时候,比如对已经有序的数组取第一个元素当基准。考试里如果问"如何避免快排退化",标准答案是随机选取基准或者三数取中,把最坏情况变成概率极小的事件。我一般还会补一句:随机化之后,虽然理论最坏还是 O(n²),但它的期望复杂度稳定在 Θ(n log n),工程上这就够用了。
讲一个我自己的踩坑经历。有次考试让分析"在 n 个元素里同时找最大值和最小值最少需要多少次比较"。我第一反应是分治,两个子问题各找最值再合并,写了半天比较次数。正确做法其实是两两分组:先把元素配对,每对比较一次分出大小,然后在所有"较大者"里找最大值、所有"较小者"里找最小值。总比较次数是 3n/2 - 2,比朴素的 2n - 2 少了四分之一。这个例子说明,分治不是万能的,有些题用一点预处理思维反而更快,答题时别一根筋。
3. 动态规划:状态定义定生死,写对一半就赢
动态规划是整张卷子分值最高、也最容易拉开差距的部分。我把它放在复杂度之后讲,是因为 DP 的复杂度分析全靠你状态怎么定义,状态定错了,后面方程再漂亮也是白搭。DP 的核心就三步:定义状态、写状态转移方程、确定边界和计算顺序。这里面最难的是第一步,也是最多人卡住的地方。
先说矩阵链乘,这是 DP 的入门经典题。问题是有 n 个矩阵连乘,加括号的顺序不同,总的标量乘法次数差异巨大。状态定义 m[i][j] 表示从第 i 个到第 j 个矩阵连乘的最少乘法次数,转移方程是 m[i][j] = min{ m[i][k] + m[k+1][j] + p[i-1]·p[k]·p[j] },k 从 i 到 j-1。这里 p 数组是矩阵的维度链,第 i 个矩阵的维度是 p[i-1] × p[i]。计算顺序必须按区间长度从小到大,因为长的区间依赖短的区间,这个顺序写错了,数组里的值还没算出来就被引用了,答案直接崩。
0-1 背包是另一道必考。状态 f[i][v] 表示前 i 件物品、容量为 v 时能装的最大价值,转移方程是 f[i][v] = max( f[i-1][v], f[i-1][v-w[i]] + val[i] )。前一项是不选第 i 件,后一项是选。基于一维数组的滚动优化要倒序遍历容量 v,从大到小,这样能保证每件物品只用一次;如果你写成正序遍历,就变成了完全背包(每件可以用无限次)。这个正序倒序的区别是考点,我见过太多人在卷子上写反了还浑然不觉。
注意:0-1 背包的一维优化必须倒序,完全背包才是正序。这个细节一旦写错,整道题的结果全错,而且很多老师改卷时第一眼就看这里。
最长公共子序列(LCS)也是高频题。状态 c[i][j] 表示字符串 X 前 i 个字符和 Y 前 j 个字符的 LCS 长度。当 X[i] == Y[j] 时,c[i][j] = c[i-1][j-1] + 1;否则 c[i][j] = max(c[i-1][j], c[i][j-1])。时间和空间都是 O(mn)。如果要还原出具体的子序列,就沿着一张方向表回溯:相等就记下字符往左上走,否则往值大的那个方向走。这道题经常还会追问空间优化,答案是用两行滚动数组把空间压到 O(min(m,n)),代价是没法回溯出具体序列了,只能求长度。
最长递增子序列(LIS)是一道能体现算法优化思维的题。朴素 DP 的方程是 dp[i] = max(dp[j]) + 1(j < i 且 a[j] < a[i]),复杂度 O(n²)。但如果用贪心加二分维护一个"当前长度下最小的结尾元素"数组,能做到 O(n log n)。考试里如果只要求写一种,写 O(n²) 的保险;如果要求最优,就得把二分优化版写出来。这道题的二分思想很容易和前面的二分查找串起来,复习时放在一起记会更牢。
编辑距离是 DP 里比较有工程味的一道。状态 dp[i][j] 表示把 A 的前 i 个字符变成 B 的前 j 个字符最少要几步,允许插入、删除、替换三种操作。转移方程分四种情况:字符相等时继承 dp[i-1][j-1],不等时取 dp[i-1][j](删除)、dp[i][j-1](插入)、dp[i-1][j-1](替换)三者最小值加一。这道题在自然语言处理、拼写纠错里都有实际应用,理解它比死记公式有用得多。
关于 DP 还有一个经常被问到的理论点:最优子结构和重叠子问题。这两条是 DP 适用的前提,缺一不可。最优子结构是说大问题的最优解包含子问题的最优解;重叠子问题是指递归求解时会反复遇到同样的子问题。如果子问题不重叠,那用分治就够了;如果连最优子结构都没有,那就得考虑别的模型。考试里让你判断"某问题能否用 DP 解决"时,就把这两条拿出来对照分析,逻辑链会很清晰。
4. 贪心算法:能用不能用,靠交换论证说话
贪心算法看着比 DP 简单,写起来也短,但它有个致命前提——你得证明贪心选择是正确的。很多人答题时直接写一套贪心策略就算完,完全不证明,这是丢分的重灾区。贪心正确性的标准证明方法是交换论证:假设存在一个最优解,其中第一个选择跟贪心选择不一样,然后证明把它换成贪心选择后,解不会变差,从而说明贪心选择一定存在于某个最优解里。
活动选择问题是贪心的标准范例。有 n 个活动,每个活动有开始和结束时间,要选出最多数量的互不冲突活动。贪心策略是按结束时间从早到晚排序,然后依次选不冲突的。为什么按结束时间而不是开始时间或持续时间?因为结束越早,给后面活动留的空间越大,这是"尽早腾出资源"的思路。交换论证的证明是:最优解里第一个活动如果是 f,结束时间记为 e_f,贪心选的是结束最早的活动 g,那么 e_g ≤ e_f,把 f 换成 g 后,剩下的活动集合兼容性只会更好,不会更差。这个证明思路你必须能默写。
Huffman 编码是贪心里唯一带点数据结构味道的题。构造方法是用最小堆,每次取频率最小的两个节点合并,直到只剩一个节点。得到的编码树保证加权路径长度最小,也就是总的编码长度最短。考试里常见的考法是给一组频率让你手画 Huffman 树并写出各字符的编码。画的时候有个技巧:合并产生的新节点要重新放回堆里参与比较,别漏掉;另外同一组频率的 Huffman 树不唯一,但只要加权路径长度算对,编码怎么标左右都行。
最小生成树有两个贪心算法:Prim 和 Kruskal。Prim 从一个点出发,每次选连接当前树和外部的最短边,适合稠密图,用邻接矩阵加朴素实现是 O(n²);Kruskal 把所有边排序后依次加,用并查集判断是否成环,适合稀疏图,复杂度 O(E log E)。这两个算法的选用逻辑经常被考:点少边多用 Prim,边少点多用 Kruskal。这个判断背后是复杂度表达式的实际含义,不是拍脑袋。
单源最短路径里,Dijkstra 是贪心的代表,但它的适用条件经常被考——不能有负权边。原因是 Dijkstra 的核心假设是"已经确定的最短距离不会再被更新",一旦出现负权边,后面可能通过一条负边把已经确定的点再缩短,这个假设就崩了。如果有负权边,得用 Bellman-Ford,它的复杂度是 O(VE),通过 V-1 轮松弛把所有最短路径找出来,而且它还能检测负权回路。
提示:题干里出现"负权边"三个字,Dijkstra 直接排除,用 Bellman-Ford;如果还要求所有点对的最短路,那就是 Floyd-Warshall,三重循环 O(n³)。
0-1 背包和分数背包是检验你懂不懂贪心边界的经典对比。分数背包允许把物品切开,按单位价值排序贪心取就对了,因为切开之后每个部分的价值密度一样,局部最优能推出全局最优。但 0-1 背包不能切,贪心选单位价值最高的可能挤占空间导致总价值反而不如最优解,所以必须用 DP。这道对比题几乎是每张卷子的必考点,答题时要把"为什么贪心在这里失效"讲清楚,最好的方式是举一个反例。
贪心还有一个理论工具叫拟阵,用来判断一类问题是否具备贪心可行性。拟阵满足遗传性和交换性两条性质,在拟阵上做贪心能得到最优解。这个知识点偏理论,期末考得不多,但如果老师讲到这一块,你至少要能说出拟阵的两个性质,以及最小生成树问题可以建模成图拟阵,所以 Prim 和 Kruskal 才能保证最优。
5. 图论大题:最短路、生成树、最大流三条主线
图算法在期末卷上的分量很重,几乎是必考一到大题。复习时把它拆成三条主线:最短路径、最小生成树、最大流。这三条线的算法结构、适用条件和复杂度各不相同,我建议你列一张表横向对比着背,比零散记忆强太多。
| 问题类型 | 算法 | 适用条件 | 时间复杂度 | 数据结构 |
|---|---|---|---|---|
| 单源最短路 | Dijkstra | 无负权边 | O((V+E)log V) | 邻接表+优先队列 |
| 单源最短路 | Bellman-Ford | 可有负权边,可检测负环 | O(VE) | 边表 |
| 全源最短路 | Floyd-Warshall | 可有负权边,不能有负环 | O(V³) | 邻接矩阵 |
| 最小生成树 | Prim | 稠密图更优 | O(V²) 或 O(E log V) | 邻接矩阵/优先队列 |
| 最小生成树 | Kruskal | 稀疏图更优 | O(E log E) | 并查集 |
| 最大流 | Ford-Fulkerson | 增广路思想 | 依实现而定 | 残量网络 |
| 最大流 | Edmonds-Karp | BFS 找增广路 | O(VE²) | 邻接表+队列 |
拓扑排序是 DAG(有向无环图)上的基础操作,也是很多图算法的前置。实现方式是不断找入度为 0 的点输出并删除,用队列维护这些点,复杂度 O(V+E)。它的实际意义是给有依赖关系的任务排序,比如课程先修关系、编译构建顺序。考试里拓扑排序经常和"判断图是否有环"结合考:如果拓扑排序输出的点数少于总点数,说明图里有环。
最大流是图论里公式感最强的一块,核心定理是最大流最小割定理——一个网络的最大流等于最小割的容量。Edmonds-Karp 算法是 Ford-Fulkerson 的一个具体实现,区别在于它每次用 BFS 找增广路,保证找到的是最短增广路(边数最少),从而把复杂度收紧到 O(VE²)。理解残量网络是关键:正向边的残量是剩余容量,反向边的残量是已经流过去的流量,反向边的存在让算法可以"反悔",把之前流的量退回去改道。
Floyd-Warshall 的代码只有五行,但很多人背不下来,因为它有个容易记混的循环顺序。正确顺序是最外层循环 k 是"允许经过的中间点",内两层是起点 i 和终点 j。计算式是 dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])。这个 k 必须在最外层,因为它表示的是一种"逐步放开中间点限制"的 DP 思想。如果你把 i 或 j 放到最外层,算法就错了。这个顺序是考点,我见过有人顺手写成 k 在内层,结果全盘皆错。
还有一类"最短路变体"值得单独准备,就是在 DAG 上求最短路。因为 DAG 没有环,可以按拓扑序处理,复杂度降到 O(V+E),比 Dijkstra 还快。题目如果明确说是 DAG,你就要意识到可以用拓扑排序加一次线性扫描搞定,而不是上来就调 Dijkstra。这种"识别特殊结构用更优算法"的能力,往往是拿满分和拿九十分的差别。
6. 字符串匹配:KMP 的 next 数组手算不再翻车
KMP 是那种"上课听懂了、考试手算就出错"的典型算法。它的价值在于把朴素字符串匹配的最坏 O(mn) 降到 O(m+n),m 是模式串长度,n 是主串长度。降复杂度的关键是利用已经匹配过的信息,避免主串指针回退。朴素算法每次失配就把主串指针退回到起点、模式串指针清零,KMP 则通过 next 数组(有的教材叫前缀函数 π)让模式串指针在失配时跳到一个合适位置,主串指针一路向前不回退。
手算 next 数组是很多人的噩梦,我给你一个不出错的流程。next 数组的含义是:对于模式串的每个位置,求出该位置之前子串的最长"相同前后缀"长度。举个例子,模式串是 "ABABC",我一步步算:位置 1 之前没有字符,记 0;位置 2 之前是 "A",没有相同前后缀,记 0;位置 3 之前是 "AB",前后缀不同,记 0;位置 4 之前是 "ABA",前缀 "A" 和后缀 "A" 相同,长度 1,记 1;位置 5 之前是 "ABAB",前缀 "AB" 和后缀 "AB" 相同,长度 2,记 2。所以 next = [0, 0, 0, 1, 2]。这个手算流程你练五遍基本就稳了。
注意:不同教材的 next 数组定义可能差 1,有的用"最长前后缀长度",有的用这个长度加一,还有的直接把整个数组右移一位。答题前先看清楚教材或老师给的定义,别按自己习惯来,否则答案对不上。
匹配过程本身也有讲究。主串指针 i 一直往前走,模式串指针 j 在失配时回退到 next[j]。当 j 走到模式串末尾时说明匹配成功,记录位置后把 j 回退到 next[j] 继续找下一个匹配。整个过程 i 不回退,这是 KMP 线性复杂度的根本原因。理解这一点,比死记代码重要得多。
KMP 之外,字符串这块偶尔还会考字典树(Trie)和 AC 自动机。字典树用来做前缀查询,每个节点代表一个字符,从根到某节点的路径是一个前缀,插入和查询都是 O(长度)。AC 自动机是 KMP 思想在多模式串上的扩展,本质是字典树加失配指针,用来在主串里同时匹配多个模式串。这块考得少,但如果老师课上强调过,至少要把字典树的插入和查询代码写出来。
7. 回溯与分支限界:搜索题怎么把时间打下来
回溯和分支限界都属于"搜索"这个大范畴,前者用深度优先、后者常用广度优先或优先队列。它们的共同点是通过剪枝来减少搜索空间,区别在于回溯是找所有可行解(或一个可行解),分支限界是找最优解。期末卷上这两块经常合在一起考,让你设计剪枝函数。
回溯的模板结构是固定的:进入一个状态,判断是否到达边界,如果没到就遍历所有候选选择,对每个选择判断是否满足约束,满足就做出选择、递归、再撤销选择。这个"做选择—递归—撤销选择"的框架适用于 N 皇后、子集和、图着色、数独、全排列等一大类问题。写代码时最容易漏的是撤销选择那一步,一旦漏了,状态会被污染,后面的分支全错。
N 皇后是回溯的代表题。剪枝的关键是约束函数:任何两个皇后不能在同一列、同一主对角线、同一副对角线上。列冲突用一个布尔数组,主对角线用"行号减列号"作索引,副对角线用"行号加列号"作索引,这样判断冲突就是 O(1) 的。如果不做任何剪枝、直接枚举所有位置组合,复杂度是 O(n^n) 量级;剪枝之后实际能处理的 n 大得多。这个从"枚举"到"剪枝"的复杂度差异,是答题时要重点说清楚的。
分支限界比回溯复杂一点,它需要维护一个"当前已知最优解"作为限界。搜索过程中,如果某个节点的估价值已经比当前最优解还差,就直接剪掉,不用再往下搜。TSP(旅行商问题)的分支限界是经典例子:每个节点估计一条下界(比如已走路径长度加上每个未访问点最小出边之和),如果这个下界已经超过当前最优解,就剪枝。这里说的"下界"是分支限界的核心概念,它必须保证不会高估,否则会误剪掉最优解。
LC 检索(最小耗费优先)是分支限界的一种搜索策略,用优先队列每次取当前估价值最小的节点扩展。它和 BFS、DFS 的区别在于:BFS 按层扩展,DFS 按深度优先,LC 按估价值优先。期末卷上如果问"哪种策略能更快找到最优解",一般答 LC 检索,因为它优先探索最有希望的方向。不过 LC 的内存开销大,因为它要把所有活节点都存在优先队列里,这是它的代价。
剪枝这块有个通用思路值得记:剪枝函数分两类,约束函数剪掉不满足约束的子树,限界函数剪掉不可能产生最优解的子树。写剪枝时先想清楚"什么样的分支一定没有希望",把判断条件写出来,往往能省掉大量搜索。考试里评分不太看你能不能真的把大实例跑出来,更看你剪枝的逻辑对不对、复杂度分析说得清楚不清楚。
8. NP 完全性:证明题有一套固定的话术
NP 完全性是算法课里最抽象的一块,也是期末卷上很多人直接放弃的一道大题。但我要说,这道题其实是最"套路化"的,只要你掌握归约的固定写法,拿分反而比 DP 稳。核心概念有四个:P 类、NP 类、NP 难、NP 完全。P 是能在多项式时间内解决的问题,NP 是能在多项式时间内验证一个解的问题,NP 难是所有 NP 问题都能归约到它的问题,NP 完全是既属于 NP 又是 NP 难的问题。
要证明一个问题是 NP 完全,需要两步:第一步证明它属于 NP,也就是给出一个多项式时间的验证算法;第二步证明它是 NP 难的,方法是找一个已知的 NP 完全问题,把它在多项式时间内归约到你要证的问题上。第一步通常一句话带过,最难的是第二步的归约构造。
多项式归约的写法是有模板的。假设已知问题 A 是 NP 完全的,要证问题 B 也是 NP 完全的,就构造一个从 A 到 B 的变换 f,使得 A 的实例 x 是"是"当且仅当 f(x) 是 B 的实例且答案为"是",并且 f 必须在多项式时间内算完。答题时你要写清楚三件事:怎么把 A 的输入变成 B 的输入、为什么这个变换是多项式的、以及为什么两边的答案一一对应。
经典的归约链条要记住:SAT 是第一个被证明的 NP 完全问题,3-SAT 由 SAT 归约而来,然后 3-SAT 可以归约到顶点覆盖、团问题、哈密顿回路、子集和、0-1 背包的判定版等。这张归约图是答题时的"弹药库",遇到相关证明题就从中找最接近的已知问题往上套。比如要证团问题是 NP 完全的,可以从顶点覆盖归约,因为"图 G 有大小为 k 的团"等价于"补图里有大小为 |V|-k 的独立集"。
提示:判定问题和优化问题要分清。TSP 的判定版(是否存在长度不超过 L 的回路)是 NP 完全的,但优化版(求最短回路)是 NP 难但不是 NP 完全的,因为它不是判定问题,没有"多项式时间验证"这一说。
还有一个常考的对比:NP 难和 NP 完全的区别。NP 难只要求"所有 NP 问题都能归约到它",不要求它本身属于 NP;NP 完全则两个条件都满足。所以一个 NP 难问题可能比 NP 里任何问题都难,甚至可能根本不可判定。理解这个层次关系,就能回答"如果 P = NP 会怎样"这类理论题了——如果 P = NP,那所有 NP 完全问题都能在多项式时间内解决,但因为目前没人能证明 P 和 NP 是否相等,所以我们只能对 NP 完全问题求近似解。
9. 排序算法横向对比与考前最后的复习路线
排序算法是贯穿整门课的基础,期末卷上要么单独考一道对比题,要么作为其他大题的一部分出现。我把常见排序的复杂度、稳定性、适用场景整理成表,你考前扫一眼就够了。
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 特点 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 实现简单,教学用 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 小规模或近乎有序时快 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 交换次数少 |
| 希尔排序 | O(n^1.3) 左右 | O(n²) | O(1) | 不稳定 | 插入排序的改进 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 最坏也是 nlogn,需额外空间 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 平均最快,可随机化 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 原地排序,最坏有保证 |
| 计数排序 | O(n+k) | O(n+k) | O(k) | 稳定 | 适合值域小的整数 |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(n+k) | 稳定 | 适合固定位数的整数或字符串 |
这张表里有三个高频考点。第一,哪些排序是稳定的。稳定意味着相等元素排序后相对顺序不变,归并、冒泡、插入、计数、基数稳定,快排、堆排、选择不稳定。第二,哪些排序最坏能保证 O(n log n)。只有归并和堆排能做到,快排最坏会退化到 O(n²)。第三,哪些是原地排序。快排、堆排、插入、冒泡、选择空间都是 O(1) 或 O(log n),而归并需要 O(n) 额外空间。
线性时间排序要单独说一句。计数排序、基数排序、桶排序都基于"不通过比较来决定顺序"的思想,所以能突破比较排序的 Ω(n log n) 下界。这道下界证明本身也是考点,用的是决策树模型:n 个元素有 n! 种排列,决策树至少有 n! 个叶子,而高度为 h 的二叉树最多有 2^h 个叶子,所以 2^h ≥ n!,解出 h ≥ log(n!) = Θ(n log n)。这个证明逻辑特别漂亮,我建议你把它完整背下来,考到就是白送的分。
考前最后两天怎么安排,我分享一套自己用过的路线。第一天上午把复杂度分析、分治、DP 的递归式和状态方程全部默写一遍,尤其是主定理的三种情况和几个经典递归式;下午刷图算法,把 Dijkstra、Bellman-Ford、Floyd、Prim、Kruskal、拓扑排序的代码在纸上手写一遍,不查书;晚上过一遍贪心的交换论证和回溯剪枝的模板。第二天上午专门攻 NP 完全性的归约链条和排序对比表,下午把历年真题里的编程题挑两三道完整写出手写代码,晚上早睡。
答题顺序上有个实用建议:先扫一遍卷子,把复杂度题和排序对比这类"确定性高、耗时不长"的题先做掉,把 DP 和 NP 证明这类需要思考的放中间,最后做编程大题。这样能保证基础分先拿到手,心态也稳。遇到完全没思路的证明题,不要空着,把你记得的相关概念、归约方向、复杂度关系写上去,很多时候老师会给步骤分。
最后再分享一个我自己总结的小技巧:复习时不要只盯着"答案对不对",要盯着"为什么是这个答案"。算法设计与分析这门课考的不是记忆力,而是你分析问题的思路。你能把主定理为什么分三种情况、DP 的状态为什么这么定义、贪心的交换论证为什么成立这几点讲清楚,那不管题目怎么变,你都能找到下手的地方。我当年就是靠这套"讲清楚为什么"的方法,从六十多分一路刷到接近满分,这个方法比任何题库都管用。