1. 先想明白:为什么数据结构的第一课是链表
如果你正在学数据结构,或者刚买了一本《数据结构(C语言版)》之类的教材,那你大概率绕不开“单链表”这一关。单链表(Singly Linked List)是C语言学习者接触的第一种动态数据结构,也是很多人第一次被指针绕晕的地方。说实话,我当年学链表的时候,也让空指针、头节点这些东西搞得一头雾水,Debug到怀疑人生。
但反过来看,链表恰恰是最值得花时间搞懂的内容。原因很简单:它把你之前学的C语言零散知识点——结构体、指针、动态内存分配、函数传参——全部串在了一条线上,而且这些能力在后续学习栈、队列、二叉树、图的时候都会反复用到。所以与其说链表是一个“知识点”,不如说它是你从“会写C语法”走向“会用C做设计”的一座桥。
这篇文章我要做的,就是把我自己学习和实战中关于单链表的经验完整拆开讲清楚。从为什么需要链表讲起,到结构体怎么设计、六个核心操作怎么写、常见的坑怎么避,最后再扩展一点翻转链表这种经典面试题。所有代码都用纯C语言实现,逻辑尽量直白,方便直接照着写和调。适合正在学数据结构的学生、准备面试的开发者,以及想补一下C语言基础的自学者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单链表的核心设计与结构体定义
2.1 节点的定义:最简结构体写法
链表的基本单位叫“节点”(Node),每个节点存两个东西:一个是数据本身,另一个是指向下一个节点的指针。这个结构用C语言写出来就是下面这样:
c复制typedef struct Node {
int data; // 数据域
struct Node* next; // 指针域
} Node;
这里有个初学者特别容易懵的点:为什么是 struct Node* next,而不是直接 Node* next?因为在 typedef 生效之前,Node 这个类型名还不存在,你只能先用完整的 struct Node 来声明指针。等这个 typedef 执行完,后面才能用 Node* 来写。这个细节在很多教材上不会特意强调,但如果你自己写结构体时遇到“未知类型名”的编译错误,第一反应就应该是这个。
我把数据域写成 int,是为了让例子最简化。实际工程里,data 可以是任意类型,甚至可以是一个结构体。比如存储学生信息时,data 可能是一个包含学号、姓名、成绩的结构体。但不管 data 多复杂,节点的组织方式都一样,这就是链表“把存储这件事抽象化”的好处。
还有一个常见选择是:用 void* 来作为数据域,实现所谓的“泛型链表”。这个思路在C语言里是可行的,但会引入类型转换和内存管理的复杂度,新手阶段先不用管,等你把一个 int 版本的单链表玩熟了,再考虑 void* 的进阶改造。
2.2 头节点的意义:带头节点与不带头节点
链表实现里有个经典分歧:到底要不要“头节点”(Head Node)?这里的头节点不是指存储第一个有效数据的节点,而是一个额外的、不存实际数据的节点,它的 next 才指向真正的第一个数据节点。
我个人的建议非常明确:新手期一律带头节点。原因有下面几个,都是我实测下来的感受:
第一,带头节点后,插入和删除的逻辑可以统一。比如删除第一个有效节点时,不带头节点的链表需要单独判断“删除的是不是头一个”,然后修改链表头指针;带头节点的话,头指针永远指向头节点,永远不用改,只需要操作头节点的 next 指针。代码少一个分支,就少一半出错机会。
第二,空表的表达更统一。不带头节点的链表,空表就是 head == NULL;带头节点的链表,空表是 head->next == NULL,头节点本身始终存在。后面写遍历、查找、删除的时候,统一用 p->next 来判断,能少写很多特判。
第三,面试和考试时,带头节点是一个很标准的写法,批卷和面试官都容易看懂你的逻辑。
所以下面所有代码我都采用带头节点的写法。结构上就是:head 是一个节点,它的 next 指向第一个有效数据节点。初始化时,head->next = NULL。
3. 六个核心操作,逐个击破
链表的操作看似很多,但拆开看,核心就是六个:初始化、头部插入、尾部插入、按值删除、按值查找、遍历打印。这六个会了,链表的80%应用场景你都能应付。
3.1 创建链表:初始化一个带头节点的空链表
初始化函数我通常这样写:
c复制Node* createList() {
Node* head = (Node*)malloc(sizeof(Node));
if (head == NULL) {
printf("内存分配失败\n");
return NULL;
}
head->next = NULL;
return head;
}
你可能注意到我用的是 malloc,而不是直接声明一个局部变量 Node head; 然后把 &head 传出去。这背后有一个很重要的原因:局部变量存储在栈区,函数结束后就自动销毁了。如果你在 createList 里定义了一个局部头节点然后返回它的地址,调用方拿到的是一个悬空指针,一访问就出问题。而 malloc 分配的内存位于堆区,除非你手动 free,否则它会一直存在。
这就是链表和数组的一个关键区别:数组可以“声明一个然后用”,因为空间是静态分配好的;链表必须“造一个才能用”,因为每个节点的生命周期都依赖动态内存管理。
3.2 头部插入:理解“先连后断”的黄金法则
头部插入就是在链表的第一个有效数据节点之前插入一个新节点。代码如下:
c复制void insertAtHead(Node* head, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return;
}
newNode->data = value;
newNode->next = head->next;
head->next = newNode;
}
这段代码里最重要的一行是 newNode->next = head->next;,它的含义是:让新节点的 next 指向原来链表的第一个有效节点。然后再执行 head->next = newNode;,让头节点的 next 指向新节点。
很多新手会写反,先执行 head->next = newNode; 再执行 newNode->next = head->next;。这样会导致什么样的后果?因为此时 head->next 已经指向 newNode 了,再取 head->next 拿到的还是 newNode 自己,新节点指向了自己,后面的节点全部丢失,链表变成了一个环。这不仅是逻辑错误,而且会让遍历陷入死循环。
所以我总结了一个口诀:先连后断。新节点先把自己的 next 连到后面的节点上,然后再回头修改头节点的 next。任何插入操作都遵循这个原则,就不容易出错。
3.3 尾部插入:遍历到最后一个节点
尾部插入需要先找到链表的最后一个节点,然后把新节点接上去:
c复制void insertAtTail(Node* head, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return;
}
newNode->data = value;
newNode->next = NULL;
Node* p = head;
while (p->next != NULL) {
p = p->next;
}
p->next = newNode;
}
这个函数的遍历逻辑是:p 从头节点开始,只要 p->next 不是空,就继续往后挪。循环结束时,p 指向的一定是最后一个节点,因为最后一个节点的 next 就是 NULL。
这里要注意区分两种情况。如果链表为空(只有头节点),那循环一次都不执行,p 还是 head,新节点就成了第一个有效节点。如果链表有n个有效节点,循环执行 n 次,p 会指向第 n 个节点。你可以用一张纸自己画一遍,把 p 的移动轨迹标出来,这个操作就彻底理解了。
尾部插入的时间复杂度是 O(n),因为每次都要从头遍历到尾部。如果你频繁往尾部追加数据,更优的方案是额外维护一个尾指针,即“带头节点 + 尾指针”的双指针结构,插入尾部可以做到 O(1)。不过这是后话,先把基础版跑通再说。
3.4 按值删除:找到前驱节点是关键
删除操作比插入稍微绕一点,因为你要删除的是某个节点,但真正要改的是它的前驱节点的 next:
c复制void deleteNode(Node* head, int value) {
Node* p = head;
while (p->next != NULL && p->next->data != value) {
p = p->next;
}
if (p->next == NULL) {
printf("未找到值为 %d 的节点\n", value);
return;
}
Node* toDelete = p->next;
p->next = toDelete->next;
free(toDelete);
}
我解释一下这个循环的条件为什么那么写。p->next != NULL 首先保证 p->next 是合法存在的,然后 p->next->data != value 检查当前节点的数据是否等于目标值。两个条件用 && 连接,只要有一个不成立就退出循环。退出后就分两种情况判断:如果是因为找到目标而退出,那 p->next->data 等于 value;如果是因为遍历到尾部而退出,那 p->next == NULL。通过后面的 if (p->next == NULL) 就能区分是哪种情况。
这里我踩过的一个坑是:删除节点后忘了 free(toDelete)。在C语言里,malloc 的内存不会自动释放,你只改指针不 free,内存在堆上不会被回收,程序运行时间长了就会内存泄漏。虽然小程序看不出问题,但在长期运行的服务里,累积泄漏会导致内存暴涨,最终进程崩溃。所以每次删除节点都要养成习惯:先保存被删除节点的指针,改完指针后立刻 free。
3.5 按值查找:返回第一个匹配节点
查找比删除简单,不需要改动链表,只需要沿着链表遍历:
c复制Node* searchNode(Node* head, int value) {
Node* p = head->next;
while (p != NULL) {
if (p->data == value) {
return p;
}
p = p->next;
}
return NULL;
}
这个函数返回的是指向匹配节点的指针。如果找不到,返回 NULL。调用方拿到返回值后,要先判断是不是 NULL 再访问数据,否则就是空指针解引用,程序直接崩溃。
我见过很多新手写查找函数时,用 while (p->next != NULL) 而不是 while (p != NULL)。区别在哪里?用 p->next 做条件会导致最后一个节点永远不被检查,因为当 p 指向最后一个节点时,p->next 是 NULL,循环直接退出,最后一个节点就被跳过了。这个错误非常隐蔽,因为当链表元素较多时,少检查最后一个是完全看不出来的。我建议一律用 p != NULL 作为遍历条件,除非你有明确的理由要跳过某个节点。
3.6 遍历打印:所有后续操作的基础
遍历打印是一个最朴素的函数,但却是排查问题最常用的工具:
c复制void printList(Node* head) {
Node* p = head->next;
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
这个函数的核心思想是:从第一个有效节点开始,打印当前节点数据,然后让 p 指向下一个节点,直到 p 变成 NULL 为止。它不会修改链表本身,所以不用担心打印一次链表就没了。
实际调试时,我经常在插入、删除操作后调用一次 printList,观察链表是否符合预期。比如头插三个数字 3、2、1,打印结果应该是“3 2 1”,因为头插是逆序的。如果你得到的是“1 2 3”,那说明你写的其实更像尾插,或者头插的指针顺序写反了。反正打印函数是你最好的朋友,多调用几次,很多隐蔽问题一眼就看出来了。
4. 整合 Demo:一口气跑通全部操作
为了方便你直接验证,我把上述所有函数整合成一个完整的 C 文件,可以在本地编译运行:
c复制#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node* next;
} Node;
Node* createList() {
Node* head = (Node*)malloc(sizeof(Node));
if (head == NULL) {
printf("内存分配失败\n");
return NULL;
}
head->next = NULL;
return head;
}
void insertAtHead(Node* head, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return;
}
newNode->data = value;
newNode->next = head->next;
head->next = newNode;
}
void insertAtTail(Node* head, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if (newNode == NULL) {
printf("内存分配失败\n");
return;
}
newNode->data = value;
newNode->next = NULL;
Node* p = head;
while (p->next != NULL) {
p = p->next;
}
p->next = newNode;
}
void deleteNode(Node* head, int value) {
Node* p = head;
while (p->next != NULL && p->next->data != value) {
p = p->next;
}
if (p->next == NULL) {
printf("未找到值为 %d 的节点\n", value);
return;
}
Node* toDelete = p->next;
p->next = toDelete->next;
free(toDelete);
}
Node* searchNode(Node* head, int value) {
Node* p = head->next;
while (p != NULL) {
if (p->data == value) {
return p;
}
p = p->next;
}
return NULL;
}
void printList(Node* head) {
Node* p = head->next;
while (p != NULL) {
printf("%d ", p->data);
p = p->next;
}
printf("\n");
}
int main() {
Node* list = createList();
insertAtHead(list, 3);
insertAtHead(list, 2);
insertAtHead(list, 1);
printf("头部插入 1,2,3 后: ");
printList(list); // 预期输出: 1 2 3
insertAtTail(list, 4);
insertAtTail(list, 5);
printf("尾部插入 4,5 后: ");
printList(list); // 预期输出: 1 2 3 4 5
deleteNode(list, 3);
printf("删除节点 3 后: ");
printList(list); // 预期输出: 1 2 4 5
Node* result = searchNode(list, 4);
if (result != NULL) {
printf("找到节点: %d\n", result->data); // 预期输出: 4
} else {
printf("未找到节点\n");
}
return 0;
}
你可以把这份代码保存成 linkedlist.c,然后用 gcc linkedlist.c -o linkedlist && ./linkedlist 编译运行。程序会依次执行头插、尾插、删除、查找,并在每一步打印结果。如果你看到的输出和注释里的预期一致,说明你已经把单链表的核心逻辑跑通了。如果不一致,那就对照着函数往回查,看看是哪一步的指针操作和你预想的不一样。
5. 那些年我们踩过的坑,以及排查方法
5.1 空指针、野指针、悬空指针:指针的三大梦魇
C语言链表新手最常见的崩溃,几乎都来自空指针解引用。什么叫空指针解引用?就是你试图通过一个值为 NULL 的指针去访问数据,比如 p->data,但 p 是 NULL,程序直接段错误退出。
我举一个高频错误场景:在 deleteNode 里,如果链表只有一个有效节点,删除后 head->next 变成 NULL。此时再去调用 printList,head->next 是 NULL,循环体不执行,没有问题。但如果你在删除后直接访问 head->next->data,那就相当于在 NULL 上取数据,必崩。所以访问任何节点前,先确认它不是 NULL。
野指针和悬空指针也值得留意。野指针是没有被初始化的指针,它的值是随机的,你根本不知道它会指向哪里。悬空指针是指针曾经指向一块合法内存,但那块内存被 free 了,指针还在,但指向的内容已经不可用。我见过很多人在 free(toDelete) 之后还试图通过 toDelete->data 去访问数据,这种操作结果是未定义的,有时候能打印出旧值,有时候直接崩溃,非常难排查。
我的建议是:每次 free 之后,立刻把指针置为 NULL。虽然 C 标准没有强制要求,但这是成本最低的防护手段,可以在调试时减少大量重复定位问题的时间。
5.2 内存泄漏、重复释放:关于 free 的那点事
前面已经提到了 malloc 和 free 要成对出现。但这里还有个容易被忽略的细节:链表销毁时,不能只 free(head)。因为每个节点都是独立 malloc 出来的,如果你只释放了头节点,其他节点就全部泄漏了。正确的销毁函数应该是循环释放:
c复制void destroyList(Node* head) {
Node* p = head;
while (p != NULL) {
Node* next = p->next;
free(p);
p = next;
}
}
然后再写一个方便的分装:
c复制void clearList(Node* head) {
Node* p = head->next;
while (p != NULL) {
Node* next = p->next;
free(p);
p = next;
}
head->next = NULL;
}
destroyList 是把整条链表连带头节点一起释放,释放后 head 不能再使用了。clearList 是保留头节点,只把有效节点全部清掉,链表变成一个空表,可以继续插入新元素。这两个函数在工程里都有用途,看你要清空还是销毁。
还有一个很隐蔽的问题:重复释放。如果你在 deleteNode 里已经 free 了一个节点,又在别的地方再次 free 同一个指针,C 标准说这是未定义行为,实际运行可能崩溃,也可能不崩,全看运气。为了避免这种问题,除了 free 后置空指针,还应该在逻辑上保证每个节点的释放只有明确的一处负责。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 编译报错“未知类型名” | typedef 的使用时机不对 |
检查是否在类型定义完成前就使用了别名 |
| 程序运行崩溃(段错误) | 空指针解引用 | 打印所有涉及指针的地址,检查是否为 NULL |
| 插入后只显示一个节点 | 新节点指向自己形成环 | 检查插入时“先连后断”是否执行正确 |
| 删除末尾节点后崩溃 | 删除后访问了悬空指针 | free 后置空指针,遍历前先判 NULL |
| 程序内存不断增长 | 动态分配的节点没有 free | 用 Valgrind 检查内存泄漏 |
| 遍历时漏掉最后一个节点 | 循环条件用了 p->next != NULL |
改用 p != NULL 作为循环条件 |
需要特别说明的是,定位指针问题单靠眼睛看有时很费劲。我强烈推荐你在 Linux 环境下用 Valgrind 检测内存问题。命令很简单:valgrind --leak-check=full ./linkedlist。它会把每一处非法读取、非法写入、内存泄漏都精确到行号报告出来。我第一次用 Valgrind 查链表程序时,真的是一查一个准,节省了至少一晚上的调试时间。
5.4 我的调试心得:小规模用例是王道
链表调试特别忌讳一上来就搞几百个节点的数据。我常用的做法是:先用三到五个节点做最小用例,手动画出每一步的链表结构,然后对照代码逐步演算。比如头插 1、2、3 之后,链表应该是 1→2→3 还是 3→2→1,不运行程序,先用纸笔画一遍。画完之后再运行程序比对输出,这样你能很快发现自己对指针操作的理解是否有偏差。
如果程序还是不对,就在关键节点加打印。比如在 insertAtHead 里打印 head->next 和 newNode->next 的值,观察它们是否指向你预期的节点。这种方法虽然原始,但对初学者非常有效,比直接上调试器更容易理解。
6. 学会这些之后,怎么再往深处走
6.1 从单链表延伸到双向链表和循环链表
单链表的核心逻辑搞懂之后,你会发现双向链表和循环链表都是“换汤不换药”。双向链表是在节点结构体里多加一个 prev 指针,指向前面一个节点。好处是既能从头往尾遍历,也能从尾往头遍历,删除节点时不需要找前驱节点,因为自己有 prev 可以用。代价是每次插入删除都要维护两个指针,代码量翻倍,出错概率也高一些。
循环链表是把最后一个节点的 next 指向头节点或第一个节点,形成一个环。好处是可以从任意节点出发遍历整条链表,适合实现循环队列、约瑟夫环这类问题。需要注意的是遍历循环链表时要特别注意终止条件,否则很容易死循环。常见的做法是用一个计数器,最多遍历 n 次就停,或者先记录 головный节点,回到起点就结束。
我给你的学习路线建议是:先把单链表练到闭着眼睛都能写,再花一个晚上把单向代码改成双向,你会对指针操作有更深的体感。循环链表可以放到学完栈和队列之后做,因为它的经典场景基本都在那些地方。
6.2 一道经典的面试题:翻转链表
翻转链表可能是链表题目里最常出现的一道题,它考察的就是你对“改指针顺序”的熟练度。比如 1→2→3→4→5 翻转后变成 5→4→3→2→1。对于带头节点的链表,翻转的经典做法是头插法:
c复制void reverseList(Node* head) {
Node* p = head->next;
head->next = NULL;
while (p != NULL) {
Node* next = p->next;
p->next = head->next;
head->next = p;
p = next;
}
}
这个函数的思路其实是对每个有效节点做一次头插操作。p 从第一个有效节点开始,先把 p->next 保存下来,然后让 p->next 指向当前头节点后面的链,再把 p 插到头节点后面。这样第一个节点会跑到最后面,最后一个节点会跑到最前面,整个链表就反过来了。我当年第一次写翻转链表时,就是把头插法理解透了之后,发现这道题根本不是难题,只是头插的“故技重施”而已。
接下来你还可以挑战两个经典变形:每K个节点一组翻转,以及判断链表是否有环。前者需要更精细的指针操作,后者需要快慢指针。这些题目在 LeetCode 上都有原题,等你把本文的基础操作吃透之后,去刷这些题会顺畅得多。
7. 最后再分享一点个人经验
我学链表的时候,有一件事对我帮助特别大:每次写完一个操作,我都会把链表画在小本子上,用箭头表示指针,用方框表示节点。插入操作就画一个新的方框,然后把箭头改成预期的样子,再和代码对照。别小看这个方法,它会强迫你在大脑里建立“指针就是箭头”的直观模型,而不是死记代码。等你画了几十个箭头之后,写链表代码就会变成一件很自然的事,因为你已经能“看到”指针的移动方向了。
还有一件事我也想说:链表这个章节不是光看懂就行的,它需要你真的动手去编译、运行、调试。哪怕你照着本文的代码自己敲一遍,都会比只看不练有完全不同的收获。不要怕报错,不要怕段错误,每一个你越过的报错,都会成为你后面写C语言的底气。数据结构的路上,单链表只是个开始,但它值得你花时间打牢。
