单链表建立与逆置:头插法、尾插法及迭代与递归实现详解
2026/9/9 13:22:31 网站建设 项目流程

在实际的数据结构课程和考研复习中,单链表的建立与逆置是绕不开的基础操作。很多初学者看书时觉得代码都能看懂,但真正在编译器里写出来、跑起来,却会遇到头指针丢失、逆置后链表断链、尾插法顺序不对等问题。这篇文章从单链表的结点定义讲起,分别实现头插法、尾插法两种建表方式,再用迭代法和递归法完成逆置,最后给出验证方法和常见错误的排查思路。学完以后,你既能应付课程实验和考试,也能在后续学习双向链表、循环链表时复用同一套思考方式。

1. 先理解单链表的结构,再动手写代码

1.1 单链表解决什么问题

数组和链表是两种最基本的线性存储结构。数组在内存中是一段连续空间,优点是按下标访问是 O(1),缺点是插入和删除需要移动大量元素。单链表则用一组任意的存储单元存放线性表元素,每个结点除了存储数据,还要存储指向下一个结点的指针。这样带来的直接好处是插入和删除只需要修改指针,不需要移动数据。

单链表的缺点是失去了随机访问能力,找第 i 个结点必须从头开始遍历。所以单链表适合“频繁插入删除、不常按下标访问”的场景,例如内存池的空闲块管理、操作系统的进程队列、图的邻接表等,底层都可能用到单链表或它的变体。理解了这一点,就不会在需要频繁按下标定位的数据结构里硬用单链表。

1.2 结点结构定义

在 C 语言中,单链表结点包括数据域和指针域:

typedef struct LNode { int data; // 数据域,这里用 int 演示 struct LNode *next; // 指针域,指向下一个结点 } LNode, *LinkList;

这里有两个细节容易混淆。LNode表示结点类型,LinkList表示指向结点的指针类型。实际代码中,LNode *pLinkList 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 的编译器中都能编译运行。示例代码只用标准库函数printfmalloc,不包含平台相关头文件,因此不依赖具体集成开发环境。

实验约定如下:

  • 链表带头结点。
  • 数据域类型固定为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初始化为NULLmalloc分配的内存内容不确定,不置空的话,最后一个结点的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_TailInsertList_HeadInsert的第一个参数是LinkList *L,也就是二级指针LNode **。原因是建表时要给头指针L本身赋值。C 语言按值传参,如果参数写成LinkList L,函数内部对L的修改不会影响到main里的L,函数返回后main中的指针仍然是NULL

这是初学者最常见的报错之一:函数运行没有异常,但main里访问L->next时程序崩溃,或者在 Visual Studio 中弹出“使用了未初始化的内存”之类的调试提示。解决方案有两个:

  • 使用二级指针,函数内部通过(*L)->next访问链表。
  • 让函数返回LinkList,调用处接收返回值。

上面例程选择了“二级指针 + 返回”的混合方式,只是为了同时演示两种写法的效果。实际项目中选一种保持一致即可,推荐统一用返回值,代码看起来更直观。

5. 验证方法和常见错误排查

5.1 如何验证建表和逆置都正确

验证不能只看程序没有崩溃。建议按下面顺序逐项检查:

  1. 用长度为 0 的数组调用建表函数,打印结果,确认空链表时只输出换行,不崩溃。
  2. 用一个元素的数组验证边界,逆置后结果应和原链表相同。
  3. 用 5 个以上元素的数组验证逆置,确认顺序完全反转。
  4. 连续调用两次逆置,链表应恢复原顺序。
  5. 在调试器中观察每个循环步骤的指针变化,确认precurnext的移动顺序符合预期。

如果使用 Visual Studio,可以在Reverse_List的循环里添加临时断点,逐轮观察三个指针的值和L->next的变化。链表题目非常适合用手画图和断点调试配合验证,肉眼检查指针比猜代码高效得多。

注意:不要只验证程序能启动,还要验证输入、输出、异常分支和日志是否符合预期。链表代码尤其要验证空表、单结点和多结点三种情况。

5.2 常见错误排查表

问题现象常见原因检查方式处理建议
建表后打印乱码或崩溃尾插法忘记把s->next置空检查循环内是否有s->next = NULL每次分配新结点后立即置空
链表只有 2 个结点尾插法忘记更新尾指针r检查循环末尾是否有r = s插入后把r指向新结点
建表结果和输入顺序相反用了头插法但期望尾插法效果打印链表确认顺序需要保持顺序时改用尾插法
逆置后链表只剩一个结点循环中丢了next检查循环开头是否保存cur->nextnext = cur->next再反转指针
逆置后链表头部数据异常循环结束后没有执行L->next = pre检查逆置函数末尾补上L->next = pre
主函数调用后 L 仍为 NULL建表函数参数用错了传值方式检查参数是否为LinkList *L改用二级指针或接收函数返回值
程序进入死循环链表中有环或遍历条件写错检查循环条件和每个结点的next保证最后一个结点nextNULL

5.3 从现象倒推原因

当问题出现时,按这个顺序排查:

  1. 确认输入数组和元素个数是否正确,n是否等于数组真实长度。用sizeof(arr) / sizeof(arr[0])计算长度时,如果数组已经退化为指针,结果会出错。
  2. 确认建表使用的是头插法还是尾插法,对照打印结果是否符合该方法的特点。
  3. 确认传参方式。函数内部修改指针后,主调函数是否真的拿到了新值。
  4. 确认逆置循环开始时是否保存了next,指针改写顺序是否符合预期。
  5. 确认最后是否把头结点的next更新为逆置后的新表头。
  6. 如果打印输出正常但程序退出时报错,检查释放内存时是否重复释放了某个结点。

实际调试过程中,最有效的办法是写一个打印函数并在关键步骤后调用。很多链表问题通过一次完整打印就能定位,不需要一开始就怀疑编译环境或系统问题。先检查自己的代码逻辑,再检查环境配置。

6. 从课程代码到工程代码:清单与扩展建议

6.1 学习环境与生产环境的差异

课程实验和考研做题时,代码重点是逻辑正确,内存释放、健壮性检查和工程组织可以适当简化。但在公司项目或自研组件里写链表,至少要补上下面几件事:

  • 封装创建、插入、删除、查找、逆置、销毁等接口,不要让外部直接操作next指针。
  • 每个malloc都要检查返回值,分配失败时给出明确错误提示。
  • 每个操作都要考虑空链表、单结点、尾结点等边界条件。
  • 退出前统一释放内存,并使用内存检测工具检查泄漏。
  • 定义链表结构时尽量加上长度字段,避免每次查找长度都遍历全表。
  • 如果链表需要被多个线程同时访问,还要考虑加锁或改用无锁队列等并发方案。

课程代码追求“跑出正确结果”,工程代码追求“长时间稳定运行且易于维护”。两者的目标不同,完整度要求也不同。

6.2 可复用检查清单

每次写完链表相关代码,对照清单检查一遍:

  • [ ] 是否定义了头结点,空表时L->next是否为NULL
  • [ ] 每个新结点是否都初始化了datanext
  • [ ] 头插法是否先保存旧链表,再修改L->next
  • [ ] 尾插法是否更新了尾指针r
  • [ ] 逆置时是否在修改cur->next之前保存了next
  • [ ] 逆置结束后是否更新了L->next
  • [ ] 是否测试过空表、单结点、多结点三种情况。
  • [ ] 是否在所有退出路径上释放了内存。
  • [ ] 是否把多处重复逻辑抽取成函数。
  • [ ] 是否添加了必要的注释,说明指针移动顺序。

这份清单不仅适用于单链表建立和逆置,也适用于双向链表、循环链表和静态链表。只要涉及指针改写,就值得按“保存现场、修改指针、更新入口、验证边界”的顺序自查。

6.3 扩展方向

单链表的建立和逆置是基础,后面可以继续学习以下内容:

  • 双向链表和循环链表。它们解决“前驱不好找”和“尾部无法回头”的问题,逆置逻辑会有变化。
  • 链表排序。归并排序在链表上很好实现,快速排序也可以改造,但要注意不能依赖随机访问。
  • 检测环和找环入口。这是链表题里的高频扩展,用快慢指针可以实现。
  • 静态链表。用数组模拟链表,常见于考研题和内存受限场景,指针域存的是数组下标。
  • 链表与递归的配合。逆置递归能理解之后,再尝试用递归实现合并两个有序链表,会更容易上手。

单链表看似简单,但任何一步指针顺序错误都会导致难以排查的运行时问题。把建立和逆置彻底想清楚,后面学习更复杂的链式结构就会顺畅很多。建议下一步用相同思路写一遍双向链表逆置,再尝试用迭代法对链表做归并排序,这两个练习能帮你确认自己是否真的掌握了指针操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询