一、什么是双指针
双指针(Two Pointers)是指在遍历数据结构时使用两个指针变量协同移动的一种技巧。两个指针按特定规则前进,从而避免暴力枚举所有组合,把原本 O(n²) 的双重循环优化到 O(n)。
双指针 = 用两个游标在数组/链表上协同运动,利用"单调性"剪枝,把双层循环压成单层。
双指针并不是一种具体的算法,而是一类思想的总称。根据两个指针的运动方向不同,主要分三种形态:
1.1 三种主要形态
| 类型 | 初始位置 | 运动方向 | 典型应用 |
|---|---|---|---|
| 对撞指针 | 一头一尾 | 左→←右 相向而行 | 有序数组求两数之和、回文判定 |
| 快慢指针 | 同一起点 | 快指针走两步,慢指针走一步 | 链表找中点、判环 |
| 左右指针(滑动窗口) | 同一起点 | 右指针扩,左指针缩 | 区间求和、最长/最短子串 |
不同教材对"双指针"的细分叫法略有差异。本文把"一头一尾相向运动"称为对撞指针,"同向快慢运动"称为快慢指针,"同向左缩右扩"称为滑动窗口(滑动窗口单列为另一篇专门讲,这里只做对照)。
双指针之所以高效,核心是"单调性剪枝":当某对 (i, j) 不满足条件时,我们能根据单调性直接排除掉一整片不可能的组合,而不需要逐一尝试。
二、核心原理与图解
理解双指针的关键,是理解"为什么指针只往一个方向走,不会错过答案"。下面分别用三张图展示对撞、快慢、滑动三种典型过程。
2.1 对撞指针过程图
以有序数组 [2, 3, 4, 6, 8, 11, 15] 找和为 15 的两数为例。两个指针 left、right 分别从两端出发,根据 nums[left] + nums[right] 与目标的大小关系决定移动哪一侧。
2.2 快慢指针过程图
快慢指针多用于链表。两个指针同时从头出发,快指针一次走两步,慢指针一次走一步。当快指针到达末尾时,慢指针恰好在链表中点。
2.3 左右指针滑动过程图
左右指针同向而行:右指针 right 主动右扩,左指针 left 在窗口不满足条件时右缩。下面以"和 ≥ 7 的最短子数组"演示这一过程。
2.4 复杂度分析
无论哪种形态,两个指针累计移动次数不超过 2n(对撞是从两端向中各 n 次;快慢各走 n 与 n/2;滑动窗口的 left、right 各最多走 n 次)。所以总复杂度是线性的。
💾 空间复杂度:O(1)
只用了两个指针变量和少数状态量,原地操作。
三、通用模板
双指针没有"一种模板吃所有题"的统一写法,但对撞和快慢各有典型骨架。下面给出两种最常见的模板。
3.1 对撞指针模板
// 对撞指针通用模板:在有序数组中找满足条件的两数 vector<int> twoSumSorted(const vector<int>& nums, int target) { int left = 0, right = (int)nums.size() - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { return {left, right}; // 命中 } else if (sum < target) { ++left; // 和太小,左指针右移让和变大 } else { --right; // 和太大,右指针左移让和变小 } } return {-1, -1}; // 无解 }
1. 数组必须有序——对撞指针依赖"和随指针移动单调变化"的性质
2. 循环条件 left < right——指针重合或越界即结束
3. 一次只移动一侧——根据当前和与目标的关系决定走哪边
3.2 快慢指针模板(链表找中点)
// 快慢指针通用模板:找链表中点 struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* findMiddle(ListNode* head) { if (!head) return nullptr; ListNode* slow = head; // 慢指针,一次走 1 步 ListNode* fast = head; // 快指针,一次走 2 步 while (fast->next && fast->next->next) { slow = slow->next; fast = fast->next->next; } return slow; // 偶数个节点时返回前半段最后一个 }
1. 判环用 fast != nullptr && fast->next != nullptr——快指针走两步,必须保证两步都非空
2. 找中点用 fast->next && fast->next->next——让偶数链表时 slow 落在前半段末尾
3. 链表题的特殊性——无法回退,所以快慢是处理链表的"标配技巧"
四、示例一:两数之和 II(有序数组)
题目(LeetCode 167):给定一个已升序排序的数组 numbers,找到两个数使其和等于目标 target。返回这两个数的下标(从 1 开始),且下标小的在前。保证恰好有一个解。
4.1 示例
输入:numbers = [2, 7, 11, 15],target = 9
输出:[1, 2](即 2 + 7 = 9)
4.2 C++ 代码
#include <vector> using namespace std; vector<int> twoSum(const vector<int>& numbers, int target) { int left = 0, right = (int)numbers.size() - 1; while (left < right) { int sum = numbers[left] + numbers[right]; if (sum == target) { return {left + 1, right + 1}; // 题目要求下标从 1 开始 } else if (sum < target) { ++left; // 和太小,左指针右移 } else { --right; // 和太大,右指针左移 } } return {-1, -1}; // 不会执行到,题目保证有解 }
这就是对撞模板的直接套用,注意返回时下标加 1 即可。如果题目没说"恰好一个解",要在找到后继续移动指针收集所有解。
五、示例二:三数之和
题目(LeetCode 15):给定数组 nums,找出所有和为 0 的不重复三元组。答案中不能包含重复的三元组。
5.1 思路:固定一个,对撞两个
三数之和是两数之和的扩展:先排序,再枚举第一个数 a = nums[i],然后在 [i+1, n-1] 区间用对撞指针找两数之和等于 -a。去重的关键是相邻相同值跳过。
5.2 C++ 代码
#include <vector> #include <algorithm> using namespace std; vector<vector<int>> threeSum(vector<int>& nums) { vector<vector<int>> res; sort(nums.begin(), nums.end()); // 排序是对撞指针的前提 int n = (int)nums.size(); for (int i = 0; i < n - 2; ++i) { if (nums[i] > 0) break; // 剪枝:a > 0 则三数之和必 > 0 if (i > 0 && nums[i] == nums[i - 1]) continue; // a 去重 int left = i + 1, right = n - 1; while (left < right) { int sum = nums[i] + nums[left] + nums[right]; if (sum == 0) { res.push_back({nums[i], nums[left], nums[right]}); // b、c 去重:跳过相邻相同值 while (left < right && nums[left] == nums[left + 1]) ++left; while (left < right && nums[right] == nums[right - 1]) --right; ++left; --right; } else if (sum < 0) { ++left; } else { --right; } } } return res; }
1. a 去重:
if (i > 0 && nums[i] == nums[i-1]) continue;2. b 去重:命中后 left 右移直到值变化
3. c 去重:命中后 right 左移直到值变化
去重的本质是"同值只让第一次出现的下标产生答案"。
六、示例三:盛最多水的容器
题目(LeetCode 11):给定 n 条垂直线,第 i 条高度为 height[i],两线与 x 轴围成的容器能盛多少水。返回最大盛水量。
6.1 贪心思路:每次移动较矮的一侧
容器水量 = min(h[L], h[R]) * (R - L)。两个指针从两端向中间走,每次只移动较矮的一侧——因为移动较高的一侧不可能让水量增加(高度被较矮一侧限制,宽度反而变小)。
6.2 C++ 代码
#include <vector> using namespace std; int maxArea(const vector<int>& height) { int left = 0, right = (int)height.size() - 1; int bestArea = 0; while (left < right) { // 容器水量 = 短板高度 × 宽度 int h = min(height[left], height[right]); int w = right - left; bestArea = max(bestArea, h * w); // 关键贪心:总是移动较矮的一侧 if (height[left] < height[right]) { ++left; } else { --right; } } return bestArea; }
当 height[L] < height[R] 时,水量被 L 限制。如果移动 R,宽度变小、高度仍被 L 限制,水量必减少;只有移动 L 才有可能(虽然不保证)让高度上升从而水量增加。所以移动较矮的一侧是唯一可能找到更优解的方向。
七、总结与适用场景
双指针核心要点
| 维度 | 要点 |
|---|---|
| 本质 | 两个游标协同运动,借助单调性剪枝把 O(n²) 优化到 O(n) |
| 时间复杂度 | O(n)——两指针累计移动 ≤ 2n 次 |
| 空间复杂度 | O(1)——原地操作,只用常数变量 |
| 识别信号 | "有序数组 + 找两数""链表找中点/判环""区间按条件伸缩""有序 + 回文" |
| 常见变体 | N 数之和(N≥2)、滑动窗口、快慢指针判环、回文判定 |
典型题目清单
- LeetCode 167:两数之和 II(对撞,基础)
- LeetCode 15:三数之和(对撞 + 枚举)
- LeetCode 16:最接近的三数之和(对撞 + 枚举)
- LeetCode 11:盛最多水的容器(对撞 + 贪心)
- LeetCode 283:移动零(快慢同向)
- LeetCode 141/142:环形链表判环/找入口(快慢)
- LeetCode 876:链表的中间结点(快慢)
- LeetCode 344:反转字符串(对撞交换)
- LeetCode 125:验证回文串(对撞)
"有序找两数,对撞头尾移;链表找中点,快慢同出发;同向找区间,左缩右扩追。"
这三句话覆盖了双指针的三大主流场景,遇到题目先按"是否有序""是数组还是链表"两步判断选哪种形态即可。