1. 程式人生 > >程式設計師面試中常見的雜湊表,到底是什麼?

程式設計師面試中常見的雜湊表,到底是什麼?

640?wx_fmt=gif

640?wx_fmt=jpeg

作者 | 倪升武

責編 | 胡巍巍

我所寫的這些資料結構,都是比較經典的,也是面試中經常會出現的,這篇文章我就不閒扯了,全是乾貨,如果你能讀完,希望對你有所幫助~ 

雜湊表也稱為散列表,是根據關鍵字值(key value)而直接進行訪問的資料結構。也就是說,它通過把關鍵字值對映到一個位置來訪問記錄,以加快查詢的速度。

這個對映函式稱為雜湊函式(也稱為雜湊函式),對映過程稱為雜湊化,存放記錄的陣列叫做散列表。比如我們可以用下面的方法將關鍵字對映成陣列的下標:

  • arrayIndex=hugeNumber%arraySize

那麼問題來了,這種方式對不同的關鍵字,可能得到同一個雜湊地址,即同一個陣列下標,這種現象稱為衝突,那麼我們該如何去處理衝突呢?

一種方法是開放地址法,即通過系統的方法找到陣列的另一個空位,把資料填入,而不再用雜湊函式得到的陣列下標,因為該位置已經有資料了;

另一種方法是建立一個存放連結串列的陣列,陣列內不直接儲存資料,這樣當發生衝突時,新的資料項直接接到這個陣列下標所指的連結串列中,這種方法叫做鏈地址法。下面針對這兩種方法進行討論。

640?wx_fmt=png

開放地址法

線性探測法

所謂線性探測,即線性地查詢空白單元。我舉個例子,如果21是要插入資料的位置,但是它已經被佔用了,那麼就是用22,然後23,以此類推。

陣列下標一直遞增,直到找到空白位。下面是基於線性探測法的雜湊表實現程式碼:

public class HashTable {
   private

 DataItem[] hashArray; //DateItem類是資料項,封裝資料資訊
   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) {

640?wx_fmt=png

鏈地址法

在開放地址法中,通過再雜湊法尋找一個空位解決衝突問題,另一個方法是在雜湊表每個單元中設定連結串列(即鏈地址法),某個資料項的關鍵字值還是像通常一樣對映到雜湊表的單元,而資料項本身插入到這個單元的連結串列中。

其他同樣對映到這個位置的資料項只需要加到連結串列中,不需要在原始的陣列中尋找空位。下面看看鏈地址法的程式碼:

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。

宣告:本文為作者投稿,版權歸其個人所有。

推薦閱讀:

640?wx_fmt=gif

640?wx_fmt=gif