哈希、哈希表詳解及應用


前置概念

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的質因子的合數

隨機數法:通常關鍵字不等的時候采用此法構造哈希函數較恰當。

但是這些東西貌似都是形式上的,具體怎么操作還是得靠實現

哈希表的實現

聽課的同學里面有多少人寫過圖/最短路等算法呢?

圖的存儲有兩種方法:

  1. 鄰接矩陣

  2. 鄰接表

在這里我們用鄰接表來實現。

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();
    }
    
}

講完了

祝大家身體健康

參考:信息學奧賽一本通 提高篇


免責聲明!

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



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