双指针解决什么问题
如果一道题让你枚举所有区间,最直接的写法是两层循环:左端点一层,右端点一层。这样通常是 O(n^2)。
双指针想做的事情,是让左端点和右端点都只往前走。每个元素最多被左指针经过一次、被右指针经过一次,整体就可能降到 O(n)。
所以双指针的重点不是“写两个变量”,而是确认这两个变量能不能单调移动。
相向双指针
相向双指针通常用在有序数组里。一个指针从左边走,一个指针从右边走。
比如经典的两数之和:数组已经有序,找是否存在 a[l] + a[r] == target。
int l = 0, r = n - 1;
while (l < r) {
int sum = a[l] + a[r];
if (sum == target) {
cout << "YES\n";
break;
} else if (sum < target) {
l++;
} else {
r--;
}
}
为什么可以这样走?因为数组有序。当前和太小,左指针右移才可能变大;当前和太大,右指针左移才可能变小。这就是单调性。
同向双指针
同向双指针更像是在维护一个区间。右指针不断扩张,左指针在条件不满足时向右收缩。
比如所有元素为正数,找和至少为 S 的最短连续子数组:
int ans = n + 1;
long long sum = 0;
int l = 0;
for (int r = 0; r < n; r++) {
sum += a[r];
while (sum >= S) {
ans = min(ans, r - l + 1);
sum -= a[l];
l++;
}
}
这里能成立的关键是数组元素为正。右指针右移,窗口和只会变大;左指针右移,窗口和只会变小。如果有负数,这个单调性就不一定存在。
滑动窗口
滑动窗口可以看成同向双指针的一种常见形式:维护一个连续窗口,让窗口满足某个条件。
比如最长不含重复字符的子串,可以用哈希表记录窗口里每个字符出现次数。
int ans = 0;
vector<int> cnt(256, 0);
int l = 0;
for (int r = 0; r < (int)s.size(); r++) {
cnt[s[r]]++;
while (cnt[s[r]] > 1) {
cnt[s[l]]--;
l++;
}
ans = max(ans, r - l + 1);
}
这段代码的节奏很固定:右边加入一个元素,窗口不合法就从左边删,合法后更新答案。
固定长度窗口
有些题窗口长度固定,比如“长度为 k 的子数组最大和”。这种题甚至不需要 while,只要保持窗口大小就行。
long long sum = 0, ans = LLONG_MIN;
for (int r = 0; r < n; r++) {
sum += a[r];
if (r >= k) sum -= a[r - k];
if (r >= k - 1) ans = max(ans, sum);
}
固定窗口的关键是:每次右边进来一个,左边最多出去一个。它更像一个队列。
分组双指针
还有一种很常见的写法叫分组双指针:把连续相同、连续满足某种性质的一段一次性处理掉。
int i = 0;
while (i < n) {
int j = i;
while (j < n && a[j] == a[i]) j++;
// [i, j) 是一段相同元素
int len = j - i;
i = j;
}
这类写法在去重、统计连续段、压缩字符串、处理相同颜色块时很常见。
什么时候不能用
双指针最怕的是你以为它能动,但其实没有单调性。
- 数组里有负数时,窗口和不一定随右指针增加而增加;
- 条件不是连续区间性质时,删掉左端点可能影响很复杂;
- 排序会破坏原始位置关系时,不能随便先排序;
- 左右指针移动方向不明确时,可能还是需要别的数据结构或 DP。
写双指针前,最好先问一句:为什么这个指针一旦右移,就不需要再回头?能回答清楚,才是真的能用。
这一篇先记住什么
- 双指针的核心是单调移动,不是两个变量。
- 相向双指针常配合排序,同向双指针常维护连续窗口。
- 滑动窗口的基本节奏是:右边加入,左边收缩,合法后更新答案。
- 只要指针会回头,复杂度就可能不再是线性的。
下一篇进入栈、队列和单调结构。它们会把“最近一个更大值”“窗口最大值”这类问题处理得很漂亮。