资讯详情

资讯详情

数据结构与算法学习路线:从数组链表到树图排序哈希的实战指南

最近后台一直有读者问我数据结构与算法到底该怎么学很多人手里捧着严蔚敏老师的《数据结构C语言版》或者跟着王卓老师的PPT课件、王道考研系列在啃但学着学着就卡住了——不是看不懂代码而是不知道这些东西学了能干嘛。我太理解这种状态了。从408考研到软考从外包面试到大厂算法题数据结构与算法的确是一座绕不过去的大山。但我想先说一个反直觉的结论你学不好数据结构与算法问题往往不是出在“不够努力”而是出在“不知道每个结构到底在解决什么问题”。这篇笔记我会把自己这些年学习和实战的整理思路全部倒出来串起数组、链表、栈、队列、树、图、排序、哈希这些核心模块每一块都讲清楚“它是什么——它解决什么问题——工程里怎么用——有哪些坑”尽量让零基础的读者也能顺畅地跟下来。1. 学数据结构之前先把这些基础概念钉死1.1 逻辑结构、存储结构与“怎么选”的问题数据结构这个词听起来玄乎其实就两件事数据元素之间的关系怎么描述以及这些关系在计算机里怎么落地。先看逻辑结构。教科书里分四种集合、线性结构、树形结构、图状结构。集合就是一堆元素之间没什么特别关系你中有我我中有你但彼此平等线性结构就是一对一像排队打饭每个人都有且只有一个前驱和一个后继树形结构是一对多像公司组织架构一个老板管好几个员工图状结构是多对多像地铁线路网任意两个站点之间都可能连通。这是从“逻辑”上描述数据关系你可以理解为画在纸上的蓝图。再看存储结构也就是这份蓝图在内存里怎么落地。主流两种顺序存储和链式存储。顺序存储用一段连续的内存空间挨个存放C语言里就是一个数组链式存储则在每个节点里额外存一个指针把分散在内存各处的节点串起来。C语言版数据结构教材里大量出现的struct Node { int data; struct Node *next; }就是链式存储的典型形态。那实际开发中怎么选我的经验是三个字看操作。如果核心操作是按下标随机访问比如“给我第100个元素”顺序存储直接通过地址偏移搞定时间复杂度O(1)链表却要一步步遍历O(n)。反过来如果是频繁在中间插入、删除比如维护一批实时变化的订单顺序表每插一个元素都要把后面所有元素往后挪链表只需改几个指针就行。所以没有绝对的好坏只有适不适合当前的业务场景。1.2 时间复杂度和空间复杂度不是考试专用而是工程决策的依据很多初学者把大O复杂度当成考试填空题来背觉得跟写代码没什么关系。其实这是工程决策最重要的工具。大O描述的是当数据规模n趋向无穷大时算法运行时间的增长趋势它忽略常数系数、忽略低阶项只保留最高阶。常见的量级我排个序O(1)是常数时间不管数据多大都是一瞬间完成比如数组按下标取值O(log n)是对数时间典型代表是二分查找数据翻一倍只多一次比较O(n)是线性时间比如遍历一遍数组找最大值O(n log n)是线性对数时间快速排序、归并排序就落在这个级别O(n^2)是平方时间冒泡排序、两层for循环嵌套就是再往上还有O(2^n)、O(n!)这种规模到了几十基本就跑不动了。怎么快速估算核心是看循环。一段代码里如果有一个循环遍历n个元素大概率是O(n)有嵌套的两层循环每层都遍历n个那就是O(n^2)如果循环变量每次迭代都减半比如while (i 0) i / 2那就是O(log n)。递归的情况稍微复杂要看递归的调用树有几个分支、递归深度是多少比如二叉树遍历每个节点访问一次总共O(n)但递归栈的深度是树高平均O(log n)最坏O(n)这就是空间复杂度。注意空间复杂度不只算显式分配的数组递归调用时的函数栈帧也要算进去。面试里常问“这题的空间复杂度是多少”很多人漏掉递归栈一答就错。1.3 抽象数据类型为什么说“接口优先于实现”教材里还会反复出现一个概念叫ADTAbstract Data Type抽象数据类型。听起来高大上其实核心思想特别朴素把“能做什么操作”和“内部怎么实现”分开。举个例子栈这个ADT定义了入栈push、出栈pop、取栈顶top这几个操作但你用数组实现也行用链表实现也行外部调用者根本不关心。就像你点外卖只关心能不能送到不关心骑手走哪条路。C的STL、Java的集合框架都是先定义接口再给出多种实现这是软件工程设计里“面向接口编程”的基石。学数据结构的时候养成一个习惯先问“这个结构支持哪些操作、各自的时间复杂度是多少”再去看代码思路会清晰很多。2. 线性表、栈与队列所有高级结构的积木2.1 顺序表与链表C语言实现里的经典对决线性表是所有数据结构里最简单的形态它的两种实现方式——顺序表和链表——是理解后面一切结构的地基。顺序表说白了就是动态数组C语言里可以是固定长度的数组也可以是malloc出来的连续空间。它的优势是随机访问强、CPU缓存友好因为数据都在连续内存里读取时会一次性加载到缓存行遍历速度极快。缺点是中间插入、删除需要搬移大量元素。链表则完全不同每个节点单独分配内存通过指针串联typedef struct Node { int data; struct Node *next; } Node;在已知前驱节点的情况下插入、删除都只要改指针时间复杂度O(1)这是它最大的优势。但链表的每个节点要额外存一个指针内存占用更大而且节点在内存里是分散的遍历时频繁发生缓存未命中实际运行速度往往比顺序表慢。这也是很多初学者困惑的点明明链表插入删除快为什么实际项目里很多时候还是用数组因为现代计算机里内存访问的代价往往比计算本身更大顺序排列带来的缓存友好性能优势很多时候能抵消掉算法层面的劣势。我把两者的关键对比列在下面方便你复习时一眼扫过对比维度顺序表链表内存布局连续分散随机访问O(1)O(n)已知位置插入/删除O(n)O(1)额外内存基本无每节点一个指针缓存友好性高低工程里最典型的链表应用是Linux内核的双向循环链表以及各种LRU缓存的实现。平时写业务代码你可能很少直接操作链表但这个结构的思想无处不在——比如数据库的B树叶子节点之间就是用指针串联的。2.2 栈从递归到括号匹配后进先出的哲学栈是一个只允许在一端栈顶插入和删除的线性表后进先出LIFO。你可以把它想成一摞盘子只能从最上面拿也只在最上面放。栈的学习里括号匹配是最经典的入门题给你一串包含()[]{}的字符串判断括号是否合法。解法思路就是用栈遇到左括号就入栈遇到右括号就检查栈顶是否匹配bool isValid(char *s) { Stack st initStack(); for (int i 0; s[i]; i) { if (s[i] ( || s[i] [ || s[i] {) { push(st, s[i]); } else { if (isEmpty(st)) return false; char top pop(st); if ((s[i] ) top ! () || (s[i] ] top ! [) || (s[i] } top ! {)) return false; } } return isEmpty(st); }初看不觉得有什么但你要是亲手实现一遍就会明白栈天然适合处理“需要回溯最近状态”的场景。递归函数其实就是靠系统栈一层层压栈、出栈的所以递归太深会爆栈Stack Overflow。反过来当你想把递归改成迭代版本时往往就要自己手动维护一个栈这就是二叉树遍历迭代版的核心思路。表达式求值、函数调用、浏览器后退都是栈在撑腰。2.3 队列与循环队列生产者和消费者之间的“传送带”队列是先进先出FIFO跟排队做核酸一样先来的先处理。它最常用的场景是“缓冲”——生产者产生数据消费者处理数据两者速度不一致时队列就是中间那条传送带。顺序实现队列时有个经典坑叫“假溢出”入队出队都只移动rear和front指针当rear到达数组尾部时即使数组前面还有空位也无法再入队了。解决方案是循环队列——逻辑上把数组首尾相接。判断队空是front rear判断队满是(rear 1) % MAXSIZE front注意这里故意浪费一个存储单元来区分队空和队满这个细节考试特别喜欢考也特别容易记混。可以这样记队空时两个指针重逢队满时rear紧挨在front后面。工程里队列到处是操作系统的进程就绪队列、消息中间件里的Topic分区、BFS广度优先搜索里的待访问节点集合全都是队列的舞台。学到这里你就已经拿到了后面图论里BFS的钥匙。3. 树与二叉树从考研真题到工程应用的桥梁3.1 二叉树的遍历递归转迭代的关键思路树是递归结构最自然的表达一个节点下面挂着若干子树子树又是一棵树。二叉树因为每个节点最多两个孩子结构规整成了研究和应用最广泛的形态。四种遍历方式——先序根左右、中序左根右、后序左右根、层序逐层扫——是所有树相关算法的基础。递归版本的遍历代码只有几行无脑好写但面试官经常会追问一句“不用递归怎么写”这时你要意识到递归在底层靠的是系统栈迭代版其实就是用显式的栈模拟系统栈。先序遍历的迭代版本很简单根先入栈然后每次弹出节点、先压右孩子再压左孩子中序和后序稍微麻烦一些需要加一个状态标记或者用“反向思维”处理节点的访问时机。这个过程建议你亲手推演至少三遍推明白了你对“递归就是栈”这件事会有切肤的体会。层序遍历则需要队列配合节点出队时把它左右孩子依次入队天然符合一层一层扩散的节奏。很多实战场景——比如求二叉树的最小深度、二叉树的右视图——本质都是在层序遍历的骨架上加点逻辑。3.2 二叉搜索树与平衡树为什么AVL和红黑树是面试常客二叉搜索树BST的规则是左小右大对任意节点左子树所有值都小于它右子树所有值都大于它。这个性质带来一个巨大红利查找、插入、删除都能通过比较大小不断缩小范围平均复杂度O(log n)。但BST有个致命弱点——如果数据按有序顺序插入树会退化成一条链表操作复杂度直接掉到O(n)比没优化还惨。于是平衡树出现了。AVL树强制任何节点的左右子树高度差不超过1严格平衡查询稳定O(log n)但插入删除时为了维持平衡需要频繁旋转代价较高。红黑树则是“近似平衡”——它不追求绝对高度差只保证最长路径不超过最短路径的两倍牺牲一点点查询性能换来了大幅减少的旋转次数。这也是为什么工程界最终普遍选择了红黑树C STL的map/set、Java的TreeMap/TreeSet、Linux内核的CFS调度器底层都是红黑树。你不需要能手写红黑树但必须知道它“近似平衡、查询O(log n)、插入删除代价可控”这几个关键特征以及“旋转”在维持平衡中的作用。3.3 堆与优先队列堆排序和TOP K问题的核心堆是一棵特殊的完全二叉树通常用数组来存储而且存法很巧妙根节点在下标1或0任意节点i的左孩子是2i或2i1右孩子是2i1或2i2父亲是i/2。有了这套下标映射完全不需要指针就能在数组里“长”出一棵树来。大顶堆要求父节点值不小于孩子节点值小顶堆则相反。堆的精华操作有两个上浮新元素插入到末尾后不断和父节点比较、交换直到满足堆序和下沉删除堆顶后把末尾元素放到堆顶再不断和较大的孩子交换。复杂度都是O(log n)因为树高就是log n。堆最经典的应用是TOP K海量数据里找最大的K个维护一个大小为K的小顶堆堆顶是当前第K大的元素每次来一个新元素如果比堆顶大就替换堆顶并下沉最终堆里就是最大的K个。这个思路在搜索引擎的热词统计、实时日志里的异常值监控里用得极其频繁。堆排序也是基于堆先建堆再反复把堆顶与末尾交换把最大元素沉到数组末尾逐步形成有序序列。3.4 哈夫曼树最优前缀编码是怎么诞生的哈夫曼树最优二叉树解决的问题是给一批带权节点怎么构造一棵二叉树使所有叶子节点的带权路径长度之和最小通俗说就是权重大的节点离根越近越好这样总体代价最小。构造过程是个典型的贪心策略每次从集合中选择权值最小的两个节点合并生成一个新的父节点权值等于两者之和放回集合重复直到只剩一个根。最后左分支标0、右分支标1就能得到每个叶子对应的哈夫曼编码。这套编码有个关键性质任意一个字符的编码都不是另一个字符编码的前缀所以可以无歧义解码。当年我第一次接触时觉得这纯粹是数学游戏后来才意识到ZIP、JPEG这些压缩算法里到处都有它的影子——频率高的字符给短编码频率低的给长编码用更少的比特表达同样的信息压缩的本质就这么回事。4. 图论算法最短路径、最小生成树与拓扑排序实战4.1 图的存储选择邻接矩阵 vs 邻接表图比树更自由任何两点之间都可能相连所以存储方式需要更多权衡。两种主流方案邻接矩阵用二维数组edge[i][j]存储i到j是否有边或者权重判断任意两点是否相邻是O(1)但空间永远是O(V^2)适合稠密图。邻接表则是给每个顶点挂一条链表只存储实际存在的边空间O(VE)适合稀疏图但判断两点是否相邻需要遍历链表。实际工程中真实的图——比如社交网络好友关系、城市道路网——基本都是稀疏的所以邻接表是更常见的选择。但也别完全排斥矩阵当顶点数量很少比如几十个、需要频繁判断两点连通性时矩阵在代码简洁度和常数性能上有很大优势。这类“看着哪个都不错要按场景选”的决策就是数据结构这门课真正要训练你的核心能力。4.2 迪杰斯特拉算法与负权值一次面试追问引发的思考迪杰斯特拉算法Dijkstra解决的是非负权重的单源最短路径问题从起点出发每次从未访问的节点中选出当前距离最小的节点标记为已访问并尝试用它去松弛更新相邻节点的距离重复直到所有节点都被访问。这是一种贪心策略之所以能成立是因为所有边权非负时当前未访问节点中距离最小的那个已经不可能再被其他路径优化了。我见过很多人被面试官追问“Dijkstra能处理负权边吗”时答不上来。正确答案是不行原因藏在贪心的正确性前提里一旦存在负权边某个节点即使已经被标记为已访问也可能通过一条包含负边的路径得到更短的距离。比如A到B权重1A到C权重10C到B权重-9那么从A出发先确定B距离1就不对了因为走A-C-B总距离只有1-9但这是负权边场景说明之前的最短路判定被打破。更简单的例子是三角形结构里直接到B的路径是1但绕道C再到B反而是1(-9)-8比直接去还近。遇到负权边时要改用Bellman-Ford算法可以处理负权还能检测负权环或SPFA。这个问题背后的启示是任何算法都有适用边界边界往往就藏在它证明过程里那一步关键假设中。4.3 从最小生成树看贪心策略Prim和Kruskal怎么选最小生成树MST解决的是在带权无向图中找到一棵包含所有顶点的树使所有边的权重之和最小。典型场景是网络布线、铺水管——要让所有城市连通怎么修路总造价最低。两个经典算法都是贪心但切入点不同。Prim算法是“加点”从一个点出发每次从未连接的顶点里选一个离已连接集合最近的点加进来适合稠密图用优先队列优化后复杂度O(E log V)。Kruskal算法是“加边”把所有边按权重排序从小到大依次尝试加入只要不形成环就保留适合稀疏图配合并查集实现复杂度O(E log E)。判断“是否成环”这一步就是并查集的经典应用场景——学习的时候建议把并查集和Kruskal放在一起看你会发现前者几乎是为后者量身定做的数据结构。4.4 拓扑排序与关键路径有向无环图的工程价值拓扑排序解决的是依赖顺序问题很多任务之间有先后关系比如“必须先学完数据结构再去学算法分析”“必须先完成需求评审才能开始编码”怎么排出一个合法的执行顺序拓扑排序的输出就是这样一个线性序列使得任意一条有向边u-vu都排在v前面。最常用的实现是Kahn算法统计每个节点的入度把所有入度为0的节点入队然后不断出队、把它指向的节点入度减1减到0就入队。如果最后入队的顶点数少于总顶点数说明图里有环——这在工程里意味着依赖循环比如模块A依赖B、B又依赖A编译系统会直接报错。拓扑排序在构建工具Makefile、包管理器依赖解析、编译器里都有应用是一个看着冷门但实际特别实用的算法。5. 排序算法从冒泡到快排八种排序到底在比什么5.1 冒泡、选择、插入O(n^2)三兄弟和它们的使用场景排序算法是所有教材里篇幅最重的部分也是初学者最容易迷失的地方。我的建议是别急着背代码先分清楚三类O(n^2)算法的性格差异冒泡排序相邻元素两两比较把大的往后“冒”。好处是代码直观、最好理解坏处是交换次数多而且即使数据几乎有序不加优化时仍然要跑完整趟。它的教学意义大于实战意义但提前结束标志这个优化点值得记住某一趟完全没有发生交换说明已经有序可以直接break。选择排序每趟扫描选出最小元素放到前面固定位置。思路清晰交换次数少最多n-1次但不管数据本来多有序比较次数永远是n(n-1)/2所以“适应能力”最差。插入排序把当前元素插入到前面已排序区间的合适位置。它的优势被很多人忽视了当数据基本有序时插入排序的比较次数接近O(n)是这三兄弟里唯一拥有“准线性”表现的。这也是为什么复杂排序算法比如Timsort、快排的优化版本在数据规模较小或接近有序时会回退到插入排序来收尾的原因。5.2 希尔、归并、快速与堆排序跨越O(n log n)的台阶从O(n^2)跨到O(n log n)核心思想是分治——把大问题拆成小问题解决小问题后合并结果。快速排序是使用最广的排序算法核心是partition分区选一个基准值把数组分成小于和大于基准的两部分递归处理左右。平均O(n log n)但因为基准选取不当可能退化成O(n^2)所以工程版快排通常采用“三数取中”或随机选基准来规避。归并排序则是彻底稳定的分治先拆到单元素再两两有序合并代价是需要O(n)的额外空间。堆排序我们已经讲过去它的优势是原地排序、最坏也是O(n log n)劣势是不稳定、常数较大。对比这几个排序我把关键指标整理成一张表考试前复习这张表能省不少时间排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性冒泡排序O(n^2)O(n^2)O(1)稳定选择排序O(n^2)O(n^2)O(1)不稳定插入排序O(n^2)O(n^2)O(1)稳定快速排序O(n log n)O(n^2)O(log n)不稳定归并排序O(n log n)O(n log n)O(n)稳定堆排序O(n log n)O(n log n)O(1)不稳定5.3 工程中排序的真实选择稳定性、原地性与系统库真到了工程项目里绝大多数情况下你不会手写排序而是直接调用语言内置的排序函数。但理解排序算法依然必要因为你要能回答“为什么这个库这么选”。稳定性在业务上非常关键比如表格先按时间排序再按用户排序如果算法不稳定第二次排序会把第一次的结果打乱。Java对对象数组用的Timsort就是稳定排序的典范它本质上是归并排序和插入排序的融合还特别擅长处理接近有序的数据。Python内置的sorted也用Timsort。C语言的qsort则是不稳定的快排变体因为它更关注平均速度和原地性。你不需要记住每种语言的具体实现但要形成一种判断力当数据量级、有序程度、稳定性需求发生变化时你能说出哪种算法更合适这才是面试官真正想考察的东西。6. 哈希表与更多经典算法面试题库背后的工程思维6.1 哈希表从散列函数到冲突消解哈希表Hash Table可能是工程应用最广泛的数据结构它的核心是用一个散列函数把键映射到数组下标从而实现O(1)的插入、查找、删除。但散列函数可能把不同键映射到同一个下标这就是哈希冲突所有哈希表的设计本质都在围绕如何减少和处理冲突。两种主流策略拉链法在冲突位置挂一条链表现代主流实现是链表加红黑树Java的HashMap在链表长度超过8且数组长度超过64时会树化开放寻址法则在冲突时向后探测空闲位置Redis字典、Go的map早期版本都用到这类思路。无论哪种策略哈希表性能都受一个指标影响叫负载因子——已存元素占桶数量的比例超过阈值就要扩容rehash这是一次全量重排代价很高。所以工程上当你能预估数据规模时提前指定初始容量是极其划算的优化。6.2 字符串匹配从暴力到KMP的思维跳跃字符串匹配是文本处理的基础搜索引擎、编辑器查找替换、病毒特征码扫描底层都是它。暴力匹配的思路是逐个位置尝试一旦失配就把模式串整体右移一位重新比最坏O(n*m)在长文本上会卡到怀疑人生。KMP算法的精髓在于失配时不是在模式串上傻乎乎地移到头而是根据已经匹配部分的信息next数组决定跳到哪个位置继续比。这个next数组记录的是模式串每个前缀里“相同前后缀的最大长度”代码写出来十几行但理解它需要反复推演。比如模式串ABABAC当匹配到字符C时失配根据next数组可以直接跳回位置因为前缀ABA与文本的ABA已经配上了不需要从头开始。KMP的复杂度是O(nm)这事最妙的点在于它用预处理阶段O(m)的代价换来了匹配阶段线性时间的收益这个“用空间换时间”的思想在大数据处理里到处都是。6.3 贪心、二分与动态规划三个容易混的算法思想刷题的人最常挂在嘴边的三个词就是贪心、二分、动态规划但很多人其实分不清它们的适用场景。贪心算法每次做出当前看起来最优的选择寄希望于局部最优能导向全局最优。它最典型的特征是“不可反悔”所以能用贪心的问题往往需要严格的证明比如活动选择问题每次选最早结束的活动、哈夫曼编码每次合并最小两个。二分算法则依赖一个单调性条件待搜索区间内的元素一定满足某种有序性质。常见的坑是边界条件——left right还是left rightmid (left right) / 2还是(left right 1) / 2一个符号写错就是死循环。我的建议是固定记住一套写法不要每次现场推理比如统一用while (left right)配合mid left (right - left) / 2并在循环外处理终止条件能省下大量调试时间。动态规划则是三者里最通用也最难掌握的它适合有重叠子问题和最优子结构的问题把大问题拆成小问题记录每个子问题的答案避免重复计算。拿零钱兑换来说给定面额和总金额求最少硬币数暴力递归会反复计算同一金额的最优解DP则用一维数组从0开始逐步推到target。判断一个题能不能用DP关键不是看题目的长相而是看“剪掉一个选择后剩下的问题是不是一个更小但同类型的问题”。6.4 从数据结构到真实系统检索、加密、调度里的算法身影写到最后一个章节我想回应很多读者心里的疑问“这些算法我在业务里根本用不到啊”我的看法是算法知识确实不会每天出现在你写的CRUD里但它是你理解真实系统的“隐形眼镜”。搜索引擎的倒排索引本质就是哈希表加链表的组合数据库的索引是B树与哈希索引的取舍消息队列的延迟消息依赖优先队列堆来管理时间戳实时监控里的增量式PID控制算法本质也是“按误差动态调整输出”的经典算法……再比如安全领域AES、国密SM2/SM3/SM4这些加密算法表面看是数学公式实际上每一步都依赖精心设计的数据排列和异或、移位等位运算组合。还有热词里那些看起来很高端的粒子群算法、模拟退火算法它们本质上就是“在解空间里搜索最优解”的不同策略和你在数据结构课里学的贪心、分治、动态规划是同一层思维在不同问题域里的延伸。所以不要把自己框在“算法只是应付考试”的认知里。学数据结构与算法真正学到的是两样东西一是分析问题复杂度、评估方案优劣的思维框架二是把现实关系抽象成结构模型的能力。这两样东西一旦建立学任何新框架、新中间件你都会比别人快一截。拿我自己的体会来说当年啃Dijkstra的证明时觉得费劲后来做地图路线的技术方案时才意识到那套“贪心加松弛”的框架直接帮我理解了链路状态路由协议的设计意图。这也是我一直建议身边人不要囫囵吞枣背代码的原因——那些教科书里的定理和证明才是你将来判断一个技术方案靠不靠谱的底层依据。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →