开头部分: 链式线性表的倒数第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-K+1个节点。比如L是5,K是2,那倒数第2个节点就是正向第4个节点。
于是解法分成两步:
- 从头节点开始,遍历整个链表,统计节点个数,得到L。
- 从第一个节点开始,走L-K步,所在节点就是目标节点。
为什么是L-K步而不是L-K+1?注意一下,从头节点出发,走0步到达的是第1个节点,走1步到达第2个节点,那么到达第L-K+1个节点,需要走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, val=0, next=None): 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步到达位置K+1。如果链表总长度为L,快指针走了K步后还剩下L-(K+1)步要走。慢指针和快指针同步走完这段路,慢指针一共走了L-(K+1)步,其位置为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 -> E,K=2。fast先走2步,从A到C,此时fast在C。然后fast和slow同步移动,fast从C走到D、再走到E、再从E走到NULL,一共走了3步。slow同时从A走3步,从A到D。D正是倒数第2个节点,结果正确。
如果K=1呢?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大于链表长度。这种情况不管怎么处理,都没有正确的节点可以返回。方法一需要先算出链表长度才能判断;方法二可以在快指针先走的过程中实时判断,这是双指针一个很大的优势。
第四,链表只有一个节点,且K=1。这种情况很容易被测试用例漏掉。K=1时,方法一中len=1,steps=0,直接返回head。方法二中fast先走1步,从唯一节点走到NULL,然后第二个循环不执行,slow指向唯一节点,结果正确。注意如果代码里对slow的初始位置或者fast的边界判断稍有偏差,这个用例就会炸。
第五,链表只有一个节点,且K>1,比如K=2。此时方法一中len=1,k>len返回空;方法二中fast先走第1步到NULL,然后继续走K的剩余步,发现fast为NULL,返回空。这里能看出双指针方法在循环中判断空指针的好处。
第六,带头节点和不带头节点的差异。有的练习环境里链表带有一个不存数据的头节点(dummy head),真正的数据从第二个节点开始。此时快指针和慢指针的初始位置需要相应调整。要么从head->next开始,要么在后续的步数计算中把头节点的空位置也当作一个节点。我的建议是:写代码之前先明确约定,然后保持所有函数处理方式一致,不要混用。
4.2 测试用例设计:用一张表覆盖所有场景
在本地我建议直接构造一个小型测试程序,把下面这些用例逐一跑过。这里我用表格来归纳,方便你对照。
| 测试场景 | 链表内容 | K值 | 期望结果 | 方法一是否通过 | 方法二是否通过 |
|---|---|---|---|---|---|
| 空链表 | NULL | 1 | NULL | 通过 | 通过 |
| 单节点,K=1 | 1 | 1 | 节点1 | 通过 | 通过 |
| 单节点,K>1 | 1 | 2 | NULL | 通过 | 通过 |
| 正常场景,K=1 | 1->2->3 | 1 | 节点3 | 通过 | 通过 |
| 正常场景,K等于长度 | 1->2->3 | 3 | 节点1 | 通过 | 通过 |
| 正常场景,K在中间 | 1->2->3->4->5 | 2 | 节点4 | 通过 | 通过 |
| K=0 | 1->2->3 | 0 | NULL | 通过 | 通过 |
| K为负数 | 1->2->3 | -1 | NULL | 通过 | 通过 |
| K大于长度 | 1->2->3 | 5 | NULL | 通过 | 通过 |
我建议你把自己写的代码放到一个可运行的项目里,用上面这些用例测一遍。每一条边界用例背后都对应一个容易犯的错:K=0会绕过很多人的判断逻辑,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并没有变成NULL,slow停在某个节点上。如果链表长度为L,fast先走了K步后指向第K+1个节点,那么同步移动时,慢指针停的位置会变成倒数第K+1个节点,而不是倒数第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步时中途变空”的判断;但你把链表画出来,让快指针走一遍,马上就发现走到第K+1步时fast就变成了NULL,该返回空而不是继续操作。这种直觉不是天生的,就是靠画图模拟练出来的。
最后再给一个非常实用的小技巧:代码里所有返回“找不到”的地方,统一返回NULL或者None,调用方也要先判空再使用结果。这个习惯能防止你把错误值当作有效节点继续运算,也可以让整个函数的接口契约更清晰:找到了返回节点指针,找不到就返回空,由调用方决定怎么处理。
我在实际项目里处理缓冲区节点、日志链表这类结构时,经常会用到这种“一次遍历找倒数第K项”的需求。双指针方案不只在理论上体现了复杂度优势,在真实场景里也确实更稳妥。如果你现在正在复习链表这块内容,我建议你把两种方法都亲手写一遍,再对照本文末尾的测试用例验证一遍。只有每一步的指针指向、步数计算、空指针判断都形成肌肉记忆,以后遇到相关变形题才不会慌。