1. 程式人生 > 其它 >散列表(上):Word文件中的單詞拼寫檢查功能是如何實現的?

散列表(上):Word文件中的單詞拼寫檢查功能是如何實現的?

雜湊思想

散列表的英文叫“Hash Table”,我們平時也叫它“雜湊表”或者“Hash 表”,你一定也經常聽過它,我在前面的文章裡,也不止一次提到過,但是你是不是真的理解這種資料結構呢?

散列表用的是陣列支援按照下標隨機訪問資料的特性,所以散列表其實就是陣列的一種擴充套件,由陣列演化而來。可以說,如果沒有陣列,就沒有散列表。

我用一個例子來解釋一下。假如我們有 89 名選手參加學校運動會。為了方便記錄成績,每個選手胸前都會貼上自己的參賽號碼。這 89 名選手的編號依次是 1 到 89。現在我們希望程式設計實現這樣一個功能,通過編號快速找到對應的選手資訊。你會怎麼做呢?

我們可以把這 89 名選手的資訊放在數組裡。編號為 1 的選手,我們放到陣列中下標為 1 的位置;編號為 2 的選手,我們放到陣列中下標為 2 的位置。以此類推,編號為 k 的選手放到陣列中下標為 k 的位置。

因為參賽編號跟陣列下標一一對應,當我們需要查詢參賽編號為 x 的選手的時候,我們只需要將下標為 x 的陣列元素取出來就可以了,時間複雜度就是 O(1)。這樣按照編號查詢選手資訊,效率是不是很高?

實際上,這個例子已經用到了雜湊的思想。在這個例子裡,參賽編號是自然數,並且與陣列的下標形成一一對映,所以利用陣列支援根據下標隨機訪問的時候,時間複雜度是 O(1) 這一特性,就可以實現快速查詢編號對應的選手資訊。

假設校長說,參賽編號不能設定得這麼簡單,要加上年級、班級這些更詳細的資訊,所以我們把編號的規則稍微修改了一下,用 6 位數字來表示。比如 051167,其中,前兩位 05 表示年級,中間兩位 11 表示班級,最後兩位還是原來的編號 1 到 89。這個時候我們該如何儲存選手資訊,才能夠支援通過編號來快速查詢選手資訊呢?

思路還是跟前面類似。儘管我們不能直接把編號作為陣列下標,但我們可以擷取參賽編號的後兩位作為陣列下標,來存取選手資訊資料。當通過參賽編號查詢選手資訊的時候,我們用同樣的方法,取參賽編號的後兩位,作為陣列下標,來讀取陣列中的資料。

這就是典型的雜湊思想。其中,參賽選手的編號我們叫做鍵(key)或者關鍵字。我們用它來標識一個選手。我們把參賽編號轉化為陣列下標的對映方法就叫作雜湊函式(或“Hash 函式”“雜湊函式”),而雜湊函式計算得到的值就叫作雜湊值(或“Hash 值”“雜湊值”)。

通過這個例子,我們可以總結出這樣的規律:散列表用的就是陣列支援按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。我們通過雜湊函式把元素的鍵值對映為下標,然後將資料儲存在陣列中對應下標的位置。當我們按照鍵值查詢元素時,我們用同樣的雜湊函式,將鍵值轉化陣列下標,從對應的陣列下標的位置取資料


雜湊函式

從上面的例子我們可以看到,雜湊函式在散列表中起著非常關鍵的作用。現在我們就來學習下雜湊函式。

雜湊函式,顧名思義,它是一個函式。我們可以把它定義成** hash(key)**,其中 key 表示元素的鍵值,hash(key) 的值表示經過雜湊函式計算得到的雜湊值。

那第一個例子中,編號就是陣列下標,所以 hash(key) 就等於 key。改造後的例子,寫成雜湊函式稍微有點複雜。我用虛擬碼將它寫成函式就是下面這樣:

int hash(String key) {
  // 獲取後兩位字元
  string lastTwoChars = key.substr(length-2, length);
  // 將後兩位字元轉換為整數
  int hashValue = convert lastTwoChas to int-type;
  return hashValue;
}

剛剛舉的學校運動會的例子,雜湊函式比較簡單,也比較容易想到。但是,如果參賽選手的編號是隨機生成的 6 位數字,又或者用的是 a 到 z 之間的字串,該如何構造雜湊函式呢?我總結了三點雜湊函式設計的基本要求

  1. 雜湊函式計算得到的雜湊值是一個非負整數;
  2. 如果 key1 = key2,那 hash(key1) == hash(key2);
  3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

我來解釋一下這三點。其中,第一點理解起來應該沒有任何問題。因為陣列下標是從 0 開始的,所以雜湊函式生成的雜湊值也要是非負整數。第二點也很好理解。相同的 key,經過雜湊函式得到的雜湊值也應該是相同的。

第三點理解起來可能會有問題,我著重說一下。這個要求看起來合情合理,但是在真實的情況下,要想找到一個不同的 key 對應的雜湊值都不一樣的雜湊函式,幾乎是不可能的。即便像業界著名的MD5、SHA、CRC等雜湊演算法,也無法完全避免這種雜湊衝突。而且,因為陣列的儲存空間有限,也會加大雜湊衝突的概率。

所以我們幾乎無法找到一個完美的無衝突的雜湊函式,即便能找到,付出的時間成本、計算成本也是很大的,所以針對雜湊衝突問題,我們需要通過其他途徑來解決。


雜湊衝突

再好的雜湊函式也無法避免雜湊衝突。那究竟該如何解決雜湊衝突問題呢?我們常用的雜湊衝突解決方法有兩類,開放定址法(open addressing)和連結串列法(chaining)。

1. 開放定址法

開放定址法的核心思想是,如果出現了雜湊衝突,我們就重新探測一個空閒位置,將其插入。那如何重新探測新的位置呢?我先講一個比較簡單的探測方法,線性探測(Linear Probing)。

當我們往散列表中插入資料時,如果某個資料經過雜湊函式雜湊之後,儲存位置已經被佔用了,我們就從當前位置開始,依次往後查詢,看是否有空閒位置,直到找到為止。

我說的可能比較抽象,我舉一個例子具體給你說明一下。這裡面黃色的色塊表示空閒位置,橙色的色塊表示已經儲存了資料。

從圖中可以看出,散列表的大小為 10,在元素 x 插入散列表之前,已經 6 個元素插入到散列表中。x 經過 Hash 演算法之後,被雜湊到位置下標為 7 的位置,但是這個位置已經有資料了,所以就產生了衝突。於是我們就順序地往後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,於是我們再從表頭開始找,直到找到空閒位置 2,於是將其插入到這個位置。

在散列表中查詢元素的過程有點兒類似插入過程。我們通過雜湊函式求出要查詢元素的鍵值對應的雜湊值,然後比較陣列中下標為雜湊值的元素和要查詢的元素。如果相等,則說明就是我們要找的元素;否則就順序往後依次查詢。如果遍歷到陣列中的空閒位置,還沒有找到,就說明要查詢的元素並沒有在散列表中。

散列表跟陣列一樣,不僅支援插入、查詢操作,還支援刪除操作。
對於使用線性探測法解決衝突的散列表,刪除操作稍微有些特別。我們不能單純地把要刪除的元素設定為空。這是為什麼呢?

還記得我們剛講的查詢操作嗎?在查詢的時候,一旦我們通過線性探測方法,找到一個空閒位置,我們就可以認定散列表中不存在這個資料。但是,如果這個空閒位置是我們後來刪除的,就會導致原來的查詢演算法失效。本來存在的資料,會被認定為不存在。這個問題如何解決呢?

這種情況 就會使得 線性檢測出現錯誤 ,使得元素占上了被刪除的坑上!而錯失了自己原本的位置。

我們可以將刪除的元素,特殊標記為 deleted。當線性探測查詢的時候,遇到標記為 deleted 的空間,並不是停下來,而是繼續往下探測。

你可能已經發現了,線性探測法其實存在很大問題。當散列表中插入的資料越來越多時,雜湊衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久。極端情況下,我們可能需要探測整個散列表,所以最壞情況下的時間複雜度為 O(n)。

同理,在刪除和查詢時,也有可能會線性探測整張散列表,才能找到要查詢或者刪除的資料。

對於開放定址衝突解決方法,除了線性探測方法之外,還有另外兩種比較經典的探測方法,二次探測(Quadratic probing)和雙重雜湊(Double hashing)。

所謂二次探測,跟線性探測很像,線性探測每次探測的步長是 1,那它探測的下標序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探測探測的步長就變成了原來的“二次方”,也就是說,它探測的下標序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

所謂雙重雜湊,意思就是不僅要使用一個雜湊函式。我們使用一組雜湊函式 hash1(key),hash2(key),hash3(key)……我們先用第一個雜湊函式,如果計算得到的儲存位置已經被佔用,再用第二個雜湊函式,依次類推,直到找到空閒的儲存位置。

不管採用哪種探測方法,當散列表中空閒位置不多的時候,雜湊衝突的概率就會大大提高

為了儘可能保證散列表的操作效率,一般情況下,我們會儘可能保證散列表中有一定比例的空閒槽位

我們用裝載因子(load factor)來表示空位的多少。

裝載因子的計算公式是:
散列表的裝載因子=填入表中的元素個數/散列表的長度

裝載因子越大,說明空閒位置越少,衝突越多,散列表的效能會下降。


2. 連結串列法

連結串列法是一種更加常用的雜湊衝突解決辦法,相比開放定址法,它要簡單很多。我們來看這個圖,在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條連結串列,所有雜湊值相同的元素我們都放到相同槽位對應的連結串列中

  • 當插入的時候,我們只需要通過雜湊函式計算出對應的雜湊槽位,將其插入到對應連結串列中即可,所以插入的時間複雜度是 O(1)。

  • 當查詢、刪除一個元素時,我們同樣通過雜湊函式計算出對應的槽,然後遍歷連結串列查詢或者刪除。那查詢或刪除操作的時間複雜度是多少呢?

實際上,這兩個操作的時間複雜度跟連結串列的長度 k 成正比,也就是 O(k)。對於雜湊比較均勻的雜湊函式來說,理論上講,k=n/m,其中 n 表示雜湊中資料的個數,m 表示散列表中“槽”的個數。


Word 文件中單詞拼寫檢查功能是如何實現的?

常用的英文單詞有 20 萬個左右,假設單詞的平均長度是 10 個字母,平均一個單詞佔用 10 個位元組的記憶體空間,那 20 萬英文單詞大約佔 2MB 的儲存空間,就算放大 10 倍也就是 20MB。對於現在的計算機來說,這個大小完全可以放在記憶體裡面。所以我們可以用散列表來儲存整個英文單詞詞典。