數據結構與算法_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之間的字符串,該如何構造散列函數呢?我總結了三點散列函數設計的基本要求

  1. 散列函數計算得到的散列值是一個非負整數;

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

  3. 如果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)來表示空位的多少。

裝載因子的計算公式是:

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

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

2.鏈表法

鏈表法是一種更加常用的散列沖突解決辦法,相比開放尋址法,它要簡單很多。我們來看這個圖,在散列表中,每個“桶(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萬條字符串,如何快速找出兩個數組中相同的字符串?

歡迎留言和我分享,我會第一時間給你反饋。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM