NeFut Logo NeFut
EN 管理员登录

深入解析双指针技巧与滑动窗口应用

发布于:2026-05-27 09:17 最后更新:2026-06-17 08:35
#algorithm #C++ #Sliding Window

核心逻辑与数学原理

双指针(Two Pointers)并不是一种独立的算法,而是一类构建在序列单调性基础上的状态空间剪枝策略。它最核心的贡献,是将朴素双重循环的 $O(N^2)$ 复杂度优化至 $O(N)$。

其本质是在二维状态空间 $(i, j)$ 中,利用边界的单调性剔除无用分支:若状态函数满足单调性(如 $f(i, j) \le f(i, j+1)$),当主指针 $i$ 推进时,使得约束成立的临界指针 $j$ 只能单向移动(同向推进或对撞收缩),绝无回溯的必要。这种不可逆的拓扑序,使得指针移动的总体生命周期与序列长度同阶。


状态设计、拓扑演进与具体解决问题

1. 对撞指针(Two-Way Convergence)

左右指针分别初始化在序列的两端(l = 0, r = N - 1),向中间靠拢收缩。

  1. 有序序列的目标收敛问题:在已排序数组中寻找满足特定代数关系(如 $A[l] + A[r] == \text{target}$)的双元组。
  2. 对称性结构检验与变换:检验字符串是否为回文串、原地反转数组或链表。
  3. 区间边界缩减问题:如盛最多水的容器问题,通过比较两端高度,单调地剪掉不可能成为最优解的边界。 盛最多水的容器问题: $$V(i, j) = (j - i) \times \min(a_i, a_j)$$ 求 $$\max_{(i, j) \in \Omega} V(i, j)$$

2. 快慢指针与滑动窗口(Sliding Window)

两个指针同向移动,快指针 r 驱动遍历,慢指针 l 滞后推进,两者动态维护一个连续的区间。

  1. 非固定步长的快慢指针(链表拓扑结构)

Floyd 判圈算法(检测链表是否有环) 问题描述:判定单链表中是否存在闭环,要求空间复杂度为 O(1)。 算法描述: 初始化快、慢指针共同指向头节点。 慢指针每次向前走 1 步,快指针每次向前走 2 步。 若快慢指针在途中相遇(fast == slow),则判定链表有环;若快指针率先触底(遇到 nullptr),则判定链表无环。

寻找链表中点(单次遍历二分) 问题描述:在不知链表总长度、仅允许遍历一次的前提下,精准定位链表的中点。 算法描述: 快、慢指针同时从头节点出发。 慢指针每次走 1 步,快指针每次走 2 步(速度是慢指针的两倍)。 当快指针走到链表末尾(触底)时,慢指针所在的位置不偏不倚,恰好就是链表的中点。

寻找倒数第 K 个节点(定长窗口双指针) 问题描述:在不知总长度、且单链表无法反向遍历的前提下,高效找出倒数第 K 个节点。 算法描述: 快、慢指针同时指向头节点。 先手拉开间距:让快指针先独自向前走 K 步,在两指针之间构建一个固定长度的“窗口”。 同步同速推进:随后慢指针 and 快指针以相同速度(每次 1 步)同步向前挪动。 当快指针走到链表尽头(nullptr)时,由于窗口长度固定,慢指针指向的便是倒数第 K 个节点。

  1. 滑动窗口(连续子区间最值/计数)

由于 lr 均单调递增且不回溯,每个元素最多入队一次、出队一次。总时间复杂度表示为:

$$\text{Total Steps} = \sum_{i=1}^N (\Delta l_i + \Delta r_i) \le 2N = O(N)$$


C++ 标准源码

滑动窗口模板。 求解问题:求包含不超过 $K$ 种不同元素的最长连续子数组长度。

#include <iostream>
#include <vector>
#include <algorithm>

using std::cin;
using std::cout;
using std::vector;
using std::max;

// 约束:N <= 100000, 元素值域在 [0, 100000]
const int MAX_VAL = 100005;
int cnt[MAX_VAL]; // 频次哈希表,避免使用 std::map 引入 O(log N) 复杂度

int main() {
    // 优化标准 I/O 流性能,切记不可再混用 scanf/printf
    std::ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    if (!(cin >> n >> k)) return 0;

    vector<int> a(n);
    for (int i = 0; i < n; ++i) {
        cin >> a[i];
    }

    int l = 0;
    int distinct_types = 0;
    int max_len = 0;

    // r 为快指针,l 为慢指针
    for (int r = 0; r < n; ++r) {
        // 1. 移入右界元素,更新状态
        if (cnt[a[r]] == 0) {
            distinct_types++;
        }
        cnt[a[r]]++;

        // 2. 当窗口状态不合法(不同元素种类 > k)时,持续收缩左界 l
        while (distinct_types > k) {
            cnt[a[l]]--;
            if (cnt[a[l]] == 0) {
                distinct_types--; // 某种元素完全移出窗口
            }
            l++; // 避坑点:l++ 必须在状态更新逻辑之后
        }

        // 3. 此时 [l, r] 必然满足合法约束,更新全局最优解
        max_len = max(max_len, r - l + 1);
    }

    cout << max_len << "\n";

    return 0;
}

NOIP 实战避坑指南

1. 慢指针越界陷阱(最优化区间的条件差异)

编写滑动窗口时,是否需要在 while 中强加 l <= r 限制,取决于问题模型:

2. 计数器更新与指针自增的先后序

在收缩左界时,切忌盲目追求代码精简而写出 cnt[a[l++]]--。 如果当前 cnt[a[l]] 恰好为 1,这种复合写法会先提取旧的 l 完成减法,随后 l 立即自增。若后续有诸如 if (cnt[a[l]] == 0) 的联动状态判定,此时读到的 a[l] 已经是自增后的新位置内存,逻辑瞬间崩盘。

黄金法则:在赛时高压环境下,严禁将 ++/-- 算子与复杂的数组下标存取复合在同一行。请清晰地分步编写:先改计数 $\rightarrow$ 再判联动 $\rightarrow$ 最后自增指针


经典 NOIP/洛谷 真题

1. 洛谷 P1638 逛画展

2. 洛谷 P1102 A-B 数对

以下为两道真题在 NOIP 竞赛标准下(高效率、规避高常数容器)的核心算法代码。


参考代码

  1. 洛谷 P1638 逛画展(最短合法滑动窗口)

核心逻辑:快指针 r 扩展纳入新画家,当窗口内画家种类 curr_kinds == m 时,在 while 内部不断更新全局最优解,并向右收缩慢指针 l

// 关键变量定义:n 为画作总数,m 为画家总数
// a[] 存储画作,cnt[] 充当频次哈希表
int l = 1, curr_kinds = 0;
int ans_l = 1, ans_r = n, min_len = n + 1;

for (int r = 1; r <= n; ++r) {
    // 1. 移入右界,更新状态
    if (cnt[a[r]] == 0) {
        curr_kinds++;
    }
    cnt[a[r]]++;

    // 2. 窗口合法(包含所有画家)时,持续收缩左界 l
    while (curr_kinds == m) {
        // 求最短区间:在 while 内部(合法态)更新最优解
        if (r - l + 1 < min_len) {
            min_len = r - l + 1;
            ans_l = l;
            ans_r = r;
        }

        // 移出左界元素
        cnt[a[l]]--;
        if (cnt[a[l]] == 0) {
            curr_kinds--; // 某种颜色完全断档,窗口转为不合法,下个循环将退出
        }
        l++; // 慢指针右移
    }
}

// 输出最终锁定的最短区间边界
cout << ans_l << " " << ans_r << "\n";

  1. 洛谷 P1102 A-B 数对(单向不回溯三指针)

核心逻辑:将方程变形为 $A = B + C$。数组升序排序后,对于每一个固定的慢指针 l(视作 $B$),利用两个不回溯的快指针 r1r2 分别锁定目标值 $B+C$ 的左闭右开区间

// 关键变量定义:n 为元素个数,c 为目标差值
// a 为 vector<long long>,由于计数可能破 int,ans 必须用 long long
std::sort(a.begin(), a.end());

long long ans = 0;
int r1 = 0; // 快指针1:指向第一个 >= B + C 的位置
int r2 = 0; // 快指针2:指向第一个 > B + C 的位置

for (int l = 0; l < n; ++l) {
    long long target = a[l] + c; // 当前 B 对应的目标 A 的值

    // 1. 移动 r1 直到 a[r1] 开始满足 >= target (进入等值区左界)
    while (r1 < n && a[r1] < target) {
        r1++;
    }
    // 2. 移动 r2 直到 a[r2] 开始严格大于 target (跳出等值区右界)
    while (r2 < n && a[r2] <= target) {
        r2++;
    }

    // 3. 此时 [r1, r2) 区间内的元素全部等于 target
    // 由于数组单调递增,r1 和 r2 在整个主循环中绝对不需要回溯
    ans += (r2 - r1);
}

cout << ans << "\n";

[h] 返回首页