
讲个可能不太好听的事实大多数人学算法根本不是被难卡住的而是被不知道这东西到底有什么用卡住的。我刚带团队那阵子反复观察过一种现象一位开发者在力扣上能把中等题刷得飞起但到了实际项目里处理一个几万行的日志文件他第一反应居然是写个双重循环去逐条匹配然后看着程序卡死。你说他不努力吗不是。你说他没学过复杂度吗也不是。问题出在他学的所有算法知识都悬浮在半空没有真正落成一种能指导日常编码的常识。算法的常识说白了就是一套用来回答这代码到底会不会慢、为什么慢、怎么改才快的底层直觉。它不要求你记得所有高级数据结构的模板写法但要求你面对任意一个问题时能大致判断出它的瓶颈在哪儿、有没有更优的路径、以及自己写的解法到底对不对。这篇文章我不会去复述哪本教材里的定义也不会给你端上一份大而全的算法清单而是想聊聊我这些年实际编码、评审代码、带人刷题过程中沉淀下来的一些关于算法的常识性认知。它适合刚入行的新人也适合写过几年业务代码但总觉得算法是另外一门学科的同学。你看完不一定能马上秒杀难题但至少下次再遇到性能问题脑子里会多几根弦。1. 算法的本质是映射和搜索不是背模板1.1 把算法还原成朴素的人类行为小时候我们都玩过一种游戏给你一张地图和一堆剪碎了的纸条要你拼出完整的路线。你做的事无非两件——第一把每张纸条放到它该在的位置上第二反复尝试、比对、修正。放到计算机世界里这就是算法的全部把输入变成输出映射以及在众多可能性里找到符合条件的那个搜索。动态规划、贪心、分治、图论……名字听起来唬人剥开外壳没有一个逃得出这两个本质动作。动态规划是记住已经算过的映射避免重复搜索二分查找是在一个有序空间里缩小搜索范围哈希表是用算换个查直接用位置做映射。我见过太多人死记状态转移方程却不知道它其实是在描述从一个已解决的问题到下一个问题的映射关系。1.2 为什么理解本质比背模板更重要模板这个东西很害人。它给你一种我掌握了的错觉但一旦题目条件稍微变形模板就变成了一堆死代码。相反如果你抓住了映射和搜索这层底遇到新问题时你会先问自己这个问题的输入空间有多大我需要把每个输入都对应到一个输出还是只需要找其中一个搜索空间是有序的还是无序的有没有办法砍掉一半分支重复计算多不多能不能用空间换时间这三个问题问下来大多数题目的解题方向已经浮出水面了。有一次一位同事拿一段生产代码来找我说某接口偶发性超时。我一看逻辑里有一段从一个列表中反复查找元素的循环列表不长但接口调用频率极高。我说你把列表转成一个映射哈希结构查找从遍历变成直接命中问题就解决了。他改完耗时从平均800多毫秒降到了不到10毫秒。这就是一个典型的映射常识你不需要知道哈希表的底层实现原理但你得知道经常查的东西应该建成索引。1.3 像算二十四点一样理解分支与剪枝你小时候玩过算二十四点吗给你四张牌加减乘除凑出24。如果漫无边际地枚举所有括号组合可能性非常多但如果你先判断最后一步必须是两个数之间的一次运算搜索空间就骤然缩小。这就是剪枝的直觉来源。算法高手和普通人的区别很少是脑细胞多反而更多是知道哪些搜索分支根本不用走。放在写代码这件事上你不需要记住剪枝的所有技巧但你需要养成一个习惯每次写循环之前先想想循环体里有多少次无效迭代。2. 复杂度不只是大O规模感才是算法常识的核心2.1 从大O到能跑多久的换算几乎人人都知道O(n)、O(n log n)这些符号面试也爱考。可你真的知道O(n)在真实数据量下意味着什么吗来算一笔账。假设你的机器一秒钟能执行大约一亿次简单操作这个数字不算激进很多语言在简单循环里都能摸到这个量级。那么数据量 n 1,000O(n²) 是 100 万次一秒内轻松跑完n 10,000O(n²) 是 1 亿次刚好一秒左右开始有压力了n 100,000O(n²) 是 100 亿次几十秒到几分钟生产环境基本不可接受n 1,000,000O(n²) 是 1 万亿次几个小时起步如果这是在线接口你已经可以等着被投诉了。这就是我想说的规模感大O不只是面试题里的抽象符号它是你在设计系统时最该做的那道算术题。很多人写代码只关心功能正确完全不评估数据规模等到线上告警才来排查那就晚了。2.2 读代码时的复杂度体检我给自己定的一个习惯是每次在 code review 中看到嵌套循环都会本能地心里咯噔一下。尤其当外层循环遍历用户、内层循环遍历另一个表时两眼就得瞪圆。嵌套循环某种意义上就是一种坐标值透露出O(n*m)的味道你必须立刻确认 n 和 m 的量级以及这个函数会被调用多少次。有一次我们处理一批用户画像数据老代码里三层循环加一套字符串匹配用户量几千每个用户的标签上千个。本地测试数据量小看不出问题一上全量数据任务直接跑了四十多分钟。后来我把它拆成一个构建索引的预处理步骤再加一个单层遍历总耗时降到十几秒。没有用任何高深的算法只是把复杂度从O(n*m)降到了O(nm)。2.3 空间换时间划不划算你自己算哈希表、缓存、前缀和、布隆过滤器……本质上都是在用额外空间换时间。这个交易是否划算取决于你的瓶颈到底在哪一侧。我见过一种过度优化为了省一点点时间把一个几百万条记录的完整副本常驻内存结果内存告警频发。那段时间我就反复跟团队强调一个常识——空间换时间的前提是你有足够空间而且换来的时间确实值得。同样是缓存你缓存一个耗时100毫秒的计算结果和缓存一个耗时0.1毫秒的计算结果收益天差地别。好的算法工程师不会盲目套用数据结构而是先想清楚比例关系。2.4 最好用实测验证你的复杂度估算理论归理论实际跑一跑也很重要。语言不同、硬件不同、JIT预热程度不同常数因子能差出一个数量级。我建议的做法是写一个专门的小测试分别用 n10³、10⁴、10⁵、10⁶ 的数据量跑一下目标函数看耗时增长是否符合你预估的曲线。如果预估O(n log n)但实测接近O(n²)别慌先检查是不是代码里的某个隐藏操作比如字符串拼接、隐式类型转换、容器复制把复杂度拉高了。这种隐藏开销才是生产环境里真正的性能刺客。3. 暴力解不是丢人的解法它是验证一切的基准线3.1 先跑通再谈优化这个顺序不能反我经常跟团队说一句话能跑通的暴力解永远比跑不通的优雅解强一百倍。这不是开玩笑而是工程现实。你花一小时想出一个优雅但写不下去的优化方案和花十分钟写出一个正确的暴力解后者至少让你有了一个可以交付的东西。暴力解的核心价值在于它能作为你后续所有优化版本的基准答案。你想证明你的二分查找没问题拿它和暴力遍历的结果对拍。你想验证动归方程写对了先用小规模数据跑暴力递归看两者输出是否一致。没有这个基准线你连自己的优化代码对不对都说不清楚。3.2 暴力解的四种进化路径暴力解虽好但生产环境往往等不了它跑完。这时候你得知道暴力解往哪个方向进化去重同一个子问题算了一遍又一遍加个记忆化备忘录把重复计算抹掉。剪枝某些分支明显不可能产生结果提前拦下来。换结构查找太慢改成哈希或索引插入太慢改用合适的树或堆。换思路暴力枚举的是所有解但其实你只需要最优解那就想想贪心或二分。这四个方向并不互斥经常叠加使用。我建议每个开发者在面对性能瓶颈时按这个顺序往下走一层往往没到换算法那步就已经解决了问题。3.3 迭代式开发的节奏感在实际项目中我强烈不建议一上来就对着空文件写最优解。更好的节奏是先用暴力解打通整个流程确认数据流和边界都对再去针对热点函数做优化。因为优化是一个用正确性换性能的过程如果你连正确性都还没验证性能优化只会让你在错误的路上跑得更快。暴力解的好处就是它结构简单、不容易错很适合当作第一个正确版本。4. 常见算法模式的手感看到问题先能想到哪几类思路4.1 一看数据范围二猜思路很多人拿到算法题就发慌其实有个非常实用的手感可以帮你快速定位方向先看约束条件里的数据范围。n ≤ 20几乎可以盲上回溯或状态压缩因为2的20次方约一百万暴力枚举是可行的n ≤ 1,000O(n²)还在安全区双循环可以放心用n ≤ 10⁵必须上O(n log n)或O(n)这意味着排序、堆、二分、哈希这些工具该登场了n ≥ 10⁶不止要O(n)还得注意常数最好是线性扫描加常数额外空间。这不是什么严苛的公式但它是无数人总结出来的规模感速查表。刷题少的同学最容易犯的错就是n明明只有几百非要去写一个极其复杂的O(n log n)算法结果维护成本飙升或者n都到十万了还在写Dijkstra的双重循环版跑都跑不完。4.2 五大常见套路辨别排序、哈希、二分、双指针、动态规划我在带人做代码训练时经常让他们在动手写之前先说一句我打算用什么套路解决。习惯了之后大家看到题就不再是两眼一抹黑如果问题里出现最接近最小差第K大先考虑排序排序能把很多无序问题变成有序世界里的线性问题。如果出现频繁查找去重计数哈希结构是天然选择可以理解为给数据贴标签之后按标签找东西就快了。如果数据已经有序或者你打算排序那就追问一句能不能对答案进行二分搜索。别忘了二分不止能在数组里找数还能在答案的取值空间里找满足条件的值。如果要求连续子数组、连续片段满足某个条件双指针或滑动窗口想都不用多想。如果问题有重叠子问题最优子结构这种味道动态规划大概率跑不掉——虽然优化它还得靠经验但至少方向不会跑偏。这五件事并不是全部算法但它们覆盖了日常编码里绝大多数会遇到的场景。掌握这五件套的手感比你把《算法导论》背下来有用得多。4.3 从题目长什么样到对应什么结构抽掉具体的业务背景很多算法题本质上是同构的。举个例子找两个有序数组的中位数和在多个有序列表中找交集抽象出来都是在多个有序序列上做多路合并的问题前者用二分甚至更玄学的写法后者用堆但它们共享同样的底层直觉多个有序序列之间永远可以用一个有序保证来减少盲目比较。我喜欢把这层直觉称为业务问题翻译成抽象问题的能力。日常开发里遇到一个需求不要急着上框架先问一句它最像哪类经典问题是走图的最短路还是字符串的匹配还是一个区间调度翻译对了哪怕是去查现成的算法库你都比瞎写一堆循环的人强得多。5. 正确性不是感觉对测试、断言与边界值5.1 我试过了和它是对的是两回事我经常看见年轻同事改完代码跑了一遍正常数据就说没问题了。我一般会追问边界呢空输入呢只有一个元素呢全是重复元素呢逆序呢数据类型极端值呢算法代码的对和业务代码的对有一个很大的不同算法的输入空间往往是巨大的组合你跑的那几个例子只是沧海一粟。要证明一个算法是对的要么你有严谨的数学推导要么你有足够强的测试覆盖但最实际的方案是先写暴力参考实现再用随机小数据生成器对拍。这个做法在竞赛圈很流行放到工程里一样适用。5.2 边界值的变态清单在写算法实现时我脑子里总有一张边界值检查清单空集null或长度为0单元素集全部相同元素严格递增/递减序列含有负数的序列数据最大值和最小值的组合目标值比所有元素都大或都小尤其二分查找偶数和奇数长度的数组比如找中位数能整除和不能整除的边界涉及取模时尤其要命。每一条看起来都平平无奇但它们恰恰是隐藏bug的高发区。你仔细回想一下那些线上事故有多少是数组越界减一、忘记处理空串、取模没有考虑负数引起的。5.3 断言与不变量在代码里立规矩推荐一个不那么流行但非常实用的技巧在实现较复杂算法时在代码里加一些断言assert把算法执行过程中必须保持的性质明确写下来。比如快排的分区函数结束之后你断言左半部分的所有元素都小于等于 pivot右半部分都大于等于 pivot。这个断言一旦不成立立即崩溃你就能在调试时第一时间发现逻辑撕裂点而不是等结果错了之后大海捞针。这个不变量思维是算法高手和普通码农最明显的一个分水岭。普通人盯的是语句高手盯的是语句之间那个不变的性质。5.4 用对拍器碾碎自我感觉良好最后说下对拍器随机测试怎么搭。很简单写一个暴力解法函数一个你的优化解法函数再写一个随机数据生成器然后循环几千上万次每次比较两个函数的输出。一开始我还只用它来刷题阶段验证解法后来我连生产环境里的某个排序工具重构也这么干了一大轮。把新旧实现同时跑一遍所有随机样本和业务样本都对比输出确认完全一致才敢上线。用一次性成本换掉了无数隐性问题这笔账怎么算都划算。6. 面试之外算法常识在真实项目里的样子6.1 性能问题大多数不是缺一个算法而是缺一种判断到实际项目里真正让你写红黑树、写KMP、写最大流的场景其实非常少。更多时候你面对的是这个接口怎么越跑越慢、这个批处理为什么动不动就内存爆炸、这段上游数据一多就超时。而这些问题的解法恰恰来自算法常识而不是某个具体算法。你要判断的是慢是慢在循环次数太多还是慢在每轮循环里的某个隐藏重操作内存爆是数据量真的大还是你无意识地在循环里复制了太多东西超时是发生在数据获取阶段还是计算阶段是网络耗时还是CPU耗时有一次排查一个导入功能数据量从5万涨到6万耗时从2分钟涨到40分钟。乍一看像是指数爆炸仔细一查发现是导入过程中对列表做存在性检查用了双层循环而每新增一条记录都要全表扫描一遍之前插入的所有记录。数据量5万时扫描次数大概是几十亿次到6万直接逼近百亿次。这种问题不需要任何高级算法只需要把存在性检查换成哈希集合判断整个导入瞬间回到秒级。6.2 让算法常识变成团队的共同语言算法常识这东西最有价值的地方不在于你个人懂而在于它能变成团队评审代码时的共同语言。在代码评审中我不喜欢直接说这样写不行而喜欢说这个函数的复杂度大概是多少在目前的数据量下会发生什么。一旦整个团队都形成这样一种沟通习惯很多问题在评审阶段就会被提前发现而不是等上了生产环境再狼狈回滚。你不需要每个人都精通竞赛算法但每个人至少都应该对复杂度、数据量、耗时有数量级的判断能力。6.3 怎样系统性地提升算法常识最后说说我自己认为比较靠谱的提升路径。不要再从背模板开始而是从刷题时的复盘开始。每做完一道题问自己三个问题我最初的暴力思路是什么最终解法相比它优化掉了哪些重复计算这个优化思路能不能迁移到别的问题上第二个建议是挑一个经典算法把它真正实现至少三遍第一遍照猫画虎第二遍闭着眼自己写第三遍加注释讲解给别人听。三遍过去你对它的理解就远超见过的水平。第三个建议是定期做复杂度审计翻翻自己最近写的代码给每个函数标注出复杂度问问自己如果数据量变成十倍还会不会卡。不需要多的工具一个注释就够了。6.4 维护自己的算法笔记我自己有一个习惯遇到有意思的题目或真实问题都会把它整理成一张模式卡片上面写着问题输入长什么样、暴力怎么做、优化从哪个方向切入、这类问题以后看到什么关键词能想起它。长年累月下来这张卡片网比任何教材都管用。它记录的是你真实踩过的坑和真实解出的题是你自己的算法常识体系。写代码这条路上真正能陪你走远的永远是这些从实践里长出来的判断力而不是哪一本厚厚的理论书。所以我的建议是别再把算法当成一个需要攻克的学科。把它当成一种每天写代码都会用到的常识——像你知道喝水要喝温水、看地图要看路标一样自然的常识。一旦你完成了这个心态上的切换那些曾经陌生的大O符号、数据结构、设计思路都会慢慢变成你手里顺手的工具。