A-B 数对
这是一个非常经典的算法题目,考察的是对数组特性的利用以及二分查找或双指针算法的掌握。
核心思路转换:
原式
由于数据范围
以下是针对该题目的详细解析,包含二分法和双指针法两种实现。
核心预处理:排序
无论使用哪种方法,首先必须对数组进行升序排序。 排序使得数组具有单调性,这是使用二分和双指针的前提。
- 时间复杂度:
方法一:二分查找法 (Binary Search)
1 算法思路
既然数组已经有序,对于每一个数
我们可以利用 C++ STL 中的 lower_bound 和 upper_bound 函数:
lower_bound: 查找第一个 大于等于 目标值的地址。upper_bound: 查找第一个 大于 目标值的地址。
数量计算:
对于每个 upper_bound(Target) - lower_bound(Target)。
2 代码实现 (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
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 200005;
long long a[MAXN]; // 使用 long long 防止数值溢出,虽然题目说 < 2^30,int 也够,但在计算 count 时必须 long long
int main() {
int n;
long long c;
cin >> n >> c;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
sort(a, a + n); // 必须排序
long long ans = 0; // 注意:答案可能超过 int 范围
for (int i = 0; i < n; i++) {
long long target = a[i] + c;
// 在数组中寻找 target 的起始位置和结束位置
// lower_bound 返回第一个 >= target 的指针
// upper_bound 返回第一个 > target 的指针
// 两者相减即为 target 出现的次数
ans += (upper_bound(a, a + n, target) - lower_bound(a, a + n, target));
}
cout << ans << endl;
return 0;
}
3 复杂度分析
- 排序:
- 查找: 循环
次,每次二分查找耗时 ,总共 。 - 总复杂度:
,可以通过 的数据。
方法二:双指针法 (Two Pointers)
1 算法思路
双指针法利用了数组排序后的单调性。
我们遍历数组中的每个元素
因为 l (left) 和 r (right):
- 指针
l指向第一个 等于的位置。 - 指针
r指向第一个 大于的位置。
当 l 和 r 只需要继续向右移动(不需要回头),直到满足上述条件。
对于当前的 r - l。
2 代码实现 (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
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 200005;
long long a[MAXN];
int main() {
int n;
long long c;
cin >> n >> c;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
sort(a, a + n);
long long ans = 0;
int l = 0, r = 0; // 定义双指针
for (int i = 0; i < n; i++) {
long long target = a[i] + c;
// 移动 l 指针,直到 a[l] >= target
while (l < n && a[l] < target) {
l++;
}
// 移动 r 指针,直到 a[r] > target
// 注意:r 必须从 l 的位置或者是之前的位置继续向后找
// 实际上由于 target 递增,r 只要接着上次的位置走即可,但为了逻辑严密,r 至少要 >= l
if (r < l) r = l;
while (r < n && a[r] <= target) {
r++;
}
// 此时,区间 [l, r) 之间的数都等于 target
if (a[l] == target) {
ans += (r - l);
}
}
cout << ans << endl;
return 0;
}
3 复杂度分析
- 排序:
- 指针扫描: 虽然代码里有
while嵌套,但i、l、r三个变量都只从增加到 ,互不回退。所以扫描部分是线性的 。 - 总复杂度:
(瓶颈在排序)。
关键点与避坑指南 (易错点)
-
数据类型 (Long Long):
- 题目中
和 可以用 int(因为刚好在 int范围内,或者安全起见用long long存数值)。 - 但是,计数器
ans必须使用long long。 - 原因: 如果数组中所有数都相同且
(例如 个 ),那么答案是 ,这远远超过了 int的最大值 ()。
- 题目中
-
重复数字的处理:
- 题目要求“不同位置的数字一样的数对算不同的数对”。
- 上述两种方法(二分法的
upper - lower和 双指针的区间统计)都天然支持这一点,因为它们计算的是值的出现次数。
-
负数情况:
- 题目保证是“正整数数列”,但如果题目扩展到负数,只要
成立,逻辑依然通用。
- 题目保证是“正整数数列”,但如果题目扩展到负数,只要
总结
- 如果对 STL 熟悉,写二分法代码最短,最不容易出错。
- 双指针法逻辑更底层,效率略优(省去了 log 的开销,虽然总复杂度阶数一样),是面试中常考的技巧。
你需要我为你解释一下这道题如果用 std::map (哈希表) 做法会有什么优缺点吗?
我写的双指针
基本思路
如果数组里面的元素 没有重复的时候的移动,那么这个时候就不是三指针移动了,简单一点.
- 固定
head指针的时候,得到一个tail - 如果
head+1, 那么新的tail一定是原来tail的右边,(单调性) - 这样就不会反复移动,时间
- 可以证明不会遗漏
- 因为 删除不会移动没有贡献的元素.(固定tail,或tail的时候)
代码
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <bits/stdc++.h>
using namespace std;
const long long maxn = 1e6+5;
long long a[maxn];
long long b[maxn];
long long cnt[maxn];
long long n,c;
int main(long long argc, char const *argv[])
{
cin >> n >> c;
for (long long i = 0; i < n; i++)
{
cin >> a[i+1];
}
sort(a+1,a+1+n);
b[1] = a[1];
cnt[1]++;
long long idx = 1;
for(long long i = 2;i<=n;i++) {
if( a[i] == a[i-1]) {
cnt[idx]++;
}
else {
idx++;
cnt[idx] = 1;
b[idx] = a[i];
}
}
// for(long long i =1;i<=idx;i++)
// {
// cout << b[i] << ' ' << cnt[i] << endl;
// }
long long h=1,t=2;
long long ans = 0;
while(1) {
if( t > idx || h > idx ) break;
if( b[t] - b[h] == c) {
ans += cnt[t] * cnt[h];
// b[t]不可能再和前面产生贡献了
t++;
}
else if (b[t] - b[h] > c) {
h++;
}
else if ( b[t] - b[h] < c) {
t++;
}
}
cout << ans << endl;
return 0;
}
你的这份代码写得非常有意思且高效!你没有使用常规的“在原数组上跑双指针”或者“二分查找”,而是先做了一个**数据离散化(或称为“去重+计数”)**的处理,然后再对去重后的数组进行双指针扫描。
这种做法思路非常清晰,将“数值”和“该数值出现的次数”分离开来,逻辑上更符合组合数学的乘法原理。
以下是对你代码的详细解析:
1 核心思路分析
你的解法可以分为三个阶段:
- 排序:将乱序数组变为有序。
- 压缩(去重+计数):将数组转化为“数值数组
b”和“频率数组cnt”。b[i]存储第i小的唯一数值。cnt[i]存储b[i]在原数组中出现的次数。
- 双指针查找:在 无重复元素 的
b数组上寻找差值为的对子,一旦找到,答案加上 cnt[t] * cnt[h]。
2 代码分步解析
第一步:输入与排序
1
2
3
4
cin >> n >> c;
// ... 读取 a 数组 ...
sort(a+1, a+1+n);
这是标准操作,确保后续处理基于有序序列。
第二步:数组压缩 (Dedup & Count)
这是你代码中最亮眼的部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
b[1] = a[1];
cnt[1]++;
long long idx = 1;
for(long long i = 2; i <= n; i++) {
if(a[i] == a[i-1]) {
cnt[idx]++; // 如果当前数等于前一个数,当前唯一值的计数+1
} else {
idx++; // 发现新数值,指针后移
cnt[idx] = 1; // 初始化新数值的计数
b[idx] = a[i]; // 记录新数值
}
}
例子演示:
假设输入 1 1 2 3 (且 C=1)。
经过这一步后:
b数组(唯一值):[1, 2, 3]cnt数组(对应数量):[2, 1, 1](即 1 有 2 个,2 有 1 个,3 有 1 个)idx= 3
第三步:双指针计算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
long long h=1, t=2; // h(head)指向被减数位置,t(tail)指向减数位置
while(1) {
if(t > idx || h > idx) break; // 边界检查
long long diff = b[t] - b[h];
if(diff == c) {
ans += cnt[t] * cnt[h]; // 核心公式:组合数学乘法原理
t++; // 移动 t 继续寻找
}
else if (diff > c) {
h++; // 差太大了,减数(b[h])需要变大,h右移
}
else if (diff < c) {
t++; // 差太小了,被减数(b[t])需要变大,t右移
}
}
逻辑分析:
- 当
b[t] - b[h] == c时,说明找到了满足的数值对。 - 因为原数组可能有重复,这里直接用乘法:
A的数量B的数量,一步到位算出了所有组合。 - 关于指针移动:你选择了
t++。由于b数组是严格单调递增的,当b[t]变大后,差值肯定会,所以在下一次循环中会进入 else if (diff > c) h++的分支。这在逻辑上是完全正确的(虽然同时t++, h++会稍微快那么一点点,但你的写法完全没问题)。
3 代码的优缺点与潜在问题
优点:
- 逻辑清晰:通过
cnt数组处理重复元素,避免了常规双指针在处理重复元素时需要写很多while循环来跳过相同元素的麻烦。 - 效率高:时间复杂度主要取决于排序
,后续压缩和扫描都是线性 。
潜在风险 (Edge Cases):
- 关于
的情况: - 题目说明中提到
,所以你的 h=1, t=2是安全的。 - 如果题目允许
,你的代码将输出 0。因为t永远从h+1开始,b又是严格递增的,b[t] - b[h]永远。 - 修正建议:如果为了通用性(兼容
),可以设 h=1, t=1,并且加个特判if(c==0)则只算cnt[i]*(cnt[i]-1)(但这题不需要)。
- 题目说明中提到
- 单一元素情况:
- 如果输入
5 1且数组为1 1 1 1 1。 - 压缩后
idx=1。 h=1, t=2。if (t > idx)立即触发break。- 输出
0。正确(因为没有差值为1的对)。
- 如果输入
4 一个小的改进建议
虽然逻辑是对的,但是 while(1) 配合内部 break 稍微有点不优雅。另外,当 b[t] - b[h] == c 时,既然 b 数组没有重复数字,那么当前的 b[h] 也不可能再和后面的 b[t] 配对成功了,所以其实 h 也可以一起往后移。
微调后的循环部分如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
long long h = 1, t = 1; // 从1开始更通用,虽然题目C>=1导致t=2也可以
// 实际上对于C>=1,t从1开始也会迅速被 diff < c 推到 2
while (t <= idx && h <= idx) {
long long diff = b[t] - b[h];
if (diff == c) {
ans += cnt[t] * cnt[h];
t++;
h++; // 两个都移,因为数值唯一,当前h和t都已利用完毕
} else if (diff > c) {
h++;
} else {
t++;
}
}
总结
你的代码是一个非常优秀的 AC 代码。利用离散化思想处理重复元素,不仅让双指针逻辑变得极其清爽,也避免了处理重复元素时容易出现的边界 bug。完全可以作为这道题的标准题解之一!