如果你是第一次翻开数据结构与算法,大概率会经历这么一段心理过程:从头看觉得概念很简单,数组、链表、栈、队列,听名字都懂;一到实现、刷题、分析复杂度,立刻开始怀疑自己是不是走错了方向。这篇笔记不是教科书,也不是速成宝典,而是我多年来反复学习、实践、踩坑之后整理出的一个“精简版地图”——里面记录了哪些知识点是核心中的核心,哪些细节是面试和考试容易追着问的,以及在工程应用里这些理论到底是怎么“活”起来的。不管你是准备期末考试、考研复习,还是刚学编程想建立扎实基础、打算系统刷题,这篇笔记都能帮你少走很多弯路。
我先把话放在前面:数据结构与算法不是一门靠“背”就能搞定的课,它真正的门槛在于把抽象的结构映射到具体场景里。同样的数据,用数组和用链表,增删查的效率天差地别;同样的排序任务,冒泡排序可能跑上几分钟,而快速排序只需要一瞬间;同样在一个字符串里找子串,KMP算法能把暴力匹配的指数级回退变成线性扫描。搞懂这些差别背后的“为什么”,比记住一百个结论都有用。下面这份笔记,就是围绕“为什么”展开的。
1. 数据结构和算法到底在解决什么问题
1.1 用书架来理解存储与操作的关系
很多人学数据结构时,第一个困惑是:数组、链表、树这些概念,和写业务代码有什么关系?我打个比方。你去图书馆找一本书,管理员既可以把书按编号整齐排成一排,也可以随手按到货顺序堆在桌上。两种方式都能存书,但“找书”的效率完全不同。按编号排,你可以用二分查找快速定位;随意堆着,就只能一本一本地翻。数据结构解决的就是“用什么方式组织数据”的问题,而算法解决的是“在这种组织方式下,怎么高效地操作数据”的问题。
学的时候可以一直带着这个类比去理解。线性表就像排成一排的书架,查找方便但插队麻烦;链表就像每本书上写着“下一本在哪”的寻宝游戏,插入删除灵活但找第N本书得从头走;树和哈希表则是为了更快地定位目标,牺牲一部分额外空间来换时间。你的任务不是记住每种结构长什么样,而是拿到一个需求时,能立刻判断出它本质上是在“按位置存”、“按值查”还是“按范围找”,然后选对结构。
1.2 为什么复杂度分析比代码实现更重要
很多初学者会陷入一个误区:把代码写出来跑通就认为自己会了。但数据结构和算法的核心能力,不是“写得出来”,而是“算得清”。时间复杂度描述的是当数据规模n变大时,程序运行时间增长的规模。这里有个特别反直觉的事实:实际运行时间会受到机器性能影响,但复杂度不会。
举个具体例子。n = 10^6 时,O(n)的算法大约执行100万次操作,现代计算机上毫秒级完成;O(n log n)的算法大约执行2000万次操作,也还可以接受;但O(n^2)是10^12次操作,已经足够让程序卡到怀疑人生。理论上判断一个算法能不能用,先算复杂度,比先跑一遍更重要。我见过不少同学用O(n^2)的暴力方法处理大规模数据,程序跑挂了还不知道原因,其实就是没有建立“复杂度直觉”。这块要反复练,做到看到一个循环嵌套两层就本能有警惕心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线性结构里的门道:数组、链表、栈、队列
2.1 数组和链表的分水岭,不只是增删查
教科书上通常会画一张对比表:数组支持随机访问,复杂度O(1);链表插入和删除方便,复杂度O(1),但查找需要遍历。这个结论没错,但太粗糙。工程里选型时还有个很重要的因素——内存布局。
数组在内存里是一段连续空间,CPU加载数据时会自动把相邻的数据块一起读入缓存。这意味着你遍历数组时,大部分访问都在缓存里命中,速度非常快。链表节点是分散在堆里的,每次访问下一个节点都是一次新的内存跳转,缓存命中率低。所以即便理论上“链表删除是O(1)”,实际运行中它因为缓存不友好的问题,往往比数组慢不少。这也是为什么Redis的List对象早期版本用压缩列表和quicklist,本质是在用连续内存结构优化链表的缓存问题。
另一个选择标准是扩容成本。数组插入到尾部如果空间不够要扩容,需要整体复制;链表天然动态,但也带来了每节点指针的额外内存开销。实际业务里如果数据量小,链表优势几乎体现不出来;数据量大、插入删除频繁,才值得考虑。别背结论,要理解背后的取舍逻辑。
2.2 栈和队列:简单结构撑起了复杂系统
栈这种结构非常简单,但千万别小看它。函数调用本质上就是靠系统栈实现的:每次调用函数,参数、返回地址、局部变量全部压栈;函数返回时弹栈。递归为什么会爆栈?因为每递归一层就压一层栈帧,深度太深就把空间耗尽。理解了这一点,你就能明白尾递归优化和迭代改写递归的原理。
栈的经典应用还包括括号匹配、表达式求值、浏览器的前进后退、编辑器的撤销操作。队列则广泛用在BFS(广度优先搜索)、任务调度、消息缓冲等场景。我对这两种结构的建议是:多去实现一遍,然后思考“这个场景为什么必须用这个结构”。比如你手写一个函数调用模拟程序,就明白了栈的“后进先出”到底是怎样的直觉;你写一个BFS遍历图,就明白了队列的“先进先出”如何保证按层扩展。
3. 排序算法:背模板不如理解交换次数
3.1 冒泡排序为什么“低效但不过时”
冒泡排序常被当成反面教材,时间复杂度稳定O(n^2)。但实际项目中,它并没有完全消失。原因有几个:实现极其简单,不容易写错;对于基本有序的小数组,冒泡排序一次循环后可以通过标记提前退出,最好情况达到O(n);加上它是稳定排序,在元素规模极小、要求代码可读性优先时,完全够用。
学冒泡排序也有一个很大的价值:它是理解“交换排序”族最好的入门。嵌套循环实现相邻比较和交换,这种结构你写顺了,后面学快速排序、堆排序时思路会顺很多。C++里写一个经典版本也就十来行:
cpp复制void bubbleSort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
bool swapped = false;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
std::swap(arr[j], arr[j + 1]);
swapped = true;
}
}
if (!swapped) break;
}
}
注意这个提前退出标记swapped,很多教材版本没有它,导致即便数组已经有序,依然傻乎乎跑完所有循环。加上这个小优化,最好情况就是O(n)了。务实地说,面试很少让你手写冒泡排序,但是“如何改进一个朴素算法”这种思路,比代码本身更值钱。
3.2 快速排序的退化问题与工程解法
快速排序平均复杂度O(n log n),是很多语言标准库排序的默认选择。但它的最坏情况是O(n^2)——当每次都选到最小或最大元素作为基准时,分割极度不平衡。举个例子,对于一个已经有序的数组,如果你固定取第一个元素作基准,快排会退化得比冒泡还慢。
工程界的应对思路很成熟。一是取基准时用“三数取中”,比较首、中、尾三个元素,取中间值作为基准,大幅度降低最坏情况的概率;二是随机化基准,让恶意构造的输入无法稳定触发退化;三是当子问题规模小于某个阈值(比如16)时,改用插入排序,因为对小数组来说插入排序的常数极小,比快排分区更划算。C++标准库的sort函数大致就是这种组合策略。
所以在讨论排序算法时,要区分“理论复杂度”和“工程常数”。堆排序的复杂度也稳定在O(n log n),看起来不比快排差,但实际运行却通常慢于快排,原因就在于堆排序的内存访问模式对缓存不友好,建堆和下沉时跳跃式访问元素。算法选型不是看复杂度表就完事,得考虑数据特征和运行环境。
3.3 堆排序与优先队列的不可替代性
堆排序本身作为“排序算法”出场频率不高,但它背后的数据结构——二叉堆,却是工程中最常用的结构之一。优先队列、TopK问题、定时器任务调度、Dijkstra算法优化,全部依赖堆。
用堆实现TopK的思路很典型:要找最大的K个数,维护一个大小为K的小顶堆,堆顶是当前K个数里最小的。当新元素小于堆顶,直接丢弃;大于堆顶,替换堆顶并下沉。这样只需O(n log K)时间,内存占用O(K)。如果你用全排序再取前K个,内存和时间都浪费很多。这就是数据结构的价值:不改变结果,但用最少资源达成目的。
4. KMP算法:从一个手算next数组的实例说起
4.1 朴素匹配慢在哪里
字符串匹配是笔试和面试的高频题。场景是:有一个主串S,一个模式串P,要在S中找到P的位置。最朴素的做法是枚举每个起始位置,然后逐个字符比较,不匹配就右移一位重新比较。最坏情况下,比如主串是“aaaaaaaaab”,模式串是“aaab”,每次都要比到最后一位才发现不匹配,复杂度O(m*n)。
KMP算法的突破点是:匹配过程中已经比较过的信息,不应该被丢掉。当某个字符失配时,模式串不是简单地右移一位,而是跳到下一个“可能匹配”的位置。这个“跳到哪儿”的信息,被预先计算成next数组。
4.2 手算“abacaba”的next数组
不同教材对next数组的定义和下标起点略有差异,我这里采用从1开始计数的经典定义:next[1] = 0,next[j] 表示当模式串第 j 个字符匹配失败时,下一次让模式串第几个字符去和主串当前字符对齐。计算规则是:取模式串前缀P[1..j-1]中,最长相等“真前缀”和“真后缀”的长度加1。
以热搜里大家常问的模式串 P = "abacaba" 为例,我写出手算全过程。
| 下标 j | 模式串P[j] | 考察的已匹配前缀 P[1..j-1] | 最长相等真前后缀 | next[j] |
|---|---|---|---|---|
| 1 | a | 无 | 不存在 | 0 |
| 2 | b | "a" | 长度0 | 1 |
| 3 | a | "ab" | 长度0 | 1 |
| 4 | c | "aba" | "a"长度为1 | 2 |
| 5 | a | "abac" | 长度0 | 1 |
| 6 | b | "abaca" | "a"长度为1 | 2 |
| 7 | a | "abacab" | "ab"长度为2 | 3 |
逐个解释容易卡的几个位置。j=4时,前缀是“aba”,真前缀有“a”和“ab”,真后缀有“a”和“ba”,最长相等是“a”,长度1,所以next[4] = 1+1 = 2。j=7时,前缀是“abacab”,仔细看开头两个字符“ab”,结尾两个字符也是“ab”,所以最长相等真前后缀长度是2,next[7] = 2+1 = 3。这里要注意,比较时真前缀和真后缀不能是完整串本身,这是很多初学最容易算错的地方。
如果用0开始下标的版本,通常值会整体偏移,next[0] = -1、next[1] = 0,看到题面试务必先确认用的哪套定义。我建议自己动手把这道经典例子推三遍,推熟之后其他字符串的next数组就是机械劳动了。
4.3 匹配过程与时间复杂度分析
有了next数组,KMP匹配过程就顺了。假设当前主串字符S[i]与模式串字符P[j]正在比较,如果相等,i和j都加1;如果不相等,j回退到next[j],主串指针i不动,继续比较新位置的P[j]。整个过程中主串指针只会前进不会后退,所以主串扫描只需O(m)时间,而模式串构造next数组的预处理是O(n),总复杂度O(m+n)。
KMP的意义不仅在于“快”,更在于它把“如何利用历史信息”这个思想推到了高考和考研的必考层面。字符串匹配还有很多变体,比如BM算法、Sunday算法在实际场景中也常用,但KMP是理解这类问题的基石。面试被要求手写KMP不用慌,能写出next数组的构造和主循环,就已经赢了九成的人。
5. 图算法里的Dijkstra:为什么负权值是个坑
5.1 贪心策略为什么在正权图上成立
Dijkstra算法是求单源最短路径最经典的算法,它的核心思想是贪心:每次都从未确定的节点里选出距离起点最近的一个,把它确定下来,然后尝试用这个节点去松弛它邻居的距离。整个过程可以用小顶堆优化,每次O(log n)选最小,复杂度降到O((m+n) log n)。
为什么这个贪心是对的?因为所有边的权重都是正的,所以当前“距离起点最小”的未确定节点,不可能通过绕路变得更近——任何一条绕路的边都会增加距离。这个前提就是算法的生命线。我用一个具体的图演算一遍:假设有A、B、C三个节点,A到B权重1,A到C权重3,B到C权重1。从A出发,第一次选出B,dist[B]=1,然后松弛C变为2;之后再从C更新没有邻居,算法结束。看起来顺理成章,但一旦出现负权边,整个逻辑就崩了。
5.2 一个经典反例演示算法失效
有向图里,A到B权重1,A到C权重3,C到B权重-5。从A出发,Dijkstra第一步会选出B,认为dist[B]=1,并且标记为已确定。但实际上,存在一条更短路径:A到C(3),C到B(-5),总权重-2,比1小得多。可惜因为贪心策略已经锁定了B,这个更优答案永远不会被更新到。
这个反例说明了负权值的真正麻烦:一个节点一旦被选为“当前最小”并被标记确定,Dijkstra就认为它不可能再被更新。而负权边的存在使这个假设失效。这也是为什么面试常问“Dijkstra不能处理负权,那怎么办”——答案是改用Bellman-Ford或SPFA,它们允许对各边做多次松弛,能处理负权,但代价是复杂度更高。
5.3 工程里的选型建议
实际工作中写路径规划、网络路由,先确认边的权重是否严格非负。如果是,Dijkstra加小顶堆基本就是最佳选择。如果图可能带负权边但不含负权环,SPFA在随机数据上往往跑得很快,不过最坏情况下退化需要小心。如果图中存在负权环,那任何最短路径算法都无法定义收敛结果,只能检测并报错。算法选型没有银弹,核心是先想清楚约束条件再动手。
6. 从课本到工程:Redis里那些数据结构与算法
6.1 SDS、压缩列表与哈希表的取舍
很多人学完数据结构后,觉得课堂知识和真实项目隔着一层纱。其实Redis就是一本很好的“数据结构实践教科书”,很多设计都在经典结构上做了工程化改造。
最简单直观的是字符串。Redis没有直接用C语言的原生字符串,而是自己实现了SDS(简单动态字符串),在字节数组上额外记录了长度和可用空间。这样一来,获取字符串长度是O(1),也更安全,不会因为忘加结束符越界。哈希表部分,Redis采用拉链法解决冲突,同时支持渐进式扩容——不是一次性把旧表所有数据搬到新表,而是每次插入删除时搬几个桶,把扩容的O(n)开销摊薄到多次操作里,避免了扩容瞬间卡顿。这种设计在数据库系统中非常常见,理解它比背“哈希表平均O(1)”有价值得多。
6.2 跳表与平衡树:为什么Redis选了跳表
Redis的有序集合(Sorted Set)是面试高频题,背后的核心结构叫跳表(Skip List)。跳表本质上是在链表上建立多层索引,底层是有序链表,每一层都是下一层的一部分节点,形成稀疏索引。查找时从最高层开始逐层向下,类似二分查找,平均复杂度O(log n)。
那为什么不用红黑树或AVL树?跳表实现起来简单得多,不用操心旋转和变色这些平衡操作,对工程维护友好;支持范围查询(比如取分数在60到80之间所有元素)比树结构更自然;并且可以通过概率调整层级,让结构保持近似平衡,不需要严格平衡的强制调整。Redis之所以选择跳表,不是因为跳表在所有维度上碾压红黑树,而是它在插入、删除、查找、范围遍历几个需求综合下来最适合有序集合这个业务场景。
6.3 算法思想在自动控制领域的“变形”
热搜词里经常能看到粒子群算法、PID算法、FOC算法这些名字,它们其实属于更广义的“算法”范畴,和数据结构课程里的算法方向不同。PID算法解决的是让某个物理量稳定在目标值的问题,核心是靠比例、积分、微分三项不断修正偏差;粒子群算法则是一种基于群体协作的优化方法,用来搜索复杂函数的极值。它们一样强调“约束条件”和“目标函数”,本质思维和数据结构算法相通。学数据结构与算法时养成的建模能力,在接触这些工程控制算法时同样有效,因为核心都是在“限制条件”下找到“可行且最优”的方案。
7. 期末复习与考研备考:别让笔记吃灰
7.1 复习顺序与常考题型
先粗粗梳理一下常见的复习路径,很多人一开始就被厚厚一本教材劝退了。我的建议是:先把线性表、栈、队列、串、树、图、查找、排序这八个大块摸一遍,然后按考试权重分配时间。期末和考研通常集中在树、图、查找、排序这几章的算法理解和手写代码能力上,占分比重最大。选择题则会扣一些细节,比如森林转二叉树、哈希冲突处理方式、各种排序算法的稳定性比较。
复习资料我建议“王道加严蔚敏”组合。严蔚敏老师的C语言版是经典教材,概念严密,但部分叙述偏理论化;王道系列配合视频适合应试,它有大量真题和冲刺要点。两个配合起来用:先用王道理顺考点,再用严蔚敏教材补细节和推导。光看不练不行,算法部分必须自己在纸上写一遍,尤其是前序遍历、中序遍历、递归转非递归、快排、归并排序,这些高频题一定要手写到滚瓜烂熟。
7.2 实验报告怎么写才能拿稳分
很多人数据结构的实验报告,就是贴一段能跑的代码,然后配几个运行截图。这其实低估了实验报告的要求。一份好的实验报告应该包含几个固定层次:问题描述、设计思路、核心伪代码或流程图、复杂度分析、测试用例和结果分析。
设计思路部分要写清楚“我为什么选这个结构”、“有没有考虑替代方案”、“时间复杂度多少”。举个例子,要求实现一个学生信息管理系统,你可以选择顺序表也能选择链表,但需要在报告中说明各自的优缺点以及在什么操作下哪个更合适。测试用例不要只给一组正常数据,要有边界测试,比如删除空链表、查找不存在的元素、插入到第0个位置。这种“边界思维”恰恰是老师最想看到的内容,也是面试官最爱问的场景。
7.3 常见错误与避坑清单
整理了这么多年,我见过的大多数复习和实验错误都比较集中,列个自查清单:
- 链表的空指针:头节点为NULL时,访问next直接崩溃,写代码前先判断判空。
- 数组越界:C/C++里数组越界不报错,但会悄悄改坏栈内存,排查起来特别痛苦。用vector时也要注意size()返回的是无符号数,别在循环里做不当减法。
- 递归爆栈:深度接近10^6级时,递归非常危险,要想到用栈模拟或尾递归改写。
- 复杂度算错:看到三重嵌套循环就写O(n^3),忘了内层循环可能提前break;分析递推式时忽略常数项的影响。
- 排序稳定性记混:快速排序、堆排序不稳定,归并排序、插入排序、冒泡排序稳定,这个细节选择题几乎必考。
8. 刷题时的三个好习惯
8.1 先写伪代码,再动键盘
很多人刷题一上来就直接写代码,写到一半发现思路偏了,反复删改,效率极低。我自己的习惯是先在草稿纸上用中文或简单英文写出步骤:第一步做什么,第二步做什么,循环的终止条件是什么。尤其是涉及边界条件时,先想清楚“什么时候循环结束”“栈空不空”“指针是否会越界”,再落码。这个过程看着慢,实际省下的调试时间远超写代码的时间。
8.2 把“做对”变成“能讲清”
一道题写完了,能Accept只是第一步。我建议你试着自己给自己讲一遍:为什么这个解法是对的?时间复杂度为什么是这个量级?空间上有没有浪费?如果面试官追问“能不能优化”,你会怎么回答。一个特别有效的检验方法是:把题解写给自己看,假设另一个人完全不会这道题,看文字能不能让他读懂。讲不清的地方,多半就是你理解还没到位的地方。
8.3 建立自己的算法模板库
同样是树的遍历、二分查找、回溯剪枝、动态规划,类似的骨架反复出现。我建议你把自己熟练的写法整理成模板,统一命名和参数风格。比如二叉树的递归遍历模板、用栈实现的前序遍历、二分查找的闭区间和开区间两种写法、Dijkstra模板。遇到新题时先尝试用已有模板解决,而不是每次从零开始推。长期积累下来,你就有了一本真正属于自己的“数据结构与算法笔记”,比市面上任何一本教材都更适合自己复习。
最后再分享一个小技巧。我整理笔记的习惯是:每个知识点后面都跟着至少一个“为什么”——为什么要这样做,为什么不那样做。数据结构这门课,记忆负担特别大,但如果你能持续追问“为什么”,很多看似需要死记的结论会自动变成常识。比如“哈希表为什么扩容时最好是2的幂次”,因为位运算取模比取余快;比如“为什么堆底层一般用数组存储”,因为完全二叉树用数组天然不浪费空间。带着这些问题去学,你就不是在背笔记,而是在理解系统设计的本质。这也是数据结构与算法这门课最有价值的地方。
