链表是一种常见的数据结构,由一系列节点组成,每个节点包含数据和指向下一个节点的指针。环形链表则是指链表中某个节点的指针指向了链表中的某个先前节点,导致链表形成一个环。在实际编程中,环形链表可能导致无限循环等问题,因此检测和处理环形链表是算法面试中的常见考点。
环形链表问题通常分为两类:判断链表是否有环(LeetCode 141题)和找到环的入口节点(LeetCode 142题)。本文将重点讨论后者,即如何精确定位环形链表的入口节点。
给定一个链表的头节点head,返回链表开始入环的第一个节点。如果链表无环,则返回null。
示例:
code复制输入:head = [3,2,0,-4], pos = 1
输出:返回索引为1的链表节点
解释:链表中有一个环,其尾部连接到第二个节点(值为2的节点)
最直观的解法是使用哈希表记录访问过的节点:
python复制def detectCycle(head):
visited = set()
curr = head
while curr:
if curr in visited:
return curr
visited.add(curr)
curr = curr.next
return None
这种方法虽然简单,但需要O(n)的额外空间,不符合题目对空间复杂度的要求。
快慢指针法是解决环形链表问题的经典方法,它只需要O(1)的额外空间。其核心思想是:
设:
当快慢指针相遇时:
因为快指针速度是慢指针的2倍:
code复制2(a + b) = a + b + n(b + c)
化简得:
code复制a + b = n(b + c)
a = n(b + c) - b
当n=1时:
code复制a = (b + c) - b = c
这意味着:从头节点到环入口的距离 = 相遇点到环入口的距离。
python复制class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
if not head or not head.next:
return None
# 初始化快慢指针
slow = fast = head
# 第一阶段:寻找相遇点
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# 第二阶段:寻找环入口
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
可以把环想象成圆形跑道,快指针比慢指针快1步,相当于快指针在追慢指针,每次接近1步,必然会在有限步数内相遇。
这是由数学推导得出的结论:a = c。从头节点到环入口的距离等于从相遇点到环入口的距离。
python复制def test_detectCycle():
# 创建测试链表
def createLinkedList(nums, pos):
if not nums:
return None
nodes = [ListNode(val) for val in nums]
for i in range(len(nodes)-1):
nodes[i].next = nodes[i+1]
if pos != -1:
nodes[-1].next = nodes[pos]
return nodes[0]
# 测试用例
test_cases = [
([3,2,0,-4], 1, 2),
([1,2], 0, 1),
([1], -1, None),
([], -1, None),
([1,2,3], 0, 1)
]
solution = Solution()
for nums, pos, expected in test_cases:
head = createLinkedList(nums, pos)
result = solution.detectCycle(head)
if expected is None:
assert result is None
else:
assert result.val == expected
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 哈希表法 | O(n) | O(n) | 需要简单实现时 |
| 快慢指针法 | O(n) | O(1) | 要求常数空间时 |
设环外长度为a,环长度为L。慢指针进入环时,快指针已经在环中,距离慢指针d(0 ≤ d < L)。因为快指针每次比慢指针多走1步,所以经过L-d步后必然相遇。
从相遇点开始,慢指针再走c步到达入口,同时头节点出发的指针走a步也到达入口。由a=c,所以两者会相遇在入口点。
cpp复制ListNode *detectCycle(ListNode *head) {
if (!head || !head->next) return nullptr;
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
slow = head;
while (slow != fast) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
}
return nullptr;
}
java复制public ListNode detectCycle(ListNode head) {
if (head == null || head.next == null) return null;
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
}
return null;
}
"快慢相遇必有环,一回头来一前进,再次相遇是入口"
在实际测试中,对于n=10^5的链表:
快慢指针法在大数据量时表现更优,特别是在内存受限的环境中。
| 语言 | 实现特点 | 性能表现 |
|---|---|---|
| Python | 简洁,但运行速度较慢 | 中等 |
| C++ | 指针操作直接,速度快 | 优 |
| Java | 安全性高,速度中等 | 良 |
| JavaScript | 灵活,解释执行 | 中等 |
如果链表可能有多个环,需要修改算法:
当只有部分链表形成环时,算法依然适用,因为环检测是基于指针相遇而非链表结构。
快慢指针法最早由Robert Floyd在1967年提出,用于检测有限状态机中的循环。后来被广泛应用于链表环检测,成为计算机科学中的经典算法。
在实际编程和面试中,环形链表问题考察的不仅是编码能力,更是对数据结构的深入理解和数学思维能力。快慢指针法展示了如何用简单的工具解决复杂问题,这种思想可以应用到许多其他算法问题中。
我个人在解决这个问题时,最大的收获是认识到算法背后的数学原理往往比代码实现更重要。理解"为什么"比知道"怎么做"更有价值。建议学习者在掌握这个算法后,尝试自己推导数学关系,这样记忆会更深刻,应用也会更灵活。