环形数组是否存在循环

标签: 数组 哈希表 双指针

难度: Medium

存在一个不含 0 环形 数组 nums ,每个 nums[i] 都表示位于下标 i 的角色应该向前或向后移动的下标个数:

  • 如果 nums[i] 是正数,向前(下标递增方向)移动 |nums[i]|
  • 如果 nums[i] 是负数,向后(下标递减方向)移动 |nums[i]|

因为数组是 环形 的,所以可以假设从最后一个元素向前移动一步会到达第一个元素,而第一个元素向后移动一步会到达最后一个元素。

数组中的 循环 由长度为 k 的下标序列 seq 标识:

  • 遵循上述移动规则将导致一组重复下标序列 seq[0] -> seq[1] -> ... -> seq[k - 1] -> seq[0] -> ...
  • 所有 nums[seq[j]] 应当不是 全正 就是 全负
  • k > 1

如果 nums 中存在循环,返回 true ;否则,返回 false

示例 1:

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

示例 2:

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

示例 3:

输入:nums = [-2,1,-1,-2,-2]
输出:false
解释:按下标 1 -> 2 -> 1 -> ... 的运动无法构成循环,因为 nums[1] 是正数,而 nums[2] 是负数。
所有 nums[seq[j]] 应当不是全正就是全负。

提示:

  • 1 <= nums.length <= 5000
  • -1000 <= nums[i] <= 1000
  • nums[i] != 0

进阶:你能设计一个时间复杂度为 O(n) 且额外空间复杂度为 O(1) 的算法吗?

Submission

运行时间: 23 ms

内存: 16.1 MB

class Solution:
    def circularArrayLoop(self, nums: List[int]) -> bool:
        # Get the length of the input list
        length = len(nums)

        # Define a function to find the next index in a circular manner
        def get_next_index(current_index):
            # Calculate the next index considering wrapping around
            return (current_index + nums[current_index]) % length

        # Iterate over all elements in the array
        for i in range(length):
            # Skip if the current element is already marked as 0, indicating it's not part of a loop
            if nums[i] == 0:
                continue
          
            # Initialize the slow and fast pointers for cycle detection
            slow_pointer = i
            fast_pointer = get_next_index(i)
          
            # Continue moving pointers while the signs of the elements indicate a potential loop
            # This also ensures that we are not mixing cycles of different directions
            while nums[slow_pointer] * nums[fast_pointer] > 0 and nums[slow_pointer] * nums[get_next_index(fast_pointer)] > 0:
                # If the slow and fast pointers meet, a cycle is detected
                if slow_pointer == fast_pointer:
                    # Check to ensure the loop is longer than 1 element
                    if slow_pointer != get_next_index(slow_pointer):
                        return True
                    # If the loop is just one element, break and mark it as non-looping
                    break
              
                # Move slow pointer by one step and fast pointer by two steps
                slow_pointer = get_next_index(slow_pointer)
                fast_pointer = get_next_index(get_next_index(fast_pointer))
          
            # Mark all visited elements as 0 to avoid revisiting and repeated calculations
            # This process will also ensure elimination of non-loop elements
            index = i
            while nums[index] * nums[get_next_index(index)] > 0:
                next_index = get_next_index(index)
                nums[index] = 0
                index = next_index

        # If no loop is found, return False
        return False


# Intuition

# To address this problem, we need to consider that a cycle can only exist if we're moving consistently in one direction and eventually end up where we started. This naturally brings the "fast and slow pointers" technique to mind, which is often used for cycle detection in linked lists.

# The fast and slow pointers method involves two pointers moving at different speeds, and if there is a cycle, they will eventually meet. We apply the same principle here:

# The slow pointer moves one step at a time.
# The fast pointer moves two steps at a time.
# If slow and fast meet at the same index, and this index is not the same as the next step (to prevent single-element loops, which aren't considered valid cycles), we have found a cycle.

# At each step, we also verify that the direction does not change. If the product of nums[slow] and nums[fast] is positive, they are either both positive or both negative, thus maintaining a consistent direction. If this product is negative or if we reach an element that is already marked as visited (a value of 0), we do not have a valid cycle from that start point.

# For each element, if it does not lead to a cycle, we mark the visited elements as 0 to avoid re-checking them in the future, thereby optimizing our algorithm. This marking also helps to invalidate any non-cycle paths swiftly.

# Overall, the algorithm is to iterate over each element and use the fast and slow pointer method to detect a cycle. If any cycle is found, return true. After checking all possibilities, if no cycle is found, return false.

# Solution Approach
# The implementation of the solution for detecting a cycle in the circular array follows these main steps:

# Array Length Extraction: We start by obtaining the length n of the input array nums. This is crucial since we need to calculate the next index correctly within the circular context:

# n = len(nums)
# Helper Function for Index Calculation: Since the array is circular, we define a function named next() that takes an index i and returns the next index we should move to, according to nums[i], and wraps around the array if necessary:

# def next(i):
#     return (i + nums[i]) % n
# We ensure that the result of the movement remains within the bounds of the array indices by taking the modulo with n.

# Main Loop to Check for Cycles: We iterate through each element in the array to check for cycles starting from that index:

# for i in range(n):
#     if nums[i] == 0:  # Skip already marked elements (no cycle from this point)
#         continue
# Fast and Slow Pointers Initialization: For each starting index, we initiate slow and fast pointers, which represent the current position of each pointer:

# slow, fast = i, next(i)
# Cycle Detection Loop: Next, we loop to detect cycles using the following conditions:

# The product of nums[slow] and nums[fast] must be positive, indicating they move in the same direction.
# The product of nums[slow] and nums[next(fast)] must also be positive, ensuring that the fast pointer also continues in the same direction after two moves.
# while nums[slow] * nums[fast] > 0 and nums[slow] * nums[next(fast)] > 0:
#     if slow == fast:  # Pointers meet, indicating a potential cycle
#         if slow != next(slow):  # Check to avoid single-length cycle
#             return True
#         break
# Marking Elements: If not a valid cycle, we need to mark elements associated with the failed attempt to find a cycle to prevent re-processing them in future iterations. This is achieved by setting each involved element to 0:

# j = i
# while nums[j] * nums[next(j)] > 0:
#     nums[j] = 0  # Marking the element
#     j = next(j)
# Final Return: After exhaustively checking all indices, if no cycle is found, the function returns false.

# This solution leverages the cyclical two-pointer technique to identify cycles and uses in-place marking to improve efficiency by reducing redundant checks. The use of the modulo operator ensures proper index wrapping within the circular array boundaries, and thorough condition checks maintain consistency in direction for cycle validation.

Explain

本题的解决方案采用了快慢指针的思想。通过快慢两个指针在环形数组中移动,如果存在循环,它们最终会相遇。同时,在移动过程中也要检查移动的方向是否一致,只有方向一致的情况下才可能形成循环。对于非循环的元素,将其标记为0,避免重复访问。最后,如果遍历完整个数组都没有发现循环,则返回 false。

时间复杂度: O(n^2)

空间复杂度: O(1)

class Solution:
    def circularArrayLoop(self, nums: List[int]) -> bool:
        length = len(nums)

        def get_next_index(current_index):
            # 计算考虑环形数组的下一个索引
            return (current_index + nums[current_index]) % length

        for i in range(length):
            # 如果当前元素已标记为0,说明无法形成循环,跳过
            if nums[i] == 0:
                continue
          
            # 初始化快慢指针
            slow_pointer = i
            fast_pointer = get_next_index(i)
          
            # 当快慢指针移动方向一致且未到达已访问过的元素时,继续移动指针
            while nums[slow_pointer] * nums[fast_pointer] > 0 and nums[slow_pointer] * nums[get_next_index(fast_pointer)] > 0:
                # 如果快慢指针相遇,说明存在循环
                if slow_pointer == fast_pointer:
                    # 进一步检查循环长度是否大于1
                    if slow_pointer != get_next_index(slow_pointer):
                        return True
                    # 如果循环长度为1,跳出循环并标记该元素
                    break
              
                # 慢指针移动一步,快指针移动两步
                slow_pointer = get_next_index(slow_pointer)
                fast_pointer = get_next_index(get_next_index(fast_pointer))
          
            # 标记所有访问过的非循环元素为0,避免重复访问
            index = i
            while nums[index] * nums[get_next_index(index)] > 0:
                next_index = get_next_index(index)
                nums[index] = 0
                index = next_index

        # 如果遍历完数组未发现循环,返回 False
        return False

Explore

在检查快慢指针相遇时需要检查循环长度是否大于1,因为循环长度为1意味着某个元素指向自己,形成一个自环,而这不符合题目要求的循环定义,题目要求的循环必须包含多个元素。当检测到快慢指针相遇时,我们需要进一步检查是否slow_pointer等于get_next_index(slow_pointer),如果相等,说明循环长度为1,这种情况下我们会跳出循环并将该元素标记为0,避免后续算法再次访问到它。

在算法中,为了确保快慢指针在移动时始终保持方向一致,我们通过检查当前元素与其下一个索引位置的元素的乘积是否大于0来进行判断。具体地,只有当nums[slow_pointer] * nums[fast_pointer] > 0 且 nums[slow_pointer] * nums[get_next_index(fast_pointer)] > 0时,快慢指针才会继续移动。这个条件确保了快慢指针访问的元素始终有相同的正负符号,从而保持了移动的方向一致性。

标记元素为0的策略能有效地避免算法重复访问已确定不会形成有效循环的元素,从而提高整体算法的效率。一旦确定某元素所在路径不可能形成题目要求的循环,我们将此路径上的所有元素标记为0。这个策略假设一旦元素被访问过并确定不会形成循环,其后续再被遍历到的可能性也不会形成循环。在实际操作中,只有在确认一条路径不可能形成循环后才会进行标记,因此不存在因误标记而未能正确识别循环的风险。

在环形数组中,`get_next_index`函数用于计算当前索引的下一个位置,考虑到数组的环形特性。该函数通过(current_index + nums[current_index]) % length来计算。这里current_index是当前索引,nums[current_index]是当前索引位置的值,表示要向前或向后移动的步数,length是数组的长度。使用取模运算确保得到的结果始终落在数组的有效索引范围内,即使计算结果为负数,取模后也能正确反映环形结构的性质。这种方式有效处理了数组边界的问题,确保索引的环形逻辑。