散列表
查找的本質
已知對象找位置
- 有序安排對象:全序、半序
- 直接“算出”對象位置:散列
散列查找
散列查找法的兩項基本工作:
- 計算位置:構造散列函數確定關鍵詞存儲位置
- 解決衝突:應用某種策略解決多個關鍵詞位置相同的問題
- 時間複雜度幾乎是常量:
O(1) ,即查找時間與問題規模無關
抽象數據類型定義
- 類型名稱:符號表(SymbolTable)
- 數據對象集:符號表是“名字(Name)— 屬性(Attribute)”對的集合
- 操作集:
Table∈SymbolTable,Name∈NameType,Attr∈AttributeType
SymbolTable InitializeTable(int TableSize)
:創建一個長度爲TableSize
的符號表Boolean IsIn(SymbolTable Table, NameType Name)
:查找特定的名字Name
是否在符號表Table
中AttributeType Find(SymbolTable Table, NameType Name)
:獲取Table
中指定名字Name
對應的屬性SymbolTable Modefy(SymbolTable Table, NameType Name, AttributeType Attr)
:將Table
中指定名字Name
的屬性修改爲Attr
SymbolTable Insert(SymbolTable Table, NameType Name, AttributeType Attr)
:向Table
中插入一個新名字Name
及其屬性Attr
SymbolTable Delete(SymbolTable Table, NameType Name)
:從Table
中刪除一個名字Name
及其屬性
散列表的基本術語
- 裝填因子(Loading Factor):設散列表空間大小爲
m ,填入表中元素的個數是n ,則稱a=n/m 爲散列表的裝填因子
散列(Hashing)的基本思想
- 以關鍵字
key 爲自變量,通過一個確定的函數h (散列函數),計算出對應的函數值h(key) ,作爲數據對象的存儲地址 - 可能不同的關鍵字會映射到同一個散列地址上,即
h(key1)=h(key2),key1≠key2 ,稱爲“衝突(Collision)”
- 需要某種衝突解決策略
散列函數的構造方法
一個“好”的散列函數一般應考慮下列兩個因素:
- 計算簡單,以便提高轉換速度
- 關鍵詞對應的地址空間分佈均勻,以儘量減少衝突
數字關鍵詞的散列函數構造
直接定址法
取關鍵詞的某個線性函數值爲散列地址,即
例
散列函數:
除留取餘法
散列函數爲:
例
散列函數:
p=TableSize=17 - 一般,
p 取素數
數字分析法
分析數字關鍵字在各位上的變化情況,取比較隨機的位作爲散列地址
例1
取11位手機號碼key的後四位作爲地址,散列函數爲:
例2
關鍵詞
散列函數:
h1(key)=(key[6]−′0′)∗104+(key[10]−′0′)∗103+(key[14]−′0′)∗102+(key[16]−′0′)∗10+(key[17]−′0′) h(key)=
h1(key)∗10+10 ,當key[18]=′x′ 時h1(key)∗10+key[18]−′0′ ,當key[18]=′0′→′9′ 時
摺疊法
把關鍵詞分割成位數相同的幾個部分,然後疊加
例
散列函數:
平方取中法
把關鍵詞進行平方計算以後,取中間的幾位作爲散列值
例
散列函數:
字符關鍵詞的散列函數構造
簡單的散列函數 —— ASCII碼加和法
對字符型關鍵詞
衝突嚴重,如:
簡單的改進 —— 前3個字符移位法
散列函數:
- 仍然衝突,如
{string,street,strong,...} - 空間浪費:
3000/263≈30%
好的散列函數 —— 移位法
涉及關鍵詞所有
實現
Index Hash(const char *Key, int TableSize) {
unsigned int h = 0; // 散列函數值,初始化爲0
while (*Key != '\0') // 移位映射
h = (h << 5) + *Key++; // 左移5位表示乘上32
return h % TableSize;
}
衝突處理方法
常用處理衝突的思路:
- 換個位置:開放地址法
- 同一位置的衝突對象組織在一起:鏈地址法
開放地址法(Open Addressing)
原理
一旦產生衝突(該地址已有其他元素),就按某種規則去尋找另一空地址
- 若發生了第
i 次衝突,試探的下一地址將增加di ,基本公式是:hi(key)=(h(key)+di) mod TableSize ,1≤i≤TableSize di 決定了不同的解決方案
- 線性探測:
di=i - 平方探測:
di=±i2 - 雙散列:
di=i∗h2(key)
- 線性探測:
散列表查找性能分析
- 成功平均查找長度(ASLs):查找表中關鍵詞的平均查找次數(其衝突次數加1)
- 不成功平均查找長度(ASLu):不在散列表中關鍵詞的平均查找次數(不成功)
- 一般方法:將不在散列表中的關鍵詞分若干類,如根據
h(key) 值分類
- 一般方法:將不在散列表中的關鍵詞分若干類,如根據
線性探測法(Linear Probing)
以增量序列
例
設關鍵詞序列爲
- 散列表長
TableSize=13 (裝填因子a=9/13≈0.69 ) - 散列函數爲:
h(key)=key mod 11
插入後的散列表
注:元素會在衝突頻繁的地方聚集起來,稱爲聚集現象
查找性能分析
ASLs=(1+7+1+1+2+1+4+1+4)/9=23/9≈2.56 ASLu=(3+2+1+2+1+1+1+9+8+7+6)//11=41/11≈3.73
平方探測法(Quadratic Probing)
又稱二次探測,以增量序列
例
設關鍵詞序列爲
- 散列表表長
TableSize=11 - 散列函數爲:
h(key)=key mod 11
插入後的散列表
查找性能分析
平方探測空間查找問題
存在散列表空間,通過平方探測無法獲取到
例
解決方案
有定理顯示:如果散列表長度
雙散列探測法(Double Hashing)
- 對於任意的
key :h2(key)≠0 - 探測序列還應該保證所有的散列存儲單元都應該能夠被探測到
- 有良好效果的探測序列:
h2(key)=p−(key mod p) ,p<TableSize ,p 、TablseSize 都是素數
- 有良好效果的探測序列:
再散列(ReHashing)
- 當散列表元素太多(即裝填因子
a 太大)時,查找效率會下降
- 實用最大裝填因子一般取
0.5≤a≤0.85
- 實用最大裝填因子一般取
- 當裝填因子過大時,解決的方法是加倍擴大散列表,這個過程叫“再散列”
開放地址法實現
創建開放地址法的散列表
#define MAXTABLESIZE 100000 /* 允許開闢的最大散列表長度 */
typedef int ElementType; /* 關鍵詞類型用整型 */
typedef int Index; /* 散列地址類型 */
typedef Index Position; /* 數據所在位置與散列地址是同一類型 */
/* 散列單元狀態類型,分別對應:有合法元素、空單元、有已刪除元素 */
typedef enum { Legitimate, Empty, Deleted } EntryType;
typedef struct HashEntry Cell; /* 散列表單元類型 */
struct HashEntry {
ElementType Data; /* 存放元素 */
EntryType Info; /* 單元狀態 */
};
typedef struct TblNode *HashTable; /* 散列表類型 */
struct TblNode { /* 散列表結點定義 */
int TableSize; /* 表的最大長度 */
Cell *Cells; /* 存放散列單元數據的數組 */
};
int NextPrime( int N ) { /* 返回大於N且不超過MAXTABLESIZE的最小素數 */
int i, p = (N%2)? N+2 : N+1; /*從大於N的下一個奇數開始 */
while( p <= MAXTABLESIZE ) {
for( i=(int)sqrt(p); i>2; i-- )
if ( !(p%i) ) break; /* p不是素數 */
if ( i==2 ) break; /* for正常結束,說明p是素數 */
else p += 2; /* 否則試探下一個奇數 */
}
return p;
}
HashTable CreateTable( int TableSize ) {
HashTable H;
int i;
H = (HashTable)malloc(sizeof(struct TblNode));
/* 保證散列表最大長度是素數 */
H->TableSize = NextPrime(TableSize);
/* 聲明單元數組 */
H->Cells = (Cell *)malloc(H->TableSize*sizeof(Cell));
/* 初始化單元狀態爲“空單元” */
for( i=0; i<H->TableSize; i++ )
H->Cells[i].Info = Empty;
return H;
}
注:在開發地址散列表中,刪除操作要很小心。通常只能“懶惰刪除”,即需要增加一個“刪除標記(Deleted)”,而不是真正的刪除它。以便查找是不會“斷鏈”。其空間可以在下次插入時重用
平方探測法的查找與插入
Position Find( HashTable H, ElementType Key ) {
Position CurrentPos, NewPos;
int CNum = 0; /* 記錄衝突次數 */
NewPos = CurrentPos = Hash( Key, H->TableSize ); /* 初始散列位置 */
/* 當該位置的單元非空,並且不是要找的元素時,發生衝突 */
while( H->Cells[NewPos].Info!=Empty && H->Cells[NewPos].Data!=Key ) {
/* 字符串類型的關鍵詞需要 strcmp 函數!! */
/* 統計1次衝突,並判斷奇偶次 */
if( ++CNum%2 ){ /* 奇數次衝突 */
NewPos = CurrentPos + (CNum+1)*(CNum+1)/4; /* 增量爲+[(CNum+1)/2]^2 */
if ( NewPos >= H->TableSize )
NewPos = NewPos % H->TableSize; /* 調整爲合法地址 */
}
else { /* 偶數次衝突 */
NewPos = CurrentPos - CNum*CNum/4; /* 增量爲-(CNum/2)^2 */
while( NewPos < 0 )
NewPos += H->TableSize; /* 調整爲合法地址 */
}
}
return NewPos; /* 此時NewPos或者是Key的位置,或者是一個空單元的位置(表示找不到)*/
}
bool Insert( HashTable H, ElementType Key ) {
Position Pos = Find( H, Key ); /* 先檢查Key是否已經存在 */
if( H->Cells[Pos].Info != Legitimate ) { /* 如果這個單元沒有被佔,說明Key可以插入在此 */
H->Cells[Pos].Info = Legitimate;
H->Cells[Pos].Data = Key;
/*字符串類型的關鍵詞需要 strcpy 函數!! */
return true;
}
else {
printf("鍵值已存在");
return false;
}
}
分離鏈接法(Separate Chaining)
原理
將相應位置上衝突的所有關鍵詞存儲在同一個單鏈表中
例
設關鍵詞序列爲
- 散列函數爲:
h(key)=key mod 11
插入後的散列表
- 表中有9個結點只需1次查找
- 5個結點需要2次查找
- 查找成功的平均查找次數:
ASLs=(9+5∗2)/14=19/14≈1.36
實現
#define KEYLENGTH 15 /* 關鍵詞字符串的最大長度 */
typedef char ElementType[KEYLENGTH+1]; /* 關鍵詞類型用字符串 */
typedef int Index; /* 散列地址類型 */
/******** 以下是單鏈表的定義 ********/
typedef struct LNode *PtrToLNode;
struct LNode {
ElementType Data;
PtrToLNode Next;
};
typedef PtrToLNode Position;
typedef PtrToLNode List;
/******** 以上是單鏈表的定義 ********/
typedef struct TblNode *HashTable; /* 散列表類型 */
struct TblNode { /* 散列表結點定義 */
int TableSize; /* 表的最大長度 */
List Heads; /* 指向鏈表頭結點的數組 */
};
HashTable CreateTable( int TableSize )
{
HashTable H;
int i;
H = (HashTable)malloc(sizeof(struct TblNode));
/* 保證散列表最大長度是素數,具體見代碼5.3 */
H->TableSize = NextPrime(TableSize);
/* 以下分配鏈表頭結點數組 */
H->Heads = (List)malloc(H->TableSize*sizeof(struct LNode));
/* 初始化表頭結點 */
for( i=0; i<H->TableSize; i++ ) {
H->Heads[i].Data[0] = '\0';
H->Heads[i].Next = NULL;
}
return H;
}
Position Find( HashTable H, ElementType Key )
{
Position P;
Index Pos;
Pos = Hash( Key, H->TableSize ); /* 初始散列位置 */
P = H->Heads[Pos].Next; /* 從該鏈表的第1個結點開始 */
/* 當未到表尾,並且Key未找到時 */
while( P && strcmp(P->Data, Key) )
P = P->Next;
return P; /* 此時P或者指向找到的結點,或者爲NULL */
}
bool Insert( HashTable H, ElementType Key )
{
Position P, NewCell;
Index Pos;
P = Find( H, Key );
if ( !P ) { /* 關鍵詞未找到,可以插入 */
NewCell = (Position)malloc(sizeof(struct LNode));
strcpy(NewCell->Data, Key);
Pos = Hash( Key, H->TableSize ); /* 初始散列位置 */
/* 將NewCell插入爲H->Heads[Pos]鏈表的第1個結點 */
NewCell->Next = H->Heads[Pos].Next;
H->Heads[Pos].Next = NewCell;
return true;
}
else { /* 關鍵詞已存在 */
printf("鍵值已存在");
return false;
}
}
void DestroyTable( HashTable H )
{
int i;
Position P, Tmp;
/* 釋放每個鏈表的結點 */
for( i=0; i<H->TableSize; i++ ) {
P = H->Heads[i].Next;
while( P ) {
Tmp = P->Next;
free( P );
P = Tmp;
}
}
free( H->Heads ); /* 釋放頭結點數組 */
free( H ); /* 釋放散列表結點 */
}
散列表的性能分析
- 平均查找長度(ASL)用來度量散列表查找效率:成功、不成功
- 關鍵詞的比較次數,取決於產生衝突的多少,影響產生衝突多少有以下三個因素:
- 散列函數是否均勻
- 處理衝突的方法
- 散列表的裝填因子
a
線性探測法的查找性能
可以證明,線性探測法的期望探測次數滿足下列公式:
當
- 插入成功和不成功查找的期望
ASLu=0.5∗(1+1/(1−0.5)2)=2.5 次 - 成功查找的期望
ASLs=0.5∗(1+1/(1−0.5))=1.5 次
注:當採用線性探測衝突解決策略時,非空且有空閒空間的散列表中無論有多少元素,不成功情況下的期望查找次數總是大於成功情況下的期望查找次數
例
- 期望
ASLu=0.5∗(1+1/(1−0.69)2)=5.70 次 - 期望
ASLs=0.5∗(1+1/(1−0.69))=2.11 次(實際計算ASLs=2.56 )
平方探測法和雙散列探測法的查找性能
可以證明,平方探測法和雙散列探測法的探測次數滿足下列公式:
當
- 插入成功和不成功查找的期望
ASLu=1/(1−0.5)=2 次 - 成功查找的期望
ASLs=−1/0.5∗ln(1−0.5)≈1.39 次
例
- 期望
ASLu=1/(1−0.82)≈5.56 次 - 期望
ASLs=−1/0.82∗ln(1−0.82)≈2.09 次(實際計算ASLs=2 )
期望探測次數與裝填因子a 的關係
- 當裝填因子
a<0.5 的時候,各種探測法的期望探測次數都不大,也比較接近 - 隨着
a 的增大,線性探測法的期望探測次數增加比較快,不成功查找和插入操作的期望探測次數比成功查找的期望探測次數要大 - 合理的最大裝入因子
a 應該不超過0.85
分離鏈接法的查找性能
所有地址鏈表的平均長度定義成裝填因子
當
- 插入成功和不成功查找的期望
ASLu=1+e−1=1.37 次 - 成功查找的期望
ASLs=1+1/2=1.5 次
例
- 期望
ASLu=1.27+e1.27≈1.55 次 - 期望
ASLs=1+1.27/2≈1.64 次(實際計算ASLs=1.36 )
散列查找性能總結
- 優點:選擇合適的
h(key) ,散列法的查找效率期望是常數O(1) ,它幾乎與關鍵字的空間大小n 無關。也適用於關鍵字直接比較計算量大的問題 - 它是以較小的
a 爲前提,因此散列方法是一個以空間換時間 - 缺點:散列方法的存儲對關鍵字是隨機的,不便於順序查找關鍵字,也不適合於範圍查找,或最大值最小值查找
開發地址法
- 優點:散列表是一個數組,存儲效率高,隨機查找
- 缺點:散列表有“聚集”現象
分離鏈接法
- 散列表是順序存儲和鏈式存儲的結合,鏈表部分的存儲效率和查找效率都比較低
- 優點:關鍵字刪除不需要“懶惰刪除”法,從而沒有存儲“垃圾”
- 缺點:太小的
a 可能導致空間浪費,大的a 又將付出更多的時間代價。不均勻的鏈表長度導致時間效率的嚴重下降