[算法相关]二进制分组
#1.0 简单介绍
#1.1 概述
有这么一类数据结构维护的问题:所维护的修改操作满足独立性,但要求强制在线。这时候,我们我可以采用二进制分组这一策略以仅仅 \(O(\log n)\) 的代价去掉原问题的动态修改操作,将其转变为不带修的简化问题,并满足原问题的在线算法要求。[2]
#1.2 算法思想
算法的思想是这样的:将当前所有的修改操作按二进制进行分组,在每一组内分别用数据结构维护,每增添一个修改操作就暴力重新分组。
上面的叙述或许有些笼统,举个栗子吧,我们假设当前有 \(21\) 个修改操作,那么我们就将操作分为 \(21=(10101)_2=16+4+1\) 三组,如果此时又加入了一个修改操作,变为 \(22\) 个,那么就变为 \(22=(10110)_2=16+4+2\) 三组,我们将原本的第三组中的数据结构与维护新加入的修改的数据结构进行暴力合并即可,那么在查询的时候,我们只需要统计当前的所有组中的数据结构维护的信息即可。
不难发现,对于一个有 \(n\) 个操作的序列,在加入第 \(k\) 个操作后,合并的最大次数为在二进制表示下 \(k-1\) 变到 \(k\) 时需要有几次进位,也就是最多有 \(\texttt{lowbit}(k)\) 次合并,也就是说如果尾部的两个组的大小相同,那么我们就可以将他们合并,很明显,这样合并出来的组的大小必然是 \(2\) 的整数次幂(考虑考虑 \(2048\) 这个游戏就知道了),正好符合我们的二进制分组。
#1.3 时间复杂度
为什么这样做是对的,为什么需要二进制分组,其他进制分组行吗?我们接下来来探讨这个问题。
我们设对于数据规模为 \(n\) ,设合并复杂度 \(f(n)\) ,询问复杂度 \(g(n)\) 。对于每一个询问,二进制下每一个分组元素规模都小于 \(n\) ,且一共有 \(\log n\) 各分组,所以时间复杂度不会超过 \(O(g(n)\log n)\) 。
对于加入一个修改操作,比如第 \(k\) 个操作,容易发现,我们总共会将 \(lowbit(k)\) 的数据大小的数据合并。
所以所有修改操作的总复杂度是 \(\sum_{i=1}^nO(f(lowbit(i)))\)
我们尝试对这个式子进行化简,一般认为处理一次合并的复杂度大于等于数据规模,即 \(O(f(n))\ge O(n)\) :
我们考虑对于一个 \(i\) 来说,有多少 \(k\) 满足 \(lowbit(k)=i\) ,假设 \(i\) 而今之下一共有 \(q\) 位,那么对于所有的在 \(n\) 以下的数,我们考虑按照这 \(q\) 位进行分类,这 \(q\) 位相同的在一组,不难发现,分类后,这 \(2^q\) 组的个数是近似相同的,有多有少是因为 \(n\) 的限制。所以我们可以大致认为 \(k\) 的个数约在 \(\frac{n}{2^q}\) 这个量级。
所以上面的那个式子近似等于:
那么因为 \(f(n)\ge n\) 所以 \(kf(n)\ge f(kn)\) ,所以我们有:
其中回后面的那个式子,复杂度近似为 \(O(f(n)\log n)\)
这是我自己推的结果,与许昊然的略有不同,差异不大。
那么对于不按二进制分组,显然,进制数越大,查询的复杂度就会越高,插入的复杂度就会越低,因为你分的组变多了。这可能对一些特殊的题目有用。
这样,二进制分组在强制在线的情况下,让一个问题可以用不支持动态的方法解决,并且仅仅在只为时间复杂度增添一个 $\log $ 的前提下。
#2.0 例题
这样的离线算法的思想只有结合例题才能更好的理解。
#2.1 CF710F String Set Queries
题目链接 -Luogu -CodeForces
#2.1.1 题目大意
维护一个字符串集合,支持三种操作:
- 加字符串
- 删字符串
- 查询集合中的所有字符串在给出的模板串中出现的次数
操作数 \(m \leq 3 \times 10^5\),输入字符串总长度 \(\sum |s_i| \leq 3\times 10^5\)。
本题强制在线,应该在每次输出后调用 fflush(stdout)
。你只有在输出上一个询问的答案后才能读入下一组询问。
#2.1.2 解决办法
先来考虑本题如果只有一次查询该如何做,重点是如何处理 “删除” 这一操作,我们可以每次将需要加入的字符串加入 \(\tt Trie\) 树 \(t_1\),将要删除的字符串加入 \(\tt Trie\) 树 \(t_2\),那么在最后一次查询时,一次以两棵 \(\tt Trie\) 树,分别建出 \(\tt AC\) 自动机, 可以先查询 \(t_1\) 中的串在 \(s\) 中出现多少次,再查询 \(t_2\) 中的串再 \(s\) 中出现多少次,那么两次查询得到的结果之差即为答案。
再来看有多次查询的本题,本题统计答案的思路与简化版相同,那为什么不能直接将上面的操作进行多次呢?答案很简单,我们每次修改后 \(\tt Trie\) 的形态都发生了改变,所以在下一次查询之前,我们需要重建 \(\tt AC\) 自动机!而 \(\tt AC\) 自动机的建立是 \(O(\sum|s_i|)\) 的,那么时间复杂度就变为了 \(O(m\sum|s_i|)\) 了,不能接受。
那怎么办?发现题目中要询问的信息是满足独立性、可加性的,也就是说,如果我们将原本的字符串分开来,建成多个 \(\tt AC\) 自动机,在这几个 \(\tt AC\) 自动机上查询到的结果之和仍然是我们想要的答案。所以这样的情况我们可以考虑二进制分组,我们最多把长度为 \(n\) 的序列分为 \(\log n\) 组,每个组里维护一棵 \(\tt Trie\) 树和一个 \(\tt AC\) 自动机,每次插入一个新的字符串时,便将可以合并的组合并(注意是合并 \(\tt Trie\) 树),之后在合成的新组上建立相应的 \(\tt AC\) 自动机。上面证明过了,合并的时间复杂度近似 \(O(f(n)\log n)\),这里的 \(f(n)\) 是将所有字符串合并并构建 \(\tt AC\) 自动机的时间复杂度,即 \(O(\sum|s_i|)\),于是修改操作的整体时间复杂度为 \(O(\sum|s_i|\log n).\)
至于查询的时间复杂度为 \(O(|t|\log n)\),其中 \(t\) 是询问的文本串。
再来考虑统计答案的具体方案:因为我们只统计一共出现了多少次,我们可以在 \(\tt Trie\) 树上做类似前缀和的操作,也就是直接令节点 \(x\) 的计数为在 \(x\) 结束的字符串数与 \(x\) 的 \(\tt fail\) 指针指向的节点的计数的和,这样一来,在查询时只需要顺着 \(\tt Trie\) 树(\(\tt AC\) 自动机)向下走就好了,沿途统计答案。
const int N = 1000010;
const int INF = 0x3fffffff;
struct Trie {
int ch[26];
int fail;
};
struct ACAM {
Trie t[N]; int son[N][26], tot;
int rt[N], last, siz[N], cnt[N], end[N];
inline void build(int root) {
queue <int> q;
for (int i = 0; i < 26; ++ i)
if (son[root][i]) {
t[t[root].ch[i] = son[root][i]].fail = root;
q.push(t[root].ch[i]);
} else t[root].ch[i] = root;
while (q.size()) {
int now = q.front(); q.pop();
for (int i = 0; i < 26; ++ i)
if (son[now][i]) {
t[now].ch[i] = son[now][i];
t[t[now].ch[i]].fail = t[t[now].fail].ch[i];
q.push(t[now].ch[i]);
} else t[now].ch[i] = t[t[now].fail].ch[i];
cnt[now] = end[now] + cnt[t[now].fail];
}
}
inline int merge(int a, int b) {
if (!a || !b) return a + b;
end[a] += end[b];
for (int i = 0; i < 26; ++ i)
son[a][i] = merge(son[a][i], son[b][i]);
return a;
}
inline void insert(char *s) {
int len = strlen(s); rt[++ last] = ++ tot;
int now = rt[last]; siz[last] = 1;
for (int i = 0; i < len; ++ i) {
int k = s[i] - 'a';
if (!son[now][k])
son[now][k] = ++ tot;
now = son[now][k];
} end[now] = 1;
while (siz[last] == siz[last - 1]) {
rt[-- last] = merge(rt[last], rt[last + 1]);
siz[last] += siz[last + 1];
}
build(rt[last]);
}
inline int query(char *s) {
int res = 0, len = strlen(s);
for (int i = 1; i <= last; i ++)
for (int j = 0, now = rt[i]; j < len; j ++)
now = t[now].ch[s[j] - 'a'], res += cnt[now];
return res;
}
};
ACAM ac1, ac2;
char s[N];
int main() {
int m; scanf("%d", &m);
for (int i = 1; i <= m; ++ i) {
int op; scanf("%d", &op);
scanf("%s", s);
if (op == 1) ac1.insert(s);
else if (op == 2) ac2.insert(s);
else {
printf("%d\n", ac1.query(s) - ac2.query(s));
fflush(stdout);
}
}
return 0;
}
参考资料
[1] 二进制分组 - hyl天梦
[2] 许昊然. 浅谈数据结构题的几个非经典解法.