Analysis of Set Union Algorithms 题解
题意简述
有一个集合,初始为空,你需要写一个数据结构,支持:
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 树。(谁让它有个名字叫前缀树呢。)
遇到两个结点在之后完全等价,可以使用并查集加速。
本文作者:XuYueming,转载请注明原文链接:https://www.cnblogs.com/XuYueming/p/18334560。
若未作特殊说明,本作品采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。