1. 从数组的烦恼说起:为什么要用链表
1.1 一个看似简单却让人头疼的需求
我先问大家一个问题:假设你要维护一个学生名单,人数不断变化,今天加一个、明天删一个,你还不知道最多会有多少人。用数组怎么写?
刚学编程的人多半会这么干:定义一个足够大的数组,比如 struct student students[1000]。这显然是个笨办法,1000个够不够?不够怎么办?定义大了浪费内存,定义小了又不够用。更麻烦的是,你往中间插入一个学生,后面所有的元素都得往后挪一位——插入一个元素,平均要移动 n/2 个元素。
我做了一个简单测试,在 10 万元素的数组中插入一个元素,循环移位大概要花几十毫秒,单独看不算什么,但如果你的程序要频繁插入呢?性能就会变得非常难看。
这就是数组这类“连续存储结构”的典型痛点:插入和删除要大规模搬移数据。
1.2 链表的核心思路:用“线索”替代“顺序”
链表解决这个问题的思路很朴素:既然连续排列会导致牵一发动全身,那就不让大家挨着坐,而是给每个元素配一个“指针”,让它记住下一个元素在哪。这样,插入和删除只需要改一两个指针,谁都不用挪位置。
打个比方:数组就像电影院里的连排座位,座位号固定,坐满了的人要往里插一个,从中间开始所有人都得挪一遍。链表就像一群玩“寻宝游戏”的人,每个人手里都拿着一张纸条,上面写着下一个人的位置,你要找第5个人,就得从第1个人开始顺藤摸瓜。
这就是“线性表的链式存储结构”。C 语言正好是学习它的绝佳载体——指针本身就是 C 语言的核心,用 C 实现链表,你能真切地感受到内存是怎么被管理的,而不是像 Python 那样直接用现成的 list 完事。
1.3 这篇博文能给你什么
这篇文章会带你走完一条完整的路:从链表的结构定义,到创建、插入、删除、遍历、反转等核心操作,再到典型错误分析和调试技巧。每一个操作我都会搭配完整的 C 代码、图解式讲解和注意事项。
适合三类人来看:
- 正在学《数据结构》课程的学生:教材里的伪代码和条条框框容易让人晕,这里给你可以跑起来的真实代码和踩坑记录。
- 刚学完 C 语言、想进阶的自学者:链表是你从“写练习题”走向“设计程序结构”的第一道门槛。
- 准备面试的求职者:链表在面试中的出现频率高得离谱,反转链表更是必考中的必考。
读完这篇,你不仅能手写链表,还能明白“为什么这么写”,能排查常见的指针错误。这就够了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链表的基本结构与关键概念
2.1 先从最基础的单链表结构说起
单链表之所以叫“单”,是因为每个节点只有一个指针,指向它的后继。它的节点定义在 C 语言里长这样:
c复制typedef struct Node {
int data; // 数据域:存具体的数据
struct Node *next; // 指针域:指向下一个节点
} Node;
这里有个新手经常问的问题:为什么指针类型是 struct Node *,而不是直接用 Node *?
原因是这样的:在 typedef 还没生效之前,编译器还不知道 Node 是什么,所以你必须在结构体内部用完整的 struct Node * 来声明指针。等 typedef 生效之后,外面再写 Node *p 就完全没问题了。这是 C 语言声明顺序的坑,跳过不算事,但理解了能省去很多编译报错的困惑。
数据域我用了 int,实际项目里完全可以换成别的,甚至换成一个结构体。比如,学生信息管理系统中,每个节点存的是一个 struct student,里面有学号、姓名、成绩;在操作系统的进程管理中,每个节点存的是一个进程控制块。数据域的类型不改变链表的操作逻辑,这一点等你后面实现多几个例子就明白了。
另外,你还经常会看到一种写法:在链表头部加一个不存数据的“头节点”(head node)。它存在的意义有两点:
- 让空链表和非空链表的处理逻辑统一,不用对“第一个节点”单独写分支判断。
- 在头部插入、删除节点时,不需要修改头指针本身,操作起来更简洁。
有些教材把“头指针”和“头节点”混着说,很多人一开始会乱。我在这里明确一下:头指针是指向链表第一个节点的指针,它本身只是一个指针变量;头节点是链表中的第一个节点,只是这个节点不存有效数据。这篇文章里我会带头节点的写法,因为在实际工程中更常见。
2.2 堆与栈:链表节点住在哪里
写链表的时候,创建节点必然用到 malloc。新手最容易困惑的是:为什么必须要用 malloc?我直接定义一个局部变量不行吗?
我们来想一个场景:函数 createNode 中定义一个局部节点:
c复制Node createNode(int data) {
Node n;
n.data = data;
n.next = NULL;
return n;
}
表面看没问题,但这个节点是存在栈上的。栈的特点是函数返回时自动回收内存,而且局部变量每次进入函数都重新分配。问题在于:你无法在函数外部长期持有这个节点,更不可能用来构建一条“链”——链表的节点生命周期必须贯穿整个链表的使用过程。
malloc 就不同了。它从堆上申请内存,堆上的内存不会因为函数返回而消失,只有你手动调用 free 才会释放。这正是链表节点所需要的特性:我可以在一个函数里创建节点,在另一个函数里删除节点,整个链表自始至终存在。
还有个细节要注意:malloc 返回的是 void *,在 C 语言中会自动转换为目标指针类型,但在 C++ 中需要显式强转。另外,每次 malloc 之后一定要检查返回值是否为 NULL。堆内存有限,申请失败的场景是真实存在的,很多教材代码为了简洁省略了检查,但实际工程中这是不可接受的。
2.3 指针是链表的灵魂:一张图看懂指针操作
链表操作的核心,说白了就是“指针的重新指向”。在学习的时候,强烈建议你拿纸笔把每一步的指针变化画出来。
举个例子:在链表中删除一个节点 p 的后继节点,核心操作是:
c复制Node *tmp = p->next;
p->next = tmp->next;
free(tmp);
这短短三行代码里包含了一个非常关键的思路:先用 tmp 保存要删除的节点,再翻越它,最后释放内存。
有些初学者会犯这样的错误:直接写 p->next = p->next->next;,然后就不管了。表面上看,这个节点确实从链表里“脱离”了,但它的内存没有被释放——这就是传说中的内存泄漏。一次两次没关系,但要是在循环里这么干,程序跑久了内存就会越占越多,最后崩溃。
另一个常见误区是“先释放再改指针”:
c复制free(p->next);
p->next = p->next->next; // 错误!p->next 已经变成悬空指针
这就像你先拆了一座桥,再让人按桥的地址去对岸,人直接掉河里。被 free 的指针,你就不该再去读取它的 next,因为这个指针指向的内存已经不属于你了。
3. 手把手实现单链表核心操作
3.1 准备工作:完整的代码框架
为了不让后面的代码悬空,先给出一个常用的基础框架。我们维护一个带头节点的单链表,头节点作为链表的“哨兵”,不
