前面我们学数组时说过,数组在内存中是连续排列的,这让它访问任意元素飞快(O(1)),但也带来一个死穴:插入和删除慢如牛。想在数组中间插入一个新元素,需要把后面的元素全部往后挪一位,平均要移动一半的数据。
而链表正是为解决这个问题而生。它不再要求元素们挤在一起,而是用“指针”把散落在内存各处的节点串起来。插入一个节点只要修改几个指针,时间复杂度 O(1)。今天我们就来攻克最基础的链表——单向链表。
一、链表是什么?——火车与挂钩
想象一列火车:每节车厢都装着一部分货物,并且挂着一个钩子,连接到下一节车厢。火车头(头指针)只要抓住第一节车厢,就能沿着钩子找到整个列车。
在链表里:
- 车厢 = 结点(Node),包含两样东西:数据 + 指向下一个结点的指针。
- 钩子 =
next指针。 - 火车头 = 头指针(head),指向第一个结点。
单向链表中,每个结点只知道自己的后继是谁,不知道前驱。最后一个结点的 next 指向 NULL,表示列车结束。
head → [数据|next] → [数据|next] → [数据|NULL]
二、结点的定义:结构体 + 自引用
链表结点是一个结构体,里面有一个同类型的指针指向下一个结点。这种“自己指向自己”的结构称为自引用结构。
typedef struct Node {
int data; // 数据域(可以是任意类型,甚至 void*)
struct Node *next; // 指针域:指向下一个结点
} Node;
typedef 让我们以后写 Node 而不是 struct Node。但注意:在结构体内部,Node 这个别名还没定义完成,所以必须写 struct Node *next;。
数据域这里用了 int 作为例子。实际可以换成任何需要的类型,甚至用 void* 指向外部数据(后面专门讲通用链表时会展开)。
三、创建结点:malloc + 赋值
链表的结点是动态分配的,因为我们需要它们在函数返回后依然存在(动态存储期)。
#include <stdlib.h>
Node *create_node(int data) {
Node *new_node = (Node*)malloc(sizeof(Node));
if (new_node == NULL) {
return NULL; // 分配失败
}
new_node->data = data;
new_node->next = NULL;
return new_node;
}
每次调用 create_node 就在堆上造一节新“车厢”,初始时它的钩子为空,等着被挂到列车上。
四、插入结点
1. 头插法:新结点变成新的头
把新结点放到链表最前面,原来的头结点成为它的后继。时间复杂度 O(1)。
// head 是指向头指针的指针(二级指针)
void insert_at_head(Node **head, int data) {
Node *new_node = create_node(data);
if (new_node == NULL) return;
new_node->next = *head; // 新结点的 next 指向原来的第一个
*head = new_node; // 头指针指向新结点
}
为什么用二级指针? 因为插入操作可能需要修改 main 里的头指针变量本身。如果我们只传 Node *head(一级指针),函数内部修改的只是副本,外面的 head 纹丝不动。传 Node **head 我们就能解引用修改外部的指针。这与第十九篇动态分配时讲的一样。
调用:
Node *head = NULL;
insert_at_head(&head, 10);
insert_at_head(&head, 20);
insert_at_head(&head, 30);
// 现在链表:30 -> 20 -> 10 -> NULL
2. 尾插法:新结点追加到末尾
需要先找到最后一个结点(next == NULL 的那个),然后让它指向新结点。如果链表为空,新结点直接成为头。
时间复杂度 O(n),因为要遍历找到尾部。
void insert_at_tail(Node **head, int data) {
Node *new_node = create_node(data);
if (new_node == NULL) return;
if (*head == NULL) {
*head = new_node;
return;
}
Node *current = *head;
while (current->next != NULL) { // 找到最后一个结点
current = current->next;
}
current->next = new_node;
}
五、遍历链表
从头结点开始,沿着 next 一路走,直到碰到 NULL。
void print_list(Node *head) {
Node *current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
遍历是许多操作的基础——查找、计数、打印都靠它。
六、查找结点:按值搜索
从头开始,依次比较每个结点的数据,找到返回结点指针,找不到返回 NULL。
Node *find_node(Node *head, int target) {
Node *current = head;
while (current != NULL) {
if (current->data == target) {
return current; // 找到了
}
current = current->next;
}
return NULL; // 没找到
}
如果要按其他条件搜索(比如查找某个学号的学生),逻辑完全一样,只是比较的条件不同。
七、删除结点
删除比插入复杂,需要处理三种情况。
1. 删除头结点
直接让头指针指向第二个结点,然后释放原头结点。
2. 删除中间/尾部结点
需要找到被删结点的前一个结点,把它的 next 跨过被删结点,指向被删结点的下一个,然后释放被删结点。
同样用二级指针,因为可能要修改头指针。
void delete_node(Node **head, int target) {
if (*head == NULL) return;
Node *current = *head;
Node *prev = NULL;
// 寻找要删除的结点
while (current != NULL && current->data != target) {
prev = current;
current = current->next;
}
if (current == NULL) return; // 没找到
if (prev == NULL) {
// 要删除的是头结点
*head = current->next;
} else {
// 要删除的是中间或尾部结点
prev->next = current->next;
}
free(current);
}
八、释放整个链表
链表用完了,必须归还所有动态分配的内存,否则造成内存泄漏。
void free_list(Node *head) {
Node *current = head;
while (current != NULL) {
Node *temp = current; // 记住当前结点
current = current->next; // 先移动到下一个
free(temp); // 再释放记住的那个
}
}
顺序不能错——如果先 free(current),就再也找不到下一个结点了。
九、反向遍历
单向链表只能从头往后走,如果需要从尾到头输出,有两种方法。
方法 1:递归
void print_reverse_recursive(Node *head) {
if (head == NULL) return;
print_reverse_recursive(head->next);
printf("%d ", head->data);
}
原理是递归会一直走到最后一个结点,然后在逐层返回时打印。优点是简洁,缺点是链表太长会导致栈溢出。
方法 2:辅助栈(迭代)
把数据逐个压入数组(或其他栈结构),再逆序输出。这需要额外 O(n) 空间,但避免了递归栈风险。
void print_reverse_iterative(Node *head) {
int stack[1000]; // 假设链表不超过 1000 个
int top = 0;
Node *curr = head;
while (curr != NULL) {
stack[top++] = curr->data;
curr = curr->next;
}
while (top > 0) {
printf("%d ", stack[--top]);
}
}
另一个经典方法是不需要额外空间的原地反转,但那会改变链表结构,用于需要反复反向遍历的场景。
十、完整示例:串联所有操作
把上面的函数整合起来,测试一遍。
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
Node *create_node(int data) {
Node *n = (Node*)malloc(sizeof(Node));
if (n) { n->data = data; n->next = NULL; }
return n;
}
void insert_at_head(Node **head, int data) {
Node *n = create_node(data);
if (n) { n->next = *head; *head = n; }
}
void insert_at_tail(Node **head, int data) {
Node *n = create_node(data);
if (!n) return;
if (*head == NULL) { *head = n; return; }
Node *cur = *head;
while (cur->next) cur = cur->next;
cur->next = n;
}
void delete_node(Node **head, int target) {
if (*head == NULL) return;
Node *cur = *head, *prev = NULL;
while (cur && cur->data != target) { prev = cur; cur = cur->next; }
if (!cur) return;
if (prev) prev->next = cur->next;
else *head = cur->next;
free(cur);
}
void print_list(Node *head) {
for (Node *cur = head; cur; cur = cur->next)
printf("%d -> ", cur->data);
printf("NULL\n");
}
void free_list(Node *head) {
Node *cur = head;
while (cur) { Node *tmp = cur; cur = cur->next; free(tmp); }
}
int main(void) {
Node *head = NULL;
insert_at_head(&head, 3);
insert_at_head(&head, 2);
insert_at_head(&head, 1);
insert_at_tail(&head, 4);
insert_at_tail(&head, 5);
printf("初始链表: "); print_list(head); // 1->2->3->4->5->NULL
delete_node(&head, 3);
printf("删除 3 后: "); print_list(head); // 1->2->4->5->NULL
delete_node(&head, 1);
printf("删除 1 后: "); print_list(head); // 2->4->5->NULL
free_list(head);
return 0;
}
十一、常见错误与陷阱
1. 忘记在删除或释放后更新指针
free(head);
// head 没有置 NULL,如果后续不小心再访问,就是悬垂指针
释放头结点后,如果外部还要用 head,必须把它置为 NULL。可以 free_list 后手动 head = NULL;。
2. 释放顺序错误导致内存泄漏或访问无效
必须先保存 next,再 free 当前结点,否则就和链表断了联系。
3. 插入时没有修改外部头指针
如果不用二级指针而用一级指针,insert_at_head 函数内部修改了形参,外部的 head 不变,导致链表“丢失”。
void bad_insert(Node *head, int data) {
Node *n = create_node(data);
n->next = head; // head 是形参,修改不影响外部
head = n;
}
// main 里的 head 依然为 NULL
要么返回新的头指针,要么用二级指针,这是链表操作的核心设计选择。
4. 遍历时忘记移动指针,导致死循环
while (cur != NULL) {
printf("%d\n", cur->data);
// 忘了 cur = cur->next; → 无限循环
}
5. 对空链表操作未判空
对空链表调用 delete_node 或 print_list 时,如果没有在开头处理 head == NULL,可能崩溃。所有函数都对空链表保持友好是基本要求。
十二、小结
单向链表是最基础也最经典的动态数据结构。今天你学会了:
- 用自引用结构体定义结点。
- 头插、尾插、按值删除。
- 遍历与查找。
- 用递归或栈实现反向遍历。
- 正确的内存释放步骤。
- 为什么链表操作常使用二级指针。
链表的最大优点在于插入和删除不需要移动数据,代价是丧失了随机访问能力(找第 k 个元素要 O(n))。这种取舍决定了数组和链表在不同场景下的选择。
单向链表只有一个方向的钩子,有时会带来不便——比如删尾部结点要遍历整个链表找到前驱。下一篇我们将引入双向链表和循环链表,让每个结点同时知道前驱和后继,进一步拓展数据结构的灵活性。结合 void* 和函数指针,我们还能写出真正的通用链表库,让一套代码支持任意数据类型。
课后小练习
- 实现一个函数
int list_length(Node *head),返回链表长度。 - 实现一个函数
Node *get_node_at(Node *head, int index),返回第index个结点(从 0 开始),不存在返回NULL。 - 实现一个函数
void reverse_list(Node **head),原地反转单向链表。提示:需要三个指针,逐步改变链接方向。 - (小挑战)基于今天这个
Node定义,实现一个函数int has_cycle(Node *head),判断链表是否有环(即某个结点的next指回了前面的结点)。提示:用快慢指针(Floyd 判圈法)。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。
250



被折叠的 条评论
为什么被折叠?



