
很多初学数据结构的同学对单链表的“查、插、删”停留在“能看懂代码但自己写不出来”的状态。考试时笔答题能默写一旦上机实验要么程序运行直接崩溃要么打印结果顺序不对要么忘记释放内存。这篇文章就从实践视角把单链表的查找、插入、删除操作彻底拆开讲清楚重点分析边界条件、二级指针的必要性、以及头节点设计的价值。1. 这篇文章真正要解决的问题如果你正在学数据结构或者准备考研、准备面试单链表是绕不过去的基础关卡。但实际学习时很多人会遇到几个非常具体的痛点第一能看懂伪代码但用 C 语言实现时不知道从哪下手。教科书上画的节点图很清晰可一旦要自己写结构体、自己操作指针就变得畏手畏脚。第二边界条件处理不完整。头插法写成了尾插法删除最后一个节点时p-next已经指向NULL结果代码还在访问p-next-data程序直接段错误崩溃。第三不清楚为什么插入和删除有时要用二级指针。网上资料一会儿说一级指针一会儿说二级指针到底什么时候用哪个很多同学一直没想明白。第四调试效率极低。链表一旦写错编译阶段往往不报错运行时才崩溃或者输出的数据莫名其妙不知道怎么定位问题。这篇文章要做的不是再贴一遍教科书代码而是把单链表的查找、插入、删除背后的设计原因讲透。读完你会明白为什么带头节点的单链表更好写、为什么插入删除要考虑前驱节点、为什么初始化需要二级指针、以及写链表实验报告时如何组织代码和验证结果。从具体场景来说下面这些读者最应该读正在上数据结构课需要完成链表实验报告的学生。准备考研 408需要熟练掌握链表操作代码的考生。刷 LeetCode 链表题时对指针操作总是不放心的开发者。需要把链表用于实际项目但不清楚内存管理和边界处理的人。2. 单链表的前置认知结构定义与节点设计2.1 什么是单链表单链表是一种线性表的链式存储结构。和顺序表数组不同单链表不需要一整块连续的内存空间而是通过一组任意的存储单元来存放线性表的数据元素。每一个元素由一个节点表示节点之间通过指针链接。通俗地说数组就像电影院里的连排座位座位号是连续的你入座后找邻座很方便单链表则像一列火车每节车厢除了装载货物数据还必须知道下一节车厢在哪里指针否则整列车就断开了。单链表的核心特点是逻辑上相邻的元素物理上不一定相邻。每个节点中除了存储数据元素的信息还要存储后继节点的存储地址。2.2 结构体定义在 C 语言中单链表的节点通常定义为// 文件路径list.h #ifndef LINKLIST_H #define LINKLIST_H typedef int ElemType; // 元素类型可以根据需要修改 typedef struct LNode { ElemType data; // 数据域 struct LNode *next; // 指针域指向下一个节点 } LNode, *LinkList; #endif这段定义里有几个关键点需要仔细理解typedef int ElemType的作用是把元素类型统一为一个别名。如果后续想把链表从存储整数改为存储结构体只需要修改这一行不用把所有函数签名都改一遍。这是工程上很常见的做法。struct LNode *next是自引用的指针。在结构体定义内部LNode类型还没有完全定义结束时只能使用struct LNode来声明指针而不能直接写LNode *next。这是 C 语言语法规则决定的。LNode *和LinkList在类型本质上是一样的都表示指向节点结构体的指针。区分写法的意义在于语义LNode *强调这是一个节点的指针而LinkList强调这是一个链表。实际使用时LinkList L;通常表示头指针或头节点。2.3 头节点和头指针的区别这是单链表中最容易混淆的概念也是很多实验报告扣分点所在。头指针是链表第一个节点的存储位置用来标识一个链表。无论链表是否为空头指针都不应该为NULL除非链表对象本身不存在。头节点是在第一个元素节点之前附加的一个节点。它的数据域可以不存储任何信息也可以存储链表长度等附加信息指针域指向第一个元素节点。那么带头节点和不带头节点的链表有什么区别带头节点的好处很明显链表的第一个元素节点和其他元素节点的操作逻辑可以统一不需要单独特判“这是第一个节点”。空链表和非空链表的处理方式一致头指针永远指向头节点不会出现*L为空然后解引用崩溃的问题。插入和删除第一个节点时不需要修改头指针本身。不带头节点的链表更节省一个节点的内存但操作时边界情况更多。对于初学者和绝大多数场景都推荐使用带头节点的单链表。3. 查找操作按序号查找与按值查找查找操作是最直观、最不容易出错的操作但正是因为它简单很多同学会忽略查找过程中对于边界条件的遍历终止判断。单链表的查找分为两种按序号查找和按值查找。3.1 按序号查找按序号查找的目标是找到第i个节点。这里需要注意序号是从 1 开始计数的。如果链表带头节点头节点是第 0 个节点第一个数据节点是第 1 个节点。实现思路从第一个数据节点开始顺着next指针依次往后找用一个计数器记录当前已经走过的节点数直到计数器等于i或者链表遍历结束。// 文件路径list.c // 按序号查找返回第 i 个节点的指针i 从 1 开始 // 如果 i 不合法或超出链表长度返回 NULL LNode *GetElemByIndex(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; // 如果 p 为 NULL说明 i 超过了链表长度 }这个函数的循环条件是p ! NULL j i两个条件缺一不可。如果少了p ! NULL当查找的序号超过链表长度时p会变成NULL下一次循环执行p p-next就会发生空指针解引用。这里真正容易踩坑的地方是很多人写成while (j i p-next ! NULL)这样虽然跳出循环时 p 不会为空但当 i 正好是链表最后一个节点时p-next为NULL循环提前结束返回的却是倒数第二个节点查找结果错误。所以应该判断的是p本身是否为NULL而不是p-next。3.2 按值查找按值查找的目标是找到第一个数据域等于给定值的节点。遍历方式与按序号查找类似区别在于终止条件由“计数达到目标”变为“数据值匹配”。// 按值查找返回第一个值为 e 的节点的指针找不到返回 NULL LNode *GetElemByValue(LinkList L, ElemType e) { LNode *p L-next; while (p ! NULL p-data ! e) { p p-next; } return p; // 找不到时 p 为 NULL }按值查找的关键在于对p-data的类型要有预判。如果ElemType是 int、float 等基本类型用!比较即可。但如果ElemType是结构体类型就不能直接用!比较结构体而需要写一个专门的比较函数。这个在后面的最佳实践章节会再展开。3.3 查找操作的时间复杂度单链表按序号查找和按值查找的平均时间复杂度都是 O(n)。这是因为链表不支持随机访问必须从头开始逐个遍历。这个特性决定了链表适用于“频繁插入删除、较少按位置查找”的场景这是它和顺序表最核心的差异。4. 插入操作头插法、尾插法与中间插入插入操作是单链表的核心操作也是实验报告里最容易出问题的部分。理解插入的关键在于在单链表中插入一个节点本质上只需要修改两个指针但必须先找到插入位置的前驱节点。为什么要找前驱节点因为单链表节点的指针域只保存后继地址无法倒回去找前驱。假设要在第 i 个位置插入节点 s如果已经拿到了第 i 个节点的指针 p此时无法修改 p 的前驱节点的 next 指针指向 s因为这个前驱节点没有任何途径可以从 p 反查回去。所以必须从头遍历到第 i-1 个节点。4.1 中间位置插入在第 i 个位置插入节点 s先找到第 i-1 个节点前驱节点然后执行两步操作s-next p-next; p-next s;这两条语句的顺序绝对不能颠倒。如果先执行p-next sp 原本指向的后继节点就“丢失”了因为此时已经没有任何指针指向它s-next也无法再获取它的地址。用一句话总结先接后面再接前面。先让新节点指向原后继节点再让前驱节点指向新节点。完整代码// 在第 i 个位置插入值为 e 的新节点 // 带头节点的链表i 从 1 开始 bool ListInsert(LinkList L, int i, ElemType e) { if (i 1) { return false; } // 找到第 i-1 个节点 LNode *p L; // p 指向头节点 int j 0; // 当前 p 指向的是第 j 个节点 while (p ! NULL j i - 1) { p p-next; j; } if (p NULL) { return false; // i 超出链表长度位置不合法 } // 创建新节点 LNode *s (LNode *)malloc(sizeof(LNode)); if (s NULL) { return false; // 内存分配失败 } s-data e; s-next p-next; p-next s; return true; }这里有几个细节值得注意LNode *p L是从头节点开始遍历而不是从第一个数据节点。这样做的原因是当 i1 时插入位置是第一个数据节点之前此时前驱节点就是头节点。如果从L-next开始遍历i1 时就会漏掉头节点这个前驱。循环结束后的if (p NULL)检查是必须的。假设链表长度为 3要插入到第 5 个位置遍历过程中 p 会经过第 3 个节点后变成NULL此时位置不合法不能执行插入。用malloc分配新节点后必须检查返回值是否为NULL。内存分配失败时malloc返回空指针如果不检查就直接写s-data e会造成空指针解引用。4.2 头插法头插法是指在链表的头部不断插入新节点新节点始终成为第一个数据节点。头插法的代码很简单但它有一个重要特性输入顺序和链表顺序相反。// 头插法建立链表 // 输入数据以 -1 作为结束标志 LinkList ListHeadInsert(LinkList L) { LNode *s; ElemType x; // 创建头节点 L (LinkList)malloc(sizeof(LNode)); L-next NULL; scanf(%d, x); while (x ! -1) { s (LNode *)malloc(sizeof(LNode)); s-data x; s-next L-next; // 新节点指向原第一个数据节点 L-next s; // 头节点指向新节点 scanf(%d, x); } return L; }头插法的经典应用场景是链表的逆置。如果给你一个带头节点的单链表想要把它的顺序反过来一个最简单的做法就是遍历原链表把每个节点用头插法插入到新链表中。4.3 尾插法尾插法是在链表的尾部插入新节点需要用一个尾指针始终指向最后一个节点。每次插入新节点后更新尾指针。// 尾插法建立链表 // 输入数据以 -1 作为结束标志 LinkList ListTailInsert(LinkList L) { LNode *s, *r; ElemType x; // 创建头节点 L (LinkList)malloc(sizeof(LNode)); L-next NULL; r L; // r 是尾指针指向当前最后一个节点 scanf(%d, x); while (x ! -1) { s (LNode *)malloc(sizeof(LNode)); s-data x; s-next NULL; r-next s; // 当前最后一个节点指向新节点 r s; // 更新尾指针 scanf(%d, x); } return L; }头插法和尾插法的选择取决于你的需求。如果输入数据本身是正序的而你希望链表也是正序存储就用尾插法如果希望链表逆序存储或者需要实现链表逆置就用头插法。5. 删除操作按序号删除与按值删除删除操作同样是单链表的高频考点。核心思路和插入类似先在单链表中找到待删除节点的前驱节点然后修改前驱节点的 next 指针绕过待删除节点。5.1 按序号删除删除第 i 个节点找到第 i-1 个节点 p用一个指针 q 指向待删除节点p 的后继然后让 p-next 指向 q-next最后释放 q 的内存。// 删除第 i 个节点被删除的节点的数据通过 e 返回 bool ListDelete(LinkList L, int i, ElemType *e) { if (i 1) { return false; } // 找到第 i-1 个节点 LNode *p L; int j 0; while (p ! NULL j i - 1) { p p-next; j; } if (p NULL || p-next NULL) { return false; // 第 i 个节点不存在 } LNode *q p-next; // q 指向待删除节点 *e q-data; // 通过 e 带回被删除节点的数据 p-next q-next; // 断开 q 节点 free(q); // 释放 q 节点的内存 return true; }这段代码里有三个关键点if (p NULL || p-next NULL)的判断包含了两种情况。p NULL说明 i 超出了链表长度p-next NULL说明第 i-1 个节点后面没有节点了也就是说根本不存在第 i 个节点。这两种情况都属于删除位置不合法不能继续操作。*e q-data的作用是把被删除节点的数据通过指针参数带回。这是 C 语言中函数返回多个值时的标准做法。如果不需要这个数据也可以把 e 参数去掉。free(q)这一步很容易被漏掉。C 语言中malloc分配的内存必须手动释放否则会造成内存泄漏。程序一运行就结束的实验不太容易观察到问题但在长时间运行的服务或大型程序中内存泄漏会导致内存占用持续增长最终程序崩溃。这是链表实现中最重要的内存管理意识。5.2 按值删除按值删除和按序号删除的区别只在于定位方式。按值删除需要遍历链表找出第一个数据域等于给定值的节点然后执行删除。// 删除第一个值为 e 的节点成功返回 true bool DeleteByValue(LinkList L, ElemType e) { LNode *p L; // p 跟踪前驱节点 LNode *q L-next; // q 遍历节点 while (q ! NULL q-data ! e) { p q; q q-next; } if (q NULL) { return false; // 没找到值为 e 的节点 } p-next q-next; // 绕过待删除节点 free(q); return true; }这里的遍历方式与按值查找不同。按值查找只需要一个遍历指针而按值删除需要两个指针一个记录当前遍历节点 q另一个记录 q 的前驱节点 p。因为删除操作必须通过前驱节点修改 next 指针。实际上还有一种更聪明的写法只用一个指针 p让 p 指向待删除节点的前驱节点。在遍历过程中判断 p-next-data 是否等于目标值。这样可以直接通过 p 修改 next 指针。这种方式代码更简洁但有一个前提p-next 不能为 NULL否则访问 p-next-data 会出现空指针解引用。// 用前驱指针方式删除第一个值为 e 的节点 bool DeleteByValueV2(LinkList L, ElemType e) { LNode *p L; while (p-next ! NULL p-next-data ! e) { p p-next; } if (p-next NULL) { return false; // 没找到 } LNode *q p-next; p-next q-next; free(q); return true; }这种写法在面试题中非常常见因为它用更少的指针完成了同样的功能。但初学者可能不太容易理解建议先掌握双指针版本理解原理后再优化为单指针版本。5.3 删除操作的复杂度删除操作的时间主要消耗在查找前驱节点上平均时间复杂度为 O(n)。找到前驱节点后删除本身只需要修改一个指针是 O(1)。这和数组的删除不同数组删除元素需要移动大量元素来填补空缺而链表只需要修改指针不需要移动数据。这也正是链表在“插入删除频繁”场景下的核心优势。6. 为什么初始化链表节点要用二级指针这是初学者最常见的疑问之一。看网上代码有时写void InitList(LinkList L)有时写void InitList(LinkList *L)到底有什么区别核心原因在于C 语言中函数参数传递是值传递函数内部修改形参不会影响实参。如果在函数内部执行L (LinkList)malloc(sizeof(LNode))这里的L只是一个实参的拷贝。函数返回后实参仍然指向原来的地址通常是未初始化的随机值。这相当于你在工位上指导实习生搬箱子实习生确实把箱子搬走了但你的记录本上还是写的“箱子在原位”——信息没有同步回来。正确的做法是传递链表指针的指针也就是二级指针// 初始化带头节点的空链表 bool InitList(LinkList *L) { *L (LNode *)malloc(sizeof(LNode)); if (*L NULL) { return false; } (*L)-next NULL; return true; }调用方式LinkList L; InitList(L);这里L取的是L的地址类型为LinkList *也就是LNode **。在函数内部*L就相当于实参L对它赋值可以直接修改调用方的变量。但如果链表带头节点并且我们约定函数都是通过头节点来操作链表也可以不用二级指针。因为头节点已经在外部创建好了函数只需要修改头节点的 next 指针。比如在前面写的ListInsert、ListDelete函数中传入的是LinkList L并不需要修改头指针本身只是修改头节点内部的 next 字段。这种情况下一级指针就足够了。一个简洁的判断标准是如果函数需要修改头指针本身比如让头指针指向一个新节点就用二级指针如果函数只需要修改头指针指向的节点内部的数据或指针就用一级指针。7. 完整示例单链表查插删的实验代码下面给出一份完整的单链表操作示例。这个示例可以直接用于数据结构实验报告也可以作为复习材料对照使用。// 文件路径main.c // 单链表查插删完整实验示例 #include stdio.h #include stdlib.h #include stdbool.h typedef int ElemType; typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; // 初始化带头节点的空链表 bool InitList(LinkList *L) { *L (LNode *)malloc(sizeof(LNode)); if (*L NULL) { return false; } (*L)-next NULL; return true; } // 尾插法建立链表 void CreateListTail(LinkList L) { LNode *r L; // 尾指针 ElemType x; printf(请输入链表元素以 -1 结束\n); scanf(%d, x); while (x ! -1) { LNode *s (LNode *)malloc(sizeof(LNode)); s-data x; s-next NULL; r-next s; r s; scanf(%d, x); } } // 打印链表 void PrintList(LinkList L) { LNode *p L-next; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); } // 按序号查找 LNode *GetElemByIndex(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; } // 按值查找 LNode *GetElemByValue(LinkList L, ElemType e) { LNode *p L-next; while (p ! NULL p-data ! e) { p p-next; } return p; } // 插入节点在第 i 个位置插入 e bool ListInsert(LinkList L, int i, ElemType e) { if (i 1) { return false; } LNode *p L; int j 0; while (p ! NULL j i - 1) { p p-next; j; } if (p NULL) { return false; } LNode *s (LNode *)malloc(sizeof(LNode)); if (s NULL) { return false; } s-data e; s-next p-next; p-next s; return true; } // 删除节点删除第 i 个节点数据通过 e 返回 bool ListDelete(LinkList L, int i, ElemType *e) { if (i 1) { return false; } LNode *p L; int j 0; while (p ! NULL j i - 1) { p p-next; j; } if (p NULL || p-next NULL) { return false; } LNode *q p-next; *e q-data; p-next q-next; free(q); return true; } // 释放整个链表 void DestroyList(LinkList *L) { LNode *p *L; while (p ! NULL) { LNode *q p-next; free(p); p q; } *L NULL; } int main() { LinkList L; InitList(L); CreateListTail(L); printf(当前链表); PrintList(L); // 测试按序号查找 LNode *p1 GetElemByIndex(L, 2); if (p1 ! NULL) { printf(第 2 个节点的值是%d\n, p1-data); } else { printf(第 2 个节点不存在\n); } // 测试按值查找 LNode *p2 GetElemByValue(L, 5); if (p2 ! NULL) { printf(值为 5 的节点找到了地址为 %p\n, (void *)p2); } else { printf(链表中没有值为 5 的节点\n); } // 测试插入 if (ListInsert(L, 3, 99)) { printf(在第 3 个位置插入 99 后); PrintList(L); } else { printf(插入失败\n); } // 测试删除 ElemType delVal; if (ListDelete(L, 3, delVal)) { printf(删除第 3 个节点值为 %d后, delVal); PrintList(L); } else { printf(删除失败\n); } // 释放链表 DestroyList(L); return 0; }8. 运行结果与验证方式假设输入数据为3 5 8 2 9 -1程序运行流程如下编译命令Linux/macOS 环境gcc main.c -o linkedlist_demo运行./linkedlist_demo输入3 5 8 2 9 -1预期输出结果请输入链表元素以 -1 结束 当前链表3 5 8 2 9 第 2 个节点的值是5 值为 5 的节点找到了地址为 0x55f6f2b712b0 在第 3 个位置插入 99 后3 5 99 8 2 9 删除第 3 个节点值为 99后3 5 8 2 9验证成功的标准尾插法建立的链表顺序与输入顺序一致。如果输出为9 2 8 5 3说明你不小心用了头插法的逻辑。在第 3 个位置插入 99 后99 出现在 5 和 8 之间。删除第 3 个节点后链表恢复为原顺序说明插入和删除是对称的逆操作。程序正常退出没有报段错误。在 Linux 下可以用 Valgrind 检查是否存在内存泄漏valgrind --leak-checkfull ./linkedlist_demo如果看到definitely lost: 0 bytes的提示说明内存管理正确如果报告有内存未释放优先检查是否有节点忘记free。9. 常见问题与排查思路问题现象可能原因排查方式解决方案程序编译通过运行时直接崩溃链表为空时访问了p-next或p-data在关键操作前打印指针地址确认是否出现NULL遍历和操作前增加p ! NULL判断打印链表时死循环一直输出停不下来链表成环某个节点的 next 指回了自己或前驱节点检查插入操作的指针赋值顺序确保先执行s-next p-next再执行p-next s插入后链表中数据丢失指针赋值顺序错误原后继节点被覆盖单步调试观察插入语句执行前后指针指向先接后面再接前面删除后数据还能打印出来但程序异常可能重复释放同一块内存检查代码中是否有两处都free同一个节点释放节点后立即将指针置为NULL打印出来第一个元素不对建立链表时头插法和尾插法混用检查CreateList的逻辑确认使用的是头插还是尾插二者不可混用函数结束后链表还是 NULL初始化函数参数类型错误没有用二级指针检查函数签名和调用方式初始化函数使用LinkList *L调用时传入L按序号插入位置很偏时结果异常边界条件判断不完整检查while循环后是否有p NULL判断增加位置不合法检查这里最推荐的做法是写链表代码时先画出节点和指针的示意图再动手写代码。尤其是插入和删除操作画图能清晰看到指针修改前后的指向关系。段错误发生后先不要急着改代码用 gdb 或者打印语句定位崩溃的那一行很多时候都是空指针解引用。10. 最佳实践与工程建议10.1 始终使用带头节点的单链表在实验和考试中带头节点的单链表能减少大量边界判断。虽然多了一个节点但它让插入、删除、遍历的代码更加统一不容易出错。10.2 插入和删除必须检查内存分配结果malloc后一定要判断返回值是否为NULL。虽然小型的课程设计大多不会遇到内存耗尽但这个习惯在未来的项目开发中非常重要。同理free之后把指针置为NULL可以避免悬空指针问题。10.3 保持“先断后接”的指针操作顺序插入操作先让新节点的 next 指向原后继再让前驱的 next 指向新节点。删除操作先保存被删节点的 next 地址再修改前驱指针最后释放节点。这个顺序在任何链表、二叉树、图的指针操作中都是通用的。10.4 封装基本操作函数不要把所有逻辑都写在 main 函数里。把初始化、建立、查找、插入、删除、打印、销毁分别封装为独立函数每个函数只做一件事。这样既方便测试也便于后续复用代码。10.5 销毁链表时逐个释放节点很多人写完链表实验会忽略销毁操作。实际项目中链表生命周期结束后必须释放所有节点内存。销毁的正确方式是从头开始用临时指针记录当前节点的下一个节点再释放当前节点直到链表为空。10.6 理解不同语言中的链表差异如果以后使用 Python 或 Java 写链表会发现语言层面已经帮你处理了内存管理。Python 中的链表节点只需要定义数据字段和 next 引用不需要手动malloc和free。但指针概念、插入删除顺序、边界处理是共通的。理解了 C 语言版本其他语言的链表实现基本都是“换汤不换药”。11. 总结单链表的“查、插、删”是数据结构学习的第一个核心分水岭。把链表操作写熟练后续学习栈、队列、树、图都会轻松很多因为这些结构本质上都是链表思想的延伸。这篇文章真正想强调的核心点有三个第一查找的难点在边界判断。无论是按序号查找还是按值查找遍历终止条件的设置决定了程序是否会崩溃或者返回错误结果。第二插入和删除的难点在前驱节点的定位。单链表只能往后走不能往回走所以所有修改操作都必须拿到前驱节点然后通过指针操作完成链接的断开和重接。第三C 语言实现链表的难点在内存管理。分配内存后要检查使用完要释放释放后要把指针置空。建议下一步的实践路径先把文中的示例代码在本地编译运行观察每一步输出然后用断点调试查看插入和删除时指针的指向变化最后自己动手改造成“逆序建立链表”“删除链表中所有值为 x 的节点”“合并两个升序链表”等变体练习。当你能把链表的插入删除画在纸上、写在代码里、跑在机器上数据结构的第一关就算真正迈过去了。