读过多年书,带过新人,也在线上给别人看过代码,发现一个非常有意思的现象:很多人写程序,真正卡住的不是语法,不是框架,而是“数据该怎么摆”。一次简单的查询优化,有人能把数组复制出三份来用;一个队列需求,有人硬是用栈实现了半天,结果是反的。所以每次有人问我,学了语言之后该学什么,我几乎都是同一句话:先把数据结构搞清楚。
这篇文章就是围绕“数据结构”这个话题来写的,适合三类人:正在准备期末复习或者考研 408 数据结构部分的学生;刚开始接触算法、刷题时总觉得思路不够清晰的初级开发者;以及想系统梳理一遍数据结构知识点、顺便补一补空间时间复杂度和常见排序查找细节的工作党。内容不追求教科书那种面面俱到,而是把最核心的脉络、常见的坑和我自己在实操中的体会讲清楚。
1. 数据结构到底在解决什么问题
很多人第一节课就被“数据结构是计算机存储、组织数据的方式”这句话劝退了,其实不必。你只需要记住一句话:数据结构是数据的收纳方案。
1.1 数据结构和收纳哲学
想象你家的衣柜。衣服可以叠起来放成一摞,可以挂成一排,可以按季节分抽屉,也可以随手扔床上。扔床上取用最快,但找起来最慢;挂成一排取用方便,但空间浪费;分抽屉收纳整齐,但是换季时要翻箱倒柜。
数据在内存里也是这个道理。数组就是把数据连续放在一排柜格里,链表则是每个柜格里放一个“下个柜子在哪”的纸条,栈是只能从最上面取放的箱子堆,队列是两头开口、一头进一头出的通道。不同场景下,数据的组织形式决定了增删改查的快慢,也决定了代码写起来顺不顺手。
学习数据结构的核心,不是背下每种结构有哪几种操作,而是建立“给我一个问题,我能判断这问题适合用哪种方式收纳数据”的直觉。选错了结构,后面写多少行代码都别扭。
1.2 别把逻辑结构、存储结构和运算混在一起
教材里喜欢把数据结构拆成三个维度:逻辑结构、存储结构、运算。这三个词看着抽象,其实很好理解。
逻辑结构解决的是“数据之间是什么关系”,也就是你从业务视角怎么看待这些数据。比如一个班级学生名单,是线性关系;公司的组织架构,是树形关系;地图上的几个城市以及之间的路,是图关系。
存储结构解决的是“数据在内存里怎么摆”。同样一个线性关系,你可以用一片连续的地址存,这就是顺序存储,也就是数组的样子;也可以用若干个不连续的节点,每个节点里存一个指针指向下一个节点,这就是链式存储,也就是链表的样子。
运算则是你在这个结构上能做的操作:插入、删除、查找、修改、遍历等等。同一个逻辑结构,换了不同存储结构,同样操作的实现难度和速度完全不一样。经典的比较就是,在数组中间插入一个元素,你要把后面所有元素都往后挪;在链表中间插入一个元素,只需要改两个指针。
这三个层次最大的意义在于:它让你在写代码时,清楚自己改的是什么层。比如用 Java 的ArrayList还是LinkedList,本质上就是选择存储结构;用什么判断条件去遍历,则是在定义运算。
1.3 数据结构与算法的关系没那么玄
“数据结构与算法”这门课常常被合在一起讲,因为两者确实分不开。数据结构是存储形式,算法是在这个存储形式上执行的步骤序列。很多时候,选定了一种数据结构,算法差不多就定了一半。
举个例子,你要实现一个“最近访问过的文件列表”,只需要保留最近 10 条,每次访问新的就把最老的挤掉。如果你用数组实现,删除头部元素需要把所有元素往前移;如果你用双端队列实现,头部弹出、尾部追加都是 O(1)。算法思路完全一样,但数据结构选了不同的那一个,性能差一个量级。
刷题时也常常有这种现象:题目读了三遍没思路,一旦想到“这题应该用哈希表”或者“这题应该用单调栈”,马上就能做出来了。这就是数据结构在发挥作用——它不直接告诉你答案,但它决定了你能用什么操作来组织和查询数据,从而决定了算法的复杂度。
2. 线性结构:从数组到双端队列的那些细节
线性结构是最贴近直觉的一类数据结构。数据一个接一个排成一条线,有头有尾。但“直观”不代表“简单”,线性结构里的细节和坑非常多。
2.1 数组和链表:连续与散装的取舍
数组和链表是线性结构里的两个基础代表。
数组的特点是内存连续、随机访问快。想拿到第 k 个元素,直接用首地址加上偏移量就能算出来,时间复杂度 O(1)。缺点是插入和删除中间元素代价大,因为要保持连续,必须移动一串元素。
链表的特点是内存不连续,每个节点里存数据和指向下一个节点的指针。插入和删除只需要修改指针,所以中间插入是 O(1)。但随机访问很头疼,想拿第 k 个节点,必须从头往后走 k 步,时间复杂度 O(n)。
很多人潜意识里觉得“链表一定比数组高级”,其实不对。它们只是适配不同场景:频繁按索引读取,就选数组;频繁在中部插入删除,就选链表。还有一些场景,链表的节点内存不连续,缓存命中率不如数组,实际运行速度反而不如数组,这也是为什么在很多底层库里“看似该用链表的地方”最后用了数组。
实操中还有一个很常见的拓展,就是循环链表和双向链表。循环链表让尾部节点指向头部,适合环形结构,比如轮播图、游戏里出牌顺序循环。双向链表每个节点有两个指针,支持从后往前走,但代价是每个节点多存一个指针,内存开销更大。LinkedList在 Java 里就是双向链表实现,所以在中间插入时它表现很好,但千万别拿它当普通数组那样频繁按下标访问。
2.2 栈和队列:两种操作受限的线性结构
栈和队列都是“操作受限”的线性结构。栈只能在栈顶插入和删除,也就是先进后出;队列只能在队尾插入、队头删除,也就是先进先出。
栈最典型的场景是函数调用、括号匹配、表达式求值、撤销操作。比如编译器检查括号是否匹配,遇到左括号就入栈,遇到右括号就出栈并检查是否匹配,如果最终栈是空的,说明括号是配对的。这个逻辑在刷题里特别常考。
队列最典型的场景是任务排队、消息队列、缓冲区。比如多个进程同时想使用打印机,打印机一次只能服务一个任务,那就排成队列,先来的先打印。
一个很容易被忽略的陷阱是:栈和队列并不一定非要用连续内存实现。用两个栈可以模拟一个队列,用两个队列也可以模拟一个栈。面试题里特别喜欢考这种“互相模拟”的问题,目的不是让你觉得“实现很妙”,而是让你理解数据结构本质上是“一种操作规则”,规则定了,实现方式可以灵活换。
2.3 双端队列:不要再只知道 push 和 pop
双端队列是线性结构里被严重低估的一个。它允许在队列两端都进行插入和删除操作,相当于栈加队列的合体。
我为什么单独拎出来说?因为很多接触编程一两年的人,提到队列只知道offer和poll,遇到需要“窗口内最大值”这类问题时就蒙了。
滑动窗口最大值问题是双端队列的经典应用:一个整型数组,窗口大小为 k,窗口每次向右移动一个位置,要求输出每个窗口里的最大值。暴力解法是每次扫描窗口内 k 个元素,时间复杂度 O(nk)。用双端队列维护一个“队头到队尾递减”的候选下标序列,每次窗口移动时,从队尾弹出比当前元素小的旧下标,从队头弹出已经滑出窗口的旧下标,队头就是当前窗口的最大值,整体复杂度降到 O(n)。
这个例子很能说明问题:双端队列不是数据结构里的“边缘角色”,它恰恰是解决某些特定问题的高效工具。C++ 标准库里的deque、Python 里的collections.deque,都是双端队列的实现。刷题时看到“两端操作”“窗口”“最近最小最大”这些关键词,第一反应就应该是它。
2.4 线性结构的代码实现视角
学线性结构,最容易踩的坑是“全背理论,不会写”。我的建议是,一定要用自己熟悉的语言把数组、链表、栈、队列、双端队列都亲手实现一遍。
拿链表来说,很多人学会理论,写代码时依然会在“空指针”上翻车。比如在头部插入节点时,忘记把新节点的next指向原来的头节点;在删除节点时,忘记更新前一个节点的next。所以我会让初学者先画图,画出插入、删除前后的指针变化,代码照着图写,错了也能很快发现问题。
用 Python 写链表时,还会遇到一个特有的现象:Python 里的“引用”概念比 C 语言里的“指针”抽象,但本质仍然是把对象地址存在变量里。所以写cur = cur.next时要知道,这里的cur只是移动了指针,不会自动修改原链表。很多人在这上面犯糊涂,就是因为没有把“变量存的是引用”这件事想清楚。
3. 非线性结构:树、图、哈希表
线性结构之外,还有一类数据结构中的数据之间存在“分叉”或“多对多”关系,这就是非线性结构。
3.1 二叉树:递归思想最好的教科书
二叉树是非线性结构里最常用的一种,每个节点最多有两个子节点,也就是左孩子和右孩子。它为什么重要?因为很多实际问题可以被抽象成树,而树的递归结构非常适合解决和状态有关的问题。
常见二叉树类型有几个,必须分清:
- 满二叉树:每一层节点都完全填满。
- 完全二叉树:除了最后一层外,每层都是满的,最后一层的节点从左往右连续排列。堆就是一种完全二叉树。
- 二叉搜索树(BST):左子树所有节点小于根节点,右子树所有节点大于根节点。它的查找效率在平衡情况下是 O(log n),但如果插入顺序不当,可能退化成一条链表,复杂度变成 O(n)。
- 平衡二叉树(AVL 树):在 BST 基础上加了平衡性约束,左右子树高度差不超过 1,防止退化。
- 红黑树:一种近似平衡的二叉搜索树,性质略微放松了一些,但依然能保证最坏情况 O(log n)。Java 的
TreeMap、C++ 的map底层就是它。 - 堆(优先队列):本质上是一棵完全二叉树,堆顶是最大或最小值,适合实现“取最值频繁”的场景。
二叉树遍历是高频考点。先序、中序、后序是递归顺序不同:先序是根左右,中序是左根右,后序是左右根。层序则是按层从上往下、从左往右。中序遍历一棵二叉搜索树时,结果是一个递增序列,这个性质在不少题目里可以直接用。
我自己的体会是,别轻视“前中后序”这段内容。理解了遍历的递归写法之后,很多高级算法比如 DFS(深度优先搜索)、回溯、动态规划里的递归思维都会顺畅很多。树其实是把“递归”这个概念具象化的结构。
3.2 图:最贴近真实世界的数据模型
图是由顶点和边构成的结构,边可以表示“关系”。地图里的道路、社交网络里的好友关系、网页之间的链接,都可以抽象成图。
图分有向图和无向图,带权图和不带权图。存储方式主要是两种:邻接矩阵和邻接表。邻接矩阵是一个二维数组,直观但稀疏时浪费空间;邻接表是每个顶点挂一条链,存与它相邻的点,省空间但不方便快速判断两个点之间是否有边。
图的遍历也是两类:深度优先搜索(DFS)和广度优先搜索(BFS)。DFS 是“一条路走到黑,再回头”,BFS 是“一圈一圈往外扩”。最短路径问题里,最基础的算法是 Dijkstra,核心思路是维护一个“当前已确定最短距离”的集合,不断用松弛操作更新其他点的距离。
图在考研 408 里属于难度较高、计算量大的部分,但实际工程里也极其常用。像地图导航、推荐系统、路径规划,底层都有图的身影。
3.3 哈希表:用空间换时间的典型样本
哈希表也叫散列表,它通过一个哈希函数把键映射到数组下标,从而在 O(1) 平均复杂度内完成查找、插入、删除。
但这个 O(1) 是有条件的:哈希函数要把数据均匀地散开,并且要处理好冲突。哈希冲突的常见解决办法有:
- 链地址法:相同哈希值的数据挂一条链表。
- 开放定址法:发生冲突时,在数组里找下一个空位。
- 再哈希法:用另一个哈希函数计算新位置。
哈希表不是万能的,它最怕两件事:一是哈希函数选得不好,导致数据一堆一堆地挤在同一个桶里,平均复杂度退化到 O(n);二是负载因子过高,触发扩容,一次扩容可能是 O(n) 的操作。
还有一个很容易混淆的点:哈希表存储时是无序的。如果某个需求要求“按插入顺序读取”或者“按键排序”,就别只用普通哈希表,而是考虑带顺序的哈希表,比如 Python 3.7 之后dict默认保持插入顺序,但这不是所有语言所有版本的默认行为。刷题看到“查找某个值是否存在”“统计某个元素出现次数”这类问题时,哈希表通常是默认选择。
4. 复杂度分析:数据结构与算法的统一标尺
学数据结构的时候,如果只看“这个结构有什么操作”而不看“这些操作的代价”,等于白学。复杂度分析就是衡量代价的标尺,也是期末考试和考研 408 里必考的内容。
4.1 时间复杂度到底怎么算
时间复杂度不是统计代码跑了多少秒,而是描述算法执行时间随输入规模增长的趋势。它用大 O 记号表示,例如 O(1)、O(log n)、O(n)、O(n log n)、O(n²)、O(n!)。
一个常见误区是,把大 O 当成“运行时间”。其实它只是一个量级估计,用来比较“当数据规模变大时,谁的增速更慢”。
算时间复杂度的基本方法是找“基本操作执行次数”和“输入规模 n”的关系,然后去掉常数项和低阶项,保留最高阶项。比如两层循环嵌套,每一层都是 n 次,总次数就是 n×n,去掉系数后是 O(n²)。如果一层循环每次都把规模减半,那就是每次迭代 n 变 n/2,循环次数是 log n,如果是只做这么一件事且每步 O(1),整体就是 O(log n)。
还有一种容易被带偏的题是“三重循环,但内层循环次数不是 n”。比如:
for i in range(n): for j in range(i): print(i, j)这个次数是 0 + 1 + 2 + ... + (n-1) = n(n-1)/2,所以时间复杂度是 O(n²)。很多人一看“三层 for”脱口而出 O(n³),其实是没数清楚实际次数。
4.2 空间复杂度与“原地”的真相
空间复杂度描述算法运行时所需要的额外内存随输入规模增长的趋势。它统计的是在输入数据之外额外开辟的空间。比如在原数组上交换元素,这种只使用 O(1) 额外空间的算法,通常叫“原地算法”。
空间复杂度不是越小越好,关键是“用空间换时间还是用时间换空间”的取舍。哈希表就是空间换时间的典型例子;递归则是反例,虽然代码好看,但递归调用的每一层都会占用栈空间,所以递归深度高的时候空间复杂度会到 O(n),甚至可能导致栈溢出。
写刷题代码时,很多人只盯着时间复杂度,忽略了空间复杂度。比如一个题目如果要求“不要使用额外的数组空间”,你还开一个新的数组存结果,哪怕结果对,也并没有完全满足题意。这也是面试官比较看重的一点:数据结构选择背后的空间代价,你要说得出来。
4.3 常用排序和查找的复杂度对比
排序算法是期末复习和面试里的高频重灾区。我先把最常考的几种按复杂度整理成一张表。
| 排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n^1.3) 左右 | — | — | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
这张表里最容易引起争议的是快速排序的最坏情况:当每次划分都极端不平均时,复杂度退化成 O(n²)。为什么会这样?因为快速排序的时间复杂度取决于划分的平衡程度,最坏情况下输入已经有序且每次选的基准都是最大或最小,那么递归深度变成 n,自然就是 O(n²)。所以实际工程中,快排通常要加随机化基准或三数取中。
查找方面,最基础的顺序查找是 O(n),对有序数组的二分查找是 O(log n),哈希表查找平均是 O(1),二叉搜索树查找在平衡时是 O(log n)。所谓“查找”不问条件直接说复杂度都是耍流氓,一定得先说明数据是什么结构、是否有序。
5. 学习路线和复习策略:从课堂到实验报告再到考研
数据结构这门课说难不难,说简单也不简单。很多人期末突击失败,不是因为智商不够,而是因为学习方法过于陈旧。
5.1 把理论和实验报告串起来
大学里面,“数据结构实验报告”是一个经典环节。常见实验内容包括:实现一个单链表并完成插入、删除、反转;用栈判断括号匹配;写一个二叉树的递归遍历;实现快速排序和归并排序并比较不同数据规模下的耗时。
写实验报告时最容易出现的问题是“代码能跑就行”的思维。实验报告如果只贴一段代码加一张运行截图,等于没做。正确的打开方式是:
- 描述你选择的存储结构是什么,为什么选这个。
- 画出关键操作前后的结构变化,尤其是链表插入删除和二叉树递归调用过程。
- 分析时间复杂度、空间复杂度,最好加上不同数据规模下的测试数据对比。
- 记录你遇到过的问题,比如越界、空指针、死循环,以及排查过程。
这些内容写清楚之后,你会发现自己对知识点的理解深度远超只看代码和截图的人。实验报告本身就是在倒逼你梳理数据结构重点。
5.2 用什么语言入门数据结构更合适
数据结构课的经典配置是 C 语言,因为 C 可以清楚地看到内存和指针,理解“存储结构”最真实。我自己也是从 C 语言链表开始的,虽然被malloc和free折磨,但正是这种折磨让我理解了“节点是一个内存块、指针是地址”这个底层图景。
如果你不想用 C,C++ 也可以,STL提供了现成的容器,比如vector、list、stack、queue、deque、map、set,在学习阶段可以先借助容器感受不同结构的差异,再尝试自己实现一遍核心功能。
Python 也是一种很好的入门语言。它的劣势是封装太方便,初学者容易只知道用list,却不理解列表底层其实是动态数组。它的优势是写起来快,可以把注意力集中在算法逻辑上。特别提醒一点:Python 里list不适合做队头操作,因为pop(0)和insert(0, ...)是 O(n) 的,应该用collections.deque。
数据处理相关的朋友还会遇到pandas里的“数据结构”概念。Series是一维结构,DataFrame是二维结构,MultiIndex是多级索引结构。这些虽然不是传统数据结构课里的“链表二叉树”,但它们同样是数据组织方式,理解它们背后的索引逻辑,会帮你更高效地做数据筛选和聚合。
5.3 期末复习和 408 考研怎么抓重点
如果你正在准备期末复习,我建议按下面这个顺序安排时间:
- 第一优先级:复杂度分析、线性表的顺序存储与链式存储、栈和队列的操作特性、二叉树遍历、排序算法复杂度。
- 第二优先级:图的基本存储和遍历、最小生成树、最短路径、查找算法的实现。
- 第三优先级:堆、哈希冲突处理、B 树、红黑树的定义和性质。
如果是准备 408 考研,数据结构部分更讲究“概念辨析”和“计算题”。常考的点包括:完全二叉树的节点编号关系、二叉排序树构造、哈希表平均查找长度计算、图的最小生成树与最短路径手算过程、各类排序算法在特定情况下的比较次数。这些题目需要的是“动手算”,光背课本是没用的。
很多人在复习时有一个误区:只刷选择题,不做大题。实际上,数据结构的大题最喜欢考“给一个场景,让你选数据结构并说明理由”。这类题会做的前提,是你真正理解每种结构的优势和局限,而不是单纯背出“链表是链式的”。所以我在复习时的方法很朴素:每学完一个结构,就在纸上写一遍“它适合解决什么问题,不适合解决什么问题,为什么”,写多了自然形成条件反射。
6. 常见问题与踩坑记录
下面这些坑,我自己踩过,也看别人踩过,几乎年年出现。
6.1 把栈当队列用
很常见的需求是“按顺序处理任务,先来先处理”,结果有人用栈来实现,处理顺序就完全反了。排查半天,不是算法写错,是数据结构选反了。
记住一条判断规则:如果一个数据处理完,顺序要和进来时一致,选队列;如果顺序要和进来时相反,选栈。括号匹配、表达式求值是栈;日程调度、消息处理是队列。如果涉及“两端都能进出”,选双端队列。
6.2 链表操作的空指针问题
第一次写链表删除操作,十个人里九个会在删除头节点时崩。原因是删除头节点时,新的头节点可能不存在,或者没有把原来的头节点指针更新到下一个节点。
写链表代码的正确姿势是先画图。四个节点画出来,标出要删除的节点、它的前驱、它的后继。然后分情况:空链表、删头、删尾、删中间。每写一步都问一遍“如果这个节点是最后一个怎么办”。这个习惯养成以后,链表题基本不会再翻车。
6.3 只算时间复杂度不写空间复杂度
期末卷面或者面试里,经常有人分析完时间复杂度就停了,完全不提空间复杂度。这是扣分重灾区,至少说明对结构的理解不完整。
比如归并排序时间复杂度 O(n log n),但空间复杂度是 O(n),因为它需要额外的临时数组。比如递归实现树的先序遍历,空间复杂度是 O(log n) 到 O(n),取决于树的深度。这些数字都要能说出来,才算真的理解了这个算法。
6.4 把“平均复杂度”和“最坏复杂度”混为一谈
哈希表平均查找是 O(1),但如果哈希函数选得很差,或者冲突处理不当,最坏情况可能是 O(n)。快速排序大部分时候很快,但最坏是 O(n²)。写题时如果面试官问复杂度,最好主动把平均、最坏分情况说清楚,这样反而显得你对复杂度有深入理解。
6.5 不知道“有序”对算法意味着什么
很多查找算法依赖“数据有序”这个前提。数组一旦有序,就可以二分查找;二叉搜索树本质上也是在利用“左小右大”的排序性质。如果你的数据本身无序,又不打算排序,那哈希表通常比二分查找更现实。
我实际写代码时有个习惯:拿到需求,先问三句话——数据规模多大?读多还是写多?有没有顺序要求?这三个问题想明白了,应该用数组、链表、哈希表、树还是图,基本就八九不离十了。数据结构这门课到后期,考察的不是背诵,而是这种“条件反射式的选型判断”。希望这篇梳理能帮你把零散的知识点串成一条线,再遇到这类问题时不慌。