Skip to content

第 34 章 两只手指——双指针


🏗️ 前情回顾:你已经用枚举和模拟解决了不少问题。但有些题目用两重循环会超时——比如"在一个有序数组中找两个数,使它们的和等于 target"。暴力做法 O(n²),n 到 10⁵ 就炸了。有没有更快的办法?有!这就是双指针——用两个游走的"手指"在数据上跳舞,把复杂度砍到 O(n)。


🎯 本章目标

学完这一章,你能:

  • 理解双指针的核心思想:两个指针协同移动,减少不必要的重复检查
  • 掌握对撞指针(左右指针相向而行)
  • 掌握快慢指针(一快一慢,用于判环/找中点)
  • 掌握滑动窗口(区间伸缩,用于子数组问题)
  • 用双指针优化从 O(n²) 到 O(n),解决 A-B 数对、逛画展、两数之和等经典题

📖 故事引入

想象你有一条很长的绳子,上面串着很多珠子,每个珠子上写着一个数字(按从小到大排列)。你的任务是:找到两颗珠子,它们上面的数字之和等于 100。

你会怎么做?最笨的办法:左手拿一颗,右手把所有剩下的都摸一遍;然后左手换下一颗,右手再摸一遍……累死你,而且你摸了 n² 次。

聪明的方法:左手放在最左边(最小的数字),右手放在最右边(最大的数字)。计算两数之和:

  • 如果和正好是 100 → 找到,收工!
  • 如果和 < 100 → 左手往右移一格(增大总和)
  • 如果和 > 100 → 右手往左移一格(减小总和)

左手指针只能往右走,右手只能往左走。每步排除一种不可能——两根手指各自走一遍,总共最多 O(n) 步就能找到答案或确认不存在。从 n² 到 n,这就是双指针的魔力。


🧱 知识讲解

34.1 双指针的核心思想

双指针(Two Pointers)不是一种数据结构,而是一种解题套路:用两个变量("指针")标记数组中的位置,根据某种条件协调移动它们,从而在一次遍历中解决需要两次遍历的问题。

📦 类比:双指针就像拉链——两个"拉链头"从两端(或同端不同速)出发,默契地走到一起。每次移动都不是盲目的——移动哪边、移多少,都由当前状态决定。

双指针有三种经典形态:

形态描述典型场景
对撞指针一左一右,相向移动有序数组两数之和
快慢指针同向移动,速度不同链表判环、找中点
滑动窗口同向移动,维护区间子数组/子串问题

34.2 对撞指针:两数之和

题目:给定一个升序排列的数组 a[0..n-1] 和一个 target,判断是否存在 i ≠ j 使得 a[i] + a[j] == target。若存在则输出这两个数。

cpp
bool twoSum(int a[], int n, int target) {
    int l = 0, r = n - 1;
    while (l < r) {
        int sum = a[l] + a[r];
        if (sum == target) {
            cout << a[l] << " " << a[r] << endl;
            return true;
        } else if (sum < target) {
            l++;  // 和太小 → 左指针右移,增大和
        } else {
            r--;  // 和太大 → 右指针左移,减小和
        }
    }
    return false;
}

关键理解:为什么可以放心地 l++r--?因为数组是有序的

  • 如果 a[l] + a[r] < target,那么 a[l] 和任何在 r 左边的数之和只会更小。a[l] 已经没有任何希望了,所以 l++
  • 同理,如果 a[l] + a[r] > target,a[r] 和任何在 l 右边的数之和只会更大。a[r] 已经没戏了,所以 r--

⚠️ 前提:对撞指针要求数组有序。如果无序,可以先用 sort 排序再双指针——总复杂度 O(n log n),依然优于 O(n²)。

34.3 快慢指针:判环与找中点

快慢指针在链表中最常见(你后面会详细学链表),但思想同样可以用在数组上。

找中点的思想:慢指针每次走一步,快指针每次走两步。当快指针走到末尾时,慢指针刚好在中间。

cpp
int findMiddle(int a[], int n) {
    int slow = 0, fast = 0;
    while (fast + 1 < n && fast + 2 < n) {
        slow++;
        fast += 2;
    }
    return a[slow];  // 中位数(偏左)
}

📦 类比:操场跑步——你跑得比你朋友快一倍。当你跑完一圈追上他的时候,他刚好跑了半圈。这就是快慢指针的几何直觉。

34.4 滑动窗口:A-B 数对

滑动窗口的核心是维护一个可变区间 [l, r],窗口大小和位置根据条件动态调整。

题目(NOIP 2007 普及组简化变体):「A-B 数对」——给定 n 个正整数和一个数 C,求满足 A - B = C 的数对 (A, B) 的个数。

暴力:枚举 B,然后找 A = B + C,用二分查找即 O(n log n)。但双指针能做得更优雅。

双指针思路:排序后,用两个指针 lr 分别维护"第一个等于 B+C 的位置"和"最后一个等于 B+C 的位置",对于每个 B,(r - l) 就是 A 的个数。

cpp
long long countPairs(int a[], int n, int C) {
    sort(a, a + n);
    long long ans = 0;
    int l = 0, r = 0;
    for (int i = 0; i < n; i++) {  // 枚举 B = a[i]
        while (l < n && a[l] < a[i] + C) l++;
        while (r < n && a[r] <= a[i] + C) r++;
        ans += (r - l);
    }
    return ans;
}

34.5 滑动窗口:逛画展

题目(简化版):博物馆有 n 幅画排成一排,每幅画有一个画家编号(1~m)。你需要找一个最短的连续区间,使得这个区间里包含了所有 m 位画家的画各至少一幅。

暴力 O(n²):枚举每个起点,向右扩展直到收集全所有画家。

滑动窗口优化:维护窗口 [l, r),用一个 cnt[] 数组记录窗口内每种画家画的数量。当窗口还没集齐时,r++(扩张);当窗口已集齐时,尝试 l++(收缩),更新最短长度。

cpp
int solve(int a[], int n, int m) {
    int cnt[2005] = {0};  // 记录窗口内每种画的数量
    int collected = 0;    // 当前已收集的画种数
    int ans = n + 1;
    int l = 0;
    
    for (int r = 0; r < n; r++) {
        if (cnt[a[r]] == 0) collected++;
        cnt[a[r]]++;
        
        while (collected == m) {  // 集齐了,尝试收缩左端
            ans = min(ans, r - l + 1);
            cnt[a[l]]--;
            if (cnt[a[l]] == 0) collected--;
            l++;
        }
    }
    return ans;
}

滑动窗口的精髓:两个指针都只向右移动,每个元素被 lr 各访问常数次,总复杂度 O(n)。

📦 类比:滑动窗口像一条毛毛虫——头向前探(r++),身体跟上(l++)。身体永远不会超过头,大家都只往前走,从不后退。

34.6 双指针复杂度对比

问题暴力双指针
有序数组两数之和O(n²)O(n)
A-B 数对O(n²)O(n)(排序 O(n log n))
最短覆盖区间O(n²)O(n)
最长无重复子串O(n²)O(n)

💡 双指针为什么快?因为暴力枚举做了大量重复检查。比如两数之和中,暴力会检查 (a[0], a[1]), (a[0], a[2]), ... 共 O(n²) 对,但有序性告诉我们绝大多数对根本不需要看。双指针每次移动都"排除了一批不可能",从不回头。


✋ 动手试试

试试 1:写对撞指针版"两数之和",外加主函数输入测试。试几组数据验证正确性。

试试 2:用快慢指针找出一个数组的中位数下标(不排序,只是找位置)。

试试 3:用滑动窗口找出一个字符串中的最长无重复字符子串的长度。


⚠️ 容易犯的错

错 1:对撞指针数组没排序

❌ 数组是 {3, 1, 4, 1, 5},直接双指针 → 结果错误。

✅ 先 sort(a, a+n); 再双指针(或者用哈希表等其他方法)。

错 2:滑动窗口的 while 条件写成 if

if (collected == m) { ans = min(ans, r-l+1); l++; } ——只收缩一次,可能错过更短的答案。

✅ 用 while (collected == m) 持续收缩直到不再满足条件。

错 3:指针越界

❌ 对撞指针写成 while (l <= r),在 l == r 时同一个元素用了两次。

✅ 题目要求两个不同元素时,条件应为 while (l < r)

错 4:滑动窗口中忘记更新 collected

cnt[a[l]]--; l++; ——忘了检查 cnt[a[l]] 变成 0 时 collected--

✅ 先减计数,若归零则 collected--


📝 练习

基础题

1. 选择题

(1)对撞指针适用于什么情况?
A. 无序数组   B. 有序数组   C. 链表   D. 树

(2)滑动窗口中,右指针 r 的作用通常是:
A. 收缩窗口   B. 扩张窗口   C. 保持窗口大小不变   D. 重置窗口

(3)快慢指针中快指针的速度通常是慢指针的:
A. 1.5 倍   B. 2 倍   C. 3 倍   D. 任意倍

2. 填空题

(1)对撞指针中 l++ 的依据是 ____。

(2)滑动窗口的总复杂度是 O(____) ,因为左右指针各自最多移动 ____ 次。

(3)有序数组两数之和问题用暴力是 O(____),用双指针是 O(____)。

提高题

3. 编程题 — 三数之和

给定有序数组和 target,找出所有满足 a[i] + a[j] + a[k] == target 的三元组(不重复)。提示:固定一个数,剩下的用对撞指针。

4. 编程题 — 最长连续不重复子序列

输入 n 个整数,输出最长连续子序列的长度,该子序列没有重复元素。

挑战题

5. 编程题 — 盛水最多的容器

给定 n 个非负整数 a[0..n-1] 表示挡板高度,每个挡板间距为 1。找出两个挡板,使它们与 x 轴围成的容器能装最多的水。用对撞指针 O(n) 求解。

6. 编程题 — 最小覆盖子串

输入两个字符串 S 和 T,在 S 中找出包含 T 所有字母的最短子串。如果不存在则输出空串。


🧠 本章小结

双指针 = 两个变量协同游走,省掉重复检查

三种形态:
  对撞指针:  l→  ←r    有序数组,相向而行
  快慢指针:  s→  f→→   一快一慢,判环找中点
  滑动窗口:  [l..r)→    区间可伸缩,子数组/子串问题

复杂度跃迁:
  O(n²) → O(n)(或 O(n log n) 含排序)

核心思维:
  每次移动都"排除一批不可能",只走一遍
  前提通常是数组有序或满足单调性

📝 配套练习

共7题。对撞指针2题+滑动窗口4题+综合1题——双指针的三种形态都练到。。★核心(课堂必做) ◆拓展(课后练习)

级别题号链接覆盖知识点
★ 核心luogu-P1102https://hydro.ac/p/luogu-P1102对撞指针、A-B数对
◆ 拓展luogu-P1638https://hydro.ac/p/luogu-P1638滑动窗口、最短区间
◆ 拓展luogu-P1115https://hydro.ac/p/luogu-P1115双指针/前缀和、最大子段和
◆ 拓展luogu-P1381https://hydro.ac/p/luogu-P1381滑动窗口、单词背诵
◆ 拓展luogu-P3029https://hydro.ac/p/luogu-P3029对撞指针、牛
◆ 拓展luogu-P3143https://hydro.ac/p/luogu-P3143双指针+排序
◆ 拓展luogu-P5745https://hydro.ac/p/luogu-P5745滑动窗口、子数组和

💡 练习建议:先完成 1 道★核心题,确保掌握本章基本方法;再完成 6 道◆拓展题,覆盖不同变式和细节。


配套练习

共7题。对撞指针2题+滑动窗口4题+综合1题——双指针的三种形态都练到。★核心(课堂必做) · ◆拓展(课后练习)

级别题号链接覆盖知识点
★ 核心luogu-P1102https://hydro.ac/p/luogu-P1102对撞指针、A-B数对
◆ 拓展luogu-P1638https://hydro.ac/p/luogu-P1638滑动窗口、最短区间
◆ 拓展luogu-P1115https://hydro.ac/p/luogu-P1115双指针/前缀和、最大子段和
◆ 拓展luogu-P1381https://hydro.ac/p/luogu-P1381滑动窗口、单词背诵
◆ 拓展luogu-P3029https://hydro.ac/p/luogu-P3029对撞指针、牛
◆ 拓展luogu-P3143https://hydro.ac/p/luogu-P3143双指针+排序
◆ 拓展luogu-P5745https://hydro.ac/p/luogu-P5745滑动窗口、子数组和

练习建议:先在课堂完成 1 道★核心题,掌握本章基本方法;课后完成 6 道◆拓展题,覆盖不同变式和细节。

自查清单

  • [ ] 我能写出对撞指针解决有序数组两数之和
  • [ ] 我理解滑动窗口的"扩张-收缩"循环模式
  • [ ] 我会用滑动窗口解决最短覆盖区间问题
  • [ ] 我知道双指针为什么是 O(n) 而不是 O(n²)
  • [ ] 我能在代码中正确维护滑动窗口的计数变量
  • [ ] 我能把 A-B 数对问题转化为双指针解法

🚀 下章预告:双指针帮你省掉了重复计算——但还有另一类重复计算:同一个区间和被求了无数次。下一章《预制计算——前缀和与差分》,你学会"预处理一次,查询 O(1)"的神技——区间求和?随手就来!📊