期末周的图书馆凌晨两点,我在翻一本被咖啡渍染了角的《算法设计与分析》,书页上还留着上届学长的铅笔批注——那是他熬了三个通宵后总结的一句话:"这科不是背出来的,是算出来、推出来、写出来的。"说这话的时候我并不信,直到考完复盘,才发现算法设计与分析这门课的考试逻辑确实和别的课不一样。它看着抽象,其实套路极其固定;看着知识点散,其实考点高度集中。这篇就是我当年考前突击的完整复盘,加上后来带过几届学弟学妹总结出来的东西,从信息收集、考点定位,到复杂度硬算、五大范式拆解、编程题手写,再到时间分配和踩坑经验,全都摊开讲。不管你是刚考完期中还没缓过来,还是考前一周才想到这门课要考,这篇都能直接拿去用。它解决的核心问题只有一个:用最短的时间,把有限的分抓到手里,同时别把真正有用的算法思维丢掉。
1. 先搞清楚这门课到底考什么:突击前的信息收集
1.1 为什么"突击"在算法课上也有讲究
很多人一听"考前突击"就想到熬夜硬背,但这门课恰恰最吃"情报"和"结构"。算法设计与分析本质上是三门课揉在一起:一部分是数学(复杂度、递归式、正确性证明),一部分是设计范式(分治、动态规划、贪心、回溯、分支限界),还有一部分是图论与判定理论(最短路、最小生成树、NP完全性)。这三块的考试风格完全不同,数学部分考推导和计算,范式部分考识别和建模,图论部分考算法流程和手推。
我踩过的最大坑就是第一周拿着整本书从头看,看到第三章还在纠结渐进符号的严格定义,结果最后三天才意识到真正的大题集中在动态规划和回溯。突击的核心不是"看多少",而是"看对多少"。所以我建议你先花两个小时做一件看起来很功利但极其重要的事——把历年的题型摸清楚,把分值分布画出来。这个动作做完,你后面的每一小时复习都会踩在得分点上,而不是踩在舒适区里。
提示:如果你实在找不到历年真题,退一步也要把老师划的重点、期中卷、课堂例题、作业题这四样凑齐,它们的重合度通常高得惊人。
1.2 从哪搞到有效信息:三类资料的价值排序
从我的经验看,资料的价值顺序大致是这样的:历年真题 > 老师课上明确点名的例题 > 作业题 > 教材课后习题 > 参考书。真题的价值不用解释,它直接告诉你题型、分值、深度。但注意,真题要看的不是题目本身有多难,而是它反复出现的"骨架"。比如某年考了矩阵连乘的填表,另一年考了最长公共子序列的填表,看着不一样,其实都是"网格型动态规划"这一根骨头,做题思路、填表顺序、复杂度分析全都通用。
课上点名的例题是第二优先级,因为老师愿意在课上花十分钟推导的东西,往往就是他认为值得考的。作业题的用处在于查漏,尤其是那些你当时抄答案混过去的题,现在正好补上。教材课后题量大但质量参差,适合作为熟练度的补充练习,但不能作为主线。参考书我是这么用的:只在某个知识点用教材看不懂时,去翻一本讲得更啰嗦的书找那一段,看完立刻回来,绝不顺着参考书往下读。
| 资料类型 | 优先级 | 主要用途 | 使用建议 |
|---|---|---|---|
| 历年真题 | 最高 | 摸清题型与分值 | 先看题不看答案,自己判断考察点 |
| 老师点名例题 | 高 | 锁定必考范式 | 动手推一遍,别只看懂 |
| 作业题 | 中 | 查漏补缺 | 专挑当时做错的 |
| 教材课后题 | 中低 | 熟练度训练 | 限时做,不求全 |
| 参考书 | 低 | 补单一知识点 | 只看那一段,立刻回来 |
1.3 复习优先级的判断逻辑
判断一个知识点值不值得花时间,我一般用两个维度:考频和性价比。考频好理解,就是它出现得多不多;性价比指的是"投入一小时能换来多少分"。复杂度计算就是典型的超高性价比——基本是送分题,公式固定,练两小时就能稳拿。反过来,NP完全性的严格归约证明性价比就偏低,虽然概念必须懂,但如果时间紧张,把P、NP、NPC、NP-hard这四个概念和典型问题归类记牢就够了,深究归约链条反而挤占了大题时间。
这种判断背后有个很实际的逻辑:突击阶段,你的目标不是拿满分,而是把"会做但算错"和"根本没看"这两种失分尽量消掉。前者靠练熟,后者靠覆盖。所以我通常把时间切成三块——概念和计算占四成,范式建模占四成,图论算法流程占两成。这个比例不是死的,你得根据自己学校的风格微调,但整体思路是"先把必考的稳住,再冲有区分度的"。
2. 复杂度分析:送分题还是隐藏的扣分坑
2.1 渐进符号与常见复杂度排序
渐进符号这块看着是概念题,其实是整门课的地基。大O表示上界,大Ω表示下界,大Θ表示紧确界,小o和小ω表示严格的(不取等号的)上界和下界。很多同学只记符号,不记语义,结果一做题就翻车。比如题目问"3n² + 5n + 2 的紧确界是什么",你要能立刻答出Θ(n²),并且顺手说明为什么常数和低阶项可以丢掉。
丢掉低阶项和常数的逻辑很简单:当n足够大时,最高阶项会主导整个函数的增长,前面的系数只是缩放,不改变增长的量级。这也是复杂度分析的灵魂——它关心的不是"具体跑多少秒",而是"输入规模翻倍时工作量怎么变"。这一点我用生活类比来解释:你看一个人爬楼梯累不累,不看他每步迈多大,而看楼梯有多少阶。阶数翻倍,累的程度大致翻倍,这跟一个人腿长腿短(常数)关系不大。
复杂度排序必须背到条件反射:O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)。考试里常让你把几个表达式排序,或者判断"n log n 是否快于 n²",答错这种题特别冤,因为它完全靠记忆。我建议你睡前默写两遍这个链条,顺便配几个例子,比如二分查找是O(log n),归并排序和堆排序是O(n log n),冒泡和插入是O(n²),朴素的斐波那契递归是O(2ⁿ)。
2.2 递归式求解:主定理与递归树
递归式求解是复杂度部分的重点和难点,核心工具就两个:主定理和递归树。主定理适用于形如 T(n) = aT(n/b) + f(n) 的式子,其中a是子问题个数,n/b是子问题规模,f(n)是分解和合并的代价。判断规则看 n^(log_b a) 这个量和 f(n) 谁更大:如果 f(n) 明显更小,答案就是 Θ(n^(log_b a));如果两者同阶(可能差一个log因子),答案要乘一个 log n;如果 f(n) 明显更大且满足正则条件,答案就是 Θ(f(n))。
这个规则我用一个"拔河"的比喻记:把 n^(log_b a) 看成左边队伍的力气,把 f(n) 看成右边队伍的力气。左边压倒性赢,结果听左边的;势均力敌,加个log再听;右边压倒性赢,结果听右边的。比死记公式好记多了。实测下来,考试的递归式大多落在前两种情况,第三种偶尔考,但只要记得"确认正则条件"这一步就不会丢分。
递归树则更适合那些不规整、不好套主定理的式子。它的思路是把递归展开成一棵树,每层的代价加起来,再对所有层求和。比如 T(n) = 2T(n/2) + n,展开后每层代价都是n,一共log n层,总代价就是n log n,正好对上归并排序。我建议你至少手画三棵递归树,画到不假思索为止,因为画图这个动作能暴露你对"层数怎么算"的真实理解程度。
2.3 实操:手算几道典型题
光看规则不动手,考场上一定手生。我当年的做法是拿一张纸,把下面这几类各算三遍,直到全对。
第一类,直接判断复杂度。比如给你一段双重循环,外层从1到n,内层从外层到n,问时间复杂度。要一眼看出内层循环次数是 n + (n-1) + ... + 1,总和是 n(n+1)/2,所以是Θ(n²)。这类题的关键是别急着套结论,先把循环次数推出来。
第二类,套主定理。比如 T(n) = 9T(n/3) + n,这里 a=9,b=3,n^(log_3 9) = n²,而 f(n)=n 更小,所以答案是 Θ(n²)。再比如 T(n) = T(2n/3) + 1,a=1,b=3/2,n^(log_{1.5} 1) = n⁰ = 1,和 f(n)=1 同阶,所以答案是 Θ(log n),这正好对应"每次缩到三分之二,缩到1要log次"。
第三类,画递归树。比如 T(n) = 3T(n/4) + n²,用递归树展开能看到根节点代价n²,往下一层是3个(n/4)²,总和是(3/16)n²,再往下等比缩小,公比小于1,求和收敛到常数倍的n²,所以整体是Θ(n²)。这里能看出主定理和递归树互相印证,做题时用哪个顺手用哪个。
注意:主定理不是万能的,遇到 T(n) = 2T(n/2) + n log n 这种"卡在中间"的情况,主定理直接套会出问题,这时候老老实实画递归树更稳。
3. 五大算法设计范式:分治、动态规划、贪心、回溯、分支限界
3.1 分治法:识别信号与复杂度推导
分治法的套路非常统一:把问题分成若干规模更小的同类子问题,分别求解,再合并结果。三步是分、治、合。识别信号通常出现在题目里带"二分""折半""归并""划分"这类词的时候。典型例子有归并排序、快速排序、二分查找、最大子数组、大整数乘法、最近点对。
分治的复杂度分析几乎都要落到递归式上,所以它和第二章是联动的。归并排序是 T(n)=2T(n/2)+O(n),解出O(n log n);二分查找是 T(n)=T(n/2)+O(1),解出O(log n)。这些推导要能随手写出来,因为考试常让你"写出递推式并求解"。
分治最容易出错的地方在于合并步骤的代价估计。很多人写递推式时把合并写成O(1),结果答案全错。判断合并代价的方法是:合并时需要遍历或比较的子问题结果有多少个元素?如果是把两个有序数组合成一个,那代价就是O(n)。我见过太多人在这里丢分,说到底还是没把"分治的三步各花了多少"想清楚。另外分治和动态规划的关系也值得留意:分治的子问题互相独立、不重叠,而动态规划的子问题会重叠,这个区别是判断用哪种方法的第一信号。
3.2 动态规划:状态定义才是灵魂
动态规划是这门课分值最高、也最容易拉开差距的部分。它的核心思想是"用空间换时间"——把重复计算的子问题结果存起来,避免重复求解。但我必须强调,动态规划真正难的不是写代码,而是定义状态和推导状态转移方程。这一步想清楚了,剩下就是填表。
状态定义一般问自己两个问题:我要记录什么信息才能做出下一步决策?这个信息能不能用下标表示?比如0-1背包,状态 dp[i][j] 表示"前i件物品、容量为j时能拿到的最大价值",因为你要同时知道"考虑到第几件"和"还剩多少容量"。再比如最长公共子序列,dp[i][j] 表示"第一个串前i个字符和第二个串前j个字符的最长公共子序列长度"。状态定义对了,转移方程往往自然就出来了。
状态转移方程的推导逻辑,就是穷举最后一步的所有选择。背包问题最后一步要么不拿第i件,要么拿第i件,取两者的较大值。LCS最后一步要么两个字符相等(可以一起匹配),要么不等(看放弃哪一个)。这种"穷举最后一步"的思路能套用到绝大多数动态规划题上。
填表顺序也很关键,原则是"用到的状态必须先算出来"。背包通常按i从小到大、j从小到大;LCS按i、j从小到大;区间型问题(如矩阵连乘、石子合并)按区间长度从小到大。搞错顺序,填出来的表就是错的。我当年就是在一道区间DP上栽过,明明方程写对了,结果因为填表顺序错,答案全崩。
| 问题 | 状态定义 | 转移方程核心 | 填表顺序 |
|---|---|---|---|
| 0-1背包 | 前i件、容量j的最大价值 | 取/不取第i件 | i升序,j升序 |
| 最长公共子序列 | 前i、前j的LCS长度 | 字符相等则+1,否则取max | i升序,j升序 |
| 矩阵连乘 | 区间i到j的最少乘法数 | 枚举分割点k | 区间长度升序 |
| 最长递增子序列 | 以第i个结尾的LIS长度 | 枚举前面比它小的 | i升序 |
3.3 贪心算法:证明才是拿分点
贪心算法的形式很简单:每一步都选当前看起来最好的,不回头。它比动态规划好写,但难在证明。考试里如果只让你"用贪心求某问题",你写出算法只是第一步,老师真正想看你证明"这个贪心选择是安全的"。所谓安全,是指贪心做出的选择一定包含在某个最优解里,且不会让剩下的问题变差——这就是贪心选择性质和最优子结构。
拿活动安排来说,策略是"每次选结束时间最早且和已选活动不冲突的"。为什么选结束最早?因为结束越早,留给后面活动的时间就越多,这一步的直觉背后其实是交换论证:假设最优解没选这个最早结束的活动,而是选了另一个,那么把那个换成最早结束的,不会让后面的活动更差,所以总可以换。这个"交换论证"是贪心证明的标准武器,基本每道贪心证明题都能用。
常见的贪心问题要背下策略:活动安排按结束时间排序、哈夫曼编码每次合并最小的两个、最小生成树Prim任选起点不断加最近点、Kruskal按边权从小到大加且不成环、Dijkstra每次选离源点最近的未确定点。至于0-1背包,贪心是错的!这是经典陷阱,因为按单位价值排序未必得到最优解,必须用动态规划。这个反例考试超爱考,一定要能说清楚为什么贪心失效。
3.4 回溯与分支限界:搜索树上的剪枝艺术
回溯法的本质是深度优先地搜索解空间树,走不通就退回上一层再试。它的框架非常固定:递归函数里先判断是否到达边界,再 enumerate 当前所有可能的选择,做选择、递归、撤销选择。撤销选择这一步(也就是"回溯")是它和普通递归的区别,很多同学忘了撤销,导致状态污染。
典型题目有N皇后、图着色、子集和、全排列、旅行商。以N皇后为例,按行放皇后,每行尝试所有列,用三个数组分别标记列、主对角线、副对角线是否被占用,冲突就跳过,走到第n行就是一个解。这里的剪枝(冲突检测)是效率的关键,没有剪枝的回溯会退化成暴力枚举。
分支限界法则是广度优先(或优先队列)地搜索,并且用限界函数剪掉不可能产生最优解的分支。它和回溯最大的不同是搜索方式:回溯深搜,分支限界广搜或按界值优先。0-1背包的分支限界会用"当前价值加上剩余物品全部装入的上界"作为限界,如果这个上界还不如当前已知最优解,就剪掉。理解两者的区别,考试里让你对比时就能直接说清楚。
心得:回溯和分支限界的手写题,先把解空间树的形状画出来,再写递归/队列框架,最后加剪枝。顺序反了容易越写越乱。
3.5 五大范式横向对比
把这五个范式放在一张表里对比,是我复习后期的杀手锏。因为考试经常出一段描述,让你判断该用什么方法,这时候你脑子里就必须有一张对照表。
| 范式 | 核心思想 | 子问题关系 | 典型问题 | 拿分要点 |
|---|---|---|---|---|
| 分治 | 分而治之再合并 | 独立、不重叠 | 归并排序、最近点对 | 递推式与合并代价 |
| 动态规划 | 存表避免重复计算 | 重叠 | 背包、LCS、矩阵连乘 | 状态定义与填表顺序 |
| 贪心 | 每步选当前最优 | 独立 | 活动安排、哈夫曼、MST | 贪心正确性证明 |
| 回溯 | 深搜+剪枝 | 解空间树 | N皇后、图着色 | 撤销选择与剪枝 |
| 分支限界 | 广搜/优先+限界 | 解空间树 | 0-1背包、TSP | 限界函数设计 |
我一般会盯着这张表再默问一遍:"如果题目里子问题会重叠,用哪个?""如果要求最优解且能证明局部最优可推全局,用哪个?"这种自问自答练几轮,判断速度会快很多。
4. 期末编程题怎么练:从看懂到写出来
4.1 编程题常见题型清单
期末的编程题通常以"手写伪代码"或"补全代码"的形式出现,极少让你现场跑程序。所以练习的重点不是调试,而是把框架写对、把边界写对。常考题型其实就那几类:排序类的归并和快排、动态规划的背包和LCS、图的最短路和最小生成树、回溯的N皇后和全排列、还有二分查找的各种变体。
我建议你把这几类各手写三遍,不看任何参考。第一遍看着模板抄,第二遍合上模板默写,第三遍限时十分钟内写完。这个渐进式练习能有效对抗"看着会、写起来卡"的毛病。手写的时候特别注意边界:数组越界、递归终止条件、初始化值,这些都是扣分重灾区。
4.2 手写代码的套路:伪代码优先
很多人一上来就写C或Java的完整代码,结果被语法细节绊住,思路反而断了。我的做法是先写伪代码,把逻辑骨架搭好,再补具体语法。伪代码有个好处是它逼着你关注算法本身,而不是分号和大括号。
写归并排序时,我就固定成三段:分解(取中点)、递归(左右两半)、合并(双指针归并)。合并那一段是重点,也是常考的手写点,因为它展示了"O(n)合并"的具体做法。双指针归并的逻辑是:两个指针各指向一个有序子数组的开头,比较当前元素,小的先放入临时数组,指针后移,直到一个用完,再把剩下的接上。
写动态规划时,我固定成四段:定义dp数组并说明含义、初始化边界、嵌套循环填表、返回答案。这四段写清楚,即使代码有小瑕疵,老师也能看出你思路完整。写回溯时固定成三段:终止条件、遍历选择、回溯撤销。这几套模板练熟,考试就能像搭积木一样拼出来。
# 以最长公共子序列为例,手写时的标准结构 def lcs(a, b): m, n = len(a), len(b) # dp[i][j] 表示 a 前 i 个和 b 前 j 个的 LCS 长度 dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if a[i - 1] == b[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 # 字符相等,一起匹配 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # 否则放弃一个 return dp[m][n]这段代码看着简单,但考试时容易错在两点:一是dp数组大小写成m×n而不是(m+1)×(n+1),二是下标写错导致越界。手写时我会在草稿边上标注"下标从1开始对应字符下标i-1",这个小习惯救过我至少两次。
4.3 典型题实操:从建模到伪代码
拿0-1背包完整走一遍。题目给n件物品,容量W,每件有重量w和价值v,求最大价值。第一步建模:状态 dp[i][j] 表示前i件、容量j的最大价值。第二步写转移:对第i件,如果 j < w[i],装不下,dp[i][j] = dp[i-1][j];否则可以装也可以不装,取 dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])。第三步初始化:dp[0][] = 0,dp[][0] = 0。第四步填表顺序:i从小到大,j从小到大。第五步返回 dp[n][W]。
再拿Dijkstra走一遍。它的数据结构我用两个数组:dist记录源点到各点的当前最短距离,visited记录是否已确定。流程是每次从visited为假的点里选dist最小的,标记确定,然后用它去松弛邻接点。注意Dijkstra不能处理负权边,这是常考的概念点,要能解释原因——因为它每次确定一个点后就不再更新,负权边可能让已确定的点变得更好,破坏了这个假设。
最小生成树的Prim和Kruskal也要能写。Prim是"不断加距离生成树最近的点",适合稠密图;Kruskal是"按边权排