双指针算法:优雅地优化循环
摘要
双指针(Two Pointers)是一种简单而强大的算法技巧,它通过在数据结构(通常是数组或链表)上维护两个指针,并根据特定条件移动它们,从而将嵌套循环的复杂度优化到线性级别。本文将深入探讨双指针的核心思想,并通过“指定和的整数对”、“判断回文串”和“寻找区间和”等经典问题,展示双指针在不同场景下的应用模式及其代码实现。
背景与动机
在处理数组或序列问题时,我们最直接的思路往往是使用嵌套循环。例如,要在一个数组中寻找两个数,使它们的和等于目标值,一个 for 循环嵌套另一个 for 循环似乎是不可避免的。这种暴力解法的时间复杂度通常是
双指针技术提供了一种优雅的替代方案。它巧妙地利用了问题的某些性质(如单调性),通过两个指针的协同移动,以线性的时间复杂度
问题定义
双指针并非一个特定的算法,而是一种算法思想,可以应用于多种问题模式。其共性在于:
给定一个序列(如数组、链表或字符串),使用两个指针(通常命名为 i 和 j,或 left 和 right)从序列的某个位置开始,根据一定的规则同步或异步移动,直到两个指针相遇或满足某个终止条件。在移动过程中,通过指针所指向的元素来更新状态或寻找答案。
一句话算法
通过维护两个指针在序列中的相对位置,将多重循环问题转化为单次遍历,实现线性时间复杂度的优化。
关键思路
双指针的核心在于 减少冗余计算。它通过指针的移动来“缩小”问题的搜索空间。根据指针的移动方向和初始位置,双指针主要分为两类:
-
相向双指针 (Opposite Direction Pointers):
- 初始位置: 一个指针在序列的开头,另一个在结尾。
- 移动方式: 两个指针向中间靠拢。
- 适用场景: 通常用于已排序的序列,利用单调性来寻找满足特定和、差或其它关系的目标对。
-
同向双指针 (Same Direction Pointers / Sliding Window):
- 初始位置: 两个指针通常都从序列的开头开始。
- 移动方式: 一个指针(快指针)先行探索,另一个指针(慢指针)在满足条件时跟进。它们共同维护一个“窗口”。
- 适用场景: 常用于寻找满足特定条件的连续子数组或子串,如“寻找最小的区间和”、“无重复字符的最长子串”等。
无论是哪种模式,关键都在于 正确地定义指针的移动策略。我们必须保证,在每一步移动后,我们都不会错过可能的解。
算法步骤与代码实现
我们将通过几个经典问题来具体展示双指针的应用。
指定和的整数对
问题定义: 在一个整数数组 nums 中,寻找两个数,使它们的和等于一个给定的目标值 target。返回这两个数的下标。
常用方法
- 二重暴力循环枚举
- 二分法
- 哈希表(桶)
- 双指针
双指针法
算法步骤:
- 先排序
- 初始化左指针
left = 0,右指针right = n - 1(n为数组长度)。 - 当
left < right时,进行循环:- 计算当前指针指向的两个元素的和
current_sum = nums[left] + nums[right]。 - 如果
current_sum == target,则找到了目标对,返回left和right。 - 如果
current_sum < target,说明和太小了。由于数组是排序的,需要增大和,因此将左指针向右移动left++。 - 如果
current_sum > target,说明和太大了。需要减小和,因此将右指针向左移动right--。
- 计算当前指针指向的两个元素的和
- 如果循环结束仍未找到,则说明不存在这样的整数对。
复杂度分析:
- 时间复杂度:
。两个指针最多各移动 n次。 - 空间复杂度:
。
证明思路
这里用到了单调性的性质,
[i ................... j]
- 初始的
i,j: 答案必然在区间内 - 如果
,那么 ,这说明 太小了,永远不可能是答案,那么可以把 从答案区间里面删除,即 i++,这样新的答案区间就变成了 - 如果
,那么 ,这说明 太大了,永远不可能是答案,那么可以把 从答案区间里面删除,即 ,这样新的答案区间就变成了 - 显然,刚开始的区间
是符合条件的,这样递归下去一定可以得到答案
问: 当
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 在已排序数组中寻找和为 target 的两个数
void find_sum_pair(int nums[], int n,int target) {
sort(nums,nums+n);
int left = 0;
int right = n - 1;
while (left < right) {
int current_sum = nums[left] + nums[right];
if (current_sum == target) {
// 找到目标对
cout << left << " " << right << endl;
i++; // 继续寻找下一对
} else if (current_sum < target) {
// 和太小,移动左指针
left++;
} else {
// 和太大,移动右指针
right--;
}
}
}
对应题目 luogu-T609340 找指定和的整数对 tags: [双指针,二分] : 详细论证了暴力到双指针的进化
判断回文串(hdu 2029)
问题定义: 判断一个字符串是否是回文串(正读和反读都一样)。
算法步骤:
- 初始化左指针
left = 0,右指针right = s.length() - 1。 - 当
left < right时,进行循环: a. 比较s[left]和s[right]。 b. 如果s[left] != s[right],则该字符串不是回文串,返回false。 c. 如果相等,则继续向中间收缩指针:left++,right--。 - 如果循环正常结束,说明所有对称位置的字符都相等,返回
true。
复杂度分析:
- 时间复杂度:
。 - 空间复杂度:
。
证明
直觉的创建一个命题:
- 若
: 表示字符串 是回文串 - 则
: - 一个命题和它的逆否命题等价:
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <string>
#include <iostream>
bool is_palindrome(const string& s) {
int left = 0;
int right = s.length() - 1;
while (left < right) {
if (s[left] != s[right]) {
return false;
}
left++;
right--;
}
return true;
}
最多删除一个字符构成回文
给定一个字符串,最多删除一个字符,判断是否构成回文
这是一个经典的“双指针”算法题目(常见于 LeetCode 680. Valid Palindrome II)。
核心思路是:使用左右指针向中间逼近,当遇到不匹配的字符时,我们有一次“豁免权”来尝试删除左边或右边的字符。
算法逻辑
- 初始化:设置两个指针,
left指向字符串开头,right指向字符串结尾。 - 循环比较:当
left < right时:- 如果
s[left] == s[right]:两个字符匹配,left向右移,right向左移,继续比较。 - 如果
s[left] != s[right]:发现不一致。此时我们有且仅有一次删除机会。我们需要验证以下两种情况中的任意一种是否成立:- 情况 A:假定删除左边的字符(
s[left]),判断剩下的子串s[left+1 ... right]是否为回文。 - 情况 B:假定删除右边的字符(
s[right]),判断剩下的子串s[left ... right-1]是否为回文。
- 情况 A:假定删除左边的字符(
- 如果情况 A 或情况 B 任意一个为真,则返回
True;否则返回False。
- 如果
- 成功结束:如果循环走完没有遇到不匹配,说明原字符串本身就是回文,返回
True。
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
bool isPalindrome(const string& s, int i, int j) {
while (i < j) {
if (s[i] != s[j]) {
return false;
}
i++;
j--;
}
return true;
}
bool validPalindrome(string s) {
int left = 0;
int right = s.length() - 1;
while (left < right) {
if (s[left] == s[right]) {
left++;
right--;
} else {
// 遇到不匹配:
// 1. 跳过左边字符 (检查 left+1 到 right)
// 2. 跳过右边字符 (检查 left 到 right-1)
return isPalindrome(s, left + 1, right) || isPalindrome(s, left, right - 1);
}
}
return true;
}
复杂度分析
- 时间复杂度:
。 - 最坏情况下,我们需要遍历整个字符串一次。
- 当遇到不匹配时,我们最多会额外调用两次
的子串检查,但总的操作次数仍然与字符串长度成线性关系。
- 空间复杂度:
。 - 我们要只使用了几个变量(指针)来存储索引,不需要额外的数组或递归栈空间。
图解示例:s = "abca"
left=0 ('a'),right=3 ('a')。相等,left++,right--。left=1 ('b'),right=2 ('c')。不相等!- 进入分支判断:
- 尝试删左边 (
left+1到right): 也就是判断索引2到2(“c”)。它是回文吗?是。返回 True。 - (由于是逻辑或 OR 运算,此时无需再判断右边,直接得出结果)。
- 尝试删左边 (
证明
这个方法是贪心. 使用交换验证的方法,证明两侧相同的字符是一定可以不删除.
若
证明核心抓住了回文串的一个本质特征:冗余性(Redundancy)。
命题:当
证明(基于你的交换/递归思路):
- 假设:假设在
的情况下,存在一个解法必须删除 才能构成回文。 - 推导:
- 既然删除了
后变成了回文,那么新的右边界 必须和左边界 相等(即 )。 - 关键点:因为已知
,且推导出 ,根据传递性,必然有 。
- 既然删除了
- 交换(Exchange):
- 既然
和 是一模一样的字符。 - 那么,“删除外层的
” 和 “保留外层的 但删除内层的 ”,对于剩余的字符串结构来说,效果是完全等价的。
- 既然
- 结论(递归/归纳):
- 既然效果等价,我们为什么不保留那个外层的
呢?我们可以安全地把“删除操作”向内推迟给 。 - 如果
还和更里面的字符相同,我们可以继续向内推迟。 - 最终结论:只要字符相等,删除操作永远可以被“挤”到内部去,绝不需要在当前边界消耗掉。
- 既然效果等价,我们为什么不保留那个外层的
这个证明好在两点:
-
更符合直觉(物理意义): 你证明了如果
和 相等,那么删 等同于删 。这就像是在说:“如果有两个一样的积木挨在一起,抽掉哪一个对整体结构的影响是一样的,所以我干嘛非要抽掉最外面那个支柱呢?” -
揭示了本质(等效替代): 我的证明(资源论)侧重于“利益最大化”(有复活币比没复活币好),而你的证明侧重于“结构等效性”。你指出了在
的情况下,删除边界是完全多余且可被替代的操作。
总结的金句:
“只要两侧相等,删除操作就可以无限向内推迟,直到遇到不等为止。”
寻找区间和
输出正整数数组里面所有满足条件:
输入
15
6 1 2 3 4 6 4 2 8 9 10 11 12 13 14
6
输出
1 1
2 4
6 6
7 8
算法步骤:
- 初始化快指针
j = 0,慢指针i = 0。 - 初始化当前窗口的和
sum = a [0]。 - 如果
sum == target, 输出一个解,然后j++,sum += a [j] - 如果
sum < target,j++,sum += a [j] - 如果
sum > target,i++,sum -= a [i]
当 current_sum == target 时,说明当前窗口满足条件,此时为什么尝试不尝试的缩小左边界:
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# include <bits/stdc++.h>
using namespace std;
int n;
int a [100009];
int m;
void findsum ( int a[],int n,int target ) {
int i = 0,j = 0;
int sum = a [0];
while ( j < n ) {
if ( sum == target ) {
cout << i << " " << j << endl;
j++; // 这里可能有越界 bug, 但是下一次循环会判断
sum += a [j];
}else if ( sum < target ) {
j++; // 这里可能有越界 bug, 但是下一次循环会判断
sum += a [j];
}else{ // 移动 i
sum -= a [i];
i++;
}
}
}
int main ( ) {
std:: cin >> n;
for ( int i = 0;i < n ;++i ) // i: 0->n
{
std:: cin >> a [i];
}
std:: cin >> m;
findsum ( a, n, m ) ;
return 0;
}
注意:这种方法只适用于正整数数组,如果数组中有负数,那么就不可以了,因为破坏了单调性
证明
这里其实用到了区间和单调性 :
只需要证明,如果存在一对
分情况讨论, 当
: 说明 , 那么 会一直向右移动,直到 : 说明 , 那么输出 : 证明当 j 到达 的时候, 已经越过了 , 那么某个时刻 , 这个时候 k 一定小于 , 那么 , 那么 不会向右移动, 而是 向右移动, 直到 到达 , 那么 , 那么输出 , 则 不会被漏掉 ( 反证法 )
当然也可以认为这个过程是根据单调性对二重循环的优化 : 寻找以元
转换成前缀和
此时问题变成了,求有多少对元素满足:
首先你可以写一个二重循环的暴力代码,枚举
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
#include <utility>
using namespace std;
int n;
int m;
struct Node {
int val,id;
bool operator<(const Node &t)const {
return val < t.val;
}
};
Node p[100009];
void work(){
for(int j = 1;j<=n;j++) {
for(int i = 0 ;i<j;i++) {
if( p[j].val - p[i].val == m)
{
int t1 = p[j].id;
int t2 = p[i].id+1;
if( t1 > t2) std::swap(t1, t2);
cout << t1 << " " << t2 << endl;
}
}
}
}
int main() {
std::cin >> n;
for(int i = 1;i <= n ;++i ) // i: 0->n
{
int t;
std::cin >> t;
p[i] = {p[i-1].val +t ,i};
}
std::cin >> m;
sort(p,p+1+n); //排序
work();
return 0;
}
好的当你理解的暴力代码,那么你就可以理解下面,暴力优化, 利用排序后前缀和数组的单调性
, 就是所求 , 则对于所有的 , , 那么 就是相对于 的失配区间,此时应该尝试 - 如果
是 失配区间,那么对于 也是失配区间 , 则区间 , 都有 , 此时 已经尝试了所有的可能性,则
注:使用前缀和法,可以在存在负值的数组上求满足条件的区间和
@include-code ( ./code/presum2.cpp, cpp )
对应的题目 luogu-P1102 A-B 数对 tags: [双指针,二分]
寻找大于等于 target 的最短子数组和
问题定义: 给定一个正整数数组 nums 和一个目标值 target,找到数组中满足其和 ≥ target 的 连续子数组 的 最小长度。如果不存在,则返回 0。
6
2 3 1 2 4 3
7
2
算法步骤:
- 初始化快指针
j = 0,慢指针i = 0。 - 初始化当前窗口的和
current_sum = 0,最小长度min_len = infinity。 - 快指针
j遍历整个数组,j从 0 到n-1:- 将
nums [j]加入窗口,更新current_sum += nums [j]。 - 当
current_sum >= target时,说明当前窗口满足条件,此时需要尝试收缩窗口的左边界:- 更新最小长度
min_len = min ( min_len, j - i + 1 )。 - 将
nums [i]移出窗口,current_sum -= nums [i]。 - 慢指针
i向右移动i++。 - 持续这个
while循环,直到current_sum < target,即窗口不再满足条件。
- 更新最小长度
- 将
- 遍历结束后,如果
min_len仍为infinity,说明没有找到,返回 0,否则返回min_len。
复杂度分析:
- 时间复杂度:
。虽然有嵌套循环,但每个元素最多被快指针 j访问一次,被慢指针i访问一次。 - 空间复杂度:
。
代码实现(暴力):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <bits/stdc++.h>
#include <climits>
using namespace std;
int n,m;
int p[100];
int ans = INT_MAX;
int range_sum(int l,int r) {
return p[r] - p[l-1];
}
int main() {
std::cin >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
std::cin >> p[i];
p[i] += p[i-1];
}
std::cin >> m;
for(int j = 1;j <= n ;++j ) // 结尾j
{
for(int i = 1;i <= j ;++i ) // i: 1->j
{
int len = j-i+1;
int sum = range_sum(i,j);
if( sum >= m && len < ans)
{
// cout << i << " " << j << endl;
ans = len;
}
}
}
cout << ans<<endl;
return 0;
}
显然我们还是可以基于这个二重循环的暴力,根据区间和的单调性进行优化
- 若
Sum ( i,j )>= target, 则Sum ( i-1,j )>=target,Sum ( i,j+1 )>= target - 当固定
j时候,我们不停的缩小i到达一个Sum ( i,j ) < target的时候所有的 这部分区间都不要考虑了,以 j为为结尾的答案已经枚举玩了,现在要枚举j+1为结尾的- 已知
, 且区间 的长度比区间 长 - 那么对于
j+1结尾的枚举i, 就应用从i+1开始
- 就这样递归下去
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <bits/stdc++.h>
#include <climits>
using namespace std;
int n,m;
int a[100];
int ans = INT_MAX;
int main() {
std::cin >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
std::cin >> a[i];
}
std::cin >> m;
int i = 1;
int sum = 0;
for(int j = 1;j <= n ;++j ) // 结尾j
{
sum += a[j];
while( sum >= m) {
ans = std::min(ans,j-i+1);
sum -=a[i];
i++;
}
}
cout << (ans == INT_MAX ? 0 : ans) <<endl;
return 0;
}
当然也可以转成前缀和来做: 转成求
对应的题目 poj-3061 Subsequence tags: [双指针]
A-B=C 的数对
给一个数组,和数字
- 暴力:
- 二分
- 排序后不影响结果,先排序
- 固定一个数
,然后二分查找 的数量
在一个不没有重复元素的数组中,求数组中
这是一个经典的**“同向双指针”**(Same-direction Two Pointers)问题。
与“两数之和”(Two Sum)不同,“两数之和”通常指针分别在头尾向中间靠拢;而“两数之差”需要两个指针都从左边出发,向右移动。
核心思路
-
排序: 双指针法依赖于数据的单调性。原数组无序时,我们无法判断指针该往哪边移。 首先将数组从小到大排序。
-
指针定义:
left指针指向(较小的数)。 right指针指向(较大的数)。 - 我们要寻找满足
nums[right] - nums[left] == C的情况。
-
移动策略: 计算当前差值
diff = nums[right] - nums[left]:- 如果
diff < C:说明差太小了。因为left已经是最小能取的了,我们必须让被减数变大,所以 right向右移。 - 如果
diff > C:说明差太大了。我们要让减数变大(从而减小差值),所以 left向右移。 - 如果
diff == C:找到了!计数器count++。- 由于题目保证没有重复元素,这对
组合是唯一的。 - 我们可以同时移动
left++和right++来寻找下一对。
- 由于题目保证没有重复元素,这对
- 如果
-
特殊处理:
- 如果
:由于题目求 ,即 。对数是一样的,取 处理即可。 - 为了避免
left和right指向同一个元素(差为0,但题目通常隐含是不同元素,且若 则不可能相同),通常初始化 right = 1或者在循环中保证right > left。
- 如果
C++ 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <vector>
#include <algorithm>
#include <iostream>
#include <cmath>
using namespace std;
int countDiffPairs(vector<int>& nums, int c) {
c = abs(c); // 统一转为正数处理
sort(nums.begin(), nums.end()); // 1. 排序 O(N log N)
int n = nums.size();
int left = 0;
int right = 1;
int count = 0;
while (right < n) {
if (left == right) {
right++;
continue;
}
long long diff = (long long)nums[right] - nums[left];
if (diff == c) {
count++;
left++;
right++; // 无重复元素,可以大胆同时移
} else if (diff < c) {
right++; // 差小了,变大 A
} else {
left++; // 差大了,变大 B
}
}
return count;
}
复杂度分析
-
时间复杂度:
- 排序 消耗
。 - 双指针遍历 消耗
。因为 left和right都只会从头走到尾,互不回头。 - 总复杂度由排序主导。
- 排序 消耗
-
空间复杂度:
(不计算存储输入输出的空间,仅看额外变量)。 - 如果是 Python 的
sort(Timsort) 可能需要的栈空间。C++ std::sort通常是。
- 如果是 Python 的
对比:如果有重复元素怎么办?
如果题目去掉了“无重复元素”的条件(例如 [1, 1, 4, 4],
- 当
diff == C时,我们不能简单地left++, right++。 - 我们需要计算
left指向的数字有几个(比如个), right指向的数字有几个(比如个)。 - 这一轮的贡献是
。 - 然后指针跳过所有相同的元素。
- 本题因为“无重复”这一条件,极大地简化了逻辑。
证明
为了证明这个双指针算法的正确性,我们利用**“单调性”和“解空间覆盖”**的思路。
我们要证明的核心是:算法绝对不会错过任何一对满足条件的
假设数组
证明思路:反证法 (Proof by Contradiction)
我们假设存在一对标准答案
分析算法流程:
右指针
只有三种情况:
情况 1: 还在 的左边 ( )
- 现状:
, 。 - 推导:
由于数组单调递增,
。 计算差值: 。 因为 比标准答案 小,所以 必定大于 。 - 算法行为:根据算法逻辑,当
Diff > C时,执行L++。 - 结果:
会一直向右移动,直到 变成 。此时 变成 ,答案被找到。 - 结论:如果
落后了,它会被强制追上来,不会漏。
情况 2: 正好在 的位置 ( )
- 现状:
, 。 - 推导:
。 - 结果:直接命中,计数
count++。 - 结论:没有漏。
情况 3: 已经跑到了 的右边 ( ) —— 这是唯一需要反驳的“漏网之鱼”情况
- 假设:当
刚走到 时, 却早已越过了 。 - 回溯推理:
要越过 (从 变成 ),必然是在之前的某个时刻发生的。 - 让时间倒流回
停留在 的那个时刻。 - 在那一刻,右指针
一定在 的左边某个位置,设为 ( )。 - 当时为何
会移动? 算法规定只有当 Nums[R] - Nums[L] > C时,才会右移。 - 所以,当时必然满足:
。
- 寻找矛盾:
- 已知
(右指针还没走到 ),根据数组升序,则 。 - 那么:
。 - 我们已知
。 - 所以推出:
。
- 已知
- 矛盾点:
- 算法逻辑要求:只有差值 大于
才会移走 。 - 数学事实推导:此时差值 小于
。 - 结论:在
到达 之前, 绝对不可能因为差值过大而提前移过 。
- 算法逻辑要求:只有差值 大于
总结
- 右指针负责地毯式搜索,一定会经过
。 - 左指针受到数学逻辑的严格约束:
- 只要
还没到 ,差值就不够大, 就不敢越过 。 - 一旦
到了 ,如果 还在后面,差值就太大, 被迫赶去 。
- 只要
- 因此,两个指针必在
处相遇。
这个证明展示了算法的严谨性,确保了它不会漏掉任何一对满足条件的
拓展
问题更改: 求满足
思路一样,但这里使用三指针
先排序,固定前一个指针
找到第一个
显然
暴力代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;
int n;
int a[10000];
int k1,k2;
int main (int argc, char *argv[]) {
std::cin >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
std::cin >> a[i];
}
std::cin >> k1 >> k2;
std::sort(a+1,a+1+n);
int ans = 0;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
for(int j = i;j <= n ;++j ) // j: 1->n
{
int t = a[j] - a[i];
if( t >= k1 && t <= k2)
ans++;
}
}
std::cout << ans << "\n";
return 0;
}
优化代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <bits/stdc++.h>
using namespace std;
int n;
int a[10000];
int k1,k2;
int main (int argc, char *argv[]) {
std::cin >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
{
std::cin >> a[i];
}
std::cin >> k1 >> k2;
std::sort(a+1,a+1+n);
int cnt=0;
int j1 =1 ,j2 = 1;
for(int i = 1;i <= n;i++) {
// 找到第一满足 >= k1条件的j1
// 第一个不满足 < k1 条件的j1
while( a[j1] - a[i] < k1 && j1 <=n ) j1++;
// 找到最后一个满足条件的j2
// 前一个位置 j2-1 就是最后一个满足 <= k2 位置
while( a[j2] - a[i] <= k2 && j2 <=n ) j2++;
if( j1 <= n && j2 >= j1 ) {
// ? (j2-1)-j1 + 1 = j2-j1
// [j1,j2) 就是满足的区间
cnt += ( j2 - j1);
}
}
std::cout << cnt << "\n";
return 0;
}
经典例题
-
LeetCode 167. Two Sum II - Input array is sorted:
- 描述: 即“指定和的整数对”问题。
- 思路: 使用相向双指针。
-
LeetCode 209. Minimum Size Subarray Sum:
- 描述: 即“寻找大于等于 target 的最短子数组和”问题。
- 思路: 使用同向双指针(滑动窗口)。
-
LeetCode 11. Container With Most Water:
- 描述: 给定一个非负整数数组,每个数代表一个坐标点上的垂直线的高度。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
- 思路: 这是一个经典的相向双指针问题。
- 初始化
left=0,right=n-1。 - 计算当前容器面积
area = min(height[left], height[right]) * (right - left)。 - 关键在于移动策略:为了尽可能找到更大的面积,我们应该移动 高度较短 的那条边的指针。因为容器的宽度
(right - left)在不断减小,只有通过移动短板,才 有可能 找到一个更高的板来弥补宽度的损失,从而得到更大的面积。如果移动长板,面积必然不会增大。
- 初始化
实践思考与扩展
- 三指针问题: 双指针的思想可以扩展到三个甚至更多指针,用于解决更复杂的问题,如“三数之和”。
- 链表中的双指针: 双指针在链表中也有广泛应用,如“快慢指针”判断链表是否有环、寻找链表中点、删除倒数第 N 个节点等。
- 与二分查找的关系: 对于某些问题,双指针和二分查找可以互相替代。例如,在有序数组中寻找和为 target 的数对,也可以通过遍历每个数
x,然后二分查找target - x来解决,但时间复杂度为,不如双指针的 高效。