LeetCode 952这道题,我是在一次刷题群里被反复问到的,起初我也没太当回事——因为题目描述已经写得很清楚了:给定一个正整数数组,两个下标如果共享某个大于 1 的公因数,它们就属于同一个组件,最后返回最大组件大小。直到我自己动手写第一版暴力代码,跑了几个随机数据后才发现,这个“简单”背后还藏着不少东西。后来查了讨论区才知道,解法要落到并查集 + 质因数分解上,中间还要加一层“质数虚拟节点”的建模。这篇文章面向正在刷题冲大厂的 Java 开发者,也想给已经把 AC 答案背下来但还没想透的人,把这道题从朴素思路到标准解法完整拆开讲一遍。
1. 读懂“组件”的真正含义:连通不是看相邻
1.1 我一开始的理解为什么是错的
第一次读题时,我潜意识里把“组件”理解成了数组里连续的区间,后来发现完全不对。题目给的公因数关系是全局的,不要求两个下标相邻,两个元素中间隔着谁都不影响它们连接。
真正容易忽略的是“传递性”。题目说的是:如果 nums[i] 和 nums[j] 共有某个大于 1 的公因数,那它们就在同一个组件里;而如果 nums[i] 和 nums[k] 共有一个公因数,nums[k] 和 nums[j] 又共有一个公因数,那即使 nums[i] 和 nums[j] 本身没有公因数,它们最终也会被归到同一个组件里。因为并查集本身的逻辑就是连通关系可以沿着中间节点传导。
比如数组 [4, 6, 15, 35]:
- 4 和 6 共享公因数 2;
- 6 和 15 共享公因数 3;
- 15 和 35 共享公因数 5。
4 和 35 之间没有任何大于 1 的公因数,但这个数组最终四个数都会落在同一个组件里:4 通过 2 连到 6,6 通过 3 连到 15,15 通过 5 连到 35。最后组件大小是 4,而不是分成两两一组。
所以这里本质上不是“两两找朋友”,而是一个无向图上的连通块问题:每个数组下标是一个点,两个点之间是否有边,取决于它们的数值是否共享至少一个质因子。
1.2 算术基本定理才是破题点
如果直接判断两个数是否有公因数,最朴素的手段是 gcd(a, b) > 1。那下一步自然会想:能不能对每个数都预处理出所有可能的因数,然后把有公共因数的数归到一起?
真正把复杂度降下来的关键,是唯一分解定理,也就是算术基本定理:任意大于 1 的整数都可以唯一地分解成若干个质数的幂的乘积。
这个定理带来一个推论:两个整数存在大于 1 的公因数,等价于它们拥有至少一个相同的质因数。
比如:
- 12 = 2^2 × 3;
- 18 = 2 × 3^2;
- 公共质因数是 2 和 3,所以它们有公因数 6,当然也有 2 和 3。
再比如:
- 10 = 2 × 5;
- 21 = 3 × 7;
- 质因数集合没有交集,所以它们唯一可能的公因数只能是 1。
这就是一个非常关键的信息:判断公因数时,我们根本不需要关心合数因数,只看质因数就够了。
为什么这个结论能帮助优化?因为合数的因数是大量重复的,12 的约数有 2、3、4、6、12,但这些合数都能拆到 2 和 3 上。如果拿合数做节点,两个数可能通过多个不同的合数路径连起来,造成大量无效合并。而质因数数量非常少,是相对稳定的“原子成分”。
1.3 一个特殊例子:为什么组件可以“链式”传导
再举一个我实际测试时用过多次的数组:[2, 3, 6]。
2 和 6 共享 2,3 和 6 共享 3,所以最终三个元素同一个组件。这种“通过公共中间人”的案例,结构上是这样的:
- 2 连接质数 2;
- 3 连接质数 3;
- 6 连接质数 2,也连接质数 3。
于是形成了 2 — 质数2 — 6 — 质数3 — 3 的连通链。两条边都不直接连接 2 和 3,它们只是因为共同连接了 6 就走到了同一条链上。
所以这道题虽然叫“最大组件大小”,实际上是个“传递闭包”问题。一旦接受了这个设定,后面用并查集处理就很自然了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力做法为什么扛不住:一次两两 gcd 的代价估算
2.1 两两枚举思路与实现成本
很多人的第一反应和我一样:直接把每个下标看成一个点,两层循环枚举所有点对,如果 gcd > 1 就 union。伪代码逻辑很简单:
java复制for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (gcd(nums[i], nums[j]) > 1) {
union(i, j);
}
}
}
我第一版也是这么写的,AC 之后回头再看这个代码,哪怕样例全过、本地小数据完全正常,在极限数据下也必崩。
看约束:nums.length <= 20000。两层循环的总次数是 n × (n - 1) / 2,代入 20000,大概是 199,990,000 次,约 2 亿次两两比较。这还只是比较次数,一次 Java 层面的 gcd 调用又需要几次取模运算,所以整体时间会非常难看,在 LeetCode 上基本不可能跑进限制。
也许有人会想:是不是能加个小优化?比如提前把相同的数分组,只对不同组判断?但这只能减少部分重复,两个大组件之间的合并判断依然可能消耗大量操作,最坏情况下还是 O(n^2)。
所以问题的核心是:能不能不枚举点对,而是从每个数“自身携带的质因数”出发去连接?
2.2 把“比较数”变成“拆数”
暴力法要比较“两个数的关系”,复杂度本质上是点对数。而反过来想:一个数能被拆成有限个质因数,两个数只要有共同质因数就能连通,那我能不能把每个数主动告诉它所涉及的质因数: “我属于这个质数所在的社团”?
这样就不用判断 A 和 B 是否有共同因子了,而是把 A 往它的质因数节点上挂,B 也往它的质因数节点上挂,如果它们有同一个质因数,它们就自然会出现在同一个集合里。
这里的关键优势在于:每个数的不同质因数个数很少。
在 nums[i] <= 100000 的限制下,一个数最多能有多少个不同质因数?从最小的几个质数开始乘:
- 2 × 3 × 5 × 7 × 11 × 13 = 30030,还不够 100000;
- 再乘一个 17,就是 510510,已经超出 100000。
所以一个不大于 100000 的数,最多只有 6 个不同的质因数。哪怕有两万个元素,产生的总“数字到质数”连接最多也就 20000 × 6 = 120000 条。这比 2 亿这个量级少了好几个数量级,是完全可行的方案。
所以这道题的优化路线,不是把并查集本身写得更快,而是把并查集要处理的边数压缩掉。顺着这个思路,自然会想到引入虚拟的质数节点。
3. 用质数当虚拟节点:如何把稠密图变稀疏
3.1 等价转换:数字与质因子社团
我试着用一个类比来解释这个建模过程。
把数组里的每个下标想象成一个人,把每个质数想象成一个“社团”。如果某个人含有某个质因子,就说明这个人加入了对应的社团。两个人之间能不能直接交朋友,规则是:至少参加过一个相同社团。不过朋友关系是传递的,A 和 B 在同一个社团,B 和 C 在同一个社团,就算 A 和 C 没有去过同一个社团,他们也可以通过 B 被算进同一个圈子。
并查集里的“连接”,恰好就是干这件事的。
所以建模方案就出来了:
- 给每个数组元素
nums[i]分配一个并查集节点,编号取i; - 给每个可能出现的质数
p也分配一个虚拟节点,编号取n + p; - 将
nums[i]分解质因数后,对每个质因数p,都执行union(i, n + p)。
执行完所有 union 之后,凡是能通过共享质因子连通的原始下标,都会在同一个集合里。最后统计一下每个根节点下面有多少个数组元素节点,取最大值即可。
让例子来验证:
nums = [4, 6, 15, 35],分解:
- 4 = 2^2,质因子只有 2;
- 6 = 2 × 3,质因子是 2 和 3;
- 15 = 3 × 5,质因子是 3 和 5;
- 35 = 5 × 7,质因子是 5 和 7。
于是执行并查集时,4 会先连上质数 2 的虚拟节点。6 连上质数 2 和质数 3 的虚拟节点后,4 和 6 就通过质数 2 的节点连通。15 连上质数 3 和质数 5 后,又通过质数 3 连接到 6。35 连上质数 5 后,通过质数 5 与 15 连通。最后所有下标都归到一个组件。
3.2 从 12 和 18 看并查集的连接过程
假设数组的前两个元素是 [12, 18]:
- 12 = 2^2 × 3;
- 18 = 2 × 3^2。
它们都有质因数 2 和 3。操作过程大致是:
union(0, n + 2):下标 0 代表的数字 12 与质数 2 的虚拟节点合并;union(0, n + 3):下标 0 又与质数 3 的虚拟节点合并;union(1, n + 2):下标 1 代表的数字 18 与质数 2 的虚拟节点合并。这时,因为质数 2 的虚拟节点已经在步骤 1 中和下标 0 合并过,所以实际上下标 1 和下标 0 就被放进了同一个集合;union(1, n + 3):同理,这一步会再次合并已有集合,没有额外影响。
即使两个数的公共质因数不是同一个,比如 12 通过 2 与一个数连通,18 通过 3 与另一个数连通,只要这两个质数虚拟节点都因为某个共同的数连接过,它们最终也会在那个“中间数”的同步下合并。
3.3 合数因子真的需要吗
在这套建模里,有不少人问:既然只要公因数大于 1,那我为什么不直接枚举合数因子,给每个合数也建一个虚拟节点?
可以,但没必要。
原因是:如果 x 和 y 同时被合数 d 整除,那么由唯一分解定理,d 一定包含某个质因子 p,并且这个质因子 p 同时整除 x 和 y。所以两个数一旦共享任意大于 1 的因子,必然共享至少一个质数因子,共享合数因子只是“质数因子上的冗余表达”。
直接用质数节点,能把每个数字连接的虚拟节点数量压到最少,从约数级别压到质因子级别。质数节点数量再多,也不超过 max(nums) 以内质数的个数,而且题目给的 nums[i] 上限是 100000,这个规模完全可控。
4. Java 实现与代码细节
4.1 并查集模板的写法选择
Java 解法大多数会自带并查集模板,但不同人写出来的细节不完全一样。先说我推荐的模板:
java复制static class DSU {
int[] parent;
int[] rank;
DSU(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void union(int a, int b) {
int ra = find(a);
int rb = find(b);
if (ra == rb) {
return;
}
if (rank[ra] < rank[rb]) {
parent[ra] = rb;
} else if (rank[ra] > rank[rb]) {
parent[rb] = ra;
} else {
parent[rb] = ra;
rank[ra]++;
}
}
}
路径压缩 + 按秩合并是比较稳妥的写法。有些解答为了短小只写路径压缩,不写按秩,也能过,但我建议保留按秩合并。理由有两层:一是它能保证树的高度增长被限制在 O(log n) 量级,让递归版 find 更安全;二是面试时如果面试官追问“并查集为什么快”,按秩合并是一个可以说出原理的优化点。
唯一要注意的是,这里的 DSU 类应该使用 static 修饰,放在 LeetCode 的 Solution 类内部时,静态内部类不需要持有外部 Solution 实例,结构上也更干净。
4.2 质因数分解方法与完整代码
质因数分解这里提供两种方案,先给一个最简单直接、不需要预处理的试除法工具方法:
java复制private List<Integer> getPrimeFactors(int x) {
List<Integer> factors = new ArrayList<>();
if (x <= 1) {
return factors;
}
for (int d = 2; (long) d * d <= x; d++) {
if (x % d == 0) {
factors.add(d);
while (x % d == 0) {
x /= d;
}
}
}
if (x > 1) {
factors.add(x);
}
return factors;
}
这段代码有两点需要注意。
第一,x 在循环中是不断变小的,循环条件用的是当前剩余值 x,不是原始值。例如 x = 12,除尽 2 后得到 3,下一轮 d = 3 时 x % 3 == 0,添加 3,再除尽后 x 变成 1,循环自然结束。
第二,循环结束后如果 x > 1,说明剩余的是一个大于当前试除范围的质数,也要加入列表。例如 x = 37,试除到 d = 7 时 d * d = 49 已经大于 37,退出循环,37 剩余为质数,就补加入列表。
完整 Solution:
java复制class Solution {
public int largestComponentSize(int[] nums) {
int n = nums.length;
int max = 0;
for (int v : nums) {
max = Math.max(max, v);
}
DSU dsu = new DSU(n + max + 1);
for (int i = 0; i < n; i++) {
for (int p : getPrimeFactors(nums[i])) {
// nums[i] 的节点编号是 i,质数 p 的虚拟编号是 n + p
dsu.union(i, n + p);
}
}
int[] count = new int[n + max + 1];
int ans = 1;
for (int i = 0; i < n; i++) {
int root = dsu.find(i);
count[root]++;
ans = Math.max(ans, count[root]);
}
return ans;
}
private List<Integer> getPrimeFactors(int x) {
List<Integer> factors = new ArrayList<>();
if (x <= 1) {
return factors;
}
for (int d = 2; (long) d * d <= x; d++) {
if (x % d == 0) {
factors.add(d);
while (x % d == 0) {
x /= d;
}
}
}
if (x > 1) {
factors.add(x);
}
return factors;
}
}
class DSU {
int[] parent;
int[] rank;
DSU(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void union(int a, int b) {
int ra = find(a);
int rb = find(b);
if (ra == rb) {
return;
}
if (rank[ra] < rank[rb]) {
parent[ra] = rb;
} else if (rank[ra] > rank[rb]) {
parent[rb] = ra;
} else {
parent[rb] = ra;
rank[ra]++;
}
}
}
我建议先把这段跑通,理解了再谈优化。在 nums[i] <= 100000 的约束下,这个版本已经完全可以 AC,代码量也不算大。
4.3 预设节点数组或 HashMap 动态编号
上面代码选择了“预设所有质数虚拟节点”的方案:并查集大小是 n + max + 1,数组元素下标占 0 到 n-1,质数 p 的虚拟节点编号固定为 n + p。
这种做法的优点是简单、无哈希开销,缺点是空间上依赖 max(nums)。当题目限制 nums[i] <= 100000 时,n + max + 1 最多也就 20000 + 100000 + 1 = 120001,非常轻松。所以当确认题目数值上限不大时,这个方案是首选。
另一种思路是用 HashMap 动态分配质数节点的编号:
java复制Map<Integer, Integer> primeNodeMap = new HashMap<>();
int nextNode = n;
// 遇到质数 p 时
primeNodeMap.putIfAbsent(p, nextNode++);
dsu.union(i, primeNodeMap.get(p));
这种方案的优势是并查集的实际大小不是 n + max + 1,而是 n + 实际出现的质数个数,在质数非常稀疏时会省一点空间。缺点是需要额外维护一个映射关系,写起来也没预设节点那么直觉。我的建议是:除非题目把 max(nums) 提高到 10^7 甚至 10^9,否则不必费这个劲。
4.4 三个极易踩的坑
我重写这道题时踩过几个坑,拿出来提醒大家。
第一个坑:统计结果时,用了 parent[i] 而不是 find(i)。
并查集的路径压缩是惰性的,不是每个节点在某一时刻都直接指向根节点。如果统计前没有对节点做完整压缩,直接读 parent[i] 很可能读到的是父节点而不是根,导致同一个组件被拆成多个计数。安全的做法是每次都调用 find(i):
java复制int root = dsu.find(i);
count[root]++;
第二个坑:质因数分解时没有去重。
分解 12 得到质因数 2 和 3 是去重后的结果。如果每整除一次都往列表里加,就可能得到 [2, 2, 3],执行 union(0, n + 2) 两遍,逻辑上虽然不影响最终连通性,但白白增加了操作次数。更严重的是,如果不先把偶数全部除尽,后续还可能把 4 当质数加入列表,这是错误的。所以必须在 while 循环里把当前质因数除干净,再去试下一个除数。
第三个坑:忘记处理 x > 1 的剩余部分。
典型场景是 x 本身就是质数,试除法内层循环从 2 到 sqrt(x) 都找不到因子,如果最后漏掉把剩余 x 加入列表,这个质数本身对应的质数虚拟节点就不会被连接,正确性直接出问题。
5. 测试与复杂度验证
5.1 几个典型测试用例
写算法题,小数据验证是很重要的习惯。我在本地用几个用例做过测试:
| 输入数组 | 期望输出 | 原因 |
|---|---|---|
[4, 6, 15, 35] |
4 | 2 → 3 → 5 三条边形成链 |
[2, 3, 6] |
3 | 6 同时连接 2 和 3 |
[1, 1, 1] |
1 | 1 没有质因子,全部孤立 |
[20, 50, 33, 11] |
2 | 20 和 50 共享 5,33 和 11 共享 11 |
[2, 2, 4, 9, 3, 3] |
3 | 2/2/4 共享 2,9/3/3 共享 3 |
[2, 2, 4, 9, 3, 3] 这个例子很有意思,它没有把所有数都连起来,因为质数 2 所在的组件和质数 3 所在的组件之间没有中间数包含两个质因子。组件大小分别为 3 和 3,所以答案是 3。这也验证了模型不会产生“多余连通”。
5.2 复杂度不只看大 O:实际次数的数量级
按分析,这个解法每个数都要走一次质因数分解,试除法的循环次数在单个数上不会超过 sqrt(nums[i]),大约最多 316 次。20000 个数全部分解,总的试除次数大概在 600 万级别,在每个数内做 while 整除等操作也是常数。
并查集操作方面,每个不同质因数会触发一次 union。由于每个数最多 6 个不同质因数,全部 union 次数最多 20000 × 6 = 120000 次。路径压缩和按秩合并下的单次操作,在实际使用中几乎可以看成常数。
这些数字相比于 2 亿次 gcd 比较,差距是巨大的。我习惯用一个小表格做总结:
| 实现方式 | 主要操作次数 | 瓶颈 |
|---|---|---|
| 两两枚举 gcd | C(20000, 2) 约 2 亿 | 取模和 gcd 计算量太大 |
| 质因数虚拟节点 | 至多 12 万次 union + 600 万级试除 | 单次分解仍有优化空间 |
5.3 进一步优化:预处理最小质因子
如果希望分解速度更快,可以先把所有可能出现的质数筛出来,并维护每个数的最小质因子,也就是线性筛法:
java复制int[] spf = new int[max + 1];
for (int i = 2; i <= max; i++) {
if (spf[i] == 0) {
for (int j = i; j <= max; j += i) {
if (spf[j] == 0) {
spf[j] = i;
}
}
}
}
然后查询一个数不同质因数时,顺着 spf 数组一路除下去即可。这种写法在 nums[i] <= 100000 的时候收益并不显著,因为试除法本来也不慢。但了解它还是有价值的,尤其是当题目改成多组测试,或者数值上限变大时,预处理方案能保证每个数的分解接近 O(log x)。
6. 面试官可能继续追问的方向
6.1 如果要输出最大组件,而不是只求大小
LeetCode 原题只要求返回组件大小,但面试官容易在此基础上追加一问:把最大组件的元素打印出来,或者统计每个组件的成员列表。
这时只需要把最后的计数过程改一改,用 HashMap 存储根节点到成员下标的映射:
java复制Map<Integer, List<Integer>> groups = new HashMap<>();
for (int i = 0; i < n; i++) {
int root = dsu.find(i);
groups.computeIfAbsent(root, k -> new ArrayList<>()).add(i);
}
List<Integer> largest = new ArrayList<>();
for (List<Integer> group : groups.values()) {
if (group.size() > largest.size()) {
largest = group;
}
}
这个扩展也很有实操价值。我调试复杂用例时,经常把组件内容打出来,看看并查集是否把不该连在一起的东西连上了。
6.2 如果数值范围从 10^5 放大到 10^9
如果题目改动为 nums[i] <= 10^9,预设节点方案就行不通了。此时有两个调整点:
- 并查集初始大小不能再是
n + max + 1,只能开n + k,其中 k 是后续动态分配的质数节点数量; - 质数虚拟节点必须用 HashMap 记录实时编号,并在第一次出现该质数时分配新节点。
质因数分解也要相应注意乘法溢出:试除法中 d * d 要写成 (long) d * d,否则在 d 超过 46340 时会溢出成负数,导致死循环。
6.3 这道题在考察什么“双重知识”
这套解法实际上融合了三个知识点:并查集、质因数分解、二分图式的虚拟节点建模。面试时如果能把每一步的选择理由说清楚,会显得很有深度。
常见表达可以是这个逻辑链:
- 先确认“组件”是连通块,而不是连续区间;
- 两个数连通的条件是共享公因数,进而可以收缩为共享质因数;
- 直接两两判断边数太多,需要减少边数;
- 把质数作为中间虚拟节点,等于把“数字之间的关系”转化为“数字和质数的归属关系”;
- 用并查集执行 union,最后统计最大集合。
每一步都在解决前一步暴露的问题,逻辑完整。面试官最怕的是候选人背代码但说不清“为什么用质数当虚拟节点”,所以论述逻辑比代码本身更重要。
6.4 我的刷题经验收尾
最后补一个我自己的习惯。遇到这种“并查集 + 数论”的题,我会把 DSU 的模板先写好,同时在本地准备一组可复现的测试工具。
比如打印出每个组件成员:
java复制private void printComponents(DSU dsu, int n) {
Map<Integer, List<Integer>> map = new HashMap<>();
for (int i = 0; i < n; i++) {
map.computeIfAbsent(dsu.find(i), k -> new ArrayList<>()).add(i);
}
for (Map.Entry<Integer, List<Integer>> e : map.entrySet()) {
System.out.println(e.getKey() + " -> " + e.getValue());
}
}
我在查 [4, 6, 15, 35] 这类传递性用例时,打印出来的树结构能一眼看出哪里合并错了。很多人刷题只关注 AC,但当题目变形或者面试官追问成员列表时,这些调试代码才是真正体现工程感的细节。这个工具我用一次就再也没删过,后面做其他连通性问题也能复用。
