【双指针技巧】链表与快慢指针专题

快慢指针

快慢指针是链表问题中双指针技巧的一种典型应用。它通过让两个指针以不同的速度移动,利用它们之间的位置关系来解决一些链表问题。

链表的中间节点

876. 链表的中间结点

这道题要求我们找到链表的中间节点(距离链表头部 n/2 个节点的位置)。

一个很自然的思路是,先遍历一遍链表,求出链表的长度 n。

知道链表长度之后,中间节点的位置也就确定了:只需从 head 开始,向后走 n / 2 步,就可以找到题目要求的中间节点。

复盘一下,我们不难发现这种思路需要遍历两趟链表,分别在求链表长度以及找到中间节点时各遍历了一次。

其实,本题存在一种只需要遍历一趟的解法。我们不妨换一个方式,重新审视这个问题。

假如有一段长度未知的距离:

▼
text
复制代码
A -------------------------------- B

现在就你一个人,我需要你精确找到 AB 这一段的中点。你自己想想,这个任务能圆满完成吗?

显然不能。

因为事先不知道 AB 的长度,所以即使知道自己已经从 A 走了多远,也无法判断这段距离占 AB 全程的多少,自然无法确定自己是否走到了中点。

既然一个人无法在不知道总长度的情况下确定中点,我们可以尝试增加一个人,让他作为前一个人的参照。

现在,我们让两个人都从 A 出发,其中一个人的任务仍然是找到 AB 的中点,而另一个人则负责走完 AB 全程。

接下来就需要考虑这一问题:这两个人要按照什么样的节奏前进,才能同时完成各自的任务?

我们先明确两个人各自需要走多远。

第一个人的目标是 AB 的中点,也就是说,当任务完成时,他只需要走完 AB 的一半;第二个人的目标是 B,也就是说,他需要走完整个 AB。

由于两个人同时从 A 出发,我们可以把第二个人到达 B 的时刻作为判断中点的时刻。此时,两个人经过的时间相同。

根据公式: $$ 路程 = 速度 × 时间 $$ 在时间相同的情况下,走过的路程与速度成正比。由于第一个人走了 AB 的一半,第二个人走了完整的 AB,因此第二个人的速度应该是第一个人的 2 倍。

于是,我们可以让一个人每次向前移动 1 个单位的距离,另一个人每次向前移动 2 个单位的距离。

那么,回到链表中,我们应该如何理解这里的**“一个单位的距离”**呢?

链表是离散结构,我们每次沿 next 只能前进到下一个节点,所以沿 next 前进一步,就记作一个单位的距离。

于是,前面的结论就可以具体实现为:一个指针每次沿 next 前进 1 次,另一个指针每次沿 next 前进 2 次。

基于上述思路,我们不难写出如下代码:

▼
java
复制代码
public ListNode middleNode(ListNode head) { ListNode p = head, q = head; while (q != null && q.next != null) { p = p.next; q = q.next.next; } return p; }

上述操作只需遍历一趟链表,因此时间复杂度为 $O(n)$。

我们刚才让两个指针以不同的速度移动:一个指针每次沿 next 前进 1 次,另一个指针每次沿 next 前进 2 次。

在链表问题中,这种通过两个移动速度不同的指针来解决问题的方法,就称为快慢指针法。

环形链表 Ⅰ

前面我们已经通过快慢指针解决了链表中点问题。接下来,我们来看看它是如何解决环形链表相关问题的。

141. 环形链表

本题需要我们判断给定链表中是否存在环,例如这样一个链表:

image.png

在链表没有环的情况下,我们可以正常从头走到尾。但是,一旦链表中存在环结构,此时我们永远也无法走到 null。

那么,在不知道链表是否存在环的情况下,我们如何判断自己是不是一直在环中兜圈子?

这个问题其实很容易想到一种直接的解决方案:记录自己已经经过的节点。

我们可以维护一个 Set 集合,用于存储遍历过程中经过的节点。每遍历到一个新的节点,就将其加入 Set。如果当前遍历到的节点已经在 Set 中,说明我们之前已经经过这个节点,而现在又重新回到了这里,那么就可以判断链表中存在环。

反过来,如果最终遍历到了 null,说明链表中不存在环。

分析一下此方案的时间复杂度,由于每个节点最多只会被遍历一次,而 Set 的 add 和 contains 操作平均时间复杂度都是 $O(1)$,因此总的时间复杂度是 $O(n)$。

但是这种方案需要维护 Set 集合,最坏的情况下,链表的 n 个节点都要进入 Set 中,因此空间复杂度为 $O(n)$。

这个方案虽然能奏效,但是题目最后提出了一个进阶要求:能否使用 $O(1)$ 的空间复杂度解决这个问题?

如果要求空间复杂度为 $O(1)$,那么我们就不能再像刚才那样,用 Set 记录所有经过的节点。那么,还有没有其他办法判断自己是不是已经绕了一圈?

我们还是先从一个人想起。如果只有一个人沿着链表不断向后走,那么当他再次经过某个节点时,他自己其实并不知道自己之前是否来过这里。因为他没有记录,也没有其他东西可以作为参考。

既然一个人无法判断自己是否一直在环中兜圈子,那么我们可以尝试增加一个人。

此时,就好比两个人在一个环形跑道上赛跑。如果一个人速度慢,另一个人速度快。那么我们显然可以知道:在将来的某个时刻,两个人一定会再次相遇。

一个人跑得快、一个人跑得慢……欸?等等!!这不就和我们前面遇到过的快慢指针极其类似么?没错,快慢指针法正是解决这个问题的关键。

然而,我们现在只知道,快慢指针需要两个速度不同的指针,至于当前问题具体应该采用怎样的速度关系,我们目前并不知道。

在寻找链表中间节点这一问题中,我们曾经推导出 $1:2$ 的速度关系。但那个结论是为了找到中点而得到的,到了这个问题,我们不能想当然地认为它仍然成立。(虽然它确实成立,而且很多题解和代码模板也都是直接告诉你 ”快 2 慢 1“ 的结论)。

这一次,我们不去套用已有的结论,而是从头推导为什么这里继续沿用 $1:2$ 的速度关系。

我们先考虑一般化,设:

  • 链表中存在环;
  • 慢指针每次走 $a$ 步,快指针每次走 $b$ 步;
  • 快慢指针的相对速度 $d = b - a$;
  • 环长为 $L$。

因为快指针更快,所以快指针一定先进入环。当慢指针也进入环后,以慢指针第一次进入环的时刻为起点。

此时把慢指针所在的那个节点,记为环上的位置 0。从该节点出发,沿链表前进方向把环上的节点依次编号为:

$$ 0,1,2,...,L - 1 $$ 设快指针在编号为 $k$ 的位置,其中: $0 ≤ k < L$

此后经过 $t$ 轮后,慢指针所在编号为:

$$ t \cdot a \pmod L $$

快指针所在编号为:

$$ k + t \cdot b \pmod L $$

二者要相遇,当且仅当:

$$ k + t \cdot b \equiv t \cdot a \pmod L $$

移项:

$$ t \cdot (b-a) \equiv -k \pmod L $$

即:

$$ t \cdot d \equiv -k \pmod L $$

这是一个关于 $t$ 的同余方程。在数论中,这个同余方程有解,当且仅当: $$ \gcd(d,L) \mid k $$

gcd 即最大公约数,此式子读作:gcd(d,L) 整除 k。

也就是说:

  • 若 $\gcd(d, L) = 1$,则无论 $k$ 是多少,方程都有解,因此二者最终一定能够相遇。
  • 若 $\gcd(d, L) > 1$,则只有当 $k$ 是 $\gcd(d,L)$ 的倍数时,方程才有解;否则方程无解,二者永远不会相遇。

然而我们事先既不知道环长 L,也不知道慢指针进入环时快慢指针之间的相对位置 k。为了让判环算法对任意链表都成立,就必须保证对于任意可能的 L 和 k,上述同余方程都有解,就必须满足: $$ \gcd(d,L)=1 $$ 进一步地,如果要求它对任意正整数 L 都成立,那么 d 必须与所有正整数互质。正整数中只有 1 满足这一条件,因此 $d = 1$。

$d = 1$ 只确定了快指针只比慢指针多走 1 步,而满足这个条件的组合有很多,例如:

  • 慢指针每次走 1 步,快指针每次走 2 步;
  • 慢指针每次走 2 步,快指针每次走 3 步;
  • 慢指针每次走 3 步,快指针每次走 4 步;
  • ...

但通常取 “慢指针每次走 1 步,快指针每次走 2 步” 这一策略。

随便举个例子你就明白了。假如你头铁,非要选 “慢指针每次走 3 步,快指针每次走 4 步” 这一策略。你在真正移动之前,还需要进行一系列判空,例如:

▼
java
复制代码
while (fast != null && fast.next != null && fast.next.next != null && fast.next.next.next != null) { slow = slow.next.next.next; fast = fast.next.next.next.next; }

这样的代码你能接受么?

而 $a=1,b=2$ 时,代码实现起来会更简单:

▼
java
复制代码
while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; }

更重要的是,慢指针每次走 1 步,能保证它不会跳过链表中的任何一个节点。

如果慢指针一次走 2 步或 3 步,虽然对于单纯的“判环”来说,只要相对速度为 1,最终仍然可以相遇,但我们在后续问题中往往还需要利用指针的具体位置关系,例如寻找环入口。此时,前面基于“慢 1、快 2”推导出的距离关系就不能直接使用了。因此,“慢 1 快 2”是最稳妥、最通用的选择。

经过上述大段论述,我希望你能明白:“让快指针每次走2步,慢指针每次走1步” 不再是轻飘飘的模板结论,而是对实现简单性和后续可复用性进行权衡后得到的最自然选择。

基于上述思路,我们不难写出如下代码:

▼
java
复制代码
public boolean hasCycle(ListNode head) { ListNode fast = head, slow = head; while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; if (fast == slow) { return true; } } return false; }

上述操作只需遍历一趟链表,因此时间复杂度为 $O(n)$。

环形链表 Ⅱ

前面我们已经通过快慢指针解决了链表是否存在环的问题。接下来,我们继续思考一个更进一步的问题:环形链表 Ⅱ

142. 环形链表 II

本题不再只是要求我们判断链表中是否存在环,而是要求我们找到环的入口节点。

例如,对于下面这个链表:

image.png

如果链表存在环,那么我们最终需要找到的,就是图中标红的这个节点。

在上一题中我们已经知道,让慢指针每次走 1 步、快指针每次走 2 步,可以保证二者最终在环中相遇。这一次,我们就从这个第一次相遇的位置出发,重点推导第一次相遇时,快慢指针与环入口之间存在怎样的距离关系。

我们设以下变量:

  • 链表头到环起点的距离为 $x$;
  • 从环起点沿链表前进方向走到相遇点的距离为 $y$;
  • 从相遇点沿链表前进方向走到环起点的距离为 $z$。

不难得知环长 $L = y + z$,对应图示:

▼
text
复制代码
head ──x──▶ 环起点 ──y──▶ 相遇点 ▲ │ └──── z ───────┘

相遇时,慢指针走过的距离为: $$ x + y $$ 由于快指针更快,所以二者相遇时,快指针除了走过与慢指针相同的 $x+y$ 外,还会在环中多绕若干整圈。因此其走过的距离为: $$ x + y + nL $$

其中 $n \ge 1$,表示快指针比慢指针多绕的整圈数。

由于快指针的速度是慢指针的 2 倍,所以总路程有以下关系: $$ 2(x + y) = x + y + nL $$ 化简: $$ x = nL - y $$ 代入 $L = y + z$ ,并整理: $$ x = (n - 1)L + z $$ 这个式子说明了什么?

这个式子说明:从相遇点出发走 x 步,会先经过 z 步到达环起点,然后再绕环 (n-1) 圈。

这一结论非常有趣且重要。想想看,如果我们把其中一个指针从相遇点拉回 head,然后快慢指针都按照 “每次走一步” 的步伐走下去。

在二者各走了 $x$ 步过后,会再次在哪相遇?—— 环起点呀!!!

你想想是不是这样?

基于上述思路,我们不难写出如下代码:

▼
java
复制代码
public ListNode detectCycle(ListNode head) { ListNode fast = head, slow = head; while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; if (fast == slow) { fast = head; while (fast != slow) { fast = fast.next; slow = slow.next; } return fast; } } return null; }

上述操作中,两个阶段的指针移动次数总计为 $O(n)$,因此时间复杂度为 $O(n)$。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP