數據結構 第十一講 散列查找(哈希)
一、散列表
編譯處理時,涉及變量及屬性(如:變量類型)的管理:
插入:新變量定義
查找:變量的引用
編譯處理中對變量的管理:動態查找問題
利用查找樹(搜索樹)進行變量管理?
兩個變量名(字符串)比較效率不高
是否可以先把字符串轉換為數字,再處理?




“散列(Hashing)”的基本思想是:
①以關鍵字key為自變量,通過一個確定的函數h(散列函數)計算出對應的函數值h(key),作為數據對象的存儲地址。
②可能不同的關鍵字會映射到同一個散列地址上,即h(key a)=h(key b)(當key a≠key b),稱為“沖突(Collision)”——此時需要某種沖突解決策略
二、散列函數的構造方法
一個“好”的散列函數一般應考慮下列兩個因素:
1.計算簡單,以便提高轉換速度
2.關鍵詞對應的地址空間分布均勻,以盡量減少沖突
數字關鍵詞的散列函數構造
1.直接定址法
取關鍵詞的某個線性函數值為散列地址,即
h(key)= a x key + b(a、b為常數)

2.除留余數法
散列函數為:h(key)= key mod p

這里:p = Tablesize = 17
一般,p取素數
3.數字分析法
分析數字關鍵字在各位上的變化情況,取比較隨機的位作為散列地址
比如:取11位手機號碼key的后4位作為地址:
散列函數為:h(key)= atoi(key + 7)(char *key)
int atoi(char* s):將類似“5678”的字符串轉換為整數5678

4.折疊法
把關鍵詞分割成位數相同的幾個部分,然后疊加

5.平方取中法

取中間三位數的原因:使每一位的變化都能對散列結果產生影響

字符關鍵詞的散列函數構造
1.一個簡單的散列函數——ASCII碼加和法
對字符型關鍵詞key定義散列函數如下:
h(key)= (∑key[i])mod TableSize
沖突嚴重: a3、b2、c1;eat、tea
2.簡單的改進——前3個字符移位法
h(key)=(key[0] x 27^2 + key[1] x 27 + key[2])mod TableSize
仍然會產生沖突: string、street、strong、structure等等;
空間浪費: 3000 / 263 ≈ 30%
3.好的散列函數——移位法
涉及關鍵詞的所有n個字符,並且分布的很好:

如何快速計算:


Index Hash(const char *Key,int TableSize)
{
unsigned int h = 0;
while(Key!='\0')
{
h = (h<<5) + *Key++;//h = h*32 + *Key++;
}
return h % TableSize;
}
在除留余數法中,H(key) = key%p,為什么p要取素數? 你可以舉些例子分析一下。
如果散列值的因數越多,可能導致的散列分布越不均勻,所以,p的選擇需要選擇約數少的數值,什么數值的約數最少呢?當然是只有1和它自己的質數了。所以往往將桶個數設置為質數或不包含小於20的質因數的合數。
三、沖突處理的方法
常用處理沖突的思路:
換個位置:開放尋址法
同一位置的沖突對象組織在一起:鏈地址法(拉鏈法)
1.開發尋址法(Open Addressing)
一旦產生了沖突(該地址已有其他元素),就按某種規則去尋找另一空地址
若發生了第i次沖突,試探的下一個地址將增加偏移量di,基本公式是:
hi(key)= (h(key)+di)mod TableSize(1≤i<TableSize)
di決定了不同的解決沖突方案:線形探測、平方探測、雙散列

在查找不存在的字符串時,當向右查找到某個位置為空時即停止並返回查找失敗

2.平方探測法(Quadratic Probing)——二次探測
平方探測法: 以增量序列12 ,-12,22,-22,……,q2,-q2 ,且q≤[TableSize/2]循環試探下一個存儲地址


是否有空間,平方探測法(二次探測)就能找得到?

有關負數取余的問題,請參考此博客:負數取余問題

有定理顯示:如果散列表長度TableSize是某個4k+3(k是正整數)形成的素數時,平方探測法就可以探查到整個散列表空間
3.雙散列探測法(Double Hashing)
雙散列探測法:di為ih2(key),h2(key)是另一個散列函數
探測序列成:h2(key)、2h2(key)、3*h2(key),……
注意:對任意的key,h2(key)≠0
探測序列還應該保證所有的散列存儲單元都應該能夠被探測到。
選擇以下形式有良好的效果:
h2(key)= p - (key mod p)
其中:p<TableSize,p、TableSize都是素數。
4.再散列(Rehashing)
當散列表元素太多(即裝填因子α太大)時,查找效率會下降,解決的方法是加倍擴大散列表,這個過程叫做“再散列”;
實用最大裝填因子一般取0.5 <= α <= 0.85
散列表擴大時,原有元素需要重新計算並放置到新表中
5.分離鏈接法(Separate Chaining)
分離鏈接法:將相應位置上沖突的所有關鍵詞存儲在同一個單鏈表中
四、散列表的性能分析
平均查找長度(ASL)用來度量散列表的查找效率:成功、不成功
關鍵詞的比較次數,取決於產生沖突的多少
影響產生沖突多少有以下三種因素:
①散列函數是否均勻
②散列表的裝填因子α
③處理沖突的方法
1.線形探測法的查找性能


2.平方探測法和雙散列探測法的查找性能


合理的最大裝填因子α應該不超過0.85
3.分離鏈接法的查找性能

總結
選擇合適的h(key),散列表的查找效率期望是常數O(1),它幾乎與關鍵字的空間的大小n無關!也適合於關鍵字直接比較計算量大的問題
它是以較小的α為前提。因此,散列方法是一個以空間換時間
散列方法的存儲對關鍵字是隨機的,不便於順序查找關鍵字,也不適合於范圍查找,或最大值最小值查找
開放地址法
散列表是一個數組,存儲效率高,隨機查找。然而散列表有“聚集”現象
分離鏈接法
散列表是順序和鏈式存儲的結合,鏈表部分的存儲效率和查找效率都比較低。
關鍵字“刪除”不需要“懶惰刪除”法,從而沒有存儲“垃圾”。
太小的α可能導致空間浪費,大的α又將付出更多的時間代價。不均勻的鏈表長度導致時間效率的嚴重下降。
五、哈希表的構造(代碼實現)

結構體數組的哈希表
typedef struct HashTbl *HashTable;
struct Cell
{
int Info;
ElementType Element;
}Cell;
struct HashTbl
{
int TableSize;
Cell *TheCells;
}H;
HashTable InitializeTable(int TableSize)
{
HashTable H;
int i;
if(TableSize < MinTableSize)
{
Error("散列表太小");
return NULL;
}
H = (HashTable)malloc(sizeof(struct HashTbl));
if(H == NULL)
{
FatalError("空間溢出!");
}
H->TableSize = NextPrime(TableSize);//比TS大的最小素數,作為哈希表的容量
H->TheCells = (Cell*)malloc(sizeof(Cell) * H->TableSize);//分配散列表Cells
if(H->TheCells == NULL)
{
FatalError("空間溢出!");
}
for(int i=0;i<H->TableSize;i++)//設置哈希表的每個位置的狀態為空
{
H->TheCells[i].Info = Empty;
}
return H;
}
Position Find(ElementType Key,HashTable H)
{
Position CurrentPos,NewPos;
int CNum = 0;//記錄沖突次數
NewPos = CurrentPos = Hash(Key,H->TableSize);//獲取哈希值
while(H->TheCells[NewPos].Info!=Empty&&H->TheCells[NewPos].Element!=Key)//當發生沖突時
//字符串類型的關鍵字需要用到strcmp()
{
CNum++;
if(CNum%2==0)
{
NewPos = CurrentPos - CNum/2 * CNum/2;
while(NewPos<0)//等價於對TableSize取余
{
NewPos = NewPos + H->TableSize;
}
}
else
{
NewPos = CurrentPos + (CNum+1)/2 * (CNum+1)/2;
while(NewPos >= H->TableSize)//等價於對TableSize取余
{
NewPos = NewPos - H->TableSize;
}
}
}
return NewPos;
}
void Insert(ElementType Key,HashTable H)
//插入操作
{
Position Pos;
Pos = Find(Key,H);
if(H->TheCells[Pos].Info != Legitimate)
//確認在此插入
{
H->TheCells[Pos].Info = Legitimate;
H->TheCells[Pos].Element = Key;
}
//字符串類型的關鍵詞需要strcpy()函數!
}
在開放地址散列表中,刪除操作要很小心。通常只能用“懶惰刪除”,即需要增加一個“刪除標記(Deleted)”,而並不是真正刪除它。以便查找時不會“斷鏈”。其空間可以在下次插入時重用
分離鏈式法的哈希表
typedef struct ListNode *Position,*List;
struct ListNode
{
ElementType Element;
Position Next;
};
typedef struct HashTbl *HashTable;
struct HashTbl
{
int TableSize;
List TheLists;
};
Position Find(ElementType key,HashTable H)
{
Position P;
int Pos;
Pos = Hash(key,H->TableSize);
P = H->TheLists[Pos].Next;
while(P!=NULL && P.Element!=key)//若關鍵字為字符串,則使用strcmp()函數
{
P = P->Next;
}
return P;
}
課后練習




已知散列表元素狀態,推測可能的元素輸入順序?
若采用開放定址法,選取散列函數所得位置與實際位置相同的任意一個元素作為首個插入的元素,根據處理沖突的方法更新散列函數的取值,再依次以相同的方法選取余下的元素。可以歸納證明這樣的插入序列能得到給出的表狀態。

