刷到华为机试 HJ146“谐距下标对”的时候,我盯着题目名看了好一会儿。谐距是什么距离?下标对为什么还要专起一个名词?等我真正把题面读明白,把条件翻译成数学公式之后,才意识到这题最值钱的不是最后的 AC 代码,而是那个从“枚举元素”转向“枚举参数”的思维切换。
这篇文章我不打算只扔一份能过的代码,而是把中间每一步关键推导都掰开讲清楚:谐距的数学本质是什么、暴力做法为什么必然炸掉、正解为什么用两层看似朴素的循环就能把几亿次枚举压到千万级,以及我在实际提交和自测过程中踩过的几个坑。不管你是准备机试,还是在做一些 gcd 相关的计数题,顺着这个思路走一遍,基本就能独立写出这道题。
1. 先拆题:谐距下标对的数学定义
1.1 题干里的谐距到底指什么
原题描述比较绕,我用大白话转述一下。给定一个长度为 n 的正整数数组 a,对于任意下标对 (i, j)(i < j),我们先求出两个数的最大公约数 g = gcd(a[i], a[j]),然后把两个数同时除以 g,得到两个互质的数 p 和 q。如果这两个互质数相差 1,也就是 |p - q| = 1,那么这一对下标就叫做“谐距下标对”。题目要求统计整个数组里满足条件的下标对总数。
举个例子会直观很多。2 和 3,gcd 是 1,除掉之后还是 2 和 3,相差 1,所以它俩是一对。18 和 20,gcd 是 2,除掉之后是 9 和 10,也相差 1,同样是一对。6 和 8,gcd 是 2,除掉之后是 3 和 4,相差 1,这也是一对。反过来看 12 和 20,gcd 是 4,除掉之后是 3 和 5,差为 2,这就不是谐距下标对。
我第一次读完这个定义,脑子里冒出来的问题只有一个:这玩意儿凭什么能快速统计?如果按定义直接算 gcd,那不是 O(n²) 起步吗?事实确实如此,但关键并不在 gcd 本身,而在于这个定义可以继续往下化简。
1.2 等价变形:连续且互质
设两个数为 x 和 y,最大公约数是 g。我们把它们写成:
x = g * p
y = g * q
其中 p 和 q 一定互质,因为公共因子已经被 g 全部提走了。现在题目要求 |p - q| = 1,那说明 p 和 q 其实是两个相邻整数。假设较小的那个是 t,另一个就是 t + 1。于是:
x = g * t
y = g * (t + 1)
这个形式太关键了。它意味着,所有谐距下标对本质上就是“共享同一个因子 g,同时各自除掉 g 之后,剩下的是相邻两个整数”的一对数。由于相邻整数一定互质,所以 g 恰好就是 x 和 y 的最大公约数,反过来只要两个数能写成 gt 和 g(t+1),它们就一定满足谐距条件,不需要再多做一次 gcd 验证。
把条件整个反推回去,还能得到一个更紧凑的等价式:
gcd(x, y) == |x - y|
也就是说,两个数的最大公约数等于它们的差。验证一下 18 和 20:gcd 是 2,差也是 2,成立。再看 9 和 18:gcd 是 9,差也是 9,成立。这个简洁形式也终于解释了“谐距”这个名字:距离(差值)恰好等于公因数,某种程度上就是一种整除关系上的“和谐”。我对名字来源没有官方考据,但按这个式子去记,保证之后不会忘。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力思路为什么会撞墙
2.1 按下标对硬算的复杂度账
很多人拿到题的第一反应就是两层循环,枚举所有 i < j,然后每组都调用一次 gcd 检查。这个方法在 n 很小的时候没有任何问题,但机试里的 n 通常给到 2×10^5。这个时候下标对总数大约有:
n * (n - 1) / 2 ≈ 2×10^10
也就是两百亿对。就算一次 gcd 只做几十次整数运算,总操作量也到千亿级别了。OJ 的时间限制通常是两三秒,这个量级想都不用想,一定超时。我甚至不建议去尝试优化 gcd 的常数,因为在 n 上翻车的问题,常数优化救不回来。
这还只是按“下标对”枚举。如果你天真地想,数组里不同值可能没那么多,我可不可以按“值对”枚举?同样不行。
2.2 按值域硬算也不靠谱
假设值域最大值是 V。最坏情况下 V 也是 10^6,把所有值对都看一遍,数量级是 V^2 / 2,也就是 5×10^11,比刚才还离谱。就算数组去重后只剩 2×10^5 个不同的值,值对数量依然是约 2×10^10,还是致命。
所以问题的本质不是枚举对象选得不好,而是我们用了“先生成所有候选,再逐个验证”的思路。这种思路在约束条件比较松的时候可以,但这里等于把整个值域空间全扫了一遍。我们需要反过来想:能不能直接生成那些满足条件的值对,而不是生成完再去验?
2.3 从暴力中提取到的线索
我在暴力验证过程中反复观察满足条件的两组数,发现一个非常明显的共性:两个数的差值恰好就是它们的最大公约数。比如 (2,3) 差 1、gcd 也是 1;(18,20) 差 2、gcd 也是 2;(9,18) 差 9、gcd 也是 9。
这说明什么?假设差值是 d,那这两个数都得是 d 的倍数。进一步说,较小的数 x 可以写成 dt,较大的数就是 dt + d,也就是 d*(t+1)。于是我们想要的东西从一个“二维的值对”,变成了两个参数:公共因子 d 和相邻整数的起始值 t。
我在这里停下来算了一下枚举量,发现事情开始变得有意思了。对每个 d,t 最多取到 V/d 左右,总数大约是 V 乘以调和级数,而不是 V 的平方。这一下就把不可能变成了可能。
| 枚举方式 | 枚举量 | 能过吗 |
|---|---|---|
| 按下标对暴力 | O(n²) | 不能 |
| 按值对暴力 | O(V²) | 不能 |
| 枚举 d 和 t | O(V log V) | 可以 |
3. 正解核心:用 d-t 参数代替真的数
3.1 一一对应,不多不少
我们重新整理一下思路。任意满足条件的值对 (x, y),设它们差的绝对值为 d,并且 x < y。因为前面已经证明 d 等于最大公约数,所以两个数都可以写成 d 的倍数,假设 x = dt,那么 y = x + d = d(t+1)。这组 (d, t) 是唯一确定的,因为 x 和 y 给定之后,d 就是它们的差,t 就是较小数除以 d。
反过来,任意给一组正整数 (d, t),我们构造 x = dt,y = d(t+1)。由于 t 和 t+1 互质,所以 gcd(x, y) 一定等于 d,同时 y - x = d。因此这一对一定满足谐距条件。
还要排除一件事:两对不同的 (d, t) 会不会构造出同样的值对?假设两对分别构造出的 x、y 都相同。把两个式子相减,d 就必须相等,进一步推出 t 也相等。所以映射是双射,不会重复,也不会漏。
这个结论的意义在于:题目从“检验数组里的值对”变成了“生成所有可能的值对,看它们是否出现在数组里”。而生成的方式非常简单,就是枚举 d 和 t。
3.2 枚举量 V log V 的直观感觉
具体枚举范围是这样:d 从 1 到 V,t 从 1 开始,直到 d*(t+1) 超过 V 为止。稍微注意一下边界,如果 d 本身已经大于 V/2,那么 dt 最小也是 d,d(t+1) 一定超过 V,所以 d 只需要枚举到 V/2 就可以了。
对某个固定的 d,内层 t 大约有 V/d 个取值。总枚举量是:
sum_{d=1}^{V} (V / d) = V * (1 + 1/2 + 1/3 + ... + 1/V)
右边的括号就是调和级数,近似等于 ln V。所以总复杂度大约是 V log V。把 V = 10^6 代进去,约等于 1400 万次循环。这个数量级对 C++ 来说一秒钟都能轻松跑完,换成 Python 用 PyPy 也能勉强接受。
我经常看到有人把这个复杂度理解成二分出来的 log,其实不是。这里的 log 来自调和级数,和排序里面那个 log 完全是两码事。
3.3 频次相乘才是最终答案
现在值对可以枚举了,但题目统计的是下标对,不是值对。所以我们需要一个频次数组 cnt[v] 来表示值 v 在数组中出现了多少次。假设当前枚举到一个合法的值对 (x, y),并且 x != y,那么所有值为 x 的元素都可以跟所有值为 y 的元素配对。配对数量就是:
cnt[x] * cnt[y]
这里不需要除以 2,也不需要额外处理下标 i < j 的顺序问题。因为任意两个下标不同,值的组合固定时,必然有且只有一种方式让较小的下标排在前面。由于构造出来的 x 和 y 永远不会相等,也不需要担心同一个位置自己跟自己配对。
有些同学可能会习惯性地给相同值也加上 C(cnt[x], 2),那就错了。两个相同值的差是 0,但 gcd 是它本身,不可能满足 |x - y| = gcd(x, y),所以相同值永远不可能是谐距对。
4. 可直接提交的代码与验证
4.1 C++ 主解代码
直接看代码,注释已经写得很详细。实现上就是先统计频次,再枚举 d 和 t,枚举到合法值对就累加频次乘积。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
while (cin >> n) {
const int MAXV = 1000000;
vector<int> cnt(MAXV + 1, 0);
int V = 0;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
cnt[x]++;
V = max(V, x);
}
long long ans = 0;
// d 最大只能到 V/2,因为至少要有 x = d*1 和 y = d*2
for (int d = 1; d <= V / 2; d++) {
// t 从 1 开始,x = d*t, y = d*(t+1)
for (int x = d, y = d * 2; y <= V; x += d, y += d) {
if (cnt[x] && cnt[y]) {
ans += 1LL * cnt[x] * cnt[y];
}
}
}
cout << ans << '\n';
}
return 0;
}
内层循环我没有写成 t 的表达式,而是直接用 x += d、y += d 递推。这样不仅能少做几次乘法,代码也更直观:x 和 y 始终差一个 d,并且同步增长。
4.2 Python 版注意事项
Python 写这套逻辑,限制主要在常数。1400 万次循环在 CPython 下面会比较吃力,建议用 PyPy 提交。如果 OJ 只能用 CPython,那可以把枚举 d-t 的方法和对出现值枚举因子的方法都试一下,通常对稀疏数据后者更快。
python复制import sys
def solve():
data = list(map(int, sys.stdin.buffer.read().split()))
if not data:
return
n = data[0]
nums = data[1:1 + n]
if n < 2:
print(0)
return
V = max(nums)
cnt = [0] * (V + 1)
for v in nums:
cnt[v] += 1
ans = 0
for d in range(1, V // 2 + 1):
x = d
y = d + d
while y <= V:
if cnt[x] and cnt[y]:
ans += cnt[x] * cnt[y]
x += d
y += d
print(ans)
if __name__ == "__main__":
solve()
这里有一个小技巧:把 if cnt[x] and cnt[y] 的判断放在前面的确会快一点,因为大多数时候 cnt 对应位置是 0,可以直接跳过后面的乘法。
4.3 稀疏数组下的因子分解优化
如果题目把值域扩大到 10^9,V log V 的方法就不可行了,因为你不可能开一个 10^9+1 的数组。但 n 通常不会跟着变大,比如 n = 10^4 的时候,我们可以换个思路:
对于满足条件的较小值 x,设 d 是两个数的差,即 d 等于 gcd(x, y)。既然 y = x + d,并且 d 必须整除 x,那么 d 一定是 x 的一个因子。所以我们可以遍历每个出现过的值 x,枚举 x 的所有因子 d,然后检查 x + d 是否也出现在数组里。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
unordered_map<int, long long> cnt;
for (int i = 0; i < n; i++) {
int x;
cin >> x;
cnt[x]++;
}
long long ans = 0;
for (auto &p : cnt) {
int x = p.first;
long long cx = p.second;
// 枚举 x 的因子 d
for (int d = 1; 1LL * d * d <= x; d++) {
if (x % d) continue;
long long y = 1LL * x + d;
if (cnt.count(y)) {
ans += cx * cnt[y];
}
int d2 = x / d;
if (d2 != d) {
long long y2 = 1LL * x + d2;
if (cnt.count(y2)) {
ans += cx * cnt[y2];
}
}
}
}
cout << ans << '\n';
return 0;
}
这个方法的时间复杂度近似于所有出现值的因数个数之和。最坏情况每个值都接近 10^9,需要试除到 sqrt(x),也就是 31623 次,如果 n 是 10^4,总量在 3 亿左右,C++ 勉强可过。它的优点是不依赖值域大小,只依赖数组里出现值的个数,所以遇到值域极大但数据稀疏的情况,优先用它。
4.4 手算样例与边界验证
我给一个手工可以验的样例。数组是:
[2, 3, 8, 9, 18, 20]
手动找满足条件的所有值对:(2,3)、(8,9)、(9,18)、(18,20),一共 4 对。所以答案输出 4。
再想几个边界情况:
- 全相同数组,比如 [5, 5, 5]。任意两个 5 的差是 0,gcd 是 5,不满足条件,答案应为 0。
- n = 1,数组只有一个数,没有任何下标对,答案是 0。
- [1, 2]:gcd 是 1,差是 1,满足条件,答案是 1。
- 最大值的相邻边界,比如 [999999, 1000000]:这俩 gcd 是 1,差也是 1,是一个合法的谐距对,答案是 1。
这些情况代码都能正确处理,我在自测的时候也专门跑了一遍。
5. 实战中踩过的坑和现场判断
5.1 重复值导致的统计翻车
这应该是最容易错的地方。题目统计的是下标对,同一个值可能出现在很多位置。假设数组是 [4, 4, 6, 6],cnt[4] = 2,cnt[6] = 2。值对 (4,6) 满足 gcd(4,6)=2、差=2,是一个合法值对。如果只判断“这个值对存在”就直接 ans++,那结果会输出 1,但正确答案是 4。
为什么是 4?两个 4 分别和两个 6 配对,4 种组合。写成公式就是 cnt[4] * cnt[6]。我之前第一次写的时候也犯了只判断存在的毛病,测试用例一多就被打脸。记住:只要题目问的是下标对,统计时永远带频次,不要只判断布尔存在。
5.2 ans 爆 int,以及乘法溢出
第二个坑是数据类型。n 最大 2×10^5,所有下标对总数是约 2×10^10,这个数字超过了 int 的 2^31 - 1,所以答案必须用 long long。中间算 cnt[x] * cnt[y] 时也一样,cnt 最大值可以到 n,也就是 2×10^5,两个相乘就是 4×10^10,同样爆 int。
C++ 里最阴险的地方在于,如果你写 cnt[x] * cnt[y] 且两个变量都是 int,那么乘法会先在 int 范围内进行,溢出之后变成负数,再赋值给 long long 也救不回来。必须写成 1LL * cnt[x] * cnt[y]。我有一版代码就是在这个地方出现负数答案,排查了老半天。
5.3 值域 V 和 n 谁大,决定用哪套代码
现场做题千万不要拿到题就抄主解法。先看数据范围:
- 如果 V ≤ 10^6,直接开定长频次数组,用 d-t 枚举,最稳。
- 如果 V 非常大,但数组里出现过的值的数量很少,用因子分解枚举。
- 如果 V 大到无法开数组,又不想用 unordered_map,可以先把数组排序去重,然后用二分判断某个值是否存在。
我个人更习惯在草稿纸上先算一次最大枚举量,再去选方案。很多机试数论题的数据范围都是设计好的,V log V 版本基本能覆盖 80% 的情况。真正需要上稀疏方案的题,往往会在数据范围说明里写得特别夸张,一眼就能辨认出来。
5.4 0 和负数的边界
题目如果明确说了数组里是正整数,那一切都很干净。但如果没有明确说,就要小心 0。比如 arr = [0, 5],gcd(0,5) 一般定义为 5,两个数的差也是 5,你会发现它竟然满足了“gcd = 差”的条件。也就是说,0 和任意正数 x 都天然构成一组谐距对,这会完全打乱我们基于连续整数互质的推导,因为 t 可能取到 0。
我在实际刷题时遇到这类定义歧义,第一反应是去翻原题的数据范围说明。机试题目为了规避这种麻烦,通常会把数据限定在正整数范围内。如果实在没有限定,可以考虑单独统计 0 出现的次数,因为 0 只跟非 0 数配对,处理起来不复杂但很脏。负数的话,gcd 一般取绝对值,所以可以先把所有数映射成绝对值再走正常流程。多花两分钟确认边界,往往能避免一次无谓的 WA。
6. 通用套路总结:gcd 计数题的两个固定动作
6.1 先把条件写成“除干净”的样子
这类题目见多了,你会总结出非常类似的处理套路:只要条件里出现 gcd,并且涉及运算关系,就先设公共因子为 g,把两个数同时除以 g,看剩下的是什么。剩下的东西如果是互质条件,那就很舒服;如果再额外变成相邻整数,那等于直接送分。
本题的核心就是变成 t 和 t+1。相邻整数固定互质这个性质太强了,它保证我们一旦写出 dt 和 d(t+1),就完全不需要回头验证 gcd。很多同学绕在暴力里出不来,就是没有做这一步“除干净”的变换。
6.2 枚举参数空间,而不是枚举元素空间
第二件事是改变枚举的对象。暴力的对象是 n² 个元素对或者 V² 个值对,正解的对象是 (d, t) 这个参数空间。参数空间的大小是 V log V,但不是所有题都是这个形状。关键是你要找到一个约束等式,能把两个元素之间的关系解耦成一组参数。
类似的例子还有很多。比如统计数组中 a[i] * a[j] 是完全平方数的对数,可以先去掉每个数的平方因子,再按剩余部分哈希统计。统计 a[i] % a[j] == 0 的对数,可以固定较小值然后枚举倍数。这些题的共同点都是:把原来“二元变量直接比较”的关系,改写成一个可以按序枚举的映射。
6.3 别迷信某一个解,现场按数据规模选算法
最后一点是我的个人习惯:拿到 gcd 类计数题,先画一条数轴,标出 n 和值域 V,然后在旁边写上三种方案各自的量级。如果 V 是 10^6,二话不说上 d-t 枚举,代码短、不容易写错;如果 V 高达 10^9,那就改用因子枚举;如果 V 中等但 n 也很大,可以先算一下两个方案的实际访问次数,谁小用谁。
这道 HJ146 的题名确实唬人,但剥掉“谐距”这个包装,内核就是一个利用了相邻整数互质性的调和级数枚举。我现在遇到 gcd 计数题,第一反应永远是想办法把条件重写成“商长什么样、差长什么样”,再决定去枚举哪个参数。这个动作在这道题上很值,在其它同类型题目上也没亏过。
