小木的算法日记-链表面试杀手锏:快慢指针技巧精讲 (从“查找”到“删除”)

实现代码
# 返回链表的倒数第 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 步”,那么请坐好,因为接下来这个技巧将刷新你的认知。

一、破题:从“两次遍历”到“一次搞定”

传统思路(低效但直观):

  1. 第一遍遍历: 从头到尾走一遍,计算出链表的总长度 n。

  2. 第二遍遍历: 从头节点再走 n - k 步,到达的节点就是倒数第 k 个(即正数第 n - k + 1 个)。

这个方法可行,但不够“优雅”。在追求极致效率的算法世界里,遍历两次总让人觉得有些浪费。有没有更巧妙的办法呢?

答案是:有! 这就是我们今天的主角——快慢指针法

二、核心思想:快慢指针 (★★★★★)

重要性评级: ★★★★★ (链表问题核心技巧,面试高频考点,必须掌握)
一句话解释: 两个指针,一个先走,一个后走,利用它们之间的“固定距离”来定位。

想象一下,在一条长长的跑道上,如何精确地标记出终点线前 2 米的位置?

  1. 你让 指针 p1(快指针) 先从起点跑 k 步。

  2. 然后,你让 指针 p2(慢指针) 站在起点。此刻,p1 领先 p2 k 步。

  3. 接下来,让 p1 和 p2 同时、同速 向前跑。

  4. 当 p1 到达终点时,p2 停在了哪里?没错,正好就在距离终点 k 步的位置!

这个“领先 k 步”的距离差,就是我们解题的关键。

图解流程 (假设 k=2):

  1. p1 先走 k=2 步:

    Generated code
          head -> 1 -> 2 -> 3 -> 4 -> 5 -> None
    ^            ^
    p2           p1
        
  2. 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 会指向链表的最后一个节点(倒数第一个节点)。

过程分析:

  1. p1 先走 1 步。

  2. 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
    

恭喜你!现在你已经掌握了链表问题中最强大、最灵活的技巧之一。多加练习,它将成为你解决复杂链表问题的利器!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值