資料結構——並查集 學習筆記

RainPPR發表於2024-07-09

資料結構——並查集 學習筆記

並查集是一種用於管理元素所屬集合的資料結構,實現為一個森林。

並查集中,每棵樹表示一個集合,樹中的節點表示對應集合中的元素。

其思想是,把集合屬性繫結到根節點上,避免多餘的處理,因此一般難以分離。

普通並查集

並查集支援兩種操作:

  • 合併(Union):合併兩個元素所屬集合(合併對應的樹);
  • 查詢(Find):查詢某個元素所屬集合(查詢對應的樹的根節點)。
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 : getfa(fa[x]); }
    void unite(int x, int y) { fa[getfa(x)] = getfa(y); }
};

路徑壓縮

一個不通用的最佳化,我們把任意一個非根節點直接合併到它的根上。

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); }
};

非常好寫,但是對於可撤銷等就無法壓縮了。

啟發式合併和按秩合併

合併時,選擇哪棵樹的根節點作為新樹的根節點會很大程度上影響複雜度。

一般來說,我們可以將節點較少或深度較小的樹連到另一棵,以免發生退化。

  • 其中,按照節點個數合併,稱為啟發式合併(維護樹的大小)。
  • 而按照深度(稱為秩)合併的,稱為按秩合併(維護樹的高度)。

一定程度上,啟發式合併會被卡,但是按秩合併會比較難寫。

程式碼略。

複雜度分析

如果只使用路徑壓縮或啟發式合併,時間複雜度是單次 \(\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;
}

相關文章