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

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

Word 這種文字編輯器你平時應該經常用吧,那你有沒有留意過它的拼寫檢查功能呢?一旦我們在 Word 裡輸入一個錯誤的英文單詞,它就會用標紅的方式提示“拼寫錯誤”。Word 的這個單詞拼寫檢查功能,雖然很小但卻非常實用。你有沒有想過,這個功能是如何實現的呢?

其實啊,一點兒都不難。只要你學完今天的內容,散列表(Hash Table)。你就能像微軟 Office 的工程師一樣,輕鬆實現這個功能。

雜湊思想

散列表的英文叫“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 之間的字串,該如何構造雜湊函式呢?我總結了三點雜湊函式設計的基本要求:

雜湊函式計算得到的雜湊值是一個非負整數;

如果 key1 = key2,那 hash(key1) == hash(key2);

如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

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

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

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

雜湊衝突

再好的雜湊函式也無法避免雜湊衝突。那究竟該如何解決雜湊衝突問題呢?我們常用的雜湊衝突解決方法有兩類,開放定址法(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)來表示空位的多少。

裝載因子的計算公式是:

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

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

  1. 連結串列法

連結串列法是一種更加常用的雜湊衝突解決辦法,相比開放定址法,它要簡單很多。我們來看這個圖,在散列表中,每個“桶(bucket)”或者“槽(slot)”會對應一條連結串列,所有雜湊值相同的元素我們都放到相同槽位對應的連結串列中。 在這裡插入圖片描述 當插入的時候,我們只需要通過雜湊函式計算出對應的雜湊槽位,將其插入到對應連結串列中即可,所以插入的時間複雜度是 O(1)。當查詢、刪除一個元素時,我們同樣通過雜湊函式計算出對應的槽,然後遍歷連結串列查詢或者刪除。那查詢或刪除操作的時間複雜度是多少呢?

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

解答開篇

有了前面這些基本知識儲備,我們來看一下開篇的思考題:Word 文件中單詞拼寫檢查功能是如何實現的?

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

當用戶輸入某個英文單詞時,我們拿使用者輸入的單詞去散列表中查詢。如果查到,則說明拼寫正確;如果沒有查到,則說明拼寫可能有誤,給予提示。藉助散列表這種資料結構,我們就可以輕鬆實現快速判斷是否存在拼寫錯誤。

內容小結

今天我講了一些比較基礎、比較偏理論的散列表知識,包括散列表的由來、雜湊函式、雜湊衝突的解決方法。

散列表來源於陣列,它藉助雜湊函式對陣列這種資料結構進行擴充套件,利用的是陣列支援按照下標隨機訪問元素的特性。散列表兩個核心問題是雜湊函式設計和雜湊衝突解決。雜湊衝突有兩種常用的解決方法,開放定址法和連結串列法。雜湊函式設計的好壞決定了雜湊衝突的概率,也就決定散列表的效能。

針對雜湊函式和雜湊衝突,今天我只講了一些基礎的概念、方法,下一節我會更貼近實戰、更加深入探討這兩個問題。

課後思考?

  1. 假設我們有 10 萬條 URL 訪問日誌,如何按照訪問次數給 URL 排序?

  2. 有兩個字串陣列,每個陣列大約有 10 萬條字串,如何快速找出兩個陣列中相同的字串?

解答:

  1. 假設我們有 10 萬條 URL 訪問日誌,如何按照訪問次數給 URL 排序?

遍歷 10 萬條資料,以 URL 為 key,訪問次數為 value,存入散列表,同時記錄下訪問次數的最大值 K,時間複雜度 O(N)。

如果 K 不是很大,可以使用桶排序,時間複雜度 O(N)。如果 K 非常大(比如大於 10 萬),就使用快速排序,複雜度 O(NlogN)。

  1. 有兩個字串陣列,每個陣列大約有 10 萬條字串,如何快速找出兩個陣列中相同的字串?

以第一個字串陣列構建散列表,key 為字串,value 為出現次數。再遍歷第二個字串陣列,以字串為 key 在散列表中查詢,如果 value 大於零,說明存在相同字串。時間複雜度 O(N)