上次我们分析了一下双指针的妙用,这次来看一个名字上稍微相关的算法技巧,叫做快慢指针。快慢指针算法又叫做龟兔赛跑算法,因采用两个移动速度不同的指针来解决问题而得名。可能第一次见这个会有点奇怪,两个移动速度不同的指针能有什么用?其实这个技巧主要用来解决链表或者数组有环的问题,可以这么说,学会了这个技巧,80%链表相关的题目我们都会做了。
在一个环形赛道上,兔子跟龟赛跑,兔子速度快,总会绕着赛道从后面再次跟乌龟相遇。这个思想可以直接适用到链表有环问题上,只要链表有环(即没有终点)那快慢指针总会再相遇。
我们来看一道题目:给定一个有环单链表,编写一个函数找到它的环的起始点。
1的位置。
现在假设我们有快慢指针,同时指向链表的起点。然后每次迭代,慢指针向前移动一步,而快指针则向前移动两步,那我们就知道两个事实:
- 如果链表无环,快指针会率先到达终点(即访问到null),则可以证明链表无环;
- 如果链表无环,慢指针永远追不上快指针。
而如果链表有环,那么快慢指针会无休止地移动,它们总会再相遇,只要相遇即可判定为有环。我们来分析下快慢指针快相遇的时候:
- 如果快指针在慢指针后面一步远,那下次迭代的时候快指针走两步,慢指针走一步,俩指针相遇;
- 如果快指针在慢指针后面两部远,那下次迭代时,快指针走两步,慢指针走一步,这时候就变成了情况1的情形,再迭代一次它们就能相遇了。
那么怎样才能知道起始点的位置呢,我们可以这么做:
- 设置快慢指针指向链表的开头;
- 移动俩指针直到它们相遇;
- 记录相遇点,移动慢指针继续向前走,同时开始记录走过的步长,每走一步记录一次,当再次遇到我们之前记录的相遇点时,我们就能知道环的长度了,我们假设这个长度是K;
- 假设链表长度为M,我们再次把快慢指针放到链表开头,然后把快指针往前移动K步,由于快指针已经走了K步,只要再走M-K步就能到达环的起始点,而环的长度为K,也就是说慢指针从链表开头走M-K步就能到达环的起始点,换句话说,现在同时开始迭代,这次每次迭代俩指针都往前走一步,当俩指针再相遇的时候,相遇点就是环的起始点。
好了,说了一大堆理论,我们直接来看代码实现:
public static ListNode findCycleStart(ListNode head) {
int cycleLength = 0;
// 用快慢指针来寻找环
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (slow == fast) { // 找到环了
cycleLength = calculateCycleLength(slow);
break;
}
}
return findStart(head, cycleLength);
}
private static int calculateCycleLength(ListNode slow) {
ListNode current = slow;
int cycleLength = 0;
do {
current = current.next;
cycleLength++;
} while (current != slow);
return cycleLength;
}
private static ListNode findStart(ListNode head, int cycleLength) {
ListNode pointer1 = head, pointer2 = head;
// 把pointer2向前移动'cycleLength'个节点
while (cycleLength > 0) {
pointer2 = pointer2.next;
cycleLength--;
}
// 同时向前移动两个指针直到再次相遇
while (pointer1 != pointer2) {
pointer1 = pointer1.next;
pointer2 = pointer2.next;
}
return pointer1;
}
为了判断链表是否有环,时间复杂度为O(N),为了找到环的起始点,又花了O(N),那我们最后的时间复杂度就是O(N),而且空间复杂度维持在了O(1),这个很棒。
通过这个例子大家可以发现快慢指针是一种很自然的思维,它比使用一个数据结构来保存已访问的节点来判断链表是否有环要高效得多,下次在遇到类似得判断是否有环得问题,大家肯定知道该怎么做了。