尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

链表详解:从单链表到双向链表,核心操作与面试高频题一网打尽

链表详解:从单链表到双向链表,核心操作与面试高频题一网打尽 如果你正在啃数据结构翻到链表这一章大概率会产生一种又熟悉又陌生的感觉说它简单吧每道题都绕不过指针说它难吧核心概念无非就是“一个节点存数据再存一个指向下一个节点的指针”。链表在数据结构里的地位有点像盖房子时的钢筋——平时看不见但承重全靠它。不管你是正在准备期末复习、应付数据结构实验报告还是打算刷算法面试题又或者想用 C/C、Python 亲手实现一遍链表这篇文章都适合你。我会从“为什么要发明链表”讲起把单链表、双向链表、循环链表、核心操作和面试高频题一次说透最后再分享一些我当年踩坑换来的调试经验。1. 链表到底是什么先解决数组的包袱在给链表下定义之前我觉得有必要先回答一个更根本的问题数组用得好好的为什么还要发明链表这个问题的答案其实就是理解链表设计思想的第一把钥匙。1.1 数组看似万能其实藏着两个大麻烦数组是内存里一段连续的空间它的最大优点是随机访问快。你想取第 i 个元素直接通过首地址加偏移量算出来时间复杂度 O(1)这是几乎所有编程语言里数组都能高效工作的底牌。但与此相对数组有两个与生俱来的硬伤。第一个硬伤是插入和删除代价高。如果要在数组中间插入一个元素你需要把插入位置后面的所有元素整体后移一位腾出空位再放新值删除元素时则需要把后面的元素整体前移。最坏情况下在数组开头插入或删除整个数组的元素都要移动时间复杂度是 O(n)。数据量小的时候感觉不明显数据量一旦上了规模这个搬运成本会直接拖垮性能。第二个硬伤是容量固定。数组在创建时必须指定大小之后想扩容语言层面的动态数组比如 C 的 std::vector、Python 的 list帮你做的也是“重新申请一块更大的连续内存 把所有元素搬过去”这件事。频繁扩容时这个搬家的开销会反复出现而且内存申请时还要求找到一整块连续的空闲区域内存碎片稍微严重一点大数组就可能申请失败。用一个生活类比来理解数组就像电影院里固定的一排座位座位数是定死的中间想加个人必须让大家都往两边挪想加一排座位只能把整个剧场重新装修。这个痛点就是链表诞生的直接理由。1.2 链表的核心设计用“线索”代替“连续空间”链表的解决方案很朴素既然连续内存这么难伺候那我就不要求连续了。链表把数据分散存放在内存的不同位置每个数据被包装成一个“节点”节点里除了数据本身还存了一个指针或引用指向下一个节点的内存地址。内存中一个个割裂的节点通过这个“线索”被串成一条逻辑上的链访问者只要拿到第一个节点头节点就能顺着指针依次走完整个链表。这个设计的精髓在于链表的顺序是逻辑顺序不是物理顺序。数组的“下一个元素”靠物理位置相邻保证链表的“下一个节点”靠指针显式指定。正因为如此链表换来了两个直接影响插入和删除效率高。只要找到正确的位置修改相邻节点的指针即可完成插入或删除不需要搬动其他数据理想情况下 O(1) 完成。内存利用率灵活。每个节点可以散落在内存各处申请时可以按需分配不用一次性找一大块连续空间。但天下没有免费的午餐链表也付出代价随机访问能力变得很差。想拿到第 k 个节点没有捷径必须从头节点开始沿着指针一步一步走 k 次时间复杂度 O(n)。这个特性决定了链表适合“读写频繁发生在头部或已知位置”的场景不适合“按下标频繁取数”的场景。2. 链表的家族谱单链表、双向链表、循环链表分别解决什么问题很多人学链表时有个误区以为链表就是单链表。实际上链表是一个大家族不同变体解决的是不同痛点。搞清楚它们的演进逻辑代码写起来会顺手很多。2.1 单链表最基础的形态也最考验指针功底单链表是最简单的链表形态每个节点只有一个 next 指针指向后继节点最后一个节点的 next 指向 nullptrC/C或 NonePython。它只能从头到尾单向遍历想回到上一个节点做不到只能再从头找。在 C/C 里的节点定义通常长这样struct Node { int data; // 数据域实际使用中可以是任意类型 Node* next; // 指针域指向下一个节点 Node(int val) : data(val), next(nullptr) {} };如果用 Python则是这样class Node: def __init__(self, val0, nextNone): self.val val self.next next单链表里最容易出错的地方就是指针操作。我见过太多同学写插入或删除时把指针赋值的顺序搞反导致链表直接断成两截。后面第 3 部分我会专门拆解这些操作的顺序问题这里先记住一个结论单链表操作的核心是永远维护好“前驱节点”这个角色。2.2 双向链表用空间换时间的典型代表单链表最大的尴尬在于删除一个节点时你必须先拿到它的前驱节点而单链表里没有往回指的指针所以只能从头遍历找到前驱。为了干掉一个节点的代价是 O(n)这显然不够优雅。双向链表的解法很直接每个节点多加一个 prev 指针指向前驱节点。节点定义随之变成struct DNode { int data; DNode* prev; DNode* next; DNode(int val) : data(val), prev(nullptr), next(nullptr) {} };双向链表付出的代价是每个节点多存一个指针内存占用增加换来的是查找前驱节点变成 O(1)而且可以从尾节点向前遍历。这个交换很划算所以实际工程里使用最广泛的往往不是单链表而是双向链表。C 标准库里的 std::list 就是双向链表Java 的 LinkedList 也是。2.3 循环链表让链表的尾巴咬住头循环链表指的是把最后一个节点的 next 指针指回头节点形成闭环。单链表和双向链表都能做成循环版本其中循环双向链表最灵活。循环链表解决的核心问题之一是“从任意节点出发都能走遍所有节点”。经典的约瑟夫环问题一群人围成一圈报数数到某数的人出列用循环链表几乎是天然匹配操作系统进程调度里的时间片轮转也可以把进程节点挂成循环链表从头走到尾再绕回来公平地给每个进程分配 CPU。判断一个链表是否有环本质也是在判断它是不是“部分循环”了。除了上面三种主流形态还有一种在实际刷题和工程里很好用的小技巧需要提一下带头节点的链表也叫 dummy node 或哨兵节点。所谓头节点是一个不存实际数据的额外节点它的 next 指向真正链表的第一个节点。它的意义在于当链表为空或我们要在头部插入/删除节点时代码逻辑跟操作中间节点完全一致不需要单独写特判。这个技巧在后面代码示例里我会用到。3. 核心操作手把手拆解插入、删除、反转的正确姿势链表的基本操作网上一搜一大把但很多教程只给代码不给思路导致新手看完照抄能跑换个场景就懵。我在这部分不只讲怎么做还会讲每一步为什么必须这么做。3.1 插入节点先接右再断左在链表的指定节点 prev 之后插入一个新节点核心代码只有三行// 在 prev 节点之后插入值为 val 的新节点 Node* newNode new Node(val); newNode-next prev-next; // 第 1 步新节点先指向原后继节点 prev-next newNode; // 第 2 步前驱节点再指向新节点三行代码里藏着链表操作最重要的一个原则先接右再断左。第 1 步必须先用新节点的 next 记住原先后继节点的地址第 2 步才能安心地修改 prev-next。如果顺序反了先把 prev-next 指向 newNode那么原来位于 prev 后面的那整段链表就从链子里断开了而且再也找不回来——因为你没有给 newNode-next 赋值的时机节点地址就此丢失。这个错误写一次就长记性因为它会导致链表凭空蒸发一大截。头插和尾插本质上是这个操作的两种特例。头插时把 prev 看作链表头节点或 dummy 节点尾插时先遍历到最后一个节点再执行同样的逻辑。到这里你应该能体会到 dummy 节点的好处有了它头插和普通插入共用一套代码没有多余的边界分支。3.2 删除节点拿到前驱就成功了一半删除一个节点只要拿到它的前驱节点事情就好办了。假设要删除 prev 后面的那个节点Node* tmp prev-next; // 先记录要删除的节点 prev-next tmp-next; // 跳过该节点 delete tmp; // 释放内存C 必须手动做很多新手会下意识地想“直接删掉这个节点不就行了吗”问题在于单链表里你只能从当前节点往前走如果没有前驱指针单链表没有 prev你删掉当前节点后前驱节点的 next 还指着这块已释放的内存链表结构就坏了。所以删除操作的关键不是删除本身而是提前把前驱关系处理好。Python 版不需要手动释放内存逻辑类似def delete_after(prev): if prev.next is None: raise ValueError(no node to delete) prev.next prev.next.next需要特别提醒删除节点后C 里务必记得 delete。如果只是把指针跳过而不释放内存每删一个节点就泄漏一块堆内存程序跑久了内存会涨到怀疑人生。我当年写链表实验报告时因为这个错误被导师点名批评过后来养成了“delete 之后立刻把指针置空”的习惯。3.3 反转链表面试出场率第一名反转链表是链表题里最经典的题目没有之一。算法面试出题人尤其喜欢拿它来考察候选人对指针流动的理解因为它代码短、陷阱多、一紧张就写错。迭代版的核心思路是三个指针prev、curr、next。每一轮循环里先保存 curr 的下一个节点因为马上要断链然后把 curr-next 指向 prev接着整体向右移动指针。代码长这样Node* reverseList(Node* head) { Node* prev nullptr; Node* curr head; while (curr) { Node* next curr-next; // 先保存后继 curr-next prev; // 反转指针方向 prev curr; // 前驱右移 curr next; // 当前节点右移 } return prev; // 循环结束时 prev 就是新的头节点 }这段代码最容易写错的地方有两个。一是忘记在循环开头保存 next导致反转后无法继续遍历二是最后返回值写错——循环结束时 curr 已经变成 nullptrprev 指向原链表的最后一个节点也就是反转后的头节点经常有人图省事返回 curr结果返回了个空指针。调试这类问题时我的习惯是在纸上把链表节点画成小方框用箭头代表指针跟着代码走一遍循环比盯着屏幕盲猜效率高得多。除了迭代法反转链表也能用递归实现代码更简洁核心思想是“假设后面的链表已经反转好再把当前节点接到末尾”。不过递归深度等于链表长度链表很长时有栈溢出的风险工程实现里一般优先用迭代。4. 链表在工程里的真实用武之地讲完基础操作有人会问链表看起来就是在内存里穿来穿去现实项目里真的有人用吗答案是不仅用而且用得比你想的广泛得多。4.1 操作系统底层和开发框架里的链表在操作系统内核里链表几乎是内存管理的地基。比如动态内存分配器需要维护一块块空闲内存最常用的结构就是空闲链表——每次分配内存时遍历空闲链表找到合适大小的块释放内存时又把块插回链表。因为内存块在地址空间里本来就不连续链表比数组合适得多。再比如操作系统里的任务调度就绪队列里的进程控制块经常用双向链表串起来支持随时把一个进程踢出队列、把一个新进程插到队尾。文件系统里也有链式思想的影子某些文件系统的索引节点之间靠指针串联读一个文件就像沿着链表走一遍不需要文件内容在磁盘上是连续存储的。人话版本解释电脑里的软件能同时在一堆后台任务中切换背后很大程度靠的就是内核用链表维护“下一个该轮到谁跑”的状态。你每打开一个软件相关的任务节点就在某个链表里被插入或删除一次。4.2 经典算法场景LRU 缓存、约瑟夫环、多项式运算面试和课程设计里经常遇到的 LRU 缓存淘汰算法是链表工程价值最典型的体现。LRU 的核心是每次访问一个数据都要把它标记为“最近使用”缓存满时淘汰“最久未使用”的那一个。这个场景里哈希表负责 O(1) 查找双向链表负责 O(1) 插入和删除新访问的节点移到链表头部末尾的节点就是最久未使用的候选。这就是为什么很多语言内置的 LRU 实现都带着一个双向链表。约瑟夫环问题也是链表课的经典案例。把 n 个人从头到尾串成循环链表每次从某个位置开始报数报到 k 的人出列本质就是从链表中删除节点然后从后继节点继续报数。循环链表天然支持这种“绕圈”行为代码写起来比用数组处理取模运算直观很多。多项式加法也可以用链表做每个节点存放一项的系数和指数按指数降序串起来相加时两个多项式各自从头遍历指数相等的项系数相加本质上就是链表的合并和插入操作。我当年数据结构课设做“植物百科数据管理与分析”时内部就是靠链表挂接各类植物记录的插入、删除、遍历一套操作下来比用固定数组灵活太多。5. 面试高频题与避坑清单考前看这一份就够了如果你正在准备数据结构面试光会写“插入删除节点”是不够的。面试官很喜欢在基础链表操作之上叠加几个经典套路我把自己实践里最常遇到的问题整理成快解思路和错误清单。5.1 高频题快解思路速览检测链表中是否有环快慢指针慢指针每次走一步快指针每次走两步。如果相遇说明有环快指针走到 nullptr 说明无环。这是链表题里“双指针”思路的祖传例题。找链表中间节点同样用快慢指针快指针走两步、慢指针走一步快指针到末尾时慢指针正好在中间。找中点是很多题比如回文链表判断的前置步骤。找倒数第 k 个节点让快指针先走 k 步然后快慢指针同步走快指针到末尾时慢指针指向倒数第 k 个节点。合并两个有序链表不断比较两个链表当前节点的值把较小的接到结果链表的尾部递归或迭代都能实现。迭代版建议用一个 dummy 节点可以省去判断“谁是头节点”的麻烦。删除链表中倒数第 N 个节点先找到倒数第 N1 个节点也就是待删节点的前驱再执行删除操作。这时候你会发现链表删除题通通绕不开找前驱这个问题。5.2 常见错误和调试技巧速查表常见错误具体表现解决方法插入时指针赋值顺序错了链表后半段丢失打印链表只剩前面几个节点记住“先接右再断左”新节点先连后继再改前驱删除节点忘记释放内存C 程序内存持续增长最终崩溃或被系统杀死delete 后立即将原指针置空养成习惯空指针解引用访问 nullptr-next 报 Segmentation Fault每次访问节点前检查是否为 nullptr尤其是循环边界反转链表返回错误返回结果为空或链表丢了一半牢记返回 prev 而不是 curr循环结束后 curr 一定是空修改链表头部后没更新头指针链表从头部开始访问时出错所有可能改变头部的操作结束后显式更新 head循环不会终止程序死循环检查循环链表操作时的退出条件必要时设置计数器辅助定位调试链表的技巧我自己的经验是三步走。第一步永远先在纸上画出链表结构和指针变化尤其是做反转和删除这类操作第二步在代码里写一个 printList 辅助函数每步操作后打印整个链表很快就能定位到哪一步断链第三步边界测试一定要测空链表、只有一个节点的链表、只有两个节点的链表以及删除头节点和删除尾节点这几种情况。很多隐蔽 bug 都是在这些边界条件下现形的。6. 新手学习路径从看懂到能独立写出来如果这篇文章你已经看到了这里说明至少对链表产生了兴趣那我再分享一条我走过之后觉得最高效的学习路径。第一步先在 C/C 里手动实现一遍单链表。为什么要选 C/C 而不是直接上 Python因为 C/C 的指针是显式的你能亲眼看到“内存地址”在指针变量之间传递这比 Python 抽象后的“引用”更能建立内存直觉。我自己当年学链表时在 Python 里写了很多遍都没彻底通透后来转到 C 重新敲了一遍头插、尾插、删除、反转突然就懂了——因为每次出错都能明显感受到是地址问题还是逻辑问题。第二步试着用模板类做一个通用的链表。C 里把节点里的 int 换成 template 让链表能存任意类型数据。这个练习看起来只是语法升级实际上会逼迫你重新审视节点定义和操作逻辑是很多人忽略的进阶训练。第三步用 Python 或 Java 再实现一遍同样功能对比不同语言在“引用”和“指针”上的差异。你会发现 Python 里 Node.next 本质上也是引用只不过语言帮你管理了内存生命周期写起来少了一些心理负担但核心逻辑完全一致。第四步开始刷题。建议按这个顺序来先刷“反转链表”“链表中环检测”“找中间节点”这三道基础题再刷“合并有序链表”“删除倒数第 N 个节点”“回文链表”这三道综合题。每道题都先自己在纸上想清楚思路再动手写代码最后再去看题解对比优化比直接背题解有效十倍。回到开头那个问题“数据结构之什么是链表”我觉得最准确的回答是链表是用指针显式维护逻辑顺序的数据结构它用“放弃随机访问”换来了“灵活插入删除”的能力。这个 trade-off 的思想比链表本身更重要。把链表吃透之后后面学栈、队列、树、图都会顺畅很多因为它们的底层存储结构里到处都有链表的影子。我至今记得当年第一次独立写对链表反转时那个兴奋劲——指针在节点间流动起来的那一刻你会觉得整个内存模型都活了过来。希望这篇总结也能带你找到那种感觉。
返回列表