在 n = 2×10⁵ 的数组里找两个数,使它们的和等于 x。双重循环 O(n²) = 4×10¹⁰ 次比较,TLE。
如果数组是有序的,事情立刻不一样:l 指最左、r 指最右,看 a[l] + a[r] 与 x 的大小关系。和太大,说明 a[r] 配谁都太大,r 左移;和太小,说明 a[l] 配谁都太小,l 右移。每一步都能确定地丢掉一整排候选,而不是只丢一个。
| 组成 | 含义 / 格式要求 |
|---|---|
执行方式 | 每轮开始前重新求值,为真就执行循环体,再回去求值。所以循环体必须改变条件里的量,否则死循环。 |
和 for 的分工 | for 用于“次数已知”(i 从 1 到 n);while 用于“次数由数据决定”(一直缩到窗口合法为止)。双指针的骨架通常是外层 for、内层 while。 |
循环不变式 | 进循环前先写下一句恒成立的事实,例如“当前窗口 [l, r] 内的和 ≥ S”。循环体每轮都在维护它,退出时它连同否定条件一起给出结论。 |
动笔前问三句 | ① 进入循环时什么成立?② 循环体让什么改变?③ 退出时什么成立、能推出什么? |
int best = n + 1; // 当前最优长度,n+1 表示“还没有”
// 收缩:只要窗口还覆盖全部 m 种,就试着把 l 往右推
while (have == m) {
// 此刻不变式成立:[l, r] 覆盖了全部 m 种
if (r - l + 1 < best) best = r - l + 1;
if (--cnt[a[l]] == 0) --have; // 移出 a[l],可能弄丢一种
++l; // 让不变式可能被打破
}
// 退出时 have < m:[l, r] 已经不合法,正好可以继续右移 rwhile 的条件和循环体必须配套:上面靠 --cnt、--have、++l 让 have 变小,条件迟早为假。
内层 while 的总执行次数不超过 n,因为每次执行 l 都加 1,而 l 最大到 n+1。这就是它 O(n) 的全部理由。
| 组成 | 含义 / 格式要求 |
|---|---|
r(右边界) | 归外层 for 管,每轮只 +1,绝不停留、绝不回退。负责扩张。 |
l(左边界) | 只在 while 里 ++,绝不减小。负责收缩。 |
进窗口 | 把 a[r] 的影响加进维护量:sum += a[r]、cnt[a[r]]++。 |
出窗口 | 把 a[l] 的影响减掉:sum -= a[l]、cnt[a[l]]--,然后 ++l。 |
不合法 | “要最短”的题:不合法 = 条件不再满足;“求最长”的题:不合法 = 超过限制。骨架完全一样,只换布尔表达式。 |
更新答案 | 在 while 收缩前或收缩后那一瞬,[l, r] 是“以 r 结尾、不能再短”的窗口,从它更新答案。 |
对称性 | 维护量必须“加什么减什么”:进窗口加的量,出窗口一个都不能漏,否则误差会一直累积。 |
int l = 1; long long sum = 0;
int ans = n + 1; // n+1 表示“还没有答案”
for (int r = 1; r <= n; ++r) {
sum += a[r]; // 扩张
while (sum >= S && l <= r) { // 合法就缩短
ans = min(ans, r - l + 1);
sum -= a[l]; // 收缩
++l;
}
}l <= r 必须写在条件里:窗口缩空后 sum 变 0,若不检查 l,l 会跑到 r 右边继续减。
l 和 r 一定要定义在 for 外面。写进 for 里每轮重置,指针退化成 O(n²)。
先看两端收缩。问题:给一个升序数组 a[1..n] 和 x,判断是否存在 i ≠ j 使 a[i] + a[j] = x。
int l = 1, r = n;
bool ok = false;
while (l < r) {
int s = a[l] + a[r];
if (s == x) { ok = true; break; }
else if (s < x) ++l; // a[l] 配谁都太小,这一整排候选全部作废
else --r; // a[r] 配谁都太大,这一整列候选全部作废
}为什么不会漏解?设当前 a[l] + a[r] < x。对任意 j ≤ r,因为数组升序,a[l] + a[j] ≤ a[l] + a[r] < x,所以 a[l] 和任何右端点都凑不出 x——l 可以永久丢弃。反过来 a[l] + a[r] > x 时,a[r] 配任何左端点都太大,r 可以永久丢弃。每一次移动都丢掉一整排,总共丢 n 次就结束。
所以两端收缩的真正前提是单调性:丢掉的东西以后不可能再有用。数组没排序时这个论证不成立,必须先花 O(n log n) 排序。
给 n 个正整数 a₁…aₙ 和一个正整数 C,统计有多少个有序对 (i, j) 满足 aᵢ − aⱼ = C。数值相同但位置不同的对算作不同的对。
输入:第一行两个正整数 N、C(1 ≤ N ≤ 2×10⁵,1 ≤ C < 2³⁰);第二行 N 个整数,0 ≤ aᵢ < 2³⁰。
输出:一行一个整数,即满足条件的数对个数。
直接双重枚举是 O(n²) = 4×10¹⁰,TLE。题目里 A 和 B 都是数组元素,条件只有一个:A − B = C。
先排序。固定 B = a[i],则需要的 A = a[i] + C。由于相同值的元素排在一起,“等于 a[i] + C 的那些位置”在数组里是连续的一段。问题变成:对每个 i,求出这一段有多长,全部加起来。
关键观察:a[i] 升序,所以 a[i] + C 也升序。“第一个 ≥ a[i]+C 的位置”和“第一个 > a[i]+C 的位置”都只会往右走。两个指针 lo、hi 定义在循环外,各自单向前进,总移动不超过 2n。
复杂度:排序 O(n log n) + 双指针 O(n)。答案上界约 n²/4 = 10¹⁰(一半元素是 v、一半是 v+C 时取到),超出 int,必须 long long。
为什么不会重复、不会漏:i 遍历了每一个位置作为 B,窗口按值精确框住了所有可能的 A,一个 (位置 j, 位置 i) 对只会在 i 取到那个位置时被数一次。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
int a[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, c;
cin >> n >> c;
for (int i = 1; i <= n; ++i) cin >> a[i];
sort(a + 1, a + n + 1); // 有了单调性,指针才有意义
long long ans = 0; // 最多约 2×10¹⁰,int 装不下
int lo = 1, hi = 1; // 窗口 [lo, hi) 装的是所有等于 a[i]+c 的位置
for (int i = 1; i <= n; ++i) {
long long t = (long long)a[i] + c;
while (lo <= n && a[lo] < t) ++lo; // lo 停在第一个 ≥ t 的位置
while (hi <= n && a[hi] <= t) ++hi; // hi 停在第一个 > t 的位置
ans += hi - lo; // 窗口长度 = 值为 t 的元素个数
}
cout << ans << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
sort(a + 1, a + n + 1); | 把“找某个值”变成“找连续段”,同时让 a[i]+c 随 i 单调。没有这一步,指针一步都走不了。 |
long long ans | n = 2×10⁵ 时最坏约 n²/4 = 10¹⁰ 对(一半是 v、一半是 v+C),远超 int 的 2.1×10⁹,用 int 直接 WA。 |
int lo = 1, hi = 1; | 两个指针都从 1 开始,且定义在 for 外面——“只增不减”是它们能 O(n) 的前提。写进 for 里就退化成 O(n²)。 |
long long t = (long long)a[i] + c; | a[i] 可以接近 2³⁰,加 c 有溢出 int 的风险,先转 long long 再加。 |
while (lo <= n && a[lo] < t) ++lo; | lo 的含义是“第一个 ≥ t 的下标”。因为 a 升序、t 随 i 不减,lo 只需右移,不必回退。 |
while (hi <= n && a[hi] <= t) ++hi; | hi 是“第一个 > t 的下标”。lo 与 hi 之间恰好是所有等于 t 的元素。 |
ans += hi - lo; | 区间长度就是“以 a[i] 为 B 的合法 A 的个数”,直接累加,不用逐个枚举。 |
cout << ans << "\n"; | 一个整数加换行,不输出任何多余字符。 |
再看同向双指针。问题:给 n 个正整数和目标 S,求最短的连续段,使段内元素和 ≥ S。
int l = 1, ans = n + 1; // ans = n+1 表示“不存在”
long long sum = 0;
for (int r = 1; r <= n; ++r) {
sum += a[r]; // 扩张:把 a[r] 纳入窗口
while (sum >= S && l <= r) { // 合法就试着去掉左端
ans = min(ans, r - l + 1);
sum -= a[l];
++l;
}
}为什么能这么缩:所有数都是正数,所以固定 r 时 sum 随 l 增大而减小,l 越小越容易满足 sum ≥ S。于是对每个 r 只存在一个“最小的可行 l”,而且 r 增大时这个 l 只增不减——这正是双指针要求的那条单调性。反过来,如果允许负数,sum 不再随 l 单调,l 一旦右移可能永远回不到合法状态,答案就漏了。
窗口里要维护的东西不一定只有和。更常见的是计数:cnt[v] 表示值 v 在窗口里出现了几次。进窗口 ++cnt[a[r]],出窗口 --cnt[a[l]]。如果要判断“窗口是否包含全部 m 种”,再维护一个 have = 窗口里的种类数:cnt 从 0 变 1 时 have++,从 1 变 0 时 have--。这样判断只要 O(1);每轮扫一遍 cnt 数组去数种类,会把 O(n) 退化成 O(nm)。
| 能不能用双指针 | 需要满足的性质 | 不满足时的症状 |
|---|---|---|
| 是 | r 右移后,最优 l 只增不减(单调性) | —— |
| 否 | 窗口内有负数,区间和不再随 l 单调 | 窗口提前收缩,漏掉解,WA 一片 |
| 否 | 维护量不能 O(1) 增减(如窗口最大值) | 要改用单调队列,否则每次 O(len) |
| 否 | 丢掉的部分以后可能重新有用 | “和恰好为 S 的子数组个数”这类题不能随便丢 l |
TLE(死循环):while 条件里的量在循环体里没变。例如 while (sum >= S) { ans = min(ans, ...); } 忘了 sum -= a[l]; ++l;。
WA(漏解):收缩过头,把“刚好合法”的窗口也缩没了。先更新答案再收缩,或把条件写成严格不等号。
WA(重复计数):把 lo、hi 声明在 for 里面,每轮重置回 1。指针退化,既慢又可能把同一对算多次。
WA(溢出):ans 用 int。P1102 最坏约 n²/4 = 10¹⁰ 对,int 只能装 2.1×10⁹。
RE / WA(越界):出窗口时访问 a[l] 却没保证 l <= r,窗口缩空后 l 跑到 r 右边继续减。
TLE(假 O(n)):每轮都扫一遍 cnt 数组判断“是否覆盖 m 种”。n = 10⁶、m = 2×10³ 时就是 2×10⁹ 次。
关掉页面,从空文件把 P1102 的代码敲一遍并提交。再自己出一组数据:3 1 / 1 1 1,手算答案为 0,如果输出不是 0,回到 while 的两个条件里找原因。
改一改 ①:把 C 换成 0,输入 3 0 / 1 1 1。正确答案是 6(三个位置两两配对),程序却会输出 9——多出来的 3 对是“自己和自己”。想清楚该在哪里排除它们。
改一改 ②:在 while 前加一句 cerr << lo << ' ' << hi << '\n';,数一数两个指针总共走了多少步,验证它 ≤ 2n。
改一改 ③:把“最短区间和 ≥ S”改成“最长区间和 ≤ S”,while 条件从 sum >= S 换成 sum > S,看看答案怎么变,并想清楚为什么这次要“超了才缩”。
改一改 ④:给数组里加一个负数(例如 a = {4, -3, 7, 1}、S = 5),逐个 r 打印窗口,找出算法给出错误答案的那一步。
把内层 while 的执行次数记为 c。每次执行循环体都伴随一次 ++l,而 l 从 1 开始、最大到 n+1,所以整个程序的 c 不超过 n。外层 for 走 n 轮,总操作数 ≤ n + n = 2n,即 O(n)。
注意:单次 while 可能执行很多轮(最坏 n 轮)。我们不算单次最坏,只算总和——这就是摊还分析:把一次昂贵操作的开销,摊到之前让它变昂贵的那 n 次便宜操作上。
| 写法 | 指针总移动次数 | 复杂度 |
|---|---|---|
| l 只增不减(正确) | l ≤ n,r ≤ n | O(n) |
| 每轮把 l 重置为 1 | 约 n²/2 | O(n²) |
| 两端收缩(l 增、r 减) | l 增 ≤ n,r 减 ≤ n | O(n) |
换个说法:找一个只增不减、上限为 n 的计数器(这里是 l),每轮循环至少让它 +1。计数器最多 +n 次,所以循环最多跑 n 轮。写 while 之前先找到这个计数器,复杂度立刻就知道。
同样地,P1102 里 lo 和 hi 各自最多前进 n 步,合计 2n 步;外层 for 也是 n 轮,所以总复杂度 O(n log n) 完全由排序决定,双指针本身是 O(n)。
反过来说:只要你的代码里出现“指针回退”“每轮重扫一遍窗口”这类动作,摊还论证立刻失效,复杂度按最坏情况算就是 O(n²)。这是判断自己写的是不是真双指针的最快办法。
博览馆按顺序展出 n 幅画,第 i 幅画出自第 aᵢ 位画家(1 ≤ aᵢ ≤ m)。买票时指定区间 [x, y],票价是区间长度 y − x + 1,能看到的画就是这段区间里的全部画。
要求选一个区间,使它包含全部 m 位画家的画,并且票价最小;若有多组解,输出 x 最小的那组。数据保证有解。
输入:第一行 n、m(n ≤ 10⁶,m ≤ 2×10³);第二行 n 个整数 a₁…aₙ。输出:一行两个整数 x、y。
固定右端点 r,如果 [l, r] 已经覆盖全部画家,那么再往右扩只会更长;这时该试着把 l 往右推,而不是继续扩。
不要每次扫一遍计数字段去数“覆盖了几种”:用一个变量记录“已经至少出现过一次的画家数”,进窗口和出窗口时顺手改它。
答案要求 x 最小:长度相同时不要更新,让先扫描到的那一组留下。
双指针:r 从 1 到 n 右移,每次把 a[r] 加进窗口。cnt[v] 记录画家 v 在窗口里的画数,have 记录窗口里出现过的画家种数。
cnt[a[r]] 从 0 变成 1 时,说明这位画家第一次进窗口,have 加 1。
只要 have == m,[l, r] 就合法:用 r − l + 1 去更新最优区间,然后把 a[l] 移出窗口(cnt 减 1,减到 0 则 have 减 1),l 右移,继续尝试更短的窗口。
正确性:对每个 r,收缩前的 [l, r] 正是“以 r 结尾的最短合法区间”,全局最优区间必然在某个 r 处被这组候选覆盖到。
x 最小:长度相同时只在严格更短时更新。相同长度的两个窗口,r 越小则 x 越小,而 r 是从左到右扫的,第一次遇到该长度时就已经保留下来了。
复杂度:r 走 n 步,l 总共也只走 n 步,O(n);cnt 数组 O(m) 空间。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005; // n ≤ 10⁶,多留几位
const int MAXM = 2005; // 画家编号 ≤ m ≤ 2×10³,开 2005 让下标 2000 安全
int a[MAXN], cnt[MAXM];
int main() {
ios::sync_with_stdio(false); // 10⁶ 个整数,必须提速
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; ++i) cin >> a[i];
int l = 1, have = 0; // have:窗口里出现过的画家种数
int bl = 1, br = n; // 当前最优区间,初值取整个数组(题目保证有解)
for (int r = 1; r <= n; ++r) {
if (cnt[a[r]]++ == 0) ++have; // 先判断再自增:这位画家第一次进窗口
while (have == m) { // 覆盖全部画家,开始缩短
if (r - l + 1 < br - bl + 1) { bl = l; br = r; } // 严格更短才更新 → 保证 x 最小
if (--cnt[a[l]] == 0) --have; // 先自减再判断:窗口里再没有这位画家的画
++l; // 左边界右移,窗口变短
}
}
cout << bl << " " << br << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
if (cnt[a[r]]++ == 0) ++have; | 先拿旧值判断再自增:旧值 0 说明窗口里还没有这位画家,种类数 +1。 |
while (have == m) | 只有合法窗口才值得缩短;不合法时只能让 r 继续右移。 |
if (r - l + 1 < br - bl + 1) | 严格小于才更新。长度相同时保留先扫到的(它的 x 更小),这是题目要求的 tie-break。 |
if (--cnt[a[l]] == 0) --have; | 先自减再判断:减到 0 说明这位画家彻底离开窗口,种类数 −1。 |
++l; | l 只前进不后退,这是总复杂度 O(n) 的唯一来源。 |
bl = 1, br = n; | 用整个数组作初值。因为保证有解,它一定会在某次 while 里被真正的合法区间替换。 |
MAXN / MAXM | 10⁶ 与 2000 的数组都放全局静态区,别写在 main 里(栈只有几 MB)。 |