題意簡述
有一個集合,初始為空,你需要寫一個資料結構,支援:
0 x
表示將 \(x\) 加入該集合,其中 \(x\) 為一由 \(\texttt{0} \sim \texttt{9}\) 組成的數字串,長度 \(\leq 50\)。1 x
表示查詢 \(x\) 是否存在於該集合中,長度總和 \(\leq 8 \times 10^6\)。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 樹。(誰讓它有個名字叫字首樹呢。)
遇到兩個結點在之後完全等價,可以使用並查集加速。