
说实话链表这个知识点我在面试里见过太多人栽跟头了。明明思路都知道一让他手写代码就卡壳要么是边界条件没处理好要么是改指针的时候把链表弄断了更有不少人干脆连“链表到底解决了什么问题”都说不清楚。其实链表并没有那么玄乎。这篇文我尽量用大白话把这些年教学和做题过程中的心得都揉进去带着你从头到尾把链表吃透保证你看完能自己动手写出一个像样的链表遇到相关面试题心里也有底。这篇内容适合这三类人看刚学数据结构、被指针和结点搞得一头雾水的新手期末要考数据结构、突击链表操作的学生准备技术面试、想系统过一遍链表核心考点的求职者。我会尽量不堆砌术语每个名词都解释清楚复杂的地方配上类比你跟着我的思路走一遍链表的骨架就搭起来了。1. 链表长什么样——先解决“它到底是个什么东西”1.1 一个日常生活中的类比你有没有玩过那种“寻宝游戏”线索纸条上写着下一个线索藏在哪里你要沿着纸条一张一张找下去才能到达终点。链表就是这个结构。链表里每个“纸条”我们叫它结点每个结点干两件事第一存一个数据第二记下“下一个结点在哪”。这第二个信息在编程里就叫指针或者叫“引用”。所以一个结点可以理解成“数据 指向下一个结点的箭头”。对比一下数组数组就像电影院的连排座位1号挨着2号2号挨着3号大家在内存里是紧挨着坐的。链表则像一群朋友散落在广场各处每个人手里拿着一个小旗子旗子上写着“我朋友站在那边”你从第一个人开始顺着旗子指示的方向一个找一个也能走完整个队伍。注意数组在内存中是连续的区域链表在内存中却是“东一个、西一个”的它们之所以能串成一条线全靠结点里那个指针牵着。这就是链表和数组最本质的区别。理解了这一点后面所有的操作和代码都是在围绕这个“箭头”做文章。1.2 数组和链表的本质区别先说结论数组和链表各自有不可替代的优势谁也不能完全取代谁。我整理了一张对比表你可以直接保存对比项数组链表内存空间连续的一段内存分散的结点用指针连接访问方式随机访问按下标一步到位顺序访问只能从头开始一个个找插入操作中间插入需要把后面的元素整体往后挪只需改前后结点的指针指向删除操作中间删除需要把后面的元素整体往前挪只需改前一个结点的指向释放结点额外开销几乎没有每个结点都要多存一个指针空间上有额外消耗适用场景读多写少、需要频繁按下标查找写多读少、频繁插入删除、不知道到底要存多少数据数组最大的优势在于“随机访问”——知道下标是几直接就能定位到那个位置哪怕数组里有1亿个元素也是一瞬间的事。链表就做不到你要找第5万个结点只能老老实实从第一个结点开始一个一个往后数。但链表也有翻身的机会在数组中间插入一个元素需要把后面所有元素都往后移最坏情况下是O(n)的代价。链表插入只需要“把前一个结点指向新结点再让新结点指向下一个结点”改两条指针就行不管链表多长操作耗时是固定的。一句话总结数组适合“查得多、改得少”的场景链表适合“改得多、又不知道多少数据”的场景。这两句话是面试里对比题的标准答案理解了以后任何变着法儿的问法你都能接得住。1.3 链表的三种常见形态链表不是只有一种长相至少有三种形态你得认识单链表最基础的一种。每个结点只有一个“下一步”的指针只能从前往后走走到底就停。比如火车车厢车头拉着走没法倒着走。双向链表每个结点有两个指针一个指向前一个结点一个指向后一个结点。这就好比你能带着队伍正着走也能掉头倒着走。标准库里的list容器底层就是双向链表。循环链表把链表的尾结点和头结点接起来形成一个环。比如循环播放的音乐列表最后一首歌放完又从第一首开始。约瑟夫问题、操作系统里的进程调度都会用到循环链表的思想。单链表是基础中的基础后面的双向链表、循环链表都是基于单链表加东西。所以这篇文以单链表为主把它吃透其他的你自然就会了。2. 手把手实现单链表——核心代码与思路拆解2.1 结点结构怎么定义我用C来写因为C和C的指针语义最接近链表本质理解清楚了用Java、Python写只是换了层语法外壳而已。每个结点包含数据域和指针域常见的定义是这样的template typename T struct Node { T data; // 数据域类型用模板T想存int、string、自定义类都行 NodeT* next; // 指针域指向下一个同类型的结点 // 构造函数方便创建结点时顺便给数据赋值 Node(const T value) : data(value), next(nullptr) {} };这里有几个细节容易出问题我单独拎出来讲第一next指针一定要初始化成nullptr。如果不初始化它就是一个“野指针”指向一个未知的地址。后续遍历的时候如果用p-next ! nullptr作为循环条件野指针会让你莫名其妙进入死循环或者直接崩溃。我在初学阶段因为这个吃过不少亏创建结点后忘记置空调试了一下午最后发现是初始化的问题。第二为什么要用模板template typename T为了让链表能装下任意类型的数据。你写一个装int的链表以后想装string、想装学生类、想装植物百科里的一条记录不用再复制一份代码重新定义一个链表把T换成对应类型就行。这也是为什么标准库容器都用模板。第三struct和class在这里没本质区别用struct只是因为它默认公有写起来省事。用class也行记得手动加上public:就行。2.2 初始化链表没有头结点的写法为什么容易出bug很多初学者写的链表是没有头结点的也就是链表第一个结点就直接存数据。这样写不是不行但你会发现代码里到处是if (head nullptr)这种特判稍微一复杂就漏洞百出。更稳妥的方案是加一个虚拟头结点也叫哨兵结点、dummy node。这个结点不存任何有效数据它的作用就是当“链表的起点标志”让所有操作统一逻辑。打个比方虚拟头结点就像小区的门卫室。小区里的住户是有效结点门卫室本身不是住户但是你进小区找任何一户人家都得从门口进去。有了这个门卫室不管小区里有没有住户门卫室永远存在你处理首尾操作时就不用手忙脚乱了。template typename T class LinkedList { private: NodeT* head; // 虚拟头结点 int size; // 记录链表结点数量 public: // 构造函数创建一个虚拟头结点链表为空 LinkedList() : head(new NodeT(T())), size(0) {} // 析构函数逐个释放结点内存 ~LinkedList() { NodeT* cur head; while (cur ! nullptr) { NodeT* next cur-next; delete cur; cur next; } } };注意析构函数这里的写法先用next保存当前结点的下一个结点再释放当前结点然后移动指针。如果不先存next删掉当前结点后你再访问cur-next就是访问已经释放的内存属于未定义行为程序马上崩给你看。这条规则叫做“改指针之前先保存后继”整个链表编程里贯彻始终。虚拟头结点带来的好处是当链表为空时head依然存在你不需要写if (head nullptr)判断空表的情况插入第一个有效结点时也不需要单独处理“头结点不存在”的边界问题。很多书上的例题不带虚拟头结点逻辑上没问题但代码量多一倍新手极容易出错。面试或者写作业时如果你能主动用虚拟头结点“简化边界处理”这个思路本身就能加分。2.3 头插法 vs 尾插法一个细节不同的两个操作创建一个链表有两种最常见的插入策略从头部插入头插法和从尾部插入尾插法。头插法的代码很短思路是新结点来了先让新结点指向当前第一个有效结点也就是虚拟头结点的next再让虚拟头结点指向新结点。void insertAtHead(const T value) { NodeT* newNode new NodeT(value); // 第一步创建新结点 newNode-next head-next; // 第二步新结点指向原来的第一个结点 head-next newNode; // 第三步虚拟头结点指向新结点 size; }这里有一个初学者最容易犯的错把第二、三步顺序搞反。如果你先执行head-next newNode那么原来的第一个有效结点就丢了没有指针能再找到它这就叫“断链”。所以头插法的精髓是先处理新结点和旧结点之间的关系再更新入口的指向。尾插法需要先找到链表的最后一个结点然后把新结点接上去。void insertAtTail(const T value) { NodeT* newNode new NodeT(value); NodeT* cur head; // 从头开始遍历找到最后一个结点next为nullptr的那个 while (cur-next ! nullptr) { cur cur-next; } cur-next newNode; size; }while (cur-next ! nullptr)这个循环是链表遍历的“通用小马达”后面查找、修改、删除都要用到它。注意循环条件是cur-next ! nullptr而不是cur ! nullptr。如果写成cur ! nullptr循环结束时cur已经变成空指针了后面再写cur-next newNode就崩了。写成cur-next ! nullptr循环结束时cur恰好停在最后一个有效结点上这正是我们需要的。头插法和尾插法一个重要的区别是头插法得到的链表顺序和输入顺序相反尾插法得到的顺序和输入顺序一致。如果你想从头输入1、2、3用头插法得到的链表是3、2、1用尾插法得到的链表才是1、2、3。这个特性有时候可以拿来快速实现“反转”效果比如你想把一个数据序列反着存用头插法一次搞定省得后面专门写逆序函数。2.4 遍历链表的通用模板遍历是链表的“阅读理解基本功”所有查找、打印、统计类操作都用它。void printList() const { NodeT* cur head-next; // 跳过虚拟头结点从第一个有效结点开始 while (cur ! nullptr) { std::cout cur-data ; cur cur-next; } std::cout std::endl; }遍历模板就三句话定义一个指针从第一个有效结点出发只要指针不为空就继续循环每次循环结束让指针往后挪一步。这里循环条件是cur ! nullptr而不是cur-next ! nullptr因为我们的目的是把每个结点都访问一遍包括最后一个结点。要注意这里的“通用模板”和尾插法里的“找到最后一个结点”的循环条件不一样这点特别容易混淆我建议你把两个条件分开记牢想访问所有结点打印、查找、计数用while (cur ! nullptr)想在尾部插入需要停在最后一个结点上用while (cur-next ! nullptr)2.5 按位置插入和删除节点的完整实现按位置插入的核心步骤是先找到目标位置的前一个结点然后让新结点插进去。为什么要找前一个因为单链表只能从前往后走你想要在第i个位置插一个结点必须知道第i-1个结点是谁才能改它的next指针。bool insert(int pos, const T value) { if (pos 0 || pos size) { return false; // 位置越界插入失败 } NodeT* cur head; for (int i 0; i pos; i) { cur cur-next; // 从头开始走pos步走到位置的前一个结点 } NodeT* newNode new NodeT(value); newNode-next cur-next; // 先让新结点指向cur原来的下一个结点 cur-next newNode; // 再让cur指向新结点 size; return true; }注意这里的for循环结束后cur停在第pos个结点的前一个位置。比如想在位置0插入循环一次都不执行cur还停在虚拟头结点上新结点直接被接到虚拟头结点后面这正好就是头插法的效果。因为有了虚拟头结点位置0和其他任何位置的代码逻辑完全统一不用写特判。删除节点的思路是找到目标位置的前一个结点把它指向“目标结点的下一个结点”然后释放目标结点的内存。bool removeAt(int pos) { if (pos 0 || pos size) { return false; } NodeT* cur head; for (int i 0; i pos; i) { cur cur-next; // 走到目标结点的前一个结点 } NodeT* toDelete cur-next; // 记录要被删除的结点 cur-next toDelete-next; // 跳过它直接连到后面去 delete toDelete; // 释放内存 size--; return true; }删除操作最忌讳的就是“删完就走”忘了释放内存。C里new和delete必须成对出现这也是面试官在C版链表题里一定会盯着的点。用Java、Python的同学没有delete这一步有垃圾回收机制兜底但“从链表中摘除结点”这一步还是要想清楚的。删除一个结点本质上改了“前一个结点的next指针”把它绕过了要删除的目标。至于目标结点占的内存C里必须手动delete不删就是内存泄漏程序跑久了内存越占越多最后卡死。3. 进阶操作三种你必须会的高频场景3.1 单链表反转的两种写法面试里考链表反转是出现频率最高的题目没有之一。我见过好几种写法最推荐的是“三指针迭代法”思路清晰代码简洁。要求给定一个单链表返回反转后的新链表头。比如1-2-3反转后变成3-2-1。核心思路是用三个指针prev、cur、next边遍历边反转每一个结点的指向。每到一个结点先保存它的下一个结点然后把它的next改成指向前一个结点最后三个指针一起往右平移。NodeT* reverseList(NodeT* startNode) { NodeT* prev nullptr; // 前一个结点初始为空 NodeT* cur startNode; // 当前结点从第一个有效结点开始 while (cur ! nullptr) { NodeT* next cur-next; // 先保存下一个结点防止断链 cur-next prev; // 当前结点的指针掉头指向前一个 prev cur; // prev向右移动 cur next; // cur向右移动 } return prev; // 循环结束时prev指向原来的最后一个结点也就是新链表的头 }结合虚拟头结点的类来写可以写成一个成员函数直接操作head后面的部分然后更新head-nextvoid reverse() { NodeT* prev nullptr; NodeT* cur head-next; while (cur ! nullptr) { NodeT* next cur-next; cur-next prev; prev cur; cur next; } head-next prev; }我建议你亲手把这个过程画一遍拿一张纸画出1、2、3三个结点的链表三个指针用三种颜色每走一步擦掉重画。画个两三遍你再看代码就有直觉了。很多人代码看懂了一写就废就是因为没在纸上推演过。还有一种递归写法同样经典这里不展开细讲但你可以记住一个递归观点“反转链表可以看成是‘把第一个结点摘下来然后反转剩下的子链表再把摘下来的结点接到最后面’”。递归理解起来稍绕迭代法先掌握了递归以后自然能开窍。3.2 快慢指针如何10分钟内判断链表有没有环判断链表有没有环这也是面试高频题。最直观的想法是用一个集合记录访问过的结点地址每走一步就查一下当前结点之前在不在集合里在的话就说明绕回来了。这个方法没问题但需要额外的空间。用快慢指针就完全不用额外空间。思路是让“快指针”每次走两步“慢指针”每次走一步。如果链表没有环快指针会先走到尽头碰到nullptr说明没环。如果链表有环快指针一定会在某个时刻“追上”慢指针因为快慢指针都在循环里转圈快指针相对慢指针每次靠近一步总会有相遇的时候。bool hasCycle(NodeT* startNode) { NodeT* slow startNode; NodeT* fast startNode; while (fast ! nullptr fast-next ! nullptr) { slow slow-next; // 慢指针走一步 fast fast-next-next; // 快指针走两步 if (slow fast) { return true; // 相遇了说明有环 } } return false; }这里最坑的是循环条件必须写成fast ! nullptr fast-next ! nullptr两个条件都不能少。如果fast走到nullptr说明链表到头了如果fast-next是nullptr说明快指针下一步往外跳了也会越界。只要这两个条件之一不满足就能断定链表没环。快慢指针这个方法看起来很巧妙其实背后就是“追及问题”在一个圆形跑道上两个人速度不同跑得快的一定会追上跑得慢的。懂了这一层你就不需要死记硬背代码了自己都能推导出来。3.3 双向链表和循环链表什么时候用单链表只能从前往后走遇到“找前一个结点”这种需求就非常尴尬因为需要从头再遍历一遍。双向链表给每个结点多存了一个prev指针指向前面一个结点这样正着走倒着走都行。template typename T struct DoublyNode { T data; DoublyNodeT* prev; // 指向前一个结点 DoublyNodeT* next; // 指向后一个结点 };双向链表插入、删除时改的指针从“改一个”变成“改两个”逻辑稍微复杂一点但它换来了“O(1)时间找到前驱”的能力。LRU缓存淘汰算法、文本编辑器里的撤销历史底层都离不开双向链表。一般而言如果你需要一个能高效删除“当前位置前一个元素”的容器双向链表是最自然的候选。循环链表则适合“转圈”的场景最后一个结点的next不指向nullptr而是指向头结点。约瑟夫环问题、操作系统的任务轮转、音乐播放器的循环播放都是循环链表的应用场景。判断循环链表的循环条件也从cur ! nullptr变成了cur ! head这点如果你以后写循环链表记得特别留意。4. 面试考链表到底在考什么——核心考点与破题思路4.1 链表和数组的对比为什么是必考题面试官问“数组和链表有什么区别”其实不是在考你背不背得下来而是想看你对“内存布局”“时间复杂度”有没有真正的理解。从内存看数组是连续存储、链表是离散存储这是根本区别。从时间复杂度看数组按下标访问是O(1)链表按值查找是O(n)最坏情况扫到尾数组中间插入删除是O(n)链表在已知位置插入删除是O(1)。这两个结论你不仅能说出来还得能解释为什么——因为连续存储可以直接算地址因为链表改指针只需要改相邻结点。我在实际面试中还会补充一个工程上的体会CPU缓存对连续内存的访问更友好。数组在内存里挨着放遍历时CPU缓存命中率高实际跑起来比链表快不少。链表结点东一个西一个频繁跳转指针连缓存都懒得配合你。面试时主动提这一点会让面试官觉得你不是死背书而是真的写过代码、知道性能差异。4.2 几个高频手撕题的思考路径面试手撕链表题除了反转和判环还有几个高频题目值得提前准备。“删除链表的倒数第N个结点”——思路是用快慢指针快指针先走N步然后快慢指针一起走。当快指针走到链表末尾时慢指针刚好停在要删除的结点的前一个位置。这题的关键在于“如何一趟遍历搞定”的思维而不是两次遍历。配合虚拟头结点可以避免“删除的是头结点”这种边界情况。“合并两个有序链表”——思路很像归并排序的merge阶段。两个指针分别指向两个链表头部比较当前两个结点的值谁小就接谁到新链表上然后对应指针往后挪。这题最经典的解法是递归代码极短但容易绕迭代解法代码长一点但更好理解。建议你先掌握迭代写法。“寻找链表的中间结点”——又是快慢指针快指针走两步慢指针走一步当快指针走到末尾时慢指针就在中间。这个思路和判环是同一个思想用熟了以后遇到“找位置”类的链表题第一时间就会想到快慢指针。我想强调的是同样一道题不同的人会有不同解法但你脑子里必须有几个“通用工具箱”找位置用快慢指针处理边界用虚拟头结点防断链就先保存后继动态内存就务必释放。这四条凑齐了链表题能解决大半。5. 新手写链表最常见的5个坑——调试经验实录5.1 空指针访问断言不写、释放不清新手最常见的崩溃现场就是访问了nullptr的成员。比如在只有3个结点的链表里用cur-next-next-next取第4个结点分分钟段错误。解决方案就一句话访问任何指针成员前先确认它不为空。另外delete一个指针之后如果后续还想拿它做判断记得置为nullptr否则它就是“悬空指针”指向一块已经还给系统的内存。我见过不少同学在循环里delete完结点下一秒还想用这个指针访问数据程序时好时坏报错没有一点规律调试起来非常崩溃。所有动态分配的内存用完之后要么释放要么释放后立刻置空两条路只能选一条不能既留着又当它存在。5.2 断链问题改前先存next前面写头插法和反转的时候反复提到“先保存下一个结点”这里我再说一个具体的翻车现场。有人写删除函数一上来就把cur-next cur-next-next看着很爽实际上没有保存“被删结点”后面想delete都找不到地址了。还有一种写法是先释放了toDelete再执行cur-next toDelete-next用了一个已经被释放的内存里的数据同样致命。铁律在改变一个结点的next指向之前如果你还需要它的原始值就先把它存到一个临时变量里。这条规则适用于所有链表操作尤其是反转、删除、头插。5.3 边界条件长度为0、长度为1的情况绝大多数链表题翻车都翻在边界条件上。空链表、只有一个结点的链表是两种最容易被忽视的情况。比如反转函数如果链表是空的或者只有一个结点循环根本进不去返回的结果必须是“原链表”这要写在测试用例里提前覆盖。我推荐一个习惯写任何链表函数时脑子里先跑三个测试用例——空链表、单结点链表、两个结点的链表。这三个过了一般逻辑就稳了。很多算法竞赛选手和工程师都是用这个思路自测的别嫌麻烦这是帮你省调试时间的。5.4 内存泄漏new和delete必须配对C写链表new了结点不delete就会内存泄漏。平时练习规模小可能感觉不到但一个长期运行的程序如果不断循环创建链表又不清空内存就会像漏水一样越积越多。最简单的方法就是把“释放链表全部结点”的逻辑写进析构函数保证链表对象销毁时能自动清理所有结点。我在自己的代码里还会刻意写一个clear()函数把链表清空后继续复用同一个对象避免反复构造析构带来额外开销。凡是new出来的结点要么在析构里释放要么在删除函数里释放绝不能出现“从链表中摘下来了但内存没释放”的情况。5.5 调试技巧画图加打印新手写链表光靠看代码很难看出问题我强烈建议你准备纸笔把所有指针变化画出来。画图时用不同颜色的笔区分不同的指针每执行一步就更新图。画着画着指针指错、断链、空指针这些问题就都暴露了。程序跑起来以后在关键节点加打印也不丢人。打印当前结点的值、打印前后指针的地址可以很快定位问题出在哪个操作上。提示写链表代码时最忌讳闷头把一整段写完才去测试。我建议每写完一个函数立刻写个最简单的main函数调用它打印一下链表内容确认无误后再写下一个函数。小步快跑比最后一次性排错要轻松得多。6. 把链表用到真实场景——做一个简化版“植物百科”管理程序前面讲的都是知识这一节我想带你做一个真实的小项目植物百科数据管理。这也是很多高校数据结构课程设计的常见题目——用链表作为核心数据结构实现对植物信息的增删改查。举个例子假设每一条植物记录包含编号、名称、科属、产地这几项。那结点可以这样定义struct Plant { int id; std::string name; std::string family; std::string origin; // 可以继续扩展其他字段比如花期、药用价值、图片路径等 }; // 链表结点直接用上一节的模板NodePlant 就能表示一条植物数据了 LinkedListPlant plantDB;增删改查的流程和前面讲的一模一样插入一条植物记录就相当于插入一个Plant结点按编号查找植物就相当于遍历链表找data.id targetId的那一项修改植物信息就找到对应结点后替换它的数据域删除植物记录就是按位置删除结点。这样一套代码下来之前学的基本操作全用上了还能积累一个可以写进简历的小项目。有人可能会问为什么不直接用数组在这个场景里植物的种类可能随时增加而且新增操作比较多不关心顺序用链表很自然。另一方面如果以后你要“按科属快速筛选”那哈希表或者索引会更合适。这其实是大局观的问题数据结构没有绝对的好坏只有“适不适合当下这个需求”。如果你想继续扩展还可以让植物百科支持“按科属排序”那就是链表的排序问题了或者支持“批量导入导出的括号表达式解析”那就涉及链表的嵌套和递归了。这些都可以一步步往上加数据结构课程设计的要求也基本能覆盖。7. 结尾关于链表学习路径的几句心里话写完这篇我最想对你说的是链表学得好不好不取决于你背了多少代码而取决于你有没有亲手把那些“箭头”画明白、改明白。我见过太多人收藏了一堆笔记到了要手写链表的时候还是卡壳。原因很简单链表的代码只有自己一行一行敲进编译器里调试过那些指针的变化才会真正长在你脑子里。如果你现在刚开始学我建议你按这个顺序练习先把“创建结点—头插—尾插—遍历打印”这四步跑通然后尝试“按值查找—按位置删除”接着练“反转”和“快慢指针判环”最后再挑战“合并两个有序链表”“找中间结点”。每一步都在纸上画一画在代码里跑一跑比刷十篇解析都管用。我第一次完全靠自己写出链表反转的时候兴奋到半夜给朋友发代码截图。那种“啊原来真的是这样”的感觉只有自己动手后才能体会到。希望这篇内容能帮你早点遇到那个瞬间。