Loading

二进制分组

二进制分组

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)\)

\[\sum\limits_{i=1}^nO(f(lowbit(i)))\\ \]

我们考虑对于一个 \(i\) 来说,有多少 \(k\) 满足 \(lowbit(k)=i\) ,假设 \(i\) 而今之下一共有 \(q\) 位,那么对于所有的在 \(n\) 以下的数,我们考虑按照这 \(q\) 位进行分类,这 \(q\) 位相同的在一组,不难发现,分类后,这 \(2^q\) 组的个数是近似相同的,有多有少是因为 \(n\) 的限制。所以我们可以大致认为 \(k\) 的个数约在 \(\frac{n}{2^q}\) 这个量级。

所以上面的那个式子近似等于:

\[\sum\limits_{i=0}^{\log n}\frac{n}{2^i}\times f(2^i) \]

那么因为 \(f(n)\ge n\) 所以 \(kf(n)\ge f(kn)\) ,所以我们有:

\[\sum\limits_{i=0}^{\log n}\frac{n}{2^i}\times f(2^i)\le \sum\limits_{i=0}^{\log n} f(n) \]

其中回后面的那个式子,复杂度近似为 \(O(f(n)\log n)\)

这是我自己推的结果,与许昊然的略有不同,差异不大。

那么对于不按二进制分组,显然,进制数越大,查询的复杂度就会越高,插入的复杂度就会越低,因为你分的组变多了。这可能对一些特殊的题目有用。

这样,二进制分组在强制在线的情况下,让一个问题可以用不支持动态的方法解决,并且仅仅在只为时间复杂度增添一个 $\log $ 的前提下。

5 引用

  • 《浅谈一类数据结构题的非经典解法》—— 许昊然。
posted @ 2021-07-09 10:29  hyl天梦  阅读(690)  评论(0编辑  收藏  举报