```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

上一章学了二分搜索——在排序数组里找东西,O(logn)O(\log n)。但有些问题的答案不是”某个位置”,而是”一对位置”或者”一段区间”。比如”在排序数组中找两个数使它们的和等于 KK“——二分可以做,但需要先固定一个数再二分另一个,O(nlogn)O(n \log n)。有没有 O(n)O(n) 的做法?

有。让两个指针从数组两端出发,根据和的大小决定移动哪个指针——每次只移动一个,最多移动 nn 步,总计 O(n)O(n)。这就是对撞指针

另一种场景:“找最短的连续区间使和 S\ge S“。两个指针从同一端出发,右指针扩展窗口,左指针在条件满足时收缩——每个元素最多进出窗口各一次,也是 O(n)O(n)。这就是尺取法(滑动窗口)

双指针的核心思想:两个指针单调移动,总共 O(n)O(n)。把暴力 O(n2)O(n^2) 变成 O(n)O(n)

问题的本质

对撞指针:从两端向中间

适用于排序数组中”找一对满足条件的数”。左指针 l 从 0 开始,右指针 rn1n-1 开始。根据 a[l] + a[r] 和目标的关系决定移动哪个指针。

为什么一定正确?因为数组有序:l 右移让 a[l]a[l] 变大(从而和变大),r 左移让 a[r]a[r] 变小(从而和变小)。每次移动都更接近目标,不会漏掉答案。

尺取法:滑动窗口

适用于”找满足某条件的连续区间”。右指针不断扩展窗口,左指针在条件满足时收缩。因为两个指针都只往一个方向走,总共 O(n)O(n)

理论 + 代码

对撞指针

// 在排序数组中找两个数使和等于 K
int l = 0, r = n - 1;
while (l < r) {
    int sum = a[l] + a[r];
    if (sum == K) {
        // 找到了
        break;
    } else if (sum < K) {
        l++;    // ① 和太小,左指针右移
    } else {
        r--;    // ② 和太大,右指针左移
    }
}
  • l++:让 a[l] 变大,从而使和变大。能不能 r++?不行,r 已经在最右边了。
  • r--:让 a[r] 变小,从而使和变小。能不能 l--?不行,l 已经在最左边了。

走一遍过程:数组 [1, 3, 5, 7, 9]K=10K = 10

步骤lra[l]a[r]判断操作
1041910== K找到!

一步搞定。换 K=8K = 8

步骤lra[l]a[r]判断操作
1041910> 8r=3
203178== 8找到!

尺取法(滑动窗口)

// 找最短的连续子数组使和 >= S
int l = 0, sum = 0, ans = n + 1;
for (int r = 0; r < n; r++) {
    sum += a[r];                 // ① 右指针扩展,纳入 a[r]
    while (sum >= S) {           // ② 窗口满足条件
        ans = min(ans, r - l + 1);  // ③ 记录答案
        sum -= a[l];             // ④ 尝试收缩左指针
        l++;
    }
}
  • r 每次走一步,把新元素纳入窗口。
  • ②③ 如果窗口满足条件,记录答案。
  • l 右移,缩小窗口看能不能更短。

走一遍过程:数组 [2, 3, 1, 2, 4, 3]S=7S = 7

ra[r]sum满足?操作ans
022
135
216
328l→0, sum=6, l=14
4410l→1, sum=7, l=2; l→2, sum=6, l=33
539l→3, sum=7, l=4; l→4, sum=3, l=52

最终 ans=2。最短子数组是 [4, 3],长度 2,和为 7。✓

例题

例题 1:M&A 020 — Choose Cards 2(★2)

题目NN 张卡片,第 ii 张写着整数 AiA_i。从中选 5 张,使 5 张的数字之和恰好为 1000 的选法有多少种?

数据范围5N1005 \le N \le 1001Ai10001 \le A_i \le 1000

—— AtCoder M&A 020

思路:5 层循环 O(N5)=1010O(N^5) = 10^{10},太慢。但我们可以用折半枚举(meet-in-the-middle):把 5 张分成两组——前 2 张和后 3 张。

枚举所有 2 张的组合(C(N,2)4950C(N,2) \le 4950),存入数组 LL;枚举所有 3 张的组合(C(N,3)161700C(N,3) \le 161700),存入数组 RR。对每个 L[i]L[i],在 RR 中查找 1000L[i]1000 - L[i]。对 RR 排序后可以用二分或双指针。

代码

#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    int A[110];
    for (int i = 0; i < N; i++)
        scanf("%d", &A[i]);

    // ① 枚举选 2 张的所有组合和
    vector<int> L;
    for (int i = 0; i < N; i++)
        for (int j = i + 1; j < N; j++)
            L.push_back(A[i] + A[j]);

    // ② 枚举选 3 张的所有组合和
    vector<int> R;
    for (int i = 0; i < N; i++)
        for (int j = i + 1; j < N; j++)
            for (int k = j + 1; k < N; k++)
                R.push_back(A[i] + A[j] + A[k]);

    sort(R.begin(), R.end());    // ③ 排序 R

    long long ans = 0;
    for (int i = 0; i < (int)L.size(); i++) {
        int target = 1000 - L[i];
        // ④ 在 R 中统计 target 出现的次数
        auto lo = lower_bound(R.begin(), R.end(), target);
        auto hi = upper_bound(R.begin(), R.end(), target);
        ans += hi - lo;
    }
    printf("%lld\n", ans);
}

逐行解析

  • ①② LL 存 2 张的和,RR 存 3 张的和。大小分别是 C(N,2)C(N,2)C(N,3)C(N,3),最大约 49504950161700161700
  • ③ 排序 RR,为二分查找做准备。
  • lower_boundupper_bound 夹出 targetRR 中出现的次数。

例题 2:Tessoku Book — A14 Four Boxes

题目:有 4 个箱子 A、B、C、D,各有 NN 张写有整数的卡片。从每个箱子各选 1 张,是否存在选法使 4 张卡片的数字之和恰好等于 KK

数据范围1N10001 \le N \le 10001K1081 \le K \le 10^8

—— AtCoder Tessoku Book A14

思路:4 层循环 O(N4)=1012O(N^4) = 10^{12},太慢。折半枚举:把 A+B 的和存入数组 LL,C+D 的和存入数组 RR,然后对每个 L[i]L[i] 检查 KL[i]K - L[i] 是否在 RR 中。

代码

#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;

int main() {
    int N;
    long long K;
    scanf("%d%lld", &N, &K);
    vector<long long> A(N), B(N), C(N), D(N);
    for (int i = 0; i < N; i++) scanf("%lld", &A[i]);
    for (int i = 0; i < N; i++) scanf("%lld", &B[i]);
    for (int i = 0; i < N; i++) scanf("%lld", &C[i]);
    for (int i = 0; i < N; i++) scanf("%lld", &D[i]);

    // ① A + B 的所有组合和
    vector<long long> AB;
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            AB.push_back(A[i] + B[j]);

    // ② C + D 的所有组合和
    vector<long long> CD;
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            CD.push_back(C[i] + D[j]);

    sort(CD.begin(), CD.end());    // ③ 排序

    bool found = false;
    for (int i = 0; i < (int)AB.size(); i++) {
        long long target = K - AB[i];
        // ④ 二分查找
        if (binary_search(CD.begin(), CD.end(), target)) {
            found = true;
            break;
        }
    }
    printf("%s\n", found ? "Yes" : "No");
}

逐行解析

  • ①② 各 N2=106N^2 = 10^6 种组合。比 N4N^4 小了一百万倍。
  • ③④ 排序后用 binary_search 检查是否存在。总时间 O(N2logN)O(N^2 \log N)

例题 3:Tessoku Book — A13 Close Pairs(双指针解法)

题目NN 个严格递增的整数 A1<A2<<ANA_1 < A_2 < \cdots < A_N。选两个不同的数,差不超过 KKAjAiKA_j - A_i \le K)的选法有多少种?

数据范围1N1000001 \le N \le 1000001K1091 \le K \le 10^9

—— AtCoder Tessoku Book A13

思路:这道题在 00-02 排序 中用 upper_bound 解过。现在用双指针的思路再做一遍——更高效。

维护右指针 r。对每个 i,让 r 尽量右移直到 A[r]A[i]>KA[r] - A[i] > K。那么满足条件的 jjri1r - i - 1 个。关键是 r 只往右不往左——因为 ii 增大后,A[r]A[i]A[r] - A[i] 只会变小。

代码

#include <cstdio>
using namespace std;

int main() {
    int N;
    long long K;
    scanf("%d%lld", &N, &K);
    long long A[100010];
    for (int i = 0; i < N; i++)
        scanf("%lld", &A[i]);

    long long ans = 0;
    int r = 0;
    for (int i = 0; i < N; i++) {
        while (r < N && A[r] - A[i] <= K)    // ① r 尽量右移
            r++;
        // 现在 A[r] - A[i] > K(或 r == N)
        ans += r - i - 1;    // ② 满足条件的 j 有 r-i-1 个
    }
    printf("%lld\n", ans);
}

逐行解析

  • r 从不回退。因为 ii 增大后 A[i]A[i] 变大,之前 A[r]A[i]KA[r] - A[i] \le Krr 仍然满足 A[r]A[i]KA[r] - A[i'] \le Ki>ii' > iA[r]A[i]<A[r]A[i]A[r] - A[i'] < A[r] - A[i])。
  • r 是第一个不满足条件的位置,所以满足条件的是 i+1,i+2,,r1i+1, i+2, \ldots, r-1,共 ri1r - i - 1 个。

复杂度:两个指针各走 NN 步,O(N)O(N)


例题 4:M&A 019 — Choose Cards 1(★1)

题目NN 张卡片,每张涂有颜色 1(红)、2(黄)或 3(蓝)。选 2 张同色卡片的选法有多少种?

数据范围2N5000002 \le N \le 5000001Ai31 \le A_i \le 3

—— AtCoder M&A 019

思路:统计每种颜色各有多少张。如果红色有 cc 张,选 2 张红色的选法就是 C(c,2)=c(c1)/2C(c, 2) = c(c-1)/2。三种颜色求和。

代码

#include <cstdio>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    long long cnt[4] = {};    // ① 颜色计数器
    for (int i = 0; i < N; i++) {
        int a;
        scanf("%d", &a);
        cnt[a]++;    // ② 统计每种颜色出现次数
    }

    long long ans = 0;
    for (int c = 1; c <= 3; c++)
        ans += cnt[c] * (cnt[c] - 1) / 2;    // ③ C(cnt[c], 2)

    printf("%lld\n", ans);
}

逐行解析

  • ② 桶计数:cnt[c]cnt[c] = 颜色 cc 的卡片数。
  • ③ 组合公式 C(n,2)=n(n1)/2C(n,2) = n(n-1)/2

例题 5:M&A 067 — Cross Sum(★2)

题目:(M&A 067 = T90 004)给定 H×WH \times W 的矩阵,求每个元素 Ai,jA_{i,j} 的”十字和”——第 ii 行所有元素之和加上第 jj 列所有元素之和,再减去 Ai,jA_{i,j}(因为被加了两次)。输出整个矩阵的十字和。

数据范围1H,W20001 \le H, W \le 20001Ai,j991 \le A_{i,j} \le 99

—— AtCoder M&A 067

思路:预处理每行的和 R[i]R[i] 和每列的和 C[j]C[j]。对每个 (i,j)(i,j),十字和 = R[i]+C[j]Ai,jR[i] + C[j] - A_{i,j}

代码

#include <cstdio>
using namespace std;

int A[2010][2010];
long long R[2010], C[2010];

int main() {
    int H, W;
    scanf("%d%d", &H, &W);
    for (int i = 0; i < H; i++)
        for (int j = 0; j < W; j++)
            scanf("%d", &A[i][j]);

    // ① 行和
    for (int i = 0; i < H; i++) {
        R[i] = 0;
        for (int j = 0; j < W; j++)
            R[i] += A[i][j];
    }
    // ② 列和
    for (int j = 0; j < W; j++) {
        C[j] = 0;
        for (int i = 0; i < H; i++)
            C[j] += A[i][j];
    }

    // ③ 输出十字和
    for (int i = 0; i < H; i++) {
        for (int j = 0; j < W; j++) {
            if (j) printf(" ");
            printf("%lld", R[i] + C[j] - A[i][j]);
        }
        printf("\n");
    }
}

逐行解析

  • ①② 分别预处理行和、列和,各 O(HW)O(HW)
  • R[i]+C[j]A[i][j]R[i] + C[j] - A[i][j]:行和 + 列和 - 自己(被算了两次)。这个”多算一次要减去”的思想和容斥原理如出一辙。

参考文献

教材讲解 — 競技プログラミングの鉄則 第 3 章

基础练习 — アルゴリズムと数学 演習問題集

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶