简介:面向C语言与数据结构初学者的链表专题资料,以PDF文档形式整理链表操作的完整实例代码,涵盖创建、遍历、查询结点数、判空、冒泡排序、按值/按位查找、修改、头插/尾插/按位插/有序插入、头删/尾删/按位删/按值删、交换结点、删除整表等十九种操作,适合正在学习单链表或准备算法笔试的读者对照练习。资源为1个PDF文件,压缩包整体约57KB,轻量易用,下载后可直接阅读源码头注释与完整实现。已有734人学习下载,内容结构清晰,以函数模块划分,便于按需查阅;通过阅读该文档,可快速理解链表结点结构、malloc动态分配、遍历与指针修改等关键知识点,并积累常用链表操作的可复用代码片段。
1. C 语言链表实例:十九种操作到底在练什么
很多人学数据结构 C 语言版时,第一次被要求写一个“链表的实例”,常常会拿到一张实验报告,上面写着“实现单链表的基本操作实验”,少则增删改查,多则十九种操作一起上。别小看这十九种操作,建表、遍历、查找、插入、删除、修改、排序、反转、合并、判环、约瑟夫环,每加一种,指针操作就深一层。它的本质不是背代码,而是让“节点里存数据、节点间靠指针串起来”这件事变成肌肉记忆。
C 语言链表的十九种操作,解决的是几个被问烂但还是会翻车的问题:头插法和尾插法什么时候用?插入和删除要不要改前驱的 next?free 之后指针还能不能碰?边界条件是空链表还是单节点?所以这篇笔记按“建链 → 读数据 → 改结构 → 避坑 → 验证”的思路,把每类操作的关键代码和参数选择拆开讲,新手能跟着敲,熟手也能对照排查细节。
2. 链表实例的骨架:结构体定义与三种建链方式
写链表前先定义好节点,后面所有操作都建立在同一套结构上。常见的链表示例会让数据域用 typedef 重命名,好处是将来把 int 换成 float 或结构体时,只改一行。
2.1 节点结构体与宏定义:数据域、指针域、状态码
#include <stdio.h> #include <stdlib.h> typedef int ElemType; typedef struct Node { ElemType data; struct Node *next; } LNode, *LinkList; #define OK 1 #define ERROR 0逻辑说明:data是数据域,next是指针域,struct Node *next的写法是因为在结构体内部还不能直接用LNode *next这种别名,必须写完整的struct Node *。LNode用来表示节点类型,LinkList用来表示链表头指针类型,二者本质一样,但语义不同:看到LinkList时知道这是一个链表头,看到LNode *时知道这是一个普通节点指针。
参数说明:ElemType是数据域类型的抽象,若链表要存字符串,可以把typedef int ElemType改成typedef char* ElemType,但后续涉及比较、赋值、打印的地方也要跟着改。OK和ERROR是函数返回的状态码,可能只是一个int,但写出来的代码意图更清楚。
2.2 头插法建立单链表:新节点永远插在头节点之后
头插法是最容易写错的建链方式,因为它会把输入顺序反过来。代码实现是每次申请一个新节点,然后把它挂到链表的头部。
LinkList listHeadInsert(int arr[], int n) { LinkList L = (LinkList)malloc(sizeof(LNode)); if (!L) return NULL; L->next = NULL; for (int i = 0; i < n; i++) { LNode *s = (LNode*)malloc(sizeof(LNode)); if (!s) return NULL; s->data = arr[i]; s->next = L->next; L->next = s; } return L; }逻辑说明:申请头节点L,让L->next = NULL,这样链表从空表开始。每读一个数组元素就生成一个节点s,s->next = L->next把新节点接到当前首元节点前面,L->next = s再让头节点指向新节点。由于后面的节点不断抢占链表头部,最终数组的最后一个元素会排在最前面。
参数说明:arr[]是传入的原始数据,n是元素个数。函数返回值是链表头指针。注意这里为了示例简洁没有做完整的内存释放处理,如果malloc中途失败,在真实工程里要把已申请的节点逐个释放,不然会出现内存泄漏。
2.3 尾插法建立单链表:尾指针的意义
尾插法保持数据的原始顺序,代价是需要一个尾指针r始终指向最后一个节点。
LinkList listTailInsert(int arr[], int n) { LinkList L = (LinkList)malloc(sizeof(LNode)); if (!L) return NULL; L->next = NULL; LNode *r = L; for (int i = 0; i < n; i++) { LNode *s = (LNode*)malloc(sizeof(LNode)); if (!s) return NULL; s->data = arr[i]; s->next = NULL; r->next = s; r = s; } return L; }逻辑说明:初始r = L,此时头节点也是尾节点。每创建一个新节点s,s->next = NULL,然后r->next = s把新节点挂在尾节点后面,r = s让r指向新的尾节点。这样插入顺序和数组顺序完全一致。
参数说明:如果不保存尾指针,每次插入都要从L出发遍历到链表尾部,插入 n 个元素的时间会变成 O(n²)。这里保留尾指针,每次都是 O(1)。面试里经常问头插法和尾插法的区别,头插法适合栈式数据,尾插法适合队列式数据。
2.4 用数组批量构造链表:单元测试最常用的手段
手工调用多次插入来建链表太慢,我一般会用数组批量构造,这样测试用例可以写得很紧凑。
int main() { int arr[] = {3, 1, 4, 1, 5}; int n = sizeof(arr) / sizeof(arr[0]); LinkList L = listTailInsert(arr, n); for (LNode *p = L->next; p; p = p->next) { printf("%d ", p->data); } printf("\n"); return 0; }逻辑说明:sizeof(arr) / sizeof(arr[0])计算数组长度,避免硬编码n。链表建立后从头节点的下一个节点开始遍历,直到p == NULL为止。输出结果是3 1 4 1 5,说明尾插法没有改变数据顺序。
参数说明:数组批量构造适合长度固定、输入已知的测试场景。如果要在程序运行中动态输入,就把arr[i]换成scanf读入的值。批量构造的一个坑是忘记处理malloc失败,测试时数据量小可能没问题,数据量一大就容易出事。
| 建链方式 | 数据顺序 | 时间复杂度 | 适用场景 |
|---|---|---|---|
| 头插法 | 逆序 | O(n) | 需要反转数据、构建栈结构 |
| 尾插法 | 原序 | O(n) | 保持输入顺序、队列结构 |
| 数组批量构造 | 原序或逆序 | O(n) | 单元测试、固定样例 |
3. 遍历、查找与修改:读操作先过关
读操作是链表入门的第一道坎。遍历输出、求链表长度、按值查找、按位置查找,看着简单,实际写起来全是细节。
3.1 遍历输出与求长度:循环终止条件的差别
遍历链表的终止条件有两种写法:while (p != NULL)和while (p->next != NULL)。求长度和输出全部节点要用前者,只处理到倒数第二个节点时才用后者。
int listLength(LinkList L) { int len = 0; LNode *p = L->next; while (p != NULL) { len++; p = p->next; } return len; } void listPrint(LinkList L) { for (LNode *p = L->next; p != NULL; p = p->next) { printf("%d ", p->data); } printf("\n"); }逻辑说明:len从 0 开始,每经过一个节点自增一次。for循环的初始化和后继跳转集中在一起,适合只读遍历。p = p->next必须在打印完当前节点之后执行,否则会跳过首元节点,输出结果少一个。
参数说明:listLength的时间复杂度是 O(n),链表本身不记录长度。如果频繁需要长度,可以在结构体里增加一个size字段,每次插入删除时维护它,但十九种操作的基础版一般不这么做。遍历时如果误用while (p->next != NULL),长度会少算 1,这种错误很难肉眼发现。
3.2 按值查找与按位置查找:返回值的两种设计
按值查找从头节点之后开始,找到第一个data == x的节点。按位置查找需要约定下标起点,常见的有从 0 开始和从 1 开始两种习惯,实验报告里推荐从 1 开始,因为教材上 i 表示第 i 个节点。
LNode* locateByValue(LinkList L, ElemType x) { LNode *p = L->next; while (p != NULL && p->data != x) { p = p->next; } return p; } LNode* getNodeByIndex(LinkList L, int i) { if (i < 1) return NULL; LNode *p = L->next; int j = 1; while (p != NULL && j < i) { p = p->next; j++; } return p; }逻辑说明:locateByValue返回 NULL 表示没有找到。getNodeByIndex中j从 1 开始,p一开始指向第 1 个节点;当j < i时不断后移,直到p == NULL说明 i 超出了链表长度。
参数说明:这两种查找返回的都是目标节点本身,而不是它的前驱。做插入删除时,返回前驱往往更实用,因为光拿到目标节点无法知道它的前驱是谁。这也是链表和数组最大的差异:数组按下标能直接访问,链表必须顺着指针走。
3.3 修改节点值与交换两个节点:什么时候要动指针
修改节点值最简单,定位到节点后只改data,不碰next。交换两个节点有两种思路,交换值或者交换指针。链表操作里我最推荐交换值,因为不动指针就不容易断链。
int changeNodeValue(LinkList L, int i, ElemType newVal) { LNode *p = getNodeByIndex(L, i); if (p == NULL) return ERROR; p->data = newVal; return OK; } int swapNodeData(LNode *a, LNode *b) { if (a == NULL || b == NULL) return ERROR; ElemType t = a->data; a->data = b->data; b->data = t; return OK; }逻辑说明:changeNodeValue先调用getNodeByIndex,返回 NULL 就报错。swapNodeData只交换两个节点的data,链接关系完全不变,排序算法里经常这样用。
参数说明:如果一定要交换节点位置,需要同时修改两个节点的前驱和它们的next,还要处理相邻节点和头节点的情况,非常容易翻车。所以我在链表排序时一律交换值,不交换节点。
3.4 逆序输出:用递归还是用辅助栈
不修改链表结构,只要求从尾到头打印,可以用递归或辅助栈。递归代码最少,但栈深度跟链表长度成正比。
void printListReverse(LNode *p) { if (p == NULL) return; printListReverse(p->next); printf("%d ", p->data); }逻辑说明:递归调用先走到链尾,回溯时再打印,天然得到逆序输出。调用层次等于链表长度,链表有几万个节点时可能栈溢出,这时要用循环加辅助栈替代。
参数说明:辅助栈写法是先遍历链表把所有元素压栈,再逐个出栈打印。空间复杂度都是 O(n),但递归写法代码更短,适合实验报告里的“逆序输出单链表”小题。需要注意的是,这个函数不修改链表,入参是首元节点L->next,如果传L会把头节点的垃圾值也打印出来。
4. 插入、删除与排序:写操作的关键切换
读操作做熟了,接下里就是改结构。插入、删除、排序、清空,每一处都在考验指针顺序。写操作的核心口诀是:先接后断,先让新节点指向后继,再让前驱指向新节点。
4.1 在指定位置插入节点:指针顺序是生死线
在单链表的第 i 个位置插入元素 e,实质是在第 i-1 个节点后面挂新节点。找到前驱是关键。
int listInsert(LinkList L, int i, ElemType e) { if (i < 1) return ERROR; LNode *p = L; int j = 0; while (p != NULL && j < i - 1) { p = p->next; j++; } if (p == NULL) return ERROR; LNode *s = (LNode*)malloc(sizeof(LNode)); if (s == NULL) return ERROR; s->data = e; s->next = p->next; p->next = s; return OK; }逻辑说明:p从L出发而不是从L->next出发,这样i = 1时p就是头节点,可以在链表头部插入。while的终止条件同时检查p == NULL,避免插入位置超过了链表长度。插入的关键是s->next = p->next先保存后继,再p->next = s更新前驱。顺序一旦写反,p->next就被覆盖,后半段链表丢失。
参数说明:i 从 1 开始计,i 超出[1, 长度+1]范围时返回 ERROR。malloc 失败也要返回 ERROR,不要直接访问空指针。这里的头节点 L 是存在的,所以暂不考虑不带头节点的写法。
4.2 删除指定节点与按值删除:free 前先接链
删除第 i 个节点时,同样要找到它的前驱。删除操作比插入多了一步:释放被删节点的内存。
int listDelete(LinkList L, int i, ElemType *e) { if (i < 1) return ERROR; LNode *p = L; int j = 0; while (p->next != NULL && j < i - 1) { p = p->next; j++; } if (p->next == NULL) return ERROR; LNode *q = p->next; p->next = q->next; if (e != NULL) { *e = q->data; } free(q); return OK; }逻辑说明:while条件必须判断p->next != NULL,因为我们要删除的是p->next,它不能为空。q是被删节点,先让前驱p->next指向q->next,跳过q,再free(q)。如果先 free(q),q->next 就再也拿不到了。
参数说明:e是出参,用来带回被删除节点的值。如果只删除不取值,可以传 NULL。这里函数返回 OK 或 ERROR,不能只靠e判断。按值删除是另一个变体,先找到第一个值等于 x 的节点的前驱,再调用同样的删除逻辑。注意如果有多个重复值,通常只删第一个。
4.3 选择排序与冒泡排序的实现:链表排序为什么不推荐相邻交换
链表排序最简单的实现是选择排序,每趟找到最小节点,交换它的 data 到当前趟的起始位置。因为不需要随机访问,遍历找最小节点很方便。
void listSort(LinkList L) { for (LNode *p = L->next; p != NULL; p = p->next) { LNode *min = p; for (LNode *q = p->next; q != NULL; q = q->next) { if (q->data < min->data) { min = q; } } if (min != p) { ElemType t = min->data; min->data = p->data; p->data = t; } } }逻辑说明:外循环从首元节点开始,内循环从p->next开始,在未排序部分找最小值的节点。找到后交换两个节点的data,节点顺序完全不变。这里用交换值而不是交换节点,可以规避大量指针修改。
参数说明:时间复杂度是 O(n²),没有额外空间。链表做冒泡排序很别扭,因为相邻节点交换后,下一轮需要重新定位前驱,代码会写得很长。链表的 O(n log n) 排序一般用归并排序,但那属于进阶题。实验报告里要求冒泡排序时,也可以用交换 data 的方式模拟相邻交换,视觉上更好理解。
4.4 清空与销毁:保留头节点和不保留头节点的区别
清空链表是删除所有数据节点,但保留头节点。销毁链表是连同头节点一起释放,并把链表头指针置为 NULL。
void listClear(LinkList L) { LNode *p = L->next; while (p != NULL) { LNode *q = p; p = p->next; free(q); } L->next = NULL; } void listDestroy(LinkList *L) { listClear(*L); free(*L); *L = NULL; }逻辑说明:清空时先保存当前节点q,再让p指向q->next,最后 free(q)。如果先free(p)再p = p->next,读到的就是已释放的内存,行为不可预料。销毁要传二级指针LinkList *L,因为函数内部要修改调用者的指针为 NULL。
参数说明:listDestroy接收&L,调用后原来的L变成 NULL,后续再用L时会立刻发现空指针。如果不置 NULL,就成了悬空指针,这是很多代码“偶尔崩溃、偶尔不崩”的原因。
5. 避坑:链表实例里最常见的五个翻车点
链表代码看着不长,出错率却很高,很多问题都出在“以为自己理解了指针”上面。这里把最常见的翻车点按“现象 → 原因 → 解决”写清楚。
5.1 返回局部节点地址导致悬空指针
现象:链表函数返回了一个节点指针,主函数一用就乱输出,或者偶尔正常偶尔崩溃。
原因:在函数内部声明了一个局部LNode x,然后return &x;。局部变量在函数返回后就释放了,返回的指针指向一块已经被收回的栈空间。这种情况在编译时通常没有报错,运行行为像玄学。
LNode* createNodeBad() { LNode x; x.data = 1; x.next = NULL; return &x; // 错误:返回局部变量地址 } LNode* createNodeGood(int val) { LNode *s = (LNode*)malloc(sizeof(LNode)); s->data = val; s->next = NULL; return s; }解决:节点必须用 malloc 在堆上申请,不用时用 free 释放。函数返回的指针要么是 malloc 来的,要么是链表里已有的节点地址,绝不能是函数内部普通变量的地址。
5.2 插入删除时指针顺序写反导致断链
现象:插入节点后,链表只输出了前半段,后半段凭空消失。
原因:在插入时先执行了p->next = s;,再执行s->next = p->next;。此时p->next已经被改成s,s->next指向了自己或 NULL,原来的后继节点丢失。
// 错误写法 s->next = p->next; // 这两行顺序写反会导致断链 p->next = s; // 正确写法 s->next = p->next; p->next = s;解决:插入操作永远先让新节点指向旧后继,再让前驱指向新节点。删除操作先让前驱跳过被删节点,再 free。这个顺序没有例外,写完后可以用单步调试或打印语句确认s->next的值。
5.3 遍历带头节点链表时把头节点算进去
现象:链表的长度总是比实际多 1,打印输出多了一个不确定的数字。
原因:遍历从L开始,而不是从L->next开始。头节点不存数据,它的data是个未初始化的野值,打印出来难以理解。
// 错误 int badLength(LinkList L) { int len = 0; for (LNode *p = L; p != NULL; p = p->next) { len++; } return len; } // 正确 int goodLength(LinkList L) { int len = 0; for (LNode *p = L->next; p != NULL; p = p->next) { len++; } return len; }解决:所有遍历类操作都从L->next开始。只有插入和删除时为了让头节点能作为“前驱”,才从L开始移动指针。这个规律可以当成规则来记:读链表永远从第一个数据节点开始,改链表才允许从头节点开始。
5.4 free 之后不置 NULL 带来重复释放
现象:同一段链表被清理两次,程序在第二次 free 时报错崩溃。
原因:第一次free(p)后,p仍然指向那块已释放的内存,但指针变量本身没有被置 NULL。第二次再free(p),就是对同一块内存执行 double free,引发运行时错误。
Node *p = (Node*)malloc(sizeof(Node)); free(p); p = NULL; // 释放后立即置空解决:free 之后立即把指针置为 NULL,尤其是在销毁链表时,把链表头指针也置成 NULL。我习惯在写链表函数时遵循一条约束:谁 malloc,谁 free;free 完,立刻置 NULL。这样悬空指针的概率会小很多。
5.5 空链表、单节点、删除头节点时边界翻车
现象:对长度为 0 或 1 的链表执行插入、删除,程序没问题;对长度为 2 的链表删除第一个数据节点,结果链表变成空指针或者少了一个节点。
原因:边界条件的判断不完整。删除第一个数据节点时,前驱是头节点L,执行L->next = q->next没问题。但如果不写while (p->next != NULL),而是只写while (p != NULL),当p跑到最后一个节点时,p->next是 NULL,再访问p->next->next就会空指针崩溃。
| 边界情况 | 容易犯的错误 | 处理方式 |
|---|---|---|
| 空链表 | 直接访问L->next的 next | 先判断 `L == NULL |
| 单节点 | 删除节点后没把前驱置 NULL | 用p->next = q->next统一处理 |
| 删除头节点 | 直接把L往后移,丢头节点 | 带头节点链表要改L->next,不能改L |
解决:写任何链表函数之前,先列三个输入:空链表、单节点链表、普通链表。分别跑一遍,所有针对链表结构的操作都检查这两种极端情况。尤其注意,带头节点的链表删除第一个数据节点时,被修改的还是L->next,而不是L本身。
6. 用断言式测试和“最小链表”验证十九种操作
链表操作写完不测试等于没写。我一般会用一个简单的测试宏,把期望值和实际值对比,失败时打印出错位置并退出。这个方法比用printf肉眼比对更高效,也适合写实验报告里的测试截图。
#include <assert.h> #define ASSERT_INT_EQ(actual, expected) do { \ if ((actual) != (expected)) { \ fprintf(stderr, "line %d: %d != %d\n", \ __LINE__, (actual), (expected)); \ exit(1); \ } \ } while (0)用这个宏可以快速验证链表长度、查找结果、删除返回值。比如对长度为 3 的链表执行删除第 2 个节点,期望剩余长度为 2,期望第二个节点变成原来的第三个节点,代码写出来就是:
void test_list() { int arr[] = {1, 2, 3}; LinkList L = listTailInsert(arr, 3); ASSERT_INT_EQ(listLength(L), 3); ElemType deleted = 0; ASSERT_INT_EQ(listDelete(L, 2, &deleted), OK); ASSERT_INT_EQ(deleted, 2); ASSERT_INT_EQ(listLength(L), 2); LNode *second = getNodeByIndex(L, 2); ASSERT_INT_EQ(second->data, 3); listReverse(L); ASSERT_INT_EQ(getNodeByIndex(L, 1)->data, 3); ASSERT_INT_EQ(getNodeByIndex(L, 2)->data, 1); listDestroy(&L); ASSERT_INT_EQ(L == NULL, 1); }这段测试覆盖了建链表、长度、删除、取值、反转、销毁六类操作。剩下十三种操作可以继续往上叠,每加一种操作就在测试函数里加两条断言。断言失败时,错误信息直接指出是哪一行不满足,省去了到处插打印语句的功夫。
我自己的习惯是:先构造一个长度为 3 的“最小链表”,把插入、删除、反转都跑一遍,再用长度为 0 和 1 的链表跑边界,最后才用长度 5 以上的链表做压力验证。链表出问题多数不是算法没看懂,而是某个边界条件没有覆盖到。
把十九种操作当成一组可以互相调用的工具函数来写,每种操作都保持入参明确、返回值一致,最后用统一测试函数收口,这样整套代码才算真正能交差。希望这些验证方法和踩坑经验能帮到你。
本文还有配套的精品资源,点击获取