news 2026/10/3 0:36:52

C语言链表十九种操作详解:从建链到避坑实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言链表十九种操作详解:从建链到避坑实践

简介:面向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 以上的链表做压力验证。链表出问题多数不是算法没看懂,而是某个边界条件没有覆盖到。

把十九种操作当成一组可以互相调用的工具函数来写,每种操作都保持入参明确、返回值一致,最后用统一测试函数收口,这样整套代码才算真正能交差。希望这些验证方法和踩坑经验能帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 0:24:42

用Python从零打造桌面文件管理工具:实战全记录

上个月我整理素材库的时候&#xff0c;对着 5000 多个混杂文件实在忍无可忍——照片、PDF、老项目里的散落代码、各种版本的文档全挤在一个目录里&#xff0c;Windows 自带资源管理器翻几层就转圈&#xff0c;批量重命名要下第三方工具&#xff0c;找重复文件更是全靠眼力。于是…

作者头像 李华
网站建设 2026/10/3 0:15:31

51单片机LED与蜂鸣器驱动原理及实操指南

1. 从“点亮第一个灯”开始&#xff1a;51单片机入门最真实的第一课你拆开那块蓝色的STC89C52RC开发板&#xff0c;手指刚碰到P1.0引脚&#xff0c;心里其实没底——不是怕烧芯片&#xff0c;是怕连最基本的LED都点不亮。我第一次上电时&#xff0c;手抖着按下载键&#xff0c;…

作者头像 李华
网站建设 2026/10/3 0:14:19

LMStudio vs Ollama+WebUI:本地大模型部署的架构本质与实操决策指南

1. 为什么现在还在纠结 LMStudio 和 OllamaWebUI&#xff1f;——本地跑大模型的真实门槛不是“能不能”&#xff0c;而是“值不值”我从去年开始在三台不同配置的机器上反复部署、切换、压测、丢弃再重装&#xff0c;光是模型缓存目录就清空过17次&#xff0c;硬盘里躺着23个不…

作者头像 李华
网站建设 2026/10/3 0:13:56

Godot4资源异步加载:彻底解决场景切换卡顿与白屏

如果你已经跟着 Godot3D 新手入门全流程教程做到第 32 课&#xff0c;大概率正在面对这样一个问题&#xff1a;游戏场景越做越大&#xff0c;点击“开始游戏”之后&#xff0c;画面直接卡住&#xff0c;甚至白屏一两秒&#xff0c;然后才进入场景。这个教程就是要解决这个体验问…

作者头像 李华
网站建设 2026/10/2 23:56:29

HowToCook 小炒藕丁:程序员视角的快手家常素菜完整实操指南

文档教程 【免费下载链接】HowToCook Programmers guide about how to cook at home. 项目地址&#xff1a; https://gitcode.com/GitHub_Trending/ho/HowToCook 点击查看 免费下载 导读 本文基于开源仓库 HowToCook&#xff08;程序员做饭指南&#xff09;中的 小炒藕丁菜谱…

作者头像 李华