⭕一文解决环形链表问题

544 阅读12分钟

链表

链表是一种常见的数据结构,用于存储线性序列。与数组不同,链表中的元素在内存中不是连续存储的,而是通过指针相连。链表由一个头指针指向链表的第一个节点,每个节点包含两个部分:数据域和指针域。数据域存储节点的数据,指针域指向下一个节点的地址。

链表有多种类型

  • 单向链表中每个节点只有一个指针域,指向下一个节点;
  • 双向链表中每个节点有两个指针域,一个指向前一个节点,一个指向后一个节点;
  • 环形链表中最后一个节点的指针域指向头节点,形成一个环。

环形链表

环形链表问题是一类非常经典的链表问题,给定一个单向链表,判断这个链表是否存在环或者环的状态。

这类问题通常有两种解法。

  • 快慢指针法:使用两个指针,一个快指针和一个慢指针,快指针每次移动两个节点,慢指针每次移动一个节点,如果链表中存在环,则快指针最终会追上慢指针,否则快指针会到达链表末尾结束。这个算法的时间复杂度是 O(n),其中 n 是链表的长度。

  • 哈希表法:遍历链表中的每个节点,每访问一个节点就将它存储到哈希表中,如果访问到了哈希表中已经存在的节点,说明链表中存在环。这个算法的时间复杂度也是 O(n),其中 n 是链表的长度,但需要额外的空间来存储哈希表。

快慢指针法其实就是 Floyd 判圈算法,接下来我们详细介绍一下:

Floyd判圈算法

Floyd判圈算法(又称龟兔赛跑算法)是一种快速检测链表中是否存在环的算法。该算法使用两个指针,一个慢指针和一个快指针,从链表头部开始同时向后遍历链表,每次快指针比慢指针多走一步,直到快指针走到链表末尾或者两个指针相遇。

如果链表中不存在环,那么快指针最终会走到链表的末尾,算法结束。如果链表中存在环,那么快指针最终会在环内与慢指针相遇,算法会返回存在环的结论。

该算法的时间复杂度为 O(n)O(n),其中 nn 是链表的长度。由于该算法使用的是常数级别的额外空间,因此它是一种空间复杂度比较优秀的算法。

Floyd 判圈算法的正确性可以通过数学归纳法来证明。

假设链表中存在环,且环的长度为 kk。设慢指针走了 xx 步后进入环,快指针走了 2x2x 步后进入环。设快指针比慢指针多走了 yy 圈(y1y\geq 1),则有:

2x=x+ky+z2x=x+ky+z

其中 zz 表示慢指针在环内走的步数。因为快指针的速度是慢指针的两倍,所以可以得到:

z=kx%kz=k − x \% k

也就是说,慢指针在环内走了 kx%kk - x \% k 步。由于 0x%k<k0\leq x \% k < k,因此 0kx%k<k0\leq k - x \% k < k,所以 z>0z > 0z<kz < k

因为快指针每次走两步,所以 zz 的取值只有 kk 种可能。当 z=0z=0 时,表示快指针与慢指针相遇,此时算法会返回存在环的结论;当 z0z\neq 0 时,表示快指针和慢指针在环内继续前进,由于快指针比慢指针多走了一圈,因此可以将 xx 的值更新为 kzk - z,这样快指针和慢指针都在环内距离环入口的距离为 kzk - z,再次相遇的位置就是环的入口。

由此可见,Floyd 判圈算法的正确性是得到了保证的。

题目

接下来就看几道LeetCode上的相关题目:

141. 环形链表 - 力扣(LeetCode)

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ,则返回 true 。 否则,返回 false 。

 

示例 1:

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

示例 2:

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

示例 3:

输入: head = [1], pos = -1
输出: false
解释: 链表中没有环。

 

提示:

  • 链表中节点的数目范围是 [0, 104]
  • -105 <= Node.val <= 105
  • pos 为 -1 或者链表中的一个 有效索引 。

 

进阶: 你能用 O(1)(即,常量)内存解决此问题吗?

class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        slow,fast = head,head
        while True:
            if not fast or not fast.next:
                return False
            slow = slow.next
            fast = fast.next.next
            if fast == slow:
                return True

使用了快慢指针的方法:

  1. slowfast指针都被初始化为链表的头节点head
  2. 进入一个无限循环,循环条件为while True,即一直循环直到函数中的某个条件满足返回。
  3. 在循环内部,首先检查fast指针是否为空或者fast.next是否为空,如果是,说明链表已经到达末尾,没有环,直接返回False
  4. 否则,slow指针向前移动一步,fast指针向前移动两步。
  5. 然后,检查slowfast指针是否相遇,如果相遇,说明链表中有环,返回True
  6. 如果循环继续执行,重复步骤3到步骤5。

这个解决方法使用了快慢指针的技巧,如果链表中有环,快慢指针最终会相遇;如果链表中没有环,快指针会最先到达链表末尾,返回False

这种解法的时间复杂度是O(N),其中N是链表中的节点数,因为在最坏情况下,fast指针会遍历整个链表。空间复杂度是O(1),因为只使用了两个指针。

142. 环形链表 II - 力扣(LeetCode)

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

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

 

示例 1:

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

示例 2:

输入: head = [1,2], pos = 0
输出: 返回索引为 0 的链表节点
解释: 链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入: head = [1], pos = -1
输出: 返回 null
解释: 链表中没有环。

 

提示:

  • 链表中节点的数目范围在范围 [0, 104] 内
  • -105 <= Node.val <= 105
  • pos 的值为 -1 或者链表中的一个有效索引

 

进阶: 你是否可以使用 O(1) 空间解决此题?

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, x):
#         self.val = x
#         self.next = None

class Solution:
    def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
        # 快慢指针
        if not head:
            return None
        slow,fast = head,head
        while True:
            if not fast or not fast.next:
                return None
            slow = slow.next
            fast = fast.next.next
            if slow == fast:
                break
        a = head
        while a != slow:
            a = a.next
            slow = slow.next
        return a
        
        # # 哈希表
        # if not head:
        #     return None

        # visited = []
        # p = head
        # while p:
        #     if p in visited:
        #         return p
        #     else:
        #         visited.append(p)
        #         p = p.next
        # return None

我这里没注释掉用哈希表的做法,用哈希表空间复杂度就上去了。这里直接快慢指针做。

这段代码是用来检测链表中是否有环,并找出环的入口点。这里使用了快慢指针的方法。下面是对代码的详细解释:

  1. 初始化两个指针 slowfast,都指向链表的头节点 head
  2. 使用一个循环,条件为 while True,不断进行快慢指针的移动,直到它们相遇。如果快指针 fast 或者 fast.next为空,说明链表中没有环,直接返回 None
  3. 当快慢指针相遇时,将慢指针重新指向链表的头节点 head,然后慢指针和快指针同时以相同的速度向前移动,每次移动一步。当慢指针和快指针再次相遇时,就是环的入口点。
  4. 返回相遇点,即为环的入口点。

这个算法的原理是,当快慢指针相遇时,慢指针走了 a + b 步,快指针走了 a + b + c + b 步(其中,a是链表头到环的入口的距离,b是环的入口到相遇点的距离,c是环的剩余部分的长度)。由于快指针的速度是慢指针的两倍,所以快指针走的距离也是慢指针的两倍。因此,a + b + c + b = 2 * (a + b),化简得到 a = c,这就是为什么在慢指针和快指针相遇后,再用一个指针从头节点开始,与慢指针相遇的节点就是环的入口点。

这种解法的时间复杂度是O(N),其中N是链表中的节点数。空间复杂度是O(1),因为只使用了常数级别的额外空间。

287. 寻找重复数 - 力扣(LeetCode)

给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。

假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。

你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间。

 

示例 1:

输入: nums = [1,3,4,2,2]
输出: 2

示例 2:

输入: nums = [3,1,3,4,2]
输出: 3

 

提示:

  • 1 <= n <= 105
  • nums.length == n + 1
  • 1 <= nums[i] <= n
  • nums 中 只有一个整数 出现 两次或多次 ,其余整数均只出现 一次

 

进阶:

  • 如何证明 nums 中至少存在一个重复的数字?
  • 你可以设计一个线性级时间复杂度 O(n) 的解决方案吗?
class Solution:
    def findDuplicate(self, nums: List[int]) -> int:
        # 双指针
        fast = slow = 0
        while True:
            slow = nums[slow]
            fast = nums[nums[fast]]
            if slow == fast:
                break
        fast = 0
        while slow != fast:
            slow = nums[slow]
            fast = nums[fast]
        return fast


        # # 原地哈希,注意i的增加时机
        # n, i = len(nums), 0
        # while i < n:
        #     t, idx = nums[i], nums[i] - 1
        #     if nums[idx] == t:
        #         if idx != i:
        #             return t
        #         i += 1
        #     else:
        #         nums[i], nums[idx] = nums[idx], nums[i]
        # return -1
 

使用了快慢指针的方法来解决这个问题。下面是对代码的详细解释:

  1. 初始化两个指针 fastslow,都指向数组的第一个元素。
  2. 使用一个循环,slow每次移动一步,fast每次移动两步,直到它们相遇。如果数组中有重复数字,fastslow最终会相遇。
  3. fastslow相遇时,将fast重新指向数组的第一个元素,然后fastslow每次都移动一步,直到它们再次相遇。相遇的点就是重复的数字。

这个算法的原理是,如果数组中存在重复数字,那么数组中的元素可以看作是一个链表,每个元素的值作为下一个元素的索引。由于有重复数字,所以在这个链表中一定存在一个环。快慢指针相遇的点就是环的入口点,即重复的数字。

这种解法的时间复杂度是O(N),其中N是数组的长度。由于它使用了常数级别的额外空间,所以空间复杂度是O(1)。

202. 快乐数 - 力扣(LeetCode)

编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」 定义为:

  • 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
  • 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
  • 如果这个过程 结果为 1,那么这个数就是快乐数。

如果 n 是 快乐数 就返回 true ;不是,则返回 false 。

 

示例 1:

输入: n = 19
输出: true
解释: 12 + 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1

示例 2:

输入: n = 2
输出: false

 

提示:

  • 1 <= n <= 231 - 1
class Solution:
    def isHappy(self, n: int) -> bool:

        def convert(n):
            res = 0
            while n != 0:
                n, m = divmod(n, 10)
                res += m ** 2
            return res

        while n != 1:
            n = convert(n)
            if n==4:
                return False

        return True

这个解法使用了一个内部函数 convert(n),它接受一个整数 n,然后将每个数字的平方和计算出来。然后在主函数中,使用一个循环,不断调用 convert(n),直到 n 变为1(是快乐数)或者 n 变为4(不是快乐数)。如果 n 变为4,就说明陷入了循环,返回 False,否则返回 True

这个解法的原理是,如果一个数是快乐数,那么不断进行平方和操作最终会得到1。如果一个数不是快乐数,那么最终会陷入一个循环,而在这个循环中,所有数字的平方和加起来等于4。所以,当 n 变为4时,就可以判断它不是快乐数,返回 False

这种解法的时间复杂度取决于在每次迭代中计算平方和的次数,但是由于数字的位数是有限的,所以它的时间复杂度是有上限的,是O(1)级别的。空间复杂度是O(1),因为只使用了常数级别的额外空间。

在快乐数问题中,我们不断地将一个数的各个位上的数字的平方和计算出来,直到得到1(是快乐数)或者陷入循环(不是快乐数)。如果一个数不是快乐数,它会陷入一个循环中,而这个循环的性质与环形链表类似。在环形链表问题中,使用快慢指针,如果链表中有环,快慢指针最终会相遇。在快乐数问题中,如果一个数不是快乐数,那么它的平方和序列会陷入一个循环,而循环的性质也可以使用快慢指针来判断。