字符串Hash學習筆記


以下默認字符串下標從1開始,用 \(s[l,r]\) 表示字符串 \(s\) 的第 \(l\) 到第 \(r\) 個字符組成的子串,記字符串 \(s\) 的長度為 \(len(s)\)

概述

字符串 \(\text{Hash}\) 常用於各種字符串題目的部分分中。

字符串 \(\text{Hash}\) 可以在 \(O(1)\) 時間內完成判斷兩個字符串的子串是否相同。通常可以用這個性質來優化暴力以達到騙分的目的。

由於單純的字符串 \(\text{Hash}\) 已經快成為普及算法了,所以其實現原理不是本文重點。本文重點講述的是字符串 \(\text{Hash}\) 在 OI 中的常見應用(及各種騙分技巧)

假如你沒有聽說過字符串 \(\text{Hash}\) ,你可以先學習2年前的洛谷日報哈希基礎知識或熟讀第一章節。

假如你已經知道了字符串 \(\text{Hash}\) 的基本操作,你可以直接跳過第一章節。

假如你已經精通各種 \(\text{Hash}\) 操作並且隨便秒掉字符串的題目,這篇文章大概對您沒任何幫助,點個贊再走吧。

一、什么字符串 \(\text{Hash}\)

字符串 \(\text{Hash}\) 其實本質上是一個公式:

\[\text{Hash}(s)=(\sum_{i=1}^{len(s)}{s[i]\cdot b^{len-i}})\bmod m \]

其中 \(b,m\) 是常量。

由於 \(s[i]\) 通常是一個字符,你可以直接把ASCII碼代入,或者如果是小寫字母代入 \(s[i]-'a'\),數字代入 \(s[i]-'0'\) 等等。

比如如果令 \(b=7,m=100,s="114514"\)(此處代入數值,即\(s[1]=1,s[2]=1,s[3]=4,\cdots\))。

那么 \(\text{Hash}(s)=36\),即\((4+1\times7+5\times7^2+4\times7^3+7^4+7^5)\%100\)

可以發現,我們本質上是將 \(s\) 看成一個 \(b\) 進制數(比如上述例子就是把 \(s\) 看成7進制下的114514)然后 \(\bmod \ p\)

那為什么要采用這樣一個 \(b\) 進制數的形式來處理 \(\text{Hash}\) 呢?

參照哈希表,我們知道如果兩個字符串的 \(\text{Hash}\) 值相同,那么這兩個串大概率是相同的。

但事實上我們常常需要截取一個字符串的子串。可以發現,對於\(s[l,r]\)這個子串的 \(\text{Hash}\)

\[\text{Hash}(s[l,r])=(\sum_{i=l}^{r}{s[i]\cdot b^{r-i}})\bmod m \]

考慮原串\(s\)的前綴和

\[\text{Hash}(s[1,r])=(\sum_{i=1}^{r}{s[i]\cdot b^{r-i}})\bmod m \]

\[\text{Hash}(s[1,l-1])=(\sum_{i=1}^{l-1}{s[i]\cdot b^{l-i-1}})\bmod m \]

於是可以推出:

\[\text{Hash}(s[l,r])\equiv\text{Hash}(s[1,r])-\text{Hash}(s[1,l-1])\cdot b^{r-l+1}\pmod m \]

\[\text{Hash}(s[l,r])\leftarrow(\text{Hash}(s[1,r])-\text{Hash}(s[1,l-1])\cdot b^{r-l+1}\bmod m\ +m)\bmod m \]

於是對於原串記錄\(\text{Hash}\)前綴和,就可以完成 \(O(n)\) 預處理 \(O(1)\) 截取子串 \(\text{Hash}\) 值的優秀時間復雜度。

由於C++的 \(\text{unsigned long long}\) 自帶 \(2^{64}\) 的模數和極小的常數,所以一般的情況下,\(\text{Hash}\)運算通常會采用 \(\text{unsigned long long}\),這種寫法被稱為自然溢出。接下來的代碼中默認開頭添加:

#define ull unsigned long long 

好了,你已經學會了字符串 \(\text{Hash}\)所有技巧讓我們來試試吧(霧

二、字符串 \(\text{Hash}\) 的用處

字符串 \(\text{Hash}\) 是一種十分暴力的算法。但由於它能 \(O(1)\) 判斷字符串是否相同,所以可以騙取不少分甚至過掉一些字符串題。

接下來先介紹字符串 \(\text{Hash}\) 的一些騙分技巧。

1.字符串匹配(KMP)

這個其實不能說是騙分,畢竟枚舉起始點掃一遍 \(O(n)\) 解決,時間復雜度和KMP相同(甚至比KMP短)。

代碼略。

2.回文串

考慮以同一個字符為中心的回文串的子串一定是回文串,所以滿足可二分性。

將字符串正着和倒着 \(\text{Hash}\) 一遍,如果一個串正着和倒着的 \(\text{Hash}\) 值相等則這個串是回文串。枚舉每個節點為回文中心,二分即可。

時間復雜度相比較 \(\text{manacher}\) 較劣,為 \(O(n\log n)\)發現過不了模板題。

關鍵代碼

ull num[22000000],num2[22000010];
ull find_hash(int l,int r)
{
	if(l<=r)
	return num[r]-num[l-1]*_base[r-l+1];//順序Hash
	return num2[r]-num2[l+1]*_base[l-r+1];//倒序Hash
}


int l=0,r=min(i-1,len-i);
int len=0;
while(l<=r)
{
	int mid=(l+r)>>1;
	if(find_hash(i,i+mid)==find_hash(i,i-mid)) l=mid+1,len=mid;//要求順序Hash與倒序Hash匹配
	else r=mid-1;
}

3.\(\text{lcp}\)(最長公共前綴)

\(\text{lcp}\) 也具有可二分性。對於兩個串 \(s,t\) 的兩個前綴 \(s_1,s_2\),假設 \(len(s_1)<len(s_2)\),若其中 \(s_2\)\(s,t\) 的前綴,則 \(s_1\) 也是 \(s,t\) 的前綴(即公共前綴的前綴一定是公共前綴)。

所以可以在 \(O(\log n)\) 時間求出兩個串的前綴。

(這個性質對字符串 \(\text{Hash}\)來講十分重要,這其實也是字符串 \(\text{Hash}\) 雖然簡單但仍能在省選等地方看見的主要因素,具體在后文會講)

例:后綴排序

仿照上述求 \(\text{lcp}\) 的方式,因為決定兩個字符串的大小的是他們 \(\text{lcp}\) 的后一個字符,所以用快排加二分求 \(\text{lcp}\) 即可做到 \(O(n\log^2n)\) 的時間復雜度。比 \(\text{SA}\) 多了一個\(\log\)

//當然這里是開了O2的,不開直接T飛
ull hashs[1000010];
char str[1000010];
int n;
inline ull get(int l,int r){return hashs[r]-hashs[l-1]*bases[r-l+1];}
bool cmp(int l1,int l2)//二分查找lcp,同時返回下一位的大小
{
	int l=-1,r=min(n-l1,n-l2);
	while(l<r)
	{
		int mid=(l+r+1)>>1;
		if(get(l1,l1+mid)==get(l2,l2+mid)) l=mid;//判斷是否為公共前綴
		else r=mid-1;
	}
	if(l>min(n-l1,n-l2)) return l1>l2;
	else return str[l1+l+1]<str[l2+l+1];//返回下一位
}
int a[1000010];
int main()
{
	scanf("%s",str+1);
	n=strlen(str+1);
	bases[0]=1;
	for(int i=1;i<=n;i++)
	{
		bases[i]=bases[i-1]*base;
		hashs[i]=hashs[i-1]*base+str[i];
		a[i]=i;
	}
	stable_sort(a+1,a+n+1,cmp);//好像sort被卡常了,stable_sort跑過了
	for(int i=1;i<=n;i++) printf("%d ",a[i]);
	return 0;
}

除此之外,其實大部分 \(\text{SAM}\) 的題目中都有不少為 \(\text{SA}\)\(\text{Hash}\) 設置的部分分。這里就不列舉了。


難道字符串 \(\text{Hash}\) 只能去其他算法的分嗎?不!其實字符串 \(\text{Hash}\)也有着很多其他算法沒有的優點。

1.求字符串的循環節

例:OKR-A Horrible Poem

題意:給定一個字符串,多次詢問其某個子串的最短循環節。

可以發現對於字符串串 \(s[l,r]\),如果長度為 \(x\) 的前綴是 \(s[l,r]\) 的一個循環節,則必有 \(len(s[l,r])|x\)\(s[l,r-x]=s[l+x,r]\)

如果存在長度為 \(y\) 的前綴是 \(s\) 的循環節,\(y\)\(x\) 的因數且 \(x\) 是串長的因數,則長度為 \(x\) 的前綴必然是 \(s\) 的循環節(感性理解一下)。

考慮篩出每個數的最大質因數,\(O(\log n)\) 分解質因數,然后從大到小試除,看余下的長度是否是循環節,如果是則更新答案。

char str[N];
int len;
ull hashs[N],bases[N];
void make_hash(void)
{
	bases[0]=1;
	for(int i=1;i<=len;i++)
	{
		hashs[i]=hashs[i-1]*base+str[i]-'a'+1;
		bases[i]=bases[i-1]*base;
	}
}//預處理Hash值
ull get_hash(int l,int r){return hashs[r]-hashs[l-1]*bases[r-l+1];}
int prime[N],nxt[N],cnt;
int num[N],tot;
int main()
{
	scanf("%d",&len);
	scanf("%s",str+1);
	make_hash();
	for(int i=2;i<=len;i++)
	{
		if(!nxt[i]) nxt[i]=prime[++cnt]=i;
		for(int j=1;j<=cnt && i*prime[j]<=len;j++)
		{
			nxt[i*prime[j]]=prime[j];//篩出每個數的最大質因數
			if(i%prime[j]==0) break;
		}
	}
	int m;
	scanf("%d",&m);
	for(int i=1;i<=m;i++)
	{
		int l,r;
		scanf("%d%d",&l,&r);
		int lens=r-l+1;
		int ans=0;
		tot=0;
		while(lens>1)
		{
			num[++tot]=nxt[lens];
			lens/=nxt[lens];//質因數分解
		}
		lens=r-l+1;
		for(int j=1;j<=tot;j++)
		{
			int len1=lens/num[j];//進行試除
			if(get_hash(l,r-len1)==get_hash(l+len1,r)) lens=len1;//試除成立就取試除后的結果
		}
		printf("%d\n",lens);
	}
	return 0;
}

2.動態字符串查詢

現在的大多數字符串算法都是靜態的查詢或者只允許在末尾/開頭添加。但如果要求在字符串中間插入或修改,很多算法就無能為力了。

而字符串 \(\text{Hash}\) 的式子是可以合並的,只要知道左區間的 \(\text{Hash}\) 值,右區間的 \(\text{Hash}\) 值,左右區間的大小,就可以 \(O(1)\) 求出總區間的 \(\text{Hash}\) 值。這就使得字符串 \(\text{Hash}\) 可以套上很多數據結構來維護。

一般來說,修改操作中帶 插入可持久化區間修改 這類字眼的字符串題大多就是 \(\text{Hash}\) 套上一些數據結構或是真·毒瘤題

例1:火星人

題意:求兩個后綴的 \(\text{lcp}\),動態插入字符和改字符。

用平衡樹維護區間的 \(\text{Hash}\) 值。仿照上述求 \(\text{lcp}\) 的方法,將上述代碼中的 \(\text{get-hash}\) 改為平衡樹上查詢即可,時間復雜度 \(O(n\log^2n)\)

關鍵代碼。

inline void update(int u)
{
	siz[u]=siz[ch[u][0]]+siz[ch[u][1]]+1;
	sum[u]=sum[ch[u][0]]*bases[siz[ch[u][1]]+1]+val[u]*bases[siz[ch[u][1]]]+sum[ch[u][1]];//合並Hash值
}
/*
此處插入你喜歡的平衡樹
*/
int main()
{
	srand(19260817);
	scanf("%s",str+1);
	int m;
	scanf("%d",&m);
	n=strlen(str+1);
	bases[0]=1;
	for(int i=1;i<=100000;i++) bases[i]=bases[i-1]*base;
	for(int i=1;i<=n;i++) root=t.merge(root,t.new_node(str[i]-'a'+1));//平衡樹的初始化
	for(int i=1;i<=m;i++)
	{
		int x,y;
		scanf("%s%d",opt,&x);
		if(opt[0]=='Q')
		{
			scanf("%d",&y);
			printf("%d\n",lcp(x,y));//查詢
		}
		else if(opt[0]=='R')
		{
			scanf("%s",opt);
			t.erase(root,x);
			t.insert(root,x-1,opt[0]-'a'+1);//往平衡樹中刪除
		}
		else if(opt[0]=='I')
		{
			scanf("%s",opt);
			t.insert(root,x,opt[0]-'a'+1);//往平衡樹中插入
			n++;
		}
	}
	return 0;
}

例2:The Classic Problem

題目大意:求最短路,其中每條邊邊權為 \(2^x\)\(n,m,x\leq 10^5\)

假如不考慮時間復雜度,可以直接高精度加最短路得到 \(O(m\log n\times x)\) 的復雜度。

我們發現這個 \(O(x)\) 是用於 \(+2^x\) 和比較大小的。前者可以用線段樹維護,這里不詳細說明,可以參考本題題解或是我的博客

對於后者,我們發現這個可以看成是一個字符串求字典序大小的題。而線段樹又恰好可以支持這樣的 \(\text{Hash}\) 查詢。復雜度 \(O(m\log n\log^2 x)\)

考慮 \(\text{Hash}\)\(\text{lcp}\) 的本質是二分前綴是否相同,而線段樹恰好就滿足二分的性質,所以可以從根節點開始,如果兩樹的右子樹的 \(\text{Hash}\) 值不同,可以直接跳右子樹繼續查詢。否則說明右子樹相同,跳左子樹查詢即可。

還有,由於直接復制顯然會T,這里的線段樹需要可持久化,就是每個節點繼承轉移節點(最短路樹上的父親)的信息同時加上一次修改。

時間復雜度 \(O(m\log n\log x)\)

關鍵代碼:

void update(int u,int l,int r)
{
	int mid=(l+r)>>1;
	hs[u]=hs[ls[u]]+hs[rs[u]]*bs[mid-l+1];//合並Hash值
	val[u]=val[ls[u]]==(mid-l+1)?val[ls[u]]+val[rs[u]]:val[ls[u]];//從l開始1的個數
}

int cmp(int u,int v)//比較u,v兩樹的大小
{
	int fu=u,fv=v;
	int l=0,r=n;
	while(1)
	{
		if(!u || tag[u]) return fv;//如果有一方全是0,直接跳出
		if(!v || tag[v]) return fu;
		if(l==r) return hs[u]<=hs[v]?fv:fu;//如果到了葉節點,直接比較
		int mid=(l+r)>>1;
		if(hs[rs[u]]==hs[rs[v]]) u=ls[u],v=ls[v],r=mid;//如果右子樹相同,就比較左子樹
		else u=rs[u],v=rs[v],l=mid+1;//否則比較右子樹
	}
}

完整代碼詳見我的博客

例三:一道口胡的題(暫時沒有找到出處)

題意:維護一個字符串的子串的集合,一開始字符串和集合均為空。

要求完成:在集合中插入一個子串,在字符串末尾加一個字符,求集合中與當前詢問的子串 \(\text{lcp}\) 最大值。

比如字符串為 \(abbcbba\)

集合中的子串為 \(s[1,4],s[3,6],s[5,7]\)

此時查詢與子串 \(s[2,5]\),答案為2(\(bbcb\)\(bba\)\(\text{lcp}\) 為2)。

\(m\leq 10^5\),強制在線(為了防止 \(\text{SA}\) 過而特意加的,原題應該沒有)。

(假如存在SAM/后綴平衡樹的做法可以私信或在下方評論)

首先,考慮一些暴力的做法:

  1. 暴力將一個子串和集合中的串用上述方法求lcp,時間復雜度 \(O(m^2\log m)\)
  2. 暴力建\(\text{trie}\),將子串掛到\(\text{trie}\)上,時間復雜度 \(O(m^2)\),空間 \(O(m^2)\)

顯然上述的方法都不可行。

考慮使用 \(\text{SA}\) 的想法,與一個串lcp最大的串一定是字典序最靠近它的串,也就是比它字典序大中最小的,和比它小中最大的。

仿照這個思路,使用上述比較兩個串字典序大小的方法,考慮使用平衡樹來維護子串集合中字典序的順序,查詢時只需查詢前驅后繼中的 \(\text{lcp}\) 最大值即可。

時間復雜度 \(O(m\log^2m)\)

題目都沒有,代碼肯定咕了


由此可以發現:凡是維護區間,支持維護區間合並(好像區間 \(split\) 也可以)並且支持在線查找的數據結構,諸如什么樹套樹,樹套分塊,在線帶修莫隊,大都可以套上 \(\text{Hash}\) 出道題。

upd:今年ZJOI2020喜聞樂見地出了一道可以用 \(\text{Hash}\) 判斷 \(\text{lcp}\) 過的(雖然用\(\text{SA}\)其實也可以,而且標算其實是一些科技),ZJOI2017也有一道需要用\(\text{Hash}\)

由於上述兩題也是利用\(\text{Hash}\)的性質求\(\text{lcp}\),而且主要難度不在於\(\text{Hash}\),所以這里不再闡述。(其實是我太菜了,以上兩題都沒有過。

如果以上題目不能滿足你的需求,可以參考這份題單

三、字符串的\(\text{Hash}\)沖突與雙\(\text{Hash}\)

雖然字符串 \(\text{Hash}\) 在暴力方面有極大的優勢,但從它的名字中也可以看出它存在的缺點:\(\text{Hash}\) 沖突。

事實上,自然溢出的 \(\text{Hash}\) 會被定向叉,可以通過構造卡掉,具體可見 Hash Killer I(抱歉,bzoj掛了,鏈接不見了)

Hash Killer II

題意:卡單 \(\text{Hash}\) 的代碼,要求 \(n\leq 10^5\),代碼中\(\ mod=10^9+7\)

upd: bzoj沒了,題目也不見了。

根據生日悖論,對於 \(n\) 個不同的字符串,有些 \(mod\) 可能看着比 \(n\) 大得多(比如\(10^9+7\)),但它還是極有可能把其中兩個不同的串判成相同。當 \(mod\)\(n\) 相差較大時有沖突概率 \(P\approx 1-(1-\frac{1}{mod})^{\frac{n(n-1)}{2}}\)(具體推導詳見百度百科

通過代入求值可以發現,對於該題,盡管 \(mod\)\(n\) 的1000倍以上,可是這個沖突的概率仍然高得驚人(大概是 \(99.32\%\)),也就是說你隨便隨機一個字符串上去它大概率就會掛掉。

放一張度娘的圖(圖中的\(N\)對應題目中的\(mod\))。

upd: 圖可能掛了,如果不行請自行百度

通過式子可以發現,如果想要 \(P\) 不變,\(mod\) 應當與 \(n^2\) 同階,而不是和 \(n\) 同階!!(可以理解成:如果我們認為當 \(n=100\) 時, $ mod=10^5$的沖突概率剛好可以接受,那么當 \(n=10^5\) 時就必須要 \(mod\geq10^{11}\)。)

這時候就要用到雙 \(\text{Hash}\) 了,其實就是用兩個不同的 \(base\) 或是 \(mod\) 對同一個串進行2遍 \(\text{Hash}\)

同樣,如果兩個串第一個 \(\text{Hash}\) 匹配而第二個不匹配,那么這兩個串還是不相等(因為相等的串無論怎么 \(\text{Hash}\) 都是相等的)。

所以假如用自然溢出可能會被卡的情況下(其實一般情況下是不會的),建議寫雙\(\text{Hash}\)。但是需要注意一點,雙 \(\text{Hash}\) 的常數十分之大。

我常用的雙 \(\text{Hash}\) 寫法:

#define ll long long
#define P pair<ll,ll>
#define MP make_pair
#define fi first
#define se second
#define mod 1000000007
P operator +(const P a,const P b){return MP((a.fi+b.fi)%mod,(a.se+b.se)%mod);}
P operator -(const P a,const P b){return MP((a.fi-b.fi+mod)%mod,(a.se-b.se+mod)%mod);}
P operator *(const P a,const P b){return MP(a.fi*b.fi%mod,a.se*b.se%mod);}
const P base=MP(233,2333);
//后續代碼同單Hash

不過,雙 \(\text{Hash}\) 其實很不常用,首先它的誤判率並不比自然溢出的寫法優秀,再加上其巨大的常數,更長的代碼量,\(pair\) 類型也並不如 \(ull\) 那么友好,所以絕大部分 \(\text{Hash}\) 用的都還是自然溢出。

當然,在codeforces上寫自然溢出的絕對是勇士(別問我怎么知道的

四、總結

雖然字符串 \(\text{Hash}\) 相比較其他字符串算法而言存在一定概率出錯。這在計數題(尤其是數據打包的題目)中有時是十分致命的(雖然是極小的概率)。

但是字符串 \(\text{Hash}\) 的一些優秀性質也使得它有其他算法所沒有的一些優勢。在多數題目中,字符串 \(\text{Hash}\) 也未嘗不是一種簡易的騙分方式。

當然,大多數情況下 \(\text{Hash}\) 都是配合其他算法以及數據結構一起出現的,所以就算會了Hash我還是咸魚


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM