一、快慢指针——解决"环"与"中间位置"
核心思想:两个指针从同一位置出发,一个走得快(每次两步),一个走得慢(每次一步)。
场景 1:检测环形链表(LeetCode 141)
问题:给你一个链表,判断是否有环。
为什么快慢指针能检测环?
想象两个人在环形操场上跑步。快的人跑两步,慢的人跑一步。如果有环,快的人最终会"套圈"慢的人(再次相遇)。如果没环,快的人先到终点(nullptr)。
不带环的链表:
1 → 2 → 3 → 4 → 5 → nullptr
快指针先到 nullptr → 无环
带环的链表:
1 → 2 → 3 → 4 → 5 → 3(回到 3)
快指针走两步,慢指针走一步:
1: 快=3, 慢=2
2: 快=5, 慢=3
3: 快=3, 慢=4 ← 回到 3 了!
4: 快=5, 慢=5 ← 相遇!有环
整体操作流程:
- 边界检查:如果
head为空或只有一个节点,不可能有环,直接返回false。 - 初始化:快慢两个指针都指向链表头节点
head。 - 遍历移动:每次循环中,慢指针向前走一步,快指针向前走两步。
- 相遇检测:每走一步后,检查两个指针是否指向同一节点。如果是,说明快指针"套圈"了慢指针,链表有环,返回
true。 - 终止条件:如果快指针到达了链表末尾(
fast == nullptr或fast->next == nullptr),说明链表无环,返回false。
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
bool hasCycle(ListNode* head) {
if (!head || !head->next) return false; // ① 空链表或单节点 → 无环
ListNode* slow = head; // ② 慢指针从 head 出发
ListNode* fast = head; // ③ 快指针也从 head 出发
while (fast && fast->next) { // ④ 快指针没到终点就继续
slow = slow->next; // ⑤ 慢指针走一步
fast = fast->next->next; // ⑥ 快指针走两步
if (slow == fast) { // ⑦ 快慢相遇 → 有环
return true;
}
}
return false; // ⑧ 快指针到尽头 → 无环
}
时间复杂度:$O(N)$。有环时最多走一圈半就相遇;无环时 fast 先到终点。 空间复杂度:$O(1)$。只用两个指针。
场景 2:找链表中间节点
整体操作流程:
- 初始化:快慢指针都指向链表头。
- 同步移动:慢指针每次走一步,快指针每次走两步。
- 循环终止:当快指针到达末尾(
fast == nullptr或fast->next == nullptr)时停止。 - 返回结果:此时慢指针正好指向链表的中间节点。
为什么 slow 正好在中间?
- 奇数长度:1→2→3→4→5,fast 停在 5,slow 停在 3(正中间)。
- 偶数长度:1→2→3→4,fast 停在 nullptr,slow 停在 3(后半段起点)。
ListNode* middleNode(ListNode* head) {
ListNode* slow = head; // ① 慢指针从 head 出发
ListNode* fast = head; // ② 快指针也从 head 出发
while (fast && fast->next) { // ③ 快指针能走两步就继续
slow = slow->next; // ④ 慢指针走一步
fast = fast->next->next; // ⑤ 快指针走两步
}
return slow; // ⑥ slow 正好在中间
}
二、对撞指针——解决"两数之和"类问题
核心思想:两个指针分别指向数组的两端,向中间移动。适合有序数组。
场景 1:盛最多水的容器(LeetCode 11)
问题:数组 height 表示垂线高度,找出两条线和 x 轴组成的容器能装最多水。
直观解法:穷举所有 (i, j) 组合,$O(N^2)$ 太慢。
对撞指针思路:
height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
初始:left=0 (高度1), right=8 (高度7)
面积 = min(1,7) × (8-0) = 1×8 = 8
移动较矮的那一侧:left++ → left=1 (高度8)
现在:left=1 (高度8), right=8 (高度7)
面积 = min(8,7) × (8-1) = 7×7 = 49 ← 最大!
移动较矮的那一侧:right-- → right=7 (高度3)
继续...
整体操作流程:
- 初始化:左指针指向数组开头(
left = 0),右指针指向数组末尾(right = len-1)。 - 计算面积:当前容器的宽度 =
right - left,高度 = 两根垂线中较矮的那根,面积 = 高 × 宽。 - 更新最大值:用当前面积更新全局最大面积。
- 移动指针:移动较矮的那一侧——如果
height[left] < height[right],左指针右移;否则右指针左移。 - 重复:重复步骤 2-4,直到左右指针相遇。
- 返回:返回记录的最大面积。
为什么移动较矮的那一侧? 如果移动较高的那一侧,新高度 ≤ 原高度,且宽度减少,面积一定变小。移动较矮的一侧才有机会遇到更高的柱子,让面积增大。
int maxArea(vector<int>& height) {
int left = 0; // ① 左指针指向开头
int right = height.size() - 1; // ② 右指针指向末尾
int max_area = 0;
while (left < right) { // ③ 指针未相遇时循环
int h = min(height[left], height[right]);// ④ 取较矮的高度
int w = right - left; // ⑤ 计算宽度
max_area = max(max_area, h * w); // ⑥ 更新最大面积
if (height[left] < height[right]) { // ⑦ 移动较矮的一侧
left++;
} else {
right--;
}
}
return max_area;
}
为什么移动较矮的那一侧?
假设 height[left] < height[right],当前面积 = height[left] × (right-left)。
如果移动 right(较高侧):
- 新高度 ≤
height[right],但height[left]不变。 - 宽度减少,高度 ≤ 原高度,面积一定变小。
如果移动 left(较矮侧):
- 新高度可能变大,宽度减少,面积可能变大。
- 虽然不一定变大,但至少有变大的可能。
时间复杂度:$O(N)$。左右指针总共走 N 步。
场景 2:三数之和(LeetCode 15)
问题:数组中找到所有 a + b + c = 0 的三元组,不能重复。
nums = [-1, 0, 1, 2, -1, -4]
排序后:[-4, -1, -1, 0, 1, 2]
固定第一个数 i,用对撞指针找剩下的两个:
i=0 (-4): 需要在 [-1, -1, 0, 1, 2] 中找两数和为 4
对撞指针:left=1 (-1), right=5 (2) → sum=-1+2=1,太小 → left++
left=2 (-1), right=5 (2) → sum=1,太小 → left++
left=3 (0), right=5 (2) → sum=2,太小 → left++
left=4 (1), right=5 (2) → sum=3 → 找不到
i=1 (-1): 需要在 [-1, 0, 1, 2] 中找两数和为 1
对撞指针:left=2 (-1), right=5 (2) → sum=1 ✅ 找到 (-1, -1, 2)
left=3 (0), right=4 (1) → sum=1 ✅ 找到 (-1, 0, 1)
整体操作流程:
- 排序:先将数组从小到大排序,这是后续使用对撞指针的前提。
- 固定第一个数:遍历数组,将
nums[i]作为三元组的第一个数。 - 去重:如果
nums[i]和前一个数相同,跳过(避免重复三元组)。 - 对撞指针找剩下两个:在
[i+1, n-1]区间内:- 计算
target = -nums[i],问题转化为"找两数之和等于 target"。 - 左指针指向
i+1,右指针指向末尾。 - 如果
nums[left] + nums[right] == target:记录结果,跳过重复元素,同时收缩指针。 - 如果和小于
target:左指针右移(增大和)。 - 如果和大于
target:右指针左移(减小和)。
- 计算
- 返回结果集。
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end()); // ① 排序(对撞指针的前提)
vector<vector<int>> result;
for (int i = 0; i < (int)nums.size() - 2; i++) {// ② 固定第一个数
if (i > 0 && nums[i] == nums[i-1]) continue; // ③ 跳过重复的第一个数
int left = i + 1; // ④ 左指针从 i+1 开始
int right = nums.size() - 1; // ⑤ 右指针从末尾开始
int target = -nums[i]; // ⑥ 需要找两数和为 -nums[i]
while (left < right) { // ⑦ 对撞指针找两数
int sum = nums[left] + nums[right];
if (sum == target) { // ⑧ 找到一组解
result.push_back({nums[i], nums[left], nums[right]});
while (left < right && nums[left] == nums[left+1]) left++; // 跳过重复
while (left < right && nums[right] == nums[right-1]) right--;
left++; // ⑨ 继续找下一组
right--;
} else if (sum < target) {
left++; // ⑩ 和太小 → 左指针右移
} else {
right--; // ⑪ 和太大 → 右指针左移
}
}
}
return result;
}
时间复杂度:$O(N^2)$。排序 $O(N\log N)$ + 外层循环 $O(N)$ × 内层对撞 $O(N)$。 如何避免重复:排序 + 跳过相邻的相同元素。
三、面试追问 Q&A
Q1:快慢指针中,为什么 fast 要走两步而不是三步? A:三步可能导致快指针跳过慢指针(比如环很小),而且步幅越大越容易在边界处出错。两步是最安全的——保证每走一次,快慢指针的距离 ±1。
Q2:对撞指针为什么要求数组有序? A:无序时,移动 left 或 right 你无法预测新的 sum 是变大还是变小。有序时,left++ 一定让 sum 增大(或不变),right– 一定让 sum 减小,你才能根据 sum 与 target 的关系决定移动方向。
Q3:三数之和能不能不用排序? A:排序是为了让对撞指针工作 + 去重。不排序需要用哈希表(两数之和的扩展),处理去重更复杂,时间仍然是 $O(N^2)$ 但空间 $O(N)$。排序法空间 $O(1)$ 更优。