资讯动态

单链表能做什么?从LRU缓存到多项式合并的实战应用与指针技巧

发布时间:2026/10/20 2:55:33 来源:尧图企业网站定制
1. 从一个很常见的困惑说起单链表到底能做什么很多人学到“单链表”这一章时会陷入一个尴尬的循环节点定义会写、头插尾插会背、增删改查的代码也能默出来但一旦问“链表有什么用”大脑就一片空白。这不是基础差而是教材把重点放在了“链表是什么”上很少讲“链表能解决什么问题”。所以这篇笔记专门讲单链表的应用我尽量用“场景 代码 踩坑”的方式把从理论到落地之间那段路补上。我自己带过的某个项目组几个同学在实现一个简单的内存缓存时第一反应都是用数组“硬存”等到需要频繁把“最久没用的数据”挪到末尾时才发现数组的插入和删除代价高得离谱。后来把这个功能换成单链表实现代码量反而小了一大截。这个经历很典型——不是链表比数组“高级”而是有些问题的数据操作模式天然更适合用链表来表达。这篇文章适合三类读者正在学数据结构、想在期末或面试前把链表应用串起来的人需要用C语言或类C风格手写链表做过小项目、但总在指针上翻车的人以及想搞明白“LRU缓存”“多项式合并”这些经典例子到底是怎么回事的人。我会把核心操作重新过一遍但重心放在用链表解决实际问题的完整推导过程上。2. 先把“为什么是链表”想清楚它和数组的分工完全不同2.1 数组的舒适区随机访问强但中间操作代价高数组的下标访问是O(1)级别的遍历起来也极其自然因为数据在内存里是连续排布的CPU缓存命中率也好看。但它有两个先天短板第一在中间位置插入或删除一个元素后续所有元素都要整体搬移最坏情况下是O(n)第二连续内存的分配在数据量很大时容易失败扩容又是一个O(n)的拷贝过程。举一个生活化的类比。数组像一排固定间隔的电影院座位你坐在第5排第3座别人一问位置你就知道在哪里但如果你左边的人走了后面所有人都要往左挪一格才能保持“座位连续”。这个挪动成本就是数组插入删除的成本。2.2 单链表的独特优势非连续存储按节点“串”起来单链表是把每个元素放在一个独立的节点里节点之间通过一个指针串联起来。它不再要求物理内存连续插入和删除在“已经定位到位置”的前提下只需要改相邻节点的指针时间复杂度是O(1)。这就像一队人手里互相拉着绳子新来的人想插到队伍中间只需让前一个人把绳子递给他他再拉住后一个人其余人完全不用动。但代价也很明显你想知道第5个节点是谁必须从第一个节点开始顺着next指针一个一个走随机访问是O(n)。另外每个节点还要额外存一个指针字段空间上比数组多出开销。2.3 链表适合的三类问题信号我把判断逻辑整理成一个简单的问题信号列表当你拿到一个需求时可以快速对照。信号数组的代价链表的优势频繁在头部/中间插入、删除O(n)搬移O(1)改指针数据总量不确定甚至可能很大扩容搬迁、连续内存难找按需分配节点需要把元素重新“串成某种顺序”整体搬移麻烦调整指针即可反过来说如果操作主要是按下标读取、按顺序批量扫描、数据量固定且不大那数组通常是更简单高效的选择。链表不是替代数组的“更优解”而是一个适合特定操作模式的“互补解”。3. 把四个核心操作彻底写透建链、遍历、插入、删除3.1 节点定义先想清楚“数据域 指针域”怎么设计在讲应用之前我坚持先花一点时间把最基础的代码重新落地一遍否则后面所有应用场景都是空中楼阁。我用C语言风格来写因为指针的显式操作最能暴露问题也最容易迁移到其他语言。typedef struct Node { int data; // 数据域 struct Node *next; // 指针域指向下一个节点 } Node;这里有一个很值得跟初学者强调的点struct Node *next的类型必须是struct Node *而不是Node *因为在typedef名生效之前编译器还不知道Node是什么。很多报错都出在这个“先有鸡还是先有蛋”的顺序上。3.2 建链与遍历写代码前先画图创建链表有两种常用方式头插法和尾插法。头插法得到的链表顺序和输入顺序相反尾插法则保持原顺序。初学阶段建议先实现“根据数组构建链表”用来验证基本操作。// 尾插法根据数组构建链表 Node* createList(int arr[], int n) { Node *head NULL, *tail NULL; for (int i 0; i n; i) { Node *newNode (Node*)malloc(sizeof(Node)); newNode-data arr[i]; newNode-next NULL; if (head NULL) { head newNode; tail newNode; } else { tail-next newNode; tail newNode; } } return head; }遍历的代码很简单但有个习惯我希望你从第一天就养成永远用一个临时指针cur去走链表不要移动head本身。因为head一旦移动你就永远找不到链表的开头了。这也是“丢头节点”这一类bug最常见的起因。void printList(Node *head) { Node *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); }我见过某个同学在遍历完之后发现链表整体“变短了”排查半天才发现是遍历时直接用head head-next循环结束head已经指到了末尾。这个问题听起来幼稚但在忙乱的时候极其容易犯。3.3 头部插入和尾部插入核心是“先连后断”头插最容易写错的地方是顺序。正确做法是新节点的next先指向原头节点再把head更新为新节点。void insertHead(Node **head, int val) { Node *newNode (Node*)malloc(sizeof(Node)); newNode-data val; newNode-next *head; // 先让新节点指向旧头 *head newNode; // 再让头指针指向新节点 }这里使用了二级指针Node **head原因在于我们要修改调用者手里的头指针本身。如果你只传一级指针函数内部修改的只是头指针的副本外部根本不会变化。这一步是很多人从“能写代码”到“理解指针本质”之间的分水岭。尾部插入需要先找到最后一个节点再让最后一个节点的next指向新节点。这个操作本身不难但要注意“链表为空”的特殊情况此时没有“最后一个节点”必须直接让head指向新节点。3.4 删除节点真正考验指针基本功的地方删除某个节点的本质是让它的前驱节点直接跨过它指向它的后继然后释放它。这要求你必须先找到前驱节点所以单链表的删除天然是O(n)的因为你要从头遍历去定位前驱。void deleteNode(Node **head, int val) { if (*head NULL) return; // 如果要删的恰好是头节点 if ((*head)-data val) { Node *tmp *head; *head (*head)-next; free(tmp); return; } Node *cur *head; while (cur-next ! NULL cur-next-data ! val) { cur cur-next; } if (cur-next ! NULL) { Node *tmp cur-next; cur-next cur-next-next; free(tmp); } }注意这里的循环条件cur-next ! NULL cur-next-data ! val它保证循环结束后要么cur是尾节点说明没找到要么cur-next就是要删的节点。很多初学同学喜欢直接cur cur-next边移动边判断很容易漏掉对前驱的处理写出来的代码在“删除尾节点”时就会崩溃。4. 三个真正“用到链表”的经典场景缓存、多项式、哈希拉链4.1 用单链表实现一个简化版LRU缓存体会“频繁移动节点”的真正场景LRULeast Recently Used是一种缓存淘汰策略当缓存空间满了就把最久没被访问的数据淘汰掉。很多实际系统都用双向链表加哈希表来实现LRU但理解核心思想用单链表就够了还能顺便看清“为什么最终要升级成双向链表”。单链表版本的思路是头部永远保存最近被访问的数据尾部保存最久未被访问的数据。每次访问一个数据就要把它从链表中摘下来再到头部这个过程涉及两步——找到该节点以及它的前驱然后做指针调整。由于单链表找前驱需要遍历整体代价是O(n)但作为教学演示非常直观。// 简化版LRU每次访问某值就把它移到链表头部 void accessLRU(Node **head, int val) { if (*head NULL) return; // 如果头节点就是目标直接返回 if ((*head)-data val) return; Node *cur *head; while (cur-next ! NULL cur-next-data ! val) { cur cur-next; } if (cur-next NULL) return; // 未找到 // 把 cur-next 摘下来 Node *target cur-next; cur-next target-next; // 插入到头部 target-next *head; *head target; }你可以运行一个简单样例去观察链表形态的变化初始1 - 2 - 3 - NULL访问2之后变成2 - 1 - 3 - NULL再访问3变成3 - 2 - 1 - NULL。每一步都是“摘除 头插”两个动作的组合。从工程角度讲为什么真正的高性能LRU要用双向链表因为删除任意一个节点时双向链表可以通过prev指针O(1)拿到前驱而单链表必须从头遍历。这说明一个道理数据结构的选择不是越复杂越好而是要看你的核心操作要求什么代价。这也是我强烈建议大家亲手实现一遍LRU的原因它比背十遍“双向链表适合LRU”都管用。4.2 一元多项式合并教科书级应用的真正意图一元多项式的合并是数据结构书里非常经典的链表应用也是我认为最容易让初学者感受到“链表节点携带业务信息”价值的例子。一个多项式比如3x^5 2x^3 x可以看作一系列“项”的集合每一项包含系数coef和指数exp。用单链表表示时每个节点就是一个项链表的整体顺序按指数从高到低排列。合并两个多项式本质上就是合并两个有序链表只是“比较大小”变成了“比较指数”。typedef struct Term { int coef; // 系数 int exp; // 指数 struct Term *next; } Term;合并逻辑的核心是双指针遍历两个多项式各用一个指针比较当前节点的指数。指数大的项直接加入结果指数相等的项把系数相加后决定是否保留该项其中一个链表走完后把另一个链表剩余部分直接接上。Term* addPoly(Term *p1, Term *p2) { Term *dummy (Term*)malloc(sizeof(Term)); dummy-next NULL; Term *tail dummy; while (p1 ! NULL p2 ! NULL) { if (p1-exp p2-exp) { tail-next p1; tail p1; p1 p1-next; } else if (p1-exp p2-exp) { tail-next p2; tail p2; p2 p2-next; } else { // 指数相等系数相加 int newCoef p1-coef p2-coef; if (newCoef ! 0) { p1-coef newCoef; tail-next p1; tail p1; } p1 p1-next; p2 p2-next; } } // 接上剩余部分 tail-next (p1 ! NULL) ? p1 : p2; return dummy-next; }这里用了一个非常实用的技巧dummy哨兵头节点。结果链表是逐步构建的用dummy作为固定的“假头”最后返回dummy-next可以避免处理“结果链表为空”时的各种特殊情况。这个技巧在链表题目里的出镜率极高后面我还会专门讲。4.3 哈希表拉链法和链式栈队列一张“链表做容器”的大网第2章我讲过链表的优点频繁在头部操作时效率高。基于这个特性很多基础数据结构都可以用链表来当底层容器。哈希表的“拉链法”解决冲突时每个桶后面挂一条链表所有哈希到同一位置的元素都链在这条链表上。查找时先算哈希值定位到桶再在链表上顺序查找。对于负载因子不高的情况链表短查找代价可接受而且插入新元素用头插就行非常快。链式栈则是把栈顶放在链表头部入栈就是头插出栈就是删除头节点所有操作都是O(1)。链式队列则维护头尾两个指针入队走尾插出队走头删。这些例子背后其实是同一种思路链表是一个“便宜的头操作容器”。当你发现某个业务逻辑反复用“把新数据放到最前面”或“把最前面的数据拿走”时就应该条件反射式地想到链表。5. 三个高频问题的逐步推导反转、找中点、判环5.1 反转链表三指针的“接力赛”为什么不会丢节点反转链表是单链表应用里最经典、也最容易被反复问到的题目。我第一次写反转链表时觉得很简单从前往后把每个节点的next指向前一个就行了。但真正动手写才发现难点在于“你改了当前节点的next之后怎么继续往下走”。关键在于在修改某个节点的next之前必须先用另一个指针保存它的下一个节点。这就是三指针prev、cur、next的由来。Node* reverseList(Node *head) { Node *prev NULL; Node *cur head; while (cur ! NULL) { Node *next cur-next; // 先保存后继 cur-next prev; // 反转指针 prev cur; // prev前进 cur next; // cur前进 } return prev; // 最后prev就是新头 }你可以把这三步理解为一个接力赛每次循环prev、cur、next三根指针整体向右挪一个节点而cur的next指针在移动前被反转指向prev。配合画图推演几次这个代码就不会再忘。还有一个递归版本也很值得写它本质上是在回溯时做指针反转但理解门槛比迭代略高我建议先掌握迭代。5.2 找中间节点快慢指针的收敛逻辑如果链表很长你想找中间节点朴素做法是先遍历一遍数出长度n再从头走n/2步。快慢指针的做法更巧妙慢指针每次走一步快指针每次走两步。当快指针到达末尾时慢指针恰好停在中间。Node* findMiddle(Node *head) { Node *slow head, *fast head; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; } return slow; }为什么有效因为快指针的速度是慢指针的两倍快指针走完全程时慢指针就走了全程的一半。这就像两个人在跑道上跑步一个速度是另一个的两倍同时出发当快的人到达终点时慢的人正好在跑道中点。边界条件是链表节点为偶数个时快指针最后指向NULL慢指针会偏向中间偏右的那个节点具体偏哪边要根据题目要求调整循环条件。5.3 判断链表是否有环并找出环入口有环链表指某个节点的next指回了链表前面的节点形成闭环。判断是否有环还是可以用快慢指针如果存在环快指针会先进入环并在某个时刻和慢指针相遇如果无环快指针会先走到NULL。Node* detectCycle(Node *head) { Node *slow head, *fast head; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; if (slow fast) { // 存在环 Node *ptr head; while (ptr ! slow) { ptr ptr-next; slow slow-next; } return ptr; } } return NULL; }这里有一个非常有意思的数学事实当快慢指针第一次相遇时让一个新指针从链表头出发另一个指针继续从相遇点出发两者每次都走一步它们最终相遇的位置就是环的入口。证明过程涉及路程关系但结论可以直接用。我建议你至少手动构造一个1 - 2 - 3 - 4 - 2的环用纸笔推演一遍远比死记结论印象深刻。这三个问题放在一起学是因为它们都指向同一个能力在链表的“隐式顺序”里用多个指针在不同速度下协作。这是链表应用中最有含金量的思维模式。6. 单链表最容易踩的坑以及我的排查经验6.1 空指针和野指针崩溃的两大根源链表的代码崩溃十有八九是“访问了不该访问的节点”。空指针解引用是最典型的你在cur-next上取数据但cur已经是NULL。初学者经常在遍历循环里写while (cur-next ! NULL)一旦链表为空这个判断本身就会解引用空指针。野指针则来自释放之后没有置空。C语音里free(tmp)之后tmp指向的内存已经不属于程序但指针本身还能被访问这就是悬垂指针。安全做法是free(tmp); tmp NULL;并且每次把你的头指针在函数返回前也检查一遍。我在排查链表崩溃时有一个固定流程先打印“当前访问到的节点地址”定位崩溃点在哪一行再回看循环条件里的访问顺序最后用小规模样例3到4个节点逐步走一遍。只要有一个节点数量的最小样例能跑通绝大多数逻辑问题都能暴露出来。6.2 操作顺序错乱导致链表断裂插入和删除的核心风险是用例中的指针调整顺序写反。比如头插时如果先把*head赋给newNode-next之后再改head结果正确但如果先写*head newNode再写newNode-next oldHead那已经拿不到旧头了。链表断裂往往不是“哪里写错了”而是“顺序没按先连后断来”。每次改指针前先画三个节点的草图标清楚哪条线先连、哪条线后断比直接改代码要稳得多。6.3 哨兵节点的正确用法它为什么能省掉一半的边界判断前面多项式合并时我用了dummy节点。这个手法在处理“删除节点”“构建新链表”“合并链表”等场景下极其有效。它的本质是人为制造一个“虚拟头节点”让原本需要特殊处理的“空链表”“删除头节点”等情况都退化为普通情况。我自己在实际编码里的体会是只要一个操作可能修改头节点本身就优先考虑用哨兵节点。比如删除链表中所有值为val的节点如果不用dummy你得先判断头节点是否也要删用dummy之后整个删除过程就统一成“检查cur-next”一种写法代码短、逻辑清晰、边界少。6.4 头节点传递与更新的“二级指针”问题C语言里函数传参是值传递。如果你在函数里对head赋值调用者的head不会变。很多同学问“为什么我插完头节点外面的链表还是原来的头”答案就在这。只要函数内部需要修改头指针本身就必须传二级指针Node **head或者让函数返回新的头指针并重新赋值。对于用Python、Java这类引用语义语言写链表的人来说这个坑不明显但理解指针语义对排查问题依然有帮助。最后分享一点个人的实操体会我自己刚学链表时最大的误区是“刷题式学习”把每个操作都当作独立的代码片段来背。后来真正看懂链表不是因为多刷了多少题而是亲手把一个简化版LRU缓存和一个多项式合并在纸上完整画了三遍。画完才意识到链表所有的操作归根到底就两件事找到要操作的节点以及正确地改变前后节点之间的连接关系。无论是反转、删除、插入还是判环都没有超出这个范畴。如果你现在正卡在链表这个节点上我的建议很简单找一张A4纸画出一个4个节点的链表然后模拟一次尾插、一次删除、一次反转每一步都标注旧指针指向哪里、新指针指向哪里然后再回到代码里对照。这个笨办法比看十篇教程都有效。等你熟练之后再用熟悉的语言把它实现一遍之后无论是LRU缓存、多项式运算还是更复杂的数据结构都会顺畅非常。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑