2026/10/18 6:00:48

链表求倒数第K项:双指针一趟扫描与先数长度两遍遍历全解析

链表求倒数第K项:双指针一趟扫描与先数长度两遍遍历全解析 链式线性表这个叫法放在数据结构教材里很容易让人先紧张一下其实说白了就是最常见的单链表。我在帮人梳理链表题的时候经常用“求倒数第K项”作为试金石这道题看着简单但能把单向链表的约束、边界条件的处理、以及“能不能只遍历一遍”的优化意识全部串起来。尤其标题里提到的两种方法第一种是大部分学生一上来就会写的“先求表长再走两遍”第二种则是用双指针一趟扫描完成的方案。这篇文章就把两种方法的思路、代码、边界陷阱和背后的设计逻辑一次讲透适合正在学数据结构的人、准备相关笔试面试的人以及想把链表操作练扎实的读者。1. 这道题在考什么单向链表“只能往前走”带来的天然难题1.1 链表的结构约束决定了思考边界数组能直接通过下标访问任意元素是因为数据连续存放arr[n-k]一步就能拿到倒数第K个元素。链表不行。链式线性表每个节点只保存数据和指向下一个节点的指针没有任何指向“上一个节点”的信息。想访问某个节点只能从头节点开始沿着next指针一个一个走过去。用代码说就是这样一个结构typedef struct LNode { int data; struct LNode *next; } LNode;换成Python则是class ListNode: def __init__(self, val0, nextNone): self.val val self.next next在没有额外信息的情况下你也不知道链表一共多长。倒数第K项这个东西最尴尬的地方在于它明确要求“从后往前数”但链表本身只能从前往后走。所以解决这道题的核心不是会写遍历而是怎么把“倒数”这个方向问题转化成“正数”能够表达的步数问题。1.2 K的定义和隐藏的边界意识先统一口径。数据结构题里说倒数第K项默认K从1开始K1表示最后一个节点Kn表示第一个节点n是链表长度。这个约定看起来很简单但代码里写起来很容易让人糊涂尤其是当K恰好等于链表长度的时候很多人会在这里多走一步或者少走一步。另外一个严谨的函数在动手找节点之前必须想清楚这几个问题链表为空怎么返回K小于等于0怎么处理K大于链表长度怎么处理很多教材题会说“K合法”也就是默认1 ≤ K ≤ n但实际笔试、面试和工程代码里外部输入永远不可信。别指望调用方一定传对参数函数自己要把边界兜住。这道题之所以经典就是因为它表面上只是遍历实际上逼你去思考链表的方向性、长度未知、边界处理以及在不借助辅助数组的前提下能不能做到更优。出题人真正想看的不只是你会不会写循环而是你有没有“链表只能单向访问”这一层意识。这里顺便提一句有人觉得“开一个数组把所有节点指针存下来然后直接下标访问倒数第K个”也是一种方法逻辑上没错但空间复杂度是O(n)而且完全绕开了链表本身的特性。在笔试里能拿分在面试里大概率会被追问“能不能不用辅助空间”所以它只能作为兜底思路本文不把它当作正式解法展开。2. 方法一先数表长再走两步直观但有两趟遍历的代价2.1 从“倒数”到“正数”的换算逻辑方法一的思路非常直接既然链表长度未知那我先遍历一遍数出节点总数n。数完以后倒数第K个节点其实就是正数第 n-K1 个节点。举个例子链表有6个节点求倒数第2个。倒数第2个是正数第5个从头节点开始走4步就能到。这里一定要想清楚第一个节点是走0步访问到的所以访问正数第 m 个节点需要走 m-1 步。访问正数第 n-K1 个节点就走 n-K 步。这就是整个方法一的全部核心一趟求长度一趟定位。2.2 完整实现与验证以无头结点的单链表为例head直接指向第一个节点。代码写出来长这样LNode *FindLastKth_1(LNode *head, int k) { if (head NULL || k 0) { return NULL; } // 第一趟数出链表长度 int n 0; LNode *p head; while (p ! NULL) { n; p p-next; } // 长度都不够直接返回空 if (k n) { return NULL; } // 第二趟从头走 n-k 步 int steps n - k; p head; while (steps 0) { p p-next; steps--; } return p; }测试的时候最好把边界都跑一遍。比如链表只有1个节点求k1此时n1n-k0直接返回head正确。链表有5个节点求k5n5n-k0返回第一个节点也就是倒数第5个正确。链表有5个节点求k6kn返回NULL正确。2.3 方法一的真正短板从复杂度的数量级看方法一时间是O(n)空间是O(1)单独拎出来并没有问题。但注意一个细节它需要完整遍历链表两次。第一次数长度第二次定位。在一些笔试场景里这种写法能拿基本分但要命的是很多面试官看到以后会追问一句“能不能只遍历一遍”如果你没想过双指针方案现场容易卡住。更要命的是应用场景。假设链表数据不是存在内存里而是从一个只能读取一次的输入流里构建的比如从网络流、传感器数据流里不断追加节点。你想求倒数第K项用方法一先从头跑一遍数长度跑完之后流已经没数据了你想重放一遍做不到。所以“两趟遍历”不只是常数因子的问题而是某些场景下直接不可用。我第一次给人讲这道题的时候对方觉得方法一也没毛病毕竟复杂度一样。但后来我让他想一个场景不停接收数据形成一个链表每来一个数据节点就问你当前倒数第K个是谁。他想了半天承认方法一确实不行。这个点就是方法二存在的意义。3. 方法二双指针错位前进一趟扫描锁定目标3.1 双指针的直觉让一个指针永远落后K步方法二的思路可以这样理解找两个指针都先指向头节点。第一个指针p先往前走K步。走完之后p比第二个指针q提前了正好K个身位。接着p和q同步往前走每轮各走一步。当p走到链表结尾的时候它已经走完了完整链表。因为q从头开始就一直落后p正好K步所以此刻q距离链表末尾也是K步。换句话来说q就是倒数第K个节点。这个感觉很像跑步两个人同速跑A先跑K米B再起跑。A到达终点时B离终点正好还差K米。你要找的“倒数第K个节点”就是那个离终点还差K米的B。3.2 代码实现与每一步的边界检查推荐使用下面这种“先让p走K步边走边检查是否为空”的写法LNode *FindLastKth_2(LNode *head, int k) { if (head NULL || k 0) { return NULL; } LNode *p head; LNode *q head; // 让 p 先走 k 步每走一步之前都判断是否为空 for (int i 0; i k; i) { if (p NULL) { // 链表长度不足 k直接返回空 return NULL; } p p-next; } // p 和 q 同步前进直到 p 走到链表末尾的 NULL while (p ! NULL) { p p-next; q q-next; } return q; }注意for循环里的关键点每次都是“先检查p是否为NULL再执行pp-next”。这个顺序非常重要。如果把判断放在移动之后当p已经是NULL时你还继续移动就会发生空指针解引用。新手最容易在这里翻车。还有一种等价的写法是让p先走K-1步然后判断p-next ! NULL作为循环条件。这种写法在K0的时候会出问题而且当链表长度不足K时容易出现访问NULL-next的危险。除非你额外做一堆保护判断否则不如上面这种“先走K步再同步”的写法清晰。我的习惯是认准一种安全写法不要在现场临时换。3.3 步数证明和一个具体演示为什么这个代码正确可以从步数上严格推一遍。假设链表长度为n。p最开始指向第1个节点先走K步后p指向第K1个节点。如果K等于n此时p已经是NULL。接下来p需要再走n-K步才能到达NULL。在p走这n-K步的同时q也从第1个节点同步走了n-K步最终q指向第n-K1个节点。从尾部数第 K 个节点的正数位置就是 n-K1因为倒数第K个节点距离末尾有K-1个节点的间隔它的正序号 n - K 1。q正好落在那里证明完毕。光看证明有点干我拿一个具体链表走一遍。假设链表是 1 - 2 - 3 - 4 - 5求倒数第2个正确答案应该是节点4。初始化p指向1q指向1。第一步p先走第1步p指向2p先走第2步检查非空p指向3。这时q还在1。开始同步p指向4q指向2再同步p指向5q指向3再同步p指向NULLq指向4循环结束返回q指向的节点4。整个过程里两个指针一共移动了大约2n次但所有移动都发生在同一次遍历过程中而不是像方法一那样“数完长度以后再从头走一遍”。这就在逻辑上做到了只遍历一趟。Python版本的实现逻辑一模一样只是语法更简洁def find_last_kth(head, k): if not head or k 0: return None p q head for _ in range(k): if p is None: return None p p.next while p: p p.next q q.next return q很多同学看Python版本觉得思路很顺但换到C语言就卡住。其实核心就一句话先让一个指针领先K步再带着另一个指针一起走到终点。想清楚这个画面语言只是外壳。4. 两种方法硬碰硬复杂度、边界与应用场景4.1 复杂度与使用感受对比两个方法放在一起对比可以看得更清楚对比维度方法一先求表长再定位方法二双指针错位扫描遍历次数完整遍历两趟一趟扫描完成时间复杂度O(n)O(n)空间复杂度O(1)O(1)链表长度不足K时的发现时机第一趟结束才知道第二趟还没开始走K步的过程中就能发现数据只能读取一遍的场景不可用可用对链表方向性理解的体现一般较强代码风险点主要是步数换算主要是先判空再移动注意两种方法的复杂度都是O(n)所以“第二种较好”并不是说数量级上碾压而是它的适用面和思维层次更好。方法一把“不知道长度”的问题用“先数一遍”解决方法二则完全绕开了长度这个概念用位置差直接命中的方式解决问题。4.2 边界情况的处理必须统一口径写链表题时最大的坑往往不是主流程而是边界。我把几个典型情况分别用两种方法跑了一遍结果如下场景方法一方法二空链表head为NULL直接返回NULL同样先判空返回NULLk0或k0直接返回NULL直接返回NULLk等于链表长度n返回第一个节点返回第一个节点k1返回最后一个节点返回最后一个节点kn第一趟数完长度后返回NULLp走K步时发现提前碰到NULL返回NULL两种方法在边界上的判断结果一致但发现时机不同。方法二要等到“p往前走K步”的这个循环里才意识到长度不够方法一则必须先把整个链表数完才能判断。在数据量很大的时候方法二能更早放弃省掉一些无谓的遍历。4.3 为什么“第二种较好”不是营销话术我在不同场合说过很多次双指针方案最漂亮的地方是它把“倒数”这个方向概念翻译成了“错位”这个位置概念。这个翻译不依赖链表长度所以天然适合那些只知道往尾部追加、不知道当前总长度的场景。举一个实际一点的例子。假设你正在处理一个实时上报数据的系统数据不断从某个接口涌进来每个数据到达后挂到链表尾部同时你需要快速回答“当前这批数据里倒数第K个是多少”。如果用方法一每来一个数据你就得从头数一遍长度代价很高。用方法二只要维护好两个指针的错位关系每个新数据到达时让两个指针同步推进就能O(1)时间得到答案。当然工程上处理类似问题会有更成熟的结构比如用固定长度的环形缓冲区直接存最近K个数据。但作为数据结构基本功双指针的思路能让你在只有链表结构、没有额外存储条件的情况下依然高效解决“倒数”问题。这种思维迁移能力才是它优于方法一的本质原因。需要提醒的是有些讨论会纠结“方法二到底算不算空间换时间”。不算。双指针只额外增加了一个指针变量空间复杂度依然是O(1)。它和“开数组存所有指针再随机访问”有本质区别一个是常数空间一个随数据规模增长。5. 从这道题延伸出去的三个高频变体5.1 找链表的中间节点链表题里有一类和“倒数第K项”强相关就是找中间节点。思路也是双指针只是这次不是让两个指针相差K步而是让快指针每次走2步慢指针每次走1步。当快指针到达链表末尾的时候慢指针正好落在中间位置。如果链表节点个数是奇数慢指针落在正中间如果是偶数慢指针落在中间偏右的那一个。这个结论不需要先数长度靠的依然是“速度快慢形成位置差”的思想。代码大致是这样LNode *FindMiddle(LNode *head) { if (head NULL) return NULL; LNode *slow head; LNode *fast head; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; } return slow; }注意循环条件里fast ! NULL fast-next ! NULL的判断顺序必须保持否则偶数长度的链表会访问空指针。这里和“倒数第K项”里“先判空再移动”的原则是完全一致的。5.2 判断链表是否有环另一个经典变体是判断链表是否成环。快指针每次走2步慢指针每次走1步。如果链表里有环快指针迟早会追上慢指针如果无环快指针会先走到NULL。这个思路的原理是进入环以后快指针相对慢指针每次前进1步两者的间距会逐渐缩短最终必然相遇。它不需要知道环的长度也不需要额外的哈希表和双指针找倒数第K项是一套底层思维。5.3 删除倒数第N个节点如果题目从“找”变成“删除倒数第N个节点”思路需要稍微调整一下你真正要找的是倒数第N个节点的前一个节点因为删除操作改的是前驱的next指针。做法是把双指针的错位距离变成N1步让慢指针最终停在待删节点的前驱位置。然后执行slow-next slow-next-next完成删除。这里有一个非常容易踩的坑如果链表只有一个节点你要删除的就是头节点本身这时待删节点没有前驱。解决方案通常是引入一个虚拟头节点dummy让dummy指向head然后从dummy开始走双指针。这样就算删的是头节点也能通过统一逻辑处理不用额外写分支。这个坑和方法一里“正数第m个节点需要走m-1步”是同一类型的细节问题。链表题的边界永远在动手写代码之前就要想清楚而不是跑到报错再去补。我自己在实际练习中有一个很深的体会双指针这类“错位”思想一旦真正理解看链表题会顺畅很多。什么倒数第K个、中间节点、环检测、删除倒数第N个本质上都是“让某个指针提前出发然后两个指针同步移动”的变体。建议初学者不要只背代码而是拿一张纸把指针每一步的位置画出来画几遍就通了。最后再分享一个小技巧链表函数写完以后一定用空链表、单节点链表、K等于1和K等于链表长度这几个极端情况手动跑一遍二十秒的时间能帮你躲掉大半运行时错误。