← 目录 第 19 / 60 节 全部 60 节

19 · 双指针与滑动窗口

两种指针形态,一条共同前提:单调性。两个指针只前进不回头,总移动 O(n)。学会用 while 的循环不变式描述窗口,用摊还分析解释复杂度。
⭐ 已学范围普及-预计 60 分钟双指针滑动窗口单调性
本节出现的新函数 / 新语法(先看懂再用)

在 n = 2×10⁵ 的数组里找两个数,使它们的和等于 x。双重循环 O(n²) = 4×10¹⁰ 次比较,TLE。

如果数组是有序的,事情立刻不一样:l 指最左、r 指最右,看 a[l] + a[r] 与 x 的大小关系。和太大,说明 a[r] 配谁都太大,r 左移;和太小,说明 a[l] 配谁都太小,l 右移。每一步都能确定地丢掉一整排候选,而不是只丢一个。

双指针不是“写了两个变量”,而是一句承诺:右指针往前走时,左指针的目标位置只会往前走,不会回头。有这句承诺,总移动次数才是 O(n);没有,就是 O(n²)。
新函数拆解 · while 循环(次数不定)
while (条件) { 循环体 }
组成含义 / 格式要求
执行方式每轮开始前重新求值,为真就执行循环体,再回去求值。所以循环体必须改变条件里的量,否则死循环。
和 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] 已经不合法,正好可以继续右移 r
注意

while 的条件和循环体必须配套:上面靠 --cnt、--have、++l 让 have 变小,条件迟早为假。

内层 while 的总执行次数不超过 n,因为每次执行 l 都加 1,而 l 最大到 n+1。这就是它 O(n) 的全部理由。

新函数拆解 · 滑动窗口骨架(l、r 同向)
int l = 1; for (int r = 1; r <= n; ++r) { 进窗口; while (不合法) { 出窗口; ++l; } 用窗口更新答案; }
组成含义 / 格式要求
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) 排序。

例题 · A-B 数对洛谷 P1102普及/提高-去洛谷做这题 ↗

给 n 个正整数 a₁…aₙ 和一个正整数 C,统计有多少个有序对 (i, j) 满足 aᵢ − aⱼ = C。数值相同但位置不同的对算作不同的对。

输入:第一行两个正整数 N、C(1 ≤ N ≤ 2×10⁵,1 ≤ C < 2³⁰);第二行 N 个整数,0 ≤ aᵢ < 2³⁰。

输出:一行一个整数,即满足条件的数对个数。

输入: 4 1 1 1 2 3 输出: 3
例题怎么想:把“找 A”变成“找一段相等值”

直接双重枚举是 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 ansn = 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
双指针的 6 种典型死法(含症状)

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 打印窗口,找出算法给出错误答案的那一步。

数学补给 · 为什么双指针是 O(n):摊还分析

把内层 while 的执行次数记为 c。每次执行循环体都伴随一次 ++l,而 l 从 1 开始、最大到 n+1,所以整个程序的 c 不超过 n。外层 for 走 n 轮,总操作数 ≤ n + n = 2n,即 O(n)。

注意:单次 while 可能执行很多轮(最坏 n 轮)。我们不算单次最坏,只算总和——这就是摊还分析:把一次昂贵操作的开销,摊到之前让它变昂贵的那 n 次便宜操作上。

写法指针总移动次数复杂度
l 只增不减(正确)l ≤ n,r ≤ nO(n)
每轮把 l 重置为 1约 n²/2O(n²)
两端收缩(l 增、r 减)l 增 ≤ n,r 减 ≤ nO(n)

换个说法:找一个只增不减、上限为 n 的计数器(这里是 l),每轮循环至少让它 +1。计数器最多 +n 次,所以循环最多跑 n 轮。写 while 之前先找到这个计数器,复杂度立刻就知道。

同样地,P1102 里 lo 和 hi 各自最多前进 n 步,合计 2n 步;外层 for 也是 n 轮,所以总复杂度 O(n log n) 完全由排序决定,双指针本身是 O(n)。

反过来说:只要你的代码里出现“指针回退”“每轮重扫一遍窗口”这类动作,摊还论证立刻失效,复杂度按最坏情况算就是 O(n²)。这是判断自己写的是不是真双指针的最快办法。

举一反三 · 想清楚这些再往下走
  1. P1102 如果把条件放宽成 0 ≤ C,两个指针还会正常工作吗?会数出“同一个位置和自己配对”吗?该怎么修?
  2. 求“和恰好为 S 的子数组个数”,数组里有负数时滑窗为什么一定错?应该换成什么做法(提示:前缀和 + 什么)?
  3. 三数之和 a + b + c = 0:在两端收缩外面再套一层枚举,复杂度是多少?为什么不是 O(n³)?
  4. 如果窗口里要维护的是最大值而不是和,cnt 和 sum 都不够用了,需要什么结构?新元素进来时会“淘汰”哪些旧元素?
  5. 两个有序数组求中位数。用双指针归并能做到 O(n+m),能不能做到 O(log(n+m))?提示:把“取前 k 小”写成二分。
自测:能全部回答再进下一节
挑战题 · 逛画展
洛谷 P1638普及/提高-去洛谷做这题 ↗

博览馆按顺序展出 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 / MAXM10⁶ 与 2000 的数组都放全局静态区,别写在 main 里(栈只有几 MB)。
这题最容易错的地方
  • 更新答案的位置放错:写在 while 外面,此时窗口已经不合法,长度偏小,答案必错。
  • 用扫一遍 cnt 数组的方式数种类,每次 O(m):n = 10⁶、m = 2×10³ 时 2×10⁹ 次,TLE。必须维护 have。
  • 长度相同时用 <= 更新,x 变大,输出不是 x 最小的解,WA。
  • 边界:m = 1 时答案应是 1 1;MAXM 开到 2000 而 m 恰为 2000 时 cnt[2000] 越界。开 2005 并把 m = 1 单独测一遍。
延伸阅读 / 参考