
看到习题2.4 递增的整数序列链表的插入这个标题正在学数据结构的朋友应该会心一笑——这就是链表章节里那道看着简单一写就错的经典题。题干其实就一句话给一个已经按递增顺序排列好的单链表插入一个新结点要求插入后链表依然递增有序。核心就两个词链表、插入。但千万别小看它我当年在这道题上至少翻车过三次后来在帮别人讲题、看代码时又遇到了至少五种不同类型的错误写法。这篇内容就是想把这道题从头到尾拆透适合正在学数据结构的初学者、准备期末考的学生、以及面试前想快速回顾链表基本功的开发者。看完之后你不仅能写出无bug的有序插入代码还能顺手应对循环链表、双链表和有序表批量构建的变体问题。1. 这道题到底在考什么把题干翻译成人话1.1 核心考点拆解先别急着写代码把题目拆开看。题干里有三个关键信息递增、整数序列、链表插入。递增意味着这个链表不是随便指的它已经有顺序了。你要做的不是随便插而是保持这个顺序。比如链表里现在是 1 - 3 - 5你要插入 4结果应该是 1 - 3 - 4 - 5不能蹦到别的位置去。链表决定了你怎么操作。如果是数组找到位置后把后面元素整体往后移动一格就行但链表没有整体后移这种操作它靠的是指针。每个结点里存了一个next指针指向下一个结点你只能顺着这个方向一个一个走不能像数组那样通过下标直接跳到中间。这就带来一个天然的问题想在一个结点后面插入新结点很容易但想在一个结点前面插入就难了因为你没法回头找到前驱。所以这道题表面上是插入实质上考的是两件事第一如何在单链表中找到合适的位置第二如何修改指针把新结点缝进去。这两件事任何一个环节出错链表就断或者顺序就乱。1.2 为什么这道题总在期末和面试里出现我见过很多初学者问不就是插个结点吗有什么好考的这里面其实藏着一整套基本功。从教学角度来说这道题几乎覆盖了单链表的核心操作。你要初始化新结点、要遍历链表、要判断边界条件、要处理空链表、要修改指针。这些动作单独拎出来都不难但组合在一起就非常考验思路是否清晰。从面试角度来说手写链表是很多公司技术面的暖场题。面试官不一定指望你写出多优雅的代码而是想看你面对一个数据结构算法约束的问题时能不能有条理地分析、能不能把边界情况考虑全面。有相当多的人能写出主干逻辑却在空链表或者是插入尾部的地方翻车这一下就能看出基本功扎不扎实。从后续学习角度来看找前驱改指针这个模式是所有复杂链表算法的基石。后面你会学到链表的排序、链表的归并、LRU缓存淘汰算法、甚至是一些高级数据结构的调整操作本质上都是在这个模式上做变化。所以这道题花时间反复手写几遍绝对值。2. 先搞清楚链表长什么样再动手数据结构与准备工作2.1 C语言里单链表的定义方式不同教材对链表结点的定义写法略有差异但底层逻辑是通用的。最常见的是这种C语言写法typedef struct LNode { int data; // 数据域存放整数 struct LNode *next; // 指针域指向下一个结点 } LNode, *LinkList;这里有两处typedef一并解释清楚。第一个typedef struct LNode { ... } LNode表示给这个结构体起个别名叫LNode以后声明变量就不用写struct LNode那么啰嗦了。第二个typedef struct LNode *LinkList表示定义一个指针类型的别名LinkList本质上就是LNode *也就是指向链表结点的指针。所以在代码里你可能看到LNode *p和LinkList p两种写法它们其实是同一个意思都是指向链表结点的指针。有些教材习惯用LinkList来表示头指针用LNode *来操作具体的结点这只是一种风格约定不影响本质。如果你用的是C也可以写成类的形式。我曾经见过学生的代码里有ListNode类构造函数里自动初始化data和next这样更安全但题目如果要求C语言实现还是上面这种结构体写法最稳妥。2.2 带头结点到底是什么意思这是初学者最容易懵的地方。所谓头结点是指链表最前面的那个特殊结点它不存实际数据或者说它的data字段默认没意义它的唯一作用就是让第一个真正存数据的结点也有前驱。为什么需要它举个例子如果你要在一个普通的不带头结点的链表的头部插入一个结点那就得修改链表的头指针本身所以函数参数必须是指针的指针写起来很麻烦还容易错。但如果你有一个头结点头指针始终指向头结点头结点永远不动插入头部时只需要把头结点的next指到新结点函数不需要返回新头指针操作就统一了。在带头结点的链表里判断空表就是判断L-next NULL而不是L NULL。创建链表时头结点要单独分配内存LinkList L (LinkList)malloc(sizeof(LNode)); if (L NULL) { // 处理内存分配失败 exit(1); } L-next NULL;这一点必须养成习惯去哪都不丢。3. 最容易读懂的第一版双指针滑动找前驱3.1 整体思路先定位后修改我建议第一次写这道题的朋友不要上来就追求精简代码而是先用最直白的方式理清楚思路。三步走从第一个实际结点开始顺着next指针遍历找到第一个值大于等于x的结点。让一个新指针记住这个结点的前驱。在前驱和当前结点之间插入新结点。为什么非要记前驱因为单链表是单向的。当你顺着遍历走到一个结点p时你手里只有p和它的next没有回头看上一个结点的能力。而插入动作需要修改前驱的next所以必须用一个指针始终跟着p记录p的前面是谁。我把这个指针习惯性地命名为pre。代码写出来是这样void InsertSorted(LinkList L, int x) { // 创建新结点 LNode *s (LNode *)malloc(sizeof(LNode)); if (s NULL) { return; } s-data x; s-next NULL; // 双指针定位pre 始终是 p 的前驱 LNode *pre L; // 从头结点开始 LNode *p L-next; // 第一个实际结点 // 只要当前结点不为空并且它的值小于x就继续向后找 while (p ! NULL p-data x) { pre p; p p-next; } // 退出循环时 // pre 是插入位置的前驱p 是插入位置的后继可能是NULL s-next p; pre-next s; }这里的关键就是while循环条件。为什么是p-data x因为我们希望在第一个大于等于x的结点前面插入新结点。只要当前结点的值小于x说明新结点应该排在它后面于是后移。3.2 每种情况的边界条件我把这个代码在脑子里面把所有情况走了一遍你会发现它居然自己就把所有边界情况都处理了。第一种情况是空链表。这时p L-next NULL循环不进入pre L。执行s-next NULLpre-next s于是新结点成为第一个结点也是唯一一个结点。没问题。第二种情况是x小于链表里所有结点的值。比如链表是1-3-5插入0。循环第一次判断p-data x即1 0为假直接跳出。pre还是Lp还是1。执行s-next p让新结点指向原来的第一个结点1然后pre-next s让头结点指向新结点。结果0-1-3-5。完美。第三种情况是x大于链表里所有结点的值比如链表是1-3-5插入6。循环会一直走到p NULL此时pre指向5所在的尾结点p NULL。执行s-next NULLpre-next s把新结点接在5后面。这就是尾部插入。完美。第四种情况是x的值正好和链表中某个元素相等比如链表1-3-5插入3。按照p-data x这个条件当p指向第一个3时3 3为假循环停止。新结点会被插入到这个3的前面结果是1-3-3-5。这样链表依然满足递增的要求。如果你希望插到相等元素的后面就把判断条件改成p-data x。不同教材可能默认不同我自己的习惯是严格递增序列用非递减序列用保证插入位置是第一个大于x的结点之前。这一版代码的好处是思路清楚、不容易错逻辑完全跟着插入操作需要前驱这个物理事实走。劣势是多了个pre指针代码看起来略长。但作为习题答案这种写法完全合格。4. 精简版实现用哨兵卡住插入位置4.1 代码实现当我对链表足够熟练之后开始喜欢另一种写法不用pre指针只看当前结点的下一个结点。逻辑也很直白我要找的是那个值比x大的结点的前驱那我直接让p从头结点开始移动每次判断p-next-data是否大于等于x一旦大于等于说明p就是插入位置的前驱。void InsertSorted(LinkList L, int x) { LNode *s (LNode *)malloc(sizeof(LNode)); if (s NULL) { return; } s-data x; s-next NULL; LNode *p L; // p 指向插入位置的前驱 while (p-next ! NULL p-next-data x) { p p-next; } s-next p-next; p-next s; }这个版本的核心在于p的初始值是L也就是头结点。while循环的判断条件是p-next ! NULL p-next-data x。先检查后一个结点是否存在再检查后一个结点的值是否小于x两个条件必须同时满足才继续移动。循环结束后p要么停在第一个值大于等于x的结点的前驱位置要么停在尾结点然后一行代码完成插入。4.2 为什么这个写法更推荐我后来批作业时会优先推荐这一版因为它把找前驱这个动作简化成了看下一个结点。你不用在脑子里维护两个指针的同步关系只需要盯住一个pp最终停下来的位置就是插入点前驱。这个模式在链表删除操作里也一样好用删除某个结点时只要让p停在待删结点的前驱然后p-next p-next-next就完成了非常统一。不过它有个前提链表必须是带头结点的。因为如果是不带头结点的链表p从头结点开始就应该是L不是L-next吗等等不带头结点时p初始值应该是指向第一个结点循环条件就不一样了而且头插时函数还得修改外部头指针。这也是为什么教材普遍爱带头结点的原因之一——省事减少错误。还有一个细节这个写法里s-next p-next这一句即使p-next是NULL也没有问题因为把NULL赋给s-next正好完成尾部插入。不要画蛇添足加一个if (p-next ! NULL) s-next p-next;那样反而会在尾部插入时让新结点指向未初始化的区域。有朋友可能会问如果链表里已经有一个结点的值等于x这版代码会插到哪里当p-next-data x不成立时循环停止插到该结点前面。如果你想要插到等值结点的后面把循环条件改成p-next-data x即可。5. 完整测试代码与验证过程5.1 测试用的辅助函数光有核心插入函数还不够你得能建表、能打印、能释放内存才能完整地验证。我一般会在写题时顺手带上这几个辅助函数#include stdio.h #include stdlib.h typedef struct LNode { int data; struct LNode *next; } LNode, *LinkList; // 根据数组创建带头结点的链表尾插法 LinkList CreateList(int a[], int n) { LinkList L (LinkList)malloc(sizeof(LNode)); if (L NULL) { return NULL; } L-next NULL; LNode *tail L; for (int i 0; i n; i) { LNode *s (LNode *)malloc(sizeof(LNode)); if (s NULL) { return NULL; } s-data a[i]; s-next NULL; tail-next s; tail s; } return L; } // 打印链表 void PrintList(LinkList L) { LNode *p L-next; while (p ! NULL) { printf(%d , p-data); p p-next; } printf(\n); } // 释放整个链表包括头结点 void FreeList(LinkList L) { LNode *p L; while (p ! NULL) { LNode *tmp p; p p-next; free(tmp); } }创建链表用尾插法就是维护一个tail指针始终指向当前链表的最后一个结点。每次需要插入新结点时先把新结点挂在tail的next上再更新tail。尾插法创建出来的链表元素顺序和数组顺序一致测试时方便直观对照。注意free(tmp)要在p p-next之后因为一旦free了当前结点你就不能再用它去取next了所以必须先保存下一个结点的地址。5.2 覆盖全部边界的测试用例测试用例怎么设计我习惯把所有边界情况全跑一遍空表、头部插入、中间插入、尾部插入、等值插入、连续插入。int main() { // 用例1空链表插入 LinkList L1 (LinkList)malloc(sizeof(LNode)); L1-next NULL; InsertSorted(L1, 5); printf(用例1空表插入5); PrintList(L1); FreeList(L1); // 用例2头部插入 int a2[] {1, 3, 5}; LinkList L2 CreateList(a2, 3); InsertSorted(L2, 0); printf(用例21 3 5 插入0); PrintList(L2); FreeList(L2); // 用例3中间插入 int a3[] {1, 3, 5}; LinkList L3 CreateList(a3, 3); InsertSorted(L3, 4); printf(用例31 3 5 插入4); PrintList(L3); FreeList(L3); // 用例4尾部插入 int a4[] {1, 3, 5}; LinkList L4 CreateList(a4, 3); InsertSorted(L4, 6); printf(用例41 3 5 插入6); PrintList(L4); FreeList(L4); // 用例5等值元素插入 int a5[] {1, 3, 5}; LinkList L5 CreateList(a5, 3); InsertSorted(L5, 3); printf(用例51 3 5 插入3); PrintList(L5); FreeList(L5); return 0; }我预期输出分别是用例1空表插入55 用例21 3 5 插入00 1 3 5 用例31 3 5 插入41 3 4 5 用例41 3 5 插入61 3 5 6 用例51 3 5 插入31 3 3 5这里尤其要说明用例5它验证的是等值元素共存的情况。由于我们的循环条件是p-next-data x遇到等于x的结点就停下来所以新结点插在旧等值结点的前面。链表依然是递增序没有破坏题设要求。实际跑的时候最好再补一个连续多次插入的测试。比如先建空表依次插入5、2、7、1每插入一次就打印一次看看链表是不是始终保持有序。这个测试能模拟真实场景中动态维护一个有序表的过程比单次插入更能暴露问题。6. 常见错误与排查实录6.1 我见过的五个经典翻车现场第一种新结点的指针域没初始化。有些人写完s-data x就忘记写s-next NULL。在单次插入的场景里可能侥幸没出事因为后面马上执行s-next p-next把它覆盖了。但如果你在创建结点后、赋值前有别的操作或者把这段代码复用到了别的地方未初始化的指针就是一个定时炸弹访问它几乎必崩。第二种定位条件写反。我见过有同学写while (p ! NULL p-data x)说要找到第一个大于x的结点。问题在于当循环退出时p指向的是第一个小于等于x的结点不对当条件是p-data x时循环会一直走到第一个值不大于x的结点才停最后插入位置完全不对。这就是没想清楚我们要找的是插入位置的前驱这个本质。第三种指针修改顺序出错。插入操作有两句核心代码s-next p-next; p-next s;。有些人喜欢先写p-next s然后写s-next p-next这样一来p-next已经被改成s了s-next指向了自己链表就变成了一个环。正确顺序永远是先让新结点抓住后继再让前驱松开手就像火车车厢连接先把新的一节挂上车钩再解开原来的挂钩顺序不能反。第四种没有处理malloc失败。在OJ在线评测系统上内存通常够用几乎不会分配失败但在实际工程里malloc返回NULL是完全可能的。拿到malloc的返回值后至少应该判断一下如果为NULL就直接return避免后面访问空指针。我见过不少例子代码逻辑没问题但因为没有判空运行一段时间后在内存紧张时直接段错误。第五种用不带头结点的思路写成带头结点的样子。有同学写的函数一开始是if (L NULL)或者打印链表时从L而不是L-next开始。这些代码在不带头结点的背景下也许能工作但放到带头结点链表上就会错位或者把头结点的垃圾值打出来。动手前一定要想清楚你的链表到底带不带这个哨兵。6.2 问题排查速查表我整理一个表格方便你对着自查症状可能原因排查与对策打印链表时死循环插入过程中形成了环通常是s-next p-next; p-next s;顺序写反检查指针修改顺序先改新结点再改前驱插入后数据项莫名丢失pre指针更新不及时或者定位条件错误单步调试打印每一步的pre和p尾部插入后链表尾结点乱指新结点s-next没有初始化创建结点后立即置NULL空表插入报段错误没有带头结点或者直接访问p-next-data没判空检查while条件里p-next ! NULL放在前面输出顺序杂乱while循环里用了p-data x这类错误条件复述题意我要找第一个大于x的结点之前的位置头插时外部头指针没有变化不带头结点但函数参数不是指针的指针带头结点或在函数外重新赋值返回值实际排查的时候我最常用的方法就是在while循环里加打印把每一次循环前的pre和p都打出来。插入逻辑很简单多打两行就立刻看出问题出在定位还是出在改指针。等你多写几次之后这些错误基本就能一次避开了。7. 从这道题延伸出去的三个变体7.1 循环单链表和双链表的插入逻辑循环单链表和普通单链表的最大区别在于尾结点的next不再指向NULL而是指向头结点或者第一个结点看怎么定义。插入算法的整体思路不变唯一变化的是遍历终止条件。普通链表判断到尾的写法是p ! NULL或p-next ! NULL循环单链表则要判断p ! L或p-next ! L。比如用第二版精简写法循环条件就要从p-next ! NULL改成p-next ! L。插入位置、指针修改顺序完全一样。我第一次写循环链表时因为没把终止条件改过来结果在插入尾结点后p又继续往头结点方向绕了一圈差点没发现。双链表则要简单得多每个结点除了next还有一个prior指向前驱。插入时只要先遍历找到第一个值大于等于x的结点q或者是NULL然后拿到它的前驱p q-prior执行四步s-prior p; s-next q; p-next s; q-prior s; // 注意在 q-next NULL 时q不是合法结点要单独判断尾部情况这里最容易出错的是尾部插入。如果q是NULLq-prior是不存在的需要专门处理。有的写法改成先找p-next-data让p作为前驱直接四步都能对这就又回到了我们刚才判断下一个结点的思路。你会发现同一个模式在不同数据结构上反复出现这就是基本功的威力。7.2 用有序插入批量建表的代价如果有一个空链表你要通过InsertSorted函数把n个元素一个一个插入进去构建出一个递增链表总时间复杂度是多少答案是O(n²)。因为每次插入都需要从链表头部开始找位置平均要遍历一半的结点n次插入就是二次方级别。这个结论很重要。不少人初学时会觉得既然链表插入是O(1)的那建表就很快实际上他只看到了改指针那一步忽略了查找位置的开销。链表插入的O(1)是在已经知道插入位置的前提下才成立而有序插入的核心开销恰恰是找位置。如果数据已经有序或排序好批量建有序表应该用尾插法每次直接把新结点挂在tail后面时间是O(n)。如果数据无序又想保证链表有序就要权衡一下是否值得。后面学直接插入排序时你会看到这个过程的复杂分析本质就是手持一个有序表不断把无序序列里的元素插进去所以这道习题的理解程度直接影响你学排序的效率。另外提醒一句不要把这道题的插入逻辑和链表的插入排序混为一谈。插入排序里每个元素要从待排序序列里摘下来插入到已排序区跟这里给一个已经有序的表插入一个单独给的x还是有区别的。先把这道题吃透再去看插入排序会顺畅很多。7.3 内存管理细节补遗代码里用malloc分配了新结点用完链表之后释放是必须做的一件事。有些同学测试程序跑完就直接结束进程了不舍得不释放这在OJ上没问题因为进程退出后操作系统会回收所有内存。但在实际项目或实验报告里不释放内存会积累出一堆内存泄漏。释放时注意循环写法void FreeList(LinkList L) { LNode *p L; while (p ! NULL) { LNode *tmp p; p p-next; free(tmp); } }先拿tmp保存当前结点然后把p移动到下一个结点最后才free当前结点。如果先free了再移动p等于访问了一个已经释放的指针属于未定义行为。我见过有同学写完FreeList后运行到一半就崩就是这个原因。8. 这道题给我留下的三点习惯第一写链表代码之前先画图。哪怕是在草稿纸上画三个方框、两根箭头把插入前后指针的变化画出来都比闷头写强十倍。大多数指针错误画一遍图就能避免。现在我写更复杂的链表题依然会先画图这几乎成了条件反射。第二边界情况永远是链表的大坑。空表、头插、尾插、等值元素这些不是要考虑而是必须当成主干逻辑的一部分来设计。我的经验是写完主功能后立刻把四种边界情况在脑海里或草稿上各跑一遍确认逻辑不出问题才提交。第三这道题虽然小但它把所有链表基本功的要素都装进去了。如果你能一气呵成写出无bug的有序插入代码并且能顺手改出循环链表版本那链表的基本操作这一关就算真正过了。后面不管是做课程设计还是刷力扣再遇到涉及链表的题目心里都有底得多。