Page Top

数据结构——并查集 学习笔记

数据结构——并查集 学习笔记

并查集是一种用于管理元素所属集合的数据结构,实现为一个森林。

并查集中,每棵树表示一个集合,树中的节点表示对应集合中的元素。

其思想是,把集合属性绑定到根节点上,避免多余的处理,因此一般难以分离。

普通并查集

并查集支持两种操作:

  • 合并(Union):合并两个元素所属集合(合并对应的树);
  • 查询(Find):查询某个元素所属集合(查询对应的树的根节点)。
class union_find {
	private:
		vector<int> fa;
	public:
		union_find() = delete;
		union_find(const size_t N): fa(N) { iota(fa.begin(), fa.end(), 0); }
		int find(int x) { return x == fa[x] ? x : find(fa[x]); }
		bool connected(int u, int v) { return find(u) == find(v); }
		void unite(int u, int v) { fa[find(u)] = find(v); }
};

路径压缩

一个不通用的优化,我们把任意一个非根节点直接合并到它的根上。

class union_find {
	private:
		vector<int> fa;
	public:
		union_find() = delete;
		union_find(const size_t N): fa(N) { iota(fa.begin(), fa.end(), 0); }
		int find(int x) { return x == fa[x] ? x : fa[x] = find(fa[x]); }
		bool connected(int u, int v) { return find(u) == find(v); }
		void unite(int u, int v) { fa[find(u)] = find(v); }
};

非常好写,但是对于可撤销等就无法压缩了。

启发式合并和按秩合并

合并时,选择哪棵树的根节点作为新树的根节点会很大程度上影响复杂度。

一般来说,我们可以将节点较少或深度较小的树连到另一棵,以免发生退化。

  • 其中,按照节点个数合并,称为启发式合并(维护树的大小)。
  • 而按照深度(称为秩)合并的,称为按秩合并(维护树的高度)。

一定程度上,启发式合并会被卡,但是按秩合并会比较难写。

class union_find {
	private:
		vector<int> fa, siz;
	public:
		union_find() = delete;
		union_find(int N): fa(N), siz(N, 1) { iota(fa.begin(), fa.end(), 0); }
		int find(int x) { return x == fa[x] ? x : fa[x] = find(fa[x]); }
		bool connected(int u, int v) { return find(u) == find(v); }
		void unite(int u, int v) {
			u = find(u), v = find(v);
			if (u == v) return;
			if (siz[u] > siz[v]) swap(u, v);
			fa[u] = v, siz[v] += siz[u];
		}
};

按秩合并,

class union_find {
	private:
		vector<int> fa, dep;
	public:
		union_find() = delete;
		union_find(int N): fa(N), dep(N, 1) { iota(fa.begin(), fa.end(), 0); }
		int find(int x) { return x == fa[x] ? x : fa[x] = find(fa[x]); }
		bool connected(int u, int v) { return find(u) == find(v); }
		void unite(int u, int v) {
			u = find(u), v = find(v);
			if (u == v) return;
			if (dep[u] > dep[v]) swap(u, v);
			fa[u] = v;
			if (dep[u] == dep[v]) ++dep[v];
		}
};

注:因为路径压缩的存在,按秩合并没有太大优势,但是在无法路径压缩的时候,按秩合并似乎也优势不大。

附:不那么准的评测记录,

启发式合并 按秩合并
TLE 5000ms AC 32ms AC 32ms
路径压缩 AC 31ms AC 32ms AC 31ms

复杂度分析

如果只使用路径压缩或启发式合并,时间复杂度是单次 \(\mathcal O(\log n)\) 的。

如果同时使用,时间复杂度是单次 \(\mathcal O(\alpha(n))\) 的,可以近似看成单次 \(\mathcal O(1)\)

扩展域并查集

扩展域并查集用于维护两类及以上集合的连通性。

具体的,我们一般开多倍空间,用 \(x,x+n,\dots\) 表示同一个物体的不同属性。

这种用多个域表示同一元素不同属性的,也称为种类并查集

P1892 [BOI2003] 团伙

经典例题:P1892 [BOI2003] 团伙

我们用 \(F[1,N]\) 表示朋友域,用 \(F[N+1,2N]\) 表示敌人域。

  • \(x,y\) 是朋友,那么直接连接 \(\langle x,y\rangle\),表示他俩是朋友;
  • \(x,y\) 是敌人,那么连接 \(\langle x,y+N\rangle,\langle x+N,y\rangle\),表示敌人的敌人是朋友。

例如 \(A\to B\to C\),其中只有 \(B\) 是敌人域的,那么 \(A\) 敌人 \(B\) 的敌人 \(C\) 就是 \(A\) 的朋友。

点击查看代码
#include <bits/stdc++.h>

using namespace std;

struct dsu {
    vector<int> fa;
    dsu(int siz): fa(siz) { iota(fa.begin(), fa.end(), 0); }
    int getfa(int x) { return x == fa[x] ? x : fa[x] = getfa(fa[x]); }
    void unite(int x, int y) { fa[getfa(x)] = getfa(y); }
};

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);
    int n, m;
    cin >> n >> m;
    dsu a(2 * n + 1);
    while (m--) {
        char op[3];
        int x, y;
        cin >> op >> x >> y;
        if (op[0] == 'F') a.unite(x, y);
        else a.unite(x + n, y), a.unite(y + n, x);
    }
    int res = 0;
    for (int i = 1; i <= n; ++i)
        res += a.fa[i] == i;
    cout << res << endl;
    return 0;
}

P2024 [NOI2001] 食物链

一个比经典例题还经典的例题:P2024 [NOI2001] 食物链

我们另,

  • \(x\) 表示本体;
  • \(x+n\) 表示 \(x\) 的事物集合;
  • \(x+2n\) 表示 \(x\) 的天敌集合。
点击查看代码
#include <bits/stdc++.h>

using namespace std;

#define endl "\n"

struct dsu {
    vector<int> fa;
    dsu() = default;
    dsu(int siz): fa(siz) { iota(fa.begin(), fa.end(), 0); }
    int getfa(int x) { return x == fa[x] ? x : fa[x] = getfa(fa[x]); }
    void unite(int x, int y) { fa[getfa(x)] = getfa(y); }
};

int n, k;

dsu a;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);
    cin >> n >> k;
    a = dsu(3 * n + 1);
    // x:         >_<
    // x + n:     x's food
    // x + 2 * n: x's enemy
    auto uni = [] (int x, int y) -> bool {
        if (a.getfa(x) == a.getfa(y + n)) return false;
        if (a.getfa(x) == a.getfa(y + 2 * n)) return false;
        a.unite(x, y), a.unite(x + n, y + n), a.unite(x + 2 * n, y + 2 * n);
        return true;
    };
    auto eat = [] (int x, int y) -> bool {
        if (a.getfa(x) == a.getfa(y)) return false;
        if (a.getfa(x) == a.getfa(y + n)) return false;
        a.unite(x + n, y), a.unite(x, y + 2 * n), a.unite(x + 2 * n, y + n);
        return true;
    };
    int ans = 0;
    while (k--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (x > n || y > n) ++ans;
        else if (op == 1) ans += !uni(x, y);
        else if (op == 2) ans += !eat(x, y);
    }
    cout << ans << endl;
    return 0;
}

我们可以总结出来,

  • 扩展域并查集,一定要搞清楚要开几个维度,连边必须讨论清楚,尽量多连;
  • 一般来说,通常有几个维度就至少要连几条边。

带权并查集

带权并查集,也称为边带权并查集

我们在并查集的边上定义某种权值,从而解决更多的问题。

而因为路径压缩的存在,我们一般要定义这种权值在路径压缩时产生的运算。

P2024 [NOI2001] 食物链

你说得对,这道题也可以用带权并查集来做。

在边权上维护模 3 意义下的加法群,从根开始计算两个点的深度差

\[d=d(x)-d(y) \]

  • \(d\equiv0\pmod3\),则 \(x,y\) 属于同类;
  • \(d\equiv1\pmod3\),则 \(x\)\(y\)\(x\)\(y\) 的天敌;
  • \(d\equiv0\pmod3\),则 \(y\)\(x\)\(y\)\(x\) 的天敌;

当我们在路径压缩的时候,注意我们记录的 \(d(x)\) 表示的是 \(x\) 到其父节点的距离,

  • 那么,我们已经跑完了一个节点的祖先,其父节点一定是直接接在根上面的。
  • 于是,我们另一个节点的新的距离直接为其父节点到祖先(父节点的父节点)的距离加上其到其父节点的距离即可。
int getfa(int x) {
    if (x == fa[x]) return x;
    int t = getfa(fa[x]);
    d[x] = d[x] + d[fa[x]];
    return fa[x] = t;
}

合并的时候,默认把 \(x\) 分支接在 \(y\) 的祖先上,分类讨论即可,

  • 因为已经路径压缩了,因此 \(x,y\) 的父节点一定就是根节点。
  • \(x,y\) 是同类,则合并其父节点时要保证其深度相同,于是取 \(d(y)-d(x)\)
  • \(x\)\(y\),那么要使 \(x\)\(y\) 高一级,取 \(d(y)-d(x)+1\)

这两个数的本质就是,我们再向上合并的时候要加上 \(d(x)\),则可以抵消。

点击查看代码
#include <bits/stdc++.h>

using namespace std;

#define endl "\n"

struct dsu {
    vector<int> fa, d;
    dsu() = default;
    dsu(int siz): fa(siz), d(siz) { iota(fa.begin(), fa.end(), 0); }
    int getfa(int x) {
        if (x == fa[x]) return x;
        int t = getfa(fa[x]);
        d[x] = d[x] + d[fa[x]];
        return fa[x] = t;
    }
};

int n, k;

dsu a;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);
    cin >> n >> k;
    a = dsu(n + 1);
    auto uni = [] (int x, int y) -> bool {
        int px = a.getfa(x);
        int py = a.getfa(y);
        if (px != py) {
            a.fa[px] = py;
            a.d[px] = a.d[y] - a.d[x];
            return true;
        }
        return ((a.d[x] - a.d[y]) % 3 + 3) % 3 == 0;
    };
    auto eat = [] (int x, int y) -> bool {
        int px = a.getfa(x);
        int py = a.getfa(y);
        if (px != py) {
            a.fa[px] = py;
            a.d[px] = a.d[y] - a.d[x] + 1;
            return true;
        }
        return ((a.d[x] - a.d[y]) % 3 + 3) % 3 == 1;
    };
    int ans = 0;
    while (k--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (x > n || y > n) ++ans;
        else if (op == 1) ans += !uni(x, y);
        else if (op == 2) ans += !eat(x, y);
    }
    cout << ans << endl;
    return 0;
}

P1196 [NOI2002] 银河英雄传说

同时维护边权和集合大小。

注意到如果把一个队列 \(A\) 接到 \(B\),相当于 \(A\) 加上边权为集合 \(B\) 的大小,直接接到 \(B\) 的根上。

我们根据这个,直接维护即可。

点击查看代码
#include <bits/stdc++.h>

using namespace std;

struct dsu {
    vector<int> fa, siz, d;
    dsu() = default;
    dsu(int n): fa(n), siz(n, 1), d(n) { iota(fa.begin(), fa.end(), 0); }
    int getfa(int x) {
        if (x == fa[x]) return x;
        int t = getfa(fa[x]);
        d[x] = d[x] + d[fa[x]];
        return fa[x] = t;
    }
    // merge x to y
    void unite(int x, int y) {
        x = getfa(x), y = getfa(y);
        fa[x] = y, d[x] = siz[y];
        siz[y] += siz[x];
    }
};

dsu a(30005);

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);
    int T; cin >> T;
    while (T--) {
        char op[3];
        int x, y;
        cin >> op >> x >> y;
        if (op[0] == 'M') a.unite(x, y);
        else {
            if (a.getfa(x) != a.getfa(y)) cout << "-1" << endl;
            else cout << abs(a.d[x] - a.d[y]) - 1 << endl;
        }
    }
    return 0;
}

经典例题分析

P1955 [NOI2015] 程序自动分析

有若干组条件,可能为 \(a_i=a_j\)\(a_i\neq a_j\),请判断是否合法。

注意到我们先把等于的 unite 起来,然后再检查不等于的是否合法即可。

离散化可以使用 umap 复杂度低(如果是 CF 建议使用 map)(。

P1455 搭配购买

维护集合 \(c,w\) 的和,进行 01 背包。

过于板子,解析略。

点击查看代码
#include <bits/stdc++.h>

using namespace std;

struct pack01 {
    int n, v;
    vector<int> c, w;
    pack01() = delete;
    pack01(int v, vector<int> c, vector<int> w): n(c.size()), v(v), c(c), w(w), dp(v + 1) {}
    vector<int> dp;
    int calc() {
        for (int i = 0; i < n; ++i)
            for (int j = v; j >= c[i]; --j)
                dp[j] = max(dp[j], dp[j - c[i]] + w[i]);
        return dp[v];
    }
};

signed main() {
    int n, m, v;
    cin >> n >> m >> v;
    vector<int> c(n + 1), w(n + 1), fa(n + 1);
    for (int i = 1; i <= n; ++i) cin >> c[i] >> w[i], fa[i] = i;
    // dsu
    function<int(int)> getfa = [&] (int x) {
        if (x == fa[x]) return x;
        return fa[x] = getfa(fa[x]);
    };
    auto unite = [&] (int x, int y) {
        x = getfa(x), y = getfa(y);
        if (x == y) return;
        fa[x] = y, c[y] += c[x], w[y] += w[x];
    };
    while (m--) {
        int x, y;
        cin >> x >> y;
        unite(x, y);
    }
    vector<int> ct, wt;
    for (int i = 1; i <= n; ++i) {
        if (i != fa[i]) continue;
        ct.push_back(c[i]), wt.push_back(w[i]);
    }
    pack01 solev(v, ct, wt);
    cout << solev.calc() << endl;
    return 0;
}

P1197 [JSOI2008] 星球大战

每次打掉图中的几个点,询问连通块数量。

注意到并查集可以快速查询连通块数量,但是很难支持删除操作。

但是并查集可以很快的完成加入,因此我们正难则反。

  1. 先把被打掉的点一口气打掉,处理连通块;
  2. 从后往前加入被打掉的点,记录连通块数量。

注意一些细节,实现是很简单的。

点击查看代码
#include <bits/stdc++.h>

using namespace std;

#define endl "\n"

constexpr int N = 4e5 + 10;

int n, m;

int hack[N];
bool hacked[N];

vector<int> g[N];

int fa[N], tot;

int getfa(int x) {
    if (x == fa[x]) return x;
    return fa[x] = getfa(fa[x]);
}

void unite(int x, int y) {
    x = getfa(x), y = getfa(y);
    if (x != y) fa[x] = y, --tot;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);
    cin >> n >> m;
    tot = n;
    for (int i = 1; i <= n; ++i)
        fa[i] = i;
    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        ++u, ++v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    int k;
    cin >> k;
    for (int i = 0; i < k; ++i) {
        cin >> hack[i];
        hacked[++hack[i]] = true;
    }
    for (int i = 1; i <= n; ++i) {
        if (hacked[i]) continue;
        if (!g[i].empty())
        for (int j : g[i]) {
            if (hacked[j]) continue;
            unite(i, j);
        }
    }
    vector<int> ans(k + 1);
    ans[k] = tot - k;
    for (int i = k - 1; ~i; --i) {
        int x = hack[i];
        hacked[x] = 0;
        if (!g[x].empty())
        for (int y : g[x]) {
            if (hacked[y]) continue;
            unite(x, y);
        }
        ans[i] = tot - i;
    }
    for (int i : ans)
        cout << i << endl;
    return 0;
}

AT_abc238_e [ABC238E] Range Sums

题目描述:有一个长为 \(N\) 的序列,判断根据 \(Q\) 个区间 \([l_i,r_i]\) 的和,是否能确定整个序列的元素和。

我们注意到,当确定了 \([l,r]\) 的和,我们其实已经确定了 \(S_r-S_{l-1}\) 的值。

那么,我们经过若干次传递,如果能从 \(S_N\) 转移到 \(S_0\),那么就是可行的。

这就是一个并查集板子了,代码略。

P5937 [CEOI1999] Parity Game

类似的,我们设 \(S\) 为二进制序列的前缀和。

那么,我们的 \([l,r]\) 信息,也就是知道了 \(S_r-S_{l-1}\) 的奇偶性。

我们用扩展域并查集,

  • 若为偶数,连边 \(\langle l,r\rangle,\langle l+n,r+n\rangle\),表示这两个奇偶性相同。
  • 若为奇数,连边 \(\langle l+n,r\rangle,\langle l,r+n\rangle\),表示奇偶性不同。

如果连边的时候发现,同一组如果出现了另一组的边,那么失效。

提前离散化一下即可。

点击查看代码
#include <bits/stdc++.h>

using namespace std;

#define endl "\n"

struct query_t {
    int l, r;
    bool iseven;
};

struct dsu_t {
    vector<int> fa;
    dsu_t(int n): fa(n) { iota(fa.begin(), fa.end(), 0); }
    int getfa(int x) { return x == fa[x] ? x : fa[x] = getfa(fa[x]); }
    void unite(int l, int r) { fa[getfa(l)] = getfa(r); }
} dsu(1e4 + 10);

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr), cout.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<int> s(m * 2);
    vector<query_t> a(m);
    for (int i = 0; i < m; ++i) {
        int l, r; string op;
        cin >> l >> r >> op;
        --l;
        s[i] = l, s[i + m] = r;
        a[i] = (query_t){l, r, op == "even"};
    }
    sort(s.begin(), s.end());
    s.erase(unique(s.begin(), s.end()), s.end());
    n = s.size();
    #define getid(x) (lower_bound(s.begin(), s.end(), x) - s.begin() + 1)
    for (int i = 0; i < m; ++i) {
        int op = a[i].iseven;
        int l = getid(a[i].l), r = getid(a[i].r);
        // cout << "MERGE " << l << " " << r << " " << op << " WA " << n << endl;
        if (op == 1) {
            if (dsu.getfa(l) == dsu.getfa(r + n) || dsu.getfa(l + n) == dsu.getfa(r))
                cout << i << endl, exit(0);
            dsu.unite(l, r), dsu.unite(l + n, r + n);
        } else {
            if (dsu.getfa(l) == dsu.getfa(r) || dsu.getfa(l + n) == dsu.getfa(r + n))
                cout << i << endl, exit(0);
            dsu.unite(l, r + n), dsu.unite(l + n, r);
        }
    }
    cout << m << endl;
    return 0;
}
posted @ 2024-07-09 22:06  RainPPR  阅读(31)  评论(0编辑  收藏  举报