线性表之单链表:从节点到内存的完整旅程
单链表,说白了就是一组「手拉手」的节点。每个节点只管两件事:存自己的数据,记住下一个兄弟在哪。我刚开始学的时候觉得这玩意儿比数组麻烦多了,后来在嵌入式项目里才发现——当你需要频繁插入删除、又不知道数据量多大时,链表简直是救命稻草。
1. 节点定义——链表的砖瓦
每个节点长什么样?很简单:
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域
} Node, *LinkedList;
这里有个细节我提一下:struct Node *next 不能写成 Node *next,因为 typedef 还没生效。嗯,这是 C 语言的老规矩了。
LinkedList L 一看就知道是个链表头。
2. 头插法与尾插法——两种构建思路
构建链表,无非两种方式:从头部插,或者从尾部插。各有各的脾气。
头插法
每次新节点都塞到最前面。我当年做学生管理系统时用过这个——因为数据录入顺序不重要,头插法代码简单,跑得也快。
void headInsert(LinkedList *L, int data) {
Node *newNode = (Node *)malloc(sizeof(Node));
newNode->data = data;
newNode->next = *L; // 新节点指向原来的头
*L = newNode; // 头指针更新
}
注意看:头插法得到的数据顺序是反的。你输入 1、2、3,链表里存的是 3、2、1。为什么会这样?因为每次新来的都插在最前面嘛。
尾插法
这个就老实多了,新节点乖乖跟在最后面。需要维护一个尾指针,不然每次都要遍历到尾部——那就太慢了。
void tailInsert(LinkedList *L, int data) {
Node *newNode = (Node *)malloc(sizeof(Node));
newNode->data = data;
newNode->next = NULL;
if (*L == NULL) {
*L = newNode; // 空链表,新节点就是头
return;
}
Node *p = *L;
while (p->next != NULL) {
p = p->next;
}
p->next = newNode;
}
3. 按位查找与按值查找——两种找法
链表不像数组,不能直接下标访问。想找第 i 个节点?老老实实从头走。
按位查找
Node *getByIndex(LinkedList L, int index) {
if (index < 1) return NULL;
Node *p = L;
int j = 1;
while (p != NULL && j < index) {
p = p->next;
j++;
}
return p; // 没找到就返回 NULL
}
这里 index 从 1 开始,是我个人的编码习惯。你想想看,如果从 0 开始,边界条件处理起来反而容易乱。
按值查找
Node *getByValue(LinkedList L, int value) {
Node *p = L;
while (p != NULL) {
if (p->data == value) {
return p;
}
p = p->next;
}
return NULL;
}
按值查找返回的是第一个匹配的节点。如果链表里有重复值,后面的就找不到了。我在项目中遇到过这种情况——后来改成了返回所有匹配节点的链表。
4. 删除节点与释放内存——善后工作
删除节点,说白了就是「跳过」它,然后把它的内存还回去。这里有个顺序问题:先改指针,再 free,顺序不能反。
int deleteNode(LinkedList *L, int index) {
if (*L == NULL || index < 1) return 0;
Node *p = *L;
if (index == 1) {
*L = p->next; // 删除头节点
free(p);
return 1;
}
Node *prev = NULL;
int j = 1;
while (p != NULL && j < index) {
prev = p;
p = p->next;
j++;
}
if (p == NULL) return 0; // 索引越界
prev->next = p->next; // 跳过 p
free(p);
return 1;
}
释放整个链表呢?我建议用循环,不要用递归——嵌入式环境栈空间有限,递归容易爆栈。
void freeList(LinkedList *L) {
Node *p = *L;
while (p != NULL) {
Node *temp = p;
p = p->next;
free(temp);
}
*L = NULL; // 别忘了把头指针置空
}
嗯,这里有个细节:free(temp) 之后,temp 指向的内存已经归还系统了,但 p 还在用 temp->next 吗?不会,因为我们在 free 之前已经把 p 移到下一个节点了。顺序很重要。
知识体系总览
下面这张图把单链表的几个核心操作串起来了。我画图时习惯把「内存管理」单独拎出来——很多初学者只关注增删改查,忘了 free,结果内存泄漏得一塌糊涂。
避坑指南
- malloc 后一定要检查返回值。嵌入式系统内存紧张,malloc 可能返回 NULL。我见过线上设备因为 malloc 失败直接跑飞——加个 if 判断能省很多事。
- free 后记得把指针置 NULL。不然就成了野指针。你想想看,万一后面不小心又用这个指针赋值,那就是写入了已经释放的内存——这种 bug 极难排查。
- 头插法和尾插法不要混用。除非你很清楚自己在做什么。混用会导致节点顺序混乱,调试时头大。
- 删除节点时,先保存下一个节点的地址。不然 free 之后你就找不到下一个节点了。
单链表看起来简单,但指针操作稍不留神就出问题。我当年第一次写链表删除,忘了更新前驱节点的 next,结果链表断成了两截——遍历到一半就崩了。嗯,这些坑踩过一次就记住了。
公众号:蓝海资料掘金营,微信deep3321