1. 算法课到底在教什么:从“北邮算法课笔记”说开去
如果你正在翻看一份来自某高校算法课的笔记,大概率你面对的不是零散的代码片段,而是一整套从复杂度分析到图论、动态规划、贪心、分治、回溯的完整知识体系。这类笔记之所以在技术圈里反复被传阅,核心原因只有一个:算法课讲的是“怎么把一个问题拆成可计算的步骤”,而这恰恰是面试、竞赛、工程优化三件事的共同底层能力。
我自己前前后后整理过三版算法笔记,也帮身边不少同学做过考前突击和面试复盘。踩过的坑很一致:第一版抄板书,抄完看不懂;第二版抄代码,抄完不会写;第三版才想明白,算法笔记真正要记的不是代码本身,而是“这个算法为什么成立、什么时候用、边界在哪、复杂度怎么算”。这份“北邮算法课笔记”如果只停留在记录伪代码,那它的价值会打对折;但如果把它当成一份可复现的学习路径图,它能帮你省下至少两三个月的盲目刷题时间。
这篇文章适合三类人:正在上算法课、需要把课堂内容转化成自己能用的知识体系的学生;准备技术面试、想把常见算法题型系统过一遍的求职者;以及写了几年代码、但遇到性能瓶颈时只会“加缓存”的工程师。我会围绕这类算法笔记的整理逻辑、核心知识点拆解、实操复现方式、常见踩坑点四个方向展开,尽量把“笔记里没写清楚但实际很重要”的部分补上。
2. 算法笔记的整体设计思路:为什么不能只抄代码
2.1 笔记的三种层次与对应价值
我观察过很多人的算法笔记,大致可以分成三个层次。第一个层次是“板书搬运”,把老师写的定义、定理、伪代码原样抄下来。这种笔记的优点是完整,缺点是脱离课堂语境后几乎无法独立阅读,因为你不知道哪个条件是重点、哪个推导是考点。第二个层次是“代码仓库”,每道题附一份可运行代码,配上输入输出样例。这种笔记适合复习具体题目,但题目一换就失效,因为缺少对算法思想的抽象。第三个层次是“决策手册”,针对每一类问题记录:识别信号是什么、候选算法有哪些、各自复杂度如何、边界条件怎么处理、常见变形有哪些。这种笔记才是真正能带进考场和面试现场的东西。
提示:如果你手里的算法笔记还停留在第一层,不要急着补代码,先给每个算法补一行“什么情况下优先考虑它”。这一行往往比二十行代码更值钱。
2.2 为什么按“问题类型”组织比按“算法名称”组织更有效
大多数教材是按算法名称来分章的:排序、查找、图论、动态规划。但实际解题时,你面对的是一个具体问题,而不是一个算法名字。所以我在整理笔记时会把结构反过来:先列问题类型,再挂载算法。比如“最优化问题”下面挂动态规划和贪心,“状态转移问题”下面挂动态规划和记忆化搜索,“连通性问题”下面挂并查集和图的遍历。这样组织的好处是,当你拿到一道新题时,第一反应是判断它属于哪类问题,而不是回忆自己背过哪些算法名字。
这种组织方式还有一个隐性收益:它能帮你发现算法之间的重叠。比如最短路径问题,Dijkstra、Bellman-Ford、Floyd-Warshall 三个算法分别对应不同的边权条件和图规模,放在一起对比记忆,比分开背要牢固得多。我在笔记里专门做了一张对照表,把每个算法的适用条件、时间复杂度、空间复杂度、能否处理负权边、能否处理负环列清楚,复习时只看这张表就能快速回忆。
2.3 复杂度分析必须放在最前面,而不是附录
很多人的笔记把复杂度分析放在最后当补充,这是本末倒置。复杂度分析是算法课的“语法”,不懂语法就没法判断一个算法能不能用。我在笔记的第一章永远留给复杂度:大O的定义、常见复杂度量级的增长速度对比、主定理的三种情况、摊还分析的基本思路。这些内容看起来抽象,但它们是后面所有算法分析的脚手架。
举个具体例子:为什么快速排序平均复杂度是O(n log n),最坏是O(n²)?如果你不理解递归树和期望分析,就只能死记结论。但如果你在笔记里画过递归树,知道每一层的划分代价是O(n)、平均划分深度是O(log n),这个结论就是自然推出来的。我在笔记里给每个递归算法都配了一张递归树草图,标注每层的代价和层数,这样复杂度就不是背出来的,而是算出来的。
3. 核心知识模块拆解:从数据结构到算法范式
3.1 基础数据结构:数组、链表、栈、队列、哈希表
这部分看起来简单,但恰恰是面试和工程中最容易出问题的地方。我在笔记里对每个数据结构都记录了四个维度:底层存储方式、核心操作复杂度、典型应用场景、常见陷阱。以哈希表为例,底层是数组加链表或开放寻址,平均查找O(1),但最坏情况会退化到O(n)。典型应用是去重、计数、缓存,常见陷阱是哈希函数设计不当导致冲突集中,以及扩容时的重新哈希开销。
注意:很多人记哈希表只记“O(1)查找”,但实际工程中哈希冲突和扩容才是性能杀手。我在笔记里专门记了一条:如果键的分布不均匀,考虑加盐或换哈希函数,不要盲目相信平均复杂度。
链表部分我重点记了“哨兵节点”技巧。单链表删除节点时,如果没有哨兵,需要单独处理头节点,代码会变得很啰嗦。加一个虚拟头节点后,所有删除操作统一处理,这是我在实际写代码时反复受益的一个细节。栈和队列部分则重点记了“单调栈”和“单调队列”这两个变形,它们在滑动窗口最值、下一个更大元素等问题里出现频率极高。
3.2 树与图:遍历、最短路径、最小生成树
树和图是算法课的重头戏,也是笔记最容易记乱的部分。我的做法是把树和图分开记,但用同一套遍历框架串联。树的遍历有前序、中序、后序、层序四种,图的遍历有深度优先和广度优先两种。表面上看是六种遍历,但本质上只有两种:深度优先和广度优先。前中后序只是深度优先在二叉树上的不同访问时机。
最短路径部分,我在笔记里做了严格的分类讨论。单源最短路径且无负权边,用Dijkstra,配合优先队列复杂度是O((V+E) log V)。单源最短路径且有负权边,用Bellman-Ford,复杂度O(VE),还能检测负环。多源最短路径且图规模小,用Floyd-Warshall,复杂度O(V³),代码极简。这个分类逻辑比单纯背算法名字有用得多,因为面试时面试官往往会故意给一个带负权边的图,看你会不会错误地选择Dijkstra。
最小生成树部分,Prim和Kruskal的区别我记了一句话:Prim适合稠密图,Kruskal适合稀疏图。Prim从一个点开始不断扩展,复杂度O(V²)或O(E log V);Kruskal按边排序后并查集合并,复杂度O(E log E)。实际做题时,如果边数远小于V²,优先Kruskal;如果边数接近V²,Prim更稳。
3.3 动态规划:状态定义、转移方程、边界处理
动态规划是算法课里最难教也最难学的部分。我在笔记里把DP拆成四步:定义状态、写转移方程、确定初始化、确定遍历顺序。这四步里,状态定义是最关键的,也是最容易出错的。一个好的状态定义应该满足“无后效性”,即当前状态一旦确定,后续决策不受之前如何到达该状态的影响。
以背包问题为例,0-1背包的状态定义是“前i个物品、容量为j时的最大价值”,转移方程是“选或不选第i个物品取较大值”。完全背包的状态定义类似,但转移方程里“选第i个物品”后仍然可以继续选,所以遍历顺序变成正序。这个正序和逆序的区别,我在笔记里用一句话总结:0-1背包逆序遍历容量,完全背包正序遍历容量。这句话帮我省下了大量推导时间。
提示:DP的边界处理往往比转移方程更容易出错。我在笔记里要求自己对每个DP都写出“dp[0]或dp[0][0]的实际含义”,并且用一个小样例手动跑一遍。很多错误在手动跑的过程中就会暴露。
3.4 贪心与分治:适用条件与反例记录
贪心算法的笔记重点不是记算法本身,而是记“为什么贪心是对的”。我在笔记里对每个贪心算法都记录了交换论证或归纳证明的思路。比如区间调度问题,按结束时间排序的贪心策略之所以正确,是因为存在一个最优解包含最早结束的区间。这个证明思路比代码更重要,因为面试时面试官经常会问“为什么这样贪心是对的”。
分治部分我重点记了归并排序和快速排序的对比。归并排序是稳定排序,时间复杂度稳定O(n log n),但需要额外空间;快速排序不稳定,平均O(n log n),最坏O(n²),但原地排序空间开销小。这个对比在工程选型时非常实用。我还记了一个细节:归并排序的合并操作可以顺便用来求逆序对,这是很多题目的隐藏考点。
4. 实操复现:如何把笔记变成可运行的代码
4.1 环境准备与代码模板
算法笔记里的代码要能跑起来才有意义。我建议用Python或C++来复现,Python适合快速验证思路,C++适合分析底层性能。环境上不需要太复杂,一个能编译运行的单文件环境就够了。我在笔记里为每类算法准备了一个代码模板,比如并查集的模板包含路径压缩和按秩合并,图的邻接表模板包含加边和遍历。
# 并查集模板:路径压缩 + 按秩合并 class UnionFind: def __init__(self, n): self.parent = list(range(n)) self.rank = [0] * n def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, x, y): rx, ry = self.find(x), self.find(y) if rx == ry: return False if self.rank[rx] < self.rank[ry]: rx, ry = ry, rx self.parent[ry] = rx if self.rank[rx] == self.rank[ry]: self.rank[rx] += 1 return True这个模板我用了很多年,路径压缩保证查找接近常数时间,按秩合并避免树退化成链。笔记里我特意标注了:如果只写路径压缩不写按秩合并,复杂度是O(log n);两个都写,接近O(α(n)),α是反阿克曼函数,增长极慢。
4.2 关键算法的参数计算与选择过程
以Dijkstra算法为例,笔记里不能只写“用优先队列”,还要写清楚为什么用优先队列、什么时候用普通队列、什么时候用数组。Dijkstra的核心是每次取出当前距离最小的未访问节点,朴素实现是遍历所有节点找最小值,复杂度O(V²);用优先队列(最小堆)可以把取最小值降到O(log V),总复杂度O((V+E) log V)。当图是稠密图时,E接近V²,优先队列版本反而可能更慢,因为堆操作常数大。这个取舍我在笔记里用一张表列清楚。
| 实现方式 | 时间复杂度 | 适用场景 | 注意事项 |
|---|---|---|---|
| 邻接矩阵+遍历 | O(V²) | 稠密图,V较小 | 代码简单,无堆开销 |
| 邻接表+优先队列 | O((V+E) log V) | 稀疏图,V较大 | 需处理重复入堆 |
| 邻接表+斐波那契堆 | O(E + V log V) | 理论最优 | 实现复杂,常数大 |
注意:优先队列版本的Dijkstra会有重复入堆的问题,取出节点时要判断是否已访问,否则会重复处理。这个细节很多模板里没写,但实际写的时候不加判断就会出错。
4.3 动态规划的填表过程与调试方法
DP的调试是最让人头疼的,因为出错时往往不知道是状态定义错了还是转移方程错了。我的方法是:先写记忆化搜索版本,再改写成递推版本。记忆化搜索直接对应递归定义,容易写对;递推版本效率更高,但遍历顺序容易搞错。两者对照,能快速定位问题。
以最长递增子序列为例,记忆化搜索版本是“以第i个元素结尾的最长递增子序列长度”,递推版本是两层循环,外层遍历元素,内层遍历之前的元素。我在笔记里把两个版本都写出来,并标注了递推版本的遍历顺序为什么是从前往后。这个对照过程比单纯背代码有效得多。
# 最长递增子序列:递推版本 def length_of_lis(nums): if not nums: return 0 dp = [1] * len(nums) for i in range(len(nums)): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)这段代码的时间复杂度是O(n²),空间复杂度O(n)。笔记里我还补充了O(n log n)的贪心+二分版本,但标注了“面试时先写O(n²)版本,再优化”,因为O(n²)版本更容易写对,优化版本容易在边界上出错。
5. 常见问题与排查技巧实录
5.1 复杂度分析常见错误
第一个常见错误是把循环嵌套的复杂度直接相乘。两层循环不一定是O(n²),如果内层循环的边界依赖外层变量,实际复杂度可能更小。比如下面这段代码,内层循环次数是n + (n-1) + ... + 1 = n(n+1)/2,仍然是O(n²),但常数是1/2。如果内层是log n次,总复杂度就是O(n log n)。
第二个常见错误是忽略递归调用的复杂度。归并排序的递归树有log n层,每层合并代价O(n),总复杂度O(n log n)。如果只看到“递归调用两次”,可能会误以为是O(n)。我在笔记里要求自己对每个递归算法都画出递归树,标注每层代价和层数。
第三个常见错误是混淆平均复杂度和最坏复杂度。快速排序平均O(n log n),最坏O(n²),面试时如果只说O(n log n)而不提最坏情况,会被认为理解不完整。我在笔记里对每个算法都同时记录平均和最坏复杂度,并标注最坏情况出现的条件。
5.2 边界条件与特殊输入处理
算法题出错,十有八九是边界没处理好。我在笔记里整理了一份边界检查清单:空输入、单元素输入、全相同元素、已排序输入、逆序输入、包含负数、包含零、溢出风险。每次写完代码,对照清单过一遍,能排除大部分低级错误。
以二分查找为例,边界条件尤其多。左闭右闭、左闭右开、循环条件是小于还是小于等于、中点计算是否溢出、更新边界时是否加一减一,这些细节稍不注意就会写出死循环或漏查。我在笔记里固定了一种写法:左闭右开,循环条件while left < right,中点mid = left + (right - left) // 2,更新时left = mid + 1或right = mid。这种写法逻辑统一,不容易出错。
提示:二分查找的变体非常多,找第一个等于目标的位置、找最后一个等于目标的位置、找第一个大于目标的位置,每种变体的边界更新都不同。我在笔记里把每种变体都写了一个模板,并标注了关键区别。
5.3 动态规划的状态转移排查
DP出错时,我通常按以下顺序排查:第一,状态定义是否满足无后效性;第二,转移方程是否覆盖了所有决策;第三,初始化是否正确;第四,遍历顺序是否保证依赖状态已经计算。这四个问题里,遍历顺序最容易被忽略。
以完全背包为例,如果容量遍历用逆序,就变成了0-1背包,因为逆序保证每个物品只被选一次。这个区别我在笔记里用一句话总结:逆序0-1,正序完全。但这句话只适用于一维DP数组,如果是二维DP数组,遍历顺序又不一样。所以我在笔记里强调:不要死记结论,要理解为什么。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 程序超时 | 复杂度过高 | 分析循环嵌套和递归深度 | 换算法或加剪枝 |
| 结果错误 | 边界未处理 | 用空输入、单元素测试 | 补边界判断 |
| 死循环 | 循环条件或更新错误 | 打印循环变量 | 检查更新逻辑 |
| 栈溢出 | 递归深度过大 | 估算递归深度 | 改迭代或加记忆化 |
| 答案偏小 | 初始化过小 | 检查dp初始值 | 调整为负无穷或0 |
| 答案偏大 | 初始化过大 | 检查dp初始值 | 调整为正无穷或0 |
这张表是我从多次调试中总结出来的,放在笔记最后当速查用。实际做题时,大部分问题都能在这张表里找到对应行。
6. 笔记的迭代与长期维护
6.1 如何给笔记做减法
笔记越记越厚是常态,但真正有价值的是越记越薄。我的做法是每过一个月,把笔记里已经形成肌肉记忆的内容删掉,只保留容易忘、容易错、需要查的部分。比如并查集的路径压缩写法,写多了自然记住,就不需要留在笔记里;但按秩合并的细节容易忘,就保留。这样笔记会越来越精炼,复习时效率更高。
6.2 用题目反哺笔记
单纯看笔记容易产生“我都会了”的错觉,实际做题时才发现漏洞。我的习惯是每做一道题,如果发现笔记里没有覆盖某个细节,就立刻补进去。比如做了一道“课程表”的题,发现拓扑排序的入度更新容易写错,就在笔记里补一条“入度减到零时入队,不要提前入队”。这种从实战中来的补充,比抄板书有价值得多。
6.3 定期重写核心模板
算法模板会随着理解加深而进化。我每隔半年会重写一遍核心模板,比如并查集、Dijkstra、线段树。重写时不是照抄旧版,而是凭记忆写,写完再对照旧版找差异。差异处往往就是理解薄弱的地方。这个方法帮我发现过好几个隐藏的错误理解,比如线段树的懒标记下推时机,我之前一直记错了,重写时才暴露出来。
7. 从笔记到实战:面试与工程中的算法应用
7.1 面试中的算法考察逻辑
面试考算法,考的不是你会不会背题,而是你能不能把一个问题抽象成已知模型。我在笔记里专门记了一节“问题转化”:看到“最短”“最少”“最优”想动态规划或贪心;看到“所有可能”“组合”想回溯或搜索;看到“连通”“分组”想并查集或图遍历;看到“区间”“覆盖”想排序加贪心。这个转化清单帮我在面试时快速定位方向,而不是盲目尝试。
面试还有一个隐性考察点是沟通。我在笔记里记了一条:写代码前先说出思路和复杂度,写完后再主动检查边界。这个习惯让面试官看到你的思考过程,即使代码有小问题,也能通过沟通弥补。
7.2 工程中的算法优化案例
工程中直接用算法课原题的场景不多,但算法思想无处不在。比如数据库索引的B+树,本质是多路平衡搜索树;缓存淘汰的LRU,本质是哈希表加双向链表;任务调度的优先级队列,本质是堆。我在笔记里专门记了一节“工程中的算法影子”,把常见系统和算法对应起来。这样复习算法时,能同时理解它在工程中的位置。
提示:如果你觉得算法课内容离工程太远,试着找一个你熟悉的系统,分析它用了哪些数据结构。这个练习比刷十道题更能建立算法直觉。
7.3 持续练习的节奏建议
算法能力是练出来的,不是看出来的。我的建议是保持每周三到五道题的节奏,重点不是数量,而是每道题都走完“独立思考、写代码、调试、看题解、补笔记”这个完整流程。我在笔记里记录了自己的练习轨迹,发现进步最快的阶段不是刷题最多的阶段,而是每道题都写笔记的阶段。因为写笔记强迫你把思路整理清楚,而整理清楚才是真正的理解。
最后分享一个我自己的小习惯:每次复习笔记时,随机抽一个算法,合上笔记,在白纸上写出核心思路、复杂度、边界条件和一段伪代码。写不出来就说明还没掌握,回去重看。这个方法比反复阅读有效得多,因为输出才是最好的输入。