算法基础篇:(六)基础算法之双指针 —— 从暴力到高效的优化艺术
目录
前言
一、双指针算法是什么?—— 不止是 “两个指针” 那么简单
1.1 核心定义与本质
1.2 双指针的核心前提
1.3 双指针的常见类型
二、为什么要学双指针?—— 暴力解法的 “救命稻草”
2.1 暴力枚举的痛点
2.2 双指针的优化
三、双指针算法的通用模板 —— 三步搞定滑动窗口
3.1 通用模板框架
3.2 模板关键要点
四、经典例题实战 —— 从易到难吃透双指针
例题 1:唯一的雪花(洛谷 P2563)—— 无重复元素的最长子数组
题目描述
题目分析
暴力解法(超时警告)
双指针优化思路
代码实现
思路总结
例题 2:逛画展(洛谷 P1638)—— 包含所有元素的最短子数组
题目描述
题目分析
暴力解法(超时警告)
双指针优化思路
代码实现
思路总结
例题 3:字符串(牛客网)—— 包含所有小写字母的最短子串
题目描述
题目分析
双指针优化思路
代码实现
思路总结
例题 4:丢手绢(牛客网)—— 环形数组的最短距离
题目描述
题目分析
暴力解法(超时警告)
双指针优化思路
代码实现
思路总结
五、双指针算法的常见误区与避坑指南
5.1 指针回退
5.2 辅助数据结构选择不当
5.3 边界条件处理不当
5.4 忘记优化输入输出
六、双指针算法的拓展应用场景
总结
前言
在算法学习的道路上,我们总会遇到这样的场景:明明用暴力枚举能解决问题,却因为数据量太大导致超时;明明感觉思路没问题,却卡在时间复杂度的瓶颈上。而双指针算法,正是解决这类问题的 “神兵利器”—— 它通过巧妙地维护两个指针,让原本 O (n²) 的暴力解法优化到 O (n),用极简的思路实现高效运算。今天,我们就来全方位拆解双指针算法,从原理到实战,从基础到进阶,带你真正吃透这个基础却不简单的算法思想。下面就让我们正式开始吧!
一、双指针算法是什么?—— 不止是 “两个指针” 那么简单
1.1 核心定义与本质
首先我们应该要明确:双指针并不是特指某一种固定的算法,而是一种优化暴力枚举的思想。它的核心是通过两个指针(可以是数组下标、迭代器等)的协同移动,在一次遍历中完成原本需要多次遍历才能实现的功能,从而降低时间复杂度。
从本质上来说,双指针算法是 “空间换时间” 思想的反向应用 —— 它不需要额外开辟大量空间,而是通过优化指针移动的逻辑,减少无效遍历,让时间复杂度从暴力枚举的 O (n²)、O (n³) 骤降到 O (n) 或 O (n log n)。
1.2 双指针的核心前提
不是所有问题都能用双指针解决,它的适用场景有一个关键前提:问题具有 “单调性” 或 “二段性”,使得两个指针无需回退,只需同向或反向移动。
什么是 “无需回退”?举个例子:如果我们用两个指针 left 和 right 遍历数组,当 right 向右移动后,left 不需要向左退回,而是继续保持在当前位置或向右移动 —— 这种特性让双指针能够在一次遍历中完成任务。如果指针需要频繁回退,那双指针就失去了优化意义,此时不如直接使用暴力枚举。
1.3 双指针的常见类型
根据指针移动方向和功能,双指针主要分为以下两类:
- 同向双指针(滑动窗口):两个指针从同一端出发,向相同方向移动,形成一个 “窗口”,通过调整窗口的左右边界来解决问题(如子数组、子串相关问题)。
- 反向双指针:两个指针从两端出发,向中间移动,直到相遇或满足特定条件(如两数之和、数组反转等)。
注:本文重点讲解同向双指针(即滑动窗口),这是算法竞赛和笔试中最常考的类型,后续会结合具体例题深入分析。
二、为什么要学双指针?—— 暴力解法的 “救命稻草”
2.1 暴力枚举的痛点
我们先来看一个经典问题:给定一个长度为 n 的数组,找出其中不包含重复元素的最长子数组长度。
暴力解法的思路是很直接的:枚举所有可能的子数组,判断每个子数组是否包含重复元素,最后记录最长长度。代码如下:
// 暴力枚举:找出无重复元素的最长子数组长度
#include <iostream>
#include <vector>
#include <unordered_set>
using namespace std;int longestNoRepeatSubarray(vector<int>& nums) {int n = nums.size();int maxLen = 0;// 枚举所有子数组的起点for (int i = 0; i < n; i++) {unordered_set<int> st;int len = 0;// 枚举所有子数组的终点for (int j = i; j < n; j++) {if (st.count(nums[j])) {break;}st.insert(nums[j]);len++;maxLen = max(maxLen, len);}}return maxLen;
}int main() {vector<int> nums = {1,2,3,2,1};cout << longestNoRepeatSubarray(nums) << endl; // 输出3return 0;
}
这段代码的时间复杂度是 O (n²),当 n=1e5 时,1e10 次运算会直接超时 —— 这就是暴力解法的致命弱点:面对大规模数据时完全无能为力。
2.2 双指针的优化
同样的问题,用双指针优化后,时间复杂度会降到 O (n)。我们来看优化的思路:
- 用 left 和 right 两个指针表示当前子数组的左右边界,初始时都指向数组开头。
- 用一个哈希表记录窗口内元素的出现次数。
- right 向右移动,将当前元素加入窗口:
- 如果当前元素在窗口中未重复,继续移动 right,更新最长长度。
- 如果当前元素重复,移动 left,将窗口左边界向右收缩,直到窗口内不再有重复元素。
- 重复上述过程,直到 right 遍历完数组。
优化后的代码:
// 双指针优化:O(n)时间复杂度
#include <iostream>
#include <vector>
#include <unordered_set>
using namespace std;int longestNoRepeatSubarray(vector<int>& nums) {int n = nums.size();int maxLen = 0;unordered_set<int> st;int left = 0;// 仅需一次遍历for (int right = 0; right < n; right++) {// 窗口内有重复元素,收缩左边界while (st.count(nums[right])) {st.erase(nums[left]);left++;}st.insert(nums[right]);// 更新最长长度maxLen = max(maxLen, right - left + 1);}return maxLen;
}int main() {vector<int> nums = {1,2,3,2,1};cout << longestNoRepeatSubarray(nums) << endl; // 输出3return 0;
}
为什么时间复杂度是 O (n)呢?因为 left 和 right 都只会向右移动,不会回退,每个元素最多被访问两次(一次被 right 加入窗口,一次被 left 移出窗口),因此总操作次数就是 O (n) 级别。
这就是双指针的魅力:在不增加额外空间复杂度的前提下,让算法效率实现质的飞跃。
三、双指针算法的通用模板 —— 三步搞定滑动窗口
通过大量实战总结,同向双指针(滑动窗口)问题可以归纳为一个通用模板,核心分为 “进窗口、判条件、出窗口、更结果” 四个步骤。掌握这个模板,大部分滑动窗口问题都能迎刃而解。
3.1 通用模板框架
// 双指针(滑动窗口)通用模板
#include <iostream>
#include <vector>
using namespace std;int slidingWindowTemplate(vector<int>& nums) {int n = nums.size();int left = 0; // 窗口左边界int result = 0; // 存储最终结果// 可以根据需求定义辅助数据结构(哈希表、计数器等)// 例如:unordered_map<int, int> cnt; // 统计窗口内元素出现次数// int valid = 0; // 标记窗口是否满足条件for (int right = 0; right < n; right++) {// 步骤1:进窗口——将当前元素加入窗口,更新辅助数据结构// 例如:cnt[nums[right]]++;// if (cnt[nums[right]] == 1) valid++;// 步骤2:判条件——判断窗口是否需要收缩(根据题目要求调整)// 条件可能是:窗口内有重复元素、窗口内元素和超过阈值、窗口满足目标要求等while (/* 窗口不满足条件/需要优化 */) {// 步骤3:出窗口——将左边界元素移出窗口,更新辅助数据结构// 例如:cnt[nums[left]]--;// if (cnt[nums[left]] == 0) valid--;left++; // 收缩左边界}// 步骤4:更结果——窗口此时满足条件,更新最优结果// 例如:result = max(result, right - left + 1);}return result;
}
3.2 模板关键要点
- 进窗口:始终是 right 指针在移动,将当前元素纳入窗口,需要更新辅助数据结构(如计数、求和等)。
- 判条件:这是最核心的步骤,需要根据题目具体要求设计判断逻辑。常见的判断条件包括:窗口内有重复元素、窗口内元素和超过目标值、窗口内包含所有需要的元素等。
- 出窗口:当窗口不满足条件或需要优化时,移动 left 指针收缩窗口,同时更新辅助数据结构。
- 更结果:只有当窗口满足条件时,才更新结果(如最长长度、最短长度、元素和等)。
注意:判断条件的逻辑决定了窗口的性质 —— 是 “求最长” 还是 “求最短”,是 “求存在” 还是 “求最优”。后续例题会详细讲解如何根据题目调整判断条件。
四、经典例题实战 —— 从易到难吃透双指针
理论终究要落地,下面我们通过 4 道经典例题,从基础到进阶,带你逐步掌握双指针的应用技巧。每道题都会按照 “题目分析→暴力解法→双指针优化→代码实现→思路总结” 的流程讲解,帮助大家理解从暴力到优化的思考过程。
例题 1:唯一的雪花(洛谷 P2563)—— 无重复元素的最长子数组
题目链接:https://www.luogu.com.cn/problem/UVA11572
题目描述
企业家 Emily 想把独特的雪花打包出售,一个包裹里不能有两片相同的雪花。给定通过机器的雪花序列(每个雪花用一个整数标记),求不包含重复雪花的最大包裹大小(即最长无重复元素子数组的长度)。
输入:第一行是测试数据组数 T,每组数据第一行是雪花总数 n(n≤1e6),接下来 n 行每行一个整数表示雪花的标记。
输出:对于每组数据,输出最大包裹的大小。
题目分析
这道题和我们之前讲的 “无重复元素最长子数组” 是完全一致的,核心需求就是找到最长的不包含重复元素的连续子数组。
暴力解法(超时警告)
枚举所有子数组,判断是否包含重复元素,记录最长长度。时间复杂度 O (n²),n=1e6 时超时。
双指针优化思路
- 用 left 和 right 表示当前窗口的左右边界,初始值为 0。
- 用哈希表 mp 记录窗口内雪花的出现次数。
- right 向右移动,将当前雪花加入窗口:
- 如果 mp [nums [right]] > 1,说明窗口内有重复元素,需要移动 left 收缩窗口,直到 mp [nums [right]] == 1。
- 每次移动后,更新最长窗口长度。
代码实现
#include <iostream>
#include <unordered_map>
using namespace std;const int N = 1e6 + 10;
int a[N];int main() {ios::sync_with_stdio(false); // 加速输入输出cin.tie(0);int T;cin >> T;while (T--) {int n;cin >> n;for (int i = 0; i < n; i++) {cin >> a[i];}unordered_map<int, int> mp;int left = 0;int max_len = 0;for (int right = 0; right < n; right++) {// 进窗口:记录当前雪花出现次数mp[a[right]]++;// 判条件:窗口内有重复元素,收缩左边界while (mp[a[right]] > 1) {mp[a[left]]--;left++;}// 更结果:更新最长长度max_len = max(max_len, right - left + 1);}cout << max_len << endl;}return 0;
}
思路总结
这道题是双指针的入门级应用,核心是 “窗口内不允许重复元素”。当 right 遇到重复元素时,left 必须移动到重复元素的下一个位置,确保窗口内始终无重复。由于 left 和 right 都只向右移动,时间复杂度是 O (n),能够轻松处理 n=1e6 的数据。
例题 2:逛画展(洛谷 P1638)—— 包含所有元素的最短子数组
题目链接:https://www.luogu.com.cn/problem/P1638
题目描述
博览馆展出 m 位画家的作品,游客需要购买门票观看第 a 幅到第 b 幅画(门票价格为 b-a+1 元)。要求入场后能看到所有 m 位画家的作品,求最小的门票价格(即包含所有画家作品的最短子数组长度)。若有多个解,输出 a 最小的那组。
输入:第一行两个整数 n(图画总数)和 m(画家数量),第二行 n 个整数表示每幅画的画家编号(1≤a_i≤m≤2e3)。
输出:一行两个整数 a 和 b(子数组的左右边界,从 1 开始计数)。
题目分析
这道题的核心需求是 “找到包含所有 m 个不同元素的最短连续子数组”,属于 “求最短” 类型的滑动窗口问题。和上一道 “求最长” 的题不同,这道题的判断条件是 “窗口内是否包含所有 m 个元素”,当满足条件时,需要尝试收缩左边界以找到更短的子数组。
暴力解法(超时警告)
枚举所有子数组,判断是否包含所有 m 个元素,记录最短长度。时间复杂度 O (n²),n=1e6 时超时。
双指针优化思路
- 用 left 和 right 表示当前窗口的左右边界,初始值为 1(因为题目要求从 1 开始计数)。
- 用数组 mp 记录窗口内每个画家作品的出现次数,用 kind 记录窗口内不同画家的数量。
- right 向右移动,将当前画作加入窗口:
- 如果 mp [a [right]] 从 0 变为 1,说明窗口内新增了一个画家,kind++。
- 当 kind == m 时,说明窗口内包含所有画家的作品,此时需要收缩左边界,尝试找到更短的子数组:
- 记录当前窗口长度,若比当前最短长度更短,更新结果。
- 移动 left,将左边界的画作移出窗口:如果 mp [a [left]] 从 1 变为 0,kind--。
- 重复上述过程,直到 right 遍历完数组。
代码实现
#include <iostream>
using namespace std;const int N = 1e6 + 10;
const int M = 2e3 + 10;
int a[N];
int mp[M]; // 统计每个画家作品的出现次数int main() {ios::sync_with_stdio(false);cin.tie(0);int n, m;cin >> n >> m;for (int i = 1; i <= n; i++) {cin >> a[i];}int left = 1;int kind = 0; // 窗口内不同画家的数量int min_len = n; // 初始化为最大可能长度int begin = 1; // 结果的起始位置for (int right = 1; right <= n; right++) {// 进窗口:新增当前画作if (mp[a[right]]++ == 0) {kind++;}// 判条件:窗口内包含所有画家,尝试收缩左边界while (kind == m) {// 更结果:更新最短长度和起始位置int current_len = right - left + 1;if (current_len < min_len) {min_len = current_len;begin = left;}// 出窗口:收缩左边界if (mp[a[left]]-- == 1) {kind--;}left++;}}// 输出结果(起始位置和结束位置)cout << begin << " " << begin + min_len - 1 << endl;return 0;
}
思路总结
这道题的关键是 “满足条件后立即收缩窗口”。因为我们要找的是最短子数组,当窗口已经包含所有 m 个元素时,继续向右移动 right 只会让窗口更长,所以需要收缩左边界来优化。同时,由于画家编号的范围较小(m≤2e3),我们可以用数组代替哈希表,提高运行效率。
例题 3:字符串(牛客网)—— 包含所有小写字母的最短子串
题目链接:https://ac.nowcoder.com/acm/problem/18386
题目描述
给定一个只包含小写字母的字符串 S,找出所有包含所有 26 个小写字母的合法子串中,长度最短的那个。
输入:一行一个字符串 S(长度不超过 1e6)。
输出:一行一个整数,表示最短合法子串的长度。
题目分析
这道题其实是上一道 “逛画展” 的变种,只是将 “m 个画家” 换成了 “26 个小写字母”,核心思路完全一致。属于 “包含所有目标元素的最短子串” 问题,是滑动窗口的经典应用场景。
双指针优化思路
- 用 left 和 right 表示当前窗口的左右边界,初始值为 0。
- 用数组 mp 记录窗口内每个小写字母的出现次数,用 kind 记录窗口内不同字母的数量。
- right 向右移动,将当前字符加入窗口:
- 如果 mp [s [right]-'a'] 从 0 变为 1,kind++。
- 当 kind == 26 时,说明窗口内包含所有小写字母,收缩左边界以找到更短的子串:
- 更新最短长度。
- 移动 left,将左边界字符移出窗口:若 mp [s [left]-'a'] 从 1 变为 0,kind--。
- 重复上述过程,直到 right 遍历完字符串。
代码实现
#include <iostream>
#include <string>
#include <climits>
using namespace std;int mp[26]; // 统计每个小写字母的出现次数int main() {ios::sync_with_stdio(false);cin.tie(0);string s;cin >> s;int n = s.size();int left = 0;int kind = 0;int min_len = INT_MAX;for (int right = 0; right < n; right++) {// 进窗口:新增当前字符if (mp[s[right] - 'a']++ == 0) {kind++;}// 判条件:包含所有26个字母,收缩左边界while (kind == 26) {// 更结果:更新最短长度min_len = min(min_len, right - left + 1);// 出窗口:收缩左边界if (mp[s[left] - 'a']-- == 1) {kind--;}left++;}}cout << min_len << endl;return 0;
}
思路总结
这道题和 “逛画展” 的核心逻辑完全一致,只是目标元素从 “m 个画家” 固定为 “26 个小写字母”。通过这道题可以发现,滑动窗口问题具有很强的通用性 —— 只要是 “包含所有目标元素的最短子串” 或 “包含所有目标元素的最长子串” 问题,都可以用类似的思路解决。关键是要明确 “目标元素是什么”、“如何判断窗口是否包含所有目标元素”。
例题 4:丢手绢(牛客网)—— 环形数组的最短距离
题目链接:https://ac.nowcoder.com/acm/problem/207040
题目描述
小朋友们围成一个圆圈玩丢手绢游戏,每个小朋友之间的顺时针距离已知。定义两个小朋友的距离为顺时针或逆时针走的最近距离,求离得最远的两个小朋友的距离(即最大的最近距离)。
输入:第一行一个整数 N(小朋友数量),接下来 N 行每行一个整数,表示第 i-1 个小朋友顺时针到第 i 个小朋友的距离(最后一行是第 N 个小朋友顺时针到第一个小朋友的距离)。
输出:一个整数,表示最大的最近距离。
题目分析
这道题的难点在于 “环形数组”—— 小朋友围成一个圆圈,因此任意两个小朋友之间有两条路径(顺时针和逆时针),距离取较短的那个。我们需要找到所有小朋友对中,这个 “较短距离” 的最大值。
首先,整个圆圈的总长度 sum 是固定的,对于任意一段顺时针距离 k,对应的逆时针距离是 sum - k,因此最近距离是 min (k, sum - k)。我们的目标是找到 max (min (k, sum - k)),其中 k 是任意两个小朋友之间的顺时针距离。

暴力解法(超时警告)
枚举所有可能的顺时针距离 k,计算 min (k, sum - k),记录最大值。时间复杂度 O (n²),n=1e5 时超时。
双指针优化思路
由于小朋友围成一个圆圈,我们可以将环形数组展开为线性数组(复制一份拼接在后面),但这样会增加空间复杂度。更高效的方法是利用双指针维护一个 “环形窗口”:
- 用 left 和 right 表示当前顺时针路径的起点和终点,初始值为 1。
- 用 k 记录当前路径的顺时针距离之和,sum 记录整个圆圈的总长度。
- right 向右移动,累加当前距离到 k:
- 当 2*k >= sum 时,说明当前路径的顺时针距离 k 已经大于等于逆时针距离 sum - k,此时 min (k, sum - k) = sum - k。继续向右移动 right 会让 k 更大,sum - k 更小,因此不需要再移动 right,转而收缩 left。
- 每次移动后,更新最大的最近距离(取 k 和 sum - k 中的较小值,再与当前最大值比较)。
- 重复上述过程,直到 right 遍历完所有小朋友。
代码实现
#include <iostream>
using namespace std;typedef long long LL;
const int N = 1e5 + 10;
LL a[N]; // 存储每个小朋友之间的顺时针距离int main() {ios::sync_with_stdio(false);cin.tie(0);int n;cin >> n;LL sum = 0;for (int i = 1; i <= n; i++) {cin >> a[i];sum += a[i];}int left = 1;LL k = 0;LL max_dist = 0;for (int right = 1; right <= n; right++) {// 进窗口:累加当前距离k += a[right];// 判条件:顺时针距离 >= 逆时针距离,收缩左边界while (2 * k >= sum) {// 更结果:更新最大最近距离max_dist = max(max_dist, sum - k);// 出窗口:收缩左边界k -= a[left++];}// 更结果:更新最大最近距离(顺时针距离 < 逆时针距离的情况)max_dist = max(max_dist, k);}cout << max_dist << endl;return 0;
}
思路总结
这道题的关键是利用环形数组的特性,将问题转化为 “维护一段顺时针路径,找到最大的 min (k, sum - k)”。双指针的核心逻辑是 “当顺时针距离超过总长度的一半时,收缩左边界”,因为此时逆时针距离更短,继续扩展右边界只会让逆时针距离更小。通过这种方式,left 和 right 都只遍历一次数组,时间复杂度是 O (n)。
五、双指针算法的常见误区与避坑指南
5.1 指针回退
双指针的核心优势是 “指针不回退”,如果在代码中出现 left 向左移动的情况,大概率是思路出错了。此时需要重新审视判断条件,确保指针只会同向移动。
5.2 辅助数据结构选择不当
在处理大规模数据时,辅助数据结构的效率会直接影响算法性能。例如:
- 当目标元素的范围较小时(如例题 2 中的画家编号≤2e3),用数组代替哈希表可以提高访问速度。
- 当目标元素的范围较大时(如例题 1 中的雪花编号≤1e9),必须用哈希表(unordered_map)存储计数。
5.3 边界条件处理不当
- 数组或字符串的下标是否从 0 开始或从 1 开始(如例题 2 要求从 1 开始计数)。
- 当所有元素都满足条件时,是否会遗漏最小编号的解(如例题 2 要求 a 最小)。
- 环形数组的边界处理(如例题 4 中 right 遍历到 n 后是否需要重新开始)。
5.4 忘记优化输入输出
当 n=1e6 时,使用 cin 和 cout 默认的输入输出速度会很慢,导致超时。因此,在处理大规模数据时,可以加上下面两句代码:
ios::sync_with_stdio(false);
cin.tie(0);
这两行代码可以关闭 cin 和 cout 与 stdio 的同步,大幅提高输入输出速度。
六、双指针算法的拓展应用场景
除了上述例题中的场景,双指针还可以应用于以下问题:
- 两数之和 / 三数之和 / 四数之和:反向双指针,从数组两端向中间移动,降低时间复杂度。
- 数组反转:反向双指针,交换两端元素,直到指针相遇。
- 快慢指针找链表中点 / 环:同向双指针,快指针每次走两步,慢指针每次走一步。
- 合并两个有序数组:同向双指针,分别指向两个数组的起始位置,比较后合并。
- 滑动窗口求和:求子数组和等于目标值的最长 / 最短子数组。
这些问题的核心思路都是 “通过两个指针的协同移动,优化暴力枚举的时间复杂度”,只要掌握了双指针的核心思想和通用模板,都能轻松解决。
总结
双指针算法虽然简单,但却蕴含着 “化繁为简” 的算法思想。它告诉我们:有时候,解决复杂问题不需要复杂的代码,只需要换一种思路,用更巧妙的方式组织遍历逻辑。希望通过本文的讲解,你能真正吃透双指针算法,在未来的算法竞赛和笔试中,用它来解决更多问题。
最后,送给大家一句话:算法的本质是逻辑的优化,而双指针,正是这种优化思想的最佳体现。祝大家在算法学习的道路上,一路披荆斩棘,不断突破自我!
