📖 第 3.4 章 ⏱️ 约 50 分钟 🎯 中级

第 3.4 章:双指针与滑动窗口

📝 前置条件: 你应该熟悉数组、向量和 std::sort(第 2.3–3.3 章)。经典双指针方法需要已排序的数组。

双指针和滑动窗口是竞赛编程中最优雅的技巧之一。它们通过利用单调性将朴素 O(N²) 解法转化为 O(N):当一个指针向前移动时,另一个指针无需回头。


3.4.1 双指针技术

核心思想:在有序数组中维护两个下标 leftright,根据当前和/窗口大小将它们相向(或同向)移动。

使用场景:

  • 有序数组中找满足给定和的对/三元组
  • 检查有序数组中是否存在满足特定关系的两个元素
  • 「若能用大小 k 的窗口完成 X,则用大小 k-1 的窗口也能完成」的问题

Two Pointer Technique

上图展示两个指针如何向中间收拢,每步都从待考虑的对中消除整行/整列。

滑动窗口变体让两个指针同向移动。满足条件时,从左侧收缩以找到最小窗口:

Sliding Window Shrink

题目:找出所有和为目标值的对

朴素 O(N²) 做法:

// O(N²):检查每一对
for (int i = 0; i < n; i++) {
    for (int j = i + 1; j < n; j++) {
        if (arr[i] + arr[j] == target) {
            cout << arr[i] << " + " << arr[j] << "\n";
        }
    }
}

双指针 O(N) 做法(需要排序):

📄 C++ 完整代码
// 双指针 — 排序 O(N log N) + 搜索 O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, target;
    cin >> n >> target;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    sort(arr.begin(), arr.end());  // 必须先排序

    int left = 0, right = n - 1;
    while (left < right) {
        int sum = arr[left] + arr[right];
        if (sum == target) {
            cout << arr[left] << " + " << arr[right] << " = " << target << "\n";
            left++;
            right--;  // 同时移动两个指针
        } else if (sum < target) {
            left++;   // 和太小:左指针右移(增大和)
        } else {
            right--;  // 和太大:右指针左移(减小和)
        }
    }

    return 0;
}

为什么这样有效?

核心思路: 排序后,若 arr[left] + arr[right] < target,则没有比 arr[right] 更小的元素能与 arr[left] 配对达到 target。所以安全地右移 left

类似地,若和太大,没有比 arr[left] 更大的元素能与 arr[right] 配对达到 target。所以安全地左移 right

每步至少消除一个元素 → 总计 O(N) 步。

完整追踪

数组 = [1, 2, 3, 4, 5, 6, 7, 8],target = 9:

📄 数组 = [1, 2, 3, 4, 5, 6, 7, 8],target = 9:
状态:left=0(1), right=7(8)
  和 = 1+8 = 9 ✓ → 打印 (1,8),left++,right--

状态:left=1(2), right=6(7)
  和 = 2+7 = 9 ✓ → 打印 (2,7),left++,right--

状态:left=2(3), right=5(6)
  和 = 3+6 = 9 ✓ → 打印 (3,6),left++,right--

状态:left=3(4), right=4(5)
  和 = 4+5 = 9 ✓ → 打印 (4,5),left++,right--

状态:left=4, right=3 → left >= right,停止

所有对:(1,8),(2,7),(3,6),(4,5)

三数之和扩展

找和为目标值的三元组:固定一个元素,对剩余两个用双指针。

📄 找和为目标值的三元组:固定一个元素,对剩余两个用双指针。
// O(N²) — 远优于 O(N³) 暴力
sort(arr.begin(), arr.end());
for (int i = 0; i < n - 2; i++) {
    int left = i + 1, right = n - 1;
    while (left < right) {
        int sum = arr[i] + arr[left] + arr[right];
        if (sum == target) {
            cout << arr[i] << " " << arr[left] << " " << arr[right] << "\n";
            left++; right--;
        } else if (sum < target) left++;
        else right--;
    }
}

3.4.2 滑动窗口——固定大小

固定大小 K 的滑动窗口在数组上滑动,维护一个运行聚合值(和、最大值、不同值计数等)。

题目: 找任意大小为 K 的连续子数组的最大和。

数组:[2, 1, 5, 1, 3, 2],K=3
窗口:[2,1,5]=8,[1,5,1]=7,[5,1,3]=9,[1,3,2]=6
答案:9

朴素 O(NK) 对每个窗口从头重新计算和。

滑动窗口 O(N) 加上进入窗口的新元素,减去离开窗口的旧元素。

📄 C++ 完整代码
// 固定大小滑动窗口 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    // 计算第一个窗口的和
    long long windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += arr[i];

    long long maxSum = windowSum;

    // 滑动窗口:加 arr[i],减 arr[i-k]
    for (int i = k; i < n; i++) {
        windowSum += arr[i];        // 新元素进入窗口
        windowSum -= arr[i - k];   // 旧元素离开窗口
        maxSum = max(maxSum, windowSum);
    }

    cout << maxSum << "\n";
    return 0;
}

对 [2, 1, 5, 1, 3, 2], K=3 的追踪:

初始窗口 [2,1,5]:sum=8,max=8
i=3:加 1,减 2 → sum=7,max=8
i=4:加 3,减 1 → sum=9,max=9
i=5:加 2,减 5 → sum=6,max=9
答案:9 ✓

3.4.3 滑动窗口——可变大小

最强大的变体:窗口在需要时扩大,违反约束时收缩

题目: 找和 ≥ target 的最短连续子数组。

📄 C++ 完整代码
// 可变大小窗口 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, target;
    cin >> n >> target;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    int left = 0;
    long long windowSum = 0;
    int minLen = INT_MAX;

    for (int right = 0; right < n; right++) {
        windowSum += arr[right];   // 扩张:加入右端元素

        // 满足约束时从左收缩
        while (windowSum >= target) {
            minLen = min(minLen, right - left + 1);
            windowSum -= arr[left];
            left++;                // 收缩:移除左端元素
        }
    }

    if (minLen == INT_MAX) cout << 0 << "\n";  // 不存在这样的子数组
    else cout << minLen << "\n";

    return 0;
}

为什么是 O(N) 每个元素被加入一次(right 经过时),最多被移除一次(left 经过时)。总操作:O(2N) = O(N)

题目:最多 K 个不同值的最长子数组

📄 查看代码:题目:最多 K 个不同值的最长子数组
// 可变窗口:最多 K 个不同值的最长子数组
int left = 0, maxLen = 0;
map<int, int> freq;  // 窗口内每个值的频率

for (int right = 0; right < n; right++) {
    freq[arr[right]]++;

    // 有 > k 个不同值时收缩
    while ((int)freq.size() > k) {
        freq[arr[left]]--;
        if (freq[arr[left]] == 0) freq.erase(arr[left]);
        left++;
    }

    maxLen = max(maxLen, right - left + 1);
}
cout << maxLen << "\n";

3.4.4 USACO 示例:最长满足条件的子数组

题目: 给定整数数组,找所有元素 ≥ K 的最长连续子数组。

// 双指针:所有元素 >= K 的最长连续子数组
int left = 0, maxLen = 0;
for (int right = 0; right < n; right++) {
    if (arr[right] < K) {
        left = right + 1;  // 重置窗口:当前元素违反约束
    } else {
        maxLen = max(maxLen, right - left + 1);
    }
}

3.4.5 USACO 真题训练:窗口、匹配与单调移动

双指针的本质是「指针不回头」。在 USACO 中常见两类题:

模式题面信号指针含义
排序后滑动窗口最大集合,任意两者差距不超过 K窗口左右端点
区间匹配每个资源只能用一次,最大匹配数量当前资源 + 可匹配候选集合

真题 1:Diamond Collector(USACO 2016 US Open Silver)— 排序后维护合法窗口

题目链接: USACO 2016 US Open Silver P2: Diamond Collector
对应模式: 排序 + 滑动窗口 + 前后缀最优
难度定位: Silver 中等

题干解读

N 颗钻石,每颗有一个大小。一个展示柜中任意两颗钻石大小差不能超过 K。你有两个展示柜,求最多能展示多少颗钻石。

关键条件:

  • 每个柜子对应排序数组中的一个连续窗口。
  • 两个柜子不能共用钻石。
  • 需要选两个不重叠窗口,使总长度最大。

思路分析

排序后,对每个左端点 left,用右指针扩张到最大 right,满足:

size[right] - size[left] <= K

得到从 left 开始的最大合法窗口长度。然后用后缀最大值 bestFrom[i] 表示从位置 i 之后能选到的最大窗口。枚举第一个柜子的左端点,第二个柜子从第一个窗口右端之后开始。

CPP 完整代码

✅ 完整代码:Diamond Collector
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("diamond.in", "r", stdin);
    // freopen("diamond.out", "w", stdout);

    int n, k;
    cin >> n >> k;
    vector<int> diamond(n);
    for (int &x : diamond) cin >> x;
    sort(diamond.begin(), diamond.end());

    vector<int> length(n), endAt(n);
    int right = 0;
    for (int left = 0; left < n; left++) {
        while (right < n && diamond[right] - diamond[left] <= k) {
            right++;
        }
        length[left] = right - left;
        endAt[left] = right;  // 第二个柜子必须从 right 或之后开始
    }

    vector<int> bestFrom(n + 1, 0);
    for (int i = n - 1; i >= 0; i--) {
        bestFrom[i] = max(bestFrom[i + 1], length[i]);
    }

    int answer = 0;
    for (int first = 0; first < n; first++) {
        answer = max(answer, length[first] + bestFrom[endAt[first]]);
    }

    cout << answer << "\n";
    return 0;
}

复杂度: 排序 O(N log N),滑动窗口和后缀数组 O(N),空间 O(N)

易错点提醒

  1. 右指针回退。 滑动窗口的关键是 right 只增不减,否则退化成 O(N²)
  2. 两个柜子重叠。 第二个柜子必须从第一个窗口结束位置 endAt[first] 之后选。
  3. 只找一个最大窗口。 题目有两个展示柜,需要组合两个不重叠窗口。
  4. 没有排序。 合法窗口只在排序后才是连续区间。

拓展思考

如果展示柜数量从 2 变成 M,就变成「选 M 个不重叠窗口最大化总长度」的 DP:dp[i][j] 表示从第 i 个位置开始,用 j 个柜子的最大展示数量。


真题 2:Why Did the Cow Cross the Road(USACO 2017 February Silver)— 区间匹配的贪心

题目链接: USACO 2017 February Silver P1: Why Did the Cow Cross the Road
对应模式: 排序 + 贪心匹配 + 最小堆
难度定位: Silver 中等

题干解读

有若干只鸡,每只鸡在一个具体时间 T 可以帮忙;有若干头牛,每头牛只能在时间区间 [A,B] 内过马路。一只鸡最多帮一头牛,一头牛也最多被一只鸡帮助,求最大匹配数量。

关键条件:

  • 鸡是时间点,牛是时间区间。
  • 时间从小到大处理时,已经开始但还没过期的牛都可能匹配当前鸡。
  • 为了给未来留下更多选择,当前鸡应该匹配结束时间最早的可用牛。

思路分析

按时间扫描鸡:

  1. 将鸡时间排序。
  2. 将牛按开始时间 A 排序。
  3. 对每只鸡,把所有 A <= T 的牛加入最小堆,堆键是结束时间 B
  4. 弹出所有 B < T 的过期牛。
  5. 若堆非空,匹配 B 最小的牛。

这个贪心安全:结束越早的牛越紧急,当前不匹配它,未来更可能错过。

CPP 完整代码

✅ 完整代码:Why Did the Cow Cross the Road
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("helpcross.in", "r", stdin);
    // freopen("helpcross.out", "w", stdout);

    int c, n;
    cin >> c >> n;

    vector<int> chicken(c);
    for (int &t : chicken) cin >> t;

    vector<pair<int, int>> cows(n);  // {开始时间, 结束时间}
    for (auto &[a, b] : cows) cin >> a >> b;

    sort(chicken.begin(), chicken.end());
    sort(cows.begin(), cows.end());

    priority_queue<int, vector<int>, greater<int>> availableEnd;
    int cowIndex = 0;
    int matched = 0;

    for (int t : chicken) {
        while (cowIndex < n && cows[cowIndex].first <= t) {
            availableEnd.push(cows[cowIndex].second);
            cowIndex++;
        }

        while (!availableEnd.empty() && availableEnd.top() < t) {
            availableEnd.pop();  // 已经过期,不能再匹配
        }

        if (!availableEnd.empty()) {
            availableEnd.pop();  // 当前鸡匹配结束最早的牛
            matched++;
        }
    }

    cout << matched << "\n";
    return 0;
}

复杂度: 排序 O((C+N) log(C+N)),每头牛最多进出堆一次,总复杂度 O((C+N) log N)

易错点提醒

  1. 按结束时间排序后直接双指针。 鸡时间点和牛区间交错,仅靠两个指针不够,需要堆维护当前可选牛。
  2. 忘记删除过期牛。 B < T 的牛无法被当前或未来鸡匹配,必须弹出。
  3. 匹配结束最晚的牛。 应匹配结束最早者,把更宽松的牛留给未来。
  4. 边界条件写错。A <= T <= B,则 B == T 仍然可匹配,不应弹出。

拓展思考

这类题属于「按时间扫描 + 最紧急优先」模型。若每只鸡有多个时间段、每头牛也需要多个资源,问题可能升级为区间调度或二分图匹配。


⚠️ 常见错误

  1. 双指针前忘排序: 配对求和的双指针技术只在有序数组上有效。不排序会遗漏一些对或得到错误答案。
  2. 找到对时只移动一个指针: 找到匹配的对时,必须同时移动 left++right--。只移动一个会遗漏一些对(除非不需要考虑重复)。
  3. 窗口大小差一: 窗口 [left, right] 的大小是 right - left + 1,不是 right - left
  4. 忘记处理空答案: 对「最小子数组」问题,将 minLen 初始化为 INT_MAX,输出前检查它是否改变了。

本章总结

📌 核心要点

技术前提条件时间空间核心思想
双指针(配对)有序数组O(N)O(1)从两端逼近,消除不可能的对
双指针(三数之和)有序数组O(N²)O(1)固定一个,对其余用双指针
滑动窗口(固定)任意O(N)O(1)加新元素,减旧元素
滑动窗口(可变)任意O(N)O(1~N)右端扩张,左端收缩

❓ 常见问题

Q1:双指针一定需要排序吗?

A:不一定。「反向双指针」(如配对求和)需要排序;「同向双指针」(如滑动窗口)不需要。关键是单调性——指针只朝一个方向移动。

Q2:滑动窗口和前缀和都能计算区间和——该用哪个?

A:固定大小窗口的和/最大值,滑动窗口更直观。任意区间查询,前缀和更通用。滑动窗口只能处理「连续移动的窗口」;前缀和可以回答任意 [L,R] 查询。

Q3:滑动窗口能同时处理「满足条件的最长子数组」和「满足条件的最短子数组」吗?

A:两者都可以,但逻辑略有不同。「最长」:右端扩张直到条件不满足,再从左收缩直到条件重新满足。「最短」:右端扩张直到条件满足,再从左收缩直到条件不再满足,全程记录最小长度。

Q4:双指针如何处理重复元素?

A:取决于题目。若需要「所有不同对的值」,找到对后做 left++; right-- 并跳过重复值。若需要「所有对的数量」,需要仔细统计重复项(可能需要额外的计数逻辑)。

🔗 与后续章节的联系

  • 第 3.2 章(前缀和):前缀和与滑动窗口互补——前缀和适合离线查询,滑动窗口适合在线处理
  • 第 3.3 章(排序):排序是双指针的前提——反向双指针需要有序数组
  • 第 3.5 章(单调性):单调双端队列可以增强滑动窗口——在 O(N) 时间内维护窗口最小/最大值
  • 第 6.1–6.3 章(DP):一些问题(如 LIS 变体)可以用双指针优化

练习题

题目 3.4.1 — 配对计数 🟢 简单 给定 N 个整数和目标值 T,统计满足 arr[i] + arr[j] = T 的对 (i < j) 的数量。

提示 先排序数组,从两端用双指针。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, T; cin >> n >> T;
    vector<int> a(n); for (int& x : a) cin >> x;
    sort(a.begin(), a.end());
    long long cnt = 0;
    int L = 0, R = n - 1;
    while (L < R) {
        int s = a[L] + a[R];
        if (s == T) {
            if (a[L] == a[R]) {
                // [L..R] 内所有对都有效
                long long len = R - L + 1;
                cnt += len * (len - 1) / 2;
                break;
            }
            // 统计两侧的重复值
            long long cl = 1, cr = 1;
            while (L+1 < R && a[L+1] == a[L]) { cl++; L++; }
            while (R-1 > L && a[R-1] == a[R]) { cr++; R--; }
            cnt += cl * cr;
            L++; R--;
        } else if (s < T) L++;
        else R--;
    }
    cout << cnt << "\n";
}

复杂度: O(N log N)。


题目 3.4.2 — 最大平均子数组 🟡 中等 找恰好长度为 K 的连续子数组,使其平均值最大。

提示 固定大小滑动窗口:维护运行和,每步加 A[i] 减 A[i-K]。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<double> a(n); for (double& x : a) cin >> x;
    double windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += a[i];
    double maxSum = windowSum;
    for (int i = k; i < n; i++) {
        windowSum += a[i] - a[i-k];
        maxSum = max(maxSum, windowSum);
    }
    cout << fixed << setprecision(5) << maxSum / k << "\n";
}

复杂度: O(N)。


题目 3.4.3 — 最小覆盖子串 🔴 困难 给定字符串 S 和字符串 T,找 S 中包含 T 所有字符的最短子串。

提示 可变滑动窗口,用频率映射记录所需字符,满足所有 T 中字符时从左收缩。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    string S, T; cin >> S >> T;
    unordered_map<char,int> need, have;
    for (char c : T) need[c]++;
    int formed = 0, required = need.size();
    int L = 0, minLen = INT_MAX, minL = 0;
    for (int R = 0; R < (int)S.size(); R++) {
        have[S[R]]++;
        if (need.count(S[R]) && have[S[R]] == need[S[R]]) formed++;
        while (formed == required) {
            if (R - L + 1 < minLen) { minLen = R-L+1; minL = L; }
            have[S[L]]--;
            if (need.count(S[L]) && have[S[L]] < need[S[L]]) formed--;
            L++;
        }
    }
    if (minLen == INT_MAX) cout << "no solution\n";
    else cout << S.substr(minL, minLen) << "\n";
}

样例: S="ADOBECODEBANC",T="ABC" → "BANC" 复杂度: O(|S| + |T|)。


🏆 挑战题:USACO 2017 February Bronze——为何奶牛过马路 给定网格中的奶牛和它们的目的地,找哪头奶牛最快到达目的地。对排序好的区间用双指针/贪心方法。