如果你正在学C语言,大概率会在结构体和指针那里卡一阵子,等把这两个搞明白了,紧接着遇到的拦路虎就是链表。链表这个东西,说难不难,说简单也不简单,它几乎是所有数据结构教材的第二章或第三章内容,也是很多计算机专业学生第一次感受到“指针居然还能这么用”的地方。更关键的是,链表的增删改查四个操作,看起来只有几十行代码,却能把动态内存管理、指针操作、边界条件、空指针判断这些问题全揉在一起,写错一个地方,程序直接崩溃给你看。
这篇文章我会带着你从数组的痛点说起,然后逐个实现链表节点的创建、插入、删除、查找、修改和销毁,把每一步操作背后的原理、细节、坑点都讲清楚。不管你是刚学完C语言基础准备进阶的初学者,还是需要复习数据结构的考研党、面试党,这篇文章都能帮你把单链表这块硬骨头啃下来。
1. 链表到底解决什么问题:从数组的痛点说起
要说链表,得先从数组说起。很多初学者不理解,明明数组用得好好的,为什么要搞出链表这么个东西,多此一举。其实不是的,数组在固定长度、连续内存的使用场景下确实很好用,但一旦涉及动态增删,它就会暴露出一堆问题。
1.1 数组的“固定床位”问题
数组在创建的时候必须指定长度。你开int arr[10],它就固定占 10 个 int 的空间。这在很多场景下是麻烦的:数据量不确定,开小了装不下,开大了浪费内存。你说用动态数组malloc一波?那也得先知道大概需要多少个元素,分配完之后想扩容,还得重新开一块更大的内存,再把旧数据搬过去。
更麻烦的是插入和删除。假设你有一个有序数组[1, 3, 5, 7, 9],现在要在 3 和 5 之间插入一个 4,正常做法是把 5、7、9 全部往后挪一位,再把 4 填进去。删除也是同理,后面所有元素都要往前移。这种搬移操作的时间复杂度是 O(n),数据量大了之后,性能很难看。
你可以把数组想象成电影院里的一排固定座位,座位号就是下标,观众必须一个挨一个坐着。这时候有个人想插队坐到正中间,那么从中间到末尾的所有人都得起来挪位置。同理,有人中途离场,后面的人也要往前补位。链表就不一样了,它更像排队时每个人只记住自己后面那个人是谁,有人插队,只需要改一下前面那个人“记住的人”就行,后面的人完全不用动。
1.2 链表的核心设计:用指针把节点串成一条链
链表的思路很简单:每个节点不仅保存数据,还额外保存一个“指向下一个节点的指针”。这样节点之间就不需要连续存放了,每个节点想放在内存的哪个位置都可以,只要指针能把它们串起来就行。
一个单链表节点长这样:
typedef struct Node { int data; // 数据域,存放实际数据 struct Node *next; // 指针域,指向下一个节点 } Node;链表的第一个节点叫头节点,通过一个头指针head来记录它在哪里。最后一个节点的next指向NULL,表示链到这里就结束了。
所以在链表中做增删改查,本质不是搬数据,而是改指针。插入一个新节点,就是把新节点的 next 指向后一个节点,再把前一个节点的 next 指向新节点;删除一个节点,就是让前一个节点跳过它,直接指向后一个节点,然后把这个节点 free 掉。
这两种结构的差异,直接决定了它们在不同操作上的表现。我习惯用一张表来对比:
| 操作 | 数组 | 单链表 |
|---|---|---|
| 按下标访问第 i 个元素 | O(1),直接算地址 | O(n),必须从头遍历 |
| 在已知位置插入元素 | O(n),需要搬移元素 | O(1)(已知前驱节点后) |
| 删除已知元素 | O(n),需要搬移元素 | O(1)(已知前驱节点后) |
| 按值查找 | O(n) | O(n) |
| 内存空间 | 连续,可能浪费 | 不连续,额外存指针有开销 |
数组擅长随机访问,链表擅长频繁增删,这俩是互补关系。明白这一点,你就知道链表存在的理由了。
1.3 单链表、双链表、循环链表怎么选
链表家族里还有几个兄弟:单链表、双向链表、循环链表。初学者先把单链表吃透,后面学另外两种就轻松很多。
单链表是最基础的,每个节点只有 next 指针,只能从头往后走,不能回头。因为它最简单,所以很多教材、面试题、考试题都拿它开刀。缺点是删除一个节点时,你必须知道它的前驱节点是谁,否则单链表删不了,这一点后面会重点讲。
双向链表在每个节点里多了一个 prev 指针,指向前一个节点。这样一来,删除节点就不需要找前驱了,因为每个节点自己就存着前驱的地址。代价是每个节点多花一个指针的内存,插入和删除时多改一行指针操作。Linux 内核里大量使用的就是双向链表,不过内核那种链表和教材这种写法还不太一样,它是把链表节点嵌到结构体里面去的。
循环链表就是把尾节点的 next 指向头节点,形成一个环。它适合那些需要循环轮转的场景,比如约瑟夫环问题、操作系统的进程调度轮转、内存管理中的页置换等。
我个人建议:先把单向链表的手写代码练到滚瓜烂熟,做到随堂测验能在十分钟内写出创建、插入、删除、遍历的完整代码,再去看双向链表和循环链表。单链表都搞不定,后面全是空中楼阁。
2. 动手前的关键设计:结构体、头节点与内存管理
写链表代码之前,有几个设计决策需要先想清楚。很多初学者上来就敲代码,结果连头节点要不要、传参要不要用二级指针这些问题都没想明白,写着写着就开始怀疑人生。这部分我们先把设计层面的问题解决掉。
2.1 用结构体定义节点:为什么 next 要写成 struct Node*
先看这个经典的节点定义:
typedef struct Node { int data; struct Node *next; } Node;这里有个初学者常见的疑问:都已经 typedef 成 Node 了,为什么成员变量 next 不能用Node *next,非要写struct Node *next?
原因很简单:C 语言是顺序编译的,编译器看到struct Node里面的成员时,typedef的别名Node还没定义完。你自己想想,Node这个别名还没诞生,你就在结构体内部用它声明成员,编译器当然不认。所以必须显式写struct Node *next,等整个结构体定义结束,Node这个别名才生效。
另外注意,数据域不一定是 int。你可以把它换成 double、char,甚至是一个结构体。比如学生管理系统里,你可以定义:
typedef struct Student { char name[20]; int id; float score; } Student; typedef struct Node { Student data; struct Node *next; } Node;更高级一点的做法是数据域用void *data,这样链表可以存任意类型的数据,但这种写法对初学者来说有点绕,建议先把固定类型练熟。
2.2 头节点到底要不要:一个关键的设计决策
这里说的“头节点”不是指第一个存储数据的节点,而是指一个“哑节点”(dummy node),它自己不存有效数据,只是作为链表的起点存在。因为它在链表最前面,可以让所有插入、删除操作在逻辑上保持一致,不需要特判“这是不是链表头”。
举个例子:如果你不用头节点,删除第一个节点时,头指针 head 本身需要更新,因为原来指向第一个节点的 head 现在要指向第二个节点。这个操作牵扯到头指针的修改,所以函数形参必须用二级指针Node **head,或者让函数返回新的头指针。很多初学者在这里栽跟头,就是因为忘了更新 head,导致链表头丢了。
如果用头节点,head 始终指向那个哑节点,不管删哪个节点,头指针本身都不会变,代码就少了很多特判。
我做了一个对比表:
| 设计方式 | 优点 | 缺点 |
|---|---|---|
| 不带头节点 | 结构直观,节省一个节点的内存 | 删除/插入头节点时要更新头指针,代码逻辑复杂一点 |
| 带头节点 | 插入删除逻辑统一,不需要特判头节点 | 多一个哑节点,遍历时要注意跳过它 |
后面我的完整代码会采用“不带头节点”的写法,因为这是教材和面试里最常见的版本,你练熟了之后,再看带头节点的写法会非常轻松。
2.3 malloc/free 配对:链表的生命线
链表节点是动态分配的,每个节点都是用malloc在堆上申请的,用完之后必须用free释放。这句听起来简单,实际写代码时很多人根本不 care。
每写一个malloc,你就要问自己:这个内存什么时候释放、在哪里释放、如果中途出错返回了,这个节点会不会泄漏。
这里有几个硬性规矩:
- 每次
malloc之后必须判断返回值是不是NULL。虽然平时跑不出来,但在嵌入式环境或者内存紧张的系统里,分配失败是真实存在的。 - 每次
free一块内存后,那个指针应该尽快置空或者不再使用,否则就成了悬空指针。 - 绝对不要对一个指针
free两次,那是未定义行为,程序大概率直接崩。
链表的增删改查之所以让人头疼,就是因为这些内存管理细节和指针操作搅在一起。你不仅要保证逻辑正确,还要保证内存安全。我见过很多同学代码逻辑看着没问题,一跑就崩,或者跑一次内存涨一点,最后发现是free的位置不对。
提示:写链表代码前,先在脑子里把“谁 malloc 的、谁 free 的”这条线理清楚,再动手。
3. 完整实现:链表增删改查的四个核心操作
接下来是重头戏。我会按 创建 → 插入 → 删除 → 查找 → 修改 → 销毁 的顺序,把每个操作的核心代码和设计思路讲清楚。建议你边看边在本地编译器上敲一遍,光看不练十有八九还是不会。
3.1 准备工作:节点创建与打印函数
所有插入操作之前,先得有个创建节点的函数。这个函数负责开辟内存、填充数据、把 next 置成 NULL:
Node* createNode(int data) { Node *newNode = (Node*)malloc(sizeof(Node)); if (newNode == NULL) { printf("内存分配失败\n"); exit(1); } newNode->data = data; newNode->next = NULL; return newNode; }为什么要把创建节点单独抽成一个函数?因为插入、尾插、指定位置插入都要创建新节点,如果每处都写一遍 malloc 和判断,代码会非常冗余,而且容易漏掉对 malloc 返回值的检查。
再配一个打印函数,这个函数看着简单,却是调试链表的利器:
void printList(Node *head) { Node *cur = head; while (cur != NULL) { printf("%d -> ", cur->data); cur = cur->next; } printf("NULL\n"); }printList会从 head 开始,依次打印每个节点的 data,最后打印一个 NULL 表示链表结束。你每做完一次插入或删除,就调用一次 printList,就能立刻看到链表长什么样,哪里出了问题一眼就能找到。
3.2 插入操作:头插、尾插与指定位置插入
插入操作一般有三种:
头插法:新节点插到链表最前面,成为新的头节点。
void insertAtHead(Node **head, int data) { Node *newNode = createNode(data); newNode->next = *head; *head = newNode; }注意这里的参数是Node **head,也就是二级指针。为什么要这么写?因为当链表为空时,*head是 NULL,插入后 head 要指向新节点;即使链表非空,头插也会改变 head 的指向。如果不传二级指针,函数内部只改了形参的副本,外部 head 还是原来的值,链表头就丢了。这就是经典的“传值 vs 传址”问题。
如果你不想用二级指针,也可以让函数返回新头指针:
Node* insertAtHead(Node *head, int data) { Node *newNode = createNode(data); newNode->next = head; return newNode; }调用时用head = insertAtHead(head, data);。两种风格都常见,我推荐初学者先把二级指针版本搞明白,因为它能强迫你理解指针的本质。
尾插法:新节点插到链表末尾。需要先遍历到最后一个节点,然后把它的 next 指向新节点:
void insertAtTail(Node **head, int data) { Node *newNode = createNode(data); if (*head == NULL) { *head = newNode; return; } Node *cur = *head; while (cur->next != NULL) { cur = cur->next; } cur->next = newNode; }尾插的边界条件是链表为空。如果*head == NULL,说明链表里一个节点都没有,那么新节点就是头节点,直接让 head 指向它。如果不加这个判断,第二轮访问cur->next时就会对 NULL 解引用,直接段错误。
指定位置插入:假设位置从 0 开始计数,要把新节点插到下标为 pos 的位置上。思路是先找到当前位置是第 pos-1 个节点的前驱节点 cur,然后把新节点插到 cur 后面。比如在A -> B -> C中,要在位置 1 插入 X,意思是插入后变成A -> X -> B -> C,那么我们需要找到的是位置 0 的 A 节点:
int insertAtPos(Node **head, int pos, int data) { Node *newNode = createNode(data); if (pos < 0) { printf("插入位置不能为负数\n"); free(newNode); return 0; } if (pos == 0) { newNode->next = *head; *head = newNode; return 1; } Node *cur = *head; for (int i = 0; i < pos - 1 && cur != NULL; i++) { cur = cur->next; } if (cur == NULL) { printf("插入位置超出链表长度\n"); free(newNode); return 0; } newNode->next = cur->next; cur->next = newNode; return 1; }这段代码有两个地方值得停下来想一想。
第一个是为什么插入失败时要free(newNode)。因为 newNode 已经 malloc 了,如果位置不合法,它就不该被加进链表。如果不 free,这个节点就变成了内存泄漏,程序每次执行到这里都会丢一小块内存。这种细节,恰恰是区分“能跑”和“写得好”的分水岭。
第二个是最后两行指针操作的顺序:
newNode->next = cur->next; cur->next = newNode;这个顺序不能乱。如果先把cur->next改成 newNode,那原来 cur 后面的节点地址就丢了,新节点找不回去,链表就断了。所以必须先把新节点和后一个节点连起来,再把前一个节点连到新节点上。下面这个断链场景几乎是每个初学者都踩过的坑。
3.3 删除操作:找到前驱再动手
单链表的删除有一个核心痛点:你只有 next 指针,没有 prev 指针,所以要删除节点 B,你必须找到 B 的前驱节点 A,然后让 A 的 next 直接指向 B 的 next,最后 free 掉 B。
按值删除的代码如下:
int deleteByValue(Node **head, int data) { if (*head == NULL) return 0; // 如果要删的是头节点 if ((*head)->data == data) { Node *tmp = *head; *head = (*head)->next; free(tmp); return 1; } Node *cur = *head; // 找到目标节点的前驱节点 while (cur->next != NULL && cur->next->data != data) { cur = cur->next; } if (cur->next == NULL) return 0; Node *tmp = cur->next; cur->next = tmp->next; free(tmp); return 1; }为什么删除头节点要单独处理?因为删除头节点会改变 head 的指向,所以必须用二级指针。删除非头节点时,cur 从头开始走,直到cur->next指向的值等于 target。这个循环条件很巧妙:它同时判断了“下一个节点存在”和“下一个节点的值不等于目标”,循环结束后,要么cur->next == NULL,说明链表里没有这个值,要么cur->next->data == data,那么 cur 就是目标节点的前驱。
如果要按位置删除,思路也差不多,找到第 pos-1 个节点,然后让它的 next 跳过目标节点:
int deleteByPos(Node **head, int pos) { if (*head == NULL || pos < 0) return 0; if (pos == 0) { Node *tmp = *head; *head = (*head)->next; free(tmp); return 1; } Node *cur = *head; for (int i = 0; i < pos - 1 && cur->next != NULL; i++) { cur = cur->next; } if (cur->next == NULL) return 0; Node *tmp = cur->next; cur->next = tmp->next; free(tmp); return 1; }删除操作最容易犯的错误,就是 free 了节点之后还去访问它的成员,比如free(tmp); tmp->next;,这在 C 语言里是未定义行为。你看着好像有时候还能跑,但那是运气好,内存还没被系统回收;运气不好,程序当场崩掉,而且崩的位置往往离问题代码很近,排查起来很迷惑。
3.4 查找与修改:遍历的基本功
查找操作没有太多花活,核心就是从头遍历,逐个比较 data 值:
Node* findByValue(Node *head, int data) { Node *cur = head; while (cur != NULL) { if (cur->data == data) { return cur; } cur = cur->next; } return NULL; }这个函数返回的是找到的那个节点指针,调用方可以直接用found->data来访问数据。如果没找到,返回 NULL,调用方一定要判断 NULL 再使用,否则就是对空指针解引用。
修改操作就更直接了。比如按位置修改节点的 data:
int updateByPos(Node *head, int pos, int newData) { Node *cur = head; for (int i = 0; cur != NULL && i < pos; i++) { cur = cur->next; } if (cur == NULL) return 0; cur->data = newData; return 1; }注意这里的循环条件是cur != NULL && i < pos,也就是说一边往后走一边数位置。如果走到一半链表就结束了,说明 pos 越界,返回 0。
查找和修改看起来简单,但它们其实是在帮你建立一种“遍历思维”:对链表大部分操作,本质上都是从一个节点开始,通过 next 指针不断走向下一个节点,直到满足某个条件或者碰到 NULL。你写多了就会发现,插入、删除、查找、修改、销毁,底子都是同一个 while 循环。
3.5 销毁链表:free 的完整循环
链表用完了,必须把每个节点都 free 掉,不然会内存泄漏。销毁的代码长这样:
void destroyList(Node **head) { Node *cur = *head; while (cur != NULL) { Node *next = cur->next; free(cur); cur = next; } *head = NULL; }这里有一个非常关键的细节:在 free 当前节点之前,必须先保存它的 next 指针。
如果你写成:
while (cur != NULL) { free(cur); cur = cur->next; // 已经 free 了,cur->next 是无效访问 }那程序大概率会崩。因为 cur 指向的内存已经被释放了,你再访问 cur->next 就相当于读一块已经交还给系统的内存。正确做法是先把下一个节点的地址存到临时变量 next 里,free 完当前节点后,用 next 继续往前走。
最后把*head = NULL也是必须的。这样做的目的是让外部头指针不再指向一块已经被释放的内存,避免后面误用。这是防御性编程的好习惯。
到这里,链表增删改查的核心代码已经齐了。我建议你把 createNode、insertAtHead、insertAtTail、insertAtPos、deleteByValue、deleteByPos、findByValue、updateByPos、destroyList、printList 这十个函数放在一起编译一下,用一个 main 函数把它们全部串起来跑一遍。看到屏幕上链表一步步变化,你会对链表有一种“原来如此”的感觉。
4. 常见问题与排查技巧实录
链表代码不长,但出错方式花样百出。我捋了一下自己带人学习和实际写代码时遇到过的高频问题,整理成下面这份排查记录,希望能帮你少踩几个坑。
4.1 段错误:头号杀手
段错误出现在哪里,基本就说明问题出在哪。最常见的三个原因:
第一个是空指针解引用。比如你写cur->next->data,但如果cur->next是 NULL,这行代码当场崩。尤其在某些边界条件下,比如删除最后一个节点、在空链表上插入,很容易触发。
第二个是内存已经被释放却还在用。比如上面说的销毁链表时先 free 再访问 next,或者删除节点后还拿那个节点的指针去访问数据。
第三个是指针指向错误。比如链表断了,某个节点的 next 根本没被正确赋值,导致遍历到一个未知地址上。
排查段错误,我的经验是“二分定位”。先用 printList 看链表结构对不对,然后在可疑的循环里加 printf 打印当前走到了哪个节点。不要小看这个土办法,十次段错误有八次是这么定位出来的。也可以用 gdb 跑一下,程序崩了之后输入 bt 看调用栈,能直接告诉你崩在哪一行。
4.2 内存泄漏:程序越跑越卡
内存泄漏的典型表现是程序运行一段时间后内存占用越来越大,最后卡死。链表场景下的泄漏点主要有三类:
一是删除节点时只改了指针,没有 free。比如你写删除操作,只剩了cur->next = tmp->next;,忘了free(tmp),那个节点还在堆上躺着,但已经没人能找到它了。
二是插入失败时没有释放新节点。就像前面 insertAtPos 里的处理,如果位置不合法,一定记得 free(newNode),否则每次失败都泄漏一次。
三是销毁链表写得不完整。比如只 free 了第一个节点就返回,剩下的节点全挂在堆上没人管。
Linux 下可以用 valgrind 来检测内存泄漏,命令是valgrind --leak-check=full ./your_program。它会告诉你哪些内存是 definitely lost、indirectly lost 还是 possibly lost。我第一次用 valgrind 检查链表代码时,发现自己的销毁函数少处理了一个分支,当场被自己蠢到。
4.3 边界条件:空链表、单节点和头尾操作
链表代码里最容易翻车的永远是边界条件。我整理了一个自查清单,写代码时逐条打勾:
| 场景 | 常见错误 | 正确做法 |
|---|---|---|
| 空链表上插入 | 直接对 head 解引用 | 先判断 head == NULL,新节点直接作为头节点 |
| 删除头节点 | 忘了更新 head | 用二级指针或者函数返回新头指针 |
| 删除尾节点 | 找不到尾节点的前驱 | 用 cur->next 遍历到最后一个,而不是 cur 本身 |
| 插入位置为 0 | 走通用逻辑导致越界 | 位置为 0 时单独走头插逻辑 |
| pos 超过链表长度 | 循环里越界访问 | 循环条件加上 cur != NULL 判断 |
| 单节点链表删除 | 删除后 head 没置 NULL | 删除后 *head 指向 NULL,链表变空 |
我见过很多同学在“单节点链表删除”这个问题上翻车:链表里只有一个节点 5,你调用 deleteByValue 删除 5,期望结果是 head 变成 NULL。如果代码只处理了“删除非头节点”的情况,把 5 当普通节点删,那 head 指针仍然指向已经被 free 的内存,下一次遍历就出鬼。
4.4 调试链表的三板斧
最后分享三个我实际调试链表时的小技巧。
第一个是 printList 大法。我在写链表的每个关键步骤后都会调用 printList 确认链表状态,因为指针操作看不见摸不着,只有打印出来才能验证逻辑对不对。比如插入后打印一次,删除后打印一次,看到结构如预期,心里就有底。
第二个是“画图法”。大家别嫌土,硬核程序员也照样在纸上画链表。遇到指针操作混淆的时候,把节点画成方框,把 next 画成箭头,然后手动把每个步骤的箭头改一遍,逻辑立刻清晰。我教学生的时候经常说:链表的代码是抄不来的,因为你如果不懂指针是怎么指的,永远都会在某个地方写出 bug;但只要你画过一次图,这个操作就刻在脑子里了。
第三个是构造测试用例。写链表代码不能只测正常情况,空表、单节点、两个节点、删除头、删除尾、插入中间、插入越界位置,这些用例都要跑一遍。把这些边界情况全部覆盖到,你的链表代码才算真正稳了。
提示:如果你发现删除或插入之后链表“少了一截”,十有八九是某个节点的 next 被不小心覆盖了。这时候从打印结果往回推,看是哪个操作导致链表断裂,再用画图法定位具体是哪一行指针操作出了问题。
最后再分享一点个人体会
链表这个东西,代码量不大,但知识点密度极高,夹杂了结构体、指针、动态内存、函数传参这些 C 语言最核心的内容。我自己的习惯是:正式写代码之前,先花三分钟在纸上把节点结构和操作步骤画出来,画清楚了再敲键盘,写起来基本一遍过。如果你正在学链表,千万别觉得画图是在浪费时间,恰恰是那几分钟画图,能帮你省下几个小时的调试时间。
如果你已经能把这篇文章里的代码全部自己默写出来,我建议你再往前走一步,试试把单链表改成双链表,或者实现一个带头节点的版本,甚至去研究一下内核里是怎么用链表把模块串起来的。链表的本质就是“用指针组织数据”,这个思想是你以后学习树、图、哈希表、缓存淘汰算法等各种高级主题的地基,值得你多花点时间把它打牢。