關於散列表的一些思考

散列表(也叫Hash表)是一種應用較爲普遍的數據結構,幾乎全部的高級編程語言都內置了散列表這種數據結構。然而散列表在不一樣的編程語言中稱呼不同,在JavaScript中被稱爲對象,在Ruby中被稱爲哈希,而在Python中被稱爲字典。即使稱呼不一樣,語法不一樣,它們的原理基本相通。程序員

原理解析

在現代的編程語言中,幾乎都會有散列表的身影,故而難以忽視它爲程序員所帶來的種種便利性。散列跟數組是很類似的,較大的區別在於,數組直接經過特定的索引來訪問特定位置的數據,而散列表則是先經過散列函數來獲取對應的索引,而後再定位到對應的存儲位置。這是比較底層的知識了,通常的散列表,在底層都是經過數組來進行存儲,利用數組做爲底層存儲的數據結構最大的好處在於它的隨機訪問特性,無論數組有多長,訪問該數組的時間複雜度都是O(1)算法

固然要設計一個能用的散列表,在底層僅僅用普通的數組是不夠的,畢竟咱們須要存儲的不只僅是數值類型,還可能會存儲字符串爲鍵,字符串爲值,或者以字符串爲鍵,某個函數的指針爲值 (JavaScript就不少這種狀況)的鍵值對。在這類狀況中,咱們須要對底層的結點進行精心設計,纔可以讓散列表存儲更多元化的數據。編程

不管以何種數據類型爲鍵,咱們始終都須要有把鍵轉換成底層數組任意位置索引的能力,經過散列函數能夠作到這一點。散列函數是個很考究的東西,設計得很差可能會致使頻繁出現多個不一樣的鍵映射到同一個索引值的現象,這種現象稱之爲衝突,文章的後半部分會看到經常使用的一些解決衝突的方式。除此以外,每次爲散列表所分配的空間是有限的,隨着元素的插入,散列表會愈來愈滿,這時,衝突的概率就越高。故而,咱們須要按期對散列表進行擴張,並把已有的鍵值對從新映射到新的空間中去,讓散列表中的鍵值對更加分散,下降衝突的概率。這個過程被稱爲Resize。這個過程可以在必定程度上下降散列表的衝突概率,提升查找效率。數組

接下來會從散列函數,衝突解決,散列查找以及散列表Resize這幾個方面來詳細談談散列表的基本概念。bash

爲什麼不用鏈表來存儲鍵值對?

散列表本質上就是用來存儲鍵值對的,其實徹底能夠用一個鏈表來實現鍵值對存儲的功能,這樣就不會產生衝突了。舉個簡單的例子數據結構

typedef struct Node {
  char * key;
  char * value;
  struct Node * next;
} Node;
複製代碼

以上的結點可以存儲以字符串爲鍵,字符串爲值的鍵值對。假設徹底用鏈表來實現鍵值對存儲機制,那麼每次插入元素,咱們都得遍歷整個鏈表,對比每一個結點中的鍵。若是鍵已經存在的話則替換掉原來的值,若是遍歷到最後都不能找到相關的結點則建立新的結點並插入到散列的末端。查找的方式其實也相似,一樣是遍歷整個鏈表,查找到對應鍵值對則返回相關的值,當遍歷到散列最後都不能找到相關值的話則提示找不到對應結點。刪除操做跟插入操做相似,都須要遍歷鏈表,尋找待刪除的鍵值對。dom

這種實現方式雖然操做起來較爲簡便,也不會有衝突產生,不過最大的問題在於效率。不管是插入,查找仍是刪除都須要遍歷整個鏈表,最壞狀況下時間複雜度都是O(n)。假設咱們有一個至關龐大的鍵值對集合,這樣的時間成本是難以讓人接受的。爲此在存儲鍵值對的時候都會採用數組來做爲底層的數據結構,得益於它隨機訪問的特徵,不管最終的鍵值對集合多有龐大,訪問任意位置的時間複雜度始終爲O(1)。即使這種方式會有產生衝突的可能,但只要散列函數設計得當,Resize的時機合適,散列表訪問的時間複雜度都會保持在O(1)左右。編程語言

散列函數

散列函數在維基百科上的解釋是函數

散列函數(英語:Hash function)又稱散列算法、哈希函數,是一種從任何一種數據中建立小的數字「指紋」的方法。優化

在散列表中所謂的「指紋」其實就是咱們所分配的數組空間的索引值,不一樣類型的鍵須要設計不一樣的散列函數,它將會獲得一個數值不是太大的索引值。假設咱們用C語言來設計一個以字符串爲鍵,字符串爲值的散列表,那麼這個散列表的結點能夠被設計成

typedef struct Hash {
  char * key; // 指向散列的鍵
  char * value; // 指向散列的值
  char isNull; // 標識這個散列是否爲空
} Hash;
複製代碼

以這個結點爲基礎能夠簡單地建立一個長度爲7的散列表

Hash hash[7]
複製代碼

接下來就要設計對應的散列函數了,這個函數的目的是把字符串轉換成整型數值。字符串和整型最直接的關聯無非就是字符串的長度了,那麼咱們能夠設計一個最簡單的字符串散列函數

#include <string.h>

unsigned long simpleHashString(char * str) {
  return strlen(str);
}
複製代碼

不過這個散列函數有兩個比較大的問題

  1. 對一樣長度的字符串,它的散列值都是相同的。放在數組的角度上來看就是,它們所對應的數組索引值是同樣的,這種狀況會引起衝突。
  2. 以字符串爲鍵,當它的長度大於7的時候,它所對應的索引值會形成數組越界。

針對第一種狀況,能夠簡單描述爲,這個散列函數還不夠「散」。能夠進一步優化,把字符串中的每一個字節所對應的編碼值進行累加如何?

#include <string.h>

unsigned long sum(char * str) {
  unsigned long sum = 0;
  for (unsigned long i = 0; i < strlen(str); i++) {
    sum += str[i];
  }
  return sum;
}

unsigned long simpleHashString(char * str) {
  return strlen(str) + sum(str);
}
複製代碼

這樣彷佛就能夠獲得一個更「散」的地址了。接下來還須要考慮第二種狀況,索引值太大所形成的越界的問題。要解決越界的問題,最粗暴的方式就是對數組進行擴展。不過依照我前面寫的這個不太合格的散列函數,散列函數的值幾乎是隨着字符串的增加而線性增加。舉個例子

int main() {
  printf("%lu", simpleHashString("Hello World"));
}
複製代碼

打印結果是1063,這是一個至關大的值了。若是要按這個數值來分配內存的話那麼所須要的內存空間是sizeof(Hash) * 1063 => 25512個字節,大概是25KB,這顯然不太符合情理。故而須要換個思路去解決問題。爲了讓散列函數的值坐落在某個範圍內,採用模運算就能夠很容易作到這一點

unsigned long simpleHashStringWithMod(char * str) {
  return (strlen(str) + sum(str)) % 7;
}
複製代碼

這裏除數是7,故而散列函數simpleHashStringWithMod所生成索引值的取值範圍是0 <= hvalue < 7,剛好落在長度爲7的數組索引範圍內。若是要藉助這個散列函數來存儲鍵值對{'Ruby' => 'Matz', 'Java' => 'James'},那麼示意圖爲

Hash Table

這裏只是作個簡單的示範,長度爲7的散列表會顯得有點短,很容易就會產生衝突,接下來會談談解決衝突的一些方式。

衝突

前面咱們所設計的散列函數十分簡單,然而所分配的空間卻最多隻可以存儲7個鍵值對,這種狀況下很快就會產生衝突。所謂衝突就是不一樣的鍵,通過散列函數處理以後獲得相同的散列值。也就是說這個時候,它們都指向了數組的同一個位置。咱們須要尋求一些手段來處理這種衝突,現在用途比較普遍的就有開放地址法以及鏈地址法,且容我一一道來。

1. 開放地址法

開放地址法實現起來還算比較簡單,只不過是當衝突產生的時候經過某種探測手段來在原有的數組上尋找下一個存放鍵值對位置。若是下個位置也存有東西了則再用相同的探測算法去尋找下下個位置,直到可以找到合適的存儲位置爲止。目前經常使用的探測方法有

  1. 線性探測法
  2. 平方探測法
  3. 僞隨機探測法

不管哪一種探測方法,其實都須要可以保證對於同一個地址輸入,第n次探測到的位置老是相同的。

線性探測法很容易理解,簡單來說就是下一個索引位置,計算公式爲hashNext = (hash(key) + i) mod size。舉個直觀點的例子,目前散列表中索引爲5的位置已經有數據了。當下一個鍵值對也想在這個位置存放數據的時候,衝突產生了。咱們能夠經過線性探測算法來計算下一個存儲的位置,也就是(5 + 1) % 7 = 6。若是這個地方也已經有數據了,則再次運用公式(5 + 2) % 7 = 0,若是還有衝突,則繼續(5 + 3) % 7 = 1以此類推,直到找到對應的存儲位置爲止。很明顯的一個問題就是當數組越滿的時候,衝突的概率越高,越難找到合適的位置。用C語言來實現線性探測函數linearProbing結果以下

int linearProbing(Hash * hash, int address, int size) {
  int orgAddress = address;

  for (int i = 1; !hash[address].isNull; i++) {
    address = (orgAddress + i) % size; // 線性探測
  }
  return address;
}
複製代碼

只要散列表還沒全滿,它總會找到合適的位置的。平方探測法與線性探測法實際上是相似的,區別在於它每次所探測的位置再也不是原有的位置加上i,而是i的平方。平方探測函數quadraticProbing大概以下

int quadraticProbing(Hash * hash, int address, int size) {
  for (int i = 1; !hash[address].isNull; i++) {
    address = (address + i * i) % size; // 平方探測
  }
  return address;
}
複製代碼

上面兩個算法最大的特色在於,對於相同的地址輸入,總會按照一個固定的路線去尋找合適的位置,這樣之後要再從散列表中查找對應的鍵值對就有跡可循了。其實僞隨機數也有這種特性,只要隨機的種子數據是相同的,那麼每次獲得的隨機序列都是必定的。能夠利用下面的程序觀察僞隨機數的行爲

#include <stdio.h>
#include <stdlib.h>

int main() {
    int seed = 100;
    srand(seed);
    int value = 0;
    int i=0;
    for (i=0; i< 5; i++)
    {
        value =rand();
        printf("value is %d\n", value);
    }
}
複製代碼

僞隨機種子是seed = 100,這個程序不管運行多少次打印的結果老是一致的,在個人計算機上會打印如下數值

value is 1680700
value is 330237489
value is 1203733775
value is 1857601685
value is 594259709
複製代碼

利用這個特性,咱們就可以以僞隨機的機制來實現僞隨機探測函數randomProbing

int randomProbing(Hash *hash, int address, int size) {
  srand(address);
  while (!hash[address].isNull) {
    address = rand() % size;
  }
  return address;
}
複製代碼

不管採用哪一種方式,只要有相同的address輸入,都會獲得相同的查找路線。整體而言,用開放地址法來解決地址衝突問題,在不考慮哈希表Resize的狀況下,實現起來仍是比較簡單的。不過不難想到,它較大問題在於當散列表滿到必定程度的時候,衝突的概率會比較大,這種狀況下爲了找到合適的位置必需要進行屢次計算。另外還有個問題,就是刪除鍵值對的時候,咱們不能把鍵值對的數據簡單地「刪除」掉,並把當前位置設置成空。由於若是直接刪除並設置爲空的話會出現查找鏈中斷的狀況,任何依賴於當前位置所作的搜索都會做廢,能夠考慮另外維護一個狀態來標識當前位置是「空閒」的,代表它曾經有過數據,如今也接受新數據的插入。

PS: 在這個例子中,咱們能夠只利用isNull字段來標識不一樣狀態。用數值0來標識當前結點已經有數據了,用1來標識當前結點是空的,採用2來標識當前結點曾經有過數據,目前處於空閒狀態,而且接受新數據的插入。這樣就不會出現查找鏈中斷的狀況了。不過須要對上面的探測函數稍微作一些調整,這裏不展開說。

2. 鏈地址法

鏈地址法跟開放地址法的線性探測十分類似,最大的不一樣在於線性探測法中的下一個節點是在當前的數組上去尋找,而鏈地址法則是經過鏈表的方式去追加結點。實際上所分配數組的每個位置均可以稱之爲桶,總的來講,開放地址法產生衝突的時候,會去尋找一個新的桶來存放鍵值對,而鏈地址法則是依然使用當前的桶,可是會追加新結點增長桶的深度。示意圖大概以下

Link Table

可見它的結點結構是

typedef struct Hash {
  char * key; // 指向散列的鍵
  char * value; // 指向散列的值
  char isNull; // 標識這個散列是否爲空
  struct Hash * next; // 指向下一個結點
} Hash;
複製代碼

除了原來的數據字段以外,還須要維護一個指向下一個衝突結點的指針,實際上就是最開始談到的鏈表的方式。這種處理方式有個好處就是,產生衝突的時候,再也不須要爲了尋找合適的位置而進行大量的探測,只要經過散列函數找到對應桶的位置,而後遍歷桶中的鏈表便可。此外,利用這種方式刪除節點也是比較容易的。即使是採用了鏈地址法,到了必定時候仍是要對散列表進行Resize的,否則等桶太深的時候,依舊不利於查找。

3. 彙總

整體而言,採用開放地址法所須要的內存空間比較少,實現起來也相對簡單一些,當衝突產生的時候它是經過探測函數來查找下一個存放的位置。可是刪除結點的時候須要另外維護一個狀態,纔不至於查找鏈的中斷。鏈地址法則是經過鏈表來存儲衝突數據,這爲數據操做帶來很多便利性。然而,不管採用哪一種方式,都須要在恰當的時候進行Resize,纔可以讓時間複雜度保持在O(1)左右。

查找

瞭解如何插入,那麼查找也就不成問題了。對開放地址法而言,插入算法大概以下

  1. 經過散列函數計算出鍵所對應的散列值。
  2. 根據散列值從數組中找到相對應的索引位置。
  3. 若是這個位置是「空閒」的,則插入數據。若是該鍵值對已經存在了,則替換掉原來的數據。
  4. 若是這個位置已經有別的數據了,代表衝突已經產生。
  5. 經過特定的探測法,計算下一個能夠存放的位置。
  6. 返回第三步。

而查找算法是相似的

  1. 經過散列函數計算出鍵所對應的散列值。
  2. 根據散列值從數組中找到相對應的索引位置。
  3. 若是這個位置爲空的話則直接返回說找不到數據。
  4. 若是這個位置可以匹配當前查找的鍵,則返回須要查找的數據。
  5. 若是這個位置已經有別的數據,或者狀態顯示曾經有過別的數據,代表有衝突產生。
  6. 經過特定的探測法,計算下一個位置。
  7. 返回第三步。

鏈地址法其實也相似,區別在於插入鍵值對的時候若是識別到衝突,鏈地址法並不會經過必定的探測法來查找下一個存放數據的位置,而是順着鏈表往下搜索,增添新的結點,或者更新已有的結點。查找的時候則是沿着鏈表往下查找,找到目標數據則直接把結果返回。假設窮盡鏈表都沒法找到對應的數據,代表數據不存在。

重組(Resize)

Resize是專業術語,直接翻譯過來是從新設置大小,不過有些書籍會把它翻譯成重組,我以爲這個翻譯更爲貼切。當原有的散列表快滿的時候,其實咱們不只須要對原有的空間進行擴張,還須要用新的散列函數來從新映射鍵值對,讓鍵值對能夠更加分散地存儲。

不過不一樣的實現方式進行重組的時機不太同樣,對於開放地址法而言,每一個桶都只能存放一個鍵值對,須要經過特定的探測法把衝突數據存放到相關的位置中。當散列表越滿的時候衝突的概率就越大,要找到能夠存放數據的地方將會越艱難,這將不利於後續的插入和查找。爲此須要找到一個恰當的時機對散列表進行Resize。業界經過載荷因子來評估散列表的負載,載荷因子越大,衝突的可能性就越高。

載荷因子 = 填入表中的元素個數 / 散列表的長度

採用開放地址法來實現的散列表,當載荷因子超過某個閥值的時候就應當對散列表進行Resize了,不過閥值的大小則由設計者自行把控了。據維基百科上的描述,Java的系統庫把載荷因子的閥值設置爲0.75左右,超過這個值則Resize散列表。

而採用鏈地址法實現散列的時候,利用鏈表的特性,每一個桶都可以存放多個結點,所以在鏈地址法中經過桶的深度來評估一個散列是否須要Resize更有意義。你能夠當桶的最大深度超過某個值的時候對原有的散列進行Resize。

不管採用哪一種實現方式,Resize的時機還須要設計者自行把控,不一樣的應用場景Resize的時機也可能會有所不一樣。Resize操做可讓咱們原有的鍵值對數據更加分散,讓散列表插入和查找的時間複雜度保持在O(1)左右。然而Resize畢竟是耗時操做,時間複雜度隨着鍵值對數據的增加而增加,所以不宜操做得過於頻繁。

總結

這篇文章主要從原理層面闡述了一個散列表的實現方式,總結了實現一個散列表須要注意的一些事項。須要設計一個較好的散列函數,讓鍵值對更加分散以減小衝突的可能。然而不少時候衝突難以免,咱們須要一些手段來解決衝突,還要在恰當的時候進行Resize。通常而言,散列表的底層是採用了可以隨機訪問的數據結構,只要散列表中的鍵值對足夠分散,就可以把時間複雜度控制在O(1)左右。

相關文章
相關標籤/搜索