算法圖解 第5章 散列表

本章內容

學習散列表--------最有用的基本數據結構之一。散列表用途廣泛,本章將介紹其常見的用途。

學習散列表的內部機制:實現、衝突和散列函數。這將幫助你理解如何分析散列表的性能。

假設你在一家雜貨店上班。有顧客來 顧客來 買東西時,你得在一箇中查找價格。如果本子的內容不是按字母順序排列的,你臺能爲查找蘋果(apple)的價格而瀏覽每一行,這需要很長的時間。比時你使用的是第1章介紹的簡單查找,需要瀏覽每一行。還記得這需要多長時間嗎?O(n)。如果本子的內容是按字母順序排列的,可使用二分查找來找出蘋果的價格,這需要的時間更短,爲O(log n)。

需要提醒你的是,運行時間O(n)和O(log n)之間有天壤之別!假設你每秒能夠看10行。使用簡單查找和二分查找所需要的時間將如下。

你知道,二分查找的速度非常快。但作爲收銀員,在本子中查找價格是件很痛苦的事情,哪怕本子的內容是有序的。在查找價格時,你都能感覺到顧客的怒氣。看來真的需要一名能夠記住所有商品價格的僱員,這樣你就不用查找了:問好就能馬上知道答案。

不管商品有多少,這位僱員(假設好的名字爲Maggie)報出任何商品的價格的時間都爲O(1)。速度比二分查找都快。

真是太厲害了!如何聘到這樣的僱員呢?

下面從數據結構的角度來看看。前面介紹兩種數據結構:數組和鏈表(其實還有棧,但棧並不能用於查找)。你可使用數組來實現記錄商品價格本子。

這種數組的每個元素包含兩項內容:商品名和價格。如果將這個數組按商品名排序,就可使用二分查找在其中查找商品的價格。這樣查找價格的時間將爲O(log n)。然而,你希望查找商品價格的時間爲O(1),即你希望查找速度像Maggie那麼快,這是散列函數的用武之地。

5.1 散列函數

散列函數是這樣的函數,即無論你給它什麼數據,它都還你一個數字。

如果用專業術語表達的話,我們會說,散列函數“將輸入映射到數字”。你可能認爲散列函數輸出的數字沒什麼規律,但其實散列函數必須滿足一些要求。

綜必須是一致的。例如,假設你輸入apple時得到的是4,那麼每次輸入apple時,得到的都必須爲4 。如果不是這樣,散列表將毫無用處。

它就將不同的輸入映射到不同的數字。例如,如果一個散列函數不管輸入是什麼都返回1,這就不是好的散列函數。最理想的情況是,將不同的輸入映射到不同的數字。

散列函數將輸入映射爲數字,這有使用途呢?你可以使用它來打造你的"Maggie"!

爲此,首先創建一個空數組。

你將在這個數組中存儲商品的價格。下面來將蘋果的價格加入到這人數組中。爲此,將apple作爲輸入交給散列函數。

散列函數的輸出爲3,因此我們將蘋果的價格存儲到數組的索引3處。

下面將牛奶(milk)的價格存儲到數組中。爲此,將milk作爲散列函數的輸入。

散列函數的輸出爲0,因此我們將牛奶的價格存儲在索引0處。

 

不斷的重複這個過程,最終整個數組將填滿價格。

現在假設需要知道鱷梨(avocado)的價格。你無需在數組中查找,只需將avocado作爲輸入將給散列函數。

它將告訴你鱷梨的價格存儲在索引4處。果然,你在那裏找到了。

散列函數準確地指出了價格的存儲位置,你根本不用查找!之所以能夠這樣,具體原因如下。

散列函數總是將同樣的輸入映射到相同的索引。每次你輸入avocado,得到的都是同一個數字。因此,你可首先使用它來確定將鱷梨的價格存儲在什麼地方,並在以後使用它來確定鱷梨的價格存儲在什麼地方。

散列函數將不同的輸入映射到不同的索引。avocado映射到索引4,milk映射到索引0。每種商品都映射到數組的不同位置,讓你能夠將其價格存儲到這裏。

散列函數知道數組有多大,只返回有效的索引。 如果數組包含5個元素,散列函數就不會返回無效索引100。

剛纔你就打造了一個"Maggie"!你結合使用散列函數和數組創建了一種被稱爲散列表(hash table)的數組結構。散列表是你學習的第一種包含額外邏輯的數據結構。數組和鏈表都被直接映射到內存,但散列表更復雜,它使用散列函數來確定元素的存儲位置。

在你學習的複雜數據結構中,散列表可能是最有用的,也被爲散列映射、映射、字典和關聯數組。散列表的速度很快!還記得第2章關於數組和鏈表的討論嗎?你可以立即獲取數組中的元素,而散列表也使用數組來存儲數據,因此其獲取元素的速度與數組一樣快。

你中能根本不需要要自己去實現散列表,任一優秀的語言都提供了散列表實現。Python提供的散列表實現爲字典,你何使用函數dict來創建散列表。

創建散列表book後,在基中添加一些商品的價格。

# coding=utf-8
book = dict()

book["apple"] = 0.67   #一個蘋果的價格是67美分
book["milk"] = 1.49     #牛奶的價格爲1.49美元
book["avocado"] = 1.49
print book

{'avocado': 1.49, 'apple': 0.67, 'milk': 1.49}

非常簡單!我們來查詢鱷梨的價格。

print book["avocado"]

1.49

 

散列表由鍵和值組成。在前面的散列表book中,鍵爲商品名,值爲商品價格。散列表將鍵映射到值。

在下一節中,你將看到一此散列表使用示例。

5.2 應用案例

5.2.1 將散列表用於查找

手機都 內置了方便的電話簿,其中每個姓名都有對應的電話號碼。

假設你要創建一個類似這樣的電話簿,將姓名映射到電話號碼。該電話簿需要提供 如下功能。

添加聯繫人及其電話號碼。

通過輸入聯繫人來獲悉其電話號碼。

這非常適合使用散列表來實現!在下述情況下,使用散列表是很不錯的選擇。

創建映射。

查找。

創建電話簿非常容易。首先,新建一個散列表。

phone_book = dict()

順便說一句,Python提供了一種創建散列表的快捷方式----使用一對大括號。

phone_book = {}        #與phone_book = dict()等效

下面在這個電話簿中添加一些聯繫人的電話號碼。

phone_book["jenny"] = 8675309
phone_book["emergency"] = 911

這就成了!現在,假設你要查找Jenny的電稿號碼,爲此只需向散列表傳入相就的鍵。

print phone_book["jenny"]

如果要求你使用數組來創建電話簿,你將如何做呢?散列表讓你能夠輕鬆地模擬映射關係。

散列表被用於大海撈針式的查找。vkwg,wq在訪問像http://adit.io這樣的網站時,計算機必須將adit.io轉換爲IP地址。

無論你訪問哪個網站,其網址都必須轉換爲IP地址。

這不是將網址映射到IP地址嗎?好像非常適合使用散列表!這個過程被稱爲DNS解析(DNS resolution)。散列表是提供這種功能的方式之一。

5.2.2 防止重複

假設你負責管理一個投票站。顯然,每個只能投一票,但如何避免重重投票呢?有人來投票時,你詢問他的全名,並將其與已投票者名單進行比對。

如果名字在名單中,就說明這個人投過票了,因此將他拒之門外!否則,就將他的姓名加入到名單中,並讓他投票。現在 假設有很多人來投過票,因此名單非常長。

每次有人來投票時,你都得瀏覽這個長長的名單,以確定他是否投過票。但有一種更好的辦法,那就是使用散列表!

爲此,首先創建一個散列表,用於記錄已投票的人。

voted = {}

有人來投票時,檢查他是滯在散列表中。

value = voted.get("tom")

如果“tom”在散列表中,函數get將返回它;否則返回None。我可使用這個函數檢查來投票的人是否投過票!

代碼如下:

def check_voter(name):
    if voted.get(name):
        print "kick them out!"
    else:
        voted[name] = True
        print "let them vote!"

我們來測試幾次。

check_voter("tom")
check_voter("mike")
check_voter("mike")
check_voter("tom")

let them vote!
let them vote!
kick them out!
kick them out!

首先來投票的是Tom,上述代碼打印let them vote!。 接着Mike來投票,打印的也是let then vote!。然後,Mike又來投票,於是打印的就是Kick them out!。

別戽了,如果你將已投票者的姓名存儲在列表中,這個函數的速度終將變得非常慢,因爲它必須使用簡單查找搜索整個列表。但這裏將它們存儲在散列表,而散列表讓你能夠迅速知道來投票的人是否投過票。使用散列表來檢查是否重複,速度非常快。

 

5.2.3將散列表用於緩存

5.2.4

模擬映射關係

防止重複

緩存/記住數據,以免服務器再通過處理來生成它們。

 

5.3  衝突

前面說過,大多數語言都提供了散列表實現,你不用知道如何實現它們。有鑑於此,我就不再過多地討論散列表的內部原理,但你依然需要考慮性能!要明白散列表的性能,你得先搞清楚什麼是衝突。本節和一下節將分別介紹衝突和性能。

首先,我撒了一個善意的謊。我之前告訴你的是,散列函數將不同的鍵映射到數組的不同位置。

實際上,幾乎不可能編寫出這樣的散列函數。我們來看一個簡單的示例。假設你有一個數組,它包含26個位置。

而你使用的散列函數非常簡單,它按字母表順序分配數組的位置。

你可能已經看出了問題。如果你要將蘋果的價格存儲到散列表中,分配給你的是第一個位置。

接下來,你要將香蕉的價格存儲到散列表中,分配給你的是第二個位置。

一切順利!但現在你要將鱷梨的價格存儲到散列表中,分配給你的又是第一個位置。

不好,這個位置已經存儲了蘋果的價格!怎麼辦?這種情況被稱爲衝突(collision):給兩個鍵分配的位置相同。這是個問題。如果你將鱷梨的價格存儲到這個位置,將覆蓋蘋果的價格,以後現查詢蘋果的價格時,得到的將是鱷梨的價格!衝突很糟糕,必鬚鬚要避免。處理衝突方式很多,最簡單的辦法如下:如果兩個鍵映射到了同一個位置,就在這個位置存儲一個鏈表。

在這個例子中,apple和avocado 映射到了同一個位置,因此在這個位置存儲一個鏈表。在需要查詢香蕉的體系時,速度依然很快。但在需要查詢蘋果的價格時,速度要慢些:你必須在相就的鏈表中找到apple .如果這個鏈表很短,也沒什麼大不了----只需搜索三四個元素。但是,假設你工作的雜貨店只銷售名稱以字母A打頭的商品。

等待!除第一個位置外,整個散列表都是空的,而第一個位置包含了一個很長的列表!換言這,這個散列表中的所有元素都在這個鏈表中,這與一開始就將所有元素存儲到一個鏈表中一樣糟糕:散列表的速度會很慢。

這裏的經驗教訓有兩個。

散列函數很重要。前面的散列函數將所有的鍵都映射到一個位置,而最理想的情況是,散列函數將鍵均勻地映射到散列表的不同位置。

如果散列表存儲的鏈表很長,散列表的速度將急劇下降。然而,如果使用的散列函數很好,這些鏈表表不會很長!

散列函數很重要,發的散列函數很少導致衝突。那麼,如何選擇好的散列函數呢?這將在下一節介紹。

5.4 性能 

本單開頭是假設你在雜貨店工作。你想打造一人很遺憾你能夠迅速獲悉商品價格的工具,而散列表的速度確實很快。

在平均情況下,散列表執行各種操作時間都爲O(1)。O(1)被稱爲常量時間。你以前沒有見過常量時間,它並不意味着馬上,而是說不管散列表多大,所需要的時間都相同。例如,你知道的,簡單查找的運行時間爲線性時間。

二分查找的速度更快,所需要時間爲對數時間。

在散列表中查找所花費的時間爲常量時間。

一條水平線,看到了吧?這意味着無論散列表包含一個元素還是10億個元素,從其中獲取數據所需要的時間都相同。實際上,你以前見過常量時間----從數組中獲取一個元素所需的時間就是固定的:不管數組多大。從中獲取一個元素所需要的時間都是相同的。在平均情況下,散列表的速度確定很快。

在最糟情況下,散列表所有操作的運行時間都爲O(n)----線性時間,這真的很慢。我們來將散列表同數組和鏈表比較一下。

在平均情況下,散列表的查找(獲取給定索引處的值)速度與數組一樣快,而插入和刪除速度與鏈表一樣快,因此它兼具兩者的優點!但在最糟情況下,散列表的各種操作的速度都很慢。因此,在使用散列表時,避開最糟情況至關重要。爲此,需要避免衝突。而要避免衝突,需要有:

較低的填裝因子:

良好的散列函數。

5.4.1 填裝因子

散列表的填裝因子很容易計算。

散列表使用數組來存儲數據,因此你需要計算數組中被佔用的位置數。例如,下述散列表的填裝因子爲2/5,即0.4。

下面這個散列表的填裝因子爲多少呢?

如果你的答案爲1/3,那就對了。填裝因子度量的是散列表中有多少位置是空的。

假設你要在散列表中存儲100種商品的價格,而該散列表包含100個位置。那麼在最佳情況下,每個商品都將有自己的位置。

這個散列表的填裝因子爲1。如果這個散列表只有50個位置呢?填充因子將爲2.不可能讓每種商品都有自己的位置,因爲沒有足夠的位置!填裝因子大於1意味着商品數量超過了數組的位置數。一旦填裝因子開始增大,你就需要在散列表中添加位置,這被稱爲調整長度(resizing)。例如,假設有一個像下面這樣相當滿的散列表時。

你就需要調整它的長度。爲此,你首先創建一個更長的新數組:通常將數組增長一倍。

接下來,你需要使用函數hash將所有的元素都插入到這個新的散列表中。

這個新散列表的填裝因子爲3/8 ,比原來低多了!填裝因子 越低,發生衝突的可能性越小,散列表的性能越高。一個不錯的經難規則是:一旦填裝因子大於0.7,就調整散列表的長度。

你可能在想,調整散列表長度的工作需要很長野間!你說得沒錯,調整長度的開銷很大,因此你不會希望頻繁這樣做。但平均而言,即便考慮到調整長度所需要的時間,散列表操作所需要 的時間也爲O(1)。

5.4.2良好的散列函數

良好的散列函數讓數組中的值呈均勻分佈。

糟糕的散列函數讓值扎堆,導致大量的衝突。

什麼樣的散列函數良好的呢?你根本不用操心----天塌下來的高個子頂着。如果好奇,可研究一下SHA函數.你可將它用作散列函數。

5.5 小結

你幾乎根本不用自己去實現散列表,因爲你使用的編程語言提供了散列表實現。你可使用Python提供的散列表,並假定能夠獲得平均情況下的性能:常量時間。

散列表是一種功能強大的數據結構,其操作速度快,還能讓你以不同的方式建立數據模型。你可能很快會發現自己經常在使用綜。

你可以結合散列函數和數組來創建散列表。

衝突很糟糕,你應使用可以最大限度減少衝突的散列函數。

散列表的查找、插入和刪除速度都非常快。

散列表適合用於模擬映射關係。

一旦填裝因子超過0.7,就該調整散列表的長度。

散列表可用於緩存數據(例如,在Web服務器上)。

散列表非常適合用於防止重複。

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章