前置概念
Key : 我們提供的一個要進行哈希的數字
\(f(x)\):即為哈希函數,將key扔到這個函數里面,可以得到Value,最核心的構造哈希表的東西
Hash地址:hash出來的值在哈希表中的存儲位置
進入正題
字符串hash
例題1:【模板】KMP
現有T組數據,每次給定兩個字符串\(s1\text{和}s2\),求\(s1\text{在}s2\)中出現了幾次。
首先考慮的當然是KMP了(逃
但是由於我們講的是字符串hash,那就考慮怎么用字符串hash求解;
考慮每次枚舉每一個子串的hash值,但是復雜度.....\(O(nm)\)
所以介紹一個優化技巧:滾動hash
滾動hash
滾動hash的誕生就是為了避免在\(O(m)\)的時間復雜度內計算一個長度為m的字符串的hash值:
我們選取兩個合適的互質常數(雖然不知道為什么互質)b和h,對於字符串c,我們搞一個hash函數:
\(hash(c)=(c_1b^{m-1}+c_2b^{m-2}+.....+c_mb^0)mod h\)
這個hash函數的構造過程是以遞推實現的,設
\(hash(c,k)\)為前k個字符構成的子串的hash值,有
\(hash(c,k)=hash(c,k-1)\times b+c_{k}\)
為方便理解,設\(c="ABCD"\)且\(A=1,B=2....\)則
\(hash(c,2)=1\times b+2\)
\(hash(c,3)=1 \times b^2+2 \times b +3\)
\(hash(c,4)=1\times b^3+2 \times b^2+3\times b+4\)
對於c的子串\(c'=c_{k+1}c_{k+2}....c_{k+n}\),有:
\(hash(c')=hash(c,k+n)-hash(c,k)\times b^n\)
很像前綴和是不是?
也很像b進制轉十進制是不是?
某位老師說過,探究新知的最好方法就是特值代入法,所以如果大家拿上面的那個例子來稍微做一下運算,就能很好地理解滾動hash這個優化方法了。
舉個例子:
如果我們想求上面那個例子的子串\("CD"\)的hash值,那么根據這個公式,就是:
\(hash("CD")=hash(4)-hash(2)\times b^2\)
而\(hash(2)\times b^2 = 1\times b^3+2\times b^2\),
所以,原式\(=3\times b+4\)
這很像我們有一個b進制數1234要轉成十進制,而上面所做的就是把1234中的12給殺掉,只留下34,再轉成十進制就OK了
所以,如果我們預處理出\(b^n\),就可以做到在\(O(1)\)的時間復雜度內get到任意子串的hash值,所以上面那道例題的時間復雜度就成功地降到了\(O(n+m)\)。
但是有些細心的同學會發現,如果某兩個子串的hash值撞車了怎么辦呢?那么可以考慮double_hash,也就是將一個hash值取模兩次,書本上說:可以將h分別取\(10^9+7\)和\(10^9+9\),因為他們是一對“孿生質數”,雖然我也不知道這是什么意思
(提醒:要開成unsigned long long,據說是為了自然溢出,省去取模運算)
哈希表
大概就是這樣子一個東西。
那這個東西有什么用呢?
假設我們要將中國每個人的身份證號映射到每個人的頭上
如果有一個人的身份證號xxxxxx19621011XXXX
這是一個18位數!!!!(難道你要弄一個數組存??)
經過計算,\(1390000000/10^4=13900\),即至少有13900人的身份證后四位是一樣的
所以我們可以將所有身份證后四位相同的人裝到一個桶里面,這個桶的編號就是這個人身份證的后四位,這就是哈希表,主要目的就是為了解決哈希沖突,即F(key)的數值發生重復的情況。
如上面的那個身份證號,我們可以考慮:
故,哈希表就是將\(F(key)\)作為key的哈希地址的一種數據結構。
哈希的某些方法
直接定址法 :地址集合 和 關鍵字集合大小相同
數字分析法 :根據需要hash的 關鍵字的特點選擇合適hash算法,盡量尋找每個關鍵字的 不同點
平方取中法:取關鍵字平方之后的中間極為作為哈希地址,一個數平方之后中間幾位數字與數的每一位都相關,取得位數由表長決定。比如:表長為512,=2^9,可以取平方之后中間9位二進制數作為哈希地址。
折疊法:關鍵字位數很多,而且關鍵字中每一位上的數字分布大致均勻的時候,可以采用折疊法得到哈希地址,
除留取余法:除P取余,可以選P為質數,或者不含有小於20的質因子的合數
隨機數法:通常關鍵字不等的時候采用此法構造哈希函數較恰當。
但是這些東西貌似都是形式上的,具體怎么操作還是得靠實現
哈希表的實現
聽課的同學里面有多少人寫過圖/最短路等算法呢?
圖的存儲有兩種方法:
-
鄰接矩陣
-
鄰接表
在這里我們用鄰接表來實現。
void add(int a,int b,int c){
dt[cnt].from=a;
dt[cnt].to=b;
dt[cnt].value=c;
dt[cnt].next=head[a];
head[a]=cnt++;
}
這是鄰接表。
void add(int a,int b){
dt[cnt].end=b;
dt[cnt].next=head[a];
head[a]=cnt++;
}
這是哈希表。
很像有木有???
在這里\(a,b\)是我們用double_hash取出來的,取兩個不同的模數,兩個\(F(key)\)決定一個字符串。
唯一不同的是head數組的下標是\(key1\)。
其實要不要這么做隨你。
如果我們要遍歷一個哈希表?
同樣,
for(int i=head[x];i;i=dt[i].next){
.......
}
跟遍歷鄰接表一模一樣。
hash表中hash函數的確定
如果是一個數的話,上面講過。(好像用離散化就行了)
如果是一個字符串的話,用前面的滾動hash就可以了。
分兩種情況:
如果你不想用double_hash:
那你也不需要把\(key1\)作為head的下標了。
那就直接unsigned ll亂搞吧,自然溢出
如果你要用double_hash:
那你需要把\(key1\)作為head的下標。
這時候你不能ull了,,那就弄那個什么孿生質數取模吧。
b記得開小一點,最好算一算。
例題2:圖書管理
圖書館要搞一個系統出來,支持兩種操作:
add(s):表示新加入一本書名為s的書。
find(s):表示查詢是否存在一本書名為s的書。
對於每個find操作,輸出一行yes或no。書名與指令之間有空格隔開,書名可能有一大堆空格,對於相同字母但大小寫不同的書名,我們認為它是不同的。
【樣例輸入】
4
add Inside C#
find Effective Java
add Effective Java
fine Effective Java
【樣例輸出】
no
yes
【題目分析】
這題是哈希表的一個變式,判斷一個字符串是否已經出現
可以用滾動hash搞哈希表,采用double_hash
偽代碼(不知道算不算):
void add(int a,int b){
.....
}
int find(int a,int b){
for(int i=head[a];i;i=next[i]){
if(value[i]==b)true;
}
false;
}
int main(){
while(n--){
cin>>order;
gets(s);
for(i=0;i<len;i++){
key1=(key1*b1+s[i])%mod1;
key2=(key2*b2+s[i])%mod2;
}
if(add)add(key1,key2);
else{
if(find(key1,key2))yes;
else no;
}
}
}
這題還算簡單。
例題3 [LuoguP3498&POI2010]Beads
Jbc買了一串車掛飾裝扮自己,上有n個數字。它想要把掛飾扔進發動機里切成\(k\)串。如果有n mod k !=0,則最后一段小於k的可以直接舍去。而且如果有子串\((1,2,3)\)或\((3,2,1)\),Jbc就會認為這兩個子串是一樣的。Jbc想要多樣的掛飾,所以Jbc想要找到一個合適的\(k\),使得它能得到不同的子串最多。
例如:這一串掛飾是:\((1,1,1,2,2,2,3,3,3,1,2,3,3,1,2,2,1,3,3,2,1)\),
\(k=1\)的時候,我們得到3個不同的子串: $(1),(2),(3) $
\(k=2\)的時候,我們得到6個不同的子串: $(1,1),(1,2),(2,2),(3,3),(3,1),(2,3) $
\(k=3\)的時候,我們得到5個不同的子串: \((1,1,1),(2,2,2),(3,3,3),(1,2,3),(3,1,2)\)
\(k=4\)的時候,我們得到5個不同的子串: \((1,1,1,2),(2,2,3,3),(3,1,2,3),(3,1,2,2),(1,3,3,2)\)
【輸入格式】
第一行一個整數n,第二行接n個數字。
【輸出格式】
第一行2個正整數,表示能獲得的最大不同子串個數以及能獲得最大值的k的個數。第二行輸出所有的k。
【數據范圍】
\(n\le 200000\)
\(1\le a_i\le n\)
【樣例輸入】
21
1 1 1 2 2 2 3 3 3 1 2 3 3 1 2 2 1 3 3 2 1
【樣例輸出】
6 1
2
【題目分析】
考慮最暴力的方法:
枚舉k,枚舉每一個子串,從前往后、從后往前各掃一遍。
所以我們就碰到了和字符串hash一樣的問題:
枚舉每一個數復雜度有點高啊啊啊啊啊
為了避免在\(O(k)\)的復雜度內枚舉每一個子串,我們采用滾動hash(好像跟前面引述滾動hash的時候有點像)
預處理出正着跑的hash值以及反着跑的hash值。
枚舉每一個子串,將正的hash值和反的hash值乘起來。
然后再扔到set里,因為我們知道set的特性:如果set里面有兩個相同的數就會自動刪除。
最后再弄一個小根堆,如果當前k能夠獲得當前最大值,就扔進小根堆里,否則將這個小根堆清空,再扔k。
然后呢?
沒有然后了。
#include<bits/stdc++.h>
#define ull unsigned long long
using namespace std;
ull n,a[1010101],power[1010101];
ull hash[1010101],hashback[1010101],ans=0;
set<ull>ba;
priority_queue<ull,vector<ull>,greater<ull> >gas;
const ull b=1926;
ull dash(ull i){
ba.clear();
for(ull j=1;j+i-1<=n;j+=i){
ull cas1=hash[j+i-1]-hash[j-1]*power[i];
ull cas2=hashback[j]-hashback[j+i]*power[i];
ba.insert(cas1*cas2);
}
return (ull)ba.size();
}
int main(){
cin>>n;
for(ull i=1;i<=n;i++){
cin>>a[i];
}
power[0]=1;
for(ull i=1;i<1000000;i++)
power[i]=power[i-1]*b;
for(ull i=1;i<=n;i++)
hash[i]=hash[i-1]*b+a[i];
for(ull i=n;i>=1;i--)
hashback[i]=hashback[i+1]*b+a[i];
/*
for(ull i=1;i<=n;i++)
cout<<hash[i]<<" ";
cout<<endl;
for(ull i=n;i;i--)
cout<<hashback[i]<<" ";
cout<<endl;
cout<<hash[3]-hash[1]*power[2]<<" "<<b*b+b+1<<endl;
cout<<hashback[n-2]-hashback[n+1]*power[3]<<endl;*/
for(ull i=1;i<=n;i++){
ull cnt=dash(i);
if(cnt>ans){
ans=cnt;
while(!gas.empty())gas.pop();
}
if(cnt==ans)gas.push(i);
}
cout<<ans<<" "<<gas.size()<<endl;
for(;!gas.empty();){
cout<<gas.top()<<" ";
gas.pop();
}
}
講完了
祝大家身體健康
參考:信息學奧賽一本通 提高篇