资讯动态

单链表倒数第K项:双指针一次遍历与边界处理详解

发布时间:2026/10/18 14:28:13 来源:尧图企业网站定制
开头部分 链式线性表的倒数第K项这个问题我估计凡是做过数据结构作业或者准备过技术面试的人都见过。它看起来特别简单给你一个单链表让你找出从尾部数第K个节点。但真动手写的时候你会发现边界条件、空指针、节点计数的细节一堆稍不注意就写出一个“好像对但运行起来在某些输入下直接崩掉”的版本。而且这个题目特别能检验一个人对单链表单向遍历特性的理解程度——数组里想找倒数第几个直接索引下标算一下就完了单链表没有随机访问能力所有操作都得靠指针一个节点一个节点地走怎么走得聪明这就是问题本身想考察的东西。我见过不少人的第一反应是“先遍历一遍算出链表长度再从头走一遍不就拿到目标节点了吗”这确实是最直觉的解法也是很多教材里最先给出的答案。但从效率角度讲这种两次遍历的做法在数据规模变大、或者数据只能读一次的场景下就有点不够看了。所以更好的做法是用双指针让两个指针之间始终保持K个节点的距离一趟遍历走完就能锁定倒数第K项。本文就把这两种方法展开讲讲包括它们各自对应的代码写法、复杂度分析、容易踩的坑以及为什么在工程上我更推荐第二种。这篇文章适合正在学习数据结构与算法的同学、准备技术面试的求职者也适合那些基础不太牢、想借这个经典题把链表操作梳理一遍的开发者。我会用C语言和Python各写一份可运行的核心代码再补一组测试用例的设计思路保证你读完能直接复现并且能理解每一步操作背后的理由。1. 问题定义与为什么值得认真对待1.1 题目到底在问什么先把这个题目的具体要求说清楚。假设我们有一个单向链表每个节点包含一个数据域和一个指向下一个节点的指针链表最后一个节点的指针域是空。所谓“倒数第K项”指的是从最后一个节点开始往前数数到第K个节点。这里有一个前提约定必须讲清楚倒数第1项就是最后一个节点本身倒数第K项就是距离最后一个节点K-1个位置的节点。举个例子链表是A - B - C - D - E那么倒数第1项是E倒数第2项是D倒数第3项是C倒数第5项是A也就是首个节点。在这个例子里K的合法范围是1到链表长度L。如果K小于等于0或者K大于L问题本身就不成立了。很多讨论都把注意力放在“怎么找”上却忽略了“输入不合法时怎么办”这恰恰是写代码时最容易出问题的地方。另外要提醒一点这里说的是单链表。单链表意味着你只能通过每个节点的next指针从头到尾访问没有办法从尾往前回退。如果是双向链表这个问题就变简单了——直接维护一个尾指针从尾节点往回走K-1步就行。正因为单链表有这种单向性约束才需要动点脑筋去设计遍历策略。1.2 为什么这个问题比看起来更有价值一个数据结构题目能反复出现往往不只是因为它本身难而是它代表了一类更广泛的场景。这个题目背后映射的其实是“在单次遍历中解决需要位置关系的问题”这一类的算法思想。想象一个实际场景某个系统运行时会源源不断地产生日志每条日志写到一个链表的末尾。你想查看最近产生的倒数第K条日志但整个链表可能非常长你不希望每次都先把整张链表遍历一遍数出总长度再从头遍历一遍去找目标节点。更极端一点如果数据源是流式的意味着你只能从头到尾扫描一次根本没有第二次遍历的机会。在这种场景下双指针的方法几乎是唯一合理的选择。从面试角度来看这个题目的考察点也很集中第一面试官想看你有没有真正理解链表的结构而不是背模板第二看你能不能写出健壮性良好的代码能考虑到空链表、K值非法这类情况第三看你有没有一点点复杂度优化意识——同样是O(n)能不能把常数级的损耗也降下来。这些都是实际工程中非常基础但重要的能力。2. 第一种方法先求表长再正向定位2.1 思路与实现逻辑第一种方法非常直白既然单链表不能从后往前走那我就先知道长度然后把“倒数第K项”换算成一个正向的位置。如果链表长度是L倒数第K个节点其实就是正向的第L-K1个节点。比如L是5K是2那倒数第2个节点就是正向第4个节点。于是解法分成两步从头节点开始遍历整个链表统计节点个数得到L。从第一个节点开始走L-K步所在节点就是目标节点。为什么是L-K步而不是L-K1注意一下从头节点出发走0步到达的是第1个节点走1步到达第2个节点那么到达第L-K1个节点需要走L-K步。这里的索引和步数很容易出错误我自己第一次手写时就在这个位置上卡了一下结论是“步数 目标位置 - 1”。下面给出用C语言实现的代码采用不带头节点的方式也就是head直接指向第一个有效节点#include stdio.h #include stdlib.h struct ListNode { int val; struct ListNode *next; }; struct ListNode* findLastKth_1(struct ListNode* head, int k) { if (head NULL || k 0) { return NULL; } int len 0; struct ListNode* p head; while (p ! NULL) { len; p p-next; } if (k len) { return NULL; } int steps len - k; p head; while (steps 0) { p p-next; steps--; } return p; }这里第一个防御点就出现了如果链表为空或者K值本身不合法直接返回空指针。第二步再次判断K是否大于链表长度防止后面的循环越过链表末尾。注意这个逻辑要求链表长度L和K都必须大于0如果K等于0意味着找“倒数第0项”没有定义直接拒绝。用Python写的话逻辑完全一致class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def find_last_kth_1(head: ListNode, k: int) - ListNode: if not head or k 0: return None length 0 p head while p: length 1 p p.next if k length: return None steps length - k p head for _ in range(steps): p p.next return p整体实现很简单几乎没有优化的空间但它体现了一个很重要的事实在单链表场景下如果你想知道一个节点距离表头有多远唯一的办法就是从头走一遍。2.2 第一次遍历的隐藏成本与适用场景先求表长的方法在复杂度分析上看起来并不差时间复杂度是O(n)因为无非是把链表遍历了两遍2n次访问仍然是n的线性阶空间复杂度是O(1)只用了几个指针变量。这也是很多人觉得“这方法没问题啊”的原因。但如果深入一层思考两次遍历的代价并不像复杂度公式里显示的那么无关痛痒。试想一个链表特别长比如有上千万个节点并且这些节点不是缓存在内存里而是分散存储在某些外部介质上遍历一次意味着一次完整的数据访问。第一次遍历算出长度第二次又重新从第一个节点走一遍总共消耗了两次完整扫描的带宽。如果这个操作需要频繁执行累计开销就很可观。再考虑一种情况这个链表不是静态的而是不断在尾部增加新节点的日志链表。如果你在第一次遍历拿到长度L之后就有人追加了新节点然后你用原来的L去计算步数找到的节点可能已经不是真正意义上的倒数第K项了。虽然这属于并发修改的问题两种方案都会遇到但至少提示我们能少遍历一次就少一次在很多真实场景里都有实打实的好处。不过也必须承认这种方法在“好写”这一点上非常有优势。作为初学链表操作的人先通过简单方案理解清楚节点计数、指针移动这些基础概念是有价值的。如果目标是在几分钟内实现一个可用的函数对性能没有苛求那么这种两次遍历的做法是完全可以接受的。它唯一的硬伤是“必须能从头遍历两次”这个前提在只允许一次扫描的数据流场景中会直接失效。3. 第二种方法双指针一次遍历3.1 核心思想与两个指针的协作第二种方法要求在遍历过程中就确定目标节点的位置核心思路是维护两个指针一个快指针一个慢指针。开始时都指向链表的第一个节点。先让快指针向前走K步这一步走完后快指针和慢指针之间恰好间隔K个节点。然后两个指针以同样的速度同步向后移动直到快指针走到链表末尾的空位置也就是NULL。此时慢指针所在的位置就是倒数第K个节点。这个思路为什么能成立关键在于“间隔保持”。想象一下快指针领先慢指针K个节点同步前进时这个间隔始终不变。当快指针到达链表的尾部时从尾部往回看慢指针恰好是在它前面K个节点处而“倒数第K个结点”的定义正是“距离末尾节点K-1步的那个节点”。你可以在纸上画一条长度为5的链表让快指针先走2步然后两个指针同步走最后观察慢指针停在哪里会非常直观。这个方法有一个非常重要的优势整个链表只被完整遍历了一次。因为快指针走到NULL时它本身就已经走完了链表的全部节点而慢指针在这个过程中只走了一部分。总的访问次数大约是2n但其实快指针的路径是n步慢指针的路径是(n-K)步合计约2n-K和两次遍历的方案在常数上有区别但最关键的是“从头到尾只有一次完整扫描”。这里有一个典型误区我必须指出有人会以为“快指针先走K步”意味着快指针要先到达第K个节点然后在快指针到达NULL时慢指针就是从尾部数的第K个节点。实际情况是快指针先走的K步必须包含从第一个节点到第K个节点这一段。如果我们把链表的第一个节点位置记为位置1那么快指针走1步到达位置2走K步到达位置K1。如果链表总长度为L快指针走了K步后还剩下L-(K1)步要走。慢指针和快指针同步走完这段路慢指针一共走了L-(K1)步其位置为1 (L-K-1) L-K。这个位置对应倒数第K个节点。这个推演看着啰嗦但只有自己推一遍写代码时心里才有底。3.2 代码实现与关键边界判断用C语言实现双指针法我写的是无头节点版本也就是头指针直接指向第一个有效节点struct ListNode* findLastKth_2(struct ListNode* head, int k) { if (head NULL || k 0) { return NULL; } struct ListNode* fast head; struct ListNode* slow head; // 快指针先走k步 // 在走的过程要判断是否提前到达尾部防止k大于链表长度 while (k 0) { if (fast NULL) { return NULL; // 说明k大于链表长度 } fast fast-next; k--; } // 双指针同步前进 while (fast ! NULL) { fast fast-next; slow slow-next; } return slow; }这段代码里最关键的是第一个while循环中的判断。为什么要在循环里面判断fast是否为NULL而不是先让fast走K步再判断因为如果K大于链表长度比如链表只有3个节点却要求倒数第5项那么快指针走第4步时fast就已经变成NULL了。如果不在一开始就判断而是盲目移动接下来访问fast-next就会触发空指针异常。很多人写这道题时第一次出bug就是栽在这里。Python版本如下def find_last_kth_2(head: ListNode, k: int) - ListNode: if not head or k 0: return None fast head slow head # 快指针先走k步过程中判断是否存在 while k 0: if fast is None: return None fast fast.next k - 1 # 同步前进 while fast is not None: fast fast.next slow slow.next return slow测试一下几个场景链表为A - B - C - D - EK2。fast先走2步从A到C此时fast在C。然后fast和slow同步移动fast从C走到D、再走到E、再从E走到NULL一共走了3步。slow同时从A走3步从A到D。D正是倒数第2个节点结果正确。如果K1呢fast先走1步从A到B然后同步移动fast从B到C、D、E、NULL共4步slow从A走4步到E。E是倒数第1项结果正确。如果K等于链表长度5呢fast先走5步从A到NULL此时已经到尾部第二个循环直接不执行slow还停留在A而A正好是倒数第5项。这也是一个很优雅的边界K等于长度时快指针走完链表直接到空慢指针一步都不用走。3.3 为什么这种方法更好复杂度与工程直觉两种方法的时间复杂度都是O(n)空间复杂度都是O(1)。单从大O记法上讲二者没有区别。但我在实际使用中更推荐双指针理由有三点。第一遍历次数少。双指针方案中慢指针和快指针的总移动次数大约是2n-k而第一种方法是2n。单看常数差别不大但双指针从整个遍历过程看每个节点被访问的次数仍然是常数而且不存在“先完整扫一遍再从头扫第二遍”的问题。在链表位于外存、或者数据只能读取一次的场景里这个特性是决定性的。第二代码的健壮性更自然。双指针法在判断K值是否大于链表长度时其实是在“走”的过程中自然发现的不需要提前知道长度。这意味着它能天然适配“流式读取”的场景你一边从某个数据源拉取节点一边让快指针先跑等快指针跑到尽头时慢指针的位置就是答案。第三这种双指针思想具有很强的扩展性。很多链表问题本质上都是“让一个指针先走若干步”或者“让两个指针以不同速度前进”的问题。比如判断链表是否有环用快慢指针一个走一步一个走两步找链表中点也可以让快指针走两步、慢指针走一步。掌握了这个思路遇到类似的题目你能很快类比过去而不是每次从零开始想。这也正是题目描述里说“第二种较好”的原因。并不是第一种方法错了而是第二种方法在面对更广义的场景时明显更有优势。4. 防错与测试边界条件与代码细节4.1 所有可能炸掉的情况无论用哪种方法都有几类输入需要特别小心。我见过太多人在面试或在线评测时提交的代码“看起来能跑”但一遇到这些边界用例就暴露问题。第一空链表。head为空根本不存在任何节点。两种方法都必须在入口处直接返回空。第二K小于等于0。如果K传进来是0、-1这明显是非法参数。有的实现者会忽视这一点导致快指针先走了0步或者一个负数的步数然后慢指针指向第一个节点返回错误结果。防御式编程的原则是在入口处就拦截非法输入不要等到后续代码逻辑被破坏才暴露。第三K大于链表长度。这种情况不管怎么处理都没有正确的节点可以返回。方法一需要先算出链表长度才能判断方法二可以在快指针先走的过程中实时判断这是双指针一个很大的优势。第四链表只有一个节点且K1。这种情况很容易被测试用例漏掉。K1时方法一中len1steps0直接返回head。方法二中fast先走1步从唯一节点走到NULL然后第二个循环不执行slow指向唯一节点结果正确。注意如果代码里对slow的初始位置或者fast的边界判断稍有偏差这个用例就会炸。第五链表只有一个节点且K1比如K2。此时方法一中len1klen返回空方法二中fast先走第1步到NULL然后继续走K的剩余步发现fast为NULL返回空。这里能看出双指针方法在循环中判断空指针的好处。第六带头节点和不带头节点的差异。有的练习环境里链表带有一个不存数据的头节点dummy head真正的数据从第二个节点开始。此时快指针和慢指针的初始位置需要相应调整。要么从head-next开始要么在后续的步数计算中把头节点的空位置也当作一个节点。我的建议是写代码之前先明确约定然后保持所有函数处理方式一致不要混用。4.2 测试用例设计用一张表覆盖所有场景在本地我建议直接构造一个小型测试程序把下面这些用例逐一跑过。这里我用表格来归纳方便你对照。测试场景链表内容K值期望结果方法一是否通过方法二是否通过空链表NULL1NULL通过通过单节点K111节点1通过通过单节点K112NULL通过通过正常场景K11-2-31节点3通过通过正常场景K等于长度1-2-33节点1通过通过正常场景K在中间1-2-3-4-52节点4通过通过K01-2-30NULL通过通过K为负数1-2-3-1NULL通过通过K大于长度1-2-35NULL通过通过我建议你把自己写的代码放到一个可运行的项目里用上面这些用例测一遍。每一条边界用例背后都对应一个容易犯的错K0会绕过很多人的判断逻辑K大于长度会暴露空指针访问空链表会让所有指针操作失效。把这张表跑绿了你的实现才算真正过关。4.3 一个容易被忽视的坑指针更新的顺序关于指针的更新顺序我想单独提一句。在双指针同步移动时下面的代码是一对一对应的fast fast-next; slow slow-next;这个顺序看似无害但如果你先更新slow再更新fast逻辑上也没有问题因为它们互不影响。真正的问题出在“是否先判断再更新”上面。如果你写成这样while (fast-next ! NULL) { fast fast-next; slow slow-next; }那么当fast是链表的最后一个节点时fast-next是NULL循环条件为假直接退出。此时fast并没有变成NULLslow停在某个节点上。如果链表长度为Lfast先走了K步后指向第K1个节点那么同步移动时慢指针停的位置会变成倒数第K1个节点而不是倒数第K个节点。差了一个位置。这是我调试时发现的非常典型的偏差根源就在于对“循环结束条件”的理解不一致。要避免这个坑建议统一用“fast先走K步fast等于NULL时停止”这个版本因为NULL意味着快指针已经越过了链表的末尾此时慢指针位置精确对应倒数K项。如果不太确定就用“快指针走K步然后fast!NULL作为循环条件”这个固定写法连续多跑几个K值验证一遍。5. 从这道题延伸出去一次遍历的思维模式5.1 双指针思想的经典应用这道题并不是孤立的一道题。理解了双指针维持距离的思路你会发现它能直接迁移到好几类常见的链表问题上。第一个是“删除倒数第N个节点”。LeetCode上有一道经典题要求删除链表的倒数第N个节点并且最好只扫描一次。做法是让快指针先走N步然后慢指针和快指针同步走当快指针到NULL时慢指针正好在倒数第N个节点上。此时因为我们想删除它所以需要让慢指针的前一个节点指向它的下一个节点。一个常见的技巧是加一个dummy头节点让慢指针从dummy位置开始走这样当快指针到达NULL时慢指针停在待删除节点的前驱节点上可以直接执行删除操作。第二个是“寻找链表的中点”。用快慢两个指针快指针每次走两步慢指针每次走一步当快指针到达链表的尾部时慢指针正好在链表中间。如果链表长度为偶数慢指针会指向前半部分的最后一个节点还是后半部分的第一个节点取决于循环终止条件的设计但思路一致。第三个是“判断链表是否有环”。让快指针每次走两步慢指针每次走一步如果链表存在环那么快指针一定会在环里追上慢指针如果不存在环快指针会先一步到达NULL。这个“一快一慢”的步长差异和“先走K步再同步”的思想本质上是同一套双指针工具的不同用法。所以这道题值得认真掌握不只是为了应付作业或者面试而是因为它能帮你建立起一种“通过指针之间的相对位置信息在不进行二次扫描的前提下解决问题”的思维习惯。5.2 实战中的一点经验先画图再写代码最后分享一个我自己的习惯凡是遇到链表操作不要一上来就开敲代码。拿一张纸画出一条链表把要用的指针都标出来手动模拟几步然后再写实现。这个过程花不了两三分钟但能帮你避开很多低级的边界错误。举个例子双指针法如果你对着空空的编辑器硬想很容易漏掉“快指针走K步时中途变空”的判断但你把链表画出来让快指针走一遍马上就发现走到第K1步时fast就变成了NULL该返回空而不是继续操作。这种直觉不是天生的就是靠画图模拟练出来的。最后再给一个非常实用的小技巧代码里所有返回“找不到”的地方统一返回NULL或者None调用方也要先判空再使用结果。这个习惯能防止你把错误值当作有效节点继续运算也可以让整个函数的接口契约更清晰找到了返回节点指针找不到就返回空由调用方决定怎么处理。我在实际项目里处理缓冲区节点、日志链表这类结构时经常会用到这种“一次遍历找倒数第K项”的需求。双指针方案不只在理论上体现了复杂度优势在真实场景里也确实更稳妥。如果你现在正在复习链表这块内容我建议你把两种方法都亲手写一遍再对照本文末尾的测试用例验证一遍。只有每一步的指针指向、步数计算、空指针判断都形成肌肉记忆以后遇到相关变形题才不会慌。

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

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

免费获取报价 →
↑