引子
在上一篇文章《30+链表问题:概览》的引言中,我提到了当初在校招时遇到的一道链表面试题。就是这道题了。下面是问题描述以及解决方案。
问题描述
给定一个链表,如何判断该链表是否成环?
解题思路
首先,一听到这个问题,脑子一下子映出下面这幅链表成环的图:
如图,上面就是一个已经成环的链表。标红的是头结点。
针对这个问题,我的解决方案是:
使用两个指针 slow、fast 遍历该链表,slow每次走一步,fast走两步(实际上,只需要两个指针一快一慢即可)。如果 fast 指针 走到头,那说明链表中不存在环。否则,两个指针在无尽的旅程中一定会相遇。
使用一快一慢指针的这种算法,称为龟兔算法。
为了说明白这个情况:我还专门做了动态图(实际上,为了做这个动态图,我还花30大洋买了个软件。。。。):
代码实现
这里,LinkedList是我自己定义的链表类、Node是我自己定义的结点类。源码请参考附件,或《30+链表问题:概览》。
/**
* 判断一个链表是否成环
*
* @param list 链表
* @return true Or false
*/
private boolean isContainCircle(LinkedList list) {
// 两个指针,刚开始都指向 头结点
Node slow = list.getHead();
Node fast = list.getHead();
// 如果链表成环,则直到量指针相遇之前,该循环将一直进行;
while (fast != null && fast.getNext() != null) {
/* fast 每次走两步,slow 走一步;
* 在 fast 为null 之前,slow一定不为 null */
slow = slow.getNext();
fast = fast.getNext().getNext();
// 这里直接比较其地址即可
if (slow == fast) {
return true;
}
}
return false;
}
我们写代码测试一下:
public static void main(String[] args) {
Main runner = new Main();
LinkedList list = runner.buildLinkedListWithCircle();
boolean containCircle = runner.isContainCircle(list);
System.out.println("该链表是否包含环:" + containCircle);
}
/**
* 构建出示例中的包含环的链表
*
* @return 包含环的链表
*/
private LinkedList buildLinkedListWithCircle() {
LinkedList list = new LinkedList();
Node nodeOf2 = null;
for (int i = 1; i <= 7; i++) {
Node node = new Node(String.valueOf(i) + i);
list.add(node);
if (i == 2) {
nodeOf2 = node;
}
}
list.add(nodeOf2);
return list;
}
上面构建出的链表即上面我给的图片中的链表。我们执行这段代码,得到的结果如下:
该链表是否包含环:true
如果我们备注掉 buildLinkedListWithCircle 方法中的 list.add(nodeOf2); 这一行代码,再执行,得到结果:
该链表是否包含环:false
问题拓展
现在我们已经能判断一个链表是否包含环了。那么新的问题又来了:
- 如何找到碰撞点(即两指针相遇的节点)?
- 如何找到链表环的长度?
- 如何找到链表环的入口?
针对以上问题,其实突破口就是所谓的碰撞点。我又做了个示意图:
如上图,我假设链表头结点与链表环入口节点的距离为 x,碰撞点与链表环入口的距离为 y,假设链表环长 L。
当 slow 每次走一步、fast 每次走两步时,有以下定律:
1、碰撞点的位置不变,每次相遇都在同一个点。
2、
- 当两指针从 碰撞点出发时,slow每走完一个环长,两指针都相遇。
- 当 链表头结点与链表环入口节点的距离 x 小于或等于链表环长L时,若两指针从 头结点或者 碰撞点出发,slow 每走完一个环长,两指针相遇。
- 当链表头结点与链表环入口节点的距离 x 大于链表环长L时,若两指针从头结点出发,则在他们第一次相遇时,slow走过的步数 t 为:若 x % L == 0,则 t = x;否则 t = (x / L + 1) * L;
3、
- 当 链表头结点与链表环入口节点的距离 x 等于链表环长L时,碰撞点是链表环入口节点;
- 当 链表头结点与链表环入口节点的距离x 小于 链表环长L时,链表头结点与链表环入口节点的距离x 等于 碰撞点与链表环入口的距离y;
- 当 链表头结点与链表环入口节点的距离x 大于 链表环长L时,x 与 碰撞点到链表环入口节点的距离 y 相差 链表环的整数倍。即 x - y = u·L。u为一正整数。
4、从第三点我们可以得出:若两个每次走一步的指针 p1、p2分别同时从头结点、碰撞点出发,则他们相遇的节点即为入口节点。
需要注意的是,上面四条定律成立的前提条件——slow每次走一步,fast每次走两步!
上面定律的证明其实也很简单,更多的是需要理解:
假设 slow走过 t 步后,两指针相遇,那么有:
2t - t = n·L => t = n·L,其中n 是一个正整数。
这个结果也就是说,两指针相遇时,slow 走过的步数和链表环环长成正比。所以,碰撞点的位置永恒不变。
对于第二点定律,分三种情况考虑,第一二中情况很简单,稍微理一理就明了了。对于第三种,画个图,其实也能理解(我实在是不知道该如何表达出我的理解~)。
针对第三点定律,在第二点基础上思考,很容易就得出结果了。主要是第三点,当链表头结点与链表环入口节点的距离x 大于链表环长时,对于slow指针,假设经过 t 步后,两指针相遇,有公式:
t = n·L (定律一)
t + y - x = m·L
联合推出:
y = (m - n)·L + x => x - y = (m - n)·L => x - y = u·L
也就是说,x 与 y 的差值是 链表环长L的整数倍。
实际上,我看有的博主给出了证明。但是那个证明是不规范的,结果也是错误的。只有在 头结点到入口节点的距离x 小于 链表长度L 时,碰撞点到入口的距离才等于 头结点到入口的距离。这里我也想尝试着给出证明,但是最终并不能构建出合适的关系。
好在,这里的逻辑并不复杂,实际上,我们只要仔细思考,很容易就理解了。
对于定律四,其实就在上面定律三的基础上,稍微理解下,就可以得出。
我们分为三种情况考虑:x > y ,x = y,x < y。下面我用 x > y来举例。
如果 x - y = u·L,u > 0。有两个每次走一步的指针 p1、p2分别从 头结点、碰撞点出发。
由于碰撞点在链表环上,所以,两指针每走完一个环长L,p2指针会回到出发时的碰撞点。那么经过 u·L步后,p2 回到出发时的碰撞点,此时 p2 距离入口点的距离仍然为 y;而此时,p1 也走过了 u·L 步,p1 距离 走完 x 还剩下的距离为: x - u·L = y。也就是说,此时 p1 距离入口点的距离也为 y。
而对于另外两种情况,就简单很多了,这里我就不多加赘述了。
所以,当两个指针p1、p2分别同时从 头结点、碰撞点出发的话,他们相遇的点,就是链表环入口节点。现在,我们回到上面拓展的三个问题:
1、如何找到碰撞点?
其实这个就很简单了,和判断链表是否存在环算法一样,只是这里在 slow == fast 时,将slow 或者 fast 指向的节点返回即可。
/**
* 获取碰撞节点
*
* @param list 一个包含环的链表
* @return 碰撞节点
*/
private Node getMeetNode(LinkedList list) {
Node slow = list.getHead();
Node fast = list.getHead();
while (fast != null && fast.getNext() != null) {
slow = slow.getNext();
fast = fast.getNext().getNext();
if (slow == fast) {
// 这里随便返回 fast 或者 slow 都可以
return slow;
}
}
throw new IllegalArgumentException("链表不含环!");
}
2、如何找到链表环的长度?
这个就很简单了,我们已知了碰撞点,那我们就直接记录在此遇到碰撞点时走过的步数,即可求得链表环长。
/**
* 获取链表环长
*
* @param list 链表
* @return 环长
*/
private int getLinkedListCircleLength(LinkedList list) {
Node meetNode = this.getMeetNode(list);
Node p = meetNode;
int len = 0;
while (p.getNext() != null) {
p = p.getNext();
++len;
if (p == meetNode) {
return len;
}
}
throw new IllegalArgumentException("链表不含环!");
}
3、如何找到链表环的入口?
这里我们使用两个每次都走一步的指针 p1、p2,分别同时从头结点、碰撞点出发,他们相遇的那个点,就是链表环入口。
/**
* 获取 链表环的入口节点
*
* @param list 链表
* @return 入口节点
*/
private Node getEntranceNode(LinkedList list) {
// p1 从头结点出发
Node p1 = list.getHead();
// p2 从碰撞点出发
Node p2 = this.getMeetNode(list);
// 链表就一个环,相遇点就是头结点时,直接返回
if (p1 == p2) {
return meetNode;
}
while (p1.getNext() != null) {
p1 = p1.getNext();
p2 = p2.getNext();
if (p1 == p2) {
return p1;
}
}
throw new IllegalArgumentException("链表不含环!");
}
最后,我们修改一下main方法:
public static void main(String[] args) {
Main runner = new Main();
LinkedList list = runner.buildLinkedListWithCircle();
boolean containCircle = runner.isContainCircle(list);
System.out.println("该链表是否包含环:" + containCircle);
Node meetNode = runner.getMeetNode(list);
System.out.println("碰撞点的值为:" + meetNode.getValue());
Node entranceNode = runner.getEntranceNode(list);
System.out.println("入口节点的值为:" + entranceNode.getValue());
int circleLen = runner.getLinkedListCircleLength(list);
System.out.println("链表环长为:" + circleLen);
}
执行,输出结果为:
该链表是否包含环:true
碰撞点的值为:77
入口节点的值为:22
链表环长为:6
done。问题解决部分就讲到这里了。
总结
在处理链表问题的时候,双指针、多指针是常见的结题方式。这种问题见得多了,感觉都是套路。
我感觉上面的定律证明的部分,写得并不严谨。我实在是没找到一种合理的数学证明方式。但是我相信,只要仔细去理解,其实还是没问题的。
文中代码作者只是为了做个演示,有的细节并没有做严格校验。目前没有发现有严重的错误,如果有发现问题的朋友,欢迎指出哦~希望和大家一起进步~
源码附件
源代码:https://download.youkuaiyun.com/download/zereao/11705715
GitHub:https://github.com/Zereao/LinkListInterviewQuestion/tree/master/LinkedListContainCircle