1. 先搞清楚一件事:单链表到底在内存里长什么样
很多同学学到链表这一节,第一反应是:结构体里放个指针指向下一个节点,这不就完了吗?但等到自己动手写代码,尤其是写头插法的时候,数据顺序跟想象完全相反,瞬间就懵了。根本原因不是代码写错了,而是没搞懂链表和数组在内存里完全是两种组织方式。
数组在内存里是一段连续的空间,a[0]、a[1]、a[2]是紧挨着的,你按下标访问,靠的是“基地址+偏移量”。链表完全不同,它的每个节点是独立malloc出来的,散落在堆区的各个角落,节点和节点之间没有任何物理上的相邻关系。那链表靠什么把它们串起来?靠的就是每个节点里的那个指针域,它存的是下一个节点的地址。
所以单链表的核心思想就一句话:用指针把分散的内存块串成一条逻辑上的链。这个“逻辑上的链”是精髓。物理上A节点和B节点可能隔着十万八千里,但只要A的next指针存了B的地址,它们就在逻辑上相邻。这也解释了为什么链表插入、删除只需要改指针,不需要搬动数据——因为从来没有人要求节点们物理相邻,你只需要把指针的指向重新接一下就行。
我见过很多人一开始理解不了“指针域”这个概念,这里给一个生活化的类比:链表就像一列火车。每节车厢装着自己的货物,车厢之间靠挂钩连接。想在第2节和第3节之间加一节新车厢,只需要把挂钩拆开,把新车厢挂上去,再把后面的车厢挂到新车厢后面,根本不需要把整列火车拆了重装。数组呢,就像电影院的一排座位,座位都是固定死的,你临时加一个人,要么找个别的地方塞,要么让一整排人都挪位置。
这个类比可以一直往下延伸:火车想让某一节车厢离开,解开前后两个挂钩就行;链表删除一个节点,也是同样的道理。理解了“车厢+挂钩”这个模型,后面头插法、尾插法、删除节点全都是围绕挂钩的拆与装,代码写起来就不容易懵了。
1.1 结构体定义:数据域和指针域各管什么
C语言里实现单链表,节点的定义非常标准,几乎所有教材都是这样写的:
c复制typedef struct Node {
int data; // 数据域:节点实际存放的数据
struct Node *next; // 指针域:指向下一个节点的地址
} Node;
这里的重点有两个。第一个重点是数据域,我这里用int类型做演示,实际业务里data完全可以是一个结构体、一个字符串、或者任意自定义类型。第二个重点是next指针,它的类型是struct Node *,也就是“指向Node这个结构体的指针”,这行定义最让人困惑的就是:Node都还没定义完,怎么就能用Node来定义指针呢?
这在C语言里是合法的,原因是指针本身只占固定大小的内存(32位系统是4字节,64位系统是8字节),不管你指向的类型是什么,指针变量本身的大小是确定的。所以在结构体内部声明一个指向自身类型的指针,编译器完全能处理。这里新手最容易犯的一个错误是写typedef时搞混,我建议直接先把结构体完整写出来:
c复制struct Node {
int data;
struct Node *next;
};
然后再用typedef给这个结构体起一个别名。上面的写法是一种常见的合并写法,写多了就习惯了,但如果你第一次接触,先用分步写法心里更踏实。你只要记住:只要在结构体里面要声明“指向下一个节点”的指针,就一定得用struct Node *这个形式,因为此时typedef别名还没生效。
1.2 无头结点和有头结点的区别:两种写法的打法和用途
单链表的实现有两个流派,一个是带头结点(dummy node),一个是不带头结点。这两个“带头”的“头结点”指的是在第一个真正的数据节点之前,额外再挂一个不存数据的节点。这两个流派在学习的时候都想搞清楚,因为你今天用不带头结点的写法,明天看别人的代码可能就带头结点,不搞清楚会导致整段代码都看不懂。
先说最核心的区别:
- 不带头结点:head指针直接指向第一个数据节点。空链表时head为NULL。
- 带头结点:head指针永远指向那个哨兵节点,哨兵节点的next才是第一个数据节点。空链表时head->next为NULL。
使用头结点最大的好处是统一了“空链表”和“非空链表”的操作。举个例子,不带头结点的链表在头部插入一个节点,需要修改头指针head本身;而带头结点的链表在头部插入,永远只需要修改head->next,不管链表是不是空的。删除第一个节点时同理,不带头结点要改head,带头结点只需要head->next = head->next->next。带头结点的写法对初学者的好处是减少对头指针的特判,代码出错概率更低。
不过话说回来,考研、面试、竞赛中很多题目给的是不带头结点的版本,LeetCode上的链表题也是head直接指向第一个节点。我个人的学习建议是:两种写法都要会,但初学时先用不带头结点的版本把“指针操作”这件事理解透彻,因为它的头指针变化更加直接,能帮你把每一步的指针修改都看得清清楚楚。等基础扎实了,再切换到带头结点的写法感受一下它的便利。这篇文章的核心代码我以不带头结点为主,同时会在关键位置补充说明两种写法的差异。
1.3 写链表代码前必须先顺一遍脑内流程
有些同学拿到链表题就埋头敲代码,敲到一半卡住了,原因是没有提前把整个逻辑流程理顺。写链表代码和写数组代码的最大不同是:数组的访问路径很简单,arr[i]就是它,你怎么想就怎么写;链表的每一步操作涉及多个指针的改写,如果脑子里没有完整的操作顺序,代码一定会出逻辑错误。
头插法为什么容易错?因为你脑子里如果有“把新节点塞到头部”这个模糊想法,动手写代码时自然想到的是:head = newNode; newNode->next = head;。这两句话的顺序一写反,你亲手把链表丢了一半。类似的例子在链表操作里比比皆是,几乎每个操作都要考虑“先改谁、后改谁”。
所以我的习惯是动笔写代码之前,先画一遍节点之间箭头的指向变化。传统教科书里教的“画图法”在这里非常实用,每次调试链表题遇到问题,我会把当前链表的每个节点都画出来,再标注每一行代码执行完之后的箭头变化。只要能画出图,代码逻辑基本不会写出致命的错误。这篇文章后面涉及的每一个操作,我都建议你先自己画一画,再对照代码验证。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 建链表之前的基础设施:初始化、遍历、释放
有了节点类型,就可以开始搭链表的基础设施了。这部分代码看起来不起眼,但它是一个链表的“地基”。地基建不好,后面写头插法、尾插法会到处踩坑。
2.1 初始化一个空链表
空链表初始化其实就是一个赋NULL的操作:
c复制Node *head = NULL;
就这么简单。NULL是一个空指针,它表示“这个链表目前一个节点都没有”。为什么要强调这一步?因为如果你定义一个局部变量Node *head;但是不赋初值,它的值是随机的,你对一个随机地址做任何操作都会导致程序崩溃。这个错误在C语言里极其常见,而且排查起来需要费不少功夫。
如果你要写带头结点的版本,初始化就会多一步:
c复制Nod
