Analysis of Set Union Algorithms 題解

XuYueming發表於2024-08-02

題意簡述

有一個集合,初始為空,你需要寫一個資料結構,支援:

  1. 0 x 表示將 \(x\) 加入該集合,其中 \(x\) 為一由 \(\texttt{0} \sim \texttt{9}\) 組成的數字串,長度 \(\leq 50\)
  2. 1 x 表示查詢 \(x\) 是否存在於該集合中,長度總和 \(\leq 8 \times 10^6\)
  3. 2 x y 表示令數字串 \(x\)\(y\) 糾纏。不保證 \(x\)\(y\) 在集合中。如果 \(\texttt{A}\)\(\texttt{B}\) 糾纏,並且 \(\texttt{BT}\) 在集合中,則認為 \(\texttt{AT}\) 也在集合中。

注意集合中可能有無窮多個數字串。

題目分析

發現,詢問時,字串總是透過不斷透過“糾纏”改變字首,最後來到一個已經插入的字串。所以,所謂“糾纏”,就是令兩個字首相等的過程。分析到這,如果沒有“糾纏”操作,做法是什麼?別跟我說字串雜湊。對,看到字首和字串匹配,用個 Trie 樹匹配就行了。可是有“糾纏”,怎麼解決呢?

一個很直接的想法是,分別在字典樹上找到這兩個字首對應的結點,然後在結點之間連一條邊。在匹配時可以往下匹配,也可以在額外這些邊上走。輕鬆寫出如下(賽時)程式碼:

unordered_map<int, bool> vis[805010];

bool dfs(int now, char str[], int cur) {
	if (vis[cur][now]) return false;
	vis[cur][now] = true;
	for (auto to: tree[now].trans) {
		if (dfs(to, str, cur)) return true;
	}
	if (!str[cur]) {
		if (tree[now].ed) return true;
		return false;
	}
	if (tree[now].son[str[cur] ^ 48]) {
		if (dfs(tree[now].son[str[cur] ^ 48], str, cur + 1)) {
			return true;
		}
	}
	return false;
}

先拋開時間空間不談,這個演算法還是錯的,(沒拿到一點點分),為什麼?

考慮如下資料:

4
2 0 2
2 02 5
0 22
1 5

顯然,匹配的過程可以被表示為:\(\texttt{5} \rightarrow \texttt{02} \rightarrow \texttt{22}\)。但是上述程式碼給出了無解。原因就是我們無法更改已經匹配好的字首的字首。

這是一個棘手的問題,但也讓我們意識到,字典樹上,兩個字首相等,其後代也是等價的。難道我們還要在後代上連邊?!當然不是。因為你可能還要不斷插入,非常難解決。

不妨換個角度思考,兩個字首相等,以後就不會改變了,不妨把這兩個結點以及子樹合併起來。也即,後續訪問到任意一個點的時候,在合併之後的版本上面操作,這樣就可以影響所有合法的節點了。合併起來,做一個對映關係,想到並查集。

這就是並查集合並集合的優勢了。當結點之間完全沒有區別,與其插入的時候分別插入或查詢的時候都掃一遍,不如將其合併起來,後續查詢、修改,都在合併出來的節點上進行。

時間複雜度分析

插入查詢和並查集都很好分析。合併的時候,每個節點只會被合併一次,並且合併複雜度就是這些結點個數,總和是字典樹結點個數,所以是正確的。

程式碼

#include <cstdio>
#include <iostream>
using namespace std;

const int N = 1000010;
const int L = 8000010;

struct node {
    int son[10];
    bool ed;
} tree[N];
int fa[N], tot;

inline int newNode() { return ++tot, fa[tot] = tot; }

int get(int x) { return fa[x] == x ? x : fa[x] = get(fa[x]); }

int insert(char str[], bool real) {  // 線上段樹上找到 str 對應的結點 
    int now = 0;
    for (int i = 1; str[i]; ++i) {
        int &son = tree[now].son[str[i] ^ 48];
        if (!son)
            son = newNode();
        now = son = get(son);  // 注意這裡訪問是合併之後的版本 
    }
    tree[now].ed |= real;
    return now;
}

bool query(char str[]) {  // 查詢也是普通地查詢 
    int now = 0;
    for (int i = 1; str[i]; ++i) {
        int &son = tree[now].son[str[i] ^ 48];
        if (!son)
            return false;
        now = son = get(son);  // 同理 
    }
    return tree[now].ed;
}

int combine(int u, int v) {
    if (!u || !v || u == v)  // 合併過、或者有一個不存在了就返回 
        return u | v;
    fa[u] = v;
    for (int i = 0; i < 10; ++i) {
        int &su = tree[u].son[i];
        int &sv = tree[v].son[i];
        su = get(su), sv = get(sv);
        sv = combine(su, sv), v = get(v);  // 注意,可能會影響到 v,所以注意時刻操作合併後的結點 
    }
    tree[v].ed |= tree[u].ed;
    return v;
}

int n;

signed main() {
#ifndef XuYueming
    freopen("tarjan.in", "r", stdin);
    freopen("tarjan.out", "w", stdout);
#endif
    scanf("%d", &n);
    for (int i = 1, op; i <= n; ++i) {
        static char str[L];
        scanf("%d%s", &op, str + 1);
        if (op == 0) {
            insert(str, true);
        } else if (op == 1) {
            puts(query(str) ? "1" : "0");
        } else {
            int x = insert(str, false);
            scanf("%s", str + 1);
            int y = insert(str, false);
            combine(x, y);
        }
    }
    return 0;
}

後記 & 反思

看到字首和匹配,想到 Trie 樹。(誰讓它有個名字叫字首樹呢。)

遇到兩個結點在之後完全等價,可以使用並查集加速。

相關文章