深蓝黑背景品牌封面:左侧大标题「有交集的集合,合并成分组」,副标题「并查集:找连通分量的轻量标配」;右侧原创装饰——左半三个两两有重叠的圆圈,经箭头指向右半合并成两个互不重叠的实色分组,示意并查集的合并效果;左上标注「历史题目复盘 · 数据处理」,右上标注「Day 10 · 10-08」 图 1:本文封面,点明「合并成互不重叠分组」的目标。

先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面只有一句话:给若干集合,只要两个集合有公共元素就合并,最终输出若干互不重叠的集合。

先把问题翻译对。暴力做法是反复两两比较、见到交集就合并,直到不再变化——集合一多就是平方甚至立方级的开销。面试官真正想听的是你把它抽象成图问题的那一步:**元素是点,「同在一个集合」是边,答案就是这张图的连通分量。**而找连通分量的轻量标配,是并查集。

核心结论:用并查集,遍历一遍输入集合,把同集合的元素两两 union,最后按根分组,近线性时间收工。

并查集就两个操作。

初始时每个元素自成一个集合,父指针指向自己。find(x):沿父指针一路找到根,根代表这个元素所在的组——两个元素根相同,就是同组。union(x, y):先各找根,根不同就把一个根挂到另一个根下,两组合二为一。口诀九个字:查根判同组,挂根并两组。

两个优化让它快到接近常数。

朴素并查集的树可能退化成一条长链,find 一次要走 O(n)。两个优化专治这个:路径压缩——find 时把沿途节点直接挂到根上,树越查越扁;按秩合并——union 时让矮树挂到高树下面,树高长得慢。两个一起上,单次操作的均摊复杂度是 O(α(n)),α 是反阿克曼函数,在宇宙尺度的 n 下也不超过 5,工程上就当常数。

拿题面走一遍。

输入四个集合 {1,2,3}、{3,4}、{5,6}、{6,7}。处理第一个集合:union(1,2)、union(1,3),1、2、3 同根。第二个集合含 3 和 4:union(3,4) 把 4 挂进同一棵大树——这就是「有交集则合并」的落地,交集元素 3 是两座集合之间的桥。同理后两个集合经 6 桥接成 {5,6,7}。最后对所有元素做 find 按根分组,输出 {1,2,3,4} 与 {5,6,7}。

写出可运行的代码。

下面实现带路径压缩与按秩合并的并查集,先跑上面的示例,再用随机用例与 BFS 求连通分量的结果对拍。用 Node.js 22.15.0 直接运行(node union-find.js)即可:

'use strict';

class UnionFind {
  constructor() {
    this.parent = new Map(); // 元素 -> 父元素
    this.rank = new Map();   // 根 -> 树高上界
  }
  find(x) {
    if (!this.parent.has(x)) { this.parent.set(x, x); this.rank.set(x, 0); }
    let root = x;
    while (this.parent.get(root) !== root) root = this.parent.get(root);
    // 路径压缩:沿途节点直接挂到根上
    while (this.parent.get(x) !== root) {
      const next = this.parent.get(x);
      this.parent.set(x, root);
      x = next;
    }
    return root;
  }
  union(a, b) {
    let ra = this.find(a), rb = this.find(b);
    if (ra === rb) return;
    if (this.rank.get(ra) < this.rank.get(rb)) [ra, rb] = [rb, ra]; // 按秩合并
    this.parent.set(rb, ra);
    if (this.rank.get(ra) === this.rank.get(rb)) this.rank.set(ra, this.rank.get(ra) + 1);
  }
  groups() {
    const byRoot = new Map();
    for (const x of this.parent.keys()) {
      const r = this.find(x);
      if (!byRoot.has(r)) byRoot.set(r, []);
      byRoot.get(r).push(x);
    }
    return [...byRoot.values()].map((g) => g.sort((p, q) => p - q));
  }
}

function mergeSets(sets) {
  const uf = new UnionFind();
  for (const s of sets) for (let i = 1; i < s.length; i++) uf.union(s[0], s[i]);
  return uf.groups();
}

// 示例:{1,2,3}、{3,4}、{5,6}、{6,7}
const demo = [[1, 2, 3], [3, 4], [5, 6], [6, 7]];
console.log('示例合并结果:', JSON.stringify(mergeSets(demo)));

// 对拍基准:建图后用 BFS 求连通分量
function bfsGroups(sets) {
  const adj = new Map();
  const link = (a, b) => {
    if (!adj.has(a)) adj.set(a, []);
    adj.get(a).push(b);
  };
  for (const s of sets) for (let i = 1; i < s.length; i++) { link(s[0], s[i]); link(s[i], s[0]); }
  const seen = new Set(), out = [];
  for (const start of adj.keys()) {
    if (seen.has(start)) continue;
    const q = [start], g = [];
    seen.add(start);
    while (q.length) {
      const cur = q.shift();
      g.push(cur);
      for (const nb of adj.get(cur)) if (!seen.has(nb)) { seen.add(nb); q.push(nb); }
    }
    out.push(g.sort((p, q) => p - q));
  }
  return out;
}

const key = (gs) => JSON.stringify(gs.map((g) => g.join('-')).sort());
let bad = 0;
for (let t = 0; t < 2000; t++) {
  const sets = [];
  for (let k = 0; k < 6; k++) {
    const size = 1 + Math.floor(Math.random() * 4);
    const s = new Set();
    while (s.size < size) s.add(1 + Math.floor(Math.random() * 20));
    sets.push([...s]);
  }
  if (key(mergeSets(sets)) !== key(bfsGroups(sets))) bad++;
}
console.log('2000 组随机对拍(基准为 BFS 连通分量),不一致组数:', bad);

实测输出:

示例合并结果: [[1,2,3,4],[5,6,7]]
2000 组随机对拍(基准为 BFS 连通分量),不一致组数: 0

机制图:问题是有若干集合,只要两个集合有公共元素就应合并成一个,最终得到若干互不重叠的分组——这正是并查集的主场。上部左侧讲两个核心操作:find(x) 沿父指针一路找到根,根相同说明两个元素在同一组;union(x,y) 把 x 的根挂到 y 的根下,两组合二为一。初始时每个元素自成一个集合(父指针指向自己)。上部右侧用示例演示:集合 {1,2,3}、{3,4}、{5,6}、{6,7},因为 3 把前两组连起来、6 把后两组连起来,union 三轮后得到 {1,2,3,4} 与 {5,6,7} 两个分组。中部展示两个让并查集接近 O(1) 的优化:路径压缩——find 时把沿途节点直接挂到根上,树越查越扁;按秩合并——union 时矮树挂到高树下,避免退化成链表;两个优化一起上,均摊复杂度 O(α(n)),α 是反阿克曼函数,实践中视为常数。底部给出适用清单:实体对齐、账号关联、语料近重复聚类、图的连通分量统计;口诀「查根判同组,挂根并两组」 图 2:find 与 union 的机制、两个优化与适用清单。

可能的追问。

「元素不是整数而是字符串 ID 怎么办?」示例代码用的就是哈希表而不是数组,天然支持任意可哈希元素。「要支持删除一个元素呢?」这是并查集的短板——合并容易拆分难,真要拆只能重建或换动态连通性数据结构。「和 BFS 找连通分量比,优势在哪?」边是「流式到达」时,并查集每来一条边 O(α(n)) 就地合并,还能随时回答「这两个元素同组吗」;BFS 适合图已静态成型、一次性算全量的场景。

易错点清单。

一,find 不做路径压缩、union 不看秩,被追问复杂度时只能说「应该挺快」。二,union 之前不判同根,把秩加错。三,输出分组时忘了根相同的才算一组,直接按输入顺序切段。四,只背得出结构,讲不出「元素是点、同集合是边、答案是连通分量」这层抽象——这层翻译才是题目的考点。

这道题的影子在工程里到处都是:实体对齐把多个来源的同一个人并成一档,账号关联把手机号、邮箱、设备号串成同一用户,语料近重复聚类把相似文档归成一簇。下次遇到「把有关系的东西归堆」,先想想并查集。

你在真实业务里用过并查集吗?是解决账号关联、实体对齐,还是别的归堆问题?欢迎分享。