
数据结构这个东西不少初学者一上来就被各种概念砸晕链表、栈、队列、树、堆、哈希表满天飞再加上KMP、Trie、并查集这些听起来就很高端的名字很容易产生“我是不是不适合学编程”的错觉。但如果你把这些内容放在“解决特定问题的工具”这个角度看一切都会变得清晰很多。这一讲我们就来完整拆一遍数据结构的核心内容不玩虚的把每个结构背后的思路、适用场景、实现要点和常见的坑都讲清楚。不管你是在准备考研408、软考还是在刷算法题或者工作中需要补基础这份梳理都能帮你把这些零散的知识点串成一张网。1. 整体设计与学习路线解析为什么这些结构要放在一起学1.1 数据结构的内在逻辑分类明白为什么要先学链表再学栈和队列其实也就理解了这一讲的编排逻辑。我们从数据结构的操作特征来看它本质上解决的就三类问题存取方式、查找方式和组织方式。单链表和双链表解决的是“动态存数据灵活增删”的问题栈和队列解决的是“数据按什么顺序被处理”的问题单调栈和单调队列解决的是“在特定顺序约束下快速找极值”的问题KMP和Trie解决的是“字符串匹配和前缀查询”的问题并查集解决的是“元素分群和连通性”的问题堆解决的是“动态维护最值”的问题哈希表解决的是“快速映射查询”的问题。这么一拆你会发现这些结构彼此之间不是孤立的而是层层递进的。链表是很多结构的地基栈和队列的链式实现就靠它单调栈和单调队列只是在普通栈和队列上加了“单调性约束”KMP用到的前缀函数思维和Trie树的逐层匹配思路有相通之处并查集里的路径压缩和堆里的向下调整本质上都是优化树的深度。这些联系如果不打通你会觉得每个算法都是一个新的故事但打通之后数据结构在你的脑子里就变成了一张可以互相引用的地图。1.2 学习顺序和精力分配建议我给初学者和备考者的建议顺序是数组和链表 → 栈和队列 → 堆 → 哈希表 → 并查集 → Trie → KMP → 单调栈/单调队列。前面的结构是基础后面的结构很多时候是“组合拳”。比如单调栈可用来解决“柱状图中最大矩形”但如果你对普通栈的压入弹出逻辑都不熟谈单调性就更是空中楼阁。精力分配上线性表、栈、队列、链表这些属于必须完全掌握的“保命内容”堆、哈希表、并查集、Trie属于高频考点的“核心区”KMP和单调栈/单调队列属于难度较高但区分度大的“拉分区”。从历年考研408的情况来看选择题中数据结构的比重长期稳定代码大题也往往围绕树和图的遍历展开而树的遍历又依赖栈和队列这也就是为什么基础结构不能掉以轻心的原因。2. 线性结构核心解析单链表、双链表、栈、队列的实现与细节2.1 链表的两种实现方式与头节点问题链表是一个老生常谈但非常容易出细节问题的结构。单链表的实现方式有两种一种是用结构体加指针去“真”动态创建节点另一种是用数组去模拟链表。竞赛和算法题里我更推荐数组模拟因为它的常数小、速度快而且从逻辑上更容易把控。你需要定义两个数组一个存值e一个存下一跳的指针ne然后用一个变量idx表示当前用到了哪个节点。这种方式无论是考试手写还是实际使用都比动态new更稳。头节点的问题值得单独说。很多人刚学链表时总搞不清要不要用虚拟头节点dummy node。其实判断标准很简单你需不需要统一处理头节点的插入删除。如果直接用真实头节点的指针那么删除头节点和删除中间节点的代码逻辑是不一致的到头来还得特判。而引入一个不存数据的头节点之后所有增删操作的代码路径都能统一这对考试的代码题尤其重要。我自己在带学生时都直接建议他们默认加虚拟头节点省得考场上因为特判而卡壳。双链表在手写时比单链表要更小心因为多了一条前驱指针。你需要在操作时严格保证次序比如在第k个节点右侧插入通常写成四步新节点右指针指向当前节点的右节点新节点左指针指向当前节点当前节点的右节点的左指针指向新节点当前节点的右指针指向新节点。这个顺序如果乱了就会出现指针悬空或者丢链的情况。实际写的时候建议按固定的步骤来不要每次都临场推导。2.2 栈和队列顺序实现与循环队列的细节栈的考点基本是“先进后出”特性的应用代码实现相对简单一个数组加一个top指针就能搞定。要注意的是不同教程里对top指针的定义不一致有的初始化成0有的初始化成−1。判断空栈的条件也因此不同。这倒不要紧要紧的是你在同一套代码里保持一致的约定千万别一会儿空栈条件是top−1一会儿又按top0去判断这种低级的逻辑冲突最伤分。循环队列是队列这章最容易出错的重点。如果是用数组模拟队列普通队列的尾指针达到数组长度就要出问题而循环队列通过取模运算让back指针回到起点实现空间的复用。实现时要注意牺牲一个存储单元来区分队空和队满队空条件fronttail队满条件(tail1)%maxSizefront。这个“牺牲一格”的技巧在考研选择题里简直是常客一定要把这两个条件刻在脑子里。对于链式队列做法就是用单链表维护一个头指针和一个尾指针入队插到尾部出队删掉头部。考试代码题一般不会让你写完整的链式队列但二叉树的层次遍历那个经典题的队列实现其实就是链式队列思想的简化版所以这个基础还是要打好。2.3 常见错误与应试注意事项链表和队列的手写题“断链”问题排名第一。特别是在单节点插入时很多人先改了当前节点的next再去找下一个节点结果找不到后续节点了。操作原则就一句话先把新节点的关系搭好再去动原有节点的指针。队列、栈题目中还有一个高频低级错误就是在操作前不检查空。栈空还要弹栈、队列空还要出队在实际工程中会直接崩溃在考试里判卷虽然不会真跑但代码审查时很容易扣分。所以无论你是为了考试还是写工程都建议养成一个好习惯涉及pop、top、front这类操作先判断结构是否为空。3. 单调栈与单调队列从“暴力解法”到“高效极值维护”3.1 单调栈的应用场景和实现思路单调栈本质上就是维护一个元素值单调递增或递减的栈。它解决的问题通常带有“找左边/右边第一个比当前元素大/小的位置”这种特征。如果不用单调栈很多同学会写两层循环去暴力扫描时间复杂度O(n^2)在数据量大时必然超时而单调栈能做到O(n)这就是它的价值所在。这里结合一个经典的“每日温度”例子来讲。给定每天的温度列表要返回一个列表每个位置表示要等多少天才能等到更暖和的温度。暴力的做法是对于每个位置往后扫最坏O(n^2)。用单调栈的思路我们维护一个从栈底到栈顶温度递减的栈遍历每天的温度当新温度比栈顶温度高时就说明栈顶那一天等到了更暖和的天弹出并计算天数差直到新温度不再高于栈顶。这个过程每个元素最多入栈一次、出栈一次所以整体O(n)。3.2 滑动窗口与单调队列单调队列最经典的应用就是滑动窗口最大值问题。比如数组长度n8窗口大小k3要求输出每个窗口的最大值。暴力做法每次扫描窗口内k个元素复杂度约O(n×k)而单调队列可以把每个窗口最大值的维护降到均摊O(1)。实现思路是这样的用双端队列存储数组下标保证这些下标对应的值是单调递减的。遍历数组时先把窗口外的下标从队头弹出然后把新元素从队尾加入加入前如果发现队尾的值比当前元素小就把队尾持续弹出因为这些旧值在滑动窗口后续的所有阶段里都不可能再成为最大值了。这样每次队头就是当前窗口的最大值。代码写起来也不复杂但要特别注意队列里存的是下标而不是值这样你才能判断元素是否已经滑出窗口。3.3 这里容易踩的坑用单调栈时最常见的坑是边界处理。比如“接雨水”问题和“柱状图中最大的矩形”问题一个需要从两个方向扫描一个需要在数组尾部添加哨兵节点。不添加哨兵的话很容易在循环结束后还有元素留在栈里导致结果计算不完整。这就是一个经验问题写多了之后你会形成条件反射如果题目需要计算位置差就问自己最后一个元素有没有被处理。单调队列的坑主要在窗口下标判断上。很多人写着写着就忘了队列里下标的含义误把值拿来直接和窗口左边界比较导致错误。建议在代码里写清楚窗口左边界right - k 1然后弹出所有小于这个值的队头下标。4. 字符串与集合的利器KMP、Trie和并查集的原理要点4.1 KMP算法next数组的计算是关键KMP算法是一个用来在长文本中高效查找模式串的算法。它的核心思想是利用部分匹配信息在匹配失败时只回退模式串而不是回退文本串。很多人学KMP时被next数组劝退其实它没那么玄next[i]的含义是“模式串中前i个字符组成的子串的最长相同前缀后缀的长度”。我们来看一个具体的例子模式串“ABABAC”。逐步算next数组一般以从下标0开始或1开始两种规范不同教材约定不同手写时需要注明对模式串前一个字符构成的子串“A”没有真前后缀next为0。前两个字符“AB”前缀A后缀B不相同next为0。前三个字符“ABA”前缀“A”等于后缀“A”长度为1next为1。前四个字符“ABAB”前缀“AB”等于后缀“AB”长度为2next为2。前五个字符“ABABA”前缀“ABA”等于后缀“ABA”长度为3next为3。完整串“ABABAC”要找最长前缀后缀比如“A”和“C”不相同“AB”和“AC”不相同继续往前发现“ABA”和时“BAC”也不相同所以next为0。求next数组的代码实现是有递推关系的关键在于如果当前字符不匹配就把候选长度回退到next[候选长度−1]。这个回退逻辑不少人写不对建议多手推几次把“回退到更短但依然是前后缀”的过程想明白。KMP匹配过程的复杂度是O(nm)n是文本串长度m是模式串长度这在处理大文本字符串匹配时非常高效。考研和面试题中关于KMP的考察其实很少让你从头默写整套代码更多的就是给你一个模式串让你算next数组或者考察失配时移动位数是多少所以把next数组的物理含义吃透比背代码模板重要得多。4.2 Trie树空间换时间的前缀查询良方Trie树也叫前缀树或字典树是一种多叉树结构它的每一个节点代表一个公共前缀根节点不存字符从根到某个节点路径上由字符连成字符串。一个非常典型的应用场景是给你若干个单词再给你一系列查询每次问某个单词或者某个前缀是否存在。用哈希表存储确实可以O(1)地判断完整单词是否存在但想统计“有多少个单词以某个前缀开头”就没那么高效了而Trie天然适合这种前缀相关的操作。Trie的节点设计并不复杂经典实现是每个节点维护一个长度为26的指针数组如果只考虑小写英文字母再加一个计数标记表示有几个单词以该节点结尾。当然在实际工作中字符集往往不只是小写字母那么节点用哈希表来存子节点也很常见。本质上Trie就是把“前缀”这种隐性的公共信息显性地存储起来。这里的空间开销值得点评一下。很多初学者担心26叉树会不会太浪费确实如果字符集合很大且单词很少Trie的空间利用率不高。但在通用场景下这种“空间换时间”的权衡是值得的而且实际工程中也有压缩Trie、双数组Trie等变体来优化空间。作为基础学习先掌握标准Trie的实现和它的前缀统计思想就够了。4.3 并查集路径压缩与按秩合并并查集解决的是集合的合并与查询问题比如判断两个节点是否在同一个连通分量里或者动态地把两个集合合并在一起。它的经典应用包括判断图中是否有环、最小生成树的Kruskal算法、“朋友圈”问题、岛屿数量等。并查集的核心就两个操作find找根与union合并。朴素实现就是一个数组parent记录每个元素的父节点。合并时把一棵树的根节点指向另一棵树的根节点。但如果不加优化树可能会退化成一条链查找就成了O(n)。所以两个经典优化必须掌握。第一个是路径压缩。在find过程中把路径上经过的所有节点直接指向根节点。这样下次再查它们的时候一步就能到位。代码实现非常简短递归写法一行核心逻辑就够用了。第二个是按秩合并。引入一个rank数组记录树的高度合并时总是把矮树合并到高树上去避免树过深。如果把路径压缩和按秩合并一起用单次操作的均摊复杂度可以近似认为是O(α(n))α是反阿克曼函数增长极其缓慢在实际数据规模下基本可以认为是常数。初学者在写并查集时的低级错误就是忘了初始化的步骤如果把每个节点的parent都初始化为0那么find的时候会递归循环退出不了或者找错根。正确的初始化是parent[i]i每个节点最开始都是自己的根。这个虽然是细节但真能让一个看似简单的题全军覆没。5. 堆与哈希表动态最值与快速映射的实现与权衡5.1 堆的原理用完全二叉树维护最值堆并不神秘它就是一个完全二叉树并且满足堆序性大根堆里任意节点的值都大于等于它的子节点小根堆里任意节点的值都小于等于它的子节点。因为完全二叉树可以用数组连续存储而不浪费空间所以堆的实现通常基于数组下标从1开始的话节点i的左孩子下标是2i右孩子下标是2i1父节点下标是i/2。堆的核心操作有两种构建方式向上调整和向下调整。向堆中插入元素时先把元素放到数组末尾然后不断和父节点比较必要时交换也就是向上调整。弹出堆顶元素时先把堆顶和数组末尾元素交换然后删除末尾元素再从根节点开始向下调整恢复到堆序性。删除任意元素可以理解为先做替换再做调整只需要在代码里把逻辑抽象出来即可。一个很重要的考点是“建堆的时间复杂度”。如果从一个空堆开始逐一向里插入n个元素总复杂度是O(n log n)。但如果你已经有一个数组想把它原地调整为堆可以从最后一个非叶子节点开始逐个向下调整这一步的复杂度是O(n)不是O(n log n)。这个结论会在算法题里用到排序算法里的堆排序就是先O(n)建堆再每次弹出堆顶O(log n)共n次总复杂度O(n log n)。5.2 哈希表哈希函数、冲突处理与扩容哈希表是应用极广的数据结构它通过哈希函数把key映射到数组下标从而实现理想的O(1)查询。但哈希冲突是不可避免的两个不同的key可能映射到同一个下标这时就需要解决冲突。最常见的两种解决方式是拉链法和开放寻址法。拉链法下每个表项是一个链表或一棵树冲突的key都挂在同一位置。JDK8里的HashMap就是链表和红黑树结合的例子链表长度超过阈值且数组容量达到64时链表会转成红黑树目的是防止单个桶的链表过长导致查询退化为O(n)。开放寻址法则是当冲突发生时按某种探测序列去找下一个空位常见的有线性探测、二次探测、双重哈希等这种方式在删除元素时不能直接删要用一个特殊标记否则会破坏探测链。哈希表的性能受负载因子元素个数 / 表大小影响很大。负载因子过高冲突变多性能下降负载因子太低空间浪费。工程实现里通常设定一个阈值比如0.75当元素数量超过容量乘以阈值时触发扩容。扩容过程不是简单地把数组变大而是要把所有已有的元素重新计算哈希并放入新数组这是一次O(n)操作但因为发生次数少且均摊下来成本可控整体性能依然优秀。5.3 工程视角下的选型比较在刷题和实际编码中很多场景可以选不同结构。比如你要维护一个动态变化的集合同时要找最大或最小元素用堆最合适如果你要判断元素是否存在且对顺序没有要求首选哈希表如果还要支持范围查询或按序遍历那应该考虑平衡二叉搜索树。数据结构这门课很容易让人只记住“每个结构都能做什么”但真正拉开差距的是“在特定约束下该选哪个结构”。举个例子实现一个Top K问题数据量很大又不要求全局有序你用堆维护一个大小为K的小根堆每次遇到比堆顶大的元素就替换并向下调整最后堆内就是最大的K个元素整体O(n log K)。如果换成每次排序复杂度就退化到O(n log n)数据规模上来之后差距明显。这种“复杂度思维”才是数据结构这一讲真正的训练目标。6. 实战场景与备考技巧从刷题到应试的完整路径6.1 典型算法题型的思路模板数据结构学完之后很多人会有一种“学了但不会做题”的无力感。这里我给出几条对应题型的破题路径帮你建立条件反射。看到“滑动窗口 最值” → 单调队列。看到“左边/右边第一个比它大/小的元素” → 单调栈。看到“字符串匹配/模式串在主串中出现的位置” → KMP。看到“前缀统计/单词查询” → Trie。看到“连通性/动态合并集合/判断环” → 并查集。看到“Top K/动态最值/中位数维护” → 堆。看到“快速判断存在/去重/计数” → 哈希表。看到“频繁在头部/尾部插入删除” → 链表或双端队列。这些对应关系不是死记硬背而是从操作的抽象特征反推出来的。比如你为什么想到用单调队列而不是普通队列因为普通队列只能维护先进先出的顺序无法在O(1)时间内知道窗口内最大值而单调队列额外维护了元素之间的单调关系正好补齐这个需求。与其一套一套去背不如把这个推理过程固化到自己脑子里下次看到新题型也能举一反三。6.2 期末与考研408代码题的复盘要点应试场景下的数据结构代码题通常比较基础但考察细节。几个常见命题点包括单链表的逆转、两个有序链表的合并、二叉树的遍历尤其是非递归先序中序后序和层次遍历、图的最小生成树和最短路径的手动模拟、哈希表的构造与查找、堆的插入与删除、并查集的路径压缩代码。对自己要求高一点的话建议把上面这些用数组实现和用链表实现各写一遍然后对着408历年真题去复盘。不必刷太多题关键是把同类型的题目研究透弄清楚每一步为什么这样做时间复杂度为什么是这个。考试时能快速写对代码的人往往不是背题多的人而是对结构本质理解深的人。6.3 常见问题速查表问题现象可能原因解决思路链表操作后丢失了部分节点修改指针顺序错误导致某个节点不可达画图推演遵循“先搭新链再断旧链”栈或队列判断空出现异常初始化约定不统一固定top/tail的初始化值和判空判满条件哈希表查询变慢负载因子过高或哈希函数分布差扩容或者换用更均匀的哈希策略并查集find死循环未正确初始化parent[i]i初始化时让每个节点指向自己堆排序结果不稳定建堆或调整时写错下标如左右孩子下标从1开始存数据2i和2i1别搞混KMP匹配结果少一个/多一个next数组下标和模式串下标起点不一致统一起点约定建议画表格逐步推演6.4 学习顺序再提醒别让“难东西”拖垮信心最后我还是想再强调顺序问题。很多同学一上来就啃KMP和红黑树越看越崩溃最后连栈和队列都没吃透这是很可惜的。数据结构的学习应该是“由易到难、层层递进”先把数组、链表、栈、队列这些基本结构搞扎实再去研究树、堆、哈希表、并查集最后挑战KMP、单调栈、单调队列。前端后端都要用到的经验是这些知识面试考得很细笔试也会考但最重要的是它能帮你养成“理解底层逻辑、计算复杂度、权衡取舍”的工程思维。如果你正在准备408多说一句数据结构是性价比很高的科目它不像计算机网络那样记忆点特别琐碎也不像操作系统那样概念容易混淆。只要把每一个结构的操作逻辑和复杂度推到熟练数据结构的分数往往是稳的。手写代码的时候刻意练习“先写注释、再补细节”的习惯能够非常有效地减少考场上的低级失误。这个系列讲到这里其实已经把数据结构主干内容过了一遍。后续如果有机会我会继续拆一拆树和图的常见题再把考研真题里的高频题型整理成一份对照表方便你们最后冲刺阶段快速检索。如果你在链表反转、单调栈或者KMP的next数组上还有想不通的地方也可以直接按这个框架去回忆和对照通常只要把那个结构的核心操作在自己脑子里过一遍动画很多困惑就自然解开了。