如何判断链表中是否有环并找出环的入口位置

前言

前面我们分析链表的时候提了到有一种循环链表,那么假如现在给定一个单向链表的头节点,如何判断这个链表是否存在环?假如这个链表存在环,又该如何判断环的入口位置呢?

如何判断链表存在环

这道题在 leetcode 的第 142 题以及《剑指 offer》 上的第 22 题均由提到,是一个比较有意思的题目。

对于判断单向链表中是否存在环这个问题,我们可以先思考一下,如果存在环,那么当前链表会有什么特点?

存在环就说明链表的尾节点不是指向 null,而是指向了链表中的另一个节点,只有这样才会构成环,如下图所示就是一个存在环的单向链表:

哈希法

假如说这个算法不限制空间复杂度的话,也就是说允许我们在计算过程中申请额外的空间,那么我们可以直接使用哈希表来处理。

具体的做法就是直接遍历链表,并且判断当前节点是否存在于哈希表中,如果存在,那就说明当前链表存在环,如果不存在,那么我们就将当前链表节点存入哈希表中,并继续往后遍历,直到发生哈希碰撞为止。如果存在环,那么就一定会发生哈希碰撞,如果不存在环,那么就一定有一个节点的 next 指针指向 null,所以循环也会终止。

public static boolean isCircleByHash(ListNode head){
    if (null == head){
        return false;
    }

    Set<ListNode> set = new HashSet<>();//定义哈希集合
    while (null != head){
        if (set.contains(head)){//存在说明有环
            return true;
        }
        set.add(head);
        head = head.next;
    }
    return false;
}

快慢双指针法

上面的介绍的哈希判断法额外申请了空间来存储,所以上面算法的空间复杂度是 O(n),那么有没有空间复杂度是 O(1) 的方法来判断当前链表是否存在环呢?

我们设想一下这么个场景,比如说有两个人在圆形跑道上跑步,一个人一秒钟跑一米,另一个人一秒钟跑两米,然后他们两同时开始并且一直跑,那么他们会不会相遇呢?

答案是只要体力允许,他们两一定会在某一个点相遇;相反,如果是直线跑道,那么他们就不可能相遇。所以我们可以将这个思想利用起来,这就是快慢双指针法

快慢双指针法主要步骤为:定义两个指针,一个 slow 指针,一次走一步;另一个 fast 指针,一次走两步。如果可以在某一个点满足 slow=fast,那么就说明存在环,否则 fast 指针必然先到到终点。

 public static boolean isCircleByTwoPoint(ListNode head){
     if (null == head || null == head.next){
         return false;
     }
     ListNode slow = head;
     ListNode fast = head;
     while (null != fast && null != fast.next){//注意这个条件,要防止空指针
         slow = slow.next;//slow 指针一次一步
         fast = fast.next.next;//fast指针一次两步
         if (slow == fast){
             return true;
         }
     }
     return false;
 }

如何判断链表中环的位置

环中的位置,用文中开头的环形链表来说,就是节点 2,所以假如需要我们寻找这个环的入口位置,那么该如何查找呢?

大家可能很容易想到,我们上面的哈希法判断是否存在环的同时其实也能找到环的位置,那么现在如果我们不利用哈希法又该如何寻找环的位置呢?

利用双指针法可行吗?如果直接利用上面的快慢双指针法是行不通的,因为快慢指针相遇的位置不一定就是环的入口。

那么该如何解决这个问题呢?答案还是要使用双指针,但是除了双指针之外,还需要再引入一个指针(当然,也可以复用原来的快慢指针)。

为什么快指针只走 2 步

首先我们来分析一下上面的快慢双指针判断是否存在环的方法中,为什么快指针只走 2 步,而不是 3 步,4 步,甚至更多呢?

回答这个问题之前,我们先来看看假如快指针走 3 步会有什么问题?我们还是以文中开头的环形链表为例子,当第一次 fast 走了 3 步,slow1 步之后,快慢指针位置如下图所示:

这时候 fast 继续走 3 步,而 slow 继续走 1 步,快慢指针位置如下图所示:

我们发现,快指针超过了慢指针,又跑到前面去了,当然, 最终他们还是会相遇,但是这会导致一个问题,那就是当快慢指针相遇时,我们无法知道快指针在环形里面走了多少圈,也无法知道慢指针在环形里面走了多少圈,这会导致很难推断环的入口位置。

而如果快指针走 2 步呢?那么当慢指针也入环之后,每走一次,慢指针都会和快指针拉开 1 步距离;而反过来想,相当于是快指针每次都在以缩短 1 步的距离来追赶 slow 指针,因为每次只缩短 1 步,所以快慢指针一定不会出现错过相遇点的情况

快指针任何时候走的距离一定为慢指针的 2 倍

fast 指针在任何时候走的距离一定是 slow 指针的 2 倍,这个结论我想大家都没什么疑问,知道了这个结论之后,我们再来看一下下面这幅图:

上图中以节点为划分,有三段链表,abc 分别表示三段的距离,我们假设当快慢指针相遇的时候,快指针已经在环中走了 n 圈,那么就可以得到快指针走过的距离为:a+n*(b+c)+b,而慢指针走过的距离为 a+b,根据快指针走的路程一定是慢指针的两倍,可以得到如下等式:

a+n*(b+c)+b = 2*(a+b),最终经过转换,得到 a=(n-1)*(b+c) + c

到这里可能有人会有疑问,快慢指针相遇的时候,为什么慢指针一定没有走完一圈呢?如果慢指针也走了 m 圈,那么慢指针走过的距离就是 a+m*(b+c)+b 了,但是这其实是不可能的。

为什么快慢指针相遇时慢指针没有走完一圈

我们假设这个环的长度为 x,那么当 slow 指针走完一圈时,需要走 x 步,而当 slow 指针走完 x 步,fast 指针已经走了 2x 步了,也就是走了两圈了,那么他们一定在某一个点相遇过了(因为他们不可能错过相遇点),所以当快慢指针第一次相遇时,慢指针是不可能走完一圈的。

利用第三个指针找到环的位置

继续回到上面的等式:a=(n-1)*(b+c) + c,然后我们可以发现,其实 b+c 正好等于环的长度,也就是说:从链表头部到入环的距离(a)恰好等于从相遇点到入环点的距离(c)再加上 n-1 圈个环的长度。

这时候就有个有趣的现象了,如果 slowfast 相遇了,那么这时候我们再定义一个指针指向链表起点,一次走一步,slow 指针也同步继续往后走,那么这两个指针就一定会在链表的入口位置相遇

public static ListNode findCyclePosition(ListNode head){
    if (null == head || null == head.next){
        return null;
    }
    ListNode slow = head;
    ListNode fast = head;
    while (null != fast && null != fast.next){
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast){//快慢指针相遇时
            ListNode ptr = head;//定义一个新指针
            while (ptr != slow){
                ptr = ptr.next;
                slow = slow.next;
            }
            return ptr;//返回环的入口位置
        }
    }
    return null;
}

总结

本文主要讲述了利用哈希法和双指针法来判断一个单向链表是否存在环,而最后判断环的位置时,则需要利用快慢指针相遇后的特性来定义第三个指针,从而找到入口位置,其实找到环的位置代码不难,关键是推理的过程需要去理解。

Tags: