单链表建立与逆置:头插法、尾插法及迭代与递归实现详解

📅 发布时间:2026/9/9 13:22:36
单链表建立与逆置:头插法、尾插法及迭代与递归实现详解
在实际的数据结构课程和考研复习中单链表的建立与逆置是绕不开的基础操作。很多初学者看书时觉得代码都能看懂但真正在编译器里写出来、跑起来却会遇到头指针丢失、逆置后链表断链、尾插法顺序不对等问题。这篇文章从单链表的结点定义讲起分别实现头插法、尾插法两种建表方式再用迭代法和递归法完成逆置最后给出验证方法和常见错误的排查思路。学完以后你既能应付课程实验和考试也能在后续学习双向链表、循环链表时复用同一套思考方式。1. 先理解单链表的结构再动手写代码1.1 单链表解决什么问题数组和链表是两种最基本的线性存储结构。数组在内存中是一段连续空间优点是按下标访问是 O(1)缺点是插入和删除需要移动大量元素。单链表则用一组任意的存储单元存放线性表元素每个结点除了存储数据还要存储指向下一个结点的指针。这样带来的直接好处是插入和删除只需要修改指针不需要移动数据。单链表的缺点是失去了随机访问能力找第 i 个结点必须从头开始遍历。所以单链表适合“频繁插入删除、不常按下标访问”的场景例如内存池的空闲块管理、操作系统的进程队列、图的邻接表等底层都可能用到单链表或它的变体。理解了这一点就不会在需要频繁按下标定位的数据结构里硬用单链表。1.2 结点结构定义在 C 语言中单链表结点包括数据域和指针域typedef struct LNode { int data; // 数据域这里用 int 演示 struct LNode *next; // 指针域指向下一个结点 } LNode, *LinkList;这里有两个细节容易混淆。LNode表示结点类型LinkList表示指向结点的指针类型。实际代码中LNode *p和LinkList p在语法上等价都表示“指向结点的指针”但习惯上会用LinkList强调这是链表头指针用LNode *强调这是遍历过程中的某个结点。这个约定不是强制规则但能让代码的可读性更好团队协作时也建议统一这种写法。1.3 头结点到底起什么作用建表时通常会单独分配一个头结点也就是头指针指向的结点中不存有效数据。头结点不是必须的但加上它之后链表在逻辑上会更统一带头结点的链表空表时L-next NULL判断条件统一。在第一个位置插入、删除第一个结点时不需要单独修改头指针统一走“修改前一个结点的 next”这条路。逆置、遍历、查找的代码不需要对“第一结点是否为空”做特判。如果不带头结点空表时L NULL在头部插入时要额外处理if (L NULL)代码会出现很多分支。因此除非题目明确说明不带头结点课程实验和考试推荐一律带头结点。下面所有代码都按“带头结点”来写。2. 两种建立单链表的方法头插法和尾插法2.1 环境准备与实验约定下面所有代码使用 C 语言编写在 Visual Studio、Dev-C、Code::Blocks 或任何支持 C99 的编译器中都能编译运行。示例代码只用标准库函数printf和malloc不包含平台相关头文件因此不依赖具体集成开发环境。实验约定如下链表带头结点。数据域类型固定为int。建表函数接收一个整数数组把数组元素依次放入链表。输出函数打印从第一个有效结点到最后一个有效结点的全部数据。学习阶段可以把代码全部写到一个.c文件里先跑通再拆分模块。生产或大型项目中通常会把类型定义放到头文件把插入、删除、逆置、销毁等操作封装成独立函数并补充内存释放和异常处理。这个拆分过程放到最后一节展开。2.2 头插法建立单链表头插法的思路是每次把新结点插到头结点之后也就是新结点始终成为当前链表的第一个有效结点。如果输入顺序是1, 2, 3, 4, 5最终链表顺序是5, 4, 3, 2, 1顺序是反的。LinkList List_HeadInsert(LinkList *L, int arr[], int n) { *L (LinkList)malloc(sizeof(LNode)); (*L)-next NULL; for (int i 0; i n; i) { LNode *s (LNode *)malloc(sizeof(LNode)); s-data arr[i]; s-next (*L)-next; (*L)-next s; } return *L; }关键代码是s-next (*L)-next;和(*L)-next s;这两行。第一行让新结点的指针指向当前第一个有效结点第二行把头结点的指针改指向新结点。顺序不能反过来如果先执行(*L)-next s旧链表就找不到了后面再把s接到旧链表上会直接断链。注意头插法里“先保存旧链表再修改头指针”的顺序和逆置里“先保存下一个结点再反转指针”是同一个思想务必牢记。头插法的时间复杂度是 O(n)空间复杂度 O(n)因为每个元素都要分配一个新结点。它的特点是“建立顺序与输入顺序相反”这一点在需要逆序建表的场景中可以直接利用。2.3 尾插法建立单链表尾插法需要维护一个尾指针r每次把新结点接到r的后面然后让r向后移动指向新结点。这样输入顺序和链表顺序一致符合大多数题目的默认要求。LinkList List_TailInsert(LinkList *L, int arr[], int n) { *L (LinkList)malloc(sizeof(LNode)); (*L)-next NULL; LNode *r *L; for (int i 0; i n; i) { LNode *s (LNode *)malloc(sizeof(LNode)); s-data arr[i]; s-next NULL; r-next s; r s; } return *L; }尾插法最容易犯的错误是忘记把s-next初始化为NULL。malloc分配的内存内容不确定不置空的话最后一个结点的next可能指向一个随机地址打印链表时会出现越界访问或死循环。另一个容易错的地方是r s;这一步。很多初学者只在循环里写了r-next s;忘记更新r结果每个新结点都接在头结点后面链表长度永远是 2后面的数据全部丢失。尾插法和头插法的时间复杂度都是 O(n)两者的差别只在于结点插入位置和维护方式不存在谁更快的本质区别。2.4 头插法与尾插法的对比对比项头插法尾插法插入位置头结点之后链表尾部结果顺序与输入顺序相反与输入顺序一致需要维护的指针只需要头指针需要额外尾指针典型用途逆序建表、实现逆置按原始顺序建表断链风险点先改 next 会丢旧链表忘记更新尾指针或未置空 next实际做题时如果题目说“输入一串数字要求建立带头结点的单链表”没有额外说明时默认用尾插法因为建表结果和输入顺序一致便于验证。头插法更多用在“需要逆序”的场景例如把一个顺序表数据改成逆序链表。3. 单链表逆置的两种实现迭代法和递归法3.1 先明确逆置的目标逆置也叫反转目标是把链表结点顺序完全反过来。例如1 - 2 - 3 - 4 - 5变成5 - 4 - 3 - 2 - 1。讨论逆置时头结点本身不动逆置的是头结点后面的有效结点序列。逆置有两种主流实现迭代法和递归法。迭代法使用三个指针在原链表上完成指针反转空间复杂度 O(1)。递归法先递归到链表末尾再逐层改变指针方向代码简洁但空间复杂度 O(n)因为递归调用需要栈空间。3.2 迭代法逆置迭代法需要三个指针pre指向前一个结点cur指向当前结点next暂存当前结点的下一个结点。每轮循环做四件事保存cur的下一个结点、把cur-next指向前一个结点、移动pre、移动cur。void Reverse_List(LinkList L) { if (L NULL || L-next NULL) { return; } LNode *pre NULL; LNode *cur L-next; LNode *next NULL; while (cur ! NULL) { next cur-next; cur-next pre; pre cur; cur next; } L-next pre; }循环结束后pre指向原链表的最后一个结点也就是逆置后新链表的第一个有效结点所以最后要把头结点的next指向pre。这里最容易踩的坑是丢掉next。循环内如果先执行cur-next precur原来的下一个结点就找不到了必须先执行next cur-next把它存下来。这和头插法里先保存旧链表是同一个逻辑。另外循环结束后头结点的next原本还指向原链表的第一个结点也就是逆置后的最后一个结点。最后执行L-next pre后头结点才正确指向新链表表头。如果漏掉这一步打印结果会多出一个旧的第一个结点看起来像“链表没有逆置成功”。3.3 递归法逆置递归法把问题拆成“先逆置除第一个有效结点之外的子链表再把第一个结点接到子链表末尾”。递归基是空链表或只有一个结点。LNode *Reverse_Recursive(LNode *head) { if (head NULL || head-next NULL) { return head; } LNode *newHead Reverse_Recursive(head-next); head-next-next head; head-next NULL; return newHead; }调用方式要特别注意Reverse_Recursive接收的是第一个有效结点不是头结点。假设链表为L - 1 - 2 - 3需要写成L-next Reverse_Recursive(L-next);递归过程中head-next-next head;让当前结点的下一个结点的指针反过来指向当前结点head-next NULL;切断当前结点原来的前向指针。当递归逐层返回时所有指针都会被反向连接起来。递归法代码少但新手很难一眼看出执行过程。建议用长度 3 的链表在纸上画一遍调用栈比盯着代码看更有效。递归深度等于链表长度当链表很长时可能栈溢出所以生产环境或处理超大链表时优先选择迭代法。注意递归法虽然写法简洁但每一层递归都会占用栈空间。链表长度达到几万甚至几十万时迭代法更安全。面试或考试中如果题目没有限制优先展示迭代法因为它能体现对指针操作的控制力。3.4 两种逆置方法的对比对比项迭代法递归法空间复杂度O(1)O(n)代码可读性指针逻辑直观简洁但较难理解栈溢出风险无链表很长时存在适用场景生产环境、大链表算法演示、短链表修改方式原地修改不需要新链表原地修改不需要新链表两种方法的共同点是都不需要新建链表只是改变指针指向。如果题目额外要求“逆置后得到一个新链表原链表不变”那就需要复制结点不是这里讨论的原地逆置。此外还存在一种更基础的思路遍历原链表用头插法把每个结点插入新链表逻辑上最简单缺点是要额外维护一个新头结点本质上是“用空间换代码清晰度”。4. 完整示例从建表到逆置一次跑通4.1 完整可运行代码下面给出一个完整的最小示例。它包含结点定义、尾插法建表、打印、迭代法逆置、释放内存和主函数。把这段代码复制到.c文件里编译运行就能看到逆置前后的完整输出。#include stdio.h #include stdlib.h typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; void PrintList(LinkList L) { LNode *p L-next; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); } LinkList List_TailInsert(LinkList *L, int arr[], int n) { *L (LinkList)malloc(sizeof(LNode)); (*L)-next NULL; LNode *r *L; for (int i 0; i n; i) { LNode *s (LNode *)malloc(sizeof(LNode)); s-data arr[i]; s-next NULL; r-next s; r s; } return *L; } void Reverse_List(LinkList L) { if (L NULL || L-next NULL) { return; } LNode *pre NULL; LNode *cur L-next; LNode *next NULL; while (cur ! NULL) { next cur-next; cur-next pre; pre cur; cur next; } L-next pre; } void DestroyList(LinkList L) { LNode *p L; while (p ! NULL) { LNode *tmp p; p p-next; free(tmp); } } int main() { int arr[] {1, 2, 3, 4, 5}; int n sizeof(arr) / sizeof(arr[0]); LinkList L NULL; List_TailInsert(L, arr, n); printf(逆置前: ); PrintList(L); Reverse_List(L); printf(逆置后: ); PrintList(L); DestroyList(L); return 0; }示例中的DestroyList函数不是必须的但加上它可以帮助养成“谁分配、谁释放”的习惯。链表题最容易被忽视的就是内存释放课程实验中程序退出后系统会回收内存但在长时间运行的服务里不释放内存会造成泄漏。4.2 预期输出逆置前: 1 2 3 4 5 逆置后: 5 4 3 2 1如果输出只有逆置前一行或者逆置后和逆置前完全一样说明逆置代码没有被正确调用或者传入的链表本身有问题需要检查L-next是否指向第一个有效结点。4.3 为什么建表函数要用二级指针List_TailInsert和List_HeadInsert的第一个参数是LinkList *L也就是二级指针LNode **。原因是建表时要给头指针L本身赋值。C 语言按值传参如果参数写成LinkList L函数内部对L的修改不会影响到main里的L函数返回后main中的指针仍然是NULL。这是初学者最常见的报错之一函数运行没有异常但main里访问L-next时程序崩溃或者在 Visual Studio 中弹出“使用了未初始化的内存”之类的调试提示。解决方案有两个使用二级指针函数内部通过(*L)-next访问链表。让函数返回LinkList调用处接收返回值。上面例程选择了“二级指针 返回”的混合方式只是为了同时演示两种写法的效果。实际项目中选一种保持一致即可推荐统一用返回值代码看起来更直观。5. 验证方法和常见错误排查5.1 如何验证建表和逆置都正确验证不能只看程序没有崩溃。建议按下面顺序逐项检查用长度为 0 的数组调用建表函数打印结果确认空链表时只输出换行不崩溃。用一个元素的数组验证边界逆置后结果应和原链表相同。用 5 个以上元素的数组验证逆置确认顺序完全反转。连续调用两次逆置链表应恢复原顺序。在调试器中观察每个循环步骤的指针变化确认pre、cur、next的移动顺序符合预期。如果使用 Visual Studio可以在Reverse_List的循环里添加临时断点逐轮观察三个指针的值和L-next的变化。链表题目非常适合用手画图和断点调试配合验证肉眼检查指针比猜代码高效得多。注意不要只验证程序能启动还要验证输入、输出、异常分支和日志是否符合预期。链表代码尤其要验证空表、单结点和多结点三种情况。5.2 常见错误排查表问题现象常见原因检查方式处理建议建表后打印乱码或崩溃尾插法忘记把s-next置空检查循环内是否有s-next NULL每次分配新结点后立即置空链表只有 2 个结点尾插法忘记更新尾指针r检查循环末尾是否有r s插入后把r指向新结点建表结果和输入顺序相反用了头插法但期望尾插法效果打印链表确认顺序需要保持顺序时改用尾插法逆置后链表只剩一个结点循环中丢了next检查循环开头是否保存cur-next先next cur-next再反转指针逆置后链表头部数据异常循环结束后没有执行L-next pre检查逆置函数末尾补上L-next pre主函数调用后 L 仍为 NULL建表函数参数用错了传值方式检查参数是否为LinkList *L改用二级指针或接收函数返回值程序进入死循环链表中有环或遍历条件写错检查循环条件和每个结点的next保证最后一个结点next为NULL5.3 从现象倒推原因当问题出现时按这个顺序排查确认输入数组和元素个数是否正确n是否等于数组真实长度。用sizeof(arr) / sizeof(arr[0])计算长度时如果数组已经退化为指针结果会出错。确认建表使用的是头插法还是尾插法对照打印结果是否符合该方法的特点。确认传参方式。函数内部修改指针后主调函数是否真的拿到了新值。确认逆置循环开始时是否保存了next指针改写顺序是否符合预期。确认最后是否把头结点的next更新为逆置后的新表头。如果打印输出正常但程序退出时报错检查释放内存时是否重复释放了某个结点。实际调试过程中最有效的办法是写一个打印函数并在关键步骤后调用。很多链表问题通过一次完整打印就能定位不需要一开始就怀疑编译环境或系统问题。先检查自己的代码逻辑再检查环境配置。6. 从课程代码到工程代码清单与扩展建议6.1 学习环境与生产环境的差异课程实验和考研做题时代码重点是逻辑正确内存释放、健壮性检查和工程组织可以适当简化。但在公司项目或自研组件里写链表至少要补上下面几件事封装创建、插入、删除、查找、逆置、销毁等接口不要让外部直接操作next指针。每个malloc都要检查返回值分配失败时给出明确错误提示。每个操作都要考虑空链表、单结点、尾结点等边界条件。退出前统一释放内存并使用内存检测工具检查泄漏。定义链表结构时尽量加上长度字段避免每次查找长度都遍历全表。如果链表需要被多个线程同时访问还要考虑加锁或改用无锁队列等并发方案。课程代码追求“跑出正确结果”工程代码追求“长时间稳定运行且易于维护”。两者的目标不同完整度要求也不同。6.2 可复用检查清单每次写完链表相关代码对照清单检查一遍[ ] 是否定义了头结点空表时L-next是否为NULL。[ ] 每个新结点是否都初始化了data和next。[ ] 头插法是否先保存旧链表再修改L-next。[ ] 尾插法是否更新了尾指针r。[ ] 逆置时是否在修改cur-next之前保存了next。[ ] 逆置结束后是否更新了L-next。[ ] 是否测试过空表、单结点、多结点三种情况。[ ] 是否在所有退出路径上释放了内存。[ ] 是否把多处重复逻辑抽取成函数。[ ] 是否添加了必要的注释说明指针移动顺序。这份清单不仅适用于单链表建立和逆置也适用于双向链表、循环链表和静态链表。只要涉及指针改写就值得按“保存现场、修改指针、更新入口、验证边界”的顺序自查。6.3 扩展方向单链表的建立和逆置是基础后面可以继续学习以下内容双向链表和循环链表。它们解决“前驱不好找”和“尾部无法回头”的问题逆置逻辑会有变化。链表排序。归并排序在链表上很好实现快速排序也可以改造但要注意不能依赖随机访问。检测环和找环入口。这是链表题里的高频扩展用快慢指针可以实现。静态链表。用数组模拟链表常见于考研题和内存受限场景指针域存的是数组下标。链表与递归的配合。逆置递归能理解之后再尝试用递归实现合并两个有序链表会更容易上手。单链表看似简单但任何一步指针顺序错误都会导致难以排查的运行时问题。把建立和逆置彻底想清楚后面学习更复杂的链式结构就会顺畅很多。建议下一步用相同思路写一遍双向链表逆置再尝试用迭代法对链表做归并排序这两个练习能帮你确认自己是否真的掌握了指针操作。