大二那年我第一次在《数据结构》作业里写单链表插入,连续崩了三个晚上。代码逻辑看着明明没问题:先 malloc 一个节点,把数据填进去,然后“接上”链表。可一运行,要么什么都不打印,要么直接 Segmentation Fault。后来一个学长看了一眼我的代码,指了指其中一行:p->next = p;。当时没反应过来,他又补了一句:“你想想这会儿 p->next 本来是啥,被你这一赋值覆盖掉以后,后面的节点还找得着吗?”那天我才真正意识到,单链表的核心根本不是什么“用结构体串一串数据”,而是指针在内存里怎么指、指完之后原来指向的东西丢没丢——也就是标题里那六个字:指针与动态内存操作。
这篇东西想跟你把单链表整个过一遍。从最基础的节点结构体开始,到插入、删除、反转、排序这些操作里指针怎么摆弄,再到 malloc 和 free 背后那些“看不见的坑”。不管你是在校生赶数据结构实验,还是准备面试前突击算法题,又或者是自学 C 语言想真正搞懂指针,这篇应该都能让你少走几步弯路。我会尽量用平时跟同事交流的口吻,把每一步为什么这么写、不这么写会怎样,都摊开来讲。
1. 从结构体到节点:先搞清楚链表里存的到底是什么
很多教材讲单链表,上来就甩一段 typedef struct Node { int data; struct Node *next; } Node;,然后就开始讲操作。但新手最困惑的不是操作,而是这个结构体本身:为什么 next 的类型是 struct Node *?为什么不能直接写 struct Node next;?
1.1 自引用结构体:为什么 next 必须是指针
你试着在结构体里直接放一个“下一个节点”的完整副本:
c复制struct Node {
int data;
struct Node next; // 错误
};
这样写编译器直接报错,因为这个结构体的大小是无穷大:next 里还有 next,next 里还有 next……套娃套到天荒地老。但改成指针就不一样了:
c复制struct Node {
int data;
struct Node *next;
};
指针在 64 位系统上固定占 8 个字节,它只存“下一个节点在哪块内存”,不存“下一个节点本身”。这就好比你手里攥着一张写着朋友家庭住址的纸条(地址),而不是把朋友整栋房子搬过来。这样的话,每个节点的大小是固定的:一个 int(4字节)加一个指针(8字节),在常见 64 位平台上对齐后通常占 16 字节。
明白了这个,后面所有操作的本质就一句话:我们是在不断地改写“纸条上的地址”,让节点按不同顺序串起来。 头插法是把新节点的纸条先写上新地址,再把全局入口改写;删除是绕过一个节点,让前一个节点的纸条直接写后一个节点的地址。
1.2 链式存储和数组存储的本质差异
学链表前先学数组,很容易让人陷入一个思维定式:数据必须排排坐,像电影院座位一样连续挨着。数组确实是这么干的,所以才能用 a[i] 这种下标直接算出第 i 个元素的内存地址:起点 + i * sizeof(元素),时间复杂度 O(1)。代价是插入和删除很痛苦:要在数组中间插一个元素,得把后面所有元素往后搬。
链表反过来了。它的节点可以散布在内存的任意角落,彼此之间靠指针维持联系。你 malloc 一个新节点,它可能落在堆区哪个位置你根本管不着,这不重要,因为你可以把它的地址写进前一个节点的 next 里。这样插一个新节点,最坏情况也只是改两个指针,不需要搬动任何既有数据。
两种结构没有绝对优劣,看场景:
| 操作 | 数组 | 单链表 |
|---|---|---|
| 按下标/随机访问 | O(1) | O(n)(必须从头走到尾) |
| 头部插入 | O(n)(整体后移) | O(1) |
| 已知位置的后插 | O(n) | O(1) |
| 已知位置删除 | O(n)(整体前移) | O(1)(改指针即可) |
| 内存空间 | 连续,快但碎片化难用 | 离散,灵活但每个节点多出一个指针的开销 |
链表第一个好处是插入删除快,第二个好处是内存不需要连续。但它的致命弱点也很明显:不能随机访问。你想找第 n 个节点,就得老老实实从头开始走 n-1 步,哪怕内存物理上还有更快的路径也不能抄近道——因为你手里只有第一个节点的地址,后面的地址全藏在各个节点的 next 里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指针操作前的底层储备:head、游标与野指针
链表操作写多了你会发现,翻来覆去就那么几个指针变量的戏份:head、cur、prev、next。但戏份越简单,演砸的越多。很多人出错不是不懂逻辑,而是对“指针变量本身也是变量”这件事没有肌肉记忆。
2.1 指针变量也是变量:赋值到底改了什么
不少人对 p = p->next; 这句的理解是“p 往前走了一步”,但实际发生的是:把 p->next 里存的那个地址(也就是下一个节点的内存地址)复制给了 p 自己。之后你再访问 p->data,访问的就是下一个节点的数据域了。
画个图理解会更直观。假设有三个节点 A、B、C,地址分别是 0x100、0x200、0x300,那么 A 的 next 字段里存着 0x200,B 的 next 字段里存着 0x300。一开始 cur = head,cur 的值是 0x100。执行 cur = cur->next 后,因为 cur 本身在 0x100 那块内存里取 next 字段(值是 0x200)并赋给自己,于是 cur 就变成了 0x200。此刻 cur 不再“指向”A 节点了,它变成了 B 节点的入口。
这个“改地址”的瞬间,新手最容易犯的错是:遍历时直接动 head。
c复制void printList(struct Node *head) {
while (head != NULL) {
printf("%d ", head->data);
head = head->next; // 把 head 改掉了
}
}
功能上这个函数没问题,遍历完链表也能完整打印。问题在于:传入的是 struct Node * 指针,在函数内 head = head->next 实际上把函数内部的局部变量 head 改了,不会影响函数外的头指针。原因在于 C 语言函数传参是值传递,传进来的是“头指针的值”——即第一个节点的地址,不是头指针本身。所以函数内改 head 变量没问题。但如果你在同一个函数里先遍历了一遍,还想用 head 做别的操作,就发现 head 已经走到 NULL 了。所以在工程代码里,遍历用游标已是共识:
c复制struct Node *cur = head;
while (cur != NULL) {
// 处理 cur->data
cur = cur->next;
}
head 保持不变,想再用随时用。这个细节看起来小,却是很多“链表只走了一遍就丢了”的 bug 源头。
2.2 野指针、NULL 与防御性编程
热词里“野指针”出现频率极高,确实值得单独说。野指针就是“值不确定”的
