资讯详情

资讯详情

双向循环链表实战:指针操作、哨兵节点与约瑟夫环实现

1. 从看课到动手双向循环链表到底难在哪昨天整整一天我把某位老师讲的双向循环链表课程刷完了今天晚上趁热打铁自己敲了一遍还顺手改了一点结构。先说结论双向循环链表这名字听起来挺唬人拆开看其实就是“双向链表”加“首尾相连”外加一个可选的哨兵节点。但真正的坑不在概念而在指针操作的顺序和边界条件的处理。这篇文章不是课程笔记的复述而是我作为一个刚看完课、立刻上手写代码的人的真实记录。我会把结构体怎么定义、带头节点和不带头节点有什么差别、插入删除时指针怎么挪、以及我自己改的那部分是什么全部摊开讲清楚。如果你也在跟课学链表或者学完不敢动手这篇文章应该能帮你少走不少弯路。先说几个关键词双向循环链表、哨兵节点、指针操作、头插尾插、约瑟夫环。这些都是今天代码里实际用到的东西。整篇文章会围绕这些展开适合刚学完链表理论、准备第一次动手实现的人。2. 为什么要把链表设计成“双向”和“循环”2.1 单链表的痛点我之前写过单链表当时最大的感受是删一个节点得先找到它的前驱节点否则断链之后后继节点就找不到了。如果链表有十万个节点要删除尾节点就得从头一路遍历到倒数第二个时间复杂度是 O(n)。这在写算法题的时候还能忍但现实里的链表动辄就是几十万条记录这种“只能往前走、不能回头”的设计就显得很笨重。双向链表就是为解决这个问题而生的每个节点里多存一个 prev 指针指向它的前一个节点。这样一来删除节点的时候直接通过目标节点的 prev 和 next 就能拿到前后节点不需要再从头遍历。代价就是每个节点多占一个指针的内存操作的时候也要多维护一条链接。2.2 循环解决了什么问题循环链表就是把尾节点的 next 指回头节点把头节点的 prev 指向尾节点。表面上看只是多了两条指针实际上带来一个很重要的性质从任意一个节点出发都能走遍整条链表没有死角。这种特性在约瑟夫环问题里特别有用。一群人围成一圈报数数到某个数字就出列剩下的继续从下一个人开始报数。用单链表实现约瑟夫环总得处理“报数到最后一个人的时候下一次该回到第一个人”这种边界逻辑而用循环链表做指针转一圈就自动回到起点逻辑会天然贴合问题本身。2.3 哨兵节点的作用“带头节点”里的“头节点”也叫哨兵节点。它不是用来存数据的而是作为链表的固定起点。我做单链表的时候觉得哨兵节点有点多余但写完双向循环链表之后改观了。哨兵节点的核心价值在于让插入和删除的逻辑不再需要特殊处理空链表和头尾节点的边界情况。你永远有一个固定的节点在所有操作都变成“在某个节点后面插一个”或者“把某个节点摘下来”不用对“如果链表是空的怎么办”做单独判断。代码上的体现非常直接没有哨兵节点的话头插节点你要判断 head 是不是 NULL尾插也要判断 tail 是不是 NULL。而有哨兵节点的情况下头插就是“在哨兵节点后面插入”尾插就是“在哨兵节点前面插入”空链表和非空链表的处理路径完全一样。3. 动手写代码前的三个选择3.1 选择一带头节点还是不带头节点课程里讲的是带头节点的版本这也是很多教程的主流做法。但我在自己写的时候做了一点改动没有用哨兵节点而是用一个头指针和一个尾指针再加上一个 size 记录节点数量。为什么这么改因为我想试试看不带头节点的实现到底要比带头的多处理哪些边界情况。这个想法在后来的调试中让我多花了一个小时但也让我对链表结构的理解深了一层。如果你只是想快速跑通逻辑建议先用带头节点的版本等理解了再尝试去掉逃脱节点。这里涉及到一个很基础但很重要的问题头指针和头节点是两回事。头指针是一个指向链表第一个节点的指针变量它本身不是节点头节点是一个实实在在的节点只是它不存数据。很多人学链表的时候容易把这两个概念搞混导致后面写代码时完全不知道 delete 函数里该传什么参数。3.2 选择二数据和指针的组织方式典型的结构体定义是这样的typedef struct Node { int data; struct Node *prev; struct Node *next; } Node;data 用来存实际数据prev 和 next 用来指向前后节点。如果只是实现整型链表直接用 int 就行如果是更复杂的场景可以把 data 换成一个 void* 指针或者一个结构体。我额外定义了一个管理结构体typedef struct DCLList { Node *head; Node *tail; size_t size; } DCLList;这样做的目的很朴素把“链表的头尾指针和长度”打包成一个整体。之后所有函数都传这个结构体的指针不用在函数参数里又传 head 又传 tail。这个封装也是后来我做“改了一点”的起点等于是给链表加了一个“外壳”。3.3 选择三插入删除的抽象粒度我见过一些人实现双向循环链表时把所有逻辑全部堆在 main 函数里插入写一段、删除写一段能跑但没法看。我的习惯是先定义最小的操作原语再让上层函数调用这些原语。比如我实现了最核心的两个内部函数在某个节点后面插入新节点、把某个节点从链表中摘除。头插、尾插、按值删除全都是在调用这两个函数的基础上加一点逻辑。这样做的好处是调试的时候只需要保证最底层的两个函数是对的上层的逻辑永远不会出指针问题。4. 完整实现一个可运行的带大小管理的双向循环链表4.1 初始化和销毁初始化的目标是得到一个空链表。在不带头节点的版本里空链表就是 head 和 tail 都为 NULLsize 为 0。void initList(DCLList *list) { list-head NULL; list-tail NULL; list-size 0; }销毁链表的任务是释放所有节点的内存避免内存泄漏。因为链表是循环的不能用 while (cur ! NULL) 这种判断方式得用一个计数循环走 size 步即可。void destroyList(DCLList *list) { if (list-head NULL) return; Node *cur list-head; for (size_t i 0; i list-size; i) { Node *next cur-next; free(cur); cur next; } list-head NULL; list-tail NULL; list-size 0; }这里最值得注意的就是 for 循环的终止条件。单向链表的遍历可以用“当前节点是否等于 NULL”来判断但循环链表没有 NULL只能靠“走多少个节点”来控制次数。写错这一步的后果就是死循环或者访问已释放的内存我在后文的排查记录里会专门讲。4.2 在指定节点后面插入新节点这是整个链表实现里最核心的一个函数。它的逻辑是把 newnode 接到 cur 的后面同时让原来的 cur-next 接到 newnode 的后面。void insertAfter(Node *cur, Node *newnode) { if (cur NULL || newnode NULL) return; newnode-prev cur; newnode-next cur-next; cur-next-prev newnode; cur-next newnode; }这个函数建立在“cur 一定存在”的前提下所以不用再判空链表。由于链表是循环的cur 的 next 永远不为 NULL所以 cur-next-prev newnode; 这行代码是安全的。你如果有哨兵节点这一步也一样。4.3 头插和尾插头插就是在 head 前面插。因为链表是循环的head 前面的节点其实就是 tail。但我要维护 head 和 tail 两个指针所以头插之后要更新 head或者更新 tail具体要看你是插在哪个位置。尾插就是在 tail 的后面插一个新节点等于是让新节点成为新的 tail。void pushFront(DCLList *list, int data) { Node *newnode (Node *)malloc(sizeof(Node)); if (newnode NULL) { printf(malloc failed\n); return; } newnode-data data; if (list-head NULL) { newnode-prev newnode; newnode-next newnode; list-head newnode; list-tail newnode; } else { insertAfter(list-tail, newnode); list-head newnode; } list-size; } void pushBack(DCLList *list, int data) { Node *newnode (Node *)malloc(sizeof(Node)); if (newnode NULL) { printf(malloc failed\n); return; } newnode-data data; if (list-head NULL) { newnode-prev newnode; newnode-next newnode; list-head newnode; list-tail newnode; } else { insertAfter(list-tail, newnode); list-tail newnode; } list-size; }注意看头插和尾插的代码几乎一样唯一区别就是插入完成之后更新的是 head 还是 tail。这个细节很容易写错。我当时就是在头插函数里忘记更新 head结果插了三个节点之后head 还指着最老的那个节点遍历的时候永远只能看到一半内容。空链表的情况需要单独处理。此时 head 和 tail 都为 NULL新节点自己构成一个循环所以它的 prev 和 next 都要指向自己。这一步不能偷懒否则第一个节点插入后就成了一个断链的节点。4.4 删除节点删除某个节点核心操作是把它的前后节点接上然后释放这个节点的内存。再写一个摘除节点的函数void removeNode(Node *cur) { if (cur NULL) return; cur-prev-next cur-next; cur-next-prev cur-prev; }这个函数只是把 cur 从链条里解下来不负责释放内存也不负责更新链表管理结构体的 head 和 tail。这些必须让上层函数来做。完整删除一个指定值的节点int deleteByValue(DCLList *list, int value) { if (list-head NULL) return 0; Node *cur list-head; for (size_t i 0; i list-size; i) { if (cur-data value) { if (cur list-head cur list-tail) { list-head NULL; list-tail NULL; } else if (cur list-head) { list-head cur-next; } else if (cur list-tail) { list-tail cur-prev; } removeNode(cur); free(cur); list-size--; return 1; } cur cur-next; } return 0; }这里的头尾更新逻辑就是没有哨兵节点的代价删除的是 head 时要把 head 往后移删除的是 tail 时要把 tail 往前移如果链表只剩一个节点删除后 head 和 tail 都要置空。换成带头节点的版本这些判断会少很多因为哨兵节点永远在不需要考虑 head 和 tail 变成 NULL 的情况。删除时最忌讳的是先释放了 cur 的内存再去访问 cur-next 和 cur-prev。顺序写反了就是 Use-After-Free在我自己的调试里就遇到过程序不崩溃但输出乱码非常难受。4.5 遍历输出正向遍历从 head 出发走 size 步。反向遍历从 tail 出发同样走 size 步。这也是循环链表带来的便利头尾都有指针正反向都很自然。void printForward(DCLList *list) { if (list-head NULL) { printf(empty list\n); return; } Node *cur list-head; for (size_t i 0; i list-size; i) { printf(%d , cur-data); cur cur-next; } printf(\n); } void printBackward(DCLList *list) { if (list-tail NULL) { printf(empty list\n); return; } Node *cur list-tail; for (size_t i 0; i list-size; i) { printf(%d , cur-data); cur cur-prev; } printf(\n); }这段代码的灵感来自课程里的一个想法双向链表的“反向遍历”价值只有在调试时才能真正体会到。遇到指针问题的时候正向打印一遍、反向打印一遍能帮你快速定位到底是哪条链断了。4.6 查找和修改查找就是线性遍历不做过多优化Node *findNode(DCLList *list, int value) { if (list-head NULL) return NULL; Node *cur list-head; for (size_t i 0; i list-size; i) { if (cur-data value) return cur; cur cur-next; } return NULL; }修改某个节点的值可以配合查找和赋值来做。注意一点如果你要修改的是节点的数据直接改就行如果你要在链表运行过程中调整节点位置就需要先摘除再插入。5. 我做的“改了一点”到底是什么标题里说“改了一点”这点我必须交代清楚。很多初学者看完视频有一种错觉照抄代码跑通了就等于学会了。我这次特意做了一件不一样的事把课程里的带头节点双向循环链表改成不带头节点、带 size 管理的双向循环链表。这个“改”看似只动了一点实际上动了最核心的设计假设。带头节点的链表插入和删除逻辑永远建立在“哨兵节点存在”的基础上去掉哨兵节点之后每次插入和删除都要重新考虑“链表是不是空”“删的是不是头尾”这些边界条件。我在改的过程中至少修了三次 bug每一次都是在边界条件上翻车的。改完之后我用它跑了一遍约瑟夫环问题作为验证。把 n 个人编号为 1 到 n排成一个圈从 1 号开始报数数到 m 的人出列然后从下一个人继续报数。用循环链表实现约瑟夫环是非常自然的因为链表本身就是环形的只需要不断遍历、删除即可。void josephus(int n, int m) { DCLList list; initList(list); for (int i 1; i n; i) { pushBack(list, i); } Node *cur list.head; size_t remaining list.size; while (remaining 1) { for (int i 1; i m; i) { cur cur-next; } Node *toDelete cur; cur cur-next; if (toDelete list.head) list.head cur; if (toDelete list.tail) list.tail cur-prev; removeNode(toDelete); free(toDelete); list.size--; remaining--; } printf(survivor: %d\n, list.head-data); destroyList(list); }跑出来的结果和数学公式算的对得上确定逻辑没写错。这个过程最有价值的地方在于我不是背了一个约瑟夫环的实现而是从零写出了链表之后再把链表应用到一个实际问题上。6. 踩坑实录与排查技巧6.1 死循环遍历没有终止条件我第一次写的 printForward 里用的是Node *cur head; while (cur ! NULL) { printf(%d , cur-data); cur cur-next; }单链表这样写没问题但双向循环链表里节点之间循环连接没有任何一个节点的 next 是 NULL所以这个循环永远不会停。程序的表现就是控制台疯狂刷数字只能强制终止。排查方法很简单加一个计数变量循环打印的次数达到 size 就停。我后来改成 for 循环遍历 size 步立刻就没有死循环了。这个坑几乎每个刚写循环链表的人都会踩早踩早记住。6.2 断链只更新了后继链接忘了更新前驱我在写 insertAfter 的时候一开始只写了两行newnode-next cur-next; cur-next newnode;结果遍历的时候新节点的 prev 还是旧的前后指针不一致链表就乱了。这种 bug 不太会导致崩溃但会导致某些节点遍历不到或者反向遍历时回到奇怪的位置。正确操作需要记住一句话双向链表插入至少要让四个指针归位。即 newnode-prev、newnode-next、原后继节点的 prev、原前驱节点的 next。新手最容易漏的是把新节点的 prev 指对也最容易漏掉把原后继节点的 prev 指回来。6.3 内存管理malloc 和 free 不配对我的代码里有 malloc就得有 free。销毁链表的函数如果写漏了运行一次就泄漏一次内存。短小的测试程序可能看不出来但每天改代码反复运行内存会肉眼可见地涨上去。排查方法是写一个简单的 count 函数分别统计 malloc 的次数和 free 的次数。如果两边不相等就逐个函数检查。后来我习惯把分配和释放写在同一层次的函数里避免“在另一个函数里分配、在第三个函数里释放”这种跨层管理。6.4 画图是终极调试手段指针写乱的时候我靠脑子想真的想不清楚。后来老老实实在纸上画方框和箭头把每个节点的 prev 和 next 指向画出来再对照代码一行行执行问题瞬间就暴露了。这个方法我从单链表用到双向循环链表每次都能定位到问题在哪一行。画图不丢人写链表不画图才是真勇士。6.5 常见错误速查表现象可能原因排查思路打印死循环遍历循环条件用了 cur ! NULL改成 for 循环走 size 步输出缺数据头插后没更新 head插入后检查 head 是否指向新节点反向遍历错乱删除或插入时没有更新前驱节点的 next检查四个指针的赋值顺序程序崩溃use-after-free 或空指针访问先找最近一次 free 的节点检查是否还有引用内存泄漏malloc 和 free 不配对统计分配和释放次数7. 看完课到写出代码中间的差距怎么补我这一天下来最深的体会是看课和写代码中间隔着一道鸿沟这道鸿沟只能靠亲手实现来填。视频里老师敲代码很快每一步都正确逻辑理得清清楚楚。轮到自己写光是一个 insertAfter 就改了四遍。这不是因为视频讲得不好而是因为理解别人的思路和自己操作指针是两码事。我建议跟课的人不要只满足于“把课程里的代码抄一遍跑通”。抄写只能让你的手指熟悉语法不会让你的脑子理解结构。正确的做法是看完一个数据结构专题后第二天立刻关掉视频自己从零实现一遍。实现完了故意改一个核心设计比如带头改不带头、双循环改单循环看看会出什么问题。跑一个实际问题验证比如约瑟夫环而不是只用打印语句测试。这个方法不是捷径但它比连续看三天视频效果更好。我今天是这么做的虽然过程中多次想翻回视频但忍住了。最终代码运行成功的那一刻我确定自己是真的理解了双向循环链表而不是记下了它的实现。如果你也在学链表别急着看下一篇先把手头的代码写出来再改一改再丢到实际问题里跑一遍。等你能做到这三步再去看下一个知识点也不迟。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →