Analysis of Set Union Algorithms 题解

题意简述

有一个集合,初始为空,你需要写一个数据结构,支持:

  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 树。(谁让它有个名字叫前缀树呢。)

遇到两个结点在之后完全等价,可以使用并查集加速。

posted @ 2024-08-02 07:49  XuYueming  阅读(26)  评论(0编辑  收藏  举报