哈希(hashtable)表,查找方式的顛覆者!


前言

這是這個系列的最後一篇文章,與哈希表(Hash table)結緣也是在分析JDK1.8以後HashMap源碼的時候,因爲HashMap一共用到了三種數據結構——鏈表、紅黑樹、哈希表。之後爲了學習紅黑樹,又把而二叉樹、二叉排序樹(搜索樹)複習了一遍,並促成了這個系列的文章。

一、比較查找方式

不論是數組、鏈表還是二叉樹、二叉排序樹(搜索樹)、紅黑樹,我們要找到其中特定的一個元素,方法只有一個那就是挨個比較直到找到爲止,這就造成了查找的時間複雜度總是與N有關係。

數組 鏈表 二叉樹 二叉排序樹 紅黑樹
查找 O(N) O(N) O(N) O(log N)~O(N) O(log N)

數組:假設數組中有N個元素,我們要找到其中一個特定的元素,通常要進過N/2次比較,所以時間複雜度上來說還是O(N)。如果數組是有序數組的話,相當於折半查找,此時的時間複雜度是O(log N)。

鏈表:同理與數組,假設有N個元素,要找到其中一個特定的元素,時間複雜度還是O(N)。

二叉樹:注意是二叉樹,左、右節點之間沒有大小關係,實在不明白,可以看看這篇文章二叉樹(從建樹、遍歷到存儲)Java。此時要從N個節點中找到特定的節點,時間複雜度是O(N)。

二叉排序樹:此時父節點與左、右子節點之間就有大小關係了。在節點分佈均勻的情況下相當於折半查找,所以時間複雜度是O(log N),一般情況下時間複雜度在O(N)到O(log N)之間。

紅黑樹:雖然紅黑樹在插入、刪除操作上很是麻煩,但是對於查找操作跟二叉排序樹是一模一樣的,因爲紅黑樹不過是加了平衡算法的二叉排序樹而已,二叉排序樹最基本的父節點與左、右子節點之間的大小關係肯定是滿足的,所以時間複雜度是O(log N)。

只看表達式的可能感覺不強烈,那我們假設N=1000000。

數組 鏈表 二叉樹 二叉排序樹 紅黑樹
查找 1000000 1000000 1000000 20~1000000 20

注意:有人可能會說,二叉排序樹這個跨度也太大了吧,嗯嗯,這也是爲什麼要用紅黑樹的原因。

二、查找方式顛覆者

看了比較查找方式的時間複雜度之後,哈希查找方式絕對稱得上是顛覆者,因爲他徹底跟N說拜拜~~~~ 使時間複雜度降到O(1)。

我們以存儲英語單詞dog、cat、pig、sheep爲例看看哈希表的工作機制。

數據結構

哈希表聽上去沒上面那些數據結構那麼直白,它其實是以數組爲基礎的。那我們就新建一個長度爲58的字符串數組。

存入

(1)計算下標:我們以a對應1,z對應26的編碼方式,計算上面四個單詞的下標,分別爲
dog = 4 + 15 + 7 = 26
cat = 3 + 1 + 20 = 24
sheet = 19 + 8 + 5 + 5 + 20 = 57
pig = 16 + 9 + 7 = 32

(2)存儲:之後我們就把單詞dog存入到數組下標爲26的單元中,其他三個單詞同理分別存儲到數組下標爲24、32、57的單元中。

取出

(1)計算下標:如果我們要取出cat,那就再用上面的算法,計算出在數組下標爲24的單元中,這樣就可以得到單詞cat了。

雞肋?

看了取出操作可能會有人有這樣的疑問,既然我都知道cat了,還計算cat在數組中的存儲下標,再取出cat?這什麼操作????
在這裏插入圖片描述

既然說到這裏那就再提出兩個問題,(1)爲什麼HashSet沒有get方法?;(2)爲什麼HashMap有get方法?

這兩種集合類都是基於hash表的。(1)HashSet沒有取出指定元素的方法,就是因爲它只有存入的操作,不會有取出的操作。不論你要取set集合中的哪個元素,你都必須把這個元素給我,我計算下標把這個元素找到,再返回給你。既然我都知道這個元素了,我還有你給我幹嘛?所以HashSet中不會有取出指定元素的方法,只能整體遍歷。

(2)HashMap則不同,它存儲的是鍵值對。在存入的時候根據計算得到存儲下標,存入鍵值對;取出的時候把給我,我計算下標找到對應的位置,把返回給你。

三、改良hash函數

什麼是哈希函數?
就是上面我們用來計算存儲下標的算式。但上面這種哈希函數明顯不及格,因爲非常浪費存儲空間,我們創建了一個長度爲58的哈希表卻只存了四個元素。

那我們改良一下,把相加得到的結果再取餘
dog = 4 + 15 + 7 = 26
cat = 3 + 1 + 20 = 24
sheet = 19 + 8 + 5 + 5 + 20 = 57
pig = 16 + 9 + 7 = 32

dog = 26 % 4 = 2
cat = 24 % 4 = 0
sheet = 57 % 4 = 1
pig = 32 % 4 = 0

這樣只用創建一個長度爲3的哈希表就可以存儲下這四個元素了。(上面再取餘後得到兩個0,這是哈希衝突,暫時不管,刻意地安排,爲了引入相關內容。)

哈希函數深入研究
不討論深入研究。哈希函數的算法設計是哈希表的精髓,既要不浪費存儲空間,又要避免哈希衝突,真的好難哦,我們難道真的要殫精竭力整起算法?參考一下JDK源碼就行了唄。下面取自 JDK1.8版本HashMap的源碼。

if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

tab是一個數組,其中“(n - 1) & hash”就是計算存儲下標的哈希函數。&運算符相當於取餘運算,具體參考這篇文章使用與運算符代替求餘運算符的技巧

四、哈希衝突

從上面的伏筆說起。在取餘後得到了兩個0,cat和pig都想存到下標爲0的單元格中,這就是哈希衝突。既然發生了衝突,那肯定有解決的辦法,主要有兩個,一是鏈地址法、二是開發尋址法。

1.鏈地址法

因爲HashMap中採用這種方式 ,所以我們重點介紹。

還是上面的例子我們有:
dog = 26 % 4 = 2
cat = 24 % 4 = 0
sheet = 57 % 4 = 1
pig = 32 % 4 = 0

鏈地址法解決衝突的方式就是在哈希表的每個單元格中設置鏈表,當有多個元素經過哈希函數計算後得到同一個存儲位置,只需要加到鏈表中即可。
在這裏插入圖片描述
第一步在哈希表中找到存儲位置的操作是常數級的,即時間複雜度是O(1),但是之後在鏈表中的相關操作卻是O(N)的,所以在JDK1.8之後版本的HashMap中引入了紅黑樹,當鏈表中的節點個數大於等於8個的時候,就將該鏈表轉換成紅黑樹以提高效率。

所以實際中的HashMap底層數據結構是這樣的。
在這裏插入圖片描述

2.開放尋址法

開放尋址法又包括三種解決衝突的方式:線性探測、二次探測、再哈希。

線性探測

還是上面的例子
dog = 26 % 4 = 2
cat = 24 % 4 = 0
sheet = 57 % 4 = 1
pig = 32 % 4 = 0

0是pig要存入的位置,它已經被cat佔用了,那就使用1,之後是2,依次類推,數組下標一直遞增,直到找到空位。這就叫做線性探測。

二次探測

在線性探測中,如果哈希函數計算的原始下標是x,線性探測就是x+1,x+2,x+3,依次類推。而在二次探測中,探測的過程是x+1,x+2的平方,x+3的平法,x+4的平法,依次類推。

再哈希

即用另一個哈希函數再計算一遍,並以這個值作爲探測的長度。

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