1. 程式人生 > >進階一點的字符串算法

進階一點的字符串算法

就會 sandy and 個性 efi 直接 parent 每一個 src

我還什麽都不會啊

字符串還是很重要的,省選肯定會考的吧

所以還是先寫一下馬拉車吧

$ $

\(Manacher\)

是一個求最長回文子串的算法,復雜度\(O(n)\)

核心原理就是利用回文串的性質

首先還是按照對稱軸來找回文串,為了避免分類討論回文串的奇偶性,所以可以在字符串之間先填充特殊字符

具體做法就是倍長原來的字符串,在第\(0\)位和最後一位填上特殊字符作為邊界,之後原來每一個字符之間也填充上特殊字符

比如\(abaaa\),就會被填充成為\(\#a\#b\#a\#a\#a\#\)

顯然偶數位置會是特殊字符,而奇數位置則是原來的字符

之後是具體的工作原理也很簡單

在過程中我們存一個當前擴展出的回文串中最靠右的在哪裏,用\(R\)

表示,\(mid\)表示當前回文串的對稱軸在哪裏,用\(r[i]\)表示\(i\)這個位置能夠形成的最長的回文半徑是多少,也就是最多能向左右同時擴展多少位

對於一個新擴展的位置\(i\),顯然是要大於\(mid\)的,如果這個時候\(i<=R\)那麽就有一個非常顯然的性質,就是在這個以\(mid\)為軸的回文串裏,和\(i\)對稱的位置\(j\),一定存在\(r[i]>=r[j]\)

非常顯然\(j=mid*2-i\)

技術分享圖片

顯然由於\([R‘,R]\)都是一個回文串,所以會滿足\(S[i+1]=S[j-1]\)\(S[i-1]=S[j+1],S[i+2]=S[j-2],S[i-2]=S[j+2]...\)

,也就是說只要沒超過當前的這個\(R\),都會滿足這樣的性質,但是這樣最多也只能擴展到\(R\)位置

我們可以直接讓\(r[i]=min(r[j],R-i)\),之後往左右兩邊暴力擴展就好了

如果\(r[i]+i>R\)了,我們就更新\(R\)\(mid\),最後所有\(r[i]\)的最大值就是答案了

板子

#include<iostream>
#include<cstring>
#include<cstdio>
#define re register
#define maxn 11000005
#define min(a,b) ((a)<(b)?(a):(b))
#define max(a,b) ((a)>(b)?(a):(b))
char S[maxn<<1];
int r[maxn<<1];
int n,ans;
int main()
{
    scanf("%s",S+1);
    n=strlen(S+1)<<1;
    for(re int i=n-1;i>1;i-=2) S[i]=S[(i>>1)+1];
    for(re int i=n;i;i-=2) S[i]=0;
    int mid=1,R=1;
    for(re int i=1;i<=n;i++)
    {
        if(i<=R) r[i]=min(r[(mid<<1)-i],R-i);
        for(re int j=r[i]+1;j<=i&&j<=n&&S[i+j]==S[i-j];j++) r[i]=j;
        if(i+r[i]>=R) R=i+r[i],mid=i;
        ans=max(ans,r[i]);
    }
    std::cout<<ans;
    return 0;
}

\(SA\)

這個就真的是神仙操作了

我還是只會後綴排序

後綴排序的意思就是給出一個字符串,把它的所有後綴按照字典序來排序

具體後綴排序的思路就不講了,放一個很好的鏈接吧

我就是從這裏學會後綴排序的

之後可能看了這個還是一臉蒙蔽

於是放上加了很多註釋的板子

#include<iostream>
#include<cstring>
#include<cstdio>
#define re register
#define maxn 1000005
#define max(a,b) ((a)>(b)?(a):(b))
#define min(a,b) ((a)<(b)?(a):(b))
#define pt putchar(1)
inline int read()
{
    char c=getchar();int x=0;
    while(c<‘0‘||c>‘9‘) c=getchar();
    while(c>=‘0‘&&c<=‘9‘) x=(x<<3)+(x<<1)+c-48,c=getchar();return x;
}
int rk[maxn],tp[maxn],sa[maxn],tax[maxn];
//sa[i]表示排名為i的後綴
//rk[i]表示後綴i的排名 
int n,m;
char S[maxn];
inline void qsort()
{
    for(re int i=0;i<=m;i++) tax[i]=0;
    for(re int i=1;i<=n;i++) tax[rk[i]]++;//統計第一關鍵字的數量 
    for(re int i=1;i<=m;i++) tax[i]+=tax[i-1];//做一個前綴和,可以快速的確定每一種第一關鍵字所對應的最後的位置是多少 
    for(re int i=n;i;--i) sa[tax[rk[tp[i]]]--]=tp[i];
    //這裏比較復雜了
    //從裏面一步一步的推吧
    //首先我們倒著循環的,也就是按照第二關鍵字從大往小的順序
    //rk[tp[i]當前對應的桶是多少
    //由於我們是倒著循環的,所以肯定是當前桶裏最大的一個,自然要放在桶裏最靠前的位置 
}
int main()
{
    scanf("%s",S+1);
    n=strlen(S+1);
    for(re int i=1;i<=n;i++) 
        rk[i]=S[i]-‘0‘,tp[i]=i;
    m=75;
    qsort();
    for(re int w=1,p=0;p<n;m=p,w<<=1)
    //w表示當前的已經處理好的長度,也就是對於每個後綴來說往後數w位在當前的sa裏是有序的
    //p表示當前一共有多少種不同的第一關鍵字,當p=n的時候就意味著已經排好序了 
    {
        p=0;
        for(re int i=1;i<=w;i++) tp[++p]=n-w+i;
        //這裏是把最後的w位加進來,這些位置的第二關鍵字都是0 
        for(re int i=1;i<=n;i++) if(sa[i]>w) tp[++p]=sa[i]-w;
        //tp[i]表示第二關鍵字排名為i的位置,也就是哪一個位置+w對應的sa是i 
        qsort();
        for(re int i=1;i<=n;i++) std::swap(tp[i],rk[i]);
        //tp已經沒有什麽用了,於是用來存一下上次的rk 
        rk[sa[1]]=p=1;
        for(re int i=2;i<=n;i++) 
            rk[sa[i]]=(tp[sa[i-1]]==tp[sa[i]]&&tp[sa[i-1]+w]==tp[sa[i]+w])?p:++p;
        //更新rk,如果本次排序還沒有區分出來,那麽p不變,否則就有一個新的接下來排序需要的第一關鍵字了 
    }
    for(re int i=1;i<=n;i++) printf("%d ",sa[i]);
    return 0;
}

之後著重說一下\(height\)數組這個神奇的東西

\(height[i]=lcp(sa[i],sa[i-1])\)也就是\(height[i]\)表示排名為\(i\)的後綴和排名為\(i-1\)的後綴的最長公共前綴的長度

\(height\)數組如何快速求出呢,反正我只會背板子

從學長那裏抄來一個證明

首先我們不妨設第i-1個字符串按排名來的前面的那個字符串是第k個字符串,註意k不一定是i-2,因為第k個字符串是按字典序排名來的i-1前面那個,並不是指在原字符串中位置在i-1前面的那個第i-2個字符串。

這時,依據height[]的定義,第k個字符串和第i-1個字符串的公共前綴自然是height[rk[i-1]],現在先討論一下第k+1個字符串和第i個字符串的關系。

第一種情況,第k個字符串和第i-1個字符串的首字符不同,那麽第k+1個字符串的排名既可能在i的前面,也可能在i的後面,但沒有關系,因為height[rk[i-1]]就是0了呀,那麽無論height[rk[i]]是多少都會有height[rk[i]]>=height[rk[i-1]]-1,也就是h[i]>=h[i-1]-1。

第二種情況,第k個字符串和第i-1個字符串的首字符相同,那麽由於第k+1個字符串就是第k個字符串去掉首字符得到的,第i個字符串也是第i-1個字符串去掉首字符得到的,那麽顯然第k+1個字符串要排在第i個字符串前面。同時,第k個字符串和第i-1個字符串的最長公共前綴是height[rk[i-1]],

那麽自然第k+1個字符串和第i個字符串的最長公共前綴就是height[rk[i-1]]-1。

到此為止,第二種情況的證明還沒有完,我們可以試想一下,對於比第i個字符串的排名更靠前的那些字符串,誰和第i個字符串的相似度最高(這裏說的相似度是指最長公共前綴的長度)?顯然是排名緊鄰第i個字符串的那個字符串了呀,即sa[rank[i]-1]。但是我們前面求得,有一個排在i前面的字符串k+1,LCP(rk[i],rk[k+1])=height[rk[i-1]]-1;

又因為height[rk[i]]=LCP(i,i-1)>=LCP(i,k+1)

所以height[rk[i]]>=height[rk[i-1]]-1,也即h[i]>=h[i-1]-1。

那麽這個\(height\)數組有什麽用呢,非常顯然可以用來求兩個後綴的\(lcp\)

對於兩個後綴\(i,j\),約定\(rk[i]<rk[j]\),他們的\(lcp\)長度就是

\[min(height[rk[i]+1]...height[rk[j]])\]

也就是說可以通過\(rmq\)快速求出兩個後綴的\(lcp\)長度

利用這個性質還是有一些題目的

[AHOI2013]差異 題解

[HAOI2016]找相同字符

[NOI2015]品酒大會

[HEOI2016/TJOI2016]字符串

可能還會和一些數據結構套在一起,比如說單調棧,主席樹,\(st\)表之類的

之後是後綴數組的幾個非常經典的應用(都是從論文裏抄的自然非常經典了)

1.出現多次的最長可重疊子串

這個非常好做,顯然答案就是\(height\)數組裏的最大值

2.出現多次的最長不可重疊子串poj1743

這個就比剛才那個多了一個限制,就是\(sa[i]+lcp(i,j)<=sa[j]\)

這裏需要用到一個經典的套路,就是二分之後對\(height\)分組

顯然答案存在單調性,所以我們現在要判斷的是是否存在一個長度為\(mid\)的不重疊子串

我們可以對\(het\)數組分組,使得一個組內部的\(het\)都大於等於\(mid\),之後我們從這個組裏找到最大和最小的\(sa\),如果差超過\(mid\),就代表答案合法了

3.出現\(k\)次的最長可重疊子串

和上面是一個套路,我們還是二分一個答案\(mid\),之後對\(height\)分組,之後我們判斷一個組內部的數量時候大於等於\(k\)就好了

這類的題也有一些

[USACO06DEC]牛奶模式Milk Patterns

[SDOI2008]Sandy的卡片

4.本質不同的子串個數

這個直接寫了題解了

本質不同的子串個數

這樣的題好像也不少啊,但是我就沒做多少

[SDOI2016]生成魔咒

\(SAM\)

我怎麽可能學會這麽神仙的東西

\(SAM\)是一個非常強大的自動機,可以直接匹配一個串的所有子串,就是那種完全不需要動腦子直接放在自動機上跑一跑的那種

一個\(SAM\)由兩個結構組成:一個是一張有向無環圖(\(DAWG\)),之後就是一棵\(parent\)

\(SAM\)的任何一個節點都可以同時在這兩種結構上被找到

但是一個字符串的所有子串數目高達\(n^2\)級別,如何用盡量少的字符使得自動機能匹配所有子串是一個非常優雅的問題

顯然必須得把一些信息壓縮起來處理

\(SAM\)上的一個節點表示的,則是一個或者多個子串,因為從最開始的狀態出發,可以到這個節點的路徑有很多種,每一條路徑都可以表示一個子串

而一個節點所表示的所有子串,都必定是一個某一個前綴的一些後綴,且這些前綴的後綴的長度都相差\(1\)

算了還是決定正兒八經寫一下\(SAM\)這個東西了

先講一下幾個非常重要的概念吧

1.\(endpos\)集合

每個\(DAWG\)上的節點都會對應一個\(endpos\)集合,其含義是這個節點所表示的最長的子串在整個字符串裏出現的結束位置

比如說母串是\(abcbca\),那麽\(endpos(bc)=\{3,5\}\)\(endpos(a)=\{1,5\}\)

在一個\(SAM\)上沒有兩個節點的\(endpos\)集合是相同的,因為\(SAM\)上的節點表示的是一個\(endpos\)等價集合,相等的\(endpos\)會被合並在一起

之後關於\(endpos\)有幾條顯然的性質

  • 對於兩個子串\(u,w\),約定\(length(u)<=length(w)\),那麽他們兩者的\(endpos\)只會有兩種關系

\[endpos(w)\subseteq endpos(u)(u\text{是}w\text{的後綴})\]

\[endpos(w)\cap endpos(u)=\oslash (u\text{不是}w\text{的後綴})\]

  • 考慮一個\(endpos\)等價類。將類中的所有子串按長度非遞增的順序排序。即每個子串都會比它前一個子串短,與此同時每個子串也是它前一個子串的一個後綴。換句話說,同一等價類中的所有子串均互為後綴,且子串的長度恰好在一個區間\([x,y]\)

於是我們定義\(min(v)\)表示\(v\)這個節點所代表的\(endpos\)等價類中的最短長度也就是\([x,y]\)裏的\(x\)\(len(v)\)表示最長長度也就是\(y\)

有什麽用嗎,接下來肯定有用了

2.\(link\)指針(後綴鏈接)

這個是\(parent\)樹上的東西了,或者說\(parent\)根本就是靠著這個搭起來的

先來看看定義吧

  • 對於兩個節點\(u,v\),當且僅當\(min(v)=len(u)+1\)時,會存在\(link(v)=u\)

\(u\)的最大值加一恰好能和\(v\)的最小值連接起來,也就是說明如果\(u\)的子串長度區間是\([x_1,y_1]\)\(v\)\([x_2,y_2]\),那麽兩個就可以連起來了,變成\([x_1,y_2]\),於是通過\(parent\)樹上反復向上跳我們就能把每一個後綴的所有前綴都訪問一遍

換句話說我們從\(v\)\(parent\)樹上跳\(link\)最後得到的區間是\([0,len(v)]\)

看起來還是很雞肋的性質,但是接下來就非常有用了

3.構建一個\(SAM\)

其實也不是特別麻煩

\(SAM\)的構建采取的是增量算法,就是每次都往\(SAM\)裏添加一個字符,添加到最後的時候就會得到整個串的\(SAM\)

考慮我們每次插入會產生那些子串

  1. 一個新的前綴

  2. 這個新的前綴的所有後綴

簡單描述一下算法過程吧

  • 設當前加入的字符為\(c\),建立一個新節點\(p\),這個新節點要繼承上一次加入的節點的狀態,於是\(len(p)=len(last)+1\),這樣的話我們就新成立了一個\(endpos\)等價類,也完成了插入一個新的前綴的工作

  • 之後要插入這個新的前綴的所有後綴,設\(f=last\)\(last=p\),之後往上一直對\(f\)\(link\),如果當前的\(f\)沒有\(c\)這個轉移我們就給它加上,表示我們插入了這個新前綴的一些後綴,跳到\(parent\)樹上的根或者跳到\(f\)\(c\)這個兒子為止

到現在我們要插入的所有新的子串都插入完了,之後就是要給新插入的\(p\)找一個\(link\)

  • 如果跳到了根,那麽\(link(p)=1\)退出就好了

  • 否則的話我們就設\(x\)為當前\(f\)\(c\)轉移,可能我們的\(link(p)\)就是\(x\)了,但是別忘了我們的\(link\)必須得滿足一個條件,就是\(len(x)+1=min(p)\),這個時候\(min(p)\)肯定是\(len(f)+2\)了,這顯然是最小的長度了,所以我們必須要使得\(len(x)=len(f)+1\),如果\(len(x)\)這是時候正好是\(len(f)+1\),我們就直接\(link(p)=x\)之後退出就好了

  • 否則的話情況就非常棘手了,我們得強行使得\(len(x)=len(f)+1\),但又得遵循原來的\(len(x)\),於是我們再新開一個節點\(y\),令\(y\)繼承\(x\)的所有狀態,但是\(len(y)=len(f)+1\),這個時候\(link(p)=y\)就可以啦

  • 這還沒完,我們還得一路把\(f\)改上去,如果到\(c\)的轉移是\(x\)我們就把它更新成\(y\)

之後終於沒了

把自己的板子放上去吧

    inline void ins(int c)
    {
        int f=lst,p=++cnt; lst=p;
        len[p]=len[f]+1,sz[p]=1;
        while(f&&!son[f][c]) son[f][c]=p,f=link[f];
        if(!f){link[p]=1;return;}
        int x=son[f][c];
        if(len[x]==len[f]+1) {link[p]=x;return;}
        int y=++cnt;
        len[y]=len[f]+1,link[y]=link[x],link[p]=link[x]=y;
        for(re int i=0;i<26;i++) son[y][i]=son[x][i];
        while(f&&son[f][c]==x) son[f][c]=y,f=link[f];
    }

進階一點的字符串算法