新闻详情

新闻详情

首页 / 资讯中心 / 详情

C语言实现带头结点双向循环链表:定义、增删查改与调试要点

发布时间:2026/9/29 7:56:02来源:尧图网络
C语言实现带头结点双向循环链表:定义、增删查改与调试要点
如果你已经跟着前面几篇把单链表、顺序表都过了一遍大概率会遇到一个很别扭的场景想在单链表里删除某个结点却必须从头遍历找到它的前驱想在尾部插入数据也得先跑到链表末尾。这些操作的时间复杂度卡在 O(n)数据一多就明显拖后腿。双向链表Doubly Linked List就是为了解决“单向性”这个短板而生的——每个结点多存一个前驱指针换来的是前后走动都方便。这篇是初阶数据结构系列的第五篇我用 C 语言把双向链表的定义、增删改查、核心应用和调试思路一次讲透适合正在学数据结构、备考或者写小项目时想自己封装一个列表的人参考。1. 双向链表与单链表的本质差异1.1 单链表最大的短板在哪里单链表每个结点只有一个 next 指针顺着 next 一路往下走能轻松拿到“后继”但想回头找“前驱”对不起没有任何捷径。比如有个需求删除某个已知结点 p。如果 p 是头结点还好是中间结点的话你必须从头开始遍历找到 p-prev 是哪一个然后才能把它和 p-next 接上。这个遍历过程最坏情况下要跑完整个链表。同理尾插操作要不断更新尾结点但单链表只有头指针每次尾插都得找尾巴。虽然可以额外维护一个 tail 指针把尾插降到 O(1)但删除最后一个结点的时候tail 怎么回退到前一个还是得从头遍历。此类场景频繁出现时单链表的弱点就暴露了。双向链表的思想很朴素在结构体里多加一个 prev 指针指向当前结点的前一个结点。这样每个结点都“认识”自己的前后邻居从任意位置出发都能双向行走很多原本别扭的操作就顺了。1.2 双向链表的指针结构与运行机制双向链表有两种常见形态不带头结点和带头结点非循环和循环。把它们排列组合一下常见的有四种形态但工程上、教材里和实际面试中默认好用的是带头结点的双向循环链表。它的结构可以这样理解每个结点有三个区域prev 指向前驱data 存数据next 指向后继。链表有一个哨兵结点通常叫 head它不存有效数据只作为链表的“锚点”。空链表时head-prev 和 head-next 都指向 head 自己插入数据后头结点的 next 指向第一个数据结点第一个数据结点的 prev 指向 head最后一个数据结点的 next 指向 headhead-prev 又指向最后一个数据结点。整个链表首尾相接形成一个环。这个结构带来的第一个好处是你永远不会访问到 NULL。以前在单链表里写 while (cur ! NULL) 遍历循环结束条件还算直观在双向循环链表里遍历条件变成 while (cur ! head)判断逻辑同样简单。第二个好处是空链表和非空链表的状态统一了所有插入、删除操作不需要对“空表”做特殊判断代码逻辑可以写得非常规整。这里我甚至愿意用一个比喻单链表像一条单行道所有车只能往一个方向开双向链表像一条双向车道随时可以掉头而带头结点的循环链表相当于在路口安排了一个永远在岗的“环岛标识”你转一圈总能看到它不会迷路。搞懂了这张地图写代码就顺了。2. 带头结点双向循环链表的定义与初始化2.1 结构体定义与命名习惯用 C 语言实现双向链表第一步是定义结点类型。以前写单链表时typedef int SLTDataType 这类重命名习惯我已经强调过很多次目的是让代码可维护——今天存 int明天改成存 double只要改一处 typedef。双向链表也一样typedef int LTDataType; typedef struct ListNode { struct ListNode* prev; // 前驱指针 struct ListNode* next; // 后继指针 LTDataType data; // 数据域 } LTNode;注意结构体内部引用自身时必须写成 struct ListNode*因为 typedef 的名字在结构体内部还没有生效。这种写法在 C 语言里是老规矩别为了省事不写 struct。我用 LTNode 作为重命名语义上对着“List Node”很多人也习惯用 LNode 或 DLNode风格差异不影响正确性关键是整个项目保持统一。2.2 哨兵头结点为什么必须带有人刚开始学双向链表时会问为什么非得带头结点直接让一个普通数据结点充当头部不行吗实际写代码你就明白了。假设不带头结点链表为空的时候 head 是 NULL。插入第一个结点时你得让 head 指向新结点而且新结点的 prev 和 next 都只能指向 NULL。删除最后一个结点时你得把 head 置回 NULL。每个操作都要额外判断当前链表是否为空、当前删除的是不是唯一结点代码分支一大堆写着写着就容易漏条件。带头结点则完全避开了这些分支。头结点永远存在哪怕链表没有任何数据它也能通过 prev 和 next 指回自身。插入和删除时不需要判断链表是否为空也不需要判断操作位置是否特殊只需要处理四个指针的修改即可。STL 标准库里的 list底层就是用带头结点的双向循环链表实现的这也从侧面说明了这个设计在工程实践中的价值。2.3 初始化与销毁的对称操作初始化要做两件事分配头结点内存把它的 prev 和 next 都指向自身。顺序不能反更不能只初始化其中一个指针LTNode* BuyListNode(LTDataType x) { LTNode* node (LTNode*)malloc(sizeof(LTNode)); if (node NULL) { perror(malloc fail); exit(1); } node-data x; node-prev NULL; node-next NULL; return node; } LTNode* ListCreate() { LTNode* head BuyListNode(0); head-prev head; head-next head; return head; }销毁链表则要反过来从第一个数据结点开始逐个释放最后释放头结点。释放的过程中要先把下一个结点的地址保存下来否则 free 完当前结点就找不到后面的结点了。这是链表释放最常见的翻车点void ListDestroy(LTNode* head) { assert(head); LTNode* cur head-next; while (cur ! head) { LTNode* next cur-next; free(cur); cur next; } free(head); }为什么 assert(head) 放在第一行因为如果外部误传了 NULL后续访问 head-next 会直接崩在调试阶段用断言可以快速定位问题。这个习惯我在写单链表的时候就已经强调过双向链表同样适用。3. 核心增删操作的代码实现与踩坑3.1 尾插与头插从 O(n) 降到 O(1)带头双向循环链表最有优势的操作之一就是尾部插入。不需要遍历到尾部因为 head-prev 直接指向的就是尾结点void ListPushBack(LTNode* head, LTDataType x) { assert(head); LTNode* tail head-prev; LTNode* newnode BuyListNode(x); tail-next newnode; newnode-prev tail; newnode-next head; head-prev newnode; }核心思路是先把原来的尾结点和新的尾结点接起来然后更新 head-prev。这里有个写代码的小技巧先处理新旧结点的双向关系最后再让 head-prev 指向新结点。因为你要是先把 head-prev 改了后面找旧尾结点就费劲了——当然你可以先保存 oldTail像我上面这样用局部变量 tail 保存逻辑上就不容易乱。头插的写法同样简洁void ListPushFront(LTNode* head, LTDataType x) { assert(head); LTNode* first head-next; LTNode* newnode BuyListNode(x); head-next newnode; newnode-prev head; newnode-next first; first-prev newnode; }新结点插到头结点和原来的第一个数据结点之间。你看不管是头插还是尾插代码的对称性都很强。以前在单链表里尾插要专门维护尾指针或者遍历到尾部在双向循环链表里直接 O(1) 解决。这也就是为什么很多底层组件在选择“需要频繁头尾操作”的数据结构时会优先考虑双向链表。3.2 删除指定结点无需找前驱删除指定结点 pos 的操作在单链表里最头疼在双向链表里却非常自然。因为 pos-prev 直接给了前驱pos-next 直接给了后继把两头接上、释放 pos 就完事void ListErase(LTNode* pos) { assert(pos); LTNode* prev pos-prev; LTNode* next pos-next; prev-next next; next-prev prev; free(pos); }这里我习惯在函数开头断言 pos 不为 NULL但不会断言 pos ! head。为什么因为删除头结点本身是一个逻辑错误但有人会在业务逻辑里去判断把它放在断言里也算合理各人风格不同。我更倾向于让头结点永远不被传入删除函数这个约束在调用侧保证而不是在每个删除函数里白白增加一次判断。配合查找函数我们可以实现“找到某个值的结点然后删除”的完整流程LTNode* ListFind(LTNode* head, LTDataType x) { assert(head); LTNode* cur head-next; while (cur ! head) { if (cur-data x) return cur; cur cur-next; } return NULL; }使用方式是在外部调用找到 pos 后先保存 pos-next再 ListErase(pos)然后继续处理下一个结点。注意 ListErase 会释放 pos 的内存之后不能再访问 pos 的任何成员否则就是悬垂指针问题。3.3 任意位置插入与删除的对称技巧更通用的插入操作是插在某个指定结点 pos 之前。这里有一个万能套路拿到 pos 的 prev然后让 prev、newnode、pos 这三个结点依次接好void ListInsert(LTNode* pos, LTDataType x) { assert(pos); LTNode* prev pos-prev; LTNode* newnode BuyListNode(x); prev-next newnode; newnode-prev prev; newnode-next pos; pos-prev newnode; }这个操作的时间复杂度是 O(1)因为不需要遍历到 pos 之前去找前驱。对比单链表在给定结点前插入还得从头找前驱双向链表的优势在这里非常明显。更妙的是基于这个 ListInsert头插和尾插都可以复用它——头插就是 ListInsert(head-next, x)尾插就是 ListInsert(head, x)。这种代码复用会让你的实现看起来非常清爽调试的时候也更省心。操作单链表已知结点双向循环链表已知结点尾插O(n)O(1)头插O(1)O(1)删除指定结点O(n)O(1)指定位置前插入O(n)O(1)遍历O(n)O(n)表格里这些复杂度对比建议背下来面试八股和笔试小题里经常出现。当然尾部插入在单独维护 tail 指针的单链表里也是 O(1)但删除尾结点时你依然需要知道 tail 的前驱这个时候双向链表就有决定性的优势了。4. 双向链表的经典应用与衍生结构4.1 双端队列与双向链表的天然契合很多人在学数据结构时会碰到“双端队列deque”这个概念但不少人分不清双端队列和双向链表的区别。双端队列是一种抽象数据类型它要求可以在头部和尾部都能插入、删除。STL 的 std::deque 底层用的是分段连续空间不是双向链表但如果你需要在 C 语言环境下自己实现一个双端队列带头结点的双向循环链表是极其自然的载体。为什么因为双向链表的 PushFront、PushBack、PopFront、PopBack 四个操作全都是 O(1)天然满足双端队列的性能要求。我在练习时经常用双向链表实现一个 Deque然后拿来做一个简单的任务队列比如生产者往尾部放任务消费者从头部取任务。这种场景下双向链表写起来比用数组舒服得多因为它不需要考虑扩容、搬移数据的问题。如果你在笔试里看到“用双向链表实现双端队列”的题直接把刚才写的头插、尾插、头删、尾删组合起来就行。4.2 链表的逆置与回文判断链表逆置是数据结构的经典练习。单链表逆置通常需要三个指针不断翻转而双向链表逆置在某些描述下有另一种视角交换所有结点的 prev 和 next 指针然后交换头尾指针。但注意带头结点的双向循环链表逆置后哨兵结点仍然要保持自己的 prev 和 next 互相接续的关系实际操作时可以直接用头插法的思路——依次摘下原链表的结点头插到新链表中这个思路在双向链表和单链表上都通用代码逻辑也更简单。回文判断也是一个高频考点。如果用单链表判断回文可能要配合快慢指针和反转后半段如果用双向链表你可以从第一个结点和最后一个结点同时向中间走逐个比较数据。这是因为双向链表天然支持从两端向中间遍历不需要额外找尾结点。这个特性在“指定位置找前驱”的场景里非常实用。4.3 缓存淘汰与系统内核里的同类思路LRU 缓存淘汰算法的经典实现就是哈希表加双向链表。为什么要双向链表而不是单链表因为在 LRU 中当缓存满时需要删除链表尾部的结点并且把这个结点的前一个结点更新为新的尾部单链表这时需要从头找前驱效率低。有了前驱指针删除尾结点一步到位。这虽然是操作系统的知识但数据结构的底层思路是相通的。再比如 Linux 内核中的 list_head本质上就是一种侵入式双向链表它不是把数据放进链表结点而是把链表结点内嵌到数据结构里。这个设计比教科书的实现更反直觉但核心还是双向链表的 prev/next 指针思想。如果你在学习阶段就把双向链表弄扎实后续看内核代码就不会一头雾水。4.4 STL list 与 deque 的取舍逻辑C 里 std::list 就是典型双向链表插入和删除操作不搬移已有元素但它的缺点也很明显内存不连续、缓存局部性差随机访问要遍历所以 std::list 一般只用于需要频繁插入删除且遍历量不大的场景。std::deque 则更像一个“分段数组”支持随机访问但头部尾部操作同样是常数时间所以在大多数需要双端操作的场景里deque 的表现反而比 list 更好。我在自己动手封装轮子的时候会这样区分如果只做头部和尾部的增删优先考虑 deque 的语义如果必须任意位置插入删除那才选双向链表。搞清楚取舍逻辑对面试答“你说一下 map 怎么实现、list 和 vector 的区别”这类问题是很有帮助的。很多初学者遇到 list 和 deque 分不清原因就是没有理解双向链表在工程中的定位——它解决的是“任意位置快速增删”的问题而不是“快速访问”的问题。5. 调试技巧与笔试高频陷阱5.1 野指针、悬垂指针与内存泄漏双向链表最容易出错的地方不是算法设计而是指针管理。这里的坑我一个个说。第一个坑是局部未初始化。你定义了一个 LTNode* pos直接拿来用却没让它指向任何有效的堆内存。这在新手代码里极常见典型的后果是段错误。解决办法只有一条要么初始化为 NULL要么直接用 BuyListNode 分配内存不要留未初始化指针。第二个坑是悬垂指针。ListErase 里把 pos free 掉之后调用侧如果还保留着 pos 的地址后面再去访问 pos-next 或者 pos-prev读到的都是已经被释放的内存行为完全不确定。所以在调用 ListErase 之前一定要先把下一个结点用变量保存下来或者在设计接口时就约定好——erase 之后pos 就失效了谁再用谁负责。第三个坑是内存泄漏。链表销毁不彻底只释放了数据结点忘记释放 head或者遍历循环条件写错导致有一批结点没被释放。检测内存泄漏可以使用 Valgrind在 Linux 下跑一遍就能看到哪些内存块没有释放。5.2 遍历循环与边界条件的正确写法搭配带头结点的双向循环链表遍历循环的标准写法是LTNode* cur head-next; while (cur ! head) { // 处理 cur-data cur cur-next; }注意循环条件必须是 cur ! head而不是 cur ! NULL。如果你写成 cur ! NULL程序会一直遍历下去绕着环永远走不完因为 cur 永远不会变成 NULL它会在遍历一圈后又回到 head接着继续跑直到访问到非法地址。这个错误用眼睛静态检查不容易发现但一旦运行现象就是程序假死或者崩溃。还有一个边界条件是插入到 head 前面这个操作。在带头双向循环链表里ListInsert(head, x) 实际上就是在链表尾部插入因为 head 的“前面”就是尾结点。如果你把这个操作误理解为“在头结点前插入”画图的时候就会乱。记住一个口诀head-prev 永远是尾head-next 永远是头。5.3 笔试小题里的易错点速查数据结构笔试里双向链表相关的小题通常围绕几个固定套路转。我整理了一个自测清单写代码前对着看一眼能有效防呆插入操作涉及 4 个指针修改newnode-prev、newnode-next、prev-next、next-prev缺一不可。删除操作是“先接后删”还是“先删后接”必须先让前后两个结点互相连接再 free 目标结点。头结点本身不存有效数据任何遍历都不要处理 head-data。判断结点 p 是否是尾结点如果是双向循环链表判断 p-next head。判断链表是否为空返回 head-next head 或 head-prev head。我把常见错误整理成一张速查表便于快速对照常见错误现象排查思路插入时只改了 2 个指针链表断链或顺序错乱画图核对 4 个指针修改删除前没有先接好前后结点后续遍历找不到后继检查 prev-next 与 next-prev 赋值循环条件写成 cur ! NULL死循环或程序崩溃改为 cur ! headfree 后还能访问 pos 成员悬垂指针erase 后立即丢弃 pos 地址销毁时没有保存 next部分结点泄漏先保存 next 再 free 当前结点5.4 实操调试建议与单步追踪方法如果代码写出来运行报错我的标配调试流程是先用一组最小数据复现问题然后在关键操作后写一个打印函数把链表从头到尾遍历一遍打印每个结点的 prev 和 next。以 3 个结点的双向链表为例如果某个结点的 prev 指向的不是它的实际前驱打印结果会立刻暴露问题。我曾经帮一个同学排查过一段代码他写了头插函数但运行后链表数据顺序完全颠倒。我让他打印每个结点的地址与 prev、next 的地址立刻发现他把 head 和第一个结点的连接写反了head-next 应该是新结点他却把新结点放在了第一个结点后面。这类问题静态看代码不容易发现一打印地址就清清楚楚。单步跟踪也是一个不错的选择在 ListInsert 或 ListErase 的入口、出口分别设置断点逐行执行观察变量窗口中各个指针的变化。链表题不画图、不看调试信息光靠脑补很容易漏掉指针细节。我自己的体会是凡是链表代码出 bug十有八九是“图没画清楚”“指针连接顺序没理清”和算法思路本身的关联反而很小。6. 一点实操经验回顾我自己学双向链表的路径发现最有帮助的一步是亲手把每个操作都画一遍指针连线图插入前画一遍插入后画一遍删除前画一遍删除后画一遍。把四个指针的修改顺序用序号标出来代码就会像填空一样顺畅。如果你现在写双向链表还容易卡壳建议先别急着敲代码找一张纸、一支笔把插入 3 个不同位置、删除 3 个不同位置的图都画出来再动手效率会高得多。上面这套实现不仅适用于 C 语言换成 C 也就是把 malloc/free 换成 new/delete接口语义基本不变。后续要扩展双向链表为 LRU 缓存、实现双端队列或者做链表排序都可以基于这份代码继续改。希望你也能在调试中体会到一个道理链表操作的难点从来不在语法而在于指针之间的联系把这些联系在纸上理顺了代码自然就稳了。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

开发工具选型指南:五个硬指标与决策流程 2026/9/29 8:54:53

开发工具选型指南:五个硬指标与决策流程

开发工具选型这件事,表面上看是"装个软件"的小事,实际上它直接决定了你未来半年到一年的开发效率、团队协作顺畅度,甚至项目的技术走向。我见过太多团队在项目启动阶段随手选了个工具,结果做到一半发现版本管理混乱、调…

阅读更多 →
Codex 实战:额度重置周期、配置技巧与高频报错排查指南 2026/9/29 8:54:53

Codex 实战:额度重置周期、配置技巧与高频报错排查指南

1. 先聊聊这次 Reset:周二额度刷新到底发生了什么今天又是周二,我照常打开 Codex 准备继续干活,突然发现额度回来了。说实话,上周因为赶一个项目,用量烧得比较凶,一度担心这周会被卡住。结果 Reset 准时兑现…

阅读更多 →
C# 通过 Socket 与 Modbus 通信:同步与异步配置骨架与验证 2026/9/29 8:54:53

C# 通过 Socket 与 Modbus 通信:同步与异步配置骨架与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Arduino入门实验:MQ-135气体传感器实现空气质量检测 2026/9/29 8:54:47

Arduino入门实验:MQ-135气体传感器实现空气质量检测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
CCNA-200-301 PDF 实操指南:从考试大纲到Packet Tracer实验落地 2026/9/29 8:54:47

CCNA-200-301 PDF 实操指南:从考试大纲到Packet Tracer实验落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
免装Android Studio:用cmdline-tools命令行安装与管理Android SDK全攻略 2026/9/29 8:54:46

免装Android Studio:用cmdline-tools命令行安装与管理Android SDK全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉