Hopcraft 算法——DP 套 DP 的无用利器

· · 个人记录

前言

论文下载链接

《浅谈有限状态自动机及其应用》徐哲安

链接2

建议自己放到 markdown 看文字。

带你快速入门。

Part.1 定义

确定性有限状态自动机(DFA):是一个五元组 (Q,\sum,\delta,q_0,F)

语言:是字符集上 \sum 组成的任意串的集合。对于单个集合构成的语言 M(即一个串),我们称自动机接受 M 为:(简记为 L(M)

正则语言:即存在一个自动机,使得 L(M)=1

我一般简记正则表达式等于正则语言。

Part.2 DFA 的最小化

一般的自动机只存在一个接受状态,但我们为了方便应用,扩展这这个自动机存在多个接受状态,且同一个接受状态内部不区分,不同接受状态和不能被接受的状态相互区分。

(下文称节点拥有的接受状态的类别或者不能被接受直接称为状态)

如果我只关心这个问题:

由于这些字符串有很多,那么我希望缩减 DFA 的规模来做到快速处理。

具体的,若两个节点 a,b,他们接受任何一个串所到达的节点的状态相同,显然我们不必区分这两个点。那么我把状态 Q 分成若干个等价类,每个等价类内部满足他们接受任何一个串到达的节点状态都相同。他拥有以下性质:

那么我们考虑构造一个新 DFA,满足有这些等价类作为节点,边按照原来 DFA 的相对关系连边。显然这两个 DFA 是等价的。

显然新的 DFA 是一个最小 DFA,且是唯一的最小 DFA(证明见论文)。

考虑使用 Hopcraft 算法来求出一个 DFA 的最小 DFA。大体思想如下:

考虑优化上述过程,我们维护一个集合 W 表示还没有考虑过的等价类的集合。

唯一的问题是为什么不需要把 AB 全部放进 W 里:假设 Y 已经被考虑过,那么假设一个集合 X 只有集合 B 才能分裂他。那么在考虑集合 Y 时,这个集合 X 一定被算过了,因此 X 要么与 Y 没有连边,要么全部与 Y 有连边;又因为 B 能分裂集合 X,因此一定存在一条边在 Y 里但不在 B 里,那么他一定在 A 里,因为 X 的边的其中一部分在 B 里,另外全部都在 A 里,因此取 A 也能使 X 达成相同的效果。

再考虑一下时间复杂度:

因此复杂度为 O(n|\sum|\log n ),其中 n 为 DFA 的大小。

代码:

int bel[N],nxt[N][11],ch[N][11],sz[N];
//bel[0] ->     1 起始状态 
//bel[i] -> 2 ~ 11 接受状态
//siz 当前 eq 的大小(因为会延迟更新 eq) 
map<i128,int>mp;
vector<int>pre[N][10],eq[N],ele[N];//反边 ; 等价类 ; 当前被区分的等价类 
void rebuild(int x){vector<int>tmp;for(auto y:eq[x])if(bel[y]==x)tmp.push_back(y);eq[x].swap(tmp);}
//考虑延迟删除 eq[x] 内不符合的点,只动态更新当前 eq 的大小 
bool check(int x){int t=0;for(auto y:eq[x])if(bel[y]==x)t++;return t==sz[x];}
void Hopcraft()
{
    queue<int>q;cnt=11;
    for(int i=1;i<=tot;i++)eq[bel[i]].push_back(i),sz[bel[i]]++;
    for(int i=1;i<=tot;i++)for(int c=0;c<=9;c++)pre[ch[i][c]][c].push_back(i);
    for(int i=1;i<=11;i++)q.push(i);while(!q.empty())
    {
        int x=q.front();q.pop();
        rebuild(x);for(int c=0;c<10;c++)//按边考虑 
        {
            vector<int>p;for(auto y:eq[x])for(auto z:pre[y][c])//找出每一个可能被 x 划分的等价类 z
            {
                if(ele[bel[z]].empty())p.push_back(bel[z]);
                ele[bel[z]].push_back(z);
            }
            for(auto y:p)if(ele[y].size()<sz[y])//能被划分
            {
                cnt++;for(auto z:ele[y])eq[cnt].push_back(z),bel[z]=cnt;
                sz[cnt]=ele[y].size();sz[y]-=sz[cnt];
                if(sz[cnt]*2>sz[y])//保证较大的在队列前,较小的在队列后 
                //那么如果此时 y 被遍历过,加入正好的是较小的数
                //否则正好遍历两个集合 
                {
                    rebuild(y);for(auto z:eq[y])bel[z]=cnt;for(auto z:eq[cnt])bel[z]=y;
                    eq[y].swap(eq[cnt]);swap(sz[cnt],sz[y]);
                    //暴力遍历 y 是没有关系的,此时保证 \sum y <= x*2,可以看做遍历 x 的复杂度乘上常数 
                }
                q.push(cnt);
            }
            for(auto y:p)ele[y].clear();
        }
    }
    for(int i=1;i<=cnt;i++)rebuild(i);
}

Part.3 例题

一般题目中是没有给出接受状态的,那么我们可以考虑把所有合法状态分成几个接受状态类。如 CF924F。此时的判断依据是在自动机终止状态时是否存在一个差小于等于 k。那么此时我们只关心到达的等价类的最小的差是多少。那么依次划分成 10 个等价类跑 DFA 最小化即可。

代码:

#include<bits/stdc++.h>
#define int long long 
#define i128 __int128
using namespace std;
const int N=2e4+100,M=800;
int tot=0,cnt=0;
i128 vt[N],U;
int bel[N],nxt[N][11],ch[N][11],sz[N];
//bel[0] ->     1 起始状态 
//bel[i] -> 2 ~ 11 接受状态
//bel[i] -> 12 其他状态 
//siz 当前 eq 的大小(因为会延迟更新 eq) 
map<i128,int>mp;
vector<int>pre[N][10],eq[N],ele[N];//反边 ; 等价类 ; 当前被区分的等价类 
void rebuild(int x){vector<int>tmp;for(auto y:eq[x])if(bel[y]==x)tmp.push_back(y);eq[x].swap(tmp);}
bool check(int x){int t=0;for(auto y:eq[x])if(bel[y]==x)t++;return t==sz[x];}
void Hopcraft()
{
    queue<int>q;cnt=11;
    for(int i=1;i<=tot;i++)eq[bel[i]].push_back(i),sz[bel[i]]++;
    for(int i=1;i<=tot;i++)for(int c=0;c<=9;c++)pre[ch[i][c]][c].push_back(i);
    for(int i=1;i<=11;i++)q.push(i);while(!q.empty())
    {
        int x=q.front();q.pop();
        rebuild(x);for(int c=0;c<10;c++)//按边考虑 
        {
            vector<int>p;for(auto y:eq[x])for(auto z:pre[y][c])//找出每一个可能被 x 划分的等价类 z
            {
                if(ele[bel[z]].empty())p.push_back(bel[z]);
                ele[bel[z]].push_back(z);
            }
            for(auto y:p)if(ele[y].size()<sz[y])//能被划分
            {
                cnt++;for(auto z:ele[y])eq[cnt].push_back(z),bel[z]=cnt;
                sz[cnt]=ele[y].size();sz[y]-=sz[cnt];
                if(sz[cnt]*2>sz[y])//保证较大的在队列前,较小的在队列后 
                {
                    rebuild(y);for(auto z:eq[y])bel[z]=cnt;for(auto z:eq[cnt])bel[z]=y;
                    eq[y].swap(eq[cnt]);swap(sz[cnt],sz[y]);
                }
                q.push(cnt);
            }
            for(auto y:p)ele[y].clear();
        }
    }
    for(int i=1;i<=cnt;i++)rebuild(i);
    for(int i=1;i<=cnt;i++)for(int j=0;j<=9;j++)nxt[i][j]=bel[ch[eq[i][0]][j]];
    for(int i=1;i<=cnt;i++)for(int j=0;j<=9;j++)ch[i][j]=nxt[i][j];
//  swap(nxt,ch);
}
i128 getnxt(i128 S,int c){auto T=((S>>c)|(S<<c));for(int i=0;i<=c;i++)if(S&(1<<i))T|=(1<<(c-i));T&=U;return T;}
int f[20][10][M];//f[i][j][S] 表示剩 i 位没有填,要求最终值 <=j,当前在状态 M 的方案数 
int solve(int r,int k)
{
    vector<int>vt;while(r)vt.push_back(r%10),r/=10;
    int res=0,st=1;for(int i=vt.size()-1;i>=0;i--)
    {
        for(int j=0;j<vt[i];j++)res+=f[i][k][ch[st][j]];
        st=ch[st][vt[i]];
    }
    return res+f[0][k][st];
}
signed main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    vt[mp[1]=tot=1]=1;U=(__int128)1<<73;U--;
    for(int i=1;i<=tot;i++)
    {
        for(int c=0;c<=9;c++){i128 g=getnxt(vt[i],c);if(!mp[g])vt[mp[g]=++tot]=g;ch[i][c]=mp[g];}
        for(int p=0;p<=9;p++)if(vt[i]&(1<<p)){bel[i]=p+2;break;}
    }bel[1]=1;
    Hopcraft();
//  cout<<tot<<endl;
    for(int s=1;s<=cnt;s++)
    {
        int p;for(p=0;p<=9;p++)if(vt[eq[s][0]]&(1<<p))break;
        for(int j=p;j<=9;j++)f[0][j][s]=1;
    }
    for(int i=1;i<20;i++)for(int j=0;j<=9;j++)for(int s=1;s<=cnt;s++)for(int c=0;c<=9;c++)
        f[i][j][s]+=f[i-1][j][ch[s][c]];
    int _;cin>>_;while(_--)
    {
        int l,r,k;cin>>l>>r>>k;
        cout<<solve(r,k)-solve(l-1,k)<<endl;
    }
    return 0;
}

Part.4

一般正解的 DFA 状态数都有保证。除非你是打暴力。所以对于 FSZ,WMH 来说就没有多大用。