实现代码
# 返回链表的倒数第 k 个节点
def findFromEnd(head: ListNode, k: int) -> ListNode: 定义一个寻找函数,规定了head的类型以及k的类型 -->指函数最终的输出结果是节点
p1 = head 这里将p1指针放在head的位置
# p1 先走 k 步
for i in range(k): 利用for循环让p1移动K步
p1 = p1.next
p2 = head 与P1同理
# p1 和 p2 同时走 n - k 步
while p1 != None:
p2 = p2.next
p1 = p1.next
# p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2
# 主函数
class Solution:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
# 虚拟头结点
dummy = ListNode(-1) 建造一个虚拟头结点并且确定其位置,为什么要建造呢?刚才查找节点是为什么不建造呢?因为这里涉及到删除倒数第K个节点,那么我们就要对倒数第K+1个节点进行处理。因为倒数k+1个节点是删除第K个节点的关键。
dummy.next = head
# 删除倒数第 n 个,要先找倒数第 n + 1 个节点
x = self.findFromEnd(dummy, n + 1) 那这里这个删除函数为什么又虚拟节点呢?是为了处理删除头节点的特殊情况
# 删掉倒数第 n 个节点
x.next = x.next.next
return dummy.next
def findFromEnd(self, head: ListNode, k: int) -> ListNode:
# 快指针先向前移动 k 步
p1 = head
for _ in range(k):
p1 = p1.next
# 慢指针从链表头部开始,与快指针同时移动
p2 = head
while p1 is not None:
p1 = p1.next
p2 = p2.next
# 此时慢指针指向倒数第 k 个节点
return p2
删除操作的特殊性:
在删除倒数第 n 个节点时,仅需根据需求将 k 设置为 n+1,这是该场景下的特殊参数,而非函数逻辑错误。所以找的是倒数第K个节点也就是倒数第n+1个节点
findFromEnd 函数的参数 k 是待查找的倒数第 k 个节点的序号,而在删除倒数第 n 个节点的场景中,我们需要查找的是其前驱节点(即倒数第 n+1 个节点),因此调用时将 k 设置为 n+1。
你好,未来的算法大神!
链表问题,看似简单,实则暗藏玄机。面试官常常用它来考察你思维的灵活性。今天,我们要征服的是一个经典问题:“如何只遍历一次就找到链表的倒数第 K 个节点?”
如果你还在想“先遍历一遍算总数 n,再遍历 n-k+1 步”,那么请坐好,因为接下来这个技巧将刷新你的认知。
一、破题:从“两次遍历”到“一次搞定”
传统思路(低效但直观):
-
第一遍遍历: 从头到尾走一遍,计算出链表的总长度 n。
-
第二遍遍历: 从头节点再走 n - k 步,到达的节点就是倒数第 k 个(即正数第 n - k + 1 个)。
这个方法可行,但不够“优雅”。在追求极致效率的算法世界里,遍历两次总让人觉得有些浪费。有没有更巧妙的办法呢?
答案是:有! 这就是我们今天的主角——快慢指针法。
二、核心思想:快慢指针 (★★★★★)
重要性评级: ★★★★★ (链表问题核心技巧,面试高频考点,必须掌握)
一句话解释: 两个指针,一个先走,一个后走,利用它们之间的“固定距离”来定位。
想象一下,在一条长长的跑道上,如何精确地标记出终点线前 2 米的位置?
-
你让 指针 p1(快指针) 先从起点跑 k 步。
-
然后,你让 指针 p2(慢指针) 站在起点。此刻,p1 领先 p2 k 步。
-
接下来,让 p1 和 p2 同时、同速 向前跑。
-
当 p1 到达终点时,p2 停在了哪里?没错,正好就在距离终点 k 步的位置!
这个“领先 k 步”的距离差,就是我们解题的关键。
图解流程 (假设 k=2):
-
p1 先走 k=2 步:
Generated codehead -> 1 -> 2 -> 3 -> 4 -> 5 -> None ^ ^ p2 p1
-
p1 和 p2 一起走,直到 p1 到达终点 (None):
Generated code// 走一步 head -> 1 -> 2 -> 3 -> 4 -> 5 -> None ^ ^ p2 p1 // 再走一步... 直到 head -> 1 -> 2 -> 3 -> 4 -> 5 -> None ^ ^ p2 p1
当 p1 到达 None 时,p2 正好指向倒数第 k=2 个节点 4。
三、代码实现:查找倒数第 K 个节点 (★★★★☆)
重要性评级: ★★★★☆ (是核心思想的具体实践,需要能徒手写出)
# 假设 ListNode 已定义
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
# --- 核心函数 ---
def findFromEnd(head: ListNode, k: int) -> ListNode:
"""
使用快慢指针,一次遍历找到链表的倒数第 k 个节点。
"""
p1 = head # 快指针
p2 = head # 慢指针
# 1. 快指针 p1 先走 k 步
for _ in range(k):
# 鲁棒性检查:如果 k 大于链表长度,p1 会提前到达 None
if not p1:
return None
p1 = p1.next
# 2. p1 和 p2 同时走,直到 p1 到达链表末尾 (None)
while p1 is not None:
p1 = p1.next
p2 = p2.next
# 3. 此时 p2 指向的就是倒数第 k 个节点
return p2
思考: 这里的两次循环(for 和 while)不还是两次吗?
答: 不。从整个链表的角度看,每个节点只被访问了一次。for 循环使 p1 前进了 k 步,while 循环让 p1 和 p2 继续前进。总的来说,p1 从头走到了尾,是一次完整的遍历。因此,时间复杂度是 O(N),而不是 O(2N)。这是一种更精巧的单次遍历。
四、实战进阶:删除链表的倒数第 N 个节点 (★★★★★)
重要性评级: ★★★★★ (结合了快慢指针和另一核心技巧“虚拟头节点”,经典面试题)
现在,挑战升级:不是查找,而是删除。
要删除一个节点,我们光找到它还不够,必须找到它的前一个节点(前驱节点),然后执行 prev_node.next = node_to_delete.next。
如何找到倒数第 N 个节点的前驱节点?
很简单,就是找到倒数第 N+1 个节点!
这时,我们上面的 findFromEnd 函数就能派上用场了。但是,会遇到一个棘手的边界情况:如果我们要删除的是头节点呢? 头节点没有前驱节点!
为了优雅地解决这个问题,我们引入另一个神器——虚拟头节点 (Dummy Node)。
虚拟头节点的作用:
它是一个位于原始头节点 head 之前的假节点。这样一来,链表中的每一个节点(包括原来的头节点)都有了确定的前驱节点,我们的删除逻辑就统一了。
完整解法:
class Solution:
def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
# 1. 创建虚拟头节点,并将其指向原始头节点
dummy = ListNode(-1, head)
# 2. 目标:找到倒数第 n 个节点的前驱,即倒数第 n+1 个节点
# 我们复用快慢指针的逻辑来找到它
prev_to_delete = self.findFromEnd(dummy, n + 1)
# 3. 执行删除操作
# 让前驱节点跳过要删除的节点
prev_to_delete.next = prev_to_delete.next.next
# 4. 返回真正的头节点,即 dummy.next
return dummy.next
def findFromEnd(self, head: ListNode, k: int) -> ListNode:
"""
查找倒数第 k 个节点的辅助函数 (与之前完全相同)
"""
p1 = head
for _ in range(k):
if not p1: return None
p1 = p1.next
p2 = head
while p1 is not None:
p1 = p1.next
p2 = p2.next
return p2
五、随堂测验 (检验你的掌握程度)
问题1: 在 findFromEnd 函数中,如果 k=1(即查找倒数第一个节点),当 while 循环结束时,快指针 p1 和慢指针 p2 分别指向哪里?
答案:
-
快指针 p1 会指向 None(循环的终止条件)。
-
慢指针 p2 会指向链表的最后一个节点(倒数第一个节点)。
过程分析:
-
p1 先走 1 步。
-
p1 和 p2 一起走。当 p1 到达 None 时,p2 刚好比 p1 晚了 1 步,停在了最后一个节点上。
问题2: 在“删除倒数第 N 个节点”的问题中,为什么一定要用 dummy 虚拟头节点?请举例说明它解决了什么具体问题。
答案: dummy 节点主要是为了统一处理删除头节点的边界情况。
举例: 假设链表是 1 -> 2 -> 3,我们要删除倒数第 3 个节点(即头节点 1)。
-
如果没有 dummy 节点,我们的目标是找到倒数第 3+1=4 个节点。但这个链表总共只有 3 个节点,findFromEnd(head, 4) 会返回 None,逻辑会变得非常复杂,需要写特殊的 if/else 来处理 n 等于链表长度的情况。
-
有了 dummy 节点,链表在逻辑上变成了 dummy -> 1 -> 2 -> 3。我们要找的是倒数第 3+1=4 个节点,findFromEnd(dummy, 4) 会正确地返回 dummy 节点本身。然后执行 dummy.next = dummy.next.next,就巧妙地将原来的头节点 1 删除了。最后返回 dummy.next,即新的头节点 2。
通过 dummy 节点,我们无需为“删除头节点”编写任何特殊代码。
问题3: 你能用快慢指针的思想来找到链表的中间节点吗?(提示:快指针一次走两步,慢指针一次走一步)
答案: 可以,这是快慢指针的另一个经典应用。
思路:
-
创建两个指针,fast 和 slow,都指向 head。
-
在一个循环中,slow 每次向前走一步,fast 每次向前走两步。
-
当 fast 指针到达链表末尾(或 fast.next 到达末尾)时,slow 指针正好位于链表的中间位置。
def findMiddle(head: ListNode) -> ListNode:
slow = head
fast = head
# 当 fast 和 fast.next 都存在时循环
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 循环结束,slow 就在中间
return slow
恭喜你!现在你已经掌握了链表问题中最强大、最灵活的技巧之一。多加练习,它将成为你解决复杂链表问题的利器!