数据结构与算法
算法双指针快慢指针
快慢指针:移除、找中点与判环
快慢指针是双指针里最"省空间"的形态:两个指针同向而行,fast 负责探路,slow 负责记账,全程不需要额外数组。本文把三个最经典的应用放在一起对比,会发现它们是同一个思想在不同数据结构上的投影。
场景一:原地移除元素(27)
题目:原地删除数组中所有值等于 val 的元素,返回新长度。
暴力做法是发现一个删一个、把后面整体前移,最坏 O(n²)。快慢指针换一个角度:fast 只负责扫描判断,slow 指向"下一个可写入的位置"。
数组:0 1 2 3 2 4 (val = 2)
fast: 0✓写 1✓写 2✗跳 3✓写 2✗跳 4✓写
slow: 0→1 1→2 2(停) 2→3 3(停) 3→4
数组变为:0 1 3 4 | 2 4 (竖线后是残留数据,题目不关心)
返回 slow = 4,即新长度
class Solution {
public int removeElement(int[] nums, int val) {
int slow = 0; // 下一个可写入的位置
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != val) { // fast 探到该保留的元素
nums[slow++] = nums[fast]; // 写入 slow 位置
}
}
return slow; // slow 恰好就是新长度
}
}这个"快探慢写"模板是通用的:283 移动零(非零数前移再补零)、26 删除有序数组中的重复项,都只是换一个判断条件。
场景二:链表找中点(876)
fast 每次走 2 步,slow 每次走 1 步,fast 到头时 slow 恰好在中间:
class Solution {
public ListNode middleNode(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; // 1 步
fast = fast.next.next; // 2 步
}
return slow;
}
}步长 2:1 意味着 fast 走完全程时 slow 恰好走了一半:
- 奇数长度:slow 停在正中间的节点。
- 偶数长度:slow 停在中间偏后的节点(返回的是第二个中点,876 正是这个要求)。
while 条件必须是 fast != null && fast.next != null,且顺序不能反,否则 fast 为 null 时先访问 fast.next 会空指针。
场景三:链表判环(141)
fast 走 2 步、slow 走 1 步,只要链表有环,两者一定会在环内相遇:
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true; // 在环内追上
}
return false; // fast 走到头,无环
}
}为什么必然相遇?
分两段看:
- fast 一定能进入环:只要存在环,步长大的 fast 必然先于(或同时)进入环。
- 进环后必然追上:以 slow 为参照物,fast 每走一步相对靠近 1 个节点(2 - 1 = 1)。设进环瞬间两者在环内相距 d(d ≥ 1),每走一步 d 减 1;d 是有限值,迟早减到 0——相遇。
关键在于"每次恰好靠近 1":距离逐格递减,不会出现一步跨过 slow 的情况。这也是步长差必须为 1 的原因:若 fast 走 3 步、slow 走 1 步,相对步长为 2,在偶数环长下可能永远互相错过。
三大场景对比
| 维度 | 移除元素(27) | 链表中点(876) | 链表判环(141) |
|---|---|---|---|
| fast 职责 | 扫描找该保留的值 | 探测是否到链表尾 | 快速绕环 |
| slow 职责 | 指向下一个写入位置 | 记录中间位置 | 慢速绕环 |
| 指针差异 | 步长相同,"写不写"不同 | 步长 2:1 | 步长 2:1 |
| 终止条件 | fast 扫完数组 | fast 或 fast.next 为空 | 相遇或 fast 到头 |
| 复杂度 | O(n) / O(1) | O(n) / O(1) | O(n) / O(1) |
一个有意思的对比:移除元素里两个指针步长都是 1,差别在于"写不写";链表的两个场景里差别在步长本身。前者制造的是空间上的错位,后者制造的是时间上的错位——同一思想的两种投影。
易错点
- 27 里 slow 初值写成 -1 或返回
slow - 1:slow 的语义就是新长度,初值 0、直接返回 slow。 - 876 的 while 条件把
fast.next != null写在前面:短路顺序反了,fast 为 null 时先解引用直接 NPE。 - 141 的相等判断放在移动之前:初值都是 head,第一轮就"相遇",直接误判成有环。
- 142 找入环点:相遇后让一个指针回到 head,两个指针每次各走 1 步,再次相遇处即入环点;这一步的数学推导(头到入环距离 = 相遇点绕环到入环距离)值得自己写一遍。
题目清单
| 题号 | 题名 | 一句话考点 |
|---|---|---|
| 27 | 移除元素 | 快探慢写,原地删除 |
| 283 | 移动零 | 非零数前移,末尾补零 |
| 876 | 链表的中间结点 | 步长 2:1,偶数长度返回第二个中点 |
| 141 | 环形链表 | 相对速度为 1 必然相遇 |
| 142 | 环形链表 II | 判环后同步前进定位入环点 |
| 19 | 删除链表的倒数第 N 个结点 | 快指针先走 N 步制造固定间距 |