☰
C语言实现带头哨兵的双向循环链表:核心代码与踩坑复盘
2026/10/17 9:21:51 网站建设 项目流程

昨天看了一天某老师讲双向循环链表的课,晚上躺床上还在想那个哨兵节点到底是怎么回事。今天趁热打铁,自己动手写了一份双向循环链表的实现,写完之后跟课件里的代码对了一下,发现确实"改了一点"——不是那种大改,但有几处思路跟老师课上讲的不太一样。跑一遍测试竟然一次通过,这种兴奋感可能只有写过链表的人才能懂。

这次实践解决的核心问题很简单:如何用C语言实现一个带头哨兵的双向循环链表,并提供初始化、头插、尾插、删除、遍历、销毁这些基本操作。适合正在学数据结构、刚看完视频课想自己练手的人看,也适合那些"看了就会、一写就废"的选手对照自查。我写的过程中踩了几个很典型的坑,也做了一点跟老师模板不一样的设计,这篇文章会把完整代码、改动思路和排查过程都摊开来讲。

1. 双向循环链表的核心思路,先对齐这几个概念

1.1 双向和循环,分别解决了什么问题

很多人刚学链表是从单链表起步的,当时最烦的两件事:往尾部插入要一路走到链表末尾,删除一个节点要先把前一个节点找出来。单链表每个节点只有一条"向后"的指针,所以这两个操作天然就要消耗O(n)的时间。

双向链表的做法是在每个节点里增加一个prev指针,指向前一个节点。这样"找前驱"就变成O(1)的操作,删节点不用再从头遍历了。而循环链表是把最后一个节点的next指回第一个节点(或者哨兵节点),让整条链表首尾相接。这样尾部插入也可以直接利用哨兵节点的prev指针找到链表的最后一个节点,在它后面插入,依然是O(1)。

把"双向"和"循环"组合在一起,头插、尾插、删除已知位置的节点,这三个最常用的操作全部变成常数时间。在实际项目中,比如内存管理里的空闲块链表、操作系统进程调度里的环形队列,很多场景都在用这种结构。它跟普通链表的关系,说白了就是普通链表加了两条规则:每个节点看得见前一个节点,整个链表没有终点。

1.2 哨兵节点:让边界条件变成普通情况

课上老师反复强调的参数叫"带头节点的双向循环链表"。这个"头节点"其实就是哨兵节点(dummy node),它不存有效数据,只作为一个固定的锚点。

没有哨兵节点的链表,空表就是head == NULL,插入第一个节点要单独处理,删除到空表也要单独处理。这些问题不复杂,但判断条件一多,写出来的分支就特别容易漏。加了哨兵之后,空表也是一条"只有一个节点"的循环链表,哨兵的next指向自己,哨兵的prev也指向自己。任何插入和删除操作都可以统一处理:在哨兵后面插入就是头插,在哨兵前面插入就是尾插,删除节点永远是处理"这个节点自己",不需要区分"是不是第一个"。

我在写代码的时候才真正理解了这样设计的好处:代码里不需要写if (list->head == NULL)这种分支,逻辑分支少了一大半,bug的藏身之处也就少了。这是整个双向循环链表设计里最值得品味的一笔。

2. 开始写代码之前,先确认这几个设计细节

2.1 结构体定义与函数接口规划

动手前我把功能清单列了出来,不是想到哪写到哪:

  • 初始化链表,创建哨兵节点
  • 头插和尾插
  • 按值删除第一个匹配的节点
  • 遍历打印
  • 销毁整条链表

结构体定义其实大家写得都差不多,关键是要意识到这个"链表"本质上就是一个哨兵节点指针:

typedef struct Node { int data; struct Node* prev; struct Node* next; } Node; typedef struct { Node* sentinel; int size; } List;

我把size计数也放到了List结构体里,这就是我一开始"改了一点"的地方——课上老师的版本里没有维护节点数量,打印结束条件是直接判断cur->next是否回到了哨兵。而我加了size,后面遍历的时候可以用计数器来判断,也可以在删除和插入时快速校验。

2.2 接口命名与职责分离

函数接口我这样划分:

List* list_create(void); void list_destroy(List* list); void list_push_front(List* list, int value); void list_push_back(List* list, int value); void list_delete_by_value(List* list, int value); void list_print(List* list);

为什么这样分?每个函数只做一件事,测试起来方便,出错了也好定位。很多新手写链表喜欢把所有逻辑塞进main函数里,结果跑起来出错根本分不清是插入的问题还是删除的问题。我当时在草稿纸上画了节点的prev和next指向关系,边画边写,这个习惯帮了大忙——链表题不画图,写到最后一定会晕。

3. 核心代码实现:我写在代码里的三处"小改动"

3.1 初始化与节点的创建复用

创建节点这个函数很普通,但我把哨兵节点的初始化也复用了它,省了一段单独写的逻辑:

Node* create_node(int value) { Node* node = (Node*)malloc(sizeof(Node)); if (node == NULL) { printf("malloc failed\n"); exit(1); } node->data = value; node->prev = NULL; node->next = NULL; return node; } List* list_create(void) { List* list = (List*)malloc(sizeof(List)); if (list == NULL) { printf("malloc failed\n"); exit(1); } Node* sentinel = create_node(0); sentinel->next = sentinel; sentinel->prev = sentinel; list->sentinel = sentinel; list->size = 0; return list; }

这里有个容易被忽略的小坑:create_node里分配内存后一定要马上检查返回值。虽然教学代码很少写这行,但在真实环境里malloc失败是会发生的事,我写的时候习惯顺手带上。

哨兵节点的data字段存0只是占位,打印遍历的时候要从哨兵的next开始,skip掉它自己。这一点我一开始写打印函数的时候忘过一次,结果把哨兵的0也打出来了。

3.2 插入与删除:判断条件上做的调整

头插和尾插的代码看起来几乎对称:

void list_push_front(List* list, int value) { Node* node = create_node(value); node->prev = list->sentinel; node->next = list->sentinel->next; list->sentinel->next->prev = node; list->sentinel->next = node; list->size++; } void list_push_back(List* list, int value) { Node* node = create_node(value); node->next = list->sentinel; node->prev = list->sentinel->prev; list->sentinel->prev->next = node; list->sentinel->prev = node; list->size++; }

这两段代码有个共同点:先把新节点的前后指针都接好,再改动原链表里的相邻节点指针。这个顺序非常关键——如果你先把sentinel->next改掉,再想通过它找到原来的头结点去修改它的prev,就会发现自己已经把原来的地址丢了。先接好新节点,再动老节点的指针,这是写链表铁律。

删除函数我改得比较多。老师课上给的版本需要"判断当前节点是不是哨兵",还需要保留前一个指针或者用循环配合。我写的是利用双向链表的特性,直接自回退:

void list_delete_by_value(List* list, int value) { Node* cur = list->sentinel->next; while (cur != list->sentinel) { if (cur->data == value) { cur->prev->next = cur->next; cur->next->prev = cur->prev; free(cur); list->size--; return; } cur = cur->next; } printf("Value %d not found\n", value); }

整个循环里我只维护了一个cur指针,因为每个节点都有prev,找到要删的节点之后,不需要额外遍历找前驱,直接cur->prev->next = cur->next就完成了"跳过当前节点"的操作。这比单链表里删除一个节点要方便太多,也是双向链表最实在的一个优势。

注意删除之后要return,不然循环会继续往下走,而cur已经free了,用cur = cur->next就会访问野指针。这个坑我实际试过,程序直接段错误。

3.3 遍历、查找和销毁的收尾细节

遍历打印的逻辑就是从哨兵的next开始,走到哨兵结束,因为循环链表没有NULL可用:

void list_print(List* list) { Node* cur = list->sentinel->next; while (cur != list->sentinel) { printf("%d ", cur->data); cur = cur->next; } printf("\n"); }

销毁函数是另一个容易翻车的地方。链表的节点是一个个动态分配的,必须逐个释放。一个常见的错误写法是直接从头节点开始循环free,free完之后又用cur = cur->next,这时候cur指向的内存已经被释放了,属于典型的use-after-free。正确做法是先保存下一个节点的地址:

void list_destroy(List* list) { Node* cur = list->sentinel->next; while (cur != list->sentinel) { Node* next = cur->next; free(cur); cur = next; } free(list->sentinel); free(list); }

先保存next再free当前节点,这是一个设计层面的好习惯。跟插入操作里"先接新线再断旧线"其实是一个道理——别动你还依赖着的数据。

4. 我的三个小改动复盘:改了什么,为什么改,结果如何

4.1 给链表加了一个size计数

老师的版本里没有节点数统计,每次想知道链表长度只能重新遍历一遍。我加了个size字段,每次插入和删除时同步更新,遍历打印的时候也可以用size来做计数器。这样做的代价是每个链多占4个字节,但换来的是O(1)获取长度。对于学习用的链表实现来说,这不是必需的,但它让我在测试删除结果的时候非常方便——直接看size对不对,再配合打印函数确认内容。

不过这里有个细节要提醒自己:size和链表的实际长度必须保持同步。如果有一处漏了size--,后续所有依赖size的判断都会乱。所以我写的时候把size的更新放在插入删除函数内部,而不是放在main里手动改,避免外部误操作。

4.2 删除函数里用"自回退"取代了"传前驱"

老师的示例代码里,删除功能的实现思路是传入要被删的节点地址,然后通过一个辅助变量来重新连接。我改成只传要删除的值,从哨兵的下一个节点开始找,找到后通过cur->prev做回退。这样调用的地方不用关心节点的连接关系,只需要说"帮我删掉值为5的节点",更贴近实际使用场景。

这个改动表面上只是参数设计不同,实际上体现了双向链表和单链表的一个根本差异——单链表如果你想删除某个节点,你必须在遍历时记住它的前一个节点;双向链表则不需要,因为每个节点都认识自己的前驱。删除代码的可读性和简洁度完全不一样。

4.3 遍历打印时用"回到哨兵"而不是"计数器归零"

我本来想用size来控制打印循环,但后来发现跟哨兵判断混着用反而更清楚。最终我保留了"cur != sentinel"作为循环条件,size字段只是用来做长度校验和删除时的快速判断。这一点其实不算大改动,但让我意识到:不是所有字段都必须被用上,接口设计也好,内部实现也好,都要为可读性服务,不要为了用而用。

回头看这三个改动,整体上并没有改变核心算法。但"自己动手改一点"这件事,价值在于逼着我把每条指针指向都画了一遍、推演了一遍,而不是照着课件的代码抄一遍。改的地方越多,犯错的机会就越多,但理解也越深。

5. 写双向循环链表最容易翻车的几个问题:排查实录

5.1 三个现场翻车的真实bug复盘

我第一次写的时候,头插和尾插之间互相干扰,导致打印出来顺序完全不对。排查半天才发现问题出在list_push_back里——我先写了node->next = sentinel,然后写成sentinel->prev->next = node,但这条语句执行之前,sentinel->prev其实还是哨兵自己。问题就出在顺序上。如果先改sentinel->prev->next,此时它指向的还是哨兵,这没问题;但如果我先改了sentinel->prev,再回头用它的旧值就不对了。所以必须先把node的前后指针都绑好,最后再更新哨兵节点的prev和next。

第二个翻车现场是删除时没有free。我一开始删掉了节点但忘了调用free,导致内存泄漏。测试程序跑了几千次插入删除之后,内存占用明显涨上去了,最后用内存检查工具定位到问题。写链表只要用到了动态内存,free就一定要和malloc成对出现。

第三个问题是删除时忘了return。第一次运行删除函数后,因为value正好匹配多个节点,它把我所有匹配的节点全删了。调试半天才发现删除成功后循环不应该继续,而是直接退出函数。这种bug不致命但行为非常误导,排查过程会让人怀疑人生。

5.2 常见问题速查表

我把这次实践中遇到过的问题整理了一张表,方便以后再犯的时候快速对照。

症状大概率原因排查思路
打印时多出一个0遍历没跳过滤哨兵检查打印起点是不是sentinel->next
打印时死循环循环判断条件用了NULL或size且size没同步统一用cur != sentinel做判断
尾部插入后链接错乱修改指针顺序不对先改新节点的prev/next,最后改哨兵
删除后程序段错误漏了return,或者free后用cur->next删除成功后立即return,或者先保存next再free
头插和尾插互相覆盖忘记更新原头节点的prev头插后必须设置sentinel->next->prev = node
内存只涨不降删除节点后没有free用malloc的地方全部配对free,用工具查

再说一个非常实用的技巧:链表写完之后,不要只测头插和尾插,一定要测"先尾插再头插再删除"这种混合操作。只做单向操作的程序往往掩盖了指针没接对的问题,一旦混合起来,立刻暴露。

写这个链表的总体感受是,数据结构这种东西,看视频觉得懂了,自己动手才发现到处是细节。改一点不是坏事,能独立做一点跟课件不一样的实现,然后还能跑通,这才是真正的理解。如果只是照抄老师代码,收获可能就打了不少折扣。

最后再分享一个小技巧:写链表之前,先在纸上把节点的prev和next箭头画出来,尤其标注清楚哨兵节点的位置。我这次能一次跑通,很大程度是因为动手前花了二十分钟把图理清了一遍。画图这个习惯,值得所有学链表的人尝试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询