散列表的原理與實現

本文主要介紹散列表(Hash Table)這一常見數據結構的原理與實現。由于個人水平有限,文章中難免存在不準確或是不清晰的地方,希望大家可以指正:)

概述

符號表是一種用于存儲鍵值對(key-value pair)的數據結構,我們平常經常使用的數組也可以看做是一個特殊的符號表,數組中的“鍵”即為數組索引,值為相應的數組元素。也就是說,當符號表中所有的鍵都是較小的整數時,我們可以使用數組來實現符號表,將數組的索引作為鍵,而索引處的數組元素即為鍵對應的值,但是這一表示僅限于所有的鍵都是比較小的整數時,否則可能會使用一個非常大的數組。散列表是對以上策略的一種“升級”,但是它可以支持任意的鍵而并沒有對它們做過多的限定。對于基于散列表實現的符號表,若我們要在其中查找一個鍵,需要進行以下步驟:

  • 首先我們使用散列函數將給定鍵轉化為一個“數組的索引”,理想情況下,不同的key會被轉為不同的索引,但在實際應用中我們會遇到不同的鍵轉為相同的索引的情況,這種情況叫做碰撞。解決碰撞的方法我們后面會具體介紹。
  • 得到了索引后,我們就可以像訪問數組一樣,通過這個索引訪問到相應的鍵值對。

以上就是散列表的核心思想,散列表是時空權衡的經典例子。當我們的空間無限大時,我們可以直接使用一個很大的數組來保存鍵值對,并用key作為數組索引,因為空間不受限,所以我們的鍵的取值可以無窮大,因此查找任何鍵都只需進行一次普通的數組訪問。反過來,若對查找操作沒有任何時間限制,我們就可以直接使用鏈表來保存所有鍵值對,這樣把空間的使用降到了最低,但查找時只能順序查找。在實際的應用中,我們的時間和空間都是有限的,所以我們必須在兩者之間做出權衡,散列表就在時間和空間的使用上找到了一個很好的平衡點。散列表的一個優勢在于我們只需調整散列算法的相應參數而無需對其他部分的代碼做任何修改就能夠在時間和空間的權衡上做出策略調整。

散列函數

介紹散列函數前,我們先來介紹幾個散列表的基本概念。在散列表內部,我們使用桶(bucket)來保存鍵值對,我們前面所說的數組索引即為桶號,決定了給定的鍵存于散列表的哪個桶中。散列表所擁有的桶數被稱為散列表的**容量(capacity)。

現在假設我們的散列表中有M個桶,桶號為0到M-1。我們的散列函數的功能就是把任意給定的key轉為[0, M-1]上的整數。我們對散列函數有兩個基本要求:一是計算時間要短,二是盡可能把鍵分布在不同的桶中。對于不同類型的鍵,我們需要使用不同的散列函數,這樣才能保證有比較好的散列效果。
我們使用的散列函數應該盡可能滿足均勻散列假設,以下對均勻散列假設的定義來自于Sedgewick的《算法》一書:

(均勻散列假設)我們使用的散列函數能夠均勻并獨立地將所有的鍵散布于0到M – 1之間。

以上定義中有兩個關鍵字,第一個是均勻,意思是我們對每個鍵計算而得的桶號有M個“候選值”,而均勻性要求這M個值被選中的概率是均等的;第二個關鍵字是獨立,它的意思是,每個桶號被選中與否是相互獨立的,與其他桶號是否被選中無關。這樣一來,滿足均勻性與獨立性能夠保證鍵值對在散列表的分布盡可能的均勻,不會出現“許多鍵值對被散列到同一個桶,而同時許多桶為空”的情況。
顯然,設計一個較好的滿足均勻散列假設的散列函數是不容易的,好消息是通常我們無需設計它,因為我們可以直接使用一些基于概率統計的高效的實現,比如Java中許多常用的類都重寫了hashCode方法(Object類的hashCode方法默認返回對象的內存地址),用于為該類型對象返回一個hashCode,通常我們用這個hashCode除以桶數M的余數就可以獲取一個桶號。下面我們以Java中的一些類為例,來介紹一下針對不同數據類型的散列函數的實現。

String類的hashCode方法

String類的hashCode方法如下所示:

public int hashCode() { 
  int h = hash; 
  if (h == 0 && value.length > 0) { 
    char val[] = value; 
    for (int i = 0; i < value.length; i++) { 
      h = 31 * h + val[i]; 
    } 
    hash = h; 
  } 
  return h;
}

hashCode方法中的value是一個char[]數組,存儲中字符串的的每字符。我們可以看到在方法的最開始我們會把hash賦給h,這個hash就表示之前計算的hashCode,這樣以來若之前已經計算過這個字符串對象的hashCode,這次我們就無需再計算了,直接返回之前計算過得即可。這種把hashCode緩存的策略只對不可變對象有效,因為不可變對象的hashCode是不會變的。
根據上面的代碼我們可以知道,若h為null,意味著我們是第一次計算hashCode,if語句體中就是hashCode的具體計算方法。假設我們的字符串對象str包含4個字符,ck表示的是字符串中的第k個字符(從0開始計數),那么str的hashCode就等于:31 * (31 * (31 * c0 + c1) + c2) +c3。

數值類型的hashCode方法

這里我們以Integer和Double為例,介紹一下數值類型的hashCode方法的一般實現。
Integer類的hashCode方法如下:

public int hashCode() { 
  return Integer.hashCode(value);
}
public static int hashCode(int value) { 
  return value;
}

其中value表示Integer對象所包裝的整型值,所以Integer類的hashCode方法僅僅是簡單的返回了自身的值。

我們再來看一下Double類的hashCode方法:

@Override
public int hashCode() { 
  return Double.hashCode(value);
}
public static int hashCode(double value) { 
  long bits = doubleToLongBits(value); 
  return (int)(bits ^ (bits >>> 32));
}

我們可以看到Double類的hashCode方法首先會將它的值轉為long類型,然后返回低32位和高32位的異或的結果作為hashCode。

Date類的hashCode方法

前面我們介紹的數據類型都可以看做一種數值型(String可以看做一個整型數組),那么對于非數值類型對象的hashCode要怎么計算呢,這里我們以Date類為例簡單的介紹一下。Date類的hashCode方法如下:

public int hashCode() { 
  long ht = this.getTime(); 
  return (int) ht ^ (int) (ht >> 32);
}

我們可以看到,它的hashCode方法的實現非常簡單,只是返回了Date對象所封裝的時間的低32位和高32位的異或結果。從Date類的hashCode的實現我們可以了解到,對于非數值類型的hashCode的計算,我們需要選取一些能區分各個類實例的實例域來作為計算的因子。比如對于Date類來說,通常具有相同的時間的Date對象我們認為它們相等,因此也就具有相同的hashCode。這里我們需要說明一下,對于等價的兩個對象(也就是調用equals方法返回true),它們的hashCode必須相同,而反之則不然。

由hashCode獲取桶號

前面我們介紹了計算對象hashCode的一些方法,那么我們獲取了hashCode之后,如何進一步得到桶號呢?一個直接的辦法就是直接拿得到的hashCode除以capacity(桶的數量),然后用所得的余數作為桶號。不過在Java中,hashCode是int型的,而Java中的int型均為有符號,所以我們要是直接使用返回的hashCode的話可能會得到一個負數,顯然桶號是不能為負的。所以我們先將返回的hashCode轉變為一個非負整數,再用它除以capacity取余數,作為key的對應桶號,具體代碼如下:

private int hash(K key) { return (x.hashCode() & 0x7fffffff) % M;} 

現在我們已經知道了如何通過一個鍵獲取桶號,那么接下來我們來介紹使用散列表查找的第二步——處理碰撞。

使用拉鏈法處理碰撞

使用不同的碰撞處理方式,我們便得到了散列表的不同實現。首先我們要介紹的是使用拉鏈法來處理碰撞的散列表的實現。以這種方式實現的散列表,每個桶里都存放了一個鏈表。初始時所有鏈表均為空,當一個鍵被散列到一個桶時,這個鍵就成為相應桶中鏈表的首結點,之后若再有一個鍵被散列到這個桶(即發生碰撞),第二個鍵就會成為鏈表的第二個結點,以此類推。這樣一來,當桶數為M,散列表中存儲的鍵值對數目為N時,平均每個桶中的鏈表包含的結點數為N / M。因此,當我們查找一個鍵時,首先通過散列函數確定它所在的桶,這一步所需時間為O(1);然后我們依次比較桶中結點的鍵與給定鍵,若相等則找到了指定鍵值對,這一步所需時間為O(N / M)。所以查找操作所需的時間為O(N / M),而通常我們都能夠保證N是M的常數倍,所以散列表的查找操作的時間復雜度為O(1),同理我們也可以得到插入操作的復雜度也為O(1)。

理解了以上的描述,實現基于拉鏈法的散列表也就很容易了,這里簡單起見,我們直接使用前面的SeqSearchList作為桶中的鏈表,參考代碼如下:

public class ChainingHashMap<K, V> { 
  private int num; //當前散列表中的鍵值對總數 
  private int capacity; //桶數 
  private SeqSearchST<K, V>[] st; //鏈表對象數組 
  
  public ChainingHashMap(int initialCapacity) { 
    capacity = initialCapacity; 
    st = (SeqSearchST<K, V>[]) new Object[capacity]; 
    for (int i = 0; i < capacity; i++) { 
      st[i] = new SeqSearchST<>(); 
    } 
  } 
  
  private int hash(K key) { 
    return (key.hashCode() & 0x7fffffff) % capacity; 
  } 
  
  public V get(K key) { 
      return st[hash(key)].get(key); 
  }

  public void put(K key, V value) { 
    st[hash(key)].put(key, value); 
  }
} 

在上面的實現中,我們固定了散列表的桶數,當我們明確知道我們要插入的鍵值對數目最多只能到達桶數的常數倍時,固定桶數是完全可行的。但是若鍵值對數目會增長到遠遠大于桶數,我們就需要動態調整桶數的能力。實際上,散列表中的鍵值對數與桶數的比值叫做負載因子(load factor)。通常負載因子越小,我們進行查找所需時間就越短,而空間的使用就越大;若負載因子較大,則查找時間會變長,但是空間使用會減小。比如,Java標準庫中的HashMap就是基于拉鏈法實現的散列表,它的默認負載因子為0.75。HashMap實現動態調整桶數的方式是基于公式loadFactor = maxSize / capacity,其中maxSize為支持存儲的最大鍵值對數,而loadFactor和capacity(桶數)都會在初始化時由用戶指定或是由系統賦予默認值。當HashMap中的鍵值對的數目達到了maxSize時,就會增大散列表中的桶數。
以上代碼中還用到了SeqSearchST,實際上這就是一個基于鏈表的符號表實現,支持向其中添加key-value pair,查找指定鍵時使用的是順序查找,它的代碼如下:

public class SeqSearchST<K, V> { 
  private Node first; 
  
  private class Node { 
    K key; 
    V val; 
    Node next; 
    public Node(K key, V val, Node next) { 
      this.key = key; 
      this.val = val; 
      this.next = next; 
    } 
  } 

  public V get(K key) { 
    for (Node node = first; node != null; node = node.next) { 
      if (key.equals(node.key)) { 
        return node.val; 
      } 
    } 
    return null; 
  } 

  public void put(K key, V val) { 
    //先查找表中是否已存在相應key 
    Node node; 
    for (node = first; node != null; node = node.next) { 
      if (key.equals(node.key)) { 
        node.val = val; 
        return; 
      } 
    } 
    //表中不存在相應key 
    first = new Node(key, val, first); 
  }
}

使用線性探測法處理碰撞

基本原理與實現

線性探測法是另一種散列表的實現策略的具體方法,這種策略叫做開放定址法。開放定址法的主要思想是:用大小為M的數組保存N個鍵值對,其中M > N,數組中的空位用于解決碰撞問題。

線性探測法的主要思想是:當發生碰撞時(一個鍵被散列到一個已經有鍵值對的數組位置),我們會檢查數組的下一個位置,這個過程被稱作線性探測。線性探測可能會產生三種結果:

  • 命中:該位置的鍵與要查找的鍵相同;
  • 未命中:該位置為空;
  • 該位置的鍵和被查找的鍵不同。

當我們查找某個鍵時,首先通過散列函數得到一個數組索引后,之后我們就開始檢查相應位置的鍵是否與給定鍵相同,若不同則繼續查找(若到數組末尾也沒找到就折回數組開頭),直到找到該鍵或遇到一個空位置。由線性探測的過程我們可以知道,若數組已滿的時候我們再向其中插入新鍵,會陷入無限循環之中。

理解了以上原理,要實現基于線性探測法的散列表也就不難了。這里我們使用數組keys保存散列表中的鍵,數組values保存散列表中的值,兩個數組同一位置上的元素共同確定一個散列表中的鍵值對。具體代碼如下:

public class LinearProbingHashMap<K, V> { 
  private int num; //散列表中的鍵值對數目 
  private int capacity; 
  private K[] keys; 
  private V[] values; 

  public LinearProbingHashMap(int capacity) { 
    keys = (K[]) new Object[capacity]; 
    values = (V[]) new Object[capacity]; 
    this.capacity = capacity; 
  } 

  private int hash(K key) { 
    return (key.hashCode() & 0x7fffffff) % capacity; 
  } 
  
  public V get(K key) { 
    int index = hash(key); 
    while (keys[index] != null && !key.equals(keys[index])) { 
      index = (index + 1) % capacity; 
    } 
    return values[index]; //若給定key在散列表中存在會返回相應value,否則這里返回的是null 
  }
  
 public void put(K key, V value) { 
    int index = hash(key); 
    while (keys[index] != null && !key.equals(keys[index])) { 
      index = (index + 1) % capacity; 
    } 
    if (keys[index] == null) { 
      keys[index] = key; 
      values[index] = value; return; 
    } 
    values[index] = value; num++; 
  }
}

動態調整數組大小

在我們上面的實現中,數組的大小為桶數的2倍,不支持動態調整數組大小。而在實際應用中,當負載因子(鍵值對數與數組大小的比值)接近1時,查找操作的時間復雜度會接近O(n),而當負載因子為1時,根據我們上面的實現,while循環會變為一個無限循環。顯然我們不想讓查找操作的復雜度退化至O(n),更不想陷入無限循環。所以有必要實現動態增長數組來保持查找操作的常數時間復雜度。當鍵值對總數很小時,若空間比較緊張,可以動態縮小數組,這取決于實際情況。

要實現動態改變數組大小,只需要在上面的put方法最開始加上一個如下的判斷:

if (num == capacity / 2) { 
  resize(2 * capacity); 
}

resize方法的邏輯也很簡單:

private void resize(int newCapacity) { 
  LinearProbingHashMap<K, V> hashmap = new LinearProbingHashMap<>(newCapacity); 
  for (int i = 0; i < capacity; i++) { 
    if (keys[i] != null) { 
      hashmap.put(keys[i], values[i]); 
    } 
  } 
  keys = hashmap.keys; 
  values = hashmap.values; 
  capacity = hashmap.capacity; 
}

關于負載因子與查找操作的性能的關系,這里貼出《算法》(Sedgewick等)中的一個結論:

在一張大小為M并含有N = a*M(a為負載因子)個鍵的基于線性探測的散列表中,若散列函數滿足均勻散列假設,命中和未命中的查找所需的探測次數分別為:~ 1/2 * (1 + 1/(1-a))和~1/2*(1 + 1/(1-a)^2)

關于以上結論,我們只需要知道當a約為1/2時,查找命中和未命中所需的探測次數分別為1.5次和2.5次。還有一點就是當a趨近于1時,以上結論中的估計值的精度會下降,不過我們在實際應用中不會讓負載因子接近1,為了保持良好的性能,在上面的實現中我們應保持a不超過1/2。

參考資料

《算法(第四版)》(Sedgewick等)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,250評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,923評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,041評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,475評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,253評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,801評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,882評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,023評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,530評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,494評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,639評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,177評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,890評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,289評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,552評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,242評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,626評論 2 370

推薦閱讀更多精彩內容