资讯动态

单链表与双链表深度解析:结构、操作与选型指南

发布时间:2026/10/17 14:53:28 来源:尧图企业网站定制
链表这种东西凡是写过代码的人几乎都绕不开。你在课本上可能已经背过单链表和双链表的结构定义一个节点存数据一个节点存指针双链表多一个前驱指针。但你有没有真正想过为什么有了数组还要设计出链表为什么很多系统的缓存、撤销历史、LRU机制最终都落在双向结构上这篇我打算按自己这些年写代码的实际感受把单链表和双链表从头到尾梳理一遍它们是什么、怎么实现、怎么选、常见的坑在哪。适合正在学数据结构的人也适合面试前临时抱佛脚的开发者当然更适合那些想把链表用在真实项目里但不确定该用单向还是双向的朋友。1. 链表到底是什么为什么绕不开1.1 先忘掉代码想想生活中的排队你在电影院排队买票队伍里的人不需要像高铁座位一样一个挨一个固定住。每个人只需要记得下一个人是谁队伍就能从头走到尾。如果你想找队伍中间某个人只能从队头一个个问下去如果有人中途离开队头只需要把他的下一个指向离开者后面的那个人就行队伍依然完整。这就叫单向链表节点之间靠“下一个是谁”的线索串起来。如果这个队伍还额外保留了“上一个人是谁”的信息你就可以从任何一个位置往回走甚至可以让人从队尾逆着往前查。这就是双链表。它比单链表多出来的那一条线索就是前驱指针。这个类比特别关键因为链表本质上不是“一块连续的内存”而是一串“各管各的、靠线索串联”的节点。每个节点在内存里可能东一个西一个地址完全不连续但它们之间的逻辑关系是完整的。这是链表和数组最大的区别也是它存在的根本意义。1.2 站在代码层面看节点结构抛开那些玄乎的讲解链表落地到代码里就两样东西节点结构和指针。单链表节点最朴素的写法大概是这样的typedef struct Node { int data; // 数据域可以是任意类型 struct Node *next; // 指针域指向下一个节点 } Node;双链表就是在单链表基础上多一个前驱指针typedef struct DNode { int data; struct DNode *prev; // 指向前面一个节点 struct DNode *next; // 指向后面一个节点 } DNode;从字段看双链表只是多了一个指针。但就是这一个字段改变了你能做的事情。头节点、尾节点、NULL边界这三个概念也随之出现链表第一个节点叫头节点最后一个节点的next指向NULL表示“后面没人了”。在双链表里头节点的prev也指向NULL表示“前面没人了”。这里我一直强调一个观点链表的学习要抓“指针关系”不要死记代码。只要你能在纸上把节点的指向画出来代码基本就是照图翻译的事。很多人链表写不对不是语法问题是脑子里没有那张图。1.3 跟数组比到底谁优谁劣数组的内存是连续的所以它有一个独特的优势按下标随机访问O(1)就能拿到第k个元素。链表没有下标你想找第k个节点只能从头部一个一个next下去最坏情况O(n)。但反过来数组在中间插入或删除一个元素时要把后续所有元素整体搬移最坏O(n)链表只需要改几个指针的指向只是局部操作理想情况下O(1)。举一个特别生活化的例子。你在文本编辑器里输入一段文字中间插一个字符如果是数组实现后面所有字符都要往后挪一格效率可想而知。如果用链表实现把新节点塞进去只需要改两边的指针关系就行代价非常小。这也是为什么很多底层数据结构会采用链表思想的原因在不要求随机访问的场景里它解决了“频繁插入删除”的痛点。不过天下没有免费的午餐。链表的代价是每个节点都要额外存指针内存占用比数组更高而且它天生对CPU缓存不友好因为节点分散在内存各处遍历时会频繁发生缓存不命中。这一点在大数据量的高性能场景里特别明显我在后面会专门谈谈选择问题。2. 单链表核心操作插入、删除、查找2.1 头插法和尾插法先搞清楚谁快谁慢单链表最基础的两个插入操作一个叫头插法一个叫尾插法。头插法每次把新节点插到链表最前面伪代码大概是Node *head; void insertToHead(Node *newNode) { newNode-next head; head newNode; }可以看到头插法不需要遍历任何东西找好头部指针直接改指向就行了时间复杂度O(1)。所以很多场景下如果你不关心顺序只是要快速累积节点用头插法非常高效。尾插法就稍微尴尬一点。如果你只维护了一个头指针那每次尾插都必须从头走到尾部找到最后一个节点再把新节点接上去时间复杂度O(n)。但如果你额外维护一个tail尾指针那尾插也能做到O(1)。很多新手写链表只留一个head导致明明可以O(1)的操作变成了O(n)这就是设计层面的失误。所以设计链表时先想清楚你主要在哪端操作再决定要不要多加一个尾指针。2.2 删除节点的经典坑单链表没法回头单链表删除节点最大的痛点是你只知道当前节点的下一个是谁却不知道当前节点的前一个是谁。而在单链表里删除一个节点本质上是让它的前驱节点跳过它直接指向它的后继。这就意味着你必须首先找到“前驱节点”。如果你恰好拿到了待删除节点的指针p但没有前驱节点信息最直观的办法是从head开始遍历找到p的前驱prev然后执行prev-next p-next。这个做法时间复杂度是O(n)。那有没有办法O(1)删除有一个著名的技巧把p后继节点的数据复制到p里然后删除p的后继节点。这招在面试中经常出现但它有个限制——如果p是最后一个节点后面没人可复制这个技巧就失效了。所以我经常跟人说单链表删除的“标准动作”是先找前驱再改指向最后释放内存或者交给语言垃圾回收。顺序绝对不能乱。很多人先改next再找前驱结果链断了还找不到原位置。2.3 查找、遍历与复杂度直觉单链表的查找无论按值还是按下标都只能从头开始走。按值查找最坏要把整条链走完O(n)。按下标也是第k个节点就得走k步。这一点和数组的差异是你做设计时必须牢记的链表不是为“查”而生的它是为“改”而生的。遍历链表是所有操作的基础最朴素的写法就是for (Node *cur head; cur ! NULL; cur cur-next) { // 处理cur-data }很多链表题反转、找中间节点、判断环其实都是在遍历方式上做文章。比如快慢指针一个每次走一步一个每次走两步本质就是把O(n)的遍历变成了“双线程”遍历。不要觉得这些技巧很玄它们全部建立在“单链表只能箭头指向下一站”这个基本物理规则之上。搞懂了这条规则很多看起来巧妙的解法都会变得顺理成章。3. 双链表从单向到双向多了什么3.1 双链表的定义和它的“对偶结构”双链表的结构定义前面已经给过了每个节点有两个指针域prev和next。它的逻辑结构是对称的从头部可以通过next一路走到尾部从尾部也可以通过prev一路走回头部。这就是它在某些场景下碾压单链表的原因你可以从两个方向遍历可以快速拿到前驱节点。这种对称结构带来最直接的变化就是删除操作。在单链表里删除给定节点p需要先找它的前驱代价O(n)。在双链表里p-prev这个字段直接告诉你前驱是谁你只需要把前驱的next改成p-next把后继的prev改成p-prev就可以把p摘下来全程O(1)。操作对称之后很多逻辑也随之对称。插入新节点时你不仅要设置新节点的prev和next还要反过来更新旧节点之间的链接。这个“两边都要改”的特性是双链表代码里最容易出错的地方方向改错、顺序颠倒都可能导致链表断裂。3.2 插入和删除的顺序必须看这张“图”我见过很多人写双链表时脑子里没有图全靠抄代码结果一写就废。其实双链表插入一个节点只要记住一句话先把新节点的两个指针拴好再去动旧节点的链接。以在节点p之后插入新节点s为例// 假设已经在p之后插入s s-prev p; s-next p-next; if (p-next ! NULL) { p-next-prev s; } p-next s;第三步里那个if很重要。如果p是最后一个节点p-next为NULL访问NULL-prev就会直接崩溃所以必须先判断是否为空。很多新手写这段时要么忘记处理尾节点要么先改了p-next导致后面取不到原后继这些问题归根到底是没在纸上画图。删除节点的情况类似。删除pif (p-prev ! NULL) { p-prev-next p-next; } if (p-next ! NULL) { p-next-prev p-prev; }不要小看这两个if。双链表里处处是NULL边界任何一步漏掉判断都可能在链表为空或操作边界节点时踩空。这也是为什么我建议你写双链表代码时一定要把“头结点prevNULL”和“尾节点nextNULL”这两个边界刻在脑子里。3.3 双链表的自由是靠内存换来的双链表相比单链表每个节点多了一个prev指针。假设指针占8字节64位系统那么100万个节点的双链表比单链表多出约8MB内存。这个数字在某些内存受限的嵌入式场景里可能直接决定方案能不能落地。这就是“自由是有代价的”。代价之外双链表还有一个工程上需要小心的点指针数量翻倍出错概率也翻倍。单链表只要管好一个方向的链接双链表要做两次链接维护稍不注意就可能出现“往前走走对了往后走走回不了头”的情况或者更糟插入时把旧节点的prev覆盖掉导致整个链表变成“单向失联”。所以我的建议是你的需求里只要出现了“需要频繁知道前驱节点”或“需要从尾部向前遍历”这两种情况双链表才是合理选择否则单链表就够了。尤其是很多只需要“排队”的场景单链表尾指针完全可以解决强行上双链表只是徒增复杂度。4. 单链表和双链表到底怎么选4.1 几个真实场景的对号入座我想用几个日常开发中非常典型的场景说清楚什么情况该选谁。一个是LRU缓存淘汰算法。它的核心操作是每访问到一个数据就把这个数据移到链表头部缓存满了就删除链表尾部最久没被访问的数据。这里既有“给定节点立刻把它摘下来移到头部”的需求又有“删除尾部节点”的需求。如果用单链表给定节点摘除需要O(n)找前驱性能完全扛不住用双链表给定节点直接通过prev找到前驱O(1)摘除再头插就行。这就是双链表的典型主场。另一个是文本编辑器的撤销功能。如果你把每一步操作存在链表里撤销时要回退到前一步你不仅要从尾部往前走还可能要回退后再前进。双链表正好天然支持双向游走。浏览器的前进后退历史也是一样的逻辑从头到尾是“前进”从尾到头是“后退”双链表结构天然贴合。再一个是简单的文件存储块管理。很多文件系统里的空闲块链表其实用单链表就够了因为只需要按顺序从头找一块可用的不需要频繁把某个块摘到队头。但如果你做的是一块“动态内存分配”场景碎片块要频繁合并、移动更多时候还是会考虑双向结构甚至直接用更复杂的结构。这里的关键判断标准就两条是否需要反向遍历是否需要O(1)删除指定节点两个都答“是”就别犹豫了。4.2 从时间复杂度到工程复杂度一张表看清我把单链表和双链表的关键差异整理成一张表方便你以后做选择时直接对照对比维度单链表双链表节点结构data nextprev data next内存开销较小每个节点多一个指针尾部遍历需从头开始O(n)可直接从尾向前O(1)删除给定节点需先找前驱O(n)直接通过prevO(1)插入操作已知前驱时O(1)基本思路类似需维护双向链接代码复杂度低不易错高边界处理多适用场景只需单向遍历、头/尾插入删除需要反向遍历、频繁删除指定节点这张表做完之后我发现一个规律工程里的选择从来不是“哪个更高级”而是“哪个更适合”。单链表结构简单、容错率高在很多只需要“单向排队”的场景里完胜双链表功能更强但你需要为它的灵活性多付出内存和心智成本。没有绝对的优劣只有适配的问题。4.3 工程实现中的一个隐藏决定要不要带哨兵节点这里额外说一个很多教科书不强调、但工程里非常常见的细节哨兵节点dummy node。就是一个永远存在的头节点它本身不存业务数据只为让代码逻辑统一。加了哨兵节点之后空链表也“有一个头”头插和尾插、删除第一个节点时代码里那些边界判断就可以少掉很多。我在实际开发里非常喜欢用哨兵节点。因为链表操作最烦的是什么是“第一个节点”和“最后一个节点”永远需要特殊处理。哨兵节点像一个“假的前驱”让所有节点都变成“中间节点”逻辑一下子干净了。这不算什么高深技巧但真的能让代码更不容易出bug。你可以自己试试在链表的前面挂一个dummy头插法就直接变成dummy-next newNode删除头节点也不用额外判断了。这也是为什么很多算法题解里反转链表、删除倒数第k个节点这类题都爱用一个dummy节点来省事。5. 链表面试题和工程坑一次说清5.1 高频题反转单链表三指针就够反转单链表是面试题里出场率最高的一个也可以说是检验链表基本功的试金石。思路其实很简单遍历链表把每个节点的next指向它的前驱节点。所以你需要三个指针prev(前驱)、cur(当前)、next(后继)。每次先保存cur-next再把cur-next改为prev然后三个指针整体后移Node *reverseList(Node *head) { Node *prev NULL, *cur head; while (cur ! NULL) { Node *next cur-next; // 先保存后继否则改指向后就丢了 cur-next prev; prev cur; cur next; } return prev; // 最后的prev就是新链表头 }这个代码最关键的细节就是“先保存next”。很多人第一次写会直接cur-next prev然后发现cur根本走不动了——因为next已经丢了。我在带人的时候总让他们先把这个顺序写进注释里保存后继、改指向、移动指针。练熟了之后反转链表就是肌肉记忆。5.2 判断链表有没有环快慢指针判断单链表是否成环经典做法是快慢指针慢指针一次走一步快指针一次走两步。如果链表有环快慢指针终会在环里相遇如果没有环快指针会先走到NULL。为什么快指针一次走两步就一定能在有限步内追上慢指针因为当快指针进入环后它和慢指针之间的相对速度是“每步拉近一个节点”环是有限的所以追上只是时间问题。有人问为什么不走三步速度差变成两步依旧可能相遇但推导更麻烦而且不一定总是稳定追上。所以工程上大家约定俗成用两步。这个判断可以延伸到寻找环入口节点属于更高阶的变体但基础永远是快慢指针思想。5.3 找中间节点也是快慢指针另一个常见的应用是找链表中间节点快指针走两步慢指针走一步当快指针走到末尾时慢指针正好指到中间。这题在工程里的实际意义不小比如你有一个很长的链表要二分查找或者要取中位数用快慢指针就不用先遍历一遍数长度了。同样找倒数第K个节点可以让快指针先走K步然后快慢一起走快指针到末尾时慢指针正好是倒数第K个。这种“距离固定”的思想在链表操作里很值得反复练。5.4 工程里最常踩的坑暴力程度远超想象写链表代码我总结过几个高发bug几乎每个学过链表的人都遇到过。第一个是空指针解引用。访问NULL-next或者NULL-prev直接崩。这种问题大多发生在处理尾节点、空链表时解决方式就是凡事先判空。第二个是死循环。链表里某个节点的next指回了自己或者前面的节点遍历永远走不完。常见原因是反转时没把原来的头节点next置为NULL或者插入时把新节点next指成了自己。这种bug特别隐蔽因为链表短的时候肉眼看不出来。我建议你写链表操作后随手写一个打印链表的辅助函数每次操作完都打一遍看看特别是边界情况下有没有形成闭环。第三个是断链顺序错误。单链表在中间插入节点时如果你先执行了前驱节点的next指向新节点再想拿原来后继可能就拿不到了。正确顺序永远是先把新节点接到后继上再让前驱指向新节点。双链表里同理先拴新节点两端的指针再改旧节点的链接。第四个是内存泄漏或释放错误。如果用C语言删除节点时要手动free但必须先摘除节点再释放否则可能释放了一个还被别人引用的节点导致野指针。在高层面的语言里双链表同样要小心节点互相引用垃圾回收时可能形成循环引用导致内存无法被回收。这些问题不遇到可能无感遇到了就是排查半天也找不到根源的折磨。6. 从实操出发的个人体会说了这么多最后分享点我自己的经验。我是真心觉得链表这个东西光看书和代码是不够的。你不妨找一张纸画一条几个节点的单链表和双链表然后模拟一遍头插、尾插、删除、反转的指针变化。画过几次之后那些代码对你来说就不可能再是死记硬背了。实操中我发现很多人觉得链表难不是难在语法而是难在脑中没有动态图。一旦你能把每一次指针修改都“看”成一条黄色的线的改变问题瞬间就清晰了。还有个经验是写链表代码最好能做到“不依赖具体语言”。你可以在纸上用方块和箭头把逻辑走通再翻译成C、Python或者Java。翻译的过程其实很简单方块变成struct或class箭头变成指针或引用NULL变成None或nil。逻辑走通了语言只是工具。另外我对链表复杂度的一点亲身体会是很多人一听说双链表更灵活就觉得双链表一定比单链表好。但在实际项目里我给一个简单消息队列做后台存储时从单链表换成双链表并没有让性能变好反而因为代码变复杂多出了两个诡异的边界bug。后来我把需求重新理了一遍发现我们的场景纯粹是先进先出根本不需要反向遍历于是又换回单链表整个世界都清净了。这个教训我一直记着先明确需求再选择结构而不是先选一个听起来更厉害的结构再强迫需求去适应它。链表是数据结构里最基础、也最能锻炼指针感的一块。单链表是骨架双链表是在骨架外边加了一条反向的筋。掌握好它们你会发现自己对内存、引用、复杂度的理解都会上一个台阶。这篇就写到这里接下来你手边如果有代码不妨直接写一个双链表从头到尾实现一遍插入删除——踩过那几个边界坑你就真的吃透了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑