程式設計師面試中常見的雜湊表,到底是什麼?
作者 | 倪升武
責編 | 胡巍巍
我所寫的這些資料結構,都是比較經典的,也是面試中經常會出現的,這篇文章我就不閒扯了,全是乾貨,如果你能讀完,希望對你有所幫助~
雜湊表也稱為散列表,是根據關鍵字值(key value)而直接進行訪問的資料結構。也就是說,它通過把關鍵字值對映到一個位置來訪問記錄,以加快查詢的速度。
這個對映函式稱為雜湊函式(也稱為雜湊函式),對映過程稱為雜湊化,存放記錄的陣列叫做散列表。比如我們可以用下面的方法將關鍵字對映成陣列的下標:
arrayIndex=hugeNumber%arraySize
那麼問題來了,這種方式對不同的關鍵字,可能得到同一個雜湊地址,即同一個陣列下標,這種現象稱為衝突,那麼我們該如何去處理衝突呢?
一種方法是開放地址法,即通過系統的方法找到陣列的另一個空位,把資料填入,而不再用雜湊函式得到的陣列下標,因為該位置已經有資料了;
另一種方法是建立一個存放連結串列的陣列,陣列內不直接儲存資料,這樣當發生衝突時,新的資料項直接接到這個陣列下標所指的連結串列中,這種方法叫做鏈地址法。下面針對這兩種方法進行討論。
開放地址法
線性探測法
所謂線性探測,即線性地查詢空白單元。我舉個例子,如果21是要插入資料的位置,但是它已經被佔用了,那麼就是用22,然後23,以此類推。
陣列下標一直遞增,直到找到空白位。下面是基於線性探測法的雜湊表實現程式碼:
public class HashTable {
private
private int arraySize;
private int itemNum; //陣列中目前儲存了多少項
private DataItem nonItem; //用於刪除項的
public HashTable() {
arraySize = 13;
hashArray = new DataItem[arraySize];
nonItem = new DataItem(-1); //deleted item key is -1
}
public boolean isFull
return (itemNum == arraySize);
}
public boolean isEmpty() {
return (itemNum == 0);
}
public void displayTable() {
System.out.print("Table:");
for(int j = 0; j < arraySize; j++) {
if(hashArray[j] != null) {
System.out.print(hashArray[j].getKey() + " ");
}
else {
System.out.print("** ");
}
}
System.out.println("");
}
public int hashFunction(int key) {
return key % arraySize; //hash function
}
public void insert(DataItem item) {
if(isFull()) {
//擴充套件雜湊表
System.out.println("雜湊表已滿,重新雜湊化..");
extendHashTable();
}
int key = item.getKey();
int hashVal = hashFunction(key);
while(hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
++hashVal;
hashVal %= arraySize;
}
hashArray[hashVal] = item;
itemNum++;
}
/*
* 陣列有固定的大小,而且不能擴充套件,所以擴充套件雜湊表只能另外建立一個更大的陣列,然後把舊陣列中的資料插到新的陣列中。但是雜湊表是根據陣列大小計算給定資料的位置的,所以這些資料項不能再放在新陣列中和老陣列相同的位置上,因此不能直接拷貝,需要按順序遍歷老陣列,並使用insert方法向新陣列中插入每個資料項。這叫重新雜湊化。這是一個耗時的過程,但如果陣列要進行擴充套件,這個過程是必須的。
*/
public void extendHashTable() { //擴充套件雜湊表
int num = arraySize;
itemNum = 0; //重新記數,因為下面要把原來的資料轉移到新的擴張的陣列中
arraySize *= 2; //陣列大小翻倍
DataItem[] oldHashArray = hashArray;
hashArray = new DataItem[arraySize];
for(int i = 0; i < num; i++) {
insert(oldHashArray[i]);
}
}
public DataItem delete(int key) {
if(isEmpty()) {
System.out.println("Hash table is empty!");
return null;
}
線性探測有個弊端,即資料可能會發生聚集。一旦聚集形成,它會變得越來越大,那些雜湊化後落在聚集範圍內的資料項,都要一步步的移動,並且插在聚集的最後,因此使聚集變得更大。
聚集越大,它增長得也越快。這就導致了雜湊表的某個部分包含大量的聚集,而另一部分很稀疏。
為了解決這個問題,我們可以使用二次探測:二次探測是防止聚集產生的一種方式,思想是探測相隔較遠的單元,而不是和原始位置相鄰的單元。
線性探測中,如果雜湊函式計算的原始下標是x, 線性探測就是x+1, x+2, x+3, 以此類推;
而在二次探測中,探測的過程是x+1, x+4, x+9, x+16,以此類推,到原始位置的距離是步數的平方。
二次探測雖然消除了原始的聚集問題,但是產生了另一種更細的聚集問題,叫二次聚集:比如講184,302,420和544依次插入表中,它們的對映都是7,那麼302需要以1為步長探測,420需要以4為步長探測,544需要以9為步長探測。
只要有一項其關鍵字對映到7,就需要更長步長的探測,這個現象叫做二次聚集。
二次聚集不是一個嚴重的問題,但是二次探測不會經常使用,因為還有好的解決方法,比如再雜湊法。
再雜湊法
為了消除原始聚集和二次聚集,現在需要的一種方法是產生一種依賴關鍵字的探測序列,而不是每個關鍵字都一樣。
即:不同的關鍵字即使對映到相同的陣列下標,也可以使用不同的探測序列。再雜湊法就是把關鍵字用不同的雜湊函式再做一遍雜湊化,用這個結果作為步長,對於指定的關鍵字,步長在整個探測中是不變的,不同關鍵字使用不同的步長、經驗說明,第二個雜湊函式必須具備如下特點:
和第一個雜湊函式不同;
不能輸出0(否則沒有步長,每次探索都是原地踏步,演算法將進入死迴圈)。
專家們已經發現下面形式的雜湊函式工作的非常好:
stepSize=constant-key%constant
其中 constant 是質數,且小於陣列容量。
再雜湊法要求表的容量是一個質數,假如表長度為15(0-14),非質數,有一個特定關鍵字對映到0,步長為5,則探測序列是 0,5,10,0,5,10,以此類推一直迴圈下去。
演算法只嘗試這三個單元,所以不可能找到某些空白單元,最終演算法導致崩潰。如果陣列容量為13, 質數,探測序列最終會訪問所有單元。
即 0,5,10,2,7,12,4,9,1,6,11,3,一直下去,只要表中有一個空位,就可以探測到它。下面看看再雜湊法的程式碼:
public class HashDouble {
private DataItem[] hashArray;
private int arraySize;
private int itemNum;
private DataItem nonItem;
public HashDouble() {
arraySize = 13;
hashArray = new DataItem[arraySize];
nonItem = new DataItem(-1);
}
public void displayTable() {
System.out.print("Table:");
for(int i = 0; i < arraySize; i++) {
if(hashArray[i] != null) {
System.out.print(hashArray[i].getKey() + " ");
}
else {
System.out.print("** ");
}
}
System.out.println("");
}
public int hashFunction1(int key) { //first hash function
return key % arraySize;
}
public int hashFunction2(int key) { //second hash function
return 5 - key % 5;
}
public boolean isFull() {
return (itemNum == arraySize);
}
public boolean isEmpty() {
return (itemNum == 0);
}
public void insert(DataItem item) {
if(isFull()) {
System.out.println("雜湊表已滿,重新雜湊化..");
extendHashTable();
}
int key = item.getKey();
int hashVal = hashFunction1(key);
int stepSize = hashFunction2(key); //用hashFunction2計算探測步數
while(hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1) {
hashVal += stepSize;
hashVal %= arraySize; //以指定的步數向後探測
}
hashArray[hashVal] = item;
itemNum++;
}
public void extendHashTable() {
int num = arraySize;
itemNum = 0; //重新記數,因為下面要把原來的資料轉移到新的擴張的陣列中
arraySize *= 2; //陣列大小翻倍
DataItem[] oldHashArray = hashArray;
hashArray = new DataItem[arraySize];
for(int i = 0; i < num; i++) {
insert(oldHashArray[i]);
}
}
public DataItem delete(int key) {
if(isEmpty()) {
System.out.println("Hash table is empty!");
return null;
}
int hashVal = hashFunction1(key);
int stepSize = hashFunction2(key);
while(hashArray[hashVal] != null) {
鏈地址法
在開放地址法中,通過再雜湊法尋找一個空位解決衝突問題,另一個方法是在雜湊表每個單元中設定連結串列(即鏈地址法),某個資料項的關鍵字值還是像通常一樣對映到雜湊表的單元,而資料項本身插入到這個單元的連結串列中。
其他同樣對映到這個位置的資料項只需要加到連結串列中,不需要在原始的陣列中尋找空位。下面看看鏈地址法的程式碼:
public class HashChain {
private SortedList[] hashArray; //陣列中存放連結串列
private int arraySize;
public HashChain(int size) {
arraySize = size;
hashArray = new SortedList[arraySize];
//new出每個空連結串列初始化陣列
for(int i = 0; i < arraySize; i++) {
hashArray[i] = new SortedList();
}
}
public void displayTable() {
for(int i = 0; i < arraySize; i++) {
System.out.print(i + ": ");
hashArray[i].displayList();
}
}
public int hashFunction(int key) {
return key % arraySize;
}
public void insert(LinkNode node) {
int key = node.getKey();
int hashVal = hashFunction(key);
hashArray[hashVal].insert(node); //直接往連結串列中新增即可
}
public LinkNode delete(int key) {
int hashVal = hashFunction(key);
LinkNode temp = find(key);
hashArray[hashVal].delete(key);//從連結串列中找到要刪除的資料項,直接刪除
return temp;
}
public LinkNode find(int key) {
int hashVal = hashFunction(key);
LinkNode node = hashArray[hashVal].find(key);
return node;
}
}
下面是連結串列類的程式碼,用的是有序連結串列:
public class SortedList {
private LinkNode first;
public SortedList() {
first = null;
}
public boolean isEmpty() {
return (first == null);
}
public void insert(LinkNode node) {
int key = node.getKey();
LinkNode previous = null;
LinkNode current = first;
while(current != null && current.getKey() < key) {
previous = current;
current = current.next;
}
if(previous == null) {
first = node;
}
else {
node.next = current;
previous.next = node;
}
}
public void delete(int key) {
LinkNode previous = null;
LinkNode current = first;
if(isEmpty()) {
System.out.println("chain is empty!");
return;
}
while(current != null && current.getKey() != key) {
previous = current;
current = current.next;
}
if(previous == null) {
first = first.next;
}
else {
previous.next = current.next;
}
}
public LinkNode find(int key) {
LinkNode current = first;
while(current != null && current.getKey() <= key) {
if(current.getKey() == key) {
return current;
}
current = current.next;
}
return null;
}
public void displayList() {
System.out.print("List(First->Last):");
LinkNode current = first;
while(current != null) {
current.displayLink();
current = current.next;
}
System.out.println("");
}
}
class LinkNode {
private int iData;
public LinkNode next;
public LinkNode(int data) {
iData = data;
}
public int getKey() {
return iData;
}
public void displayLink() {
System.out.print(iData + " ");
}
}
在沒有衝突的情況下,雜湊表中執行插入和刪除操作可以達到O(1)的時間級,這是相當快的,如果發生衝突了,存取時間就依賴後來的長度,查詢或刪除時也得挨個判斷,但是最差也就O(N)級別。
雜湊表就分享這麼多,本文建議收藏,在等班車的時候、吃飯排隊的時候可以拿出來看看。利用碎片化時間來學習!
作者簡介:倪升武,CSDN 部落格專家,CSDN達人課作者。碩士畢業於同濟大學,曾先後就職於 eBay、愛奇藝、華為。目前在科大訊飛從事Java領域的軟體開發,他的世界不僅只有coding。
宣告:本文為作者投稿,版權歸其個人所有。
推薦閱讀: