算法这东西,入门的时候特别容易走两个极端:一种是把厚教材当小说硬啃,啃到第三章就劝退;另一种是打开题库从第一题顺着刷,刷了两百道,面试换个问法就懵。我自己从头补过一轮基础,也带过几个刚入门的朋友,最后摸出来一条相对靠谱的路子——把算法当成一门"修炼"来拆阶段。这篇聊的就是我给自己排的"练气十层",十个小关卡,每层只解决一类最基础的算法思维:从复杂度、双指针、二分,一路到排序、字符串匹配、递归、贪心、回溯剪枝、动态规划,最后摸到图论的门槛。整套走完,你会发现自己看题的眼神变了,能一眼看出"这题在考什么"。内容偏基础但不糊弄,代码我尽量给能直接跑的版本,适合刚学完语法、想系统性入门算法的人,也适合刷题刷得有点乱、想回头把地基重打一遍的朋友。
1. 练气十层到底练什么:先把修炼地图摊开
1.1 为什么算法入门要分层,而不是一口气堆题
刷题最大的坑,是把"难度"当成唯一的排序依据。Easy 刷完刷 Medium,看起来循序渐进,其实思维方式是乱跳的——今天在做数组模拟,明天突然来个记忆化搜索,后天又跳到图。每道题都现学现卖,结果就是每道题都像第一次见。
我后来想明白了,入门阶段真正该按"思维类型"来分,而不是按题目编号或者难度标签。同一个思维模型,先做最裸的版本,再做加了约束的版本,最后做伪装过的版本,这样才叫练。练气十层就是按这个逻辑排的:每一层锁定一种思维,做透再往下走。
这种分层还有个隐性好处:你能清楚地知道自己在哪一层卡住了。是复杂度算不明白,还是边界处理总出问题,还是状态定义写不出来。定位得准,补起来就快。反过来,如果你只是笼统地觉得"我算法不行",那种无力感会一直跟着你。
1.2 十层关卡与主题对照
先给一张地图,后面每一层我再展开讲。这张表你可以直接抄下来贴到自己的笔记里,每过一层打个勾。
| 层数 | 核心主题 | 关键算法/概念 | 修炼目标 |
|---|---|---|---|
| 一层 | 复杂度分析 | 大O、均摊、空间换时间 | 写完先估,不靠感觉 |
| 二层 | 双指针与滑动窗口 | 快慢指针、同向/相向、窗口收缩 | 把 O(n²) 压到 O(n) |
| 三层 | 二分查找 | lower_bound、upper_bound、边界四变体 | 边界不出错 |
| 四层 | 排序家族 | 冒泡、插入、归并、快排、堆排序 | 知道每种排序适合什么场景 |
| 五层 | 字符串匹配 | KMP、前缀函数、哈希匹配 | 线性时间匹配 |
| 六层 | 递归与分治 | 递归三要素、归并分治、快速选择 | 能把大问题拆成同类小问题 |
| 七层 | 贪心 | 跳跃游戏、区间调度、找零 | 分清贪心和DP的边界 |
| 八层 | 回溯与剪枝 | 全排列、组合、N皇后、可行性剪枝 | 会做减法,砍掉死路 |
| 九层 | 图论入门 | BFS/DFS、拓扑排序、Dijkstra、Prim | 把问题建模成图 |
| 十层 | 综合实战 | 混杂题、建模、进阶方向铺路 | 看到题能拆出套路 |
这张表里没有一层的主题是孤立的。二层用到的指针技巧,在五层的 KMP 里还会再见;六层的分治思想,是四层归并排序和九层部分算法的底座。所以别跳层,跳了后面会反复回来补。
1.3 修炼前先立三条规矩
在我自己的实践里,有三条规矩比刷多少题都重要,这里先立好,后面每一层都按这个来。
第一条,每层都要手写代码,不许只看题解。看题解的时候大脑会产生一种"我懂了"的错觉,因为逻辑是顺的,你只是跟着走了一遍。但真正写的时候,边界、初始化、循环变量这些细节会立刻暴露你的理解漏洞。我的习惯是每题至少手写两遍,第二遍不看任何参考。
第二条,复杂度的估算放在写代码之前。拿到题先想暴力解法要多少,能不能优化,目标复杂度是多少,再去动手。很多人写完才发现超时,其实一开始就知道暴力过不了,白白浪费时间。这个习惯一旦养成,你对算法优劣的直觉会越来越准。
第三条,每层留一个模板。二分、KMP 前缀函数、堆的上浮下沉、Dijkstra、并查集,这些都是高度模板化的东西。练的时候自己敲一遍,然后整理成一份带注释的模板存起来。以后遇到同类题,先调模板再改细节,速度会快很多。这不是偷懒,是把重复劳动交给肌肉记忆,把脑子留给真正需要思考的建模部分。
提示:三条规矩里,第一条最难坚持。我的建议是给自己定个硬性标准——每道题的代码必须在编辑器里跑通、且至少过三个边界用例,才算这一层算过。
2. 练气一层到三层:复杂度、双指针与二分
2.1 一层:复杂度,练的是眼力
一层的主题是复杂度分析,听起来最枯燥,但它是后面九层的地基。为什么?因为你后面所有的算法选择,本质上都是在"时间"和"空间"之间做买卖。
大O记法描述的是增长趋势,不是精确耗时。O(2n) 和 O(n) 在趋势上是同一档,O(n²) 和 O(n log n) 在数据量大起来之后是天壤之别。这里最容易犯的错是只看循环层数。一个双重循环,如果内层不是线性推进——比如双指针那种,内层指针总共只走一遍——那整体还是 O(n)。很多刚入门的人看到两层 for 就判死刑,其实是误判。
举个常见的场景:把数组里所有元素按大小分到两个桶里,一个朴素写法是外层遍历、内层再遍历一次找同类,那是 O(n²);但如果你维护两个指针,一次遍历同时填两个桶,就是 O(n)。代码没差几行,趋势差一整个数量级。你在写之前能不能看出这一点,就是这一层要练的眼力。
2.2 二层:双指针,练的是手感
双指针是我最推荐先练熟的技巧,因为它门槛低、适用广、回报快。核心就一句:让两个指针各自单调移动,合起来只走一遍数组,于是 O(n²) 变成 O(n)。
同向双指针的经典场景是"删除有序数组中的重复项""移动零""滑动窗口求最长子串"。快指针负责探索,慢指针负责写入或者标记窗口边界。滑动窗口尤其值得单独练,它的套路是:右指针扩张窗口,一旦窗口不满足条件就收缩左指针,全程记录最优解。模板大概是这个味道:
def longest_window(nums, limit): left = 0 best = 0 window_sum = 0 for right in range(len(nums)): window_sum += nums[right] while window_sum > limit: window_sum -= nums[left] left += 1 best = max(best, right - left + 1) return best相向双指针则适合"有序数组两数之和""盛最多水的容器""反转字符串"这类。两个指针从两端往中间夹,靠单调性砍掉一半的可能性。
练这一层的时候,我最大的体会是:别急着背题型,先想清楚指针移动的依据是什么。慢指针为什么能跳过某些元素?因为它前面的部分已经处理完了,不需要再回头。想通这个"不回头的理由",你就能自己推出一大堆变体,而不是死记。
2.3 三层:二分查找,练的是边界
二分看着简单,实际是入门阶段翻车率最高的算法,没有之一。问题几乎全在边界上:循环用lo < hi还是lo <= hi,mid更新成mid还是mid+1,返回lo还是hi。
我的建议是:别记两套写法,只收敛到一套自己最稳的。我常用的是"左闭右开"版本,好处是循环条件和更新方式对称,边界不容易错:
int lowerBound(const vector<int>& a, int target) { int lo = 0, hi = a.size(); while (lo < hi) { int mid = lo + (hi - lo) / 2; if (a[mid] < target) lo = mid + 1; else hi = mid; } return lo; }这里的mid = lo + (hi - lo) / 2是为了防止lo + hi溢出,虽然日常题里不容易触发,但养成习惯没坏处。
二分的四种常见变体你都得会:找等于目标的位置、找第一个大于等于目标的位置(lower_bound)、找第一个大于目标的位置(upper_bound)、找最后一个等于目标的位置。后三个是重灾区,因为它们处理的是"有重复元素"的情况。判断二分的边界,有个笨但有效的办法——把区间缩到只剩两个元素,手动走一遍循环,看指针往哪走、最后停在哪。这个动作我做了几十次,练着练着就有肌肉记忆了。
2.4 前三层的实操心得
前三层看似独立,其实是一条线:一层帮你判断"该不该优化",二层和三层给你两个最常用的"降维武器"。我在带朋友练的时候发现,跳过复杂度直接学双指针和二分,效果往往很差,因为他说不清自己到底省了什么、为什么能省。
还有个细节:二分有个前提——数据有序或者有单调性。很多人一看到"查找"就想上二分,结果数据是乱序的,直接错。你得先确认单调性成立,再谈二分。这个前提检查,我建议写成条件反射。
注意:二分题里出现"旋转数组""峰值""第一个错误的版本"这类描述,本质都是在考你能不能把问题转化成"在一个单调序列里找边界",先转化,再套模板。
3. 练气四层到六层:排序家族、字符串匹配与递归分治
3.1 四层:排序算法全景,别只会调库
实际开发里当然是调标准库的排序,但这不代表你不需要理解排序算法。排序是理解"分治""堆""稳定性""原地"这些概念的绝佳载体,而且面试里手写快排、堆排是常规操作。
冒泡排序是最容易上手的,也是理解"有序区逐渐扩大"的好例子。很多人觉得它没价值,但我建议你至少完整写一遍带优化标志位的版本:
void bubbleSort(vector<int>& a) { int n = a.size(); for (int i = 0; i < n - 1; ++i) { bool swapped = false; for (int j = 0; j < n - 1 - i; ++j) { if (a[j] > a[j + 1]) { swap(a[j], a[j + 1]); swapped = true; } } if (!swapped) break; // 本轮无交换,说明已有序 } }那个swapped标志位看着不起眼,但它体现了一种优化思维:在最好的情况下提前结束。有序数组上它能跑到 O(n),这就是"最好/最坏/平均"分析的具体例子。
几种主流排序的对比,我整理成了表,建议你亲手把每个都写一遍再回来看:
| 算法 | 平均时间 | 最坏时间 | 空间 | 稳定 | 特点 |
|---|---|---|---|---|---|
| 冒泡 | O(n²) | O(n²) | O(1) | 稳定 | 易懂,基本不用 |
| 插入 | O(n²) | O(n²) | O(1) | 稳定 | 小数据、近乎有序时极快 |
| 选择 | O(n²) | O(n²) | O(1) | 不稳定 | 交换次数最少 |
| 归并 | O(n log n) | O(n log n) | O(n) | 稳定 | 稳定排序首选,可外排 |
| 快排 | 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(n²) 来自于基准选择不当,比如每次选到最大或最小值,所以工程实现里普遍用随机基准或三数取中。归并排序的价值在于稳定和外排,当数据大到内存装不下、需要分块读文件排序时,归并几乎是唯一选择。
3.2 五层:KMP,字符串匹配里的分水岭
字符串匹配的暴力解是 O(n*m):主串每个位置都从模式串头开始比。KMP 把主串指针变成"只前进不后退",复杂度降到 O(n+m)。核心是前缀函数,也就是模式串每个前缀的最长相等前后缀长度。
暴力匹配失败时,主串指针会回退,这是浪费的来源。KMP 的想法是:我已经知道前面匹配过的这一段长什么样了,失败时能不能利用这个信息,让模式串自己滑动一段,而不是让主串重头再来?前缀函数记录的就是这个"该滑多少"的信息。
vector<int> buildNext(const string& p) { int m = p.size(); vector<int> nxt(m, 0); for (int i = 1, k = 0; i < m; ++i) { while (k > 0 && p[i] != p[k]) k = nxt[k - 1]; if (p[i] == p[k]) ++k; nxt[i] = k; } return nxt; }学习 KMP 有个诀窍:先不写匹配部分,先把前缀函数在草稿纸上对"ababaca"这种串手动算一遍。亲手推两三个串,你就能理解为什么while (k > 0 && p[i] != p[k]) k = nxt[k-1]里的回退是收敛的。硬背代码的人,往往卡在这个回退上,因为它涉及"失配后回退到次优前后缀"的嵌套逻辑。
提示:KMP 的前缀函数本身就是个宝藏。它还能直接用来解"最短循环节""字符串周期"这类题,练一层等于多拿好几个武器。
3.3 六层:递归与分治,拆问题的艺术
递归的第一课不是写代码,是先写递归三要素:这个函数要解决什么问题、base case 是什么、大问题怎么拆成同类小问题。三要素没理清就动手,多半会绕进去。
分治是递归的一个大类,核心套路是"拆开、分别解决、合并"。归并排序是标准案例:把数组从中点劈成两半,各自排好,再合并两个有序数组。合并这一步是关键,它把两个有序段用 O(n) 合成一个有序段,整个递归树加起来是 O(n log n)。
除了归并,快速选择(找第 k 大)也是分治的经典应用:它只递归处理包含目标的那一半,平均 O(n),比排序后再取快得多。练这一层,你要建立的一个直觉是:不是所有问题都需要"全部解决",有时候只处理一部分就够了。这个直觉后面在做剪枝和贪心时还会用到。
3.4 这三层最容易翻车的地方
四五六层放在一起,是因为它们都涉及"把复杂过程拆成可控步骤"的能力。翻车点也很集中。
排序层最常见的是稳定性搞混,尤其在选择排序和快排上,很多人记成稳定,其实是稳定排序。稳定性在某些业务场景下是硬需求,比如先按价格排、再按销量排,如果第二次排序不稳定,第一次的顺序就白费了。
字符串层最容易犯的是边界越界,nxt[k-1]里 k 为 0 的时候不能访问。递归层则是栈溢出和重复计算,前者靠控制递归深度或改迭代,后者得靠记忆化。这几个坑我都实打实踩过,写模板的时候多打几个断言,能省很多调试时间。
4. 练气七层到八层:贪心、回溯剪枝与动态规划
4.1 七层:贪心,先证明再动手
贪心的核心是"每一步都选当前看起来最好的",听起来简单,但它的难点在于你得能证明这个局部最优最终通向全局最优。不能证明的贪心,往往是错的。
拿"跳跃游戏2"举例:数组每个位置表示能跳的最远距离,求跳到末尾的最少步数。朴素的暴力是 BFS 所有可能,但有个贪心解法——在当前能到达的范围内,找到下一步能跳到最远的位置,把它作为下一跳的落点。这个策略能work,是因为"能跳得更远"永远不劣于"跳得更近",每一步的选择不会破坏后面的最优性。
区间调度是另一个经典:给一堆区间,求最多能选多少个互不重叠的。正确的贪心是"按结束时间排序,能选就选",而不是按开始时间或长度。为什么按结束时间对?因为结束越早,留给后面的空间越大。这个"为什么"你必须能说出来,否则换个问法就懵。
贪心和动态规划的分界线要划清楚:当局部最优能保证全局最优时用贪心,否则用DP。判断不准的时候,先写DP,再看能不能优化成贪心。
4.2 八层:回溯与剪枝,学会做减法
回溯本质是带"撤销"的暴力搜索。你沿着一条路走,走不通就退回来换一条。它的骨架非常固定:选择、递归、撤销选择。
以全排列为例,核心是维护一个"已使用"标记,每层从没用过的元素里挑一个,递归下去,回来时把标记清掉。N皇后则更典型:逐行放皇后,每放一个就检查列、两条对角线是否冲突,冲突就剪掉。
这一层真正拉开差距的是剪枝,也就是提前砍掉不可能产生答案的分支。常见的有三类:可行性剪枝(当前状态已经不可能满足条件,直接返回)、最优性剪枝(当前代价已经超过已知最优,返回)、去重剪枝(排序后跳过同层重复元素)。剪枝做得好,能把指数级的搜索砍到勉强能接受的范围。
def dfs(path, used, nums, res): if len(path) == len(nums): res.append(path[:]) return for i in range(len(nums)): if used[i]: continue # 去重:同一层里,相同值只用一次 if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue used[i] = True path.append(nums[i]) dfs(path, used, nums, res) path.pop() used[i] = False那段去重判断是很多人第一次见都会愣住的地方。它成立的前提是数组先排序,然后"同一层里值和前一个相同、且前一个还没被用过"就跳过。理解这句话的关键,是把递归想象成一棵树,同一层的兄弟节点代表同一个位置的不同选择,重复的兄弟只留一个。
4.3 动态规划:练气期最硬的一关
动态规划是练气十层里最需要理解、也最能体现水平的。它的骨架是:定义状态、写转移方程、确定初始化和遍历顺序。四步少一步都会出问题。
用 0-1 背包举例。状态dp[j]表示容量为 j 时的最大价值,转移是"第 i 件物品选或不选",遍历时容量要从大到小,防止同一件物品被重复选。这个"逆序遍历"的细节,是初学者最容易漏的坑,漏了就从 0-1 背包变成了完全背包。
最长递增子序列是另一个经典。O(n²) 的定义是dp[i]表示以 i 结尾的最长递增子序列长度,转移是往前找比它小的更新。它有 O(n log n) 的优化版本,用二分维护一个"递增序列尾巴"的数组。同一个问题的两种解法,正好把二分和三层的知识又用上了。
我练DP最大的体会是:别一上来就想最优解,先想暴力搜索,再加记忆化,最后推成递推。很多人卡住是因为跳过前两步,直接想写状态方程,结果状态定义就想半天。其实从递归树出发,你会发现哪些状态被重复计算了,把它们存起来就是记忆化,再改写成自底向上就是标准DP。
4.4 这几层的常见误区
最大的误区是把贪心当DP用,或者把DP当贪心用。前者会导致答案错误,后者会浪费时间。判断方法前面说过:能证明局部最优可推出全局最优就用贪心。
第二个误区是回溯不做剪枝。新手写N皇后,不检查冲突直接全排列,n=8 就跑不动了。剪枝不是可选项,是回溯题能不能过的关键。
第三个误区是DP的遍历顺序。这个问题比想象中复杂,一维是逆序还是正序、二维是先遍历物品还是先遍历容量,会直接影响结果是否正确。我的建议是,每写一个DP都手动填一遍小规模的表,看着表确认遍历顺序,比背结论靠谱得多。
5. 练气九层到十层:图论入门与综合实战
5.1 九层:把问题建模成图
图论的第一课是把现实问题抽象成"点"和"边"。谁是谁的邻接点、边有没有权重、有向还是无向,这几个问题想清楚了,剩下的就是套模板。
BFS 和 DFS 是图的两种基本遍历。BFS 用队列,一层层扩散,天然适合求无权图最短路;DFS 用栈或递归,一条路走到黑,适合找连通分量、判环、做拓扑排序。拓扑排序(Kahn算法)的思路特别优雅:不断把入度为 0 的点拿出来输出,同时把它的邻居入度减一。如果最后输出的点少于总数,说明有环。
带权图的最短路,Dijkstra 是绕不开的。它就是"贪心+优先队列"的组合:每次从没确定的点里挑距离最小的确定下来,再用它松弛邻居。注意它要求边权非负,有负权边就得上别的算法。最小生成树这边,Prim 和 Kruskal 是两条路,Prim 从点出发,Kruskal 从边出发,配合并查集判环,代码都不长。
// Dijkstra 核心:优先队列 + 松弛 priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq; vector<int> dist(n, INT_MAX); dist[start] = 0; pq.push({0, start}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d > dist[u]) continue; // 过期条目,跳过 for (auto [v, w] : adj[u]) { if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; pq.push({dist[v], v}); } } }那句if (d > dist[u]) continue;是优先队列实现的精髓。因为同一个点可能被多次入队,队列里会有过期的旧条目,跳过它们才能保证效率。这个细节不看注释很难自己想到。
5.2 十层:混合题与建模能力
练气十层不是引入新算法,而是练"看到一道陌生题,能拆出它用了前面哪几层的知识"。真实的题很少只考一个点,往往是"数组 + 二分""图 + 贪心 + 优先队列"这种组合。
综合题的解题流程我一般分四步走:先读题,确定输入输出和数据规模;再估复杂度,根据规模反推允许的算法档位;然后建模,把问题抽象成数组、图还是状态;最后选模板、改细节。这四步里,第二步特别有用——数据规模 n 小于等于多少,往往直接暗示了目标复杂度。n 到 10 的 5 次方,大概率要 O(n log n);n 只有 20 以内,可能允许指数级搜索加剪枝。
到了这一层,你会发现前面九层的作用显现出来了:每层你都留了模板,建模之后你只需要判断该调哪个模板,然后改参数。这就是分层练习的复利。
5.3 练气圆满之后往哪走
练气十层走完,你的基础算法思维算是立住了。再往后,就是所谓"筑基"的阶段——挑一个方向深入。偏工程和性能的,可以去研究更复杂的图算法和数据结构;偏数据智能的,自然会走向机器学习、深度学习、强化学习这些方向,卷积、注意力、各种优化算法都是后面的风景。
我个人觉得,练气期打下的那套"先估复杂度、再建模、再调模板"的习惯,是后面无论走哪条路都用得上的底层能力。很多人跳过基础直奔高级算法,结果连一个双重循环为什么是 O(n²) 都说不清,做实验调参全凭玄学。地基这东西,早打比晚补划算太多。
6. 修炼中踩过的坑与自查表
6.1 高频问题速查表
我把练气期最常见的翻车点和对应排查思路整理成表,卡住的时候照着对一遍,比干瞪眼强。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 二分死循环 | 循环条件与更新不匹配 | 用两个元素的手动用例走一遍 |
| 二分答案差一位 | 返回 lo 还是 hi 搞混 | 确认区间开闭,统一风格 |
| 滑动窗口结果偏大 | 收缩时机不对 | 检查窗口条件是否在每步都成立 |
| KMP 结果错 | 前缀函数回退写错 | 手动算一个小串的 nxt 数组 |
| 快排超时 | 基准选择不当 | 改随机基准或三数取中 |
| 回溯超时 | 没剪枝 | 加可行性/最优性/去重剪枝 |
| DP 结果偏大 | 遍历顺序错 | 手动填小规模DP表验证 |
| Dijkstra 慢 | 没用优先队列或没跳过过期条目 | 加if (d > dist[u]) continue; |
| 图遍历漏点 | 没处理非连通图 | 外层对每个未访问点启动一次遍历 |
这张表我建议你自己再补几行。每个人踩的坑不一样,把你自己踩过的记下来,才是真正属于你的经验。
6.2 几条不太有人说的经验
最后分享几条我个人觉得挺重要、但一般不写在题解里的经验。
第一,边界的测试用例要自己造,不要等判题系统告诉你。空数组、单元素、全相同、极端有序、极端逆序,这几个用例能覆盖大部分边界bug。我养成了写完代码先自己跑这几个的习惯,一次就过的概率高了不少。
第二,调试时先加日志,再下断点。对于递归和循环,打印关键变量比单步快得多。尤其是回溯,把每一层的选择和撤销打出来,整个搜索树一目了然。
第三,每层练完,隔一周回头重做两道题。这个"间隔复习"效果出奇地好。当时做出来的题,过一周可能就卡壳,那说明理解还不够深,正好补漏。我一般会用一个简单的表格记录每道题的"首次AC时间"和"复习状态",只记这两列就够。
第四,别追求刷题数量,追求模板覆盖率。你不需要做完某一类里的每一道题,你需要的是"这类题的每种变体我都有一个模板"。当你能说出"这个题是二分答案加验证"的时候,你就真的出师了。
练气十层这套东西,我自己断断续续走完大概花了两个月,中间也返工过好几次,尤其是二分和DP,重学了两遍。现在回头看,最有价值的不是学会了多少算法,而是建立起了一种"看到问题先想复杂度、再想模型、再调模板"的稳定套路。这套套路一旦成型,后面无论遇到什么新东西,你都知道该从哪下手拆。