文章目录
- 前言
- [一、数组 vs 链表:优缺点对比](#一、数组 vs 链表:优缺点对比)
- 二、单向链表基础定义
- [2.1 带头结点的设计](#2.1 带头结点的设计)
- [2.2 头文件定义(linklist.h)](#2.2 头文件定义(linklist.h))
- 三、核心操作代码实现与详解
- [3.1 创建空链表](#3.1 创建空链表)
- [3.2 判断链表是否为空](#3.2 判断链表是否为空)
- [3.3 头插法插入节点](#3.3 头插法插入节点)
- [3.4 尾插法插入节点](#3.4 尾插法插入节点)
- [3.5 遍历打印链表](#3.5 遍历打印链表)
- [3.6 链表反转(头插反转法)](#3.6 链表反转(头插反转法))
- [3.7 链表冒泡排序(交换数据版)](#3.7 链表冒泡排序(交换数据版))
- [3.8 按值查找节点](#3.8 按值查找节点)
- [3.9 按值删除节点](#3.9 按值删除节点)
- [3.10 销毁链表](#3.10 销毁链表)
- 四、实战高频踩坑总结
- [1. 空指针解引用引发段错误(SIGSEGV)](#1. 空指针解引用引发段错误(SIGSEGV))
- [2. 指针修改顺序错误导致断链](#2. 指针修改顺序错误导致断链)
- [3. 冒泡排序外层条件写错](#3. 冒泡排序外层条件写错)
- [4. 销毁链表只释放不置空](#4. 销毁链表只释放不置空)
- 五、学习总结
前言
本文是单向链表的入门学习笔记,基于 C 语言、带头结点的设计实现,覆盖创建、插入、遍历、反转、排序、删除、销毁全流程核心操作,并结合实际调试中遇到的段错误、逻辑漏洞等问题整理踩坑指南。
一、数组 vs 链表:优缺点对比
线性表有两种经典存储方式:顺序存储(数组)和链式存储(链表),二者特性互补,适用场景不同。
表格
| 维度 | 数组(顺序表) | 单向链表 |
|---|---|---|
| 空间特性 | 内存空间连续,提前分配固定大小 | 内存空间不连续,节点动态申请,按需扩容 |
| 访问效率 | 随机访问效率高,下标访问时间复杂度 O (1) | 只能顺序遍历访问,查找时间复杂度 O (n) |
| 插入 / 删除 | 效率低,需要移动大量元素,平均 O (n) | 找到目标位置后,修改指针即可,时间复杂度 O (1) |
| 空间限制 | 元素数量有上限,扩容麻烦,易造成空间浪费 | 只要内存足够,理论上无元素上限 |
| 额外开销 | 无额外指针开销,空间利用率高 | 每个节点需要存储指针域,增加空间开销 |
总结:频繁查找、数据量固定选数组;频繁插入删除、数据量动态变化选链表。
二、单向链表基础定义
2.1 带头结点的设计
本文所有代码均采用带头结点的单向链表设计:
- 头结点不存储有效数据,仅作为链表的入口标识;
- 头结点的
pNext指向第一个有效数据节点; - 优势:统一空链表和非空链表的操作逻辑,减少边界判断。
2.2 头文件定义(linklist.h)
#ifndef __LINKLIST_H__
#define __LINKLIST_H__
typedef int DataType;
// 链表节点结构体
typedef struct node{
DataType Data; // 数据域
struct node *pNext; // 指针域,指向后继节点
} Node_t;
// 函数声明
extern Node_t *CreateEmptyLinkList(void);
extern int IsEmptyLinkList(Node_t *pTmpList);
extern int InsertHeadLinkList(Node_t *pTmpList, DataType TmpData);
extern int InsertTailLinkList(Node_t *pTmpList, DataType TmpData);
extern void ShowLinkList(Node_t *pTmpList);
extern Node_t *FindLinkList(Node_t *pTmpList, DataType TmpData);
extern int DeleteLinkList(Node_t *pTmpList, DataType TmpData);
extern int ReverseLinkList(Node_t *pTmpList);
extern int BubbleSortLinkList(Node_t *pTmpList);
extern void DestroyLinkList(Node_t **ppTmpList);
#endif
三、核心操作代码实现与详解
3.1 创建空链表
功能:申请头结点,初始化后继为空,返回头结点地址。
Node_t *CreateEmptyLinkList(void)
{
Node_t *pTmpNode = (Node_t *)malloc(sizeof(Node_t));
if(NULL == pTmpNode)
{
printf("malloc failed!\n");
return NULL;
}
pTmpNode->pNext = NULL;
return pTmpNode;
}
3.2 判断链表是否为空
int IsEmptyLinkList(Node_t *pTmpList)
{
if(NULL == pTmpList) return 1;
return pTmpList->pNext == NULL ? 1 : 0;
}
3.3 头插法插入节点
核心逻辑:新节点插入到头结点之后,成为第一个有效节点。
int InsertHeadLinkList(Node_t *pTmpList, DataType TmpData)
{
if(NULL == pTmpList) return -1;
Node_t *pTmpNode = (Node_t *)malloc(sizeof(Node_t));
if(NULL == pTmpNode)
{
printf("malloc failed!\n");
return -1;
}
pTmpNode->Data = TmpData;
// 头插两步法:先连后面,再连头结点
pTmpNode->pNext = pTmpList->pNext;
pTmpList->pNext = pTmpNode;
return 0;
}
注意:顺序不能颠倒。如果先修改头结点的指针,会丢失原有链表,造成断链。
3.4 尾插法插入节点
核心逻辑:遍历找到最后一个节点,将新节点挂在尾部。
int InsertTailLinkList(Node_t *pTmpList, DataType TmpData)
{
if(NULL == pTmpList) return -1;
Node_t *pTmpNode = (Node_t *)malloc(sizeof(Node_t));
if(NULL == pTmpNode)
{
printf("malloc failed!\n");
return -1;
}
pTmpNode->Data = TmpData;
pTmpNode->pNext = NULL;
// 遍历找到尾节点
Node_t *pCur = pTmpList;
while(pCur->pNext != NULL)
{
pCur = pCur->pNext;
}
pCur->pNext = pTmpNode;
return 0;
}
3.5 遍历打印链表
void ShowLinkList(Node_t *pTmpList)
{
if(NULL == pTmpList)
{
printf("链表不存在!\n");
return;
}
Node_t *pCur = pTmpList->pNext;
while(pCur != NULL)
{
printf("%d ", pCur->Data);
pCur = pCur->pNext;
}
printf("\n");
}
3.6 链表反转(头插反转法)
核心思想:不申请新节点,复用原有节点,依次拆出原链表节点,用头插法重新插入到头结点后,实现就地反转。
int ReverseLinkList(Node_t *pTmpList)
{
if(NULL == pTmpList)
{
printf("头结点为空,无法反转!\n");
return -1;
}
Node_t *pTmpNode = NULL; // 遍历原链表的指针
Node_t *pInsertNode = NULL; // 待插入的节点
pTmpNode = pTmpList->pNext; // 指向第一个有效节点
pTmpList->pNext = NULL; // 头结点后置空,构建空的新链表
while(pTmpNode != NULL)
{
pInsertNode = pTmpNode; // 锁定当前要移动的节点
pTmpNode = pTmpNode->pNext; // 遍历指针先后移,防止断链
pInsertNode->pNext = pTmpList->pNext; // 新节点接上现有链表
pTmpList->pNext = pInsertNode; // 头结点指向新节点
}
return 0;
}
关键口诀:先存后移,先连后接。遍历指针必须在修改指针域之前后移,否则会丢失后续节点。
3.7 链表冒泡排序(交换数据版)
核心思想 :相邻节点两两比较,大值向后 "冒泡",用 pEnd 标记有序区边界,每轮收缩一次。
注意:常见错误是外层循环终止条件写错,会导致排序不完整。
int BubbleSortLinkList(Node_t *pTmpList)
{
if(NULL == pTmpList || NULL == pTmpList->pNext || NULL == pTmpList->pNext->pNext)
{
return 0; // 空链表或只有一个节点,无需排序
}
Node_t *pTmpNode1 = NULL;
Node_t *pTmpNode2 = NULL;
Node_t *pEnd = NULL; // 有序区起点,每轮向前收缩
DataType TmpData;
// 正确外层条件:pEnd未收缩到第一个有效节点就继续
while(pEnd != pTmpList->pNext)
{
pTmpNode1 = pTmpList->pNext;
pTmpNode2 = pTmpNode1->pNext;
while(pTmpNode2 != pEnd)
{
if(pTmpNode1->Data > pTmpNode2->Data)
{
// 交换数据域,不移动节点
TmpData = pTmpNode1->Data;
pTmpNode1->Data = pTmpNode2->Data;
pTmpNode2->Data = TmpData;
}
pTmpNode1 = pTmpNode1->pNext;
pTmpNode2 = pTmpNode2->pNext;
}
pEnd = pTmpNode1; // 本轮最大值沉底,更新有序边界
}
return 0;
}
3.8 按值查找节点
Node_t *FindLinkList(Node_t *pTmpList, DataType TmpData)
{
if(NULL == pTmpList) return NULL;
Node_t *pCur = pTmpList->pNext;
while(pCur != NULL)
{
if(pCur->Data == TmpData)
{
return pCur;
}
pCur = pCur->pNext;
}
return NULL;
}
3.9 按值删除节点
int DeleteLinkList(Node_t *pTmpList, DataType TmpData)
{
if(NULL == pTmpList || IsEmptyLinkList(pTmpList))
{
return -1;
}
Node_t *pPre = pTmpList;
Node_t *pCur = pTmpList->pNext;
while(pCur != NULL)
{
if(pCur->Data == TmpData)
{
pPre->pNext = pCur->pNext; // 跨过待删节点
free(pCur); // 释放内存
return 0;
}
pPre = pCur;
pCur = pCur->pNext;
}
return -1; // 未找到目标节点
}
3.10 销毁链表
注意:需要传入二级指针,才能在函数内部把主函数的头指针置空,避免野指针。
void DestroyLinkList(Node_t **ppTmpList)
{
if(NULL == ppTmpList || NULL == *ppTmpList) return;
Node_t *pCur = *ppTmpList;
Node_t *pNext = NULL;
while(pCur != NULL)
{
pNext = pCur->pNext;
free(pCur);
pCur = pNext;
}
*ppTmpList = NULL; // 头指针置空,防止野指针
}
四、实战高频踩坑总结
1. 空指针解引用引发段错误(SIGSEGV)
典型场景 :调用 DestroyLinkList 销毁链表后,头指针已被置空,后续继续调用反转、打印等函数,直接访问 NULL->pNext 触发崩溃。 解决:
- 所有链表操作函数开头增加入参判空;
- 销毁链表后,不要再对链表进行任何操作。
2. 指针修改顺序错误导致断链
头插、反转操作中,如果先修改前驱节点的指针,再移动遍历指针,会丢失后续所有节点。 规则:永远先保存下一个节点地址 / 先移动遍历指针,再修改指针域。
3. 冒泡排序外层条件写错
错误写法 while(pEnd != pTmpList->pNext->pNext) 会导致循环提前终止,排序不完整; 正确终止边界应为第一个有效节点,保证前两个元素有多轮比较机会。
4. 销毁链表只释放不置空
只 free 节点,不把主函数的头指针置空,会留下野指针,后续访问触发不可预期错误。 规范写法 :销毁函数使用二级指针,结束时 *ppList = NULL。
五、学习总结
- 单向链表的核心是指针操作 ,所有操作本质都是修改节点的
pNext指向; - 带头结点设计能大幅简化边界判断,是工程中最常用的实现方式;
- 调试链表问题优先用 GDB 查看指针地址,定位空指针、断链问题;
- 内存操作遵循 "谁申请谁释放" 原则,销毁链表必须完整释放所有节点并置空头指针。