学完C语言的结构体和指针之后,绝大多数人碰到的下一道坎就是单链表。这玩意儿上课听的时候觉得挺简单,不就是比数组多了个指针嘛,真到自己写就翻车:要么编译跑着跑着段错误,要么链表打印出来缺头少尾。作为一个被链表折磨过的人,我可以负责任地说,单链表的核心操作翻来覆去就那几件事——创建节点、插入、删除、查找、遍历、逆置。你用C语言把这几件事吃透了,之后看双向链表、二叉树都会轻松很多。这篇博文面向零基础,默认你刚学完结构体和指针,我会把每一步的“为什么这么做”都讲清楚,代码可以直接抄,抄完能跑,跑完能改。
1. 先弄明白:单链表到底是个什么东西
1.1 为什么数组不够用,才会有链表
数组在C语言里是连续内存。这种连续有两个特点:第一,随机访问方便,a[3]直接算地址就能取到值;第二,插入和删除就很痛。你在数组中间插一个元素,得把后面所有元素往后挪一位,数据量一大,CPU时间全耗在搬数据上了。另外数组长度得提前定好,开小了不够用,开大了又白白浪费内存。
单链表的思路完全反过来:节点之间不要求连续,每个节点只负责记住下一个节点在哪。要存一百个数据,就动态创建一百个节点,想插就插,想删就删,不涉及大规模移动。代价是不能像数组那样“点一下就到第n个”,必须从头一个个找。这就是典型的空间换时间、时间换空间的取舍,数据结构后面所有的选择,本质上都在权衡这两件事。
1.2 单链表的核心概念:节点、头指针与“线索链”
单链表的每个节点由两部分组成:数据域放实际数据,指针域放下一个节点的地址。C语言里就是用结构体包起来:
struct Node { int data; struct Node *next; };很多教材会把链表比喻成火车车厢,我个人更喜欢寻宝游戏的比喻:你拿到第一个线索,上面写着第二个线索的位置,顺着找到第二个,又得到第三个的位置……单链表就是一条“线索链”。你只能从第一个线索开始往后找,不能跳跃,也不能回头,这正是它叫“单向”的原因。
链表的入口是一个头指针head,它指向第一个节点。只要head丢了,整条链表就再也找不回来了。你可以把head想象成寻宝游戏的起点坐标,起点没了,后面的所有节点都变成孤立的内存块,想遍历都没门。
1.3 新手最容易卡住的三件事
我在实际辅导时发现,零基础写单链表卡住,通常不是不会语法,而是三个小问题。
第一个是搞不清“头指针”和“头结点”。头指针就是一个指向节点的指针变量,很多教材讲“带头结点”的链表,那是另一种设计,初学者先完全不用管,直接让head指向真正存储数据的第一个节点最直观。
第二个是插入时指针修改的顺序。顺序一错就会断链,具体怎么解决,后面第4章会专门讲。
第三个是遍历时循环条件用错。什么时候写while (cur != NULL),什么时候写while (cur->next != NULL),这个坑几乎人人都会踩。下面几节代码里我会反复强调,你先有个印象。
2. 动手前的准备:环境、基础与接口规划
2.1 环境怎么选:一个编译器加一个终端就够了
单链表不挑环境,任何能写C的地方都能练。Windows 上常见的是 VS 或 Dev-C++,macOS/Linux 上直接 gcc。我更建议新手尽早用命令行:写一个main.c,终端里执行gcc main.c -o main && ./main,报错信息一目了然。
VS 那种大型 IDE 对新手有些干扰,比如scanf老提示要用scanf_s,容易让人怀疑自己写错了。C语言知识点还没吃透之前,越简单的工具越好。另外建议养成编译加-g的习惯,也就是gcc -g main.c -o main,这样以后学 gdb 调试时不用重新编译。我教过不少人,这一步提前做,后面省事很多。
2.2 写链表前必须掌握的三个C语言基础
如果你刚学完指针但还没完全消化,链表正好帮你补课。有三个语法点是绕不开的。
第一个是结构体。上面那个struct Node里,next的类型是“指向本结构体类型的指针”,这种自己在定义里引用自己的写法,第一次见容易发懵,其实意思就是:每个节点里面放着一个指向下一个节点的地址。
第二个是->运算符。p->next等于(*p).next,中文读作“p 所指向的那个节点的 next 成员”。写多之后你会觉得->很顺手,但刚学时一定要知道它背后的解引用动作。
第三个是动态内存。malloc负责按字节数申请内存,返回void*,所以写代码时要强转成结构体指针;free负责回收。这里有个很重要的心智模型:malloc相当于租了一块内存,函数结束后这块内存不会自动还,只有free才会还。局部变量是一出函数就自动没了,所以链表的节点不能靠局部变量来保存——函数一结束,栈上的内存就失效了。这也是为什么链表的节点必须用动态内存来创建。
2.3 先定接口:链表的函数规划
写链表不要想到哪写到哪,而是先定好接口。我习惯按这个思路组织:
- 创建节点:
createNode - 头插:
pushFront - 尾插:
append - 按位置插入:
insertAt - 删除:
deleteNode - 查找:
findNode - 打印:
printList - 逆置:
reverseList - 清空:
clearList
每个函数职责单一,参数尽量传头指针,能返回新头指针就返回新头指针。这样写最大好处是逻辑清楚,出 bug 好定位。比如你在某个功能里发现链表断了,可以马上锁定是哪个函数出了问题,而不是在一大坨main里翻半天。
3. 核心操作逐段拆解
3.1 节点定义与创建:链表的“积木”长什么样
创建节点的函数是所有操作的地基:
#include <stdio.h> #include <stdlib.h> struct Node { int data; struct Node *next; }; struct Node* createNode(int data) { struct Node *node = (struct Node*)malloc(sizeof(struct Node)); if (node == NULL) { printf("内存分配失败\n"); exit(1); } node->data = data; node->next = NULL; return node; }createNode的逻辑很简单:申请内存,往里填数据,把next置空。有一个新手特别容易漏的细节:node->next = NULL。如果不写,malloc返回的内存里的值是随机的,这个随机地址会被当成下一个节点的地址,遍历时就会飞出去,轻则打印出乱码,重则直接段错误。
NULL在这里有两个含义:一是链表结束的标志,二是安全的风向标——遍历走到NULL就知道该停了。内存分配失败的情况虽然少见,但也是必须处理的,很多老手写代码习惯性地判断一下malloc返回值,这是防崩溃的好习惯。
3.2 尾插法:从空链表开始学会遍历
尾插就是把新节点挂在链表的最后面:
struct Node* append(struct Node *head, int data) { struct Node *newNode = createNode(data); if (head == NULL) { return newNode; } struct Node *tmp = head; while (tmp->next != NULL) { tmp = tmp->next; } tmp->next = newNode; return head; }append函数的第一个分支很关键:head == NULL,链表为空,那新节点就是第一个节点,直接返回它。注意这里并没有改变外面的head变量,因为head是函数形参,函数里改head不会影响外面的head变量,所以我们用返回值把新头带出去,调用时要写成head = append(head, data)。
第二个分支需要在链表中找到最后一个节点。怎么判断最后一个?cur->next == NULL就是最后一个。很多新手写成while (tmp != NULL),这么写循环结束后tmp已经变成NULL,NULL->next就没法写了,而且后面也腾不出手来修改它。正确的写法是while (tmp->next != NULL),这样循环结束时tmp正好停在最后一个节点上,可以直接修改它的next。
提示:判断“是否最后一个节点”,永远用
cur->next == NULL;判断“是否遍历完链表”,才用cur == NULL。两个场景别混用。
尾插是链表里最直观的操作,也是第一个能帮你建立“遍历找前驱/后继”感觉的代码。
3.3 头插法:一句代码解决的效率操作
头插法恰恰相反,新节点插到最前面:
struct Node* pushFront(struct Node *head, int data) { struct Node *newNode = createNode(data); newNode->next = head; return newNode; }pushFront只需要两行逻辑:新节点的next指向上一个头指针,然后让新节点成为新的头。为什么把头插单独拿出来讲?因为它和尾插的思考方式完全不同。
尾插要遍历,头插不要遍历,时间复杂度一个是 O(n) 一个是 O(1)。某些场景下,比如要把一串数据建成一个与输入顺序相反的链表,头插就是最优解。还有一个点以后写栈时会用到:栈顶插入和删除本质上就是链表的头插头删。很多题目里的“倒序输出”“反转链表”,用头插法来做会特别顺手。
头插最容易犯的错是顺序写反。有人先写newNode->next = head没问题,但有人会写成先改了head再去设置newNode->next,结果新节点的next指向了自己或者丢了原链表。记住口诀:先让新节点挂上旧链,再让头指针指向新节点。
3.4 打印与查找:让链表“看得见”
打印是调试神器,每写一个操作,马上打印一次,能立刻看出来节点顺序对不对:
void printList(struct Node *head) { if (head == NULL) { printf("链表为空\n"); return; } struct Node *tmp = head; while (tmp != NULL) { printf("%d", tmp->data); if (tmp->next != NULL) { printf(" -> "); } tmp = tmp->next; } printf("\n"); }查找的逻辑也简单,从head开始,如果某个节点数据等于目标值,就返回这个节点的指针,否则往后走:
struct Node* findNode(struct Node *head, int target) { struct Node *tmp = head; while (tmp != NULL) { if (tmp->data == target) { return tmp; } tmp = tmp->next; } return NULL; }这里要强调一个初学者常踩的坑:findNode返回的是指针,找不到时返回NULL。所以调用方一定要判断返回值是否等于NULL,再决定能不能用found->data,否则等于给空指针解引用,这是典型的段错误来源。我见过不少同学在查找这里翻车,不是思路问题,而是忘了判空。
3.5 按位置插入与删除:单链表的难点集中营
按位置插入,位置从1开始计数:
struct Node* insertAt(struct Node *head, int data, int pos) { if (pos <= 0) { return head; } if (pos == 1) { return pushFront(head, data); } struct Node *newNode = createNode(data); struct Node *prev = head; for (int i = 1; i < pos - 1 && prev != NULL; i++) { prev = prev->next; } if (prev == NULL) { printf("位置越界,插入失败\n"); free(newNode); return head; } newNode->next = prev->next; prev->next = newNode; return head; }思路是找到第pos-1个节点作为前驱。for循环里最怕越界:如果链表只有3个节点,你要插到第10个位置,循环会一路走空。所以循环结束后要判断prev == NULL,如果为空说明位置不合法,直接free(newNode)收工,避免白占内存。
删除节点比插入更考验对指针的理解:
struct Node* deleteNode(struct Node *head, int data) { if (head == NULL) { return NULL; } if (head->data == data) { struct Node *tmp = head; head = head->next; free(tmp); return head; } struct Node *prev = head; struct Node *cur = head->next; while (cur != NULL && cur->data != data) { prev = cur; cur = cur->next; } if (cur == NULL) { printf("没有找到要删除的节点\n"); return head; } prev->next = cur->next; free(cur); return head; }删除分两类:删头节点和删非头节点。删头节点,代码是head = head->next然后free(tmp);删非头节点,得先找到“待删节点的前驱”,让前驱的next跨过待删节点,再释放。
为什么要找前驱?单链表每个节点只存了后继地址,没有存前驱地址,你走到中间某个节点时,根本不知道谁指向它,所以删除必须从头部开始,一边走一边记录上一个节点。这也是单链表和双链表最大的区别所在。
3.6 逆置:最容易让新手崩溃的三行代码
单链表逆置是核心操作里的明星题目:
struct Node* reverseList(struct Node *head) { struct Node *prev = NULL; struct Node *cur = head; while (cur != NULL) { struct Node *next = cur->next; cur->next = prev; prev = cur; cur = next; } return prev; }这段代码值得反复琢磨。思路是三个指针:prev表示已经逆置好的部分的头,cur表示当前正在处理的节点,next先保存cur的后继,防止一改指向就把后面的节点弄丢。每次循环只做一件事:让cur掉头指向prev,然后把prev和cur都往前挪一步。
next的作用就是那个“先记下来再动手”的草稿纸。如果没有它,你执行cur->next = prev之后,原来的cur->next就丢了,后面的链表也就找不到了。
画图的时候你会发现,整个逆置过程就像一排人向后转,必须从前到后一个一个转身,而且转身前先记住旁边的人是谁。这段代码空间复杂度是 O(1),只用了固定数量的指针变量,没有开新数组,是链表题里很典型的优化思路。
3.7 完整示例:把上面的代码跑起来
把上述函数串起来,就是下面这个main:
int main() { struct Node *head = NULL; head = append(head, 1); head = append(head, 2); head = append(head, 3); head = pushFront(head, 0); printList(head); // 0 -> 1 -> 2 -> 3 head = insertAt(head, 99, 3); printList(head); // 0 -> 1 -> 99 -> 2 -> 3 head = deleteNode(head, 99); printList(head); // 0 -> 1 -> 2 -> 3 struct Node *found = findNode(head, 2); if (found != NULL) { printf("找到了 %d\n", found->data); } head = reverseList(head); printList(head); // 3 -> 2 -> 1 -> 0 clearList(head); return 0; }建议你自己建一个main.c,把函数全部贴进去编译运行,体会每个操作前后链表的变化。跑通之后,再去尝试改一改:比如先在中间插入,再删除中间节点,看看打印结果是否和自己预想一致。这一步比看十遍书都管用。
4. 新手必踩的坑与排查技巧
4.1 段错误:十次崩溃九次是这个原因
零基础写链表,十次崩溃有九次是段错误(Segmentation fault),本质是对不该访问的内存做了访问。最常见的三种:对NULL指针解引用,访问了已经free的内存,访问了野指针。
定位方式分两个层次。第一层是printList大法,在关键函数前后打印链表,看哪一步出问题;第二层是编译器加-g后使用 gdb,运行到崩溃点,输入bt看调用栈,代码行号直接告诉你崩溃在哪一行。别觉得 gdb 高端,其实就三条命令:run、bt、p 变量名。我见过不少零基础的同学在第一次学会用bt之后,排查效率直接翻倍。
4.2 内存泄漏:只 free 头节点等于白 free
链表的节点全是malloc出来的,C语言不会帮你自动回收。很多新手在链表不再使用时只写了free(head),这是不够的:只释放了第一个节点,后面节点全部泄漏。正确的清空方式是写clearList:
void clearList(struct Node *head) { struct Node *tmp; while (head != NULL) { tmp = head->next; free(head); head = tmp; } }注意必须先保存下一个节点的地址,再释放当前节点。如果先free(head)再想去访问head->next,就已经踩到野指针了。另外,删除节点后,最好把被释放的指针置为NULL,比如tmp = NULL。这能防止以后误用,提前把野指针掐死在摇篮里。
判断链表程序有没有内存泄漏,Linux 下可以用 valgrind,Windows 下可以用 CRT 调试工具;但对学习阶段来说,养成“每个malloc都要有对应free”的习惯比工具更重要。
4.3 断链问题:改指针顺序记住一条黄金法则
插入和删除时,最怕“把链改断了”。有一条黄金法则:先用新指针把后继接住,再断开旧连接。
插入时先写newNode->next = prev->next,再写prev->next = newNode,这样即使写错了,也只是多了一个节点指向旧链,不会丢链;反过来先执行prev->next = newNode,原来的后继就被你丢掉了。删除时同理:先让prev->next = cur->next,接住了以后再free(cur),顺序也不能反。
这条法则本质上是提醒你:单链表里每个节点只有一个出口,改口之前,先给后面的人留好后路。我自己写链表题时,哪怕是简单的头插,也会在心里默念一遍“先接后断”。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 程序一运行就段错误 | 对空链表操作或对NULL解引用 | 在入口处判断head是否为NULL,或用打印定位 |
| 打印结果出现巨大数字或乱码 | 节点next未初始化,遍历到野地址 | 检查createNode中是否写了next = NULL |
| 打印时陷入死循环 | 链表成环(某节点next指向自己) | 检查插入代码,防止newNode->next误写成自身 |
| 删除节点后访问崩溃 | 使用了已经free的指针 | 释放后将指针置为NULL,并检查调用方 |
| 链表少尾节点或遍历不完整 | 遍历条件写错 | 找尾用cur->next != NULL,遍历用cur != NULL |
| 内存泄漏 | 只free头节点,未释放其他节点 | 用clearList逐个释放 |
5. 把链表写出“工程感”:代码组织与后续扩展
5.1 从零散函数到小工具库:头文件与接口分离
学到这里,你已经不是只会抄代码的初学者了,可以考虑做一次工程化整理:把节点定义和所有函数放进list.h和list.c,main.c里只include头文件,然后调用接口。头文件里放函数声明,源文件里放实现,这样调用方不需要关心内部细节,只需要知道接口怎么用。
这种“接口和实现分离”的习惯,在以后写项目时非常受用。进一步你还可以扩展函数:求链表长度、按位置获取节点、把链表清空、合并两个有序链表等。每加一个函数前先想清楚输入输出,写起来会很顺。我自己带某个开发者的时候,就要求他所有链表操作必须通过函数接口完成,不允许在main里直接动节点的next,这个习惯帮他后面写项目避了很多坑。
5.2 再往前一步:单链表还能延伸出什么
单链表只是数据结构的起点。它延伸出来的方向太多了:双向链表(每个节点多一个prev指针)、循环链表(尾节点指向头节点)、用链表实现栈和队列、用链表解决哈希冲突(拉链法)、LRU 缓存淘汰策略。这些内容在实际开发里都很常见。
刷题时你还会看到大量“反转链表”“合并两个有序链表”“环形链表”相关的题目,本质都是今天这些核心操作的变形。我的建议是,先把单链表的手写像背单词一样熟练,再去碰这些变形题,会顺利很多。不要贪快,把今天这套代码写到不需要看书就能默写出来,再进入下一章。
最后说一点我自己的体会。链表这关,很多人卡住不是理解力问题,而是动手太少。你看懂了头插、删除、逆置,和你能独立把它们写出来,中间隔着四五次“报错—排查—画图—重写”的循环。我当年第一次写逆置,写了三遍才过,最后靠的是拿纸笔把五个节点画出来,一格一格地手动模拟指针移动。强烈建议你也这样练一次:把电脑合上,在纸上画一串节点,模拟一遍删除中间节点和逆置,等画明白了再回来敲代码,你会发现思路一下子通了。链表是后面二叉树、图这些复杂结构的地基,这里多花两小时,后面能省两天。希望这篇零基础入门能真正帮你跨过那道坎。