外观
142.环形链表 II
约 5823 字大约 19 分钟
链表双指针哈希表
2025-05-31
原题
给定一个链表的头节点 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)
空间解决此题?
在实际开发中,环形链表问题是一个经典的链表结构问题,常见于内存泄漏检测、循环引用检测等场景。本题要求我们不仅要判断链表是否有环,还要精确找出环的入口节点,这对于理解链表结构和指针操作有很大帮助。
审题
面试场景模拟
面试官:请实现一个算法来解决这个问题。
我:好的。在开始之前,我想确认一下对题目的理解是否正确。这个问题是要求我们找出链表中环的入口节点,如果没有环则返回null,对吗?
面试官:是的,具体来说,你需要返回链表开始入环的第一个节点,也就是从头节点出发,沿着next指针走,第一个可以重复到达的节点。
我:明白了。关于输入输出,我理解输入是一个链表的头节点,输出是环的入口节点或null,这样理解对吗?
面试官:是的,输入会是链表的头节点head,输出应该是环的入口节点的引用,如果链表没有环,则返回null。
我:关于这个问题的约束条件,我想确认一下:
- 链表中节点的数目范围在[0, 10^4]内,也就是说链表可能为空
- 节点值的范围在[-10^5, 10^5]之间
- pos只是用来表示环的位置,不作为函数参数传入
- 题目要求不允许修改链表结构
面试官:没错,这些约束条件都很重要。特别注意不能修改链表结构,这意味着你不能通过标记节点等方式来解决问题。另外,进阶要求是使用O(1)的空间复杂度。
解析
思路概述
面对这个问题,我的第一反应是使用哈希表来记录已经访问过的节点。遍历链表,对于每个节点,检查它是否已经在哈希表中,如果是,则该节点就是环的入口;如果不是,则将其加入哈希表,继续遍历。这种方法的时间复杂度是O(n),空间复杂度也是O(n)。
不过,仔细思考后,我发现这个问题实际上是一个经典的"Floyd's Tortoise and Hare"(龟兔赛跑)算法问题,可以使用双指针技巧来解决,从而将空间复杂度降低到O(1)。
就像在赛道上,如果有一个环形跑道,一个跑得快的兔子和一个跑得慢的乌龟,只要他们一直跑下去,兔子总会在环内某处追上乌龟。
算法设计与分析
按照"五步法"来系统分析这个问题:
1. 确定状态定义(或数据结构选择)
我们需要使用两个指针:
- 慢指针(slow):每次移动一步
- 快指针(fast):每次移动两步
这两个指针都从链表头部开始移动。如果链表中存在环,这两个指针最终会在环内相遇。
这里的核心思想是利用速度差来检测环。实际上,这种设计在操作系统的死锁检测、垃圾回收的循环引用检测等场景中也有类似应用。
2. 确定转移方程(或算法核心逻辑)
算法分为两个阶段:
- 检测环:使用快慢指针,如果存在环,它们一定会在环内相遇
- 找到环的入口:当快慢指针相遇后,将其中一个指针重置到链表头部,然后两个指针都以相同的速度(每次一步)移动,它们的相遇点就是环的入口
这个过程看似简单,但蕴含了数学证明的思想。如果深入思考,你会发现这与图论中的环检测算法有着异曲同工之妙。
3. 初始化(或边界条件处理)
- 如果链表为空或只有一个节点且没有自环,则不存在环,直接返回null
- 初始化慢指针和快指针都指向头节点
处理边界情况不仅是为了代码健壮性,更是算法正确性的保证。在实际工程中,这种严谨的思考方式同样重要。
4. 遍历顺序(或算法执行流程)
- 第一阶段:慢指针每次移动一步,快指针每次移动两步,直到它们相遇或快指针到达链表尾部
- 如果快指针到达链表尾部(即fast为null或fast.next为null),说明链表无环,返回null
- 如果快慢指针相遇,进入第二阶段
- 第二阶段:将慢指针重置到链表头部,然后两个指针都以每次一步的速度移动,直到它们再次相遇
- 它们的第二次相遇点就是环的入口节点
这里的顺序安排并非随意,而是经过精心设计,确保能够正确找到环的入口。
5. 举例推导(或复杂度分析)
让我们用一个具体例子来验证算法的正确性:
输入:head = [3,2,0,-4], pos = 1 预期输出:返回索引为1的节点(值为2的节点)
执行过程:
- 初始化:slow = head, fast = head
- 第一次迭代:slow移动到值为2的节点,fast移动到值为0的节点
- 第二次迭代:slow移动到值为0的节点,fast移动到值为2的节点(已经在环内)
- 第三次迭代:slow移动到值为-4的节点,fast移动到值为-4的节点,它们相遇
- 将slow重置到head,fast保持在相遇点(值为-4的节点)
- 两个指针都以每次一步的速度移动:
- 第一步:slow移动到值为3的节点,fast移动到值为2的节点
- 第二步:slow移动到值为2的节点,fast移动到值为0的节点
- 第三步:slow移动到值为0的节点,fast移动到值为-4的节点
- 第四步:slow移动到值为-4的节点,fast移动到值为2的节点
- 第五步:slow移动到值为2的节点,fast移动到值为0的节点,它们相遇在值为2的节点
- 返回值为2的节点,即环的入口
时间复杂度:O(n),其中n是链表中节点的数量。在最坏情况下,我们需要遍历整个链表两次。 空间复杂度:O(1),只使用了常数级别的额外空间。
源码
核心代码模式
Java
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
// 边界条件处理
if (head == null || head.next == null) {
return null;
}
// 初始化快慢指针
ListNode slow = head;
ListNode 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;
}
}
Python
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
# 边界条件处理
if not head or not head.next:
return None
# 初始化快慢指针
slow = head
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
JavaScript
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var detectCycle = function(head) {
// 边界条件处理
if (head === null || head.next === null) {
return null;
}
// 初始化快慢指针
let slow = head;
let 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;
};
Go
/**
* Definition for singly-linked list.
* type ListNode struct {
* Val int
* Next *ListNode
* }
*/
func detectCycle(head *ListNode) *ListNode {
// 边界条件处理
if head == nil || head.Next == nil {
return nil
}
// 初始化快慢指针
slow := head
fast := head
// 第一阶段:检测环
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针每次移动一步
fast = fast.Next.Next // 快指针每次移动两步
// 如果快慢指针相遇,说明存在环
if slow == fast {
// 第二阶段:找到环的入口
slow = head // 将慢指针重置到链表头部
for slow != fast {
slow = slow.Next // 两个指针都以相同速度移动
fast = fast.Next
}
return slow // 返回环的入口节点
}
}
// 如果快指针到达链表尾部,说明链表无环
return nil
}
ACM模式
Java
import java.util.*;
// 链表节点定义
class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 读取输入
String[] input = scanner.nextLine().replace("[", "").replace("]", "").split(",");
int pos = Integer.parseInt(scanner.nextLine());
// 构建链表
ListNode head = buildLinkedList(input, pos);
// 调用解决方案
Solution solution = new Solution();
ListNode result = solution.detectCycle(head);
// 输出结果
if (result == null) {
System.out.println("null");
} else {
// 找到环入口节点在链表中的位置
int index = findNodeIndex(head, result);
System.out.println("返回索引为 " + index + " 的链表节点");
}
}
// 构建可能带环的链表
private static ListNode buildLinkedList(String[] values, int pos) {
if (values.length == 0 || values[0].equals("")) {
return null;
}
ListNode[] nodes = new ListNode[values.length];
// 创建所有节点
for (int i = 0; i < values.length; i++) {
nodes[i] = new ListNode(Integer.parseInt(values[i]));
}
// 连接节点
for (int i = 0; i < values.length - 1; i++) {
nodes[i].next = nodes[i + 1];
}
// 如果pos有效,创建环
if (pos >= 0 && pos < values.length) {
nodes[values.length - 1].next = nodes[pos];
}
return nodes[0];
}
// 查找节点在链表中的索引
private static int findNodeIndex(ListNode head, ListNode target) {
ListNode current = head;
int index = 0;
while (current != null) {
if (current == target) {
return index;
}
current = current.next;
index++;
// 防止无限循环
if (index > 10000) {
break;
}
}
return -1;
}
}
class Solution {
public ListNode detectCycle(ListNode head) {
// 边界条件处理
if (head == null || head.next == null) {
return null;
}
// 初始化快慢指针
ListNode slow = head;
ListNode 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;
}
}
Python
# 链表节点定义
class ListNode:
def __init__(self, x):
self.val = x
self.next = None
class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
# 边界条件处理
if not head or not head.next:
return None
# 初始化快慢指针
slow = head
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
def build_linked_list(values, pos):
if not values:
return None
# 创建所有节点
nodes = [ListNode(int(val)) for val in values]
# 连接节点
for i in range(len(nodes) - 1):
nodes[i].next = nodes[i + 1]
# 如果pos有效,创建环
if pos >= 0 and pos < len(nodes):
nodes[-1].next = nodes[pos]
return nodes[0]
def find_node_index(head, target):
current = head
index = 0
while current:
if current == target:
return index
current = current.next
index += 1
# 防止无限循环
if index > 10000:
break
return -1
if __name__ == "__main__":
# 读取输入
input_str = input().strip().replace('[', '').replace(']', '')
values = input_str.split(',') if input_str else []
pos = int(input())
# 构建链表
head = build_linked_list(values, pos)
# 调用解决方案
solution = Solution()
result = solution.detectCycle(head)
# 输出结果
if result is None:
print("null")
else:
# 找到环入口节点在链表中的位置
index = find_node_index(head, result)
print(f"返回索引为 {index} 的链表节点")
JavaScript
// 链表节点定义
function ListNode(val) {
this.val = val;
this.next = null;
}
/**
* @param {ListNode} head
* @return {ListNode}
*/
var detectCycle = function(head) {
// 边界条件处理
if (head === null || head.next === null) {
return null;
}
// 初始化快慢指针
let slow = head;
let 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;
};
// 构建可能带环的链表
function buildLinkedList(values, pos) {
if (values.length === 0) {
return null;
}
// 创建所有节点
const nodes = values.map(val => new ListNode(parseInt(val)));
// 连接节点
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].next = nodes[i + 1];
}
// 如果pos有效,创建环
if (pos >= 0 && pos < nodes.length) {
nodes[nodes.length - 1].next = nodes[pos];
}
return nodes[0];
}
// 查找节点在链表中的索引
function findNodeIndex(head, target) {
let current = head;
let index = 0;
while (current !== null) {
if (current === target) {
return index;
}
current = current.next;
index++;
// 防止无限循环
if (index > 10000) {
break;
}
}
return -1;
}
// 读取输入并处理
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let inputLines = [];
rl.on('line', (line) => {
inputLines.push(line);
if (inputLines.length === 2) {
// 解析输入
const values = inputLines[0].replace('[', '').replace(']', '').split(',').filter(val => val !== '');
const pos = parseInt(inputLines[1]);
// 构建链表
const head = buildLinkedList(values, pos);
// 调用解决方案
const result = detectCycle(head);
// 输出结果
if (result === null) {
console.log("null");
} else {
// 找到环入口节点在链表中的位置
const index = findNodeIndex(head, result);
console.log(`返回索引为 ${index} 的链表节点`);
}
rl.close();
}
});
Go
package main
import (
"fmt"
"strconv"
"strings"
)
// 链表节点定义
type ListNode struct {
Val int
Next *ListNode
}
func detectCycle(head *ListNode) *ListNode {
// 边界条件处理
if head == nil || head.Next == nil {
return nil
}
// 初始化快慢指针
slow := head
fast := head
// 第一阶段:检测环
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针每次移动一步
fast = fast.Next.Next // 快指针每次移动两步
// 如果快慢指针相遇,说明存在环
if slow == fast {
// 第二阶段:找到环的入口
slow = head // 将慢指针重置到链表头部
for slow != fast {
slow = slow.Next // 两个指针都以相同速度移动
fast = fast.Next
}
return slow // 返回环的入口节点
}
}
// 如果快指针到达链表尾部,说明链表无环
return nil
}
// 构建可能带环的链表
func buildLinkedList(values []string, pos int) *ListNode {
if len(values) == 0 || values[0] == "" {
return nil
}
// 创建所有节点
nodes := make([]*ListNode, len(values))
for i, val := range values {
num, _ := strconv.Atoi(val)
nodes[i] = &ListNode{Val: num}
}
// 连接节点
for i := 0; i < len(nodes)-1; i++ {
nodes[i].Next = nodes[i+1]
}
// 如果pos有效,创建环
if pos >= 0 && pos < len(nodes) {
nodes[len(nodes)-1].Next = nodes[pos]
}
return nodes[0]
}
// 查找节点在链表中的索引
func findNodeIndex(head *ListNode, target *ListNode) int {
current := head
index := 0
for current != nil {
if current == target {
return index
}
current = current.Next
index++
// 防止无限循环
if index > 10000 {
break
}
}
return -1
}
func main() {
// 读取输入
var inputStr string
fmt.Scanln(&inputStr)
inputStr = strings.ReplaceAll(inputStr, "[", "")
inputStr = strings.ReplaceAll(inputStr, "]", "")
values := strings.Split(inputStr, ",")
var pos int
fmt.Scanln(&pos)
// 构建链表
head := buildLinkedList(values, pos)
// 调用解决方案
result := detectCycle(head)
// 输出结果
if result == nil {
fmt.Println("null")
} else {
// 找到环入口节点在链表中的位置
index := findNodeIndex(head, result)
fmt.Printf("返回索引为 %d 的链表节点\n", index)
}
}
复杂度分析
- 时间复杂度:O(n),其中n是链表中节点的数量。在最坏情况下,我们需要遍历整个链表两次:第一次是在检测环的阶段,第二次是在寻找环入口的阶段。
- 空间复杂度:O(1),我们只使用了两个指针(slow和fast),无论链表多长,额外空间都是常数级别的。
测试与优化
测试用例设计
设计几个有代表性的测试用例:
基本测试用例:
- 输入:head = [3,2,0,-4], pos = 1
- 预期输出:返回索引为1的节点(值为2的节点)
边界情况测试:
- 输入:head = [1], pos = -1(单节点无环)
- 预期输出:null
- 输入:head = [1], pos = 0(单节点自环)
- 预期输出:返回索引为0的节点(值为1的节点)
特殊输入测试:
- 输入:head = [], pos = -1(空链表)
- 预期输出:null
- 输入:head = [1,2,3,4,5], pos = 0(环连接到头节点)
- 预期输出:返回索引为0的节点(值为1的节点)
代码优化
我们的解决方案已经达到了O(n)的时间复杂度和O(1)的空间复杂度,这在理论上已经是最优的。不过,我们可以考虑以下几点优化:
性能优化:
- 在第一阶段检测到无环时立即返回,避免不必要的计算
- 对于特殊情况(如空链表或单节点链表)进行快速检查
代码简洁性优化:
- 合并相似的逻辑,减少代码重复
- 使用更清晰的变量命名,提高代码可读性
可读性优化:
- 添加详细注释,解释算法的关键步骤和数学原理
- 将复杂逻辑拆分为更小的函数,使代码结构更清晰
面试官追问环节
面试官:这个算法的时间复杂度和空间复杂度是多少?如何计算的?
我:这个算法的时间复杂度是O(n),其中n是链表中节点的数量。在最坏情况下,我们需要遍历整个链表两次:第一次是在检测环的阶段,快慢指针移动直到相遇,第二次是在寻找环入口的阶段,两个指针同步移动直到再次相遇。即使链表中有环,总的操作次数仍然与节点数成正比。
空间复杂度是O(1),因为我们只使用了两个指针变量(slow和fast),无论链表多长,额外使用的空间都是常数级别的。
面试官:你能解释一下为什么将慢指针重置到头节点,然后两个指针同速前进,它们的相遇点就是环的入口?
我:这个结论可以通过数学证明得出。假设链表头到环入口的距离为a,从环入口到快慢指针第一次相遇点的距离为b,环的长度为L。
当快慢指针第一次相遇时,假设慢指针走了s步,那么快指针走了2s步。快指针比慢指针多走的步数一定是环的长度的整数倍,即s = a + b,2s = a + b + nL,其中n是快指针在环中额外走的圈数。
解这个方程,我们得到s = nL,a + b = nL。这意味着从链表头走a步可以到达环入口,从相遇点走a步也会绕环若干圈后到达环入口。
因此,如果我们将慢指针重置到链表头,然后两个指针都以相同的速度移动,当慢指针走了a步到达环入口时,快指针也恰好走了a步,从相遇点出发绕环若干圈后也到达环入口。所以它们的相遇点就是环的入口。
面试官:如果不使用O(1)空间复杂度的解法,你还能想到其他方法吗?它们的优缺点是什么?
我:是的,我们可以使用哈希表来解决这个问题:
- 哈希表法:
- 遍历链表,对于每个节点,检查它是否已经在哈希表中
- 如果是,则该节点就是环的入口
- 如果不是,则将其加入哈希表,继续遍历
这种方法的时间复杂度也是O(n),但空间复杂度为O(n),因为在最坏情况下,我们需要将所有节点都存储在哈希表中。
优点:
- 实现简单直观,容易理解
- 只需要遍历链表一次
缺点:
- 需要额外的O(n)空间
- 在大型链表上可能会有内存压力
相比之下,双指针(Floyd's Tortoise and Hare)算法虽然实现稍复杂,需要理解其数学原理,但它只需要O(1)的额外空间,在处理大型链表时更为高效。在实际应用中,如果内存资源有限,应该优先考虑双指针算法;如果代码简洁性和可读性更重要,哈希表法可能是更好的选择。
结语
环形链表II是一个经典的链表问题,它不仅考察了对链表数据结构的理解,还融合了双指针技巧和数学思维。通过Floyd's龟兔算法,我们可以在O(n)时间和O(1)空间内优雅地解决这个问题。这种算法思想在计算机科学中有广泛应用,如循环检测、最小循环节查找等。掌握这种技巧,对于提升解决链表和图论问题的能力大有裨益,也是面试中展示算法功底的绝佳机会。