目录
一、题目介绍
二、解题思路
2.1 判断链表中是否有环
2.1.1 快慢指针法(Floyd判圈算法)
2.2 如何找到环的入口
三、 代码
四、总结
题目链接: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 解释:链表中没有环。
进阶:你是否可以使用 O(1)
空间解决此题?
这道题目其实有两问,第一问是,判断链表是否有环?第二问是,若有环,找到入环点?
Floyd判圈算法(Floyd Cycle Detection Algorithm),又称龟兔赛跑算法(Tortoise and Hare Algorithm),是一个可以在有限状态机、迭代函数或者链表上判断是否存在环,求出该环的起点与长度的算法。该算法据高德纳称由美国科学家罗伯特·弗洛伊德【美国计算机科学家,1978年图灵奖得主】发明。
如果有限状态机、迭代函数或者链表上存在环,那么在某个环上以不同速度前进的2个指针必定会在某个时刻相遇。同时显然地,如果从同一个起点(即使这个起点不在某个环上)同时开始以不同速度前进的2个指针最终相遇,那么可以判定存在一个环,且可以求出2者相遇处所在的环的起点与长度。
为什么说快慢指针的使用就一定能说明链表中是否有环?
我们想一个问题,如果某单向链表中无环,假设快指针每次移动两步,而慢指针每次移动一步,起初,快慢指针同时位于单向链表的头节点处,随后开始移动,在这样的情况下,慢指针是永远不可能追上快指针,且慢指针与快指针的距离会越拉越大,该过程如下图所示。
如果说链表中有环,即使快慢指针之间存在速度差,在非环部分慢指针与快指针不可能相遇,但当快指针进入到环中时,快指针会在环中不间断移动,那么此时慢指针终有一时也会进入到环中,当两个指针均进入到环中,这不就成了物理学上的快慢追击问题了吗?快指针何时才能追到慢指针?而这样的场景下快指针也一定能够追上慢指针,只是时间问题。这里借用了代码随想录网站上该章节的图例,如下图所示。
如何确定快慢指针每次移动的步数才能确保快慢指针在环中以最少次数相遇?
要确保快慢指针(Floyd 判环算法)在环中 以最少次数相遇,需要合理选择快指针和慢指针的步长。通常,我们使用以下策略:
- 慢指针(slow) 每次移动 1 步
- 快指针(fast) 每次移动 2 步
为什么 2 倍速是最优解?
假设链表有一个环,环的长度为 C,环的入口点距离链表起点的长度为 d,那么:
进入环后,相对速度决定相遇时间:
- 设慢指针进入环的初始位置为 P。
- 由于快指针比慢指针快 1 倍(即每次比慢指针多走 1 步),它们之间的 相对速度 是 2−1=1(即快指针相对慢指针每次前进 1 步)。
- 设快慢指针初始进入环的位置相距 k 步,即快指针在慢指针之后k步,设在经过t s后相遇,则有,慢指针到相遇点的距离(慢指针的速度为1步/s)加上慢指针与快指针之间的距离k步等于快指针到相遇点的距离(快指针的速度为2步/s),则有,1*t+k=2*t,则相遇时间为: t=ks 也就是说,快指针和慢指针将在 k s后相遇。
为什么不是更快的指针?
- 如果快指针步长增加,比如变为 3、4,甚至 m:
- 相对速度变为 m−1,相遇时间变为 k/(m−1)。
- 可能会 跳过慢指针,导致无法相遇。
- 除非 m 和 C 互质,否则可能永远不会相遇。
数学证明
快指针每次移动 2 步,慢指针每次移动 1 步,所以它们的相对位置(距离)每次减少 1,最终将在环的某个位置相遇。
如果快指针每次移动 m 步,而慢指针每次移动 1 步,则相对速度变为 m−1。为了保证它们在环中相遇,必须确保 (m−1) 能整除环的长度 C。但如果 m > 2,可能会因为 m-1 与 C 有公因数的问题,导致指针跳过相遇点,反而增加了相遇所需的时间。
结论
- 最优选择是快指针步长为 2,慢指针步长为 1。
- 这样保证它们在环内的移动差距恒定,确保相遇且最少步数。
假设从头结点到环形入口节点的节点数为x。 环形入口节点到 fast指针与slow指针相遇节点节点数为y。 从相遇节点再到环形入口节点节点数为 z。 如图所示:
那么相遇时: slow指针走过的节点数为: x + y
, fast指针走过的节点数:x + y + n (y + z)
,n为fast指针在环内走了n圈才遇到slow指针, (y+z)为 一圈内节点的个数。
因为fast指针是一步走两个节点,slow指针一步走一个节点, 所以 fast指针走过的节点数 = slow指针走过的节点数 * 2:(x + y) * 2 = x + y + n (y + z)
两边消掉一个(x+y): x + y = n (y + z)
因为要找环形的入口,那么要求的是x,因为x表示 头结点到 环形入口节点的的距离。
所以要求x ,将x单独放在左面:x = n (y + z) - y
,
再从n(y+z)中提出一个 (y+z)来,整理公式之后为如下公式:x = (n - 1) (y + z) + z
注意这里n一定是大于等于1的,因为 fast指针至少要多走一圈才能相遇slow指针。
这个公式说明什么呢?
先拿n为1的情况来举例,意味着fast指针在环形里转了一圈之后,就遇到了 slow指针了。
当 n为1的时候,公式就化解为 x = z
,这就意味着,从头结点出发一个指针,从相遇节点 也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是 环形入口的节点。
也就是在相遇节点处,定义一个指针index1,在头结点处定一个指针index2。
让index1和index2同时移动,每次移动一个节点, 那么他们相遇的地方就是 环形入口的节点。
那么 n如果大于1是什么情况呢,就是fast指针在环形转n圈之后才遇到 slow指针。
其实这种情况和n为1的时候 效果是一样的,一样可以通过这个方法找到 环形的入口节点,只不过,index1 指针在环里 多转了(n-1)圈,然后再遇到index2,相遇点依然是环形的入口节点。
补充:在推理过程中,大家可能有一个疑问就是:为什么第一次在环中相遇,slow的 步数 是 x+y 而不是 x + 若干环的长度 + y 呢?
首先slow进环的时候,fast一定是已经在环中了。
情况1:如果slow进环入口,fast也在环入口,那么把这个环展开成直线,就是如下图的样子:
可以看出如果slow 和 fast同时在环入口开始走,一定会在环入口3相遇,slow走了一圈,fast走了两圈。
情况2:重点来了,slow进环的时候,fast在环的任意一个位置,如图:
那么fast指针走到环入口3的时候,已经走了k + n 个节点,slow相应的应该走了(k + n) / 2 个节点。【fast指针的移动速度是慢指针的二倍】
因为k是小于n的(图中可以看出),所以(k + n) / 2 一定小于n。
也就是说slow一定没有走到环入口3,而fast已经到环入口3了。
这说明什么呢?
在slow开始走的那一环已经和fast相遇了。
那有同学又说了,为什么fast不能跳过去呢? 在刚刚已经说过一次了,fast相对于slow是一次移动一个节点,所以不可能跳过去。
好了,这次把为什么第一次在环中相遇,slow的 步数 是 x+y 而不是 x + 若干环的长度 + y ,用数学推理了一下。
// 环形链表
#include
struct LinkNode{
int data; // 定义链表节点的数据域
LinkNode* next; // 定义链表节点的指针域
LinkNode(int x):data(x),next(nullptr){}; // 定义节点构造函数
};
LinkNode* detectCycle(LinkNode* head){
LinkNode* fast = head; // 定义快指针
LinkNode* slow = head; // 定义慢指针
while(fast!=nullptr && fast->next!=nullptr){ // fast指针每次移动两步,因此要确保fast->next!=nullptr
fast=fast->next->next; // 快指针每次移动两步
slow=slow->next; // 慢指针每次移动一步
if(fast==slow){ // 快慢指针相遇,说明有环存在,且fast和slow指向的节点是相遇节点
LinkNode* index1 = fast; // index1 指向相遇节点
LinkNode* index2 = head; // index2 指向链表头节点、
while(index1!=index2){
index1=index1->next;
index2=index2->next;
}
// index1与index2指针相遇之处便是环的入口节点
return index1; // return index2;也可以
}
}
return nullptr; // 如果没找到,返回nullptr
}
int main(){
LinkNode* head = new LinkNode(3);
head->next = new LinkNode(2);
head->next->next = new LinkNode(0);
head->next->next->next = new LinkNode(-4);
head->next->next->next->next = head->next; // 链表成环,尾节点与第二个节点,也即pos=1的节点链接
LinkNode* CycleHead = detectCycle(head);
std::cout<data<
算法的复杂度:
时间复杂度分析
该程序分为两个主要部分:
- 检测环是否存在(快慢指针相遇)
- 寻找环的入口节点
第一部分:检测环是否存在
- 设链表中 环外部分 长度为 d,环的长度 为 C。
- 快指针每次移动 2 步,慢指针每次移动 1 步,它们在环内的 相对速度 为 1(快指针每次比慢指针多走 1 步)。
- 当慢指针进入环时,快指针已经可能在环中,但最多还需 C 步才能追上慢指针。
- 因此,整个环检测过程最多需要 d+C 步。
第二部分:寻找环的入口
- 快慢指针相遇后,使用 双指针法,从相遇点和头节点分别前进,每次移动 1 步,直到它们相遇。
- 该过程需要 最多 d 步。
总时间复杂度
- 检测环的时间复杂度:O(d+C)
- 入口检测的时间复杂度:O(d)
- 总时间复杂度:O(d+C),即 线性时间 O(n)。
空间复杂度分析
- 只使用了 固定的几个指针变量(
fast
、slow
、index1
、index2
),没有额外的数据结构。- 空间复杂度:O(1)(常数级)。
总结
- 时间复杂度:O(n)(最多遍历两次链表)
- 空间复杂度:O(1)(仅使用常数级指针变量)
本题目核心有两方面,其中第一个方面是检测链表中是否有环,第二个方面是找到链表环中的入口处。
对于如何检测链表中是否有环的存在?解题思路中给出了快慢指针的思想,快指针每次移动两步,而慢指针每次移动一步,如果快慢指针相遇,就说明链表中存在环结构。
对于如何找到链表环结构的入口节点?根据fast指针与slow指针相遇处节点的位置到环结构入口的距离与链表的头节点到环结构入口的距离相等【需数学推导】,定义两个指针,分别从链表头节点以及相遇处节点出发,直到两个指针相等,就说明找到了环结构入口节点,此时两个指针均指向入口节点。
推荐视频讲解:
把环形链表讲清楚! 如何判断环形链表?如何找到环形链表的入口? LeetCode:142.环形链表II_哔哩哔哩_bilibili