📖 第 3.13 章:并查集

⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等


前置条件

在学习本章之前,请确保你已掌握:

  • 数组与函数(第 2.3 章)
  • 图的基本概念——节点、边、连通(第 5.1 章)

🎯 学习目标

学完本章后,你将能够:

  1. 用「路径压缩 + 按秩合并」实现 O(α(n)) 的并查集
  2. 用并查集判断图的连通性和环
  3. 实现带权并查集解决差值/关系问题
  4. 用种类并查集解决多关系分组问题
  5. 独立完成 10 道从基础到挑战的练习题

3.13.1 从一道真实问题出发

问题:网络连通

你负责管理一个由 N 台服务器(编号 1~N)构成的数据中心网络。网络工程师陆续在两台服务器之间建立直连链路(无向),你需要随时回答:服务器 A 和服务器 B 目前是否可以互相通信?

初始状态:每台服务器各自孤立
                1    2    3    4    5

建立链路 (1,2):1——2    3    4    5
建立链路 (3,4):1——2    3——4    5
建立链路 (2,3):1——2——3——4    5

查询 (1,4):可以通信 ✓(1→2→3→4)
查询 (1,5):不可通信 ✗(5 是孤岛)

朴素解法的瓶颈:

做法查询时间合并时间
暴力 BFS/DFSO(N+M)O(1)
维护 group[] 数组O(1)O(N)(需遍历更新)
并查集O(α(N)) ≈ O(1)O(α(N)) ≈ O(1)

当 N、M 高达 10^5,且操作交替出现时,并查集是唯一实用的选择。


3.13.2 核心思想:用「树」表示一个集合

关键洞察: 把同一个连通块里的服务器,组织成一棵树。树的根节点作为这个集合的「代表」。

  • 判断两台服务器是否连通:看它们是否在同一棵树(根节点相同)
  • 合并两个连通块:把一棵树的根,指向另一棵树的根

用一个 pa[](parent,父节点)数组来表示这片森林:

并查集森林构建过程:三次 unite 后的结构

关键观察:

  • 每一次 unite 都是把一棵树的根指向另一棵树的根,而不是任意两个节点直接相连
  • unite(2, 3) 实际执行的是 pa[find(3)] ← find(2),即 pa[3] ← 1,所以合并后 4 仍然挂在 3 下(而不是直接挂到 1 下)
  • 要让 4 直接挂到根 1 下,需要借助 3.13.4 节的路径压缩

3.13.3 两个核心操作

Find(查找根节点)

沿着 pa[] 指针向上爬,找到根:

int find(int x) {
    while (pa[x] != x)
        x = pa[x];
    return x;  // pa[x] == x 时 x 就是根
}

判断 A、B 是否连通:find(A) == find(B)

Unite(合并两个集合)

将两棵树的根连起来:

void unite(int x, int y) {
    int rx = find(x);
    int ry = find(y);
    if (rx != ry)
        pa[rx] = ry;  // 把 rx 的树接到 ry 下面
}

3.13.4 优化一:路径压缩

问题: 若一直把新树接到旧树下面,树可能变成一条长链:

1 ← 2 ← 3 ← 4 ← 5 ← 6

find(1) 需要爬 5 步,时间 O(N)

路径压缩:find(x) 的过程中,把路径上所有节点直接连到根节点

并查集路径压缩前后对比

int find(int x) {
    // 若 x 不是根,先递归找到根 root
    // 然后顺手把 x 的父亲改为 root("压扁")
    return pa[x] == x ? x : pa[x] = find(pa[x]);
}

3.13.5 优化二:按节点数合并

问题: 合并时如果总把大树接到小树下,大树变得更高,find 更慢。

按节点数合并:小树接到大树下,保证树高 ≤ O(log N)。

按节点数合并:小树接大树

📄 C++ 完整代码
struct DSU {
    vector<int> pa, sz;   // sz[i] = i 为根时,这棵树的节点总数
    int groups;           // 当前连通块数量
    
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);  // pa[i] = i
    }
    
    // 路径压缩查根
    int find(int x) {
        return pa[x] == x ? x : pa[x] = find(pa[x]);
    }
    
    // 按节点数合并,返回 true 表示两者原本不连通(发生了合并)
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;       // 已在同一集合
        if (sz[x] < sz[y]) swap(x, y); // x 是大树
        pa[y] = x;                      // 小树接到大树
        sz[x] += sz[y];
        groups--;
        return true;
    }
    
    // 判断是否连通
    bool connected(int x, int y) { return find(x) == find(y); }
    
    // 所在连通块大小
    int size(int x) { return sz[find(x)]; }
};

复杂度:

优化单次操作
无优化O(N)
仅路径压缩均摊 O(α(N))
仅按节点数O(log N)
两者同时(推荐)均摊 O(α(N)) ≈ O(1)

α(N) 是反 Ackermann 函数,增长极其缓慢,α(10^80) < 5。实践中视为常数。


3.13.6 回到网络连通问题:完整代码

现在用并查集完整解决开头的问题:

📄 现在用并查集完整解决开头的问题:
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    int groups;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y]; groups--;
        return true;
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    int n, q;
    cin >> n >> q;
    DSU dsu(n);
    
    while (q--) {
        int op, a, b;
        cin >> op >> a >> b;
        if (op == 1) {
            // 建立链路
            if (dsu.unite(a, b))
                cout << "新增链路:" << a << " - " << b << "\n";
            else
                cout << "已连通,无需新增\n";
        } else {
            // 查询
            cout << (dsu.connected(a, b) ? "可以通信" : "无法通信") << "\n";
        }
    }
    return 0;
}

示例追踪:

输入:5 6
      1 1 2    → 建立链路 1-2,new(原本不通)
      1 3 4    → 建立链路 3-4,new
      1 2 3    → 建立链路 2-3,new
      2 1 4    → 查询 1 和 4 → 可以通信(1→2→3→4)
      2 1 5    → 查询 1 和 5 → 无法通信(5 是孤岛)
      1 1 4    → 建立链路 1-4,已连通(无需新增)

3.13.7 进阶:带权并查集

问题引入

有 N 名学生,班主任陆续告诉你:「B 同学比 A 同学高 D 厘米(即 height[B] - height[A] = D)」。

你需要回答:

  1. B 比 A 高多少厘米?
  2. 某条信息是否与之前的矛盾?

朴素思路: 用图建模,但查询每次都要 BFS 遍历路径,O(N) 每次查询太慢。

带权并查集的思路: 在每个节点存储「它到根节点的高度差 dist[x]」,查询时直接用 dist 相减。

带权并查集 dist[] 示意图

核心设计

  • dist[x] = height[x] - height[find(x)](x 的身高减去根的身高)
  • 路径压缩时,累加路径上的 dist,把 x 直接连到根:
压缩前:x → p → root
  dist[x] = height[x] - height[p]
  dist[p] = height[p] - height[root]

压缩后:x → root
  新的 dist[x] 应该 = height[x] - height[root]
                    = dist[x] + dist[p]
int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];  // 压缩同时累加路径权值
    pa[x] = root;
    return root;
}

合并时计算新边权值

「声明 height[y] - height[x] = d」:

📄 「声明 height[y] - height[x] = d」:
我们有:dist[x] = height[x] - height[rx]
        dist[y] = height[y] - height[ry]

若把 ry 接到 rx 下,则需要 dist[ry] 满足:
    height[ry] - height[rx] = ?
    
由 height[y] - height[x] = d 推导:
    (dist[y] + height[ry]) - (dist[x] + height[rx]) = d
    height[ry] - height[rx] = d + dist[x] - dist[y]

所以 dist[ry] = d + dist[x] - dist[y]
📄 C++ 完整代码
// 完整带权并查集
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int pa[MAXN], sz[MAXN];
long long dist[MAXN];  // dist[x] = height[x] - height[find(x)]

void init(int n) {
    for (int i = 1; i <= n; i++) { pa[i] = i; sz[i] = 1; dist[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];
    pa[x] = root;
    return root;
}

// 声明 height[y] - height[x] = d
// 返回 true = 无矛盾;false = 与已知矛盾
bool unite(int x, int y, long long d) {
    int rx = find(x), ry = find(y);
    long long dx = dist[x], dy = dist[y];
    
    if (rx == ry) {
        // 已在同集合,验证是否矛盾
        return (dy - dx == d);
    }
    
    // 合并:小树接大树
    if (sz[rx] < sz[ry]) {
        swap(rx, ry); swap(dx, dy); d = -d;
    }
    pa[ry] = rx;
    dist[ry] = d + dx - dy;
    sz[rx] += sz[ry];
    return true;
}

long long query(int x, int y) {
    find(x); find(y);
    return dist[y] - dist[x];  // height[y] - height[x]
}

int main() {
    int n = 5;
    init(n);
    
    unite(1, 2, 3);   // height[2] - height[1] = 3(2 比 1 高 3cm)
    unite(2, 3, 5);   // height[3] - height[2] = 5
    
    // 查询 1 和 3 的差
    cout << "3 比 1 高 " << query(1, 3) << " cm\n";  // 输出 8
    
    // 添加矛盾信息
    cout << (unite(1, 3, 10) ? "一致" : "矛盾") << "\n";  // 矛盾(应该是8)
    cout << (unite(1, 3, 8)  ? "一致" : "矛盾") << "\n";  // 一致
    
    return 0;
}

3.13.8 进阶:种类并查集

问题引入

经典题: 动物王国中有三类动物 A、B、C,满足:A 吃 B,B 吃 C,C 吃 A。

依次输入 N 条信息,格式为:

  • 1 X Y:X 和 Y 是同类
  • 2 X Y:X 吃 Y

若某条信息与前面的所有真实信息矛盾,则它是「假话」。求假话总数。

关键难点: 需要同时追踪「是否同类」「是否捕食关系」两种关系,普通并查集只能处理一种等价关系。

种类并查集:动物三角关系

解法:把每个节点拆成三份

将每个动物 x 扩展为三个虚拟节点:

节点含义
x(原始)与 x 同类的集合
x + n被 x 吃的集合(x 的猎物)
x + 2n吃 x 的集合(x 的天敌)

处理「X 和 Y 同类」:

x 的同类 = y 的同类    → unite(x, y)
x 的猎物 = y 的猎物    → unite(x+n, y+n)
x 的天敌 = y 的天敌    → unite(x+2n, y+2n)

处理「X 吃 Y」(X 的猎物就是 Y 的同类):

x 的猎物 = y 的同类    → unite(x+n, y)
x 的天敌 = y 的猎物    → unite(x+2n, y+n)
x 的同类 = y 的天敌    → unite(x, y+2n)

判断矛盾「X 和 Y 同类」时:

若 connected(x, y+n) → 矛盾(x 和 y 一个吃另一个,不可能同类)
若 connected(x, y+2n) → 矛盾

判断矛盾「X 吃 Y」时:

若 connected(x, y) → 矛盾(同类不可能有捕食关系)
若 connected(x, y+n) → 矛盾(y 吃 x,但说 x 吃 y)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 150005;
int pa[MAXN * 3], sz[MAXN * 3];

void init(int n) {
    for (int i = 0; i < 3 * (n + 1); i++) { pa[i] = i; sz[i] = 1; }
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool same(int x, int y) { return find(x) == find(y); }
void unite(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;
    if (sz[x] < sz[y]) swap(x, y);
    pa[y] = x; sz[x] += sz[y];
}

int main() {
    int n, k;
    cin >> n >> k;
    init(n);
    
    int lies = 0;
    while (k--) {
        int t, x, y;
        cin >> t >> x >> y;
        
        // 编号越界直接判假
        if (x < 1 || x > n || y < 1 || y > n) { lies++; continue; }
        
        if (t == 1) {
            // 声明 x 和 y 同类
            if (same(x, y + n) || same(x, y + 2 * n)) {
                lies++;  // 矛盾:x 和 y 之间有捕食关系
            } else {
                unite(x, y);
                unite(x + n, y + n);
                unite(x + 2 * n, y + 2 * n);
            }
        } else {
            // 声明 x 吃 y
            if (x == y) { lies++; continue; }  // 自己不能吃自己
            if (same(x, y) || same(x, y + n)) {
                lies++;  // 矛盾
            } else {
                unite(x + n, y);
                unite(x + 2 * n, y + n);
                unite(x, y + 2 * n);
            }
        }
    }
    
    cout << lies << "\n";
    return 0;
}

⚠️ 常见错误

错误原因修复方案
带权并查集路径压缩后权值错误没在递归中累加 distdist[x] += dist[pa[x]] 在改 pa[x] 前执行
合并时没更新 sz只改了 pa,忘记维护大小sz[rx] += sz[ry]
种类并查集数组开小需要 3N 个节点int pa[MAXN * 3]
先 unite 再判矛盾合并后信息已融合,无法判断必须先判矛盾,再 unite
find 递归爆栈极长链时(N > 10^5)递归深度溢出用迭代版路径压缩替代

💪 练习题(共 10 道,全部含完整解答)

🟢 基础练习(1~4)

题目 1:朋友圈计数
给 N 个人(编号 1~N)和 M 对朋友关系,同一朋友圈里的人可以互相联系。
求最终共有多少个朋友圈。

输入: N M,然后 M 行每行 A B 表示 A 和 B 是朋友。
输出: 朋友圈数量。

示例:

输入:5 3
     1 2
     2 3
     4 5

输出:2
({1,2,3} 和 {4,5})
✅ 完整解答

思路: 每次合并时若两人原本不在同一集合(unite 返回 true),连通块数 -1。最终 dsu.groups 就是答案。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    int groups;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y]; groups--;
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int a, b; cin >> a >> b;
        dsu.unite(a, b);
    }
    cout << dsu.groups << "\n";
    return 0;
}

追踪(N=5, M=3):

初始 groups = 5
unite(1,2) → 不同集合,groups = 4,pa[2]=1
unite(2,3) → 不同集合,groups = 3,pa[3]=1
unite(4,5) → 不同集合,groups = 2,pa[5]=4
输出:2 ✓

题目 2:判断图中是否有环
给 N 个节点和 M 条无向边,判断这个图是否包含环。

输入: N M,然后 M 行每行 U V 表示一条边。
输出: YES(有环)或 NO(无环)。

✅ 完整解答

思路: 加一条边 (u, v) 时,若 u 和 v 已经连通(find(u)==find(v)),则这条边构成环。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;  // 已连通 = 加边后形成环
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    bool has_cycle = false;
    while (m--) {
        int u, v; cin >> u >> v;
        if (!dsu.unite(u, v)) has_cycle = true;
    }
    cout << (has_cycle ? "YES" : "NO") << "\n";
    return 0;
}

关键点: unite 返回 false 代表两端已连通 → 这条边是多余边 → 存在环。


题目 3:最大连通块
给 N 个节点和 M 条边,输出最大连通块包含的节点数。

✅ 完整解答

思路: 用带 sz[] 的并查集,合并结束后遍历所有节点,找 sz[find(i)] 的最大值。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
    }
    int size(int x) { return sz[find(x)]; }
};

int main() {
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int u, v; cin >> u >> v;
        dsu.unite(u, v);
    }
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, dsu.size(i));
    cout << ans << "\n";
    return 0;
}

题目 4:Kruskal 最小生成树
给 N 个节点和 M 条带权无向边,求最小生成树的总权重。若图不连通输出 -1。

✅ 完整解答

思路: Kruskal 算法:将所有边按权重从小到大排序,依次尝试加入。若两端不在同一集合(不会成环),则加入 MST。最终若 MST 边数 = N-1,则图连通。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    int n, m;
    cin >> n >> m;
    
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) cin >> u >> v >> w;
    sort(edges.begin(), edges.end());  // 按权重排序
    
    DSU dsu(n);
    long long total = 0;
    int cnt = 0;  // MST 中的边数
    
    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {
            total += w;
            cnt++;
            if (cnt == n - 1) break;  // 找够 n-1 条边
        }
    }
    
    cout << (cnt == n - 1 ? total : -1) << "\n";
    return 0;
}

追踪示例(N=4, 边:1-2权1, 2-3权2, 1-3权3, 3-4权4):

排序后:(1,1,2), (2,2,3), (3,1,3), (4,3,4)

加边(1,2)权1 → unite成功,cnt=1,total=1
加边(2,3)权2 → unite成功,cnt=2,total=3
加边(1,3)权3 → find(1)=find(3),已连通,跳过
加边(3,4)权4 → unite成功,cnt=3=n-1,total=7

输出:7

🟡 进阶练习(5~8)

题目 5:网络连通性查询
给 N 台服务器,M 次操作:

  • connect A B:在 A 和 B 之间建立链路
  • query A B:询问 A 和 B 是否可以通信
  • block A B:断开 A 和 B 之间的直连链路(注意:不是断开连通性!

输出所有 query 的结果。

提示: 普通并查集不支持「断边」。解决方案:离线倒序处理——将操作逆序执行,把「断边」变成「加边」。

✅ 完整解答

核心思路:

  1. 先记录所有操作
  2. 从后向前处理:block 变成 connect,正向的 connect 但时间上在 block 之前需要排除
  3. 并查集 + 离线逆序处理,输出时逆序输出 query 结果

更简洁的方案:预处理每条边「实际存在的时间段」,再用离线并查集。

以下给出离线逆序 + 回收操作的简化版本(假设每对服务器最多断开一次):

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    int n, m;
    cin >> n >> m;
    
    vector<tuple<int,int,int>> ops(m);  // {type, a, b},type: 0=connect,1=query,2=block
    set<pair<int,int>> blocked;         // 已断开的边
    
    for (auto& [t, a, b] : ops) {
        string op; cin >> op >> a >> b;
        if (op == "connect") t = 0;
        else if (op == "query") t = 1;
        else { t = 2; blocked.insert({min(a,b), max(a,b)}); }
    }
    
    // 逆序处理
    DSU dsu(n);
    // 先加入所有「最终状态下存在」的边(connect 且未被 block 的)
    for (auto& [t, a, b] : ops) {
        if (t == 0) {
            auto key = make_pair(min(a,b), max(a,b));
            if (!blocked.count(key)) dsu.unite(a, b);
        }
    }
    
    vector<string> answers;
    for (int i = m - 1; i >= 0; i--) {
        auto [t, a, b] = ops[i];
        if (t == 2) {
            // 逆序时 block 变 connect
            dsu.unite(a, b);
        } else if (t == 1) {
            answers.push_back(dsu.connected(a, b) ? "YES" : "NO");
        }
        // connect 在逆序中不处理(已在初始化时加入)
    }
    
    reverse(answers.begin(), answers.end());
    for (auto& s : answers) cout << s << "\n";
    return 0;
}

题目 6:身高差查询(带权并查集)
N 名学生,M 条信息。每条信息格式为 A B D 表示「B 比 A 高 D cm(可为负)」。
然后 Q 次查询,每次询问「B 比 A 高多少 cm」,若无法推断输出 unknown,若已知矛盾输出 conflict

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int pa[MAXN], sz_arr[MAXN];
long long dist[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) { pa[i] = i; sz_arr[i] = 1; dist[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];
    pa[x] = root;
    return root;
}

// 声明 height[b] - height[a] = d,返回 false 表示矛盾
bool add_info(int a, int b, long long d) {
    int ra = find(a), rb = find(b);
    long long da = dist[a], db = dist[b];
    if (ra == rb) return (db - da == d);
    if (sz_arr[ra] < sz_arr[rb]) { swap(ra, rb); swap(da, db); d = -d; }
    pa[rb] = ra;
    dist[rb] = d + da - db;
    sz_arr[ra] += sz_arr[rb];
    return true;
}

int main() {
    int n, m, q;
    cin >> n >> m;
    init(n);
    
    bool global_conflict = false;
    for (int i = 0; i < m; i++) {
        int a, b; long long d;
        cin >> a >> b >> d;
        if (!add_info(a, b, d)) global_conflict = true;
    }
    
    cin >> q;
    while (q--) {
        int a, b; cin >> a >> b;
        int ra = find(a), rb = find(b);
        if (ra != rb) cout << "unknown\n";
        else if (global_conflict) cout << "conflict\n";
        else cout << dist[b] - dist[a] << "\n";
    }
    return 0;
}

输入示例:

5 3
1 2 3    → height[2] - height[1] = 3
2 3 5    → height[3] - height[2] = 5
1 3 8    → height[3] - height[1] = 8(与前两条一致)

2
1 3      → 输出 8
1 4      → 输出 unknown

题目 7:方格染色(种类并查集变形)
N × N 方格,每格初始为白色。M 次操作,每次给某行或某列的所有格子染色(黑←→白 翻转)。
操作结束后,询问 Q 个格子的颜色(黑或白)。

提示: 用「行并查集」和「列并查集」分别维护,结合奇偶性(翻转次数的奇偶)跟踪颜色。

✅ 完整解答(简化版:只处理行翻转)

用带权并查集,dist[x] 记录奇偶性(0=未翻转,1=翻转过奇数次):

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int pa[MAXN];
int flip[MAXN];  // flip[x] = x 相对于根的翻转次数(mod 2)

void init(int n) {
    for (int i = 0; i <= n; i++) { pa[i] = i; flip[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    flip[x] ^= flip[pa[x]];  // 路径上翻转次数的奇偶叠加
    pa[x] = root;
    return root;
}

// 声明「x 和 y 属于同组,且翻转关系为 d(0=同色,1=不同色)」
void unite(int x, int y, int d) {
    int rx = find(x), ry = find(y);
    int fx = flip[x], fy = flip[y];
    if (rx == ry) return;
    pa[ry] = rx;
    flip[ry] = d ^ fx ^ fy;
}

// 查询 x 的颜色相对于根的偏移
int query(int x) {
    find(x);
    return flip[x];
}

此模板适用于所有「奇偶关系」类型的种类并查集问题。


题目 8:标准并查集模板(Luogu P3367)
即标准并查集模板题:M 次操作,每次 1 X Y(合并 X 和 Y 所在的集合)或 2 X Y(查询 X 和 Y 是否在同一集合,输出 YN)。

✅ 完整解答

这是标准并查集裸题,直接套模板:

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) dsu.unite(x, y);
        else cout << (dsu.connected(x, y) ? "Y" : "N") << "\n";
    }
    return 0;
}

🔴 挑战练习(9~10)

题目 9:食物链(NOIP 2001 P2024)
N 种动物,三类关系(A 吃 B,B 吃 C,C 吃 A 循环)。K 条信息,格式同 3.13.8 节。
求假话数量。

✅ 完整解答

直接使用 3.13.8 节的「种类并查集」代码,注意细节:

  • 自己吃自己(x == y 且 type=2):假话
  • 编号越界(x > n 或 y > n):假话
  • 矛盾检测先于合并
// 直接使用 3.13.8 节代码即可
// 关键测试:
// N=100, K=7
// 1 101 1    → x=101 > N=100,假话,lies=1
// 2 1 2      → 声明 1 吃 2,无矛盾,合并
// 2 2 3      → 声明 2 吃 3,无矛盾,合并  
// 2 3 1      → 1 吃 2 吃 3 吃 1 ← 合法循环
// 1 1 3      → 1 和 3 同类?但 1 吃 3,矛盾,lies=2
// 2 3 3      → x==y,自己吃自己,lies=3
// 1 1 2      → 1 和 2 同类?但 1 吃 2,矛盾,lies=4
// 输出:4

完整代码见 3.13.8 节,直接提交即可。


题目 10:可持久化并查集(综合应用)
给 N 个元素和 M 次操作,每次操作为「合并两个集合」或「回滚到历史的某一版本」。每次操作后回答若干查询(例如某两个元素在当前版本下是否连通)。

提示: 需要「可持久化并查集」——用按秩合并(不用路径压缩)+ 线段树维护历史版本的 pa[] 数组。

✅ 核心思路(框架代码)

为什么不能路径压缩? 路径压缩会改变树的结构,版本回滚后结构会乱。只用按秩合并(树高 O(log N)),每次 find 最多 O(log N) 步,可接受。

// 可持久化并查集框架(用持久化线段树维护 pa[])
// 每次 unite 操作只改动 2 个节点(rx 和 ry),
// 对线段树做单点修改,生成新版本

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int MAXLOG = 20;

struct Node {
    int left, right, val, rnk;  // rnk = 树的秩
} tr[MAXN * MAXLOG * 4];

int root[MAXN], cnt = 0;  // root[i] = 第 i 个版本的根节点

// 建初始线段树(所有节点 pa[i] = i)
int build(int l, int r) {
    int node = ++cnt;
    if (l == r) {
        tr[node].val = l;  // pa[l] = l(自己是根)
        tr[node].rnk = 0;
        return node;
    }
    int mid = (l + r) / 2;
    tr[node].left = build(l, mid);
    tr[node].right = build(mid + 1, r);
    return node;
}

// 持久化单点修改 pa[pos] = new_val
int update(int prev, int l, int r, int pos, int new_val, int new_rnk = -1) {
    int node = ++cnt;
    tr[node] = tr[prev];  // 复制上一版本
    if (l == r) {
        tr[node].val = new_val;
        if (new_rnk >= 0) tr[node].rnk = new_rnk;
        return node;
    }
    int mid = (l + r) / 2;
    if (pos <= mid)
        tr[node].left = update(tr[prev].left, l, mid, pos, new_val, new_rnk);
    else
        tr[node].right = update(tr[prev].right, mid + 1, r, pos, new_val, new_rnk);
    return node;
}

// 查询 pa[pos]
int query(int node, int l, int r, int pos) {
    if (l == r) return tr[node].val;
    int mid = (l + r) / 2;
    if (pos <= mid) return query(tr[node].left, l, mid, pos);
    return query(tr[node].right, mid + 1, r, pos);
}

int n;

// 在版本 ver 中查找 x 的根(不做路径压缩!)
int find(int ver, int x) {
    int pa = query(root[ver], 1, n, x);
    if (pa == x) return x;
    return find(ver, pa);
}

int main() {
    int m;
    cin >> n >> m;
    root[0] = build(1, n);
    
    for (int i = 1; i <= m; i++) {
        int op; cin >> op;
        if (op == 1) {
            // 合并
            int x, y; cin >> x >> y;
            int rx = find(i - 1, x), ry = find(i - 1, y);
            // ... 按秩合并,生成 root[i]
        } else if (op == 2) {
            // 回滚到第 k 版本
            int k; cin >> k;
            root[i] = root[k];
        } else {
            // 查询
            int x; cin >> x;
            cout << find(i - 1, x) << "\n";
            root[i] = root[i - 1];
        }
    }
    return 0;
}

完整实现参考:Luogu P3402 可持久化并查集


💡 章节联系: 并查集是图论最基础的工具之一——判连通性(第 5.2 章)、Kruskal MST(第 8.1 章)都依赖并查集。带权并查集在 USACO Gold 和信奥中频繁出现,建议重点掌握。