二进制分组
二进制分组
1 简介
二进制分组是一类在线算法,其最大的功能是以一个 $\log $ 的代价,让一个需要支持动态修改的问题变成不需要支持动态修改的问题。
2 算法概述
做法是对修改序列分组,二进制位一组,在不断加入修改的过程中,不断维护这个二进制序列。对于一个询问,扫一下每一个二进制序列,用对于每一个组用数据结构进行维护即可。
对于维护二进制序列,其实暴力合并就可以。
至于合并的次数,一是我们可以维护每一个分组的数量,如果两个数量一样,就需要合并。二是我们可以观察到,对于第 \(k\) 次操作,合并次数实际上是 \(lowbit(k)-1\) 。
下面展示一组图片,描述我们如何添加一个操作。
3 例题
很明显这像是一个 AC 自动机可以完成的题目,但是这个题要求强制在线,且需要删除,这个东西显然无法动态修改来完成,我们考虑二进制分组。
当我们需要合并的时候,我们暴力合并两颗 Trie
树,然后构造 fail
指针,再进行回答询问。
因为笔者并没有联系很多 AC 自动机的题目,所以打的时候代码并不是非常美观,这里的代码在树上的 dp 统计十分巧妙,且以前一直没有察觉到的一点是 AC 自动机构造 fail
指针的正确性依靠于这张 Trie
图,所以我们要注意对根节点的处理。
以及这个题需要维护两个:一个是 Trie
图,另一个是 Trie
树。
关于 Trie
图到 Trie
树的赋值,我们直接在求 \(fail\) 的时候赋值就可以。
代码:
#include<bits/stdc++.h>
#define dd double
#define ld long double
#define ll long long
#define uint unsigned int
#define ull unsigned long long
#define N 600100
#define M number
using namespace std;
const int INF=0x3f3f3f3f;
template<typename T> inline void read(T &x) {
x=0; int f=1;
char c=getchar();
for(;!isdigit(c);c=getchar()) if(c == '-') f=-f;
for(;isdigit(c);c=getchar()) x=x*10+c-'0';
x*=f;
}
struct AC_zidongji{
int son[N][26],end[N],ch[N][26],fail[N],cnt[N],tot;
int rt[N],size[N],top;queue<int> q;
inline void build_fail(int root){
for(int i=0;i<26;i++)
if(son[root][i]) fail[ch[root][i]=son[root][i]]=root,q.push(ch[root][i]);
else ch[root][i]=root;
while(q.size()){
int top=q.front();q.pop();
for(int i=0;i<26;i++){
if(son[top][i]){
ch[top][i]=son[top][i];fail[ch[top][i]]=ch[fail[top]][i];
q.push(ch[top][i]);
}
else ch[top][i]=ch[fail[top]][i];
}
cnt[top]=end[top]+cnt[fail[top]];
}
}
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 n){
rt[++top]=++tot;size[top]=1;int now=rt[top];
for(int i=1;i<=n;i++){
if(!son[now][s[i]-'a']) son[now][s[i]-'a']=++tot;
now=son[now][s[i]-'a'];
}
end[now]=1;
while(size[top]==size[top-1]){
--top;
rt[top]=merge(rt[top],rt[top+1]);size[top]+=size[top+1];
}
build_fail(rt[top]);
}
inline int query(char *s,int n){
int res=0;
for(int i=1;i<=top;i++)
for(int j=1,now=rt[i];j<=n;j++)
now=ch[now][s[j]-'a'],res+=cnt[now];
return res;
}
}t1,t2;
char s[N];
int main(){
int m;read(m);
for(int i=1;i<=m;i++){
int op;read(op);scanf("%s",s+1);
int n=strlen(s+1);
if(op==1) t1.insert(s,n);
if(op==2) t2.insert(s,n);
if(op==3) printf("%d\n",t1.query(s,n)-t2.query(s,n)),fflush(stdout);
}
return 0;
}
任何算法都是运用加深理解。
4 时间复杂度分析
为什么这样做是对的,为什么需要二进制分组,其他进制分组行吗?我们接下来来探讨这个问题。
我们设对于数据规模为 \(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 $ 的前提下。
5 引用
- 《浅谈一类数据结构题的非经典解法》—— 许昊然。