
单链表、双链表、循环单链表、循环双链表这四个东西看起来像是数据结构课本里最难啃的骨头但说白了它们都是“节点 指针”那点事儿。如果你正在学C语言或者刚起步学数据结构大概率会被头插法、尾插法、删除节点时指针怎么绕来绕去搞得头大。这篇文章就用最直白的代码和踩坑经验把单链表、双链表以及它们各自的循环版本一次讲透。我会从设计思路讲到完整可运行的C语言实现再分享一些调试链表时特别实用的排查方法保证你学完之后能自己动手写出这些结构而不是只会背概念。很多人问C语言都这么古老了为什么还用C写链表其实恰恰是C这种直接操作内存的语言才能让你真正理解指针和内存分配。用Python或Java写链表语法糖把底层细节都藏起来了你根本体会不到“断链”和“内存泄漏”是什么感觉。而链表恰恰是理解复杂数据结构的基石后面的树、图、哈希表本质上都是链表思想的延伸。1. 项目整体设计思路先分清四种链表的异同1.1 链表的本质节点、指针和内存链表里的每个元素叫节点Node每个节点至少包含两个部分数据域和指针域。数据域存的是你要保存的值指针域存的是下一个节点的地址。在C语言里这个“指向下一个节点的地址”就通过结构体指针来实现。// 单链表节点定义 struct Node { int data; // 数据域 struct Node *next; // 指针域指向下一个节点 };多个节点通过next指针串起来就像一串糖葫芦。最后一个节点的next指向NULL表示链表结束。双链表则在单链表基础上多了一个前驱指针prev让每个节点既能往后走也能往前走。也正是因为这个额外的指针双向链表在删除节点、反向遍历时要比单链表方便得多但代价是每个节点多占一个指针的内存操作时也需要多维护一个指针域。而循环链表是把链表的尾部重新指回头部从而形成一个环。也就是说最后一个节点的next不再指向NULL而是指向头节点。循环双链表则是头节点的prev指向尾节点尾节点的next指向头节点整个链表是一个首尾相接的双向环。1.2 带头结点 vs 不带头结点一个影响所有操作的决策开始写代码前你必须先决定一件事链表要不要带头结点。头结点是一个不存实际数据或者数据域无意义的额外节点它放在链表的第一个位置。头结点的存在让“在第一个位置插入”和“在中间位置插入”的逻辑统一起来也避免了对空链表作出特殊判断。不带头结点的链表第一个节点就是真正的数据节点。这种写法在原理上更简单但操作麻烦。比如删除第一个节点时你必须要修改头指针本身所以函数形参得用“指向指针的指针”或者返回新头指针。很多新手在这里写错就是因为只传了头指针的值把链表调没了。我个人建议初学阶段从带头结点的单链表開始等到逻辑理顺了再尝试不带头结点的版本。这样你对这两种写法的差异会有更深的体会。下面的代码示例我以带头结点的版本为主。2. 单链表从零开始搭一套核心操作2.1 结构体定义与创建节点先定义一个基础结构体为了方便演示数据域就用int。如果你想存字符串或结构体类型只需替换data的类型即可。#include stdio.h #include stdlib.h // 节点定义 typedef struct Node { int data; struct Node *next; } Node; // 创建一个新节点 Node* createNode(int data) { Node *newNode (Node*)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); return NULL; } newNode-data data; newNode-next NULL; return newNode; } // 初始化带头结点的空链表 Node* initList() { Node *head createNode(0); // 头结点数据域不使用可以初始化为0 return head; }注意一个小细节malloc返回的是void*在C语言中需要显式强转为Node*。虽然C编译器可能不强制要求但写清楚能让代码更易读也能让C编译器接受。创建节点之后一定要检查malloc是否成功。虽然考试题里很少写这个判断但实际工程中内存分配失败是会发生的所以我建议养成检查的习惯。2.2 头插法与尾插法两种最基础的构建方式头插法就是每次把新节点插到链表的最前面也就是头结点之后。头插法的特点是真的方便时间复杂度O(1)但生成链表的顺序和输入顺序相反。如果按1、2、3的顺序输入用头插法得到的是3、2、1。// 头插法在头结点后插入 void insertHead(Node *head, int data) { Node *newNode createNode(data); newNode-next head-next; // 新节点指向原来的第一个节点 head-next newNode; // 头结点指向新节点 }尾插法就是每次把新节点接到链表的末尾。这样得到的链表顺序和输入顺序一致。如果连一个指向尾节点的指针都没有那每次插入都得从头结点遍历到最后时间复杂度是O(n)。很多初学者会觉得尾插法麻烦但实际场景中“保持输入顺序”往往是刚需所以尾插法也绝不能躲。// 尾插法在链表末尾插入 void insertTail(Node *head, int data) { Node *newNode createNode(data); Node *p head; while (p-next ! NULL) { // 找到尾节点 p p-next; } p-next newNode; }为了既保留顺序又能O(1)尾插我习惯在结构体外额外维护一个尾指针变量。但在带头结点的单链表当中如果频繁尾插为了效率可以把链表的头尾信息封装在一个结构体里。比如typedef struct { Node *head; // 头结点 Node *tail; // 尾节点 } List;这样每次插入时如果tail不为空直接tail-next指向新节点再更新tail如果为空再从头找。很多实际项目的链表实现都是这么写的。2.3 按位置插入和按值删除指针操作的细节关键按位置插入比如在第i个位置插入节点。首先要找到第i-1个节点然后修改指针关系。这里的难点是边界条件如果i小于1或者大于链表长度1就要报错。// 在第pos位置插入节点pos从1开始 int insertByPos(Node *head, int pos, int data) { if (pos 1) return 0; Node *p head; int cnt 0; while (p ! NULL cnt pos - 1) { p p-next; cnt; } if (p NULL) { // 链表长度不足 return 0; } Node *newNode createNode(data); newNode-next p-next; p-next newNode; return 1; }按值删除是删除第一个data值等于指定值的节点。这里要注意删除节点后要保留住被删节点的位置free不能太早否则后面的节点就找不到了。// 删除第一个值为value的节点 int deleteByValue(Node *head, int value) { Node *pre head; Node *p head-next; while (p ! NULL) { if (p-data value) { pre-next p-next; // 绕过p free(p); return 1; } pre p; p p-next; } return 0; // 没找到 }这里为什么需要pre因为单链表只能往后走你走到p的时候如果没有pre就不知道p的前一个是谁也就没法让前一个节点跳过p。我见过很多新手把代码写成“p-next p-next-next”在遍历的时候就乱了还是老老实实用两个指针吧。3. 双链表多一个指针多了哪些便利3.1 双链表结构与前驱指针的意义双链表的每个节点多了一个prev指针指向前驱节点。结构体定义如下typedef struct DNode { int data; struct DNode *prev; struct DNode *next; } DNode;有了prev在删除一个节点时你可以直接通过p-prev找到前驱而不需要再用一个pre指针跟随。这样删除操作的时间复杂度可以做到O(1)因为不需要从头遍历找前驱。另外反向遍历链表时单链表得先创建一个新链表倒序存储或者用递归而双链表只需要从尾节点往前跳就行。空间换时间这就是双链表的思路。顺便说一句学习双链表的时候千万不要因为多了一个指针就觉得更复杂。它只是你从“只知道下一个邻居”变成了“还知道上一个邻居”生活方便了很多。3.2 双链表插入和删除的指针顺序牢记“先连接新节点再断开旧连接”双链表的指针操作比单链表多一步出错率也更高。我发现一个很实用的口诀先处理新节点的prev和next再处理它前后邻居的指针。这样能尽量避免中间状态出现“悬空”。在某个节点p之后插入新节点s// 在节点p之后插入s s-prev p; s-next p-next; if (p-next ! NULL) { p-next-prev s; } p-next s;这里要注意如果p是最后一个节点那么p-next是NULL对NULL-prev赋值是错的所以要先判断。删除节点p已知p不是头结点或尾节点或者允许修改// 删除节点p p-prev-next p-next; if (p-next ! NULL) { p-next-prev p-prev; } free(p);为什么这里不用特判p是头结点因为通常头结点不会存有效数据也不会被删除。如果是不带头结点的双链表删除第一个节点时要额外处理头指针复杂度就上来了。这就是带头结点的好处。3.3 双链表的逆序遍历双链表逆序输出非常简单前提是你有一个尾节点的指针。如果没有你可以先遍历到尾节点然后再往前循环输出。代码如下void printReverse(DNode *tail) { DNode *p tail; while (p ! NULL) { printf(%d , p-data); p p-prev; } printf(\n); }初学双链表时很多人总想着“我要操作一堆指针好麻烦”。其实你只要在纸上把节点画成方框把指针画成箭头操作之前把箭头重新画一遍代码基本就不会错。画图永远比瞎调试靠谱。4. 循环链表尾部重新指向头部之后4.1 循环单链表的构建与遍历终止条件把单链表的最后一个节点的next指向头结点就成了循环单链表。这里的头结点可以是有实际的第一个节点也可以是带头结点的空链表逻辑。如果用带头结点的空链表来表示空的循环单链表头结点的next就指向头结点本身。这是一个很常见的形式。构建循环单链表时插入和普通单链表几乎一样区别只在于初始化和遍历的终止条件。// 初始化空循环单链表头结点指向自己 Node* initCircularList() { Node *head createNode(0); head-next head; // 关键头结点指向自己 return head; }遍历的终止条件不再是p NULL而是p head。比如void printCircularList(Node *head) { if (head-next head) { printf(空链表\n); return; } Node *p head-next; while (p ! head) { printf(%d , p-data); p p-next; } printf(\n); }这个循环很容易写错成死循环——如果你忘记把最后一个节点的next指回头结点或者遍历时不判断是否回到头结点程序就会一直在环里转。排查时要先检查插入时是否修改了尾节点的next为head。4.2 循环双链表的特殊之处循环双链表就是把双链表的头节点和尾节点也串起来尾节点的next指向头结点头结点的prev指向尾节点。这样从任何一个节点出发往前走或往后走都能回到原点。循环双链表的插入和删除逻辑和普通双链表非常像。因为整个链表成环所以很多“p-next是否为NULL”的判断可以省略反而代码变简单了。比如在p之后插入ss-next p-next; s-prev p; p-next-prev s; p-next s;可以看到这里不需要判断p-next是否为NULL因为循环结构里p-next一定存在。这就是循环结构的优势之一边界条件更少。4.3 约瑟夫环循环链表最经典的实战学循环链表不写约瑟夫环相当于学泡面不打蛋总是缺了点味道。约瑟夫环问题是这样的n个人围成一圈从第1个人开始报数数到m的人出列然后从下一个人重新从1开始报数直到所有人都出列要求输出出列顺序。这个问题用循环单链表来描述非常自然每个节点代表一个人把尾节点的next指向第一个节点形成环。报数时就来回移动指针数到m就删除当前节点。节点删除后让下一个节点继续成为待报数的起点。核心代码如下void josephus(int n, int m) { Node *head initCircularList(); // 带头结点的空循环链表 // 先构建n个人的环 Node *tail head; for (int i 1; i n; i) { Node *newNode createNode(i); tail-next newNode; tail newNode; } tail-next head; // 首尾相接 Node *p head; while (p-next ! p) { // 当链表不为空 // 从p的下一个节点开始报数 // 报1到m-1停在m-1位置然后删除下一个 for (int cnt 0; cnt m - 1; cnt) { p p-next; if (p head) { // 跳过带头结点 p p-next; } } Node *q p-next; if (q head) { // 防止q是头结点 p p-next; q p-next; } printf(%d , q-data); p-next q-next; free(q); } printf(\n); }这里的跳过带头结点的逻辑稍微有点绕我在实际调试时发现最好把头结点这个概念单独理解成“哨兵”而不是环的一部分。另一种更简洁的实现是不带头结点直接把第一个数据节点作为环的起点操作时会更直观。两种方法都行但你要在脑海里明确哪个是哨兵哪个是真实节点千万别混。约瑟夫环的题目在考研、面试、课程设计里都非常常见建议大家自己手写一遍不要抄代码边写边画图。5. 实操中常见的坑与排查技巧5.1 内存泄漏和free的时机C语言链表最隐蔽的问题是内存管理。写实验报告时大家几乎都会在程序末尾写一个销毁链表的函数但很多人只是意思一下。真正的坑在于删除单个节点时如果先free了节点再继续使用前一个节点的next程序就会崩溃或者产生未定义行为。正确流程永远是这样用一个临时指针q保存待删除节点把前一个节点pre的next指向q-next再free(q)。顺序绝对不能反。我练过很多次有几次自以为是先free再改指针结果调试了半天后来才明白free只是把内存归还给堆管理器它并不会自动帮你改指针你手头还存着q但q-next可能已经被系统写坏了。另外一个完整程序结束前最好遍历链表把所有节点都free掉。虽然操作系统会在进程结束之后回收全部内存但如果你写的是长时间运行的程序或者嵌入式裸机环境不free就等着内存越用越少吧。5.2 空指针和野指针最常见的崩溃根源在链表中空指针NULL是表示“到达边界”或“没有下一个节点”的标志。但当你对NULL-next赋值或者试图访问NULL-data时程序就会段错误Segmentation Fault。我就收到过很多次“程序一运行就闪退”的提问最后发现全是访问了空指针。比如在遍历时while循环条件写成了p-next ! NULL但p本身已经是NULL了下一步操作p-next就崩了。正确条件应该是while (p ! NULL)。野指针是指指向了已经被释放或无效内存的指针。比如你已经free了p但还有一个指针q仍然指向那个地址之后再访问q就是野指针。解决野指针的方法是free之后将指针置为NULL。free(p); p NULL;这样后续如果误操作程序至少会在访问NULL时报错而不是访问到一块可能被其他人占用的内存导致各种神仙bug。5.3 双链表和循环链表里的“断链”问题断链是指指针的关联关系没有完整更新导致链表从某个位置断开后面的节点再也找不到。这在双链表里尤其常见。比如在节点p之前插入一个节点s很多人只写了p-prev-next s却忘了s-prev p-prev或者忘了p-prev s。少一条程序不会马上崩溃但当你反向遍历时链就断了。循环链表里常见的错误是忘记更新最后一个节点的next导致它不是指向头结点而是指向NULL。这样遍历代码while(p ! head)就会在p变成NULL时继续访问NULL-next直接段错误。解决办法是每次插入完成后都检查尾节点的next是否仍然指向head。我自己的调试习惯是写一个“检查链表完整性”的函数。对于单链表遍历一遍统计节点数并确认最后一个节点为NULL对于循环链表则确认最后一个节点的next为head。每次操作完调用一下就能第一时间发现断链。// 检查单链表完整性返回节点个数-1表示存在环用于检测意外成环 int checkList(Node *head) { if (head NULL) return -1; Node *p head; int count 0; while (p ! NULL) { p p-next; count; if (count 100000) return -1; // 防止极端情况死循环 } return count; }5.4 快速定位问题的三个调试技巧第一画图。不是让你画UI而是画指针图。把每个节点画成一个小方块里面写data和next指向谁。操作之前画操作之后对照代码再画一遍十有八九能找到错在哪。第二用printf输出关键指针。不加断点、不用gdb的初学者最直接的方法就是在插入、删除前后打印当前节点的地址和next地址。比如printf(正在删除节点 %d它的next是 %p\n, q-data, q-next);这样能看到链条是否连续。第三分段测试。不要等到整个程序写完才调试。每写完一个函数就写一小段测试代码单独测它。比如先测createNode再测insertHead再测deleteByValue。这样出错的范围小定位快。我还推荐大家把链表代码放在单文件里加上简单的菜单循环做成一个交互式的小工具。输入1插入输入2删除输入3打印。对于期末实验和自学这种可交互的程序学起来比单纯背代码快多了。6. 关于链表学习的最后一点感受我在刚开始学链表那阵子也经历过看着代码觉得全懂一合上书自己写就卡壳的困境。后来发现其实链表就像一个拼图游戏你要清楚每一块节点之间的关系尤其要记住“做任何修改之前先想清楚哪些指针会被破坏又需要哪些指针来兜底”。写单链表就当自己在串珍珠写双链表就当自己在修一条双向公路写循环链表就当自己在设计一个环形跑道心态一转换理解就快了。如果你现在还在因为“为什么这段代码存了指针又去取指针”而纠结那很好说明你在思考。不要急把文章里的代码挨行敲一遍在纸上画出指针跳动的路径再用我提到的检查函数去验证。链表的那些坑踩过一次之后就不再是坑而是你以后写代码时能比别人判断得更快的资本。希望这篇经验分享能让你少走一些弯路也祝你早日把链表这块硬骨头啃下来。