☰
头插法与尾插法详解:单链表建表核心原理与C语言实现
2026/10/14 23:59:06 网站建设 项目流程

1. 从一道老题说起:头插法、尾插法到底在折腾什么

如果你准备过考研或者刷过数据结构题,单链表的头插法和尾插法应该是绕不开的两个名字。很多教材把它们放在“顺序表与链表”那一章的建表小节里,一带而过,结果就是:考试会做,代码能背,但过两个月再写,一定有人把顺序搞反。

我见过不少同学在做王道书上的题目时,对着“头插法结果逆序、尾插法结果正序”这两句话反复背,但真要让他们手写一个用尾插法建链表的函数,还是会卡住。卡住的原因通常不是不会写,而是没想明白“头指针、尾指针、工作指针”这三者在内存里到底谁指向谁。

这篇文章想把这件事彻底讲透。不整虚的,直接从上手写代码的角度拆解,顺带把开头提到的“王道道道道”这个东西解释清楚:网上那些“道道道”的说法,其实是在调侃考研教材里反复强调的“王道”解法,核心就是:链表插入必须“先连后断”,先让新节点找到自己的下一个节点,再去动原来的指针。记住这六个字,头插尾插都不会写乱。

适合读这篇文章的人:正在备考数据结构、期末突击、写作业卡链表的同学,以及工作后想快速捡起C语言链表知识的开发者。基础越差,越应该把下面这几段代码亲手敲一遍,只看不写,效果减半。

2. 头插法:为什么新来的总站在最前面

2.1 头插法的核心代码与执行过程

先给出最标准的带头节点版本,这也是考研和面试里最常见的写法。所谓带头节点,就是链表有一个“哨兵”节点,它本身的 data 不存数据,只是为了统一操作逻辑,让插入、删除的第一个位置和后面的位置代码一致。

#include <stdio.h> #include <stdlib.h> typedef struct LNode { int data; struct LNode *next; } LNode; // 头插法建立单链表,返回头节点 LNode* list_head_insert() { LNode *head = (LNode*)malloc(sizeof(LNode)); head->next = NULL; int x; scanf("%d", &x); while (x != 9999) { // 输入 9999 结束 LNode *node = (LNode*)malloc(sizeof(LNode)); node->data = x; node->next = head->next; // 第一步:新节点指向原来的第一个节点 head->next = node; // 第二步:头节点指向新节点 scanf("%d", &x); } return head; }

看不懂这段代码的人,通常卡在node->next = head->next这一行。我用最笨的图来解释。

假设链表现在是空表:head->next 指向 NULL。现在输入数字 1。执行完第一步,1 这个节点的 next 是 NULL;执行完第二步,head->next 指向 1。链表变成 head -> 1 -> NULL。

继续输入 2。此时 head->next 已经指向 1 了。执行第一步:node(2)->next = head->next,也就是 2 节点的 next 指向 1;执行第二步:head->next = node(2)。链表变成 head -> 2 -> 1 -> NULL。

输入 3,链表变成 head -> 3 -> 2 -> 1 -> NULL。

看出规律了吗?每次新插入的节点都被塞在 head 后面,原来那些节点整体往后挪一个位置。所以输入顺序是 1 2 3,链表里的顺序却是 3 2 1。这就是“头插法结果逆序”的直接原因——不是代码写错了,是这种插入方式天生就是“后来者居上”。

2.2 为什么头插法能实现 O(1) 插入

链表插入本身不需要搬动其他节点,只需要改两个指针,这是它对比顺序表的核心优势。头插法因为永远只动 head 的 next,所以不管链表多长,插入一个新节点的时间复杂度都是 O(1)。

而头插法这种“当前插入的节点永远在 head 之后”的特性,决定了只要把头指针传进去,每次操作就固定那么两行代码,不需要遍历。对比一下后面的尾插法,你会发现尾插法要是不做优化,插入一个节点可能要跑完整个链表,那就变成 O(n) 了。

O(1) 和 O(n) 的差距,在题目里很容易被拿来出选择题。比如问“在长度为 n 的单链表上插入一个新节点,头插法的时间复杂度是?”答案永远是 O(1),同时你要在心里清楚:前提是已经拿到了新节点和头指针。

2.3 不带头节点的头插法

不带头的写法,麻烦在于:空表和不为空表时,头指针本身要变。看这段代码:

LNode* list_head_insert_without_head() { LNode *head = NULL; int x; scanf("%d", &x); while (x != 9999) { LNode *node = (LNode*)malloc(sizeof(LNode)); node->data = x; node->next = head; // 新节点指向原来的头 head = node; // 头指针移到新节点 scanf("%d", &x); } return head; }

这段代码在函数内部修改了 head,但注意 head 是局部变量,最后通过返回值把新的头传出去。如果在你的代码里写成head = node后发现调用完链表还是空的,多半是你把链表头指针直接传进了一个函数,在函数里修改了它,外面却感知不到。这在 C 语言里叫“传值”,链表头指针本身就是一个指针变量,传给函数时,函数拿到的是这个指针的值,而不是这个指针本身。要改指针本身就得用二级指针,或者像我这样用返回值。这是新手最常见的问题,后文我会专门列一节细讲。

3. 尾插法:老老实实排队是怎么实现的

3.1 两种尾插思路:每次都找尾 vs 维护尾指针

尾插法,顾名思义,新节点永远插在链表的末尾。最简单的实现思路是:每次从头开始遍历,一直走到 next 为 NULL 的节点,让它指向新节点。这种写法没问题,就是慢。

链表有 n 个节点,每次插入都要跑 n 步,插入 m 个节点就是 O(m*n)。假设你要构建一个 10000 个节点的链表,每次都遍历到尾,总共就要跑大约 5000 万步,虽然现代电脑跑完也就一瞬间,但算法题和考试里不能这么干,复杂度不好看。

所以教科书的尾插法标准做法是:多加一个尾指针 r,始终指向链表的最后一个节点。每次插入,直接让 r 的 next 指向新节点,再让 r 移到新节点上。这样插入就是 O(1),跟头插法一样高效。

LNode* list_tail_insert() { LNode *head = (LNode*)malloc(sizeof(LNode)); LNode *tail = head; // 初始时尾指针指向头节点 int x; scanf("%d", &x); while (x != 9999) { LNode *node = (LNode*)malloc(sizeof(LNode)); node->data = x; node->next = NULL; // 新节点必然是最后一个 tail->next = node; // 链上 tail = node; // 尾指针后移 scanf("%d", &x); } return head; }

尾插法的执行过程,用生活化类比就是排队:新来的人永远排在最后面,收银员(尾指针)永远知道队伍最后一个人是谁,不用每次从头数到尾。而头插法更像插队,新来的人直接站到第一个位置,原来的人挨个往后挪。

3.2 尾插的常见病:忘记更新尾指针

尾插法的代码就三行,很多人背下来了,但写的时候总出怪问题。最常见的一种是:

tail->next = node; // 忘记写 tail = node;

这种写法会怎么样?第一次插入:tail 指向 head,head->next 指向 node1,链表成立了,但 tail 还是指向 head。第二次插入:tail->next = node2,这一步会覆盖 head->next——head 本来指向 node1,现在变成指向 node2。node1 就丢了。而且 node2 的 next 是 NULL,链表里只剩 head -> node2。如果输入三次,逻辑继续错下去,最后链表只保留最后一个节点。

我见过太多人栽在这里,然后怀疑自己的 malloc 有问题。真相就是这么一个看起来无关紧要的“尾指针后移”漏掉了。你想想,如果收银员永远站在队伍第一个人的位置,每次新来的人都站在他后面,那队伍里永远只有两个人——收银员和最新来的人。尾指针不更新,就相当于收银员从来不移动。

3.3 尾插法建表的完整过程演示

把上节代码跑一遍,逐步看指针变化。

初始状态:head -> NULL,tail = head。

输入 5,建立 node1(5)。执行tail->next = node1:head -> node1。执行tail = node1:tail 指向 node1。

输入 9,建立 node2(9)。执行tail->next = node2:node1 -> node2。执行tail = node2:tail 指向 node2。

输入 1,建立 node3(1)。这次不用遍历,直接 node2 -> node3,tial 变成 node3。

最终链表:head -> 5 -> 9 -> 1 -> NULL。

输入顺序和链表顺序完全一致,这就是尾插法“结果正序”的含义。这也是为什么实际建表时尾插法更常用,因为它保持原始顺序,读文件、处理字符串、构建具有先后顺序的数据时,你不会希望它反转。

4. 最容易踩的坑与排查心得

4.1 头插法顺序搞反:是“新节点的 next”先赋值,还是“头节点的 next”先赋值

我统计过周围同学写头插法翻车的情况,第一大类就是这两行的顺序写反了。先说结论:必须是先node->next = head->next,再head->next = node。说是两行代码,但其实是两个步骤的固定先后关系,原因在于,第二步会覆盖掉 head->next 原有的值。如果你先执行第二步,原来的“第一个节点”就丢了,谁也找不到它了。这就是文章开头说的“先连后断”——先让新节点和后面的链表连上,再让头节点改指向。如果顺序反了,相当于先把旧桥拆了,再想搭新桥,结果发现桥对面已经够不着了。

同样的道理,在双向链表、二叉树插入、甚至线程切换的链表管理里也成立。你不需要背口诀,只要记住一句话:改指针前,先想想你有没有备份被覆盖的指针指向的那个节点。

4.2 尾插法打印不出预期的数据

尾插法打印出问题,集中在两个地方。第一是尾指针没更新,这个上面已经说了。第二是头节点本身有数据。

有些同学写尾插法时,没有分配头节点,直接让 head 在第一次插入时等于新节点:

LNode *head = NULL; LNode *tail = head; while (...) { node = (LNode*)malloc(...); if (head == NULL) { head = node; tail = node; } else { tail->next = node; tail = node; } }

这种写法也能工作,但每次插入都要判断 head 是不是空,代码变杂。更大的隐患是:在函数内修改 head 后,如果不用返回值或二级指针,外面拿到的还是 NULL。很多人考试卷子上写这种“无头节点”版本,判分时会因为边界判断写不完整被扣分。我的建议很朴素:平时练习一律用带头节点版本,逻辑清晰,不容易错。等你会用了,再研究不带头节点的情况。

打印链表的遍历代码,也值得顺手写对:

void print_list(LNode *head) { LNode *p = head->next; // 跳过带头节点 while (p != NULL) { printf("%d ", p->data); p = p->next; } printf("\n"); }

有人打印时从头节点本身开始,把哨兵节点的 data 也打印出来了(malloc 出来没赋值的话,是个随机值)。如果你打印链表发现第一个数字是乱七八糟的大整数,检查一下是不是遍历从 head 开始了。

4.3 内存泄漏与操作无响应:malloc 之后到底有没有配 free

C 语言链表题目做多了,常见问题不只是逻辑错,还有内存管理。普通练习里忘了 free 可能没感觉,但考试中的“释放链表”题或者真实项目里,这是要命的。

我见过一种情况:某人写了一个 head_free 函数,里面用一个 while 循环把每个节点 free 掉,然后测试时发现程序没有任何报错,但内存占用涨得吓人。一查,原来 free 的不是每个节点本身,而是每次用一个临时指针指向下一节点,然后 free 临时指针——指向的却还是同一个节点。这就像你手里拿着一张褪色的门票,反复撕同一张票,其他座位照样有人占着。

正确写法:

void free_list(LNode *head) { LNode *cur = head; while (cur != NULL) { LNode *next = cur->next; // 先保存下一个 free(cur); // 再释放当前 cur = next; } }

注意,必须先用next保存下一个节点的地址,再 free 当前节点。因为 free 之后当前节点已经不可访问了,如果你写cur = cur->next,就得保证cur->next这条指令在 free 之前执行完毕的是那个地址值而不是解引用。实际上很多编译器可能不会立刻给你报错,但这是未定义行为,考场和面试中不被接受。直接记住上面这个 next 临时变量的写法即可。

4.4 二级指针到底什么时候用

很多课件一上来就讲二级指针,搞得人一头雾水。我这里给一个最简单的判断标准:函数内部需不需要修改调用者的“头指针变量本身”?需要就用二级指针,不需要就用一级指针。

举例:

void tail_insert(LNode **head, int x) { LNode *node = (LNode*)malloc(sizeof(LNode)); node->data = x; node->next = NULL; if (*head == NULL) { *head = node; return; } LNode *tail = *head; while (tail->next != NULL) { tail = tail->next; } tail->next = node; }

这段代码用二级指针是因为:当链表为空时,*head = node要修改调用者手里那个头指针变量。如果你只传LNode *head,函数里改了 head,调用方一无所知。这个点很多人到大四才彻底明白,其实做题时自己写一遍无头节点的插入就懂了。

对于有头节点的情况,头指针不会变,传一级指针就够了。判断逻辑就一句话:看你的函数里有没有给“头指针”重新赋值。

5. 应用场景:头插法不只是逆序,它还是一种“栈”

5.1 头插法 = 逆序建表 = 栈式插入

头插法的本质,是把输入序列反过来存储。如果用一句话描述:头插法是“后进先出”的建表方式。

其实你在写的时候就已经观察到规律了:输入 1、2、3,链表里是 3、2、1。这种操作,跟入栈出栈的结果一模一样。栈是后进先出,头插法也是后进先出——新节点永远是第一个。

所以在实际应用中,如果你需要把一组数据倒序存储,通常不需要先正序建表再反转链表,直接用头插法建表即可。比如:

  • 网页浏览记录,最后访问的应该在最前面,用头插法建表存 URL。
  • 操作系统的撤销栈,最后操作的要最先恢复,用头插法。
  • 编译原理里的表达式求值,后缀表达式转逆波兰,很多临时存储用类似机制。

这就是为什么“头插法可以用于链表反转”这句话反复出现:把链表从头到尾遍历一遍,每遍历到一个节点就用头插法插入到新链表,原来的第一个变成新链表的最后一个,原来的最后一个变成新链表的第一个,正好逆序。

5.2 链表反转的另一道经典题:原地反转与头插

如果只允许在一个链表上操作,不让新建链表,那怎么反转?常见做法有三个:迭代倒指针、递归、还有用头插法思想进行“拆节点再头插”。

其中“拆节点再头插”的写法,理解头插法后特别顺:

LNode* reverse_list(LNode *head) { LNode *new_head = NULL; LNode *p = head; while (p != NULL) { LNode *tmp = p->next; // 先保存后面的节点 p->next = new_head; // 让当前节点指向新链表的第一个 new_head = p; // 当前节点变成新链表的第一个 p = tmp; // 继续处理原链表后面的节点 } return new_head; }

这段代码的思想就是:原链表每个节点,依次“搬到”新链表,每次搬到新链表时都放在最前面,于是顺序被倒了过来。你看代码里的p->next = new_head,这不就是头插法的第一步嘛。所有用头插法解决的问题,本质上都是逆序问题,这是面试里最值得记住的结论。

5.3 尾插法的应用侧重点

尾插法保持顺序,适合的场景是“读文件后要保持原顺序”“按时间序列建表”“维护一个 FIFO 队列”。典型例子:

  • 日志系统:新日志永远追加在末尾,读取时按时间顺序。
  • 消息队列:先到的任务先处理。
  • 学生名单:输入顺序就是展示顺序,直接尾插。

工程中链表常用来做队列,队列的核心操作是“入队尾、出队头”,尾指针配合头指针一起用,效率最优。你在写这一类代码时,尾插法是家常便饭。

6. 头插尾插对比:一张表记住所有关键点

对比维度头插法尾插法
插入位置头节点之后链表末尾
生成顺序与输入相反(逆序)与输入相同(正序)
时间复杂度O(1)维护尾指针 O(1);不维护 O(n)
核心代码node->next = head->next; head->next = node;tail->next = node; tail = node;
指针移动每次只动 head 的 next每次要动 tail 的 next 并更新 tail
常用场景逆序建表、链表反转、栈式存储正序建表、队列、日志追加
常见错误两行顺序写反忘记更新尾指针

考试选择题如果问“哪个方法建立的链表是逆序的”,答案永远是无脑选头插法。但如果问你“哪个方法需要额外维护一个尾指针才能保证 O(1)”,答案就是尾插法。

6.1 带头节点 vs 不带头节点:一句话总结

带头节点:操作统一,删除第一个节点、插入第一个节点不需要特殊判断,代码稳定,适合考试和工程。

不带头节点:省一个节点的内存,但插入删除首节点时必须修改头指针本身,函数参数要多考虑一级还是二级指针。除非题目明确要求“不带头节点”,否则我不推荐你主动用。

6.2 实战技巧:如何快速判断一段代码用的是头插还是尾插

很多人拿到一段别人写的建表代码,很容易看晕。我给你一个速判法:找“插入新节点后,有没有维护一个指向末尾的指针”。

  • 如果有 tail 变量,并且每次插入后tail = node或者tail = tail->next,就是尾插。
  • 如果操作总是围绕head->next转,新节点像“空降”在头部,就是头插。

再变通一下:看最终输出顺序,跟输入顺序一样就是尾插,反了就是头插。这个方法最傻瓜,也最可靠。你甚至可以写完代码不去读逻辑,建一个链表后打印看看顺序,一眼就能判断自己写的是哪种。

7. 从两个插法延伸出去:链表这部分到底怎么学才有效

写了这么多,最后想给正在备考或者刚学完数据结构的读者一点个人体会。链表这部分,本质上就三个基本功:遍历、插入、删除。而插入又无非是头插和尾插两个变体,删除又无非是“删头、删中间、删尾、按值删、按下标删”几种组合。一旦你吃透了指针指向变化那一小段逻辑,后面所有链表题都只是重复操作。

我见过很多同学拿着一本厚厚的习题集,从头到尾刷链表题,每道题就那么背。结果考到“将两个递增有序的单链表合并成一个递减有序的单链表”,就傻眼了——因为这里要用到头插法把较小节点依次插到结果链表的头部,从而得到递减序列。你看,题目一变“花样”,本质还是头插法。你在理解“尾插法保持有序,头插法产生逆序”之后,这种题基本就是套路。

所以我的建议是:别看太多“花活”,先把这几段代码练到闭着眼睛能写出来:

  • 带头节点的头插法建表
  • 带头节点的尾插法建表(含尾指针)
  • 遍历打印
  • 释放链表
  • 链表反转(头插思想)

这五段代码,每一段都要求你亲手敲三遍以上,并且要能说出每一步执行前后指针指向哪里。做到这个程度,链表基础题不敢说满分,但至少不会再犯“顺序写反”“忘记更新尾指针”这种低级错误了。

最后再补一句实战里的心得:写链表题时,不要省那几个临时变量。很多人为了代码短,喜欢直接写p = p->next->next、head = head->next这种,看着爽,但它们其实是“危险操作”——你可能还没保存旧位置,就把唯一能到某个节点的引用丢了。代码写短不如写对。真正熟练的人,宁可多写一行LNode *next = p->next,也不会让自己陷入指针丢链的困境。

等你能把链表基础打完,再往深走就顺了:双向链表只是多了个前驱指针,循环链表只是尾节点指向头,静态链表只是用数组模拟指针。到头来你会发现,所有变化的背后,还是那个最简单的道理——你想让一个节点改变去向,先确保它要走的那条路是通的。这就是“先连后断,先备份再覆盖”八个字的全部含义,也是头插法、尾插法乃至整个链表操作的核心心法。

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

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

立即咨询