先给结论:因为指针这个词在链表里有两种完全不同的含义。一种表示"当前正在看哪个结点",它的值随时在变;另一种表示"整条链表从哪开始",它必须保持不变。前者是遍历指针,后者是头指针。用一个变量同时承担这两件事,遍历跑完链表就找不回来了。

初学者的直觉是"一个链表一个指针就够了",写出来的代码往往长这样:用保存链表起点的那个变量去做循环,一路往后走。编译通过、运行时也能打印出全部元素,看起来毫无问题——直到下一次还想用这条链表的时候,它已经是一条空链了。
本文基于标准 C 的通用行为,示例是一个可以整体编译运行的完整程序。所有输出为预期结果(未在本机执行),读者需要亲自编译一遍确认。适用边界也写清楚:这里讨论的是单向不带头结点的链表,带头结点的写法在判断上有细微差别。
先看结论
- 头指针代表入口:它保存第一个结点的地址,遍历过程中绝对不能被改动。
- 遍历指针代表当前位置:每一轮循环往后挪一个,走到 NULL 表示到达链尾。
- 需要改动入口时传二级指针:头部插入与删除会更换第一个结点,这时要把头指针的地址传进函数。
- 释放顺序不能乱:先记住下一个结点的地址,再释放当前结点。
结构体与指针的基础语法记不清时,可以对着 C 语言教程 的指针对章节复习一遍。
一、一个能直接编译的最小例子
把问题定死:输入三个数,用链表存起来,打印之后释放内存。
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
Node *append(Node *head, int value) {
Node *node = (Node *)malloc(sizeof(Node));
if (node == NULL) {
return head;
}
node->data = value;
node->next = NULL;
if (head == NULL) {
return node;
}
Node *cur = head;
while (cur->next != NULL) {
cur = cur->next;
}
cur->next = node;
return head;
}
int main(void) {
Node *head = NULL;
head = append(head, 10);
head = append(head, 20);
head = append(head, 30);
Node *cur = head;
while (cur != NULL) {
printf("%d\n", cur->data);
cur = cur->next;
}
cur = head;
while (cur != NULL) {
Node *next = cur->next;
free(cur);
cur = next;
}
head = NULL;
return 0;
}
这段程序里出现了两个角色清晰的变量:head 自始至终只在赋值时出现,cur 全程在被挪动。前者是链表的入口,后者是当前位置。
编译与运行(预期结果,未在本机执行):
gcc -std=c11 -Wall list.c -o list
./list
预期输出是三行数字:10、20、30。加上 -Wall 是为了让编译器把可疑写法直接提示出来,初学阶段不建议省掉。
二、只用一个变量会发生什么
下面这段是局部片段,不能单独编译运行,这里只为说明变量被挪动之后的结果:
/* 错误示范:遍历结束后入口已经丢失 */
while (head != NULL) {
printf("%d\n", head->data);
head = head->next;
}
/* 此时 head 已经是 NULL,这条链表再也找不回来了 */
这段能打印出正确结果,但它把入口改成了 NULL。之后任何试图使用这条链表的代码都只会看到一个空指针,而且由于没有立即崩溃,排查时很难联想到是上一次打印造成的。
正确的写法就是多引入一个变量:
Node *cur = head;
while (cur != NULL) {
printf("%d\n", cur->data);
cur = cur->next;
}
两者的差别只有一行,含义却完全不同:cur = head 是把入口的值复制一份给临时变量,让临时变量去走,入口原封不动。
这也是"两种含义"真正的由来:同一个地址值,既可以表示"我是这条链表的起点",也可以表示"我现在访问到这里",区别只在于你打算让它保持多久。

三、当修改发生在头部:二级指针
第二个容易卡住的地方是:要在链表最前面插入或删除结点时,入口本身会变。
因为 C 的函数参数是值传递,把入口变量传进函数,函数里只能改它指向的内容,改不了这个函数外面的那个变量。所以在函数内部申请好结点并接好后,要用返回值把新入口交回去,这也是上面 append 返回 head 并且调用处写 head = append(head, 10) 的原因。
另一种写法是传入入口变量的地址:
void push_front(Node **head, int value) {
Node *node = (Node *)malloc(sizeof(Node));
if (node == NULL) {
return;
}
node->data = value;
node->next = *head;
*head = node;
}
这里 Node **head 的含义是"指向入口变量的指针",通过 *head = node 直接改写外层变量。调用时写 push_front(&head, 5)。
选哪种取决于你的风格:返回值方式更直观,head = append(head, x) 一眼能看出入口被更新了;二级指针方式更适合不需要返回值的修改型函数。混着用最容易出错的地方是忘了接收返回值——函数明明改了,外面还是旧入口。
内存相关的失败往往源于把顺序写反:释放结点之前必须先取出 next,否则一旦执行 free,那个地址就成了悬空值。上面释放那段写成先 Node *next = cur->next; 再 free(cur);,就是为了避免这个问题。

四、排查顺序与常见崩溃
把定位固化成一套流程(预期结果,未在本机执行):
- 配置:编译时始终加上
-Wall -Wextra,让可疑写法在编译期暴露; - 检查:逐个函数过一遍,确认有没有在循环里改动代表入口的那个变量;
- 运行:用两个元素的小样本跑一遍,比用十个更容易看清指针走向;
- 预期:打印顺序与插入顺序一致,且遍历之后入口变量的值没有变化;
- 失败:第二次使用链表时得到空内容,通常是遍历时动用了入口;
- 失败:在释放阶段崩溃,检查是不是先把结点释放后才取 next;
- 修复:遍历用独立的临时变量,头部修改统一走返回值或二级指针;
- 验证:在遍历前后各打印一次入口的值,两次必须完全相同。
动态内存的分配与释放是这个话题的另一半:每次申请都要在同一层有对应的释放,链表越长越要保证释放走完整一轮,否则内存占用会随运行次数累积。
编译命令的参数记不清时,翻 Linux 速查手册 里的编译调试一节可以核对。
总结
链表里的指针之所以让人困惑,是因为同一个地址值在"这是入口"和"这是当前位置"两种语义之间来回切换。
四条带走:
- 代表入口的变量在遍历里不动,另起一个变量去走;
- 遍历前把入口的值复制给临时变量,而不是让入口自己往前挪;
- 头部插入删除会改写入口,用返回值或二级指针二选一;
- 释放前先取出下一个结点的地址再释放当前结点。
下一步建议把这个小程序改写成支持头部插入的版本,用两种方法各实现一次,体会入口更新的时机。
常见问题
Q:为什么遍历一遍之后链表就用不了了?
A:因为你用保存入口的变量去跑循环,跑完它已经指向空。改成用另一个变量遍历,入口保持不变即可。
Q:插入时为什么要传入二阶指针?
A:因为要在函数里改写外层的入口变量。C 语言的参数是值传递,不传地址就只能改它指向的内容,改不了变量本身。
Q:typedef 里的 struct Node next 为什么不能写成 Node next?
A:因为在结构体内部,别名 Node 还没有定义完成。用带标签的完整写法 struct Node *next 才能被正确识别。
Q:释放的时候为什么会崩溃?
A:多数是先释放了当前结点,再去取它的 next。释放之后那块内存的归属已经交回系统,再访问得到的结果是不确定的。
Q:怎么一眼看清指针走向?
A:先删到只剩两个结点,在每处赋值后面打印变量的值。样本越小,越容易判断是哪一步把指针挪错了。
延伸学习
想把指针这块练扎实,可以按这个顺序来:
- 想在题目里反复练这类结构,编程面试:数据结构问题 可以跟一遍;
- 读一篇相关笔记 C语言指针是什么,从更基础的角度理解指针变量;
- 想看同一套结构在另一门语言里的写法,翻 C++ 教程 最快。

TRAE-AI编程



