不知道你有没有过这种经历:背了一堆经典算法,快排、KMP、Dijkstra张口就来,可一碰到实际项目里的性能问题,或者面试官换了个场景问"这个需求该用什么数据结构",脑袋里瞬间一片空白。网上关于算法和数据结构的资料浩如烟海,但大多是零散的"知识点"——链表讲一遍,动态规划讲一遍,好像什么都看了,真到用的时候又串不起来。
这正是我想在这篇文章里解决的事。我会把"算法与数据结构"拆开揉碎,用一条主线把它们串起来:数据结构解决的是"数据怎么存才高效",算法解决的是"数据怎么算才高效",两者从来不是孤立的。文章会覆盖从数组、链表到树、图的核心数据结构,从排序、KMP到贪心、动态规划的经典算法,还会结合Redis、规则引擎这类实际组件,告诉你课本知识在工程里到底是怎么落地的。不管你是准备面试的在校生、工作几年想补基础的开发,还是刚入门想建立体系框架的新手,这篇文章都值得你花二十分钟从头读到尾。
1. 先搞清楚一件反直觉的事:数据结构比算法更重要
很多人学算法一上来就刷题,刷了三百道还是觉得没入门。问题不在题量,在于脑子里没建立起"数据结构"这个底座。计算机科学里有一句老话:程序 = 数据结构 + 算法。我自己的体会是,这句话应该倒过来理解——算法往往是数据结构定义好了之后自然长出来的东西。
1.1 一个真实案例:搜索功能的性能从3秒到10毫秒
前几年我优化过一个电商后台的订单搜索接口。最初版本用MySQL的LIKE去模糊匹配订单号,订单量到百万级以后,接口响应从几百毫秒直接飙到3秒多。当时团队第一反应是"优化SQL",加索引、调参数,折腾半天效果有限。后来产品经理一句话点醒了大家:"你们为什么不用内存里的结构先过滤一遍?"
我们把订单号主键导入Redis,用有序集合(ZSET)存订单号,再用前缀匹配的方式做模糊查询。改造后接口平均响应10毫秒左右,提升了两百多倍。这个案例里,算法根本没什么高深的——就是ZSET的有序特性加上二分查找的思路,真正起作用的是"选对了数据结构"。
1.2 数据结构选型的四个维度
工程里选数据结构,其实就是在做四个维度上的权衡:
- 存储成本:每个元素需要多少额外空间。链表比数组每个节点多存一个指针,哈希表比数组多存哈希值和冲突链。
- 访问效率:随机访问、顺序访问、按值查找的复杂度分别是多少。
- 插入删除效率:在头部、中间、尾部做增删操作的成本。
- 内存局部性:CPU缓存友好程度。数组是连续的,遍历时缓存命中率高;链表节点分散,缓存命中率低,数据量大了性能差异非常明显。
这四个维度互相牵制,不存在"完美数据结构"。数组随机访问O(1)但插入删除O(n),链表插入删除O(1)但随机访问O(n),哈希表查找O(1)但不支持有序遍历,树结构各方面均衡但实现复杂。选型的本质,是根据你的业务读写比例,选出最划算的那个"偏科生"。
1.3 基础数据结构的脑图式梳理
我不打算事无巨细地把每种结构都抄一遍定义,只把最核心的脉络拉出来:
- 数组:连续内存,随机访问O(1)。一切的基础。动态数组(比如Java的ArrayList)解决了扩容问题,但扩容本身是O(n)的,需要预判。
- 链表:节点+指针,插入删除O(1),但定位需要从头遍历。实际工程里用得比教科书少,因为CPU缓存不友好,但在LRU缓存、系统内核等场景仍是主力。
- 栈和队列:操作受限的线性表。栈解决"回溯"问题(函数调用栈、括号匹配、DFS),队列解决"排队"问题(BFS、任务调度、消息队列)。
- 哈希表:数组+哈希函数+冲突处理。查找O(1),但哈希冲突、扩容、负载因子这些细节是面试常考的重灾区。
- 树:层级结构。二叉搜索树、平衡树(AVL、红黑树)、堆、Trie树各有适用场景。数据库索引用的B+树也是树家族的成员。
- 图:表达复杂关系。邻接矩阵适合稠密图,邻接表适合稀疏图,实际业务里绝大多数图都是稀疏的。
- 跳表:链表+多级索引,Redis的ZSET底层就是它。它用空间换时间,把链表的查找从O(n)降到O(log n),实现比红黑树简单得多,这是它走红工程界的关键。
你可以把这七种结构想象成七种工具箱里的工具:数组是螺丝刀,链表是扳手,栈队列是带限位的滑轨,哈希表是秒取的抽屉柜,树是能保持有序的文件柜,图是能表示任意关系的乐高积木。 选工具之前先搞清楚你要拧的是哪种螺丝。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序算法全景:从冒泡到快排,它们到底在比什么
排序是算法入门的第一课,也是面试手撕代码的高频区。很多人背了冒泡、快排、归并、堆排的代码,但说不清楚它们之间本质的差别是什么。我尝试用一个框架重新解释排序——排序算法的演进,就是在"比较次数"和"交换成本"之间不断做权衡。
2.1 三大O(n²)排序:教学价值大于实用价值
冒泡排序、选择排序、插入排序都是O(n²)的,但性格完全不同:
- 冒泡排序:相邻元素两两比较,每一轮把最大的元素"冒"到末尾。最好情况下(已有序)经过优化可以做到O(n),但大多数情况下比较次数多,交换频繁,工程里几乎不用。
- 选择排序:每一轮从剩余元素中选出最小值,放到已排序区间的末尾。它的特点是交换次数少(最多n次),但比较次数固定为n(n-1)/2,不管数据是否有序都一样。
- 插入排序:像打牌时理牌一样,把新元素插入到已排序区间的合适位置。它最好的情况是O(n)(几乎有序时),而且对于小规模数据、近乎有序的数据,性能常常超过快排。这就是为什么很多高级排序算法在递归到小规模子数组时会切换成插入排序。
我推荐学习时把插入排序重点掌握,它不光是理论上的"玩具",在工程里它经常作为快排和归并排序的"最后一道工序"出现。
2.2 快排:为什么它是综合之王
快排的核心是分治:选一个基准值(pivot),把数组分成"小于基准"和"大于基准"两部分,再分别递归。平均时间复杂度O(n log n),而且原地排序,不需要额外内存,这是它干翻归并排序的重要原因。
但快排有两个关键细节,实现时最容易翻车:
- 基准值怎么选:如果数组已经有序,你每次都选第一个元素当基准,快排会退化成O(n²)。一个稳妥的做法是三数取中——取左端、中间、右端三个元素的中位数作为基准,可以大幅降低退化概率。更极端的做法是随机选基准。
- partition怎么写:经典的Lomuto分区和Hoare分区是两种主流实现。Lomuto写起来简单但交换次数多,Hoare写起来绕一些但效率更高。我建议至少能手写一种,明确知道另外一种是干什么的。
2.3 归并和堆排:各自镇守一方
归并排序的优势是稳定、无论什么数据都是O(n log n),但需要O(n)的额外空间。外部排序(比如对放不下的超大文件排序)就是归并思想的天下——把大文件切成能装进内存的小块,排好序后多路归并。
堆排序的优势是原地且最坏情况O(n log n),但实际常数因子大,而且不稳定,所以工程里不常用。但堆这个数据结构本身太重要了——优先队列就是堆实现的,Dijkstra算法求最短路径、定时任务调度、Top K问题,全都离不开堆。
2.4 各排序算法对比表
| 算法 | 平均时间 | 最坏时间 | 额外空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 几乎不用 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 交换成本极高的场景 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 小规模/近乎有序数据 |
| 快排 | O(n log n) | O(n²) | O(log n) | 不稳定 | 通用排序,工程首选 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 外部排序、链表排序 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | Top K、优先队列 |
| 计数排序 | O(n+k) | O(n+k) | O(k) | 稳定 | 整数范围小的场景 |
| 基数排序 | O(nk) | O(nk) | O(n+k) | 稳定 | 定长整数/字符串 |
你注意到没有,没有一个排序算法是全面占优的。所以Java的Arrays.sort()这么干:基本类型用双轴快排,对象类型用TimSort(归并的改进版,为了稳定性);数据量小的时候切换到插入排序。这就是工程智慧——没有银弹,只有组合拳。
3. KMP的next数组:从"暴力匹配"到"跳过不可能"
字符串匹配是计算机里最频繁的操作之一,而KMP(Knuth-Morris-Pratt)算法是所有讲解字符串匹配的书里绕不开的经典。不少人对KMP的恐惧,集中在next数组上——背了忘,忘了背。我试图用"跳过不可能"这个角度,把它讲明白。
3.1 朴素匹配的问题
在主串"abacababc"中匹配模式串"abacaba",最朴素的做法是:主串从第0位开始,模式串逐位比较,失配后主串下标只前进一位,再从头比较。这个过程的代价是O(m×n)。但这里面有一个巨大的浪费——两次匹配之间有很多比较是重复的。
我在实际讲解的时候喜欢用一个类比:你在一本厚厚的书里找一句话,每次看到某个词对不上,你不是翻回这一页开头重新找,而是你已经知道哪些前缀是确定匹配过的,直接跳到那几个字符之后继续查。
3.2 next数组到底存放了什么
next[i]的定义在不同教材里有细微差别,我采用最主流的一种:next[i]表示模式串p[0:i]中,前缀和后缀相等的最长长度(不包含整个子串本身)。这里"前缀"和"后缀"都是指子串自己的真子串。比如模式串"abacaba":
- i=0,子串"a",next[0]=0(没有真前缀)
- i=1,子串"ab",前缀"a",后缀"b",无相等,next[1]=0
- i=2,子串"aba",前缀"a"=后缀"a",next[2]=1
- i=3,子串"abac",next[3]=0
- i=4,子串"abaca",前缀"ab"?后缀"ca"不等;前缀"a"=后缀"a",next[4]=1
- i=5,子串"abacab",前缀"ab"=后缀"ab",next[5]=2
- i=6,子串"abacaba",前缀"aba"=后缀"aba",next[6]=3
所以"abacaba"的next数组是[0, 0, 1, 0, 1, 2, 3]。
在匹配阶段,当主串和模式串在位置j发生失配时,模式串的下标不用回退到0,而是跳到next[j-1]继续比较。这就是KMP的核心动作——利用已经匹配成功的部分,把模式串"滑动"到下一个可能匹配的位置,从而保证主串下标永不回退,整体复杂度降到O(m+n)。
3.3 手写一个可用的KMP
我给出一个Java版本实现,注释里标了每个关键步骤的作用:
java复制public class KMP {
// 构建next数组
public static int[] buildNext(String pattern) {
int m = pattern.length();
int[] next = new int[m];
int j = 0; // 当前最长相等前后缀长度
for (int i = 1; i < m; i++) {
// 前后缀不匹配时,利用已有的next信息回退
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
// 匹配成功,最长相等前后缀长度+1
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
// KMP搜索
public static int indexOf(String text, String pattern) {
int n = text.length();
int m = pattern.length();
if (m == 0) return 0;
int[] next = buildNext(pattern);
int j = 0;
for (int i = 0; i < n; i++) {
// 失配时模式串回退到next[j-1]
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (text.charAt(i) == pattern.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1; // 找到,返回起始下标
}
}
return -1;
}
}
我在刷题网站和实际项目中把这段代码反复用过很多次,唯一需要特别提醒的是:while循环那两行是整个KMP的灵魂,也是最容易写错的。很多人会用if,结果就变成了"只处理单次失配",遇到连续失配就崩了。记住:回退是一个循环过程,不是一次if就能搞定的。
3.4 KMP之外的字符串匹配思路
KMP是教科书中的明星,但工程里常用的字符串匹配方案还有不少。比如Boyer-Moore算法(文本编辑器的查找功能常基于它)、Rabin-Karp算法(用哈希值快速过滤不匹配的候选位置)。如果你只是要在业务代码里找一个子串,直接用String.indexOf就够了,JDK的底层已经针对不同长度做了优化(朴素匹配和类似BM的思路)。学KMP更多是为了建立"空间换时间""失配后如何利用已知信息"这类算法思维。
4. 图算法:Dijkstra不是用来背的,是用来"在图上做决策"的
图大概是所有数据结构里最接近真实世界的模型——社交网络里的人是节点、关注关系是边,地图上路口是节点、道路是边,微服务调用链里服务是节点、调用是边。图算法的核心问题很朴素:两点之间怎么走最短?怎么知道所有节点能不能连通?怎么把节点分组?
4.1 图的两种存储方式:邻接矩阵 vs 邻接表
先厘清存储方式,后面所有算法都建立在它之上。邻接矩阵用二维数组存边,任意两点能否直达查一下就是O(1),但空间占用是O(V²)。假如你有100万个节点,矩阵就存不下了。邻接表为每个节点维护一个列表存它"指向谁",空间是O(V+E),非常适合稀疏图。实际工程里,几乎都是稀疏图,所以邻接表是默认选择。
4.2 Dijkstra算法:带权最短路径的标杆
Dijkstra解决的是"单源最短路径"问题:给定一个起点,求它到图中所有其他点的最短距离。前提是图中不能有负权边。它的核心思想是贪心——不断从未确定最短距离的节点里,挑一个距离最短的"确认下来",然后用它去松弛它的邻居。
Dijkstra的教科书版本用数组维护dist,每次找最小值要O(V),整体O(V²)。但配上**优先队列(堆)**之后,每次取最小值变成O(log V),整体降到O(E log V)。这是数据结构优化算法的绝佳例证——堆这个数据结构,直接决定了一个算法的量级。
代码骨架长这样(简化版):
java复制public int[] dijkstra(List<int[]>[] graph, int start) {
int n = graph.length;
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
// 优先队列,按距离从小到大排序
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
pq.offer(new int[]{start, 0});
while (!pq.isEmpty()) {
int[] cur = pq.poll();
int node = cur[0], d = cur[1];
if (d > dist[node]) continue; // 过期记录,跳过
for (int[] edge : graph[node]) {
int next = edge[0], weight = edge[1];
int newDist = d + weight;
if (newDist < dist[next]) {
dist[next] = newDist;
pq.offer(new int[]{next, newDist});
}
}
}
return dist;
}
这里有个工程里非常实用的小技巧:当堆顶弹出的距离比dist数组里记录的还大时,说明这是之前某次更新时留下的旧记录,直接跳过。少了这行if,算法结果没错但会白白多做很多次无效松弛,数据量大了性能差距是数量级的。
4.3 面试和工程里更常碰到的图问题
Dijkstra是经典,但工作里我更常遇到的是下面这些:
- BFS和DFS:无权图的最短路径就是BFS天然职责。判断两点是否连通、走迷宫、遍历树结构,BFS/DFS是基本功中的基本功。面试官出的很多"岛屿数量""课程表能否完成"题,本质都是它们。
- 拓扑排序:有依赖关系的任务调度。Kahn算法基于入度统计,每次移除入度为0的节点。前置课程安排、编译依赖顺序都是它。
- 并查集(Union-Find):快速判断两个节点是否在同一个连通分量里,还能合并集合。朋友圈数量、冗余连接、Kruskal最小生成树都靠它。它实现简单、思想优雅,是我个人最推荐优先掌握的图相关数据结构之一。
- 最小生成树:Kruskal(基于并查集)和Prim(基于优先队列),解决的是"怎样用最小的成本把所有点连起来"。
4.4 图论经典复杂度速查表
| 算法 | 解决的问题 | 时间复杂度 | 核心数据结构 |
|---|---|---|---|
| BFS | 无权图最短路径 | O(V+E) | 队列 |
| DFS | 连通性、回溯 | O(V+E) | 栈/递归 |
| Dijkstra | 非负权最短路径 | O(E log V) | 优先队列 |
| Bellman-Ford | 含负权最短路径 | O(V×E) | 数组(松弛V-1轮) |
| Floyd-Warshall | 多源最短路径 | O(V³) | 二维数组 |
| Kruskal | 最小生成树 | O(E log E) | 并查集 |
| Prim | 最小生成树 | O(E log V) | 优先队列 |
| 拓扑排序 | 有向无环图的线性顺序 | O(V+E) | 队列/栈 |
我见过不少人在面试里栽在"什么时候能用Dijkstra,什么时候不能用"这个问题上——图里有负权边时不能用Dijkstra,因为贪心确认的节点可能被后面的负权边再更新。这时候要用Bellman-Ford或者SPFA。这种边界条件,才是面试官真正想考察的点。
5. 贪心、动态规划与回溯:三个"求最优解"的兄弟,怎么区分
算法设计范式里,贪心、动态规划、回溯是三个最容易混的血缘兄弟。它们都试图解决多阶段决策问题,但策略完全不同。我用一个很有名的例子来说明:换零钱问题。假设有面额[1, 5, 11]的硬币,要凑15元,最少需要几枚?
- 贪心:每一步选面额最大的,15→11+1+1+1+1,共5枚。但实际上最优解是5+5+5,3枚。贪心扑街了。原因在于它只看眼前,没有全局视野。
- 动态规划:用dp[i]表示凑i元的最少硬币数,dp[i] = min(dp[i-1], dp[i-5], dp[i-11]) + 1。它能找到3枚的正确答案。因为它记录了所有子问题的最优解,自底向上搭出全局最优。
- 回溯:尝试所有排列组合(暴力枚举),也能找到3枚的答案,但耗时是O(面额数量的n次方),数据大了直接爆炸。
5.1 贪心:什么时候"目光短浅"反而是优点
贪心算法的核心是局部最优能推导出全局最优,也就是最优子结构+贪心选择性。它不需要状态数组,不需要递归,代码通常最短,运行也最快,但前提是问题真的满足贪心性质。经典的反例就是上面的换零钱,面额设计变化后贪心就不再正确——如果硬币面额换成人民币的1、5、10、20、50,贪心就恰好是对的,因为货币面额设计本身就考虑了整除关系。
工程里贪心用得非常多,最典型的是Huffman编码(数据压缩)、活动安排问题、区间调度。学习贪心最重要的是不要一上来就套模板,而是要证明或验证当前问题的贪心选择性是否成立。面试时如果拿不准,快速举一个反例测试,比背模板有意义得多。
5.2 动态规划:把"递归爆栈"变成"查表"
动态规划的核心是三件事:定义状态、写出状态转移方程、确定遍历顺序。还是换零钱那道题,dp[i]是状态,dp[i] = min(...) + 1是转移方程,i从小到大是遍历顺序。一旦这三件事想清楚,代码实现非常简单。难点在于听别人讲都觉得简单,自己遇到新题就卡住——这很正常,因为动态规划的能力只能靠"刷够足够多的不同类型的题"培养,没有捷径。
我提供一个自己的解题套路,也许对你有帮助:
- 暴力递归先写出来(哪怕超时),这能帮你想清楚"状态有哪些、子问题是什么"。
- 观察递归函数有哪些参数,这些参数就是状态维度。
- 看递归过程中哪些子问题被重复计算了,用memo数组缓存结果(自顶向下的记忆化搜索)。
- 把递归改成自底向上的循环,这就是完整的动态规划解法。
很多动态规划题,从暴力递归到记忆化搜索,再到真正的DP,是一个自然而然的过程。不要一上来就追求"最优解",先把暴力写对,再逐步优化,这是我在实战里最常给自己的提醒。
5.3 回溯:暴力枚举的优雅化
回溯法本质是DFS:一步一步构建解,发现问题不满足条件就"撤销"上一次选择,回到上一步继续尝试。N皇后、全排列、子集组合、数独、正则表达式匹配,全是它的天下。
回溯代码模板高度统一,我在面试时写过无数遍:
java复制void backtrack(路径, 选择列表) {
if (满足结束条件) {
加入结果集;
return;
}
for (选择 : 选择列表) {
做选择;
backtrack(路径, 选择列表);
撤销选择; // 关键!回到上一步状态
}
}
回溯的优化重点是剪枝——提前判断当前路径已经不可能通向合法解,直接return。以N皇后为例,如果不剪枝,复杂度接近O(n!),加上对角线冲突检查可以直接砍掉大量分支。这也是"剪枝算法"这个词的来源,在很多搜索优化、规则引擎中都有应用。
5.4 三个兄弟的决策表
| 维度 | 贪心 | 动态规划 | 回溯 |
|---|---|---|---|
| 决策方式 | 每次都选当前最优,不回头 | 枚举所有子问题,保留最优 | 深度优先搜索,撤销选择 |
| 能否保证全局最优 | 仅当问题满足贪心选择性 | 一定能 | 一定能(穷举了所有情况) |
| 时间复杂度 | 通常最低 | 中,状态数×转移成本 | 通常指数级 |
| 典型场景 | 区间调度、Huffman、最小生成树 | 背包、LCS、编辑距离、换零钱 | 排列组合、搜索、数独、N皇后 |
我的经验是:先看问题能不能分成子问题,再看子问题是否重叠。重叠了就用动态规划,不重叠但能局部贪心就用贪心,需要枚举所有结果就用回溯。这套"三兄弟判定法"帮我解决了很多分析题。
6. 从面试到工程:课本里的算法怎么一步步变成线上系统
刷题归刷题,真正把算法用进生产环境,中间还隔着好几座山。我结合自己参与过的项目来聊聊,课本里的算法在真实系统里经历了什么。
6.1 Redis为什么是数据结构的"活教材"
Redis堪称内存数据结构的教科书级实现。它的五种基本类型——字符串、哈希、列表、集合、有序集合——底层分别由不同的数据结构支撑,而且会根据数据量动态切换:
- 列表对象:数据少时用压缩列表(ziplist),元素多了转成双向链表(quicklist的结构)或者更紧凑的listpack。
- 哈希对象:小型数据用压缩列表节省内存,达到阈值后转成哈希表。
- 有序集合:数据量小时用压缩列表,大了用跳表+哈希表组合。跳表在这套体系里替代了红黑树,原因就是实现简单、支持范围查询无需旋转操作、并发场景下更好调试。
我举ZSET的例子是想说明:工程里的数据结构不是非黑即白,而是多种结构组合、按需切换。你在课本上学的"压缩列表""跳表""哈希表"确实会各就其位,但真实系统会告诉你,数据规模、读写比例、内存成本这些课本里不常提的因素,才是选型的最终裁判。
6.2 规则引擎里的Rete算法和"事实匹配"问题
另一个让我印象深刻的是规则引擎Drools里的Rete算法。业务规则动辄几百上千条,每条规则都包含多个条件,如果每来一条数据就逐条规则逐条件匹配,性能会非常难看。
Rete算法的核心思路是缓存匹配中间结果——把规则拆解成节点(模式节点、连接节点、终端节点),形成一个有向无环图;事实(业务数据)在这个网络里流动,每个节点只计算自己负责的那部分匹配,并把结果缓存下来,供后续规则复用。这就是为什么它能把O(规则数×条件数×事实数)的暴力匹配,压缩到接近O(事实数)的增量匹配。
这和动态规划的思想如出一辙——把重复计算的子问题缓存起来,用空间换时间。我第一次理解Rete时非常震撼:原来算法的思想可以跨越如此不同的领域,从字符串匹配的KMP到规则引擎,本质上都在做同一件事——"不要重复计算已经算过的东西"。
6.3 工业控制里的PID和FOC:算法不只是"计算机专属"
算法与数据结构不只是互联网后端的事。在工业控制、机器人、电机驱动这些领域,你会发现算法以另一种形态存在。比如PID控制算法,它不依赖堆、树、哈希表,但依赖一个朴素的反馈思想:根据误差的比例(P)、积分(I)、微分(D)来调整输出,让系统快速且稳定地逼近目标值。电机控制的FOC算法更是把坐标变换、PID调节、SVPWM这些数学工具组合起来,才把一个三相电机"驯服"成可以精确控制转速和力矩的部件。
这些领域提醒我:算法是思维方法,不只在软件里体现。当你说"我用到了算法",不一定要是力扣原题,可能只是"我决定用这个公式去调那个参数",这也是一种算法。
6.4 从"会写题"到"会选型",还差这几步
刷题给你的是"手上有锤子",工程经验告诉你"哪里该钉钉子、哪里该用螺丝刀"。我个人认为,从面试验收合格到工程落地合格,至少要补齐这四块拼图:
- 复杂度要心里有数:O(n log n)在百万数据下是多少秒?千万呢?亿呢?建议有空用真实数据测一测各种数据结构的性能,建立直觉,而不是停留在纸面复杂度的比较上。
- 空间成本要会算:一个哈希表存一千万个键值对,大概要占多少内存?一个B+树索引占多少?项目上线前评估资源,这比"能不能跑通"更现实。
- 并发场景要纳入考量:单线程下的ArrayList和并发环境下的CopyOnWriteArrayList行为完全不同。HashMap和ConcurrentHashMap的差异,刷题永远不会教你,但线上环境天天在问。
- 要会读别人的实现:框架源码里全是最顶尖的算法实践。读一读Spring的缓存抽象、Netty的内存池、Guava的RateLimiter实现,比刷一百道题,更能理解"生产级算法长什么样"。
7. 一个高效的学习路径:我踩过的弯路你别再走了
文章最后,我想分享一些比较实际的学习建议。我见过太多人从"数据结构与算法"的深坑里爬出来,白白浪费了大量时间,我自己也交过学费,所以这些经验应该能帮你跳过几个大坑。
7.1 别一上来就啃大部头教材
严蔚敏老师的《数据结构》C语言版是经典中的经典,但它是教材,不是入门读物。我的建议是:先看视频或者图解,建立起直觉,再回到教材以"查缺补漏"的方式读。一本好的可视化算法网站、一套思路清晰的视频课程,初期比砖头书有用得多。尤其是树、图、动态规划这些抽象概念,生动的可视化比文字描述效率高十倍。
7.2 刷题要"分类突破",不要"随机漫步"
我见过很多朋友每天随机刷两三道题,刷了三个月,一问还是只会做简单题。正确的方式是按专题刷:这一周只做链表题,下一周只做二叉树题,再下一周只做动态规划。每个专题内部,知识点是连贯的,这样你才能通过多道题总结出同一类解的套路。刷完一个专题再进入下一个,比东一榔头西一棒子高效得多。
一个礼拜里如果某个专题的题做了十道还是找不到感觉,我的经验是暂停刷题,回去重看这个专题的视频讲解和总结文章,别急着硬刷。很多时候不是题量不够,是基本概念还没吃透。
7.3 每道题做完,用"一句话总结"沉淀套路
我坚持了很久的一个习惯是:每做完一道有代表性的题,在笔记里用一句话记录"这道题教会我的套路是什么"。比如:
- "合并两个有序链表:递归的终止条件是其中一个为空,然后比较头节点。"
- "二叉树层序遍历:队列+BFS,按层分组时先记录当前队列大小。"
- "最长递增子序列:dp[i]表示以i结尾的最长序列长度,O(n²);优化版用贪心+二分维护一个递增数组。"
这样积累下来,你的笔记就是一本"错题+套路"的私人宝典,面试前翻一遍,效果远好过从零开始刷题。
7.4 面试前的手撕代码清单
最后给一份我当年面试前反复手写练习的清单。这些题出现频率极高,掌握了它们,面试的算法关基本就稳了:
- 反转链表(迭代+递归两种写法)
- 判断链表是否有环(快慢指针)
- 合并两个有序链表/数组
- 二叉树前中后序遍历(递归+迭代)
- 二叉树层序遍历
- 求二叉树最大深度/最小深度
- 快速排序/归并排序手写
- 二分查找(包括查找左边界、右边界)
- 下一个排列
- 子集/全排列(回溯模板)
- 爬楼梯/斐波那契(入门动态规划)
- 打家劫舍/背包0-1(进阶动态规划)
- 三数之和(双指针)
- 最长回文子串(中心扩展或DP)
- LRU缓存(哈希表+双向链表)
这15个题背后覆盖了链表、树、排序、二分、回溯、DP、双指针、缓存设计八类核心考点。把它们吃透,其实很多变种题都是它们的"换皮"。
7.5 学完不要停:让算法成为你日常的一部分
我自己的体会是,算法和数据结构的学习不是"学一个月就完了"的事情,而是一个持续反复的过程。工作里遇到性能瓶颈、缓存设计、数据模型选型,我都会下意识地在脑子里过一道:这个场景用哈希表还是跳表?这个查询能不能用前缀树优化?把这些问题记下来,周末花半小时验证一下,久而久之,算法就不再是"面试前突击的东西",而成为你解决问题的本能。
最后再分享一个我最近在用的技巧:关注那些"面试不会考但工程里极有用"的知识点。比如B+树在数据库索引里的具体形态、跳表在LSM-Tree存储引擎里的角色、布隆过滤器如何用极小的内存判定"一定不存在还是可能存在"。这些点不在常规面试题清单里,但它们在真实系统的设计中出现频率极高。把这些冷门但实战的知识点补上,你的算法体系才算真正完整了。
