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,否则它会一直存在。
这就是链表和数组的一个关键区别:数组可以“声明一个然后用”,因为空间是静态分配好的;链表必须“造一个才能用”,因为每个节点的生命周期都依赖动态内存管理。
