常见编程模式之快慢指针

时间:2022-07-24
本文章向大家介绍常见编程模式之快慢指针,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

3. 快慢指针(Fast & Slow pointers)

基本原理及应用场景

快慢指针方法,又称为龟兔赛跑算法,其基本思想就是使用两个移动速度不同的指针在数组或链表等序列结构上移动。这种方法对于处理「环形」链表或数组非常有用。以链表为例,通过以不同的速度移动,我们可以证明如果链表中存在环,则两个指针必定会相遇,当两个指针均处在环中时,快指针会追上慢指针(如下图所示)。

在以下场景中,我们可能会用到快慢指针:

  • 题目涉及包含「循环」的链表或数组
  • 需要求解链表中某个元素的位置或链表长度

快慢指针和双指针比较类似(可以理解为特殊的双指针法),在只能单向移动的数据结构中(如单向链表),一般使用快慢指针,如判断回文链表(Leetcode 234)。

经典例题

141. 环形链表(Easy)

给定一个链表,判断链表中是否有环。

「示例」

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

采用快慢指针,快指针每次向前两步,慢指针每次向前一步,只有链表中存在环时两指针才会相遇:

class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        slow, fast = head, head
        while fast and fast.next: # 只要关注fast即可
            slow = slow.next
            fast = fast.next.next
            if fast == slow:
                return True
        return False

该方法的证明如下:

  • 如果快指针在慢指针后一格,则下一步快指针移动两格,慢指针移动一格,两者相遇;
  • 如果快指针在慢指针后两格,则下一步后快指针在慢指针后一格,回到第一种情况,两者可以相遇
  • 如果快指针在慢指针后 N 格,则下一步后快指针在慢指针后 N - 1 格,根据上述情况,两者必会相遇

可以看到快指针追赶慢指针是一个递归的过程,只要存在循环,则两者必然会在某一时间点相遇。

142. 环形链表 II(Medium)

给定一个链表,返回链表开始入环的第一个节点。如果链表无环,则返回 null

「示例」

输入:head = [3,2,0,-4], pos = 1
输出:tail connects to node index 1
解释:链表中有一个环,其尾部连接到第二个节点。

这道题基本思路与上一题一样,先到达快慢指针相等的位置,然后新建一个指针从头开始移动,直到和慢指针相遇的位置即为循环起始节点:

class Solution:
    def detectCycle(self, head: ListNode) -> ListNode:
        slow, fast = head, head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow == fast:
                current = head
                while current != slow:
                    current = current.next
                    slow = slow.next
                return slow
        return None

下面简单证明上述方法的正确性。如下图所示,假定快慢指针在节点 「-4」 处相遇,则快指针走过的距离为 A+2B+C,慢指针走过的距离为 A+B。由于快指针的速度为慢指针的两倍,所以我们有 A+2B+C == 2(A+B),从而得到 A == C。因此,我们再使用另一个指针从头结点开始移动(每次一步),此时慢指针也同时移动,则两者必会在节点 「2」 相遇,即循环开始的点。

457. 环形数组循环(Medium)

给定一个含有正整数和负整数的「环形」数组 nums。如果某个索引中的数 k 为正数,则向前移动 k 个索引。相反,如果是负数 (-k),则向后移动 k 个索引。因为数组是环形的,所以可以假设最后一个元素的下一个元素是第一个元素,而第一个元素的前一个元素是最后一个元素。 确定 nums 中是否存在循环(或周期)。循环必须在相同的索引处开始和结束并且循环长度 > 1。此外,一个循环中的所有运动都必须沿着同一方向进行。换句话说,一个循环中不能同时包括向前的运动和向后的运动。

「示例 1」

输入:[2,-1,1,2,2]
输出:true
解释:存在循环,按索引 0 -> 2 -> 3 -> 0 。循环长度为 3 。

「示例 2」

输入:[-1,2]
输出:false
解释:按索引 1 -> 1 -> 1 ... 的运动无法构成循环,因为循环的长度为 1 。根据定义,循环的长度必须大于 1 。

这道题可以通过快慢指针法解决。由于题目明确数组元素不为 0,我们可以通过将元素置 0 来标记已经遍历过的元素,以减少时间复杂度。这里的快慢指针选择从不同起点开始移动,因为指针的更新位于内循环的最后。对于不同的题目,需要根据实际情况选择指针的起始位置和循环的终止条件,本题中的终止条件为快慢指针所指向的操作不同向(注意由于快指针一次移动两步,所以还需要和当前快指针对应的下一个元素的操作比较)。具体的代码实现如下:

class Solution:
    def circularArrayLoop(self, nums: List[int]) -> bool:
        def getNext(i): # 求出下一个位置
            return (nums[i] + i) % n
        
        n = len(nums)
        for i in range(n):
            if nums[i] == 0: continue # 用0标记已访问的元素,实现剪枝
            slow, fast = i, getNext(i) # 设定快慢指针,慢指针指向当前索引,快指针指向下一个索引(这里起始两指针位置不同)
            # 保证快慢指针同向且慢指针和快指针的下一个也同向(因为快指针一次移动两步)
            while nums[slow] * nums[fast] > 0 and nums[slow] * nums[getNext(fast)] > 0:
                if slow == fast:
                    if slow == getNext(slow):
                        break
                    return True
                slow = getNext(slow)
                fast = getNext(getNext(fast))
            # 对于已经遍历过的节点置0,因为其对应的序列不可能存在合法循环了,否则已经返回True了(剪枝)
            slow = i
            val = nums[i]
            while val * nums[slow] > 0:
                nums[slow] = 0
                slow = getNext(slow)
        return False

其他类似的题目列表:

  • LeetCode 202-「快乐数」(Easy)
  • LeetCode 234-「回文链表」(Easy)
  • LeetCode 876-「链表的中间节点」(Easy)