ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单链表头插法和尾插法详解:动图图解、代码实现与区别对比

单链表头插法和尾插法详解:动图图解、代码实现与区别对比 项目标题【数据结构】单链表之头插法和尾插法动图图解你一定遇到过这种情况刚开始学数据结构翻书、看视频、背定义讲到链表的时候前面还挺明白一到“插入”就有点绕。等到自己上机写代码跟着老师的代码抄头插法还能凑合换个题目要求你“输入一串数字输出正好反过来”你才发现头插法、尾插法不只是“插的方向不同”这么简单它们的运行逻辑、指针移动顺序、甚至最终的输出顺序都是两码事。这篇文章就是来把这个“两码事”彻底讲透的。先说明白单链表在内存里到底怎么存、指针到底指到哪里去再用图解和动图逐帧拆解头插法和尾插法的每个步骤最后给出手写代码、复杂度分析、常见报错排查方法。无论你是准备考研数据结构、刷LeetCode链表题还是写课程设计要手动实现链表这篇文章都能让你从“会抄代码”变成“真懂链表”。1. 单链表基础先把存储结构和指针逻辑搞明白1.1 单链表是怎么把数据“串”起来的很多初学者之所以在头插法、尾插法上卡壳根本原因是没有真正理解链表的存储方式。数组是在一片连续的内存空间里一个挨一个地放元素你要找第5个元素直接用下标就行。但链表不是链表里的每一个结点是“各睡各的床铺”的——它在内存中是分散存放的谁也不知道谁在哪里。那它怎么串起来呢靠的就是“线索”。每个结点除了存自己的数据还要存一个“下一个结点在哪”的地址这个地址在C语言里就对应指针。你可以把链表想象成“寻宝游戏”第一张纸条告诉你宝藏这个数据长啥样还告诉你下一张纸条藏在哪个墙角你走到那个墙角第二张纸条又告诉你第三张纸条的位置。这样一环扣一环走完所有纸条你就把所有数据都找完了。所以单链表的结点定义本质上就是“数据字段 指针字段”的组合。下面这段C语言代码就是最经典的结点结构体typedef struct Node { int data; // 数据域存放真正的数据 struct Node *next; // 指针域存下一个结点的地址 } Node;注意那个struct Node *next有些初学者会写成Node *next这在C语言里如果不先typedef是编译不过的因为结构体还没定义完类型别名还不存在。很多教材里的写法是先定义结构体再用typedef起别名比如typedef struct Node { int data; struct Node *next; } Node;这样写的好处是结构体内部引用自己时用完整的struct Node外部使用时可以直接用简短的Node。这块别看是小事上机考试手写代码时容易在这里栽跟头。1.2 头指针为什么是“命根子”单链表的访问入口全靠头指针也就是链表第一个结点的地址。一般来说我们会定义一个Node *head如果链表为空head就是NULL一旦有了第一个结点head就指向它。为什么要单独强调这一点因为在做头插法和尾插法的时候几乎所有新手都会犯同一个错误函数里改了指针跑回来发现主函数里的head根本没变。这个问题出在C语言的函数传参上。比如你写出这样的代码void headInsert(Node *head, int data) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data data; newNode-next head; head newNode; // 以为这样 head 就更新了 }你发现没有这里参数Node *head是一份指针的拷贝你在函数里让head newNode只是让这个副本指向了新的结点调用结束后副本销毁主函数里的head依然是原来的值。所以正确做法是传二级指针Node **head或者让函数返回新的头指针。我在刚开始学链表的时候就被这个问题卡了两个晚上。最后一层一层想明白才意识到指针也是值传参也是拷一份想让函数内部修改外部的指针变量必须传“指针的地址”。所以在下面的所有代码示例里如果是需要修改头指针的操作一律用二级指针或者用返回值。这是写链表代码最关键的习惯之一。2. 头插法新结点永远插在最前面2.1 头插法的核心思路新结点“压”在旧头结点的上面头插法顾名思义每次把新结点插入到链表的头部。你想象一个场景有一叠试卷你每次批改完一张就放在最上面最后所有试卷的顺序就是“最后批改的放在最上面”。也就是说你用头插法把一串数据依次插入链表最后遍历链表输出的顺序和输入顺序刚好相反。这个“反过来”的特性正是很多算法题的考点。比如逆序一个单链表最直接的做法就是遍历原链表用头插法把每个结点插入一个新链表遍历结束后新链表就是逆序后的结果。那具体怎么插呢核心就两句话新结点的next指向当前的头结点头指针指向新结点。代码实现也很简洁void headInsert(Node **head, int data) { // 1. 创建一个新结点 Node *newNode (Node *)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); return; } newNode-data data; // 2. 新结点指向当前的头结点 newNode-next *head; // 3. 头指针换人 *head newNode; }这里面最关键的一步就是newNode-next *head;可能有人会问为什么不能先执行*head newNode再执行newNode-next *head如果真这么写在第一步就把head指向了新的结点原来的头结点地址就找不到了除非另外保存新结点的next就会指回自己链表就变成了一个环。这种bug非常隐蔽程序可能不会立刻崩溃但遍历时就会陷入死循环。2.2 动图拆解头插法插入一个结点到底走了哪几步我用文字把动图的关键帧拆出来你照着画一遍就透彻了。假设现在链表里有两个结点A - B头指针head指向A。现在要插入一个新结点C数据值是C。第1步创建新结点CC-data 你要存的数据这一步和链表当前状态无关先造一个独立的结点出来。第2步C-next head让C的指针域指向当前链表的第一个结点A此时C-A原来的链条A-B没断。第3步head C让头指针改指向C。因为C-A所以现在的链表是 C-A-BC成为了新的头结点。注意一个细节整个过程中链表的其他结点完全不需要移动也不需要重新连接。这也是链表“插入操作高效”的根本原因——它不需要像数组那样整体移动元素只需改两条指针。你可以拿一张纸画三个方框每个方框分成两半左半写数据右半写箭头自己动手走一遍这个过程。数据结构这东西光看是不行的必须动手画、动手敲才能真正内化。2.3 头插法的时间复杂度与典型应用场景头插法的时间复杂度是O(1)不管链表有多长要插入的始终是第一个位置所以操作步骤固定不会随着链表长度增长而增加。但它有个明显的特点插入顺序和最终链表顺序相反。这个特点在哪些场景有用最常见的场景就是“逆序输出一组数据”。比如你从文件里读入一组整数希望以相反的顺序输出。如果你用头插法依次插入读完直接遍历链表得到的就是逆序结果省掉了一次额外的逆序操作。再比如某些算法需要“后进先出”的结构但又不想用库里的栈结构就可以用头插法的单链表来实现一个简易栈。插入用头插法出栈就直接从头部移除结点。在考研数据结构里头插法还有一个很经典的用途就是“原地逆置单链表”。思路是扫描原链表每扫描到一个结点就把它摘下来用头插法插入到新链表中。虽然这种逆置思路不是最优解最优解可以做到原地三指针逆置但头插法逆置的代码量最少、逻辑最好理解适合初学阶段先掌握。3. 尾插法新结点老老实实排到队伍最后3.1 尾插法的核心思路新结点接在链表末尾头插法虽然快但有一个缺点插入顺序和链表顺序相反。如果我想保持进入的顺序也就是“先来的在前面后来的在后面”那就得用尾插法。尾插法的逻辑同样不难找到当前链表的最后一个结点让最后一个结点的next指向新结点然后新结点的next置空标志它成为了新的末尾。这里有一个很容易被忽略的细节链表为空的时候怎么办如果链表为空就没有“最后一个结点”这时候尾插法实际上变成了“头插法”——新结点就是头结点也是尾结点。所以尾插法的代码必须对空链表单独处理。一个新手经常犯的错误就是上来就写Node *p *head; while (p-next ! NULL) { p p-next; } p-next newNode;如果head是NULL走到p-next这一步就直接段错误了因为你对空指针取了next字段。3.2 带尾指针的尾插法为什么要多维护一个tail最朴素的尾插法是先遍历到链表尾部再插入。问题在于每次插入都需要从头遍历到尾部时间复杂度是O(n)。如果插入n个结点总复杂度就是O(n²)。这个复杂度在数据量小的时候不明显数据量一大差距就出来了。所以工程上更常见的做法是额外维护一个尾指针tail始终指向链表的最后一个结点。这样每次插入就不用从头找了直接操作尾指针时间复杂度降为O(1)。代码如下void tailInsert(Node **head, Node **tail, int data) { Node *newNode (Node *)malloc(sizeof(Node)); if (newNode NULL) { printf(内存分配失败\n); return; } newNode-data data; newNode-next NULL; // 如果链表为空新结点既是头也是尾 if (*head NULL) { *head newNode; *tail newNode; } else { (*tail)-next newNode; *tail newNode; } }有没有发现这个代码里的tail指针也在被修改所以同样需要二级指针Node **tail。这里又回到前面强调过的点了想改指针变量就必须传指针的地址。还有一种写法是定义结构体来管理链表typedef struct List { Node *head; Node *tail; } List;这样代码读起来更舒服函数参数也不用扎堆用二级指针。比如初始化一个空链表就是List list {NULL, NULL}调用尾插法时传入list在函数内部访问list-head和list-tail就行。3.3 尾插法的动图拆解插入一个结点时指针怎么变同样以文字描述动图帧假设链表里有结点 A - Bhead指向Atail指向B。现在要插入新结点D。第1步创建新结点DD-next NULL。这一步很关键新结点的尾部必须置空否则你后面遍历链表时到了D还会继续往后走走到一个野地址上程序就崩了。第2步tail-next D把原来链表最后一个结点B的next改成指向D于是链条变成 A - B - D。第3步tail D尾指针更新到DD成为新的尾结点。如果链表为空整个链表的初始状态是head NULL, tail NULL插入第一个结点时head和tail都直接指向新结点。这里要注意后续再插入新结点时走tail-next newNode这条分支不会再影响head。这段代码如果面试时让你手写我建议先画一个只有两个结点的链表把三个指针head、tail、要插入的newNode画出来再在图上标出指针改动的顺序。你画图的速度基本就是你的思路清晰程度。3.4 尾插法的复杂度分析与选型建议带尾指针的尾插法单次插入是O(1)整体插入n个结点是O(n)不带尾指针的尾插法单次插入是O(n)整体插入n个结点是O(n²)。所以如果你需要频繁地在链表尾部插入数据建议务必维护尾指针。相反如果插入操作不频繁只是偶尔插一次那么从头遍历找尾部也不是不行代码更简单不容易出错。另外还有一种特殊情况如果需求是“从尾部删除”或者“从尾部开始处理”只有单向链表时尾指针帮不上太多忙因为单链表没法从尾部向前走。这种需求一般建议直接用双向链表或者用栈来倒序。4. 头插法 vs 尾插法一张表理清区别4.1 核心对比对照表我在上课和带新人的时候最常用的就是下面这张对照表。别小看这个表格每次讲完至少能帮大家省掉一半的“啊原来是这样”的恍然大悟时间。对比维度头插法尾插法插入位置链表头部链表尾部顺序效果逆序插入顺序与遍历顺序相反正序插入顺序与遍历顺序一致单次插入复杂度O(1)O(1)带尾指针/ O(n)不带尾指针是否需要尾指针不需要推荐维护否则要遍历找尾空链表处理新结点直接成为头结点新结点同时成为头结点和尾结点典型应用链表逆置、简易栈保持原顺序构建链表、队列新结点next设置指向当前头结点置为NULL头指针是否变化每次插入都必须更新只有第一次插入时更新这张表里最容易混淆的就是“顺序效果”。很多程序员写头插法写多了默认“插入链表就是这个顺序”结果在需要正序输出的时候用了头插法最后发现输出是反的调试半天还找不到原因。我建议你在工程中凡是遇到“批量创建链表”的需求先问自己一句我最后要的输出顺序是什么如果和输入顺序一致首选尾插法如果不一致考虑头插法。4.2 空链表处理两个方法的最大差异点头插法和尾插法在空链表上的处理差别是最容易出bug的地方。头插法处理空链表非常简单因为新结点要放最前面而空链表没有最前面所以新结点直接成为头结点就好。代码里的newNode-next *head当*head为NULL时newNode-next就是NULL天然就对了不用单独写空链表分支。尾插法就没有这么省心了。你必须写一个if (*head NULL)的分支让头和尾同时指向新结点。如果忘记这个分支直接用tail-next newNode而此时tail还是NULL就会引发空指针访问。很多新手做尾插法报段错误Segmentation fault十有八九是空链表没有处理。我自己的习惯是写尾插法之前第一件事就把“空链表分支”写在前面。这个分支写好了后面非空的逻辑基本不会出大问题。4.3 面试和考研喜欢怎么考这两个方法从考研数据结构的历年真题和各大公司的笔试面试来看头插法和尾插法很少单独出“默写代码”这种直球题更多是藏在题目里作为隐含考点。考研里常见的出法有给你一组数据的插入顺序问你用头插法或尾插法构建出的链表分别是什么样让你用头插法实现单链表的原地逆置让你写出尾插法建立带头结点链表的代码给你递归遍历链表和头插法的结合题判断某个操作的输出结果。面试里常见的出法有如何反转一个单链表用迭代法本质就是对每个结点做一次“摘下来头插法”如何判断一个链表是回文结构很多解法需要先找中点再把后半部分用头插法逆置再比较如何用两个单链表模拟栈和队列栈用头插法实现push队列用尾插法实现enqueue。所以你会发现掌握头插法和尾插法的目的不只是会“插入”这个动作而是能灵活运用“头部插入”和“尾部插入”这两种基本操作去拼装出更复杂的算法。5. 实操中的常见问题与排查技巧实录5.1 空指针崩溃和内存泄漏两个最常见的运行期事故先讲段错误。段错误几乎是所有学链表的人都会遇到的第一座大山。最常见的引起原因就是对NULL指针解引用了。比如尾插法你忘了处理空链表直接p-next newNode而p是NULL程序立刻崩溃。排查段错误我常用的方法是在怀疑的地方前后加打印。比如在函数入口打印printf(enter tailInsert, head%p, tail%p\n, *head, *tail)然后逐步往后打印每一步执行后的指针值。别小看%p打印指针这个操作它能看到指针是不是突然变成了0x0这对定位空指针问题特别有用。再说内存泄漏。链表操作里malloc和free必须成对出现。尤其是“删除结点”操作很多人删了链表的逻辑连接却忘了把结点的内存释放掉时间一长程序内存越吃越多最终崩溃。我建议所有写链表代码的同学养成一个习惯写完插入代码后接着写一个遍历函数和一个销毁链表的函数。销毁函数就是从头部开始一个一个free结点同时保留下一个结点的地址。比如void destroyList(Node *head) { while (head ! NULL) { Node *tmp head; head head-next; free(tmp); } }这样至少有始有终不会在测试时反复分配内存却从不释放。5.2 链“断”了和链成“环”了两个要命的逻辑bug链断了是指新结点没能正确接入链表中遍历时少了某个结点。比如头插法如果你忘记了newNode-next *head直接*head newNode那么原来链表里的所有结点都会丢失因为从新头结点出发找不到后面的结点了。一旦断链链表就变成了一个只含一个结点的“孤儿”。链成环是另一种极端。如果插入时新旧结点的指向搞反了比如*head newNode之后又让newNode-next *head这时候指向就乱了新结点指回自己链表成了一个循环结构。表面看好像一直能找到“下一个结点”但实际上永远遍历不完最终死循环。排查这两种问题我推荐一个土办法在关键操作前后分别打印整条链表。遍历打印的代码很短但每次插入后都打印一次你就能亲眼看到链表每一步的状态问题出在哪一步一目了然。比如头插法每次插入后打印应该看到新结点在最前面后面的顺序不受影响尾插法每次插入后打印应该看到新结点在最后面。5.3 手写代码的稳定性技巧先画图再写码后验证无论是考试还是面试手写链表代码稳定性比速度更重要。我自己的习惯是严格分成三步第一步画图。画一个只有两个结点的链表标出head指针和尾指针如果有的话画出要插入的新结点然后用带箭头的线条画出每一步指针修改后的状态。第二步写码。照着图上的指针变化顺序写代码。注意先处理新结点的next再改旧结点的next最后再移动头指针或尾指针。这个顺序在图上一目了然不会搞反。第三步在脑子里跑一遍。用一个具体的例子比如链表已有 1-2插入3从头到尾把代码走一遍看最终的链表是不是 1-2-3尾插法或者 3-1-2头插法。这里有一个细节特别值得说在考试中你代码的变量命名、缩进、注释其实都会影响阅卷观感。不要为了省时间就写出一堆缩写变量。老老实实写newNode、head、tail既不容易写乱也方便阅卷人理解你的思路。6. 扩展延伸这两个方法到底还能干什么6.1 头插法就是“天然逆置器”算法题里经常借它反转链表LeetCode上反转链表Reverse Linked List这道经典题很多解法其实本质就是头插法。思路很简单新建一个空链表头newHead NULL遍历原链表每拿到一个结点就把它摘下来用头插法插到newHead里。遍历结束newHead就是原来的逆序链表。如果你已经理解了头插法这个题你几乎可以秒写Node *reverseList(Node *head) { Node *newHead NULL; while (head ! NULL) { Node *tmp head; head head-next; tmp-next newHead; newHead tmp; } return newHead; }注意这里node-next newHead其实就是头插法的第二步newHead node是头插法的第三步。所以不要以为头插法只是个“插入操作”它本身就是反转操作的底层逻辑。6.2 尾插法配合尾指针可以很快构建出队列结构队列的特点是先进先出。如果用单链表实现队列入队操作就是在尾结点后插入新结点出队操作是删除头结点。这种场景下尾插法配合一个尾指针就能让入队操作达到O(1)。很多用Python刷题的同学可能觉得链表为什么还要学Python里有现成的list但list底层是动态数组某些场景下插入和删除头部元素是O(n)。你要是理解尾插法头删法这个组合你就能理解用队列实现广度优先搜索BFS的时候为什么用deque比用list更高效。数据结构这种东西学的时候是“概念”用的时候是“思维”。头插法和尾插法看似只是两种插法但它们背后代表的是在一维线性结构中在两端分别操作时的复杂度差异和顺序差异。理解了这一点再看栈、队列、双向链表、循环链表都会有一种“其实都是相通的”感觉。6.3 可视化工具与学习建议最后说点学习建议。如果你觉得看书上的静态图解还是不够直观我推荐你动手用下面这些方法辅助理解自己用纸笔画图。画格子、画箭头每次执行一个函数步骤就在图上改一遍箭头。这个方法虽然原始但比看任何视频都有效。用Python的list模拟链表行为。Python刷题时可以用list的insert(0, x)模拟头插法用append(x)模拟尾插法观察两者的顺序差异。在调试器中逐步执行C代码观察每个变量的地址和值。如果用的IDE支持可视化调试比如CLion、VS Code配合调试器你可以看到每个结点的地址和next的值这样指针的每一步变化都看得清清楚楚。在动手写代码的过程中你会反复踩到“传二级指针”“空链表分支”“先改next还是先改头指针”这些细节。踩坑不可怕关键每次踩完都要回到指针图上去想清楚我改的到底是“指针本身”还是“指针指向的对象的字段”想明白这一层链表基本就算过关了。
返回列表