【算法】哈希表的誕生

參考資料
《算法(java)》                           — — Robert Sedgewick, Kevin Wayne
《數據結構》                                  — — 嚴蔚敏
 

爲何要使用哈希表

查找和插入是查找表的兩項基本操做,對於單純使用鏈表,數組,或二叉樹實現的查找表來講,這兩項操做在時間消耗上仍顯得比較昂貴。 以查找爲例:在數組實現的查找表中,須要用二分等查找方式進行一系列的比較後,才能找到給定的鍵值對的位置。而二叉樹的實現中也存在着一個向左右子樹遞歸查找的過程。 而如今,咱們但願在查找/插入/刪除這三項基本操做裏, 能不經過比較,而是經過一個哈希函數的映射,直接找到鍵對應的位置,從而取得時間上的大幅優化, 這就是咱們選用哈希表的緣由。
 
相比起哈希表,其餘的查找表中並無特定的「鍵」和「鍵的位置」之間的對應關係。因此須要在鍵的查找上付出較大的開銷。而哈希表則經過一個映射函數(哈希函數)創建起了「鍵」和「鍵的位置」(即哈希地址)間的對應關係,因此大大減少了這一層開銷
 

哈希表的取捨

所謂選擇,皆有取捨。哈希表在查找/插入/刪除等基本操做上展示的優越性能,是在它捨棄了有序性操做的基礎上實現的。由於哈希表並不維護表的有序性,因此在哈希表中實現有序操做的性能會很糟糕。例如:max(取最大鍵),min(取最小鍵), rank(取某個鍵的排名), select(取給定排名的鍵),
floor(向下取整) ceiling(向上取整)。 而相對的, 用二叉樹等結構實現的查找表中,由於在動態操做(插入/刪除)中一直維護着表的有序性,因此這些數據結構中實現的有序操做開銷會小不少。
 

使用哈希表的前提

使用哈希表的前提是: 這個表存儲的鍵是無序的,或者不須要考慮其有序性
 

哈希函數的構造

 
哈希函數有許多不一樣的構造方法,包括:1.直接定址法 2.數字分析法 3.平方取中法 4.摺疊法 5. 除留取餘法
 

1.直接定址法

取鍵或鍵的某個線性函數值爲哈希地址。設 f 爲哈希函數,key爲輸入的鍵,則f(key) = key或者 f(key) = k*key+b (k,b爲常數)
例如,有一個解放後的人口調查表, 鍵爲年份,則可設置哈希函數爲: f(key) = key+ (-1948),以下圖所示:

 

 
1949對應的哈希函數值爲1, 1950對應的爲2,依次類推
 
 

2.數字分析法

以下圖所示,有80個記錄,每一行爲一個記錄中的鍵,假設表長爲100,則可取兩位十進制數組成哈希地址。

 

 
經過觀察能夠得出,第1,2列對應的數字都是相同的,而第3列和第8列存在大量重複的數字(分別是3和2,7),不能選作哈希地址。而中間4位能夠看做是隨機的,能夠從中任選兩位做爲哈希地址
 

3. 平方取中法

取關鍵字平方後的中間幾位爲哈希地址,這種方法叫作平方取中法。它彌補了數字分析法的一些缺陷,由於咱們有時並不能知道鍵的所有狀況,取其中幾位也不必定合適,而一個數平方後的中間幾個數和原數的每一位都相關,由此咱們就能獲得隨機性更強的哈希地址取的位數由表長決定。
 

 

4.摺疊法

將關鍵字分紅位數相同的幾部分(最後一位能夠不一樣),而後取疊加和做爲哈希地址,這一方法被稱爲摺疊法。當表的鍵位數不少,並且每一位上數字分佈比較均勻的時候, 能夠考慮採用這一方法。 摺疊法有移位疊加和間位疊加兩種方法例如國際標準圖書編號0-442-20586-4的哈希地址能夠用這兩種方法表示爲
 

 

 

5.除留餘數法

除留餘數法是最基礎的,最經常使用的取得哈希函數的方法。選定一個統一的基數, 對全部的鍵取餘,從而獲得對應的哈希地址。下圖中的M就表示這個統一的基數,在實現上,它通常是數組的長度
 

 

 
這也是咱們接下來實現哈希表時採用的哈希函數方法。
 

哈希地址的衝突

一個常常會碰到的問題是; 不一樣的鍵通過哈希函數的映射後,獲得了一個一樣的哈希地址。這種現象叫作衝突(或者碰撞)以下圖所示。
 
 

 

 
 
 
 

解決衝突的方法

衝突並非一件嚴重的事情,由於咱們能夠用一些方式去解決它html

解決衝突的方式有三種: 拉鍊法,線性探測法和再哈希法java

拉鍊法

拉鍊法是基於鏈表實現的查找表去實現的,關於鏈表查找表能夠看下我以前寫的這篇文章:算法

 
拉鍊法處理衝突的思路是: 利用鏈表數組實現查找表。即創建一個數組, 每一個數組元素都是一條鏈表。當不一樣的鍵映射到同一個哈希地址(數組下標)上時, 將它們掛到這個哈希地址(數組下標)對應的鏈表上, 讓它們成爲這條鏈表上的不一樣結點。
 

 

 
在拉鍊法中,哈希表的任務是根據給定鍵計算哈希值,而後找到對應位置的鏈表對象。剩下的查找/插入/刪除的操做,就委託給鏈表查找表的查找/插入/刪除接口去作。
 
即:
哈希表的查找操做 = 計算哈希值 + 鏈表查找表的查找操做
哈希表的插入操做 = 計算哈希值 + 鏈表查找表的插入操做
哈希表的刪除操做 = 計算哈希值 + 鏈表查找表的刪除操做
 

 

 

編寫哈希函數數組

在Java中, 默認的hashCode方法返回了一個32位的整數哈希值,由於hashCode可能爲負,因此要經過hashCode() & 0x7fffffff)屏蔽符號位,將一個32位整數變成一個31位非負整數。同時由於咱們要將其運用到數組中,因此要再用數組大小M對其取餘。這樣的話就能取到在0和M-1間(數組下標範圍內)分佈的哈希值。
  /**
   * @description: 根據輸入的鍵獲取對應的哈希值
   */
  private int hash (Key key) {
    return (key.hashCode() & 0x7fffffff) % M;
  }

 

下面給出拉鍊法的具體實現
  • SeparateChainingHashST.java: 拉鍊法實現的哈希表
  • SequentialSearchST.java:  鏈表查找表
  • Test.java: 測試代碼
 
SeparateChainingHashST.java(哈希表)
public class SeparateChainingHashST<Key,Value> {
  private int M; // 數組的大小
  private SequentialSearchST<Key, Value> [] st; // 鏈表查找表對象組成的數組
 
  public SeparateChainingHashST (int M) {
    st= new SequentialSearchST [M];
    this.M = M;
    // 初始化數組st中的鏈表對象
    for (int i=0;i<st.length;i++) {
      st[i] = new SequentialSearchST();
    }
  }
 
  /**
   * @description: 根據輸入的鍵獲取對應的哈希值
   */
  private int hash (Key key) {
    return (key.hashCode() & 0x7fffffff) % M;
  }
  /**
   * @description: 根據給定鍵獲取值
   */
  public Value get (Key key) {
    return st[hash(key)].get(key);
  }
  /**
   * @description: 向表中插入鍵值對
   */
  public void put (Key key, Value val) {
    st[hash(key)].put(key, val);
  }
  /**
   * @description: 根據給定鍵刪除鍵值對
   */
  public void delete (Key key) {
    st[hash(key)].delete(key);
  }
}

 

 
SequentialSearchST.java (鏈表查找表)
public class SequentialSearchST<Key, Value> {
  Node first; // 頭節點
  int N = 0;  // 鏈表長度
  private class Node {
    Key key;
    Value value;
    Node next; // 指向下一個節點
    public Node (Key key,Value value,Node next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }
  }
 
  public int size () {
    return N;
  }
 
  public void put (Key key, Value value) {
    for(Node n=first;n!=null;n=n.next) { // 遍歷鏈表節點
      if(n.key == key) { // 查找到給定的key,則更新相應的value
        n.value = value;
        return;
      }
    }
    // 遍歷完全部的節點都沒有查找到給定key
 
    // 1. 建立新節點,並和原first節點創建「next」的聯繫,從而加入鏈表
    // 2. 將first變量修改成新加入的節點
    first = new Node(key,value,first);
    N++; // 增長字典(鏈表)的長度
  }
 
  public Value get (Key key) {
    for(Node n=first;n!=null;n=n.next) {
      if(n.key.equals(key)) return n.value;
    }
    return null;
  }
 
  public void delete (Key key) {
    if (N == 1) {
      first = null;
      return ;
    }
    for(Node n =first;n!=null;n=n.next) {
      if(n.next.key.equals(key)) {
        n.next = n.next.next;
        N--;
        return ;
      }
    }
  }
}

 

測試代碼
Test.java:
public class Test {
  public static void main (String args[]) {
    SeparateChainingHashST<String, Integer> hashST = new SeparateChainingHashST<>(16);
    hashST.put("A",1); // 插入鍵值對 A - 1
    hashST.put("B",2); // 插入鍵值對 B - 2
    hashST.delete("B"); // 刪除鍵值對 B - 2
    System.out.println(hashST.get("A")); // 輸出 1
    System.out.println(hashST.get("B")); // 輸出 null
  }
}

 

線性探測法

解決衝突的另外一個方法是線性探測法,當衝突發生的時候,咱們檢查衝突的哈希地址的下一位(數組下標加一),判斷可否插入,若是不能則再繼續檢查下一個位置。
 
【注意】線性探測法屬於開放定址法的一種。 開放定址法還包括二次探測,隨機探測等其餘方法
 
實現類的結構以下:
public class LinearProbingHashST<Key, Value> {
  private int M; // 數組的大小
  private int N; // 鍵值對對數
  private Key [] keys;
  private Value [] vals;
  public LinearProbingHashST (int M) {
    this.M = M;
    keys = (Key []) new Object[M];
    vals = (Value[]) new Object[M];
  }
  /**
   * @description: 獲取哈希值
   */
  private int hash (Key key) {
    return (key.hashCode() & 0x7fffffff) % M;
  }
  /**
   * @description: 插入操做
   */
  public void put (Key key, Value val)  // 具體代碼下文給出
  /**
   * @description: 根據給定鍵獲取值
   */
  public Value get (Key key)   // 具體代碼下文給出
  /**
   * @description: 刪除操做
   */
  public void delete (Key key)   // 具體代碼下文給出
}

 

 
爲了較好地理解, 下面我將線性探測表的實現比喻爲一個「警察抓小偷」的遊戲。把被插入的鍵值對當作」小偷「,把數組元素當作」小偷「躲藏的箱子。  則:
 
  • 插入操做是小偷藏進箱子的過程;
  • 查找操做是警察尋找某個小偷的過程;
  • 刪除操做是小偷被警察抓獲,同時離開箱子的過程
 

插入操做

對某個位置進行插入操做時候,可分三種狀況處理:
  1. 該位置鍵爲空,則插入鍵值對
  2. 該位置鍵不爲空,但已有鍵和給定鍵相等,則更新對應的值
  3. 該位置鍵和給定鍵不一樣,則繼續檢查下一個鍵
 
將插入鍵值對的過程比做遊戲中小偷藏進箱子的過程,那麼狀況1和狀況3可用下圖表示:
狀況1:
 

 

狀況3:
 

 

插入操做代碼
  /**
   * @description: 調整數組大小
   */
  private void resize (int max) {
    Key [] temp = (Key [])new Object[max];
    for (int i =0;i<keys.length;i++) {
      temp[i] = keys[i];
    }
    keys = temp;
  }
  /**
   * @description: 插入操做
   */
  public void put (Key key, Value val) {
    // 當鍵值對數量已經超過數組一半時,將數組長度擴大一倍
    if(N>(M/2)) resize(2*M);
    // 計算哈希值,求出鍵的位置
    int i = hash(key);
    // 判斷該位置鍵是否爲空
    while(keys[i]!=null) {
      if(key.equals(keys[i])) {
        // 該位置的鍵和給定key相同,則更新對應的值
        vals[i] = val;
        return;
      } else {
        // 該位置的鍵和給定key不一樣,則檢查下一個位置的鍵
        i = (i+1) % M;
      }
    }
    // 該位置鍵爲空則插入鍵值對
    keys[i] = key;
    vals[i] = val;
    N++;
    return;
  }

 

 
可循環的哈希表
 
i = (i+1) % M這一語句使得線性探測的哈希表是可循環的
i = (i+1) % M的做用表現爲兩方面:
1. 若是當前的元素不是keys數組的最後一個元素, 那麼遊標i會移動到數組下一個元素的位置
2. 若是當前的元素是keys數組的最後一個元素, 那麼遊標i會移動到數組的頭部,即第一個元素,這樣就避免了當哈希值剛好爲數組尾部元素而尾部元素非空時候插入失敗
以下圖所示:
 
 
及時調整數組大小的必要性
 
1. 在拉鍊法實現的哈希表中,由於鏈表的存在,能夠彈性地容納鍵值對,而對於線性探測法實現的哈希表,其容納鍵值對的數量是直接受到數組大小的限制的。因此必須在數組充滿之前調整數組的大小
2. 在另外一方面,即便數組還沒有充滿,隨着鍵值對的增長,線性探測的哈希表的性能也會不斷降低。能夠用鍵值對對數 / 數組大小來量化地衡量其對性能的影響, 以下圖所示:
 

簡單思考下就能明白爲何隨着鍵值對佔數組長度的比例的增長, 哈希表的性能會降低: 由於在這個過程當中,將更容易造成長的鍵簇(一段連續的非空鍵的組合)。而哈希表的查找/插入等通常都是遇到空鍵才能結束, 所以,長鍵簇越多,查找/插入的時間就越長,哈希表的性能也就越差數據結構

 

所以,咱們要及時地擴大數組的大小。如咱們上面的代碼中, 每當總鍵值對的對數達到數組的一半後,咱們就將整個數組的大小擴大一倍。
 

查找操做

 
線性探測的查找過程也分三種狀況處理
1.該位置鍵爲空,則中止查找
2.該位置鍵不爲空,且和給定鍵相等,則返回相應的值
3.該位置鍵不爲空,且和給定鍵不一樣,則繼續檢查下一個鍵
以下圖A,B, 將查找操做比喻成警察尋找某個小偷的過程:
圖A:
 

 

圖B:

 

爲何遇到空鍵就返回?
 
由於插入操做是遇到空的位置就插入, 因此若是不考慮刪除操做的話,哈希值相同的鍵必定是分佈在連續的非空的鍵簇上的。 反之,遇到空的位置, 就說明這後面沒有哈希值相同的鍵了, 因此這時就中止了查找操做
 
查找操做代碼以下
  /**
   * @description: 根據給定鍵獲取值
   */
  public Value get (Key key) {
    for (int i=hash(key);keys[i]!=null;i=(i+1)%M) {
      if (key.equals(keys[i])) {
        return vals[i];
      }
    }
    return null;
  }

 

 

刪除操做

能直接刪除某個鍵值對而不作後續處理嗎? 這是不能的。由於在查找操做中,咱們在查找到一個空的鍵的時候就會中止查找, 因此若是直接刪除某個位置的鍵值對,會致使從該位置的下一個鍵到鍵簇末尾的鍵都不能被查找到了,以下圖1,2所示, 將刪除操做比喻成警察抓獲某個小偷, 並讓小偷離開箱子的過程
圖1:
 
 

 

圖2:
 
 

 

刪除操做的正確方法
刪除操做的正確方法是: 刪除某個鍵值對,並對被刪除鍵後面鍵簇的全部鍵都進行刪除並從新插入
 

 

 
代碼以下:
 
  /**
   * @description: 刪除操做
   */
  public void delete (Key key) {
    // 給定鍵不存在,不進行刪除
    if (get(key) == null) return ;
    // 計算哈希值, 求得鍵的位置
    int i = hash(key);
    // 獲取給定鍵的下標
    while (!key.equals(keys[i])) {
      i = (i+1) % M;
    }
    // 刪除鍵值對
    keys[i] = null;
    vals[i] = null;
    // 對被刪除鍵後面鍵簇的全部鍵都進行刪除並從新插入
    i = (i+1)%M;
    while (keys[i]!=null) {
     Key redoKey = keys[i];
     Value redoVal = vals[i];
     keys[i] = null;
     vals[i] = null;
     put(redoKey,redoVal);
     i = (1+1) % M;
    }
    N--;
  }

 

 
線性探測所有代碼:
public class LinearProbingHashST<Key, Value> {
  private int M; // 數組的大小
  private int N; // 鍵值對對數
  private Key [] keys;
  private Value [] vals;
  public LinearProbingHashST (int M) {
    this.M = M;
    keys = (Key []) new Object[M];
    vals = (Value[]) new Object[M];
  }
  /**
   * @description: 獲取哈希值
   */
  private int hash (Key key) {
    return (key.hashCode() & 0x7fffffff) % M;
  }
  /**
   * @description: 調整數組大小
   */
  private void resize (int max) {
    Key [] temp = (Key [])new Object[max];
    for (int i =0;i<keys.length;i++) {
      temp[i] = keys[i];
    }
    keys = temp;
  }
  /**
   * @description: 插入操做
   */
  public void put (Key key, Value val) {
    // 當鍵值對數量已經超過數組一半時,將數組長度擴大一倍
    if(N>(M/2)) resize(2*M);
    // 計算哈希值,求出鍵的位置
    int i = hash(key);
    // 判斷該位置鍵是否爲空
    while(keys[i]!=null) {
      if(key.equals(keys[i])) {
        // 該位置的鍵和給定key相同,則更新對應的值
        vals[i] = val;
        return;
      } else {
        // 該位置的鍵和給定key不一樣,則檢查下一個位置的鍵
        i = (i+1) % M;
      }
    }
    // 該位置鍵爲空則插入鍵值對
    keys[i] = key;
    vals[i] = val;
    N++;
    return;
  }
  /**
   * @description: 根據給定鍵獲取值
   */
  public Value get (Key key) {
    for (int i=hash(key);keys[i]!=null;i=(i+1)%M) {
      if (key.equals(keys[i])) {
        return vals[i];
      }
    }
    return null;
  }
  /**
   * @description: 刪除操做
   */
  public void delete (Key key) {
    // 給定鍵不存在,不進行刪除
    if (get(key) == null) return ;
    // 計算哈希值, 求得鍵的位置
    int i = hash(key);
    // 獲取給定鍵的下標
    while (!key.equals(keys[i])) {
      i = (i+1) % M;
    }
    // 刪除鍵值對
    keys[i] = null;
    vals[i] = null;
    // 對被刪除鍵後面鍵簇的鍵的位置進行刪除並從新插入
    i = (i+1)%M;
    while (keys[i]!=null) {
     Key redoKey = keys[i];
     Value redoVal = vals[i];
     keys[i] = null;
     vals[i] = null;
     put(redoKey,redoVal);
     i = (1+1) % M;
    }
    N--;
  }
}

 

 
測試代碼:
public class Test {
  public static void main (String args[]) {
    LinearProbingHashST<String, Integer> lst = new LinearProbingHashST<>(10);
    lst.put("A",1);
    lst.put("B",2);
    lst.delete("A");
    System.out.println(lst.get("A")); // 輸出null
    System.out.println(lst.get("B")); // 輸出 2
  }
}

 

 

再哈希法

設計多個哈希函數做爲備份,若是發當前的哈希函數的計算會草成衝突,那麼就選擇另外一個哈希函數進行計算,依次類推。這種方式不易產生鍵簇彙集的現象, 但會增長計算的時間
 
什麼是好的哈希函數
在介紹完了解決衝突的方式後,咱們再回過頭來看什麼是「好」的哈希函數, 一個「好」的哈希函數應該是均勻的, 即對於鍵的集合中的任意一個鍵,映射到哈希值集合中的的任意一個值的機率是相等的。
 
這樣的哈希函數的效果進一步表現爲兩個方面:
1. 當衝突能夠不發生的時候(如線性探測實現的哈希表),能儘量地減小衝突的發生
2. 當衝突不可避免地要發生的時候(如拉鍊法實現的哈希表), 能使不一樣的哈希值發生衝突的機率大體相等, 從而保證哈希表動態變化時仍能保持較爲良好的結構(各條鏈表的長度大體相等)
 
 
最後用一張圖總結下文章內容:
 
 

 

【完】
相關文章
相關標籤/搜索