《图解算法》学习笔记:一把尺子、一张地图,和算法的直觉
- 约 18 分钟读完 - 全文 7095 字《图解算法》学习笔记:一把尺子、一张地图,和算法的直觉
目标读者:会写代码但没系统学过算法、一碰到"该用什么数据结构/算法"就凭感觉的新手;也适合学过但忘光了、想快速重建地图的老手。
参考书籍:Aditya Bhargava《图解算法》(Grokking Algorithms: An Illustrated Guide for Programmers and Other Curious People)
阅读目标:不追求覆盖每个细节,只提炼全书的骨架——一把评估工具的尺子(大 O),一张"问题形状 → 工具"的地图。
0. 写在前面:一本刻意"不完整"的算法书
算法书的光谱上,两端各有一座大山:
- 一端是《算法导论》(CLRS):全面、严谨、数学化,厚得能挡子弹,也厚得让大多数人停在第三章。
- 另一端是刷题网站:直接给你题目和答案,见效快,但题目之间的"地形"要自己去悟。
《图解算法》刻意站在这两端之外。作者 Bhargava 是工程师出身、靠画画解释概念出名(他维护过 illustrated 技术博客),这本书的每一个算法都先配一幅手绘风格的图,再配一段短代码。它覆盖的主题只有十来个,每个都只讲"够用"的深度。
这不是缺点,是设计。这本书的定位是第一张地图:让你在"问题"和"工具"之间先建立起粗糙但正确的连接,知道某个形状的问题该往哪个方向走。地图粗糙没关系,怕的是手里连地图都没有。
全书十一章,蒸馏到最后其实是两件事:
一把尺子:大 O 表示法,用来评估任何工具的性能。
一张地图:识别问题的形状,映射到对应的工具——二分查找、分治、散列表、图搜索、贪婪、动态规划。
下面按这条主线展开。
1. 大 O:全书通用的货币
书从二分查找讲起,但二分查找只是引子,真正的主角是大 O 表示法——后面每一章都在花这种"货币"。
二分查找本身:在有序数组里找元素,每次猜中间值,把搜索空间砍半。100 个元素最多 7 次猜中,40 亿个元素最多 32 次。前提只有一个:数据必须有序。
大 O 说的不是时间,是增长趋势。它回答的问题是:“输入规模翻倍,运行时间怎么变?“书里反复出现的五种:
| 大 O | 名字 | 书里的例子 |
|---|---|---|
O(log n) |
对数时间 | 二分查找 |
O(n) |
线性时间 | 简单查找(逐个翻) |
O(n log n) |
— | 快速排序(平均) |
O(n^2) |
— | 选择排序 |
O(n!) |
— | 旅行商问题的暴力解 |
新手最容易误解的三点,书里都点了名:
- 大 O 是最坏情况。简单查找运气好时第一次就命中,但大 O 管的是"倒霉到底"的那个上界。
- 大 O 吃掉了常数,但常数有时很重要。快排的常数因子比归并排序小,所以两者同为
O(n log n),实践中快排通常更快——这是第四章的一个伏笔。 - 两种算法的差距会随规模放大。
O(log n)对O(n),在小数据上无所谓,在 40 亿条数据上是 32 次对 40 亿次。评估算法时先问自己:我的数据会涨到多大?
这把尺子从此不离手:后面每拿出一个新工具,第一件事就是量一量它。
2. 数据结构的地基:数组与链表
第三章之前,书先补了一块地基:内存里的数据只有两种基本摆法。
flowchart LR
A["需要随机访问<br/>按下标直接拿"] --> B["数组<br/>读取 O(1)<br/>插入删除 O(n)"]
C["需要频繁插入删除<br/>顺序遍历为主"] --> D["链表<br/>读取 O(n)<br/>插入删除 O(1)"]
- 数组:元素在内存里连排,知道下标就能直接跳过去;但插入一个元素,后面所有人都得挪窝,而且数组满了还得整体搬家。
- 链表:每个元素存着下一个的地址,插入删除只要改指针;但想找第 5 个,只能从头数过去。
选择排序在这里出场,不是因为它重要,而是因为它是一个绝佳的"反面教具”:每轮扫一遍数组找最小值,n 个元素扫 n 轮,O(n^2)。它存在的意义是让你亲身体会"为什么 O(n^2) 不能接受”,给后面的快排当对照组。
这一章的真正收获是一个思维模式:先说操作模式,再选数据结构。读写各半?那就得接受折中(后面散列表给出答案)。
3. 递归与分治:把问题缩到基线
递归在书里被拆成一句话:每个递归函数都有两部分——
- 基线条件(base case):什么时候不再调用自己;
- 递归条件(recursive case):什么时候调用自己。
漏掉基线条件就是死循环。而理解递归的物理载体是调用栈:每次调用往栈上压一层,栈顶永远是当前正在执行的函数。栈好用,但深度失控会爆栈——这是递归的隐形成本。
递归的最大用处是支撑**分治(Divide and Conquer)**策略。书里给分治的解题套路是:
- 找出尽可能简单的基线条件;
- 不断把问题分解(缩小规模),直到触及基线条件。
书的例子很妙:把一块 1680×640 的地均匀分成方块、要求方块尽可能大——每次"切掉一块最大的正方形,对剩下的部分重复同样的操作",最后收敛到欧几里得算法。分治不是算法,是想问题的姿势。
快速排序是分治的招牌应用:
- 基线条件:数组元素少于 2 个,不用排。
- 递归条件:选一个基准值(pivot),把数组分成"比它小"和"比它大"两堆,对两堆分别快排。
快排的复杂度很讲政治:平均 O(n log n),最坏 O(n^2)——最坏情况出现在每次都选到极端 pivot(比如对已排序数组总选第一个元素)。所以 pivot 的选择就是性能的关键,随机选是最省事的保险。
记住那个伏笔:快排平均情况的常数比归并排序小,所以它是工程实践的默认选择。大 O 相同不代表一样快,常数在日复一日地收税。
4. 散列表:用函数换时间
如果说数组和链表是两种"认命"的摆法,散列表(hash table)就是那个"我全都要"的答案:平均情况下,查找、插入、删除全是 O(1)。
它的构造朴素得不像话:
flowchart LR
A["键 key"] --> B["散列函数<br/>把任意输入映射成固定数字"]
B --> C["数组下标"]
C --> D["值 value"]
一个合格的散列函数要满足:同样的输入永远得到同样的输出;不同的输入尽量映射到不同位置。于是"查找"从"翻箱倒柜"变成了"算一下地址直接拿"。
书里的三个经典用法,值得刻进肌肉:
- 查找表:电话簿、商品价格、DNS 解析——
dict/map存在的意义。 - 去重:投票站查"这人投过票没有",把逐个翻名单变成
O(1)查询。 - 缓存:服务器把算过的结果按 URL 存起来,下次直接给——所有 Web 框架都在这么干。
但散列表不是免费的,书也讲了两条暗线:
- 冲突(collision):两个键被映射到同一位置。标准解法是链表法——这个位置存一个链表,所有撞车的都挂上去。冲突多了,散列表就退化成了链表,性能从
O(1)滑向O(n)。 - 负载因子(load factor)= 元素数 / 位置数。负载因子超过约 0.7 就该扩容(换个更大的数组重新散列)。扩容昂贵,但摊销到每次操作上仍然便宜。
工程直觉:散列表的平均 O(1) 建立在"散列函数好 + 负载受控"两个前提上。前提塌了,最坏 O(n) 在等着你。
5. 图搜索(一):广度优先,用队列换最短
第六章把场景从"数组里找东西"升级到"网络里找关系":在你的朋友关系网里,谁认识芒果经销商?
图就是节点加边,是对"关系"的建模。广度优先搜索(BFS)回答两类问题:
- 从 A 到 B 有没有路?
- 从 A 到 B 的最短路径(经过的边最少)是哪条?
算法的核心道具是队列(FIFO):
- 把起点的朋友全部入队;
- 每次从队首取一个人检查:是芒果经销商吗?不是就把他的朋友加到队尾;
- 队列为空还找不到,就是不存在。
为什么队列保证"最短"?因为你按距离分层推进:一度人脉全部查完,才轮到二度人脉。第一个被找到的目标,一定在最少边数的那条路上。换栈(LIFO)就变成深度优先,性质全变——道具即语义。
两个工程细节别漏:
- 必须记录"已检查"名单(正好用上一章的散列表),否则关系网里有环时会在原地打转。
- 检查名单还顺便去重:同一个人被多个人介绍,只查一次。
树是特殊的图(没有环、没有指回祖先的边),所以这一章的思维方式覆盖了文件系统、组织结构、依赖关系等大量日常问题。
6. 图搜索(二):狄克斯特拉,带权重的最短
BFS 找的是"段数最少",但现实里每段路的代价不同——换乘 2 次但花 2 小时,和直达但花 3 小时,选哪个?狄克斯特拉算法(Dijkstra)处理的就是带权图的最短路径。
它的循环只有四步:
flowchart LR
A["找出当前开销最小<br/>且未处理的节点"] --> B["检查它的邻居<br/>经由它是否更便宜"]
B --> C["更便宜就更新<br/>邻居的开销和父节点"]
C --> D["标记该节点已处理"]
D --> A
关键直觉是贪心的有序性:一个"当前最便宜"的节点一旦被处理,它的最终开销就锁定了——因为不存在更便宜的迂回路线能再改进它(这正是不能有负权边的原因)。
书明确划了两条边界,比算法本身还值钱:
- 负权边禁用狄克斯特拉,改用贝尔曼-福特算法(Bellman-Ford)。负权边意味着"绕路反而赚钱",廉价节点处理完后仍可能被改进,四步循环的前提崩了。
- BFS 是狄克斯特拉的特例:所有边权重相等时,“段数最少"就是"开销最小”。学算法时这种"一般与特例"的父子关系,比单独记两个算法省一半脑子。
7. 贪婪算法:局部最优的边界
有些问题不存在快速精确解,贪婪算法的哲学是退而求其次:每步都选当前看起来最好的,拼出一个"足够好"的解。
书里的两个对比案例是这个哲学的完整两面:
- 教室调度:要在一间教室排最多的课。策略是"每步选结束最早的课"——这里的贪婪恰好就是最优解。
- 背包问题:小偷往包里装最值钱的商品,按"单价最高"贪婪地装,装不满,也装不到总价值最大——这里的贪婪只能拿到近似解。
同一个策略,一个精确一个近似,差别在问题结构。所以书的真正教益是后半章:识别 NP 完全问题。
旅行商问题、集合覆盖问题这类难题,精确解的复杂度是 O(n!) 或 O(2^n),规模稍大就算不动。书里给的判断流程非常工程:
- 元素少时算法很快,元素一多就爆炸?可疑。
- “所有组合"的字眼出现在解法里?可疑。
- 要算所有子集/所有排列才能出答案?基本坐实。
一旦确认撞上 NP 完全问题,正确动作不是硬算,而是换目标:用贪婪算法拿近似解。集合覆盖问题里,“每步选覆盖最多未覆盖电台的台"这个贪婪策略,能以 O(n^2) 的代价拿到足够好的答案。
完美是优秀的敌人,在 NP 完全问题面前,这句话是数学事实而不是鸡汤。
8. 动态规划:把大问题拆成可缓存的小问题
第九章是全书的智力高峰。动态规划(Dynamic Programming)的场景是:问题可以拆成子问题,且子问题的最优解能拼出大问题的最优解——那就先解子问题、存起来、再组装。
书用背包问题把整套方法演了一遍:背包容量 4 磅,商品有吉他、音响、笔记本电脑,各有限重和价值,怎么装总价值最大?
答案是画一张网格:
- 行是商品(逐个加入考虑范围);
- 列是背包容量(1 磅到 4 磅,每个容量是一个子问题);
- 每个单元格填:“只考虑前 i 件商品、容量为 j 时,能装的最大价值”。
填第 i 行第 j 列时,只有两种选择取最大:不装第 i 件(抄上一行的值),或者装(这件的价值 + 剩余容量在上一行的最优值)。填到右下角,答案自然浮现。
flowchart TD
A["大问题<br/>容量4 全部商品"] --> B["子问题网格<br/>每个单元格 = 部分商品 + 部分容量"]
B --> C["按行填表<br/>每格取两种选择的最大值"]
C --> D["右下角 = 原问题的最优解"]
网格法同样解了最长公共子串(比如拼写检查里 hunish 更像 hunch 还是 fish)和最长公共子序列(diff 工具、生物序列比对的雏形)。两者的网格公式不同,书特意对比:子串要求连续,只在左上角相邻格匹配时加一;子序列允许跳跃,不匹配时取左格和上格的较大者。
动态规划的使用前提,书里埋了两条硬规则:
- 每个子问题必须是离散的、不互相依赖的——背包里装两件商品的"组合优惠”(比如去巴黎的行程互相抢时间)会毁掉方法;
- 它优化的是某个约束下的某个指标,先想清楚约束是什么、指标是什么,再画网格。
新手对动态规划的恐惧,九成来自没见过"网格"这个脚手架。见过一次,DP 就从玄学变成了填表。
9. K 最近邻:从算法走向机器学习的一脚
第十章画风突变,讲起了K 最近邻(KNN)——它不是传统意义上的"精确算法”,而是通往机器学习的引桥。
套路分两步:
- 特征抽取:把对象变成一组可比较的数值。判断橙子还是柚子,可以抽"大小"和"颜色"两个特征;推荐电影,就把用户口味变成若干维评分。
- 找邻居算距离:新对象来了,找特征空间里最近的 K 个已知对象,分类就看邻居里哪类多,回归就取邻居的平均值。
书顺手点了两条暗线:特征选得好不好决定一切(推荐系统里"喜好"这种主观特征比"年龄"有用得多);以及回归——不只分类,还能预测数值(比如预测明天面包销量)。第十章还拿 OCR 和垃圾邮件过滤、预测股市当例子,并诚实地泼冷水:股市这种受太多不可测因素影响的对象,KNN 也无能为力。
这一章不求深,它是在全书结尾处把一扇门推开一条缝:算法世界的下一层是统计学习,特征工程是那层世界的"数据结构"。
10. 最后一章的真正价值:一张进阶地图
第十一章标题是"接下来如何做",看似附录,实则是这本书最像"地图"的一章——它把十个更大的主题各用一页纸点了名,告诉你每个是干什么的、什么时候会撞上:
- 二叉查找树:有序数据的插入查找都比排序数组灵活,是数据库索引的原型。
- 反向索引:搜索引擎的核心——给每个词建一张"出现在哪些网页"的表,正好是散列表思路的工业化。
- 傅里叶变换:把信号拆成频率成分,MP3 压缩、地震预测、音乐识别都靠它。
- 布隆过滤器与 HyperLogLog:海量数据下的概率型判断——“这个 URL 我爬过吗?“允许极小的误判率,换来指数级的空间节省。
- SHA 与局部敏感哈希:前者做安全散列和密码存储,后者(如 Simhash)让"相似内容"散列到相近位置,是查重和推荐的底层件。
- Diffie-Hellman 密钥交换:双方在不安全的信道上协商出共享密钥,现代加密的基石。
- 线性规划:在给定约束下最大化目标,图论问题是它的特例,运筹学的发动机。
- MapReduce 与并行算法:数据大到单机装不下时,把计算分发到成百上千台机器;并行排序的最优复杂度也从
O(n log n)变成约O(n)。
这一章的正确读法不是"学会”,而是建立索引:以后在真实工作里撞见"爬虫去重"“密码存储"“海量计数"这些词,你能想起"好像有个专门的东西解决这个”,再回头深查。地图的价值不在精细,在覆盖。
11. 带走的清单
把全书压成一张工位清单:
评估任何工具时
- 先问数据规模会涨到多大——小数据上无所谓的差距,大数据上是生死线。
- 用大 O 说最坏情况,别用"运气好"的例子安慰自己。
- 大 O 相同时,记得问常数因子(快排 vs 归并);常数在日复一日地收税。
- 空间和时间一样要算:递归的调用栈、散列表的扩容、DP 的网格都是空间账单。
看到问题时先认形状
- 数据有序、要查找?二分查找,
O(log n)。 - 要"按 key 秒查 / 去重 / 缓存”?散列表,盯紧负载因子。
- 关系网里找"最少几步”?BFS + 队列 + 已检查名单。
- 带代价的图里找"最便宜"?狄克斯特拉,先确认没有负权边。
- 问题能拆成独立子问题且子问题可复用?画 DP 网格,先写清约束和优化指标。
- 解法里出现"所有组合/所有排列"?多半是 NP 完全,别硬算,贪婪近似走起。
卡住时
- 试试分治:基线条件是什么?能不能把规模砍掉一块再递归?
- 试试换数据结构:很多"算法难题"其实是"数据摆错了地方"。
- 识别"当前问题是不是某个经典问题的特例"——BFS 之于狄克斯特拉,图论之于线性规划,特例意味着现成解法。
继续学
- 把第十一章当索引:布隆过滤器、反向索引、傅里叶变换、MapReduce,撞见了再深挖。
- 想系统化:去啃《算法导论》对应章节,那时你有地图了,不会迷路。
- 想练手感:刷题,但每道题先归类"这是什么形状的问题",再看答案。
这本书两百多页、图比字多,值回票价的理由只有一个:它把"算法"从一门课变成了一箱工具,并且教会你拿起任何一件工具之前,先看清手里问题的形状。
参考书籍:Aditya Bhargava《图解算法》(Grokking Algorithms: An Illustrated Guide for Programmers and Other Curious People, Manning, 2016;中译本由人民邮电出版社出版)。