学习数据结构和算法的框架思维
本文是我对数据结构和算法学习方法的总结,也是本站所有教程的纲领。主要包含两部分:第一,我对数据结构和算法本质的理解;第二,概括几种常用的算法思想。
全文没有复杂的代码,都是我的经验和思考,希望能帮你少走弯路,更透彻地掌握算法。

一、总结一切数据结构和算法
各种数据结构的底层存储方式,无非就是数组(顺序存储)和链表(链式存储)两种。
数据结构的关键操作在于遍历和访问,也就是增、删、查、改。
- 种种确定性算法的核心,本质上都是某种形式的枚举(遍历)。 聪明算法和暴力算法的区别在于——能否避免重复或无效的枚举。
枚举的关键在于:不遗漏(把所有可能都考虑到)和不冗余(不重复做无用功)。掌握好算法框架,就能做到不遗漏;充分利用已知信息,就能减少冗余。
如果你能真正理解上面这几句话,那么本文就可以不用看了。如果不理解,那就让我用下面的几千字(以及后面的例题和练习)来慢慢解释。在学习过程中,你可以反复回味这两句话,会大大提高效率。
二、数据结构的存储方式
数据结构的底层存储方式只有两种:数组(顺序存储)和链表(链式存储)。
你可能会问:那哈希表、栈、队列、堆、树、图等等这些数据结构呢?
我们分析问题要像写递归一样,自顶向下,从抽象到具体。上面列出的那 些都是“上层建筑”,而数组和链表才是“地基”。因为那些丰富的数据结构,本质上都是在链表或数组之上施加特殊操作、定义不同 API 的结果。
- 队列、栈:既可以用数组实现,也可以用链表实现。用数组实现需要处理扩容和缩容;用链表实现则没有扩容问题,但每个节点需要额外的指针存储空间。
- 图:两种常见存储方式——邻接表(本质是链表)和邻接矩阵(本质是二维数组)。邻接矩阵判断两点是否相连很快,还支持矩阵运算,但稀疏图时浪费空间;邻接表更节省空间,但某些操作效率不如矩阵。
- 哈希表:通过散列函数把键映射到一个大数组里。解决冲突的常见方法中,“拉链法”需要链表来串联冲突元素,操作简单但需要额外指针;“线性探测法”完全依赖数组的连续空间,不需要指针,但操作稍复杂。
- 树结构:用数组实现就是“堆”,因为堆是一棵完全二叉树,用数组存储不需要额外指针,经典应用是二叉堆;用链表实现就是普通的树结构(如二叉搜索树、AVL 树、红黑树等),因为不一定是完全二叉树,所以用数组存储不合适。
总结一下数组和链表的优缺点,记牢这对你以后选择数据结构很有帮助:
| 存储方式 | 优点 | 缺点 |
|---|---|---|
| 数组 | 连续存储,支持随机访问(通过索引 O(1) 时间找到元素),相对节约空间 | 扩容麻烦(需要重新分配一整块更大的空间并复制所有元素,O(N) 时间);在中间插入/删除需要搬移后续元素(O(N) 时间) |
| 链表 | 没有扩容问题;知道前驱/后继时,插入和删除只需改变指针,O(1) 时间 | 不能随机 访问(只能从头遍历);每个元素需要额外空间存储指针 |
三、数据结构的基本操作
任何数据结构的基本操作,本质上就是“遍历 + 访问”,具体就是增、删、查、改。
不同数据结构存在的目的,就是在不同场景下尽可能高效地完成这些操作。这就是数据结构的使命。
如何遍历和访问?从最高层看,无非两种形式:线性和非线性。
- 线性遍历:用
for/while循环(迭代)。 - 非线性遍历:用递归。
下面是最基本的代码框架(以 Java 为例,但思想通用)。
数组遍历(线性)
void traverse(int arr[], int len) {
for (int i = 0; i < len; i++) {
// 访问 arr[i]
}
}
### 链表遍历(兼具迭代和递归)
```java
class ListNode {
int val;
ListNode next;
}
// 迭代方式
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
// 访问 p.val
}
}
// 递归方式
void traverse(ListNode head) {
if (head == null) return;
// 访问 head.val
traverse(head.next);
}
二叉树遍历(非线性递归)
class TreeNode {
int val;
TreeNode left, right;
}
void traverse(TreeNode root) {
if (root == null) return;
traverse(root.left);
traverse(root.right);
}
你看,二叉树的递归遍历和链表的递归遍历是不是很像?二叉树和单链表的结构也类似(一个节点有两个指针,一个节点有一个指针)。如果再把指针数量增加,就变成 N 叉树:
class TreeNode {
int val;
TreeNode[] children;
}
void traverse(TreeNode root) {
if (root == null) return;
for (TreeNode child : root.children) {
traverse(child);
}
}
N 叉树的遍历再进一步就可以扩展到图的遍历。图可以看作多棵树的组合,只是可能出现环。环的问题很好解决:用一个 visited 布尔数组记录已经访问过的节点即可(具体在图遍历章节会讲)。
所谓“框架”,就是固定的套路。 不管你要做什么增删查改,以上这些遍历结构都是无法脱离的骨架。你可以把这个骨架当作大纲,然后根据具体问题往里添加自己的代码。
四、算法的本质
如果只让我说一句话,我会说:计算机算法(指我们刷题面试中遇到的算法)的本质就是“枚举”。
肯定有人要反驳:难道所有算法问题都是枚举吗?没有例外吗?
例外的确存在,比如某些“一行代码就能解决”的脑筋急转弯题,它们靠的是观察规律,不需要枚举。但这些题数量很少,不必纠结。另外,像密码学算法、机器学习算法,它们的本质是数学原理的编程实现,这类算法不属于我们讨论的“数据结构与算法”范畴。
这里顺便澄清一个常见误解:“算法工程师”做的“算法”和“数据结构与算法”中的“算法”完全是两回事。
- 算法工程师的“算法”更偏向数学建模和调参,计算机只是计算工具。
- 我们学习的“数据结构与算法”更注重计算机思维:如何抽象实际问题、选择合适的数据结构、写出高效的代码。
所以,不要以为学好数据结构与算法就能当算法工程师,也不要以为不当算法工程师就不用学数据结构与算法。坦白说,大部分开发岗位不常直接用到底层算法,但技术岗位的笔试面试几乎都会考察这部分知识,因为它是公认的程序员基本功。
为了区分,我们可以把算法工程师研究的称为“数学算法”,把刷题面试中常用的称为“计算机算法”。本文只关心后者。
计算机的思维其实很简单:计算机最大的特点就是快。你的大脑一秒钟转几圈,CPU 一秒钟能转几亿圈。所以计算机解决问题的方式往往就是——枚举。
举个例子:让你计算一个数组中的最大值,你怎么做?只能用 for 循环遍历每一个元素,找出最大的。没有更好的办法了。这就是枚举,这就是算法。
我刚入门时也有误解,总觉得算法应该像数学公式一样,能“啪”的一下算出答案。比如写一个排列组合算法,别人可能以为我发明了一个公式,能直接输出所有排列。但实际上呢?我只是用回溯算法把整个排列树遍历了一遍,收集所有叶子节点的结果而已。
数学题的思维通常是:找规律、列方程、推导答案。如果你用枚举去做数学题,很可能思路错了。但计算机解决问题的思维恰恰相反:如果你找不到巧妙的数学公式,那就枚举吧!只要复杂度能接受,没有枚举解决不了的问题。
理论上,你不断地随机打乱一个数组,总有一天会得到有序的结果——但这显然不是好算法,因为它可能运行到宇宙末日。
现在技术岗笔试面试考的那些算法题,求最小值、求最优解,本质上就是:把所有可能的解枚举出来,然后找出最值。就这么点事。
五、枚举的难点
你可能会觉得枚举很容易,但事实上它有两个关键难点:
- 无遗漏:不能漏掉任何一个可能的解,否则答案就错了。
- 无冗余:不能重复计算相同的东西,否则算法会变慢,可能超时。
- 为什么会遗漏? —— 因为你没有掌握正确的枚举框架,不知道怎么 把问题转化成代码。
- 为什么会冗余? —— 因为你没有充分利用信息,做了很多重复劳动。
看到一道算法题时,你可以从这两个维度思考:
- 如何枚举? —— 保证不遗漏地找出所有可能解。
- 如何聪明地枚举? —— 尽量避免冗余,用最少的资源找到答案。
5.1 难点一:如何枚举
这类问题通常涉及递归,比如回溯算法和动态规划。
回溯算法:比如排列组合问题。你在草稿纸上可以手动列举:先固定第一位,再固定第二位……但要把这个过程写成代码,需要先把问题抽象成一棵树,然后精确地遍历这棵树的所有节点,不能多也不能少。这正是回溯算法框架要做的事。
动态规划:它比回溯更抽象,因为它不是“遍历”的思维,而是“分解问题”的思维。
什么叫分解问题?举个例子:你面前有一棵树,问树上有多少片叶子?
- 遍历思维:顺着树枝一片一片去数。
- 分解思维:整棵树的叶子数 = 左子树的叶子数 + 右子树的叶子数。然后左子树的叶子数又可以继续分解,直到空树(0片)或单节点(1片)。
动态规划就是这种“分解问题”的思维。很多同学觉得动态规划难,是因为他们第一步“状态转移方程”(即如何把大问题分解成小问题)就写不出来。我在《动态规划核心框架》中会详细讲解,其实可以用数学归纳法来思考。