ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

双向链表详解:从结构设计到插入删除的工程实践

双向链表详解:从结构设计到插入删除的工程实践 做嵌入式开发这几年我几乎每个项目里都要和链表打交道但真正让我把双向链表用明白的是一次做设备菜单系统的时候——上下级菜单切换、返回上一级、记录操作路径单链表根本玩不转调来调去全是“回头”的痛。今天这篇《初阶数据结构05》就专门聊聊双向链表它比单链表多出来的那根前驱指针到底解决了什么问题、怎么实现、有哪些坑是新手必踩的以及它在真实项目里是怎么出场的。这篇文章适合正在学数据结构的学生、准备考研复试的兄弟以及工作中需要自己写基础容器的朋友我会把从结构定义到完整代码、再到选型对比一次讲透。1. 单链表最尴尬的痛点只能往前不能回头1.1 单链表的“回溯灾难”先回忆一下单链表的结构每个节点只存一个指向后继的指针。这意味着你拿到某个节点之后想访问它前面的节点没有任何直接路径只能从头节点开始重新遍历一遍。这不是理论上的绕路而是实际就会踩到的坑。我就遇到过这样的需求一个操作记录系统需要支持“上一步”和“下一步”来回切换。用单链表实现时“下一步”很简单顺着next走就行可“上一步”就麻烦了每次都得从头遍历到当前位置的前一个节点时间复杂度是O(n)。记录多了以后界面肉眼可见地卡顿。你可能说那我多存一个变量记录当前节点的前一个节点不就行了问题是当“上一步”执行后当前位置变了你记录的那个“前节点”也会跟着失效。你永远需要一种能从当前节点直接回溯的能力而不是临时去查。这就是双向链表存在的根本动机让每个节点同时知道它的“前任”和“后任”。代价是每个节点多存一个指针换来的是“向前走”和“向后走”都是O(1)的天花板级效率。“空间换时间”这四个字在这里体现得最直白。1.2 双向链表的本质改进空间换时间从存储开销看一个双向链表节点需要三个字段数据域、前驱指针prev、后继指针next。对比单链表节点的两个字段多了一个指针的大小。在64位系统下就是多8字节。你可能会想多这8字节值得吗这要看场景。如果你的核心操作是“频繁删除当前节点的前驱节点”“判断两个节点是否相邻”“需要从后往前批量处理”那双向链表多出来的8字节可能是整个系统性能的关键。反过来如果你的需求只是往尾部追加数据、从头到尾遍历一遍那双向链表就是纯浪费。我个人的选型经验是当你需要“回退”“上翻”这类反向操作时先默认选双向链表当你的数据访问模式完全是单向流动时老老实实单链表。这个判断标准几乎不会错。不过这里要提醒一点很多教材在入门阶段会先用“不带头节点”的双向链表讲原理。但我在实际写代码和带新人时几乎一律用带头节点 循环的双向链表。原因后面我会专门说先记住这个结论。2. 结构设计与初始化比别人多一根指针边界却更好处理2.1 节点结构三件套在C语言里双向链表节点的结构长这样typedef int ListDataType; typedef struct ListNode { ListDataType data; // 数据域 struct ListNode* prev; // 前驱指针 struct ListNode* next; // 后继指针 } ListNode;这里有个新手容易忽略的细节prev和next的类型必须是struct ListNode*因为在结构体内部ListNode这个typedef别名还没有完全生效。如果你写ListNode* prev编译直接报错。我第一次教别人写链表时就见过卡在这儿的。有了这个结构你就能把多个节点串成双链结构。比如A节点的next指向BB节点的prev就必须指向A。双向链表最核心的约束就是两条每个节点的prev和next必须互相匹配整个链的“头能到尾”“尾能到头”。在调试链表时我有个铁律写完插入或删除操作后一定要检查指针配对是否成立。所谓配对就是node-next-prev node以及node-prev-next node必须同时为真。这个检查几乎是所有链表bug的照妖镜。2.2 为什么我推荐带头节点 循环先解释“带头节点”。头节点也叫哨兵位它本身不存储有效数据只是作为一个固定的起始标记。不带头节点的链表空表时头指针是NULL插入第一个节点、删除唯一节点时都要单独写逻辑去更新头指针分支极其繁琐。带头节点之后空表也至少有一个节点存在所有插入删除操作对“空表”和“非空表”的处理逻辑完全一致。说白了哨兵位把“空表特殊处理”这个分支直接消灭了。再说“循环”。普通双向链表的两端都指向NULL遍历时以NULL为终止条件。循环双向链表则是让头节点的prev指向尾节点尾节点的next指向头节点整个结构是一个环。循环的核心好处有两个。第一从任何节点开始都能遍历整条链。第二尾节点的查找变成O(1)——head-prev就是尾节点不用从头一路走到黑。对于一个需要频繁在尾部插入数据的场景这是实打实的性能提升。所以带头节点的循环双向链表就是我在实战中的默认选择。它兼顾了代码简洁和操作效率而且后续实现栈、队列的底层容器时也都能直接复用。2.3 初始化与销毁代码初始化要做的就一件事创建头节点让它的prev和next都指向自己。// 创建一个新节点 ListNode* BuyListNode(ListDataType x) { ListNode* node (ListNode*)malloc(sizeof(ListNode)); if (node NULL) { perror(malloc fail); exit(1); } node-data x; node-prev NULL; node-next NULL; return node; } // 初始化带头节点的循环双向链表 ListNode* InitList(void) { ListNode* guard BuyListNode(0); // 哨兵位data随意 guard-prev guard; guard-next guard; return guard; }注意guard-prev guard和guard-next guard让头节点自环。这一步至关重要它保证了链表在“空”的状态下也满足循环约束后续所有操作都不用再判断“是不是第一次插入”。销毁时不能直接free(guard)因为链上还有其他节点。要先循环释放所有有效节点最后再释放哨兵位void ListDestroy(ListNode* guard) { if (guard NULL) return; ListNode* cur guard-next; while (cur ! guard) { ListNode* next cur-next; free(cur); cur next; } free(guard); }这里判断循环是否走完的标准是cur ! guard。因为循环链表里走完整个环一定会回到哨兵位。这个终止条件一定要记牢很多人写普通链表写习惯了到这里会拿NULL当终止条件结果直接访问野指针。3. 增删改查的实现细节每个指针操作都有它的顺序逻辑3.1 尾插法的四步接线带头节点的循环双向链表里尾插其实就是在哨兵位的前面插入。因为guard-prev就是尾节点所以逻辑上等价于“把新节点插到尾节点和哨兵位之间”。void ListPushBack(ListNode* guard, ListDataType x) { ListNode* newNode BuyListNode(x); ListNode* tail guard-prev; newNode-next guard; newNode-prev tail; tail-next newNode; guard-prev newNode; }这四步的先后顺序很有讲究。我见过很多新手会把最后两步写反比如先tail-next newNode再newNode-prev tail一旦newNode的prev原来指向的是自己或者脏值还没等修正链就已经断了一半。稳妥的顺序是先搭好newNode自己的两根指针指向 guard 和 tail。再断开旧链把tail-next和guard-prev重新指向newNode。只要遵守这个顺序中间无论是调试还是打印链表都不会处于不可恢复的断链状态。头插同理核心是“往哨兵位后面插”void ListPushFront(ListNode* guard, ListDataType x) { ListNode* newNode BuyListNode(x); ListNode* first guard-next; newNode-next first; newNode-prev guard; guard-next newNode; first-prev newNode; }3.2 删除节点的真正优势不需要找前驱单链表删除一个节点时最大的麻烦是什么你必须知道它的前驱节点才能把前驱的next跨过它连到后继。但单链表只有后继指针找前驱只能遍历时间复杂度O(n)。双向链表直接摧毁了这个痛点。当前节点的prev就在自己手里删除操作变成了纯粹的O(1)void ListErase(ListNode* pos) { ListNode* prev pos-prev; ListNode* next pos-next; prev-next next; next-prev prev; free(pos); }就四行代码不需要遍历不需要多余参数。你只需要拿到要删除节点的指针就可以直接把它从链上摘下来。这也是为什么很多需要频繁删除操作的底层容器内核里用的都是双向链表。删除在“双链”里要注意一个习惯问题如果你删的是哨兵位后面的第一个节点那就是头删删的是哨兵位前面的节点就是尾删。别去单独写头删尾删函数直接调ListErase(guard-next)和ListErase(guard-prev)就行逻辑统一代码更少。3.3 遍历与查找的小技巧双向链表的遍历最标准的是从哨兵位的下一个节点开始走到哨兵位结束void ListPrint(ListNode* guard) { ListNode* cur guard-next; while (cur ! guard) { printf(%d , cur-data); cur cur-next; } printf(\n); }如果你想倒着打印非常简单——把cur换成从guard-prev开始迭代方向改成cur cur-prevvoid ListPrintReverse(ListNode* guard) { ListNode* cur guard-prev; while (cur ! guard) { printf(%d , cur-data); cur cur-prev; } printf(\n); }正着走和倒着走边界都是哨兵位。这就是循环结构的好处你不必为了倒序遍历去专门维护一个栈。查找某个值的位置也只需要一次循环找到后返回节点指针。要注意的是如果你找到的是哨兵位说明没找到。所以查找函数里结尾会有一个坑哨兵位的data是无效的不能拿它来匹配业务数据。ListNode* ListFind(ListNode* guard, ListDataType x) { ListNode* cur guard-next; while (cur ! guard) { if (cur-data x) { return cur; } cur cur-next; } return NULL; }3.4 一个可直接运行的完整例子把上面的内容串起来写一个完整、可运行的例子。这个例子演示初始化、尾插、头插、删除中间节点、正反打印#include stdio.h #include stdlib.h typedef int ListDataType; typedef struct ListNode { ListDataType data; struct ListNode* prev; struct ListNode* next; } ListNode; ListNode* BuyListNode(ListDataType x); ListNode* InitList(void); void ListPushBack(ListNode* guard, ListDataType x); void ListPushFront(ListNode* guard, ListDataType x); void ListErase(ListNode* pos); void ListPrint(ListNode* guard); void ListPrintReverse(ListNode* guard); void ListDestroy(ListNode* guard); // …… 函数定义见上文这里省略空间以保持文章紧凑 int main(void) { ListNode* head InitList(); ListPushBack(head, 1); ListPushBack(head, 2); ListPushBack(head, 3); ListPushFront(head, 0); ListPrint(head); // 输出: 0 1 2 3 ListPrintReverse(head); // 输出: 3 2 1 0 ListNode* pos ListFind(head, 2); if (pos ! NULL) { ListErase(pos); } ListPrint(head); // 输出: 0 1 3 ListDestroy(head); return 0; }实际跑一下就会发现整个增删改查的过程非常丝滑。这也是为什么在很多教程里双向链表被当作“链表系列收尾之王”来对待——它把所有链式结构的优点都集齐了。4. 双向链表在真实项目中的出场方式4.1 浏览器前进后退的内存模型浏览器里那个“前进”“后退”按钮大家天天用但很少人把它和双向链表联系起来。其实它的核心就是一个双向链表的移动过程。当前页面是一个节点访问新页面就在当前节点后面插入新页面并把当前节点指针往后移点“后退”就顺着prev往前走点“前进”就顺着next往后走。这里有个细节能看出双向链表的巧思当你后退到某个旧页面后再访问一个新页面这个新页面会覆盖掉原来“前进路径”上所有的后续节点。这个操作在双向链表里就是“删除当前节点后面的所有节点”因为有后驱指针可以顺着next把所有未来节点全部摘除。如果用单链表实现你甚至没法高效地知道当前节点后面还有哪些节点。4.2 多级菜单的上下级导航热词里出现了“双向链表多级菜单”说明这个场景大家是真的关心。嵌入式设备或者桌面软件的菜单系统天然就是一棵树但你在界面上的操作是线性的进一级菜单是往下钻返回上一级是往上弹。用双向链表实现菜单导航每个菜单项存放指向子菜单的指针和指向父菜单的指针。当前菜单项要进子菜单就current current-child要返回父菜单就current current-parent。这和链表的前驱后继逻辑完全一致。我做过一个实际的设备菜单项目底层就是用这个结构实现的。最直接的好处是返回上一级不需要维护调用栈不会栈溢出切换层级清晰明了逻辑都在链表指针上。当时旁边同事用递归加栈实现每次深入一层就多一分栈溢出的风险回头改起来还麻烦。4.3 LRU缓存与内存管理中的身影再往深一点说操作系统里经典的LRU缓存淘汰算法最常见的实现就是“哈希表 双向链表”。哈希表负责O(1)查找数据在不在缓存里双向链表负责维护数据的访问顺序每次访问一个数据就把它移动到链表头部缓存满了就淘汰链表尾部的节点。为什么这里必须用双向链表因为移动一个节点到头部需要同时操作它的前驱和后继而缓存淘汰时你又必须O(1)删除尾节点。单向链表在删除时还得从头找前驱一来一回性能就崩了。内核的很多链表设计、内存块管理底层也都是这种双向链表思想。所以别小看今天学的东西。双向链表不是应付考试的抽象概念它是很多高性能组件的底层地基。5. 高发踩坑点与考研面试高频考点5.1 指针断链和野指针的典型场景我在带人写双向链表时最常见的bug就是指针顺序写错导致断链。断链的表现是插入后打印链表中间少了一个节点或者直接死循环。典型错误场景是这样的// 错误的插入逻辑 tail-next newNode; // 先把原生链断了 guard-prev newNode; newNode-prev tail; newNode-next guard;顺序稍一乱原来的tail-next还没来得及读完就被覆盖成newNode导致后续新节点找不到正确的后继。这种bug如果在大型项目里出现单靠看代码很难发现因为报错不一定在这个函数可能要到遍历或销毁时才爆出来。我的习惯是插入操作永远先修改新节点的指针再修改链上节点的指针。删除操作刚好反过来先把链上节点的指针接好再释放当前节点。这样做能最大程度避免悬垂指针和断链。第二个高发坑是销毁时用了NULL终止条件。循环双向链表如果忘了哨兵位这个环在while循环里判断while (cur ! NULL)那么走到哨兵位时cur会变成哨兵位它的next不是NULL继续往下走就拜访了已经被释放的内存行为完全不可预测。这就是为什么销毁和遍历时统一以guard为终止条件。5.2 面试官最爱问的三件事考研和求职面试里双向链表是高频考点但问题其实就围绕三件事。第一单链表和双向链表的区别。标准答法是存储结构不同双链表多一个前驱指针删除操作单链表是O(n)双链表是O(1)双链表代价是多一个指针的内存开销。如果只答到这里只是及格。加分项是补充一句“所以在需要频繁删除、回退的场景双链表是更优解否则单链表更省空间”。第二链表中环的检测。这个题虽然通常拿单链表考但原理在双链表里同样适用。快慢指针法慢指针一次走一步快指针一次走两步如果两者相遇说明有环。循环双向链表本身就是个环面试时可能会让你判断某个链表是不是循环双向链表或者找出环的入口节点。第三删除节点时要不要考虑哨兵位。如果给的是“不带头节点”的双链表删除头节点时需要单独处理——让头指针指向second节点并更新second的prev为NULL。但如果是带头节点的版本ListErase那段代码就是普适的。很多学校的期末考试会让你在白纸上手写双链表删除少写一个prev-next next就算错了。5.3 易错点对照表易错点错误表现正确做法插入时先改原链指针链表断链、节点丢失先设置新节点指针再修改原链前后节点指针删除时先free节点后续访问悬垂指针先接好前驱和后继再free当前节点遍历终止条件用NULL循环双向链表死循环或越界统一用cur ! guard判断忘记维护循环特性尾插后guard-prev不对每次操作后检查guard-prev是否指向尾节点哨兵位参与业务查找查到无效数据遍历时从guard-next开始遇到guard就停止这张表是我带新人时整理出来贴在工位旁边的。现在分享出来大家可以对照自查。6. 怎么选型数组、单链表还是双向链表6.1 三种结构的成本对比学到这里很多同学会问那是不是以后都用双向链表算了不是的。选型要看真实需求。我画了一张对比表帮你看清楚三种结构的差异。维度数组单链表双向链表随机访问O(1)O(n)O(n)头部插入O(n)O(1)O(1)尾部插入O(1)支持扩容时均摊O(n)无尾指针O(1)删除指定节点O(n)需搬移O(n)需找前驱O(1)向前遍历支持不支持支持额外内存开销无或少量每个节点1个指针每个节点2个指针注意数组尾部插入之所以是O(1)是均摊意义下的——需要扩容时得搬数据。链表头部插入是真O(1)因为不需要搬动已有数据。所以结论很简单如果核心场景是频繁按下标读数据用数组如果数据量大、频繁在头部插入删除用单链表如果既要频繁插入又要频繁回退、删除指定节点用双向链表。6.2 什么场景我坚决不用双向链表我在实际项目里也遇到过不少双向链表不是最优解的时刻。最常见的场景是数据规模小且生命周期短比如一个局部临时列表一百个节点都不到此时双向链表多出来的指针内存和时间开销都很小但也谈不上优势用数组更简单直接。另一个我坚决不用的场景是纯追加型日志缓冲区。数据只会往后写满了就整体丢弃从不回头访问也不删除中间节点。这种场景用数组或单链表就够双向链表多出来的prev更新成本和时间开销属于纯浪费。还有一种情况是并发环境下双向链表需要维护两个方向的指针一致性加锁的粒度更细、竞争更容易出问题。如果你的系统并发压力大优先考虑无锁单向链表或者数组加锁实现会简单很多。6.3 其它变种循环双向链表、带尾指针的双向链表除了标准双向链表还有两个变种值得了解。带尾指针的双向链表就是在链表结构体里额外存一个tail字段专门指向尾节点这样即使不做循环尾插也是O(1)。这个变种适合不想用循环结构、但确实需要频繁尾插的场景。代价是你得保证这个tail在插入删除时同步更新多了一份维护负担。循环双向链表我们已经详细讲了。在Linux内核里大量的链表都是这个结构只是节点的data字段是一个嵌入在结构体中的list_head而不是像教学代码这样直接存放一个基本类型。这套设计被称为“内核链表”它把链表从具体数据类型里解耦出来你只需要在自定义结构体里放一个list_head成员就能把任意结构体挂上链表。理解了今天这篇的基础逻辑再去看内核链表会觉得一切都很熟悉。还有一个变种是“带头循环双向链表 哈希表”也就是之前提到的LRU缓存的标准姿势。这种复合结构在系统设计面试中是高频考点值得在学完基础后进一步研究。最后聊点实操体会双向链表这个东西代码量不大逻辑也不复杂但真正写对、写顺、写出工程水准是需要刻意练习的。我个人的建议是学完这篇之后不要只看代码拿张纸把插入和删除的每一步指针变化画出来画一遍就再也不会错了。调试时也分享一个我自己的土办法打印链表时把每个节点的prev地址、data、next地址都打出来然后对照检查每个节点的node-next-prev是否等于node。这个办法虽然啰嗦但在链表bug面前比任何高级调试器都管用。接下来你可以继续往后走把双向链表用在栈、队列的底层实现上或者去研究内核链表、LRU算法。数据结构是工具也是思维的训练场双向链表只是其中一个环节把这一步走扎实后面的图、树都不再是难题。
返回列表