寫這篇文章的時候,是大年三十,原本應該和家人一塊兒看春節聯歡晚會的,可是看了一個小時感受沒有什麼勁,我想今年春晚又會被吐槽吧。哈哈哈哈。書歸正傳,仍是按照咱們的老規矩,走起。(PS:原本是早就應該寫完的文章,發現本身仍是太懶。到了如今真正的寫完。檢討檢討....)git
在寫這篇文章以前,看了不少關於HashMap解析的文章。對於大多數人來講,可了跟着別人的文章走一遍。你們都能瞭解HashMap的內部結構,使用方法以及注意事項。我仍是以爲知道用是一回事。知道原理是另外一回事。只有瞭解了其數據結構設計初衷。才能更好的使用它。 此係列文章主要分爲兩個部分,具體目錄以下:程序員
其中第一篇是帶領着你們理解爲何會設計此種數據結構,及其碰見的問題及解決辦法。我相信經過閱讀這篇文章後,你再去理解HashMap,我相信你會有一種豁然開朗的感受。建議先閱讀第一部分。github
哈希表是咱們程序員開發者常常會使用到的數據結構。咱們都知道是其主要用於映射(鍵值對)關係的數據。哈希表在查找、刪除、添加數據方面效率都比較高。既然哈希表有如此多的優勢,那麼我就帶着你們從哈希表實際應用例子出發,經過相應例子,帶領你們完全的瞭解哈希表的使用情景及其遇到的問題,以及相應的解決方法。算法
哈希表(Hash table,也叫散列表),是根據關鍵值(Key value)而直接進行訪問的數據結構。也就是說,它經過把關鍵鍵值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫作哈希函數,存放記錄的數組叫作哈希表。數組
上文提到了兩個比較重要的知識點。哈希表是基於數組且經過哈希函數來構建映射關係。接下來咱們經過生活中的幾個例子,來了解一下哈希表在實際使用中會出現的問題以及解決方案。bash
假如如今咱們要作一個學校的學生信息記錄。這個學校大概有1000人。學生的記錄信息包括學號、年齡、性別等信息。假設學生的學號是從零開始的有序自增加,那麼若是要求咱們須要從快速檢索某一個學生的信息。那咱們應該使用什麼樣的數據結構呢?數據結構
咱們可能首先想到的就是數組。即數組下標對應着相應學生信息,具體數據結構以下圖所示: app
若是咱們須要找到Jennifer這個學生。咱們只須要經過數組下標拿到相應信息就好了。數據結構和算法
Student andy = StudentArray[2];
複製代碼
若是咱們須要增長一個Jack學生,咱們只須要在數組的末尾添加新添加的學生信息。函數
StudentArray[storeNumber++] = new Student("Jack");
複製代碼
咱們發現經過上述結構設計,咱們能很快的找到某個學生,或者刪除一個學生,由於學生的信息是與學號進行關聯的。同時每一個學生的學號與數組的下標是相對應的。經過數組下標的操做。咱們就能完成咱們想要的數據操做。固然上述狀況只是理想的數據狀況,咱們能夠直接經過將學號做爲數組的下標來做爲鍵值對的映射關係。實際開發常見中,咱們並不能遇到如此良好的數據映射關係的。
上文描述了理想狀況下的數據映射關係,下面咱們來看看**「不良好」**的數據關係。
假如,咱們但願在咱們的程序中存儲100000個單詞,若是咱們考慮每一個單詞都佔據一個數組單元,那麼咱們就須要建立一個容量爲100000的數組,經過上述步驟,咱們能快速的對單詞進行存儲。可是數組下標與單詞有什麼關係呢?咱們如何能快速的找到某個單詞的位置呢?
由於數組中的數組單元與單詞是沒有關係的。爲了達到映射關係,咱們能夠經過ASCII的編碼思想來解決相應的問題,咱們都知道ASCII 碼使用指定的7 位或8 位二進制數組合來表示128 或256 種可能的字符。其中ASCII包含了全部的全部的大寫和小寫字母,數字0 到九、標點符號。其中小寫英文字母的對應的十進制範圍是97-122。那麼咱們能夠採用簡單的編碼方式, 從字母a到z進行依次從1遞增進行編碼。
例如單詞 abandon
其中
a = 1
b = 2
a = 1
n = 14
d = 4
o = 15
n = 14
求和 1+2+1+14+4+15+14 = 51
那麼咱們能夠將abandon放入下標爲51的數組中。
這樣就能直接經過words數組下標進行訪問了,代碼以下:
Stirng words = words[51];
複製代碼
可是經過這種方式來存儲單詞,會出現一個問題。假如咱們規定單詞的最大長度爲10,那麼對應的單詞求的和就有260種可能,而咱們總共的單詞有100000個,那麼每一個數組單元要存儲大約380個單詞(10000除以260),那麼咱們能夠考慮的是數組單元使用子數組或者鏈表的方式來存儲數據,可是每一個數組單元有380個單詞,在對數據進行操做的時候,效率是否是很低下呢?因此咱們能不能想一個辦法。讓每一個數組單元的存儲數據個數儘可能減少。讓數組單元存儲的數據儘可能分散呢?
由於直接使用簡單編碼進行相加的方式會致使產生的數組下標較小(數據比較集中),數組單元個數太多的狀況,因此咱們採用冪的方式。
仍是使用單詞abandon
其中
a = 10^0+1
b = 10^1+2
a= 10^2+1
n= 10^3+14
d = 10^4+4
o = 10^5+15
n = 10^6+14
求和 1+12+101+1014+10004+100015+1000014 =1111161
那咱們是否是就能夠直接將abandon放入下標爲1111161的數組中?
不要忘了咱們的單詞的最大長度是10。那麼咱們數組中的最大下標爲:
10^0+10^1+10^2+10^3+10^4+10^5+10^6+10^7+10^8+10^9
算都不用算,咱們知道這是很大的數,咱們不可能申請這麼大容量的數組。
經過以上分析,咱們若是若是採用第一種方案的話,產生數組的下標比較少(數據比較集中),若是使用第二種方案產生的數組下標會更多(數據比較分散),且申請了沒必要要的空間。那麼爲了將第二種方案的下標範圍進行壓縮,那麼咱們該使用什麼樣的解決方法呢?繼續往下看。
經過一種算法將一個大範圍的數字轉化一個小範圍的數字,這個算法對應的函數稱爲哈希函數
如何將一個大範圍的數字區間轉換成一個小範圍的數字區間,咱們經常使用的方式是取餘(也叫取模操做)。
咱們都知道對於給定任意一個整數p,任意一個整數n,必定存在不等式:
n= kp+r
其中k、r是整數,且r大於等於0小於p ,r爲n除以p的餘數。
既然咱們已經知道了一個數(n)在除以另外一個數(p)是餘數的取值範圍(大於0且小於p減去1)。 那麼咱們把一個範圍是0~199(bigerRange)的數據壓縮到0~9(smallerRange)的範圍。咱們能夠進行以下操做:
對應的僞代碼爲:
smallerRange = bigerRange % 10;
arrayIndex = smallerRange;//arrayIndex 表明哈希化操做後,數據對應的數組下標
複製代碼
同理對應咱們上述提到的單詞存儲,咱們也能夠進行以下操做。
smallerRange = bigerRange % arraySize;
複製代碼
通過取餘操做後,咱們如今已經將單詞從一個較大的範圍壓縮到了一個小的範圍,可是細心的讀者確定會發現。假如經過這種方式進行單詞的存儲,假如某個單詞和另外一個單詞進行冪操做後,進行取餘的值是相同的,那麼就會出現衝突的問題。也就是同一數組下標中存儲了兩份不一樣的數據。 列如上圖中,數組中words[196]與words[6]。
既然出現了衝突的問題,通常咱們會採用兩種方式,第一種方式是找到數組的一個空位,並把這個單詞填入,第二種方法建立一個存儲鏈表的數組,數組內部不存儲單詞,產生的衝突的數據直接添加到這個數組下標所對應的鏈表的下一個節點。這兩種方法分別對應着咱們下文要講的開發地址法與鏈地址法。
當數據不能直接放入由哈希函數計算出來的下標對應相應的數組單元,咱們須要獲取數組中的其餘的位置。根據獲取新位置的計算方式的不一樣,開發地址法分爲了三種方法。線性探測、二次探測、再哈希化。下面咱們就來具體來說講這三種方式的分別實現以及一些問題。
線性探測是在產生衝突時,咱們就順勢下推,尋找數組中空白的地址。列如,當前咱們須要存儲單詞abandon,可是當前0下標對應的數組單元已經存儲了數據(a)。那麼咱們就嘗試使用1標,若是1下標對應的數組單元也一樣存儲了數據(apple),那麼咱們繼續判斷數組下標2。這樣經過依次遞增的方式去尋找可以進行存儲的數據單元。具體實現以下圖所示:
對應添加元素僞代碼爲:
public void insert(int key ,Word word){
int hashVal = hashMethod(key);//經過hash函數計算獲得對應的數組下標
while(words[hashVal]!=null){
++hashVal;//對角標進行遞增
hashVal %=words.size();
}
words[hashVal]=word;//找到空數據單元,進行賦值操做
}
複製代碼
可是聰明的你,確定會發現一個問題,就是當我咱們的數據越插入的愈來愈多的時候,哈希表會變得愈來愈臃腫,這致使咱們在插入新的元素的時候,會探測很長一段距離。當數組填的越滿時,彙集就越可能發生。具體問題以下圖所示:
對應添加元素僞代碼爲:
public void insert(int key ,Word word){
int hashVal = hashMethod(key);//經過hash函數計算獲得對應的數組下標
int step=0;
while(words[hashVal]!=null){
step = Math.pow(step,2);//獲取步長
hashVal+=step;
hashVal %=words.size();
step++;
}
words[hashVal]=word;//找到空數據單元,進行賦值操做
}
複製代碼
從上圖來看,若是咱們與數組0(a)產生衝突的時候,咱們須要線性的向下尋找空白單元。當咱們的數組數據存儲比例(當前數組存儲數據與數組容量的比例,也能夠叫作裝填因子)較高時。那麼咱們查詢空白單元。所耗的時間也比較長。(這裏先不討論數組擴容的問題,下面咱們才討論擴容。)
上面咱們討論了,在使用線性探測時會出現彙集的問題,當數據量大時,查詢空白數據單元的次數也會相應的增長。爲了減小這種彙集的問題,咱們能夠採用二次探測。二次探測的原理就是儘可能探測相對較遠的數據單元,而不探測相鄰的數據單元。
在線性探測中,若是經過計算得到的數組下標爲x,則對應的線性探測步長就是x+1,x+2,x+3,那麼在二次探測中,探測的步長爲:x+1^2,x+2^2,x+3^2,也就是x+1,x+4,x+9。具體實現以下圖所示:
二次探測雖然消除了線性探測中產生的彙集問題,可是又出現了更細的彙集問題,出現這種更細的彙集問題是由於多個數據通過計算後,得到相同的數組下標,在探測空的數據單元的時候,所尋找的數據單元是相同的。 如如今咱們須要將a,apple,abandon,access等4個單詞插入哈希表中,假如它們計算後的數組下標都是同樣的。那麼假如已經插入a單詞,那麼當apple插入時(假設查詢步長爲1後,直接插入成功),所走的步長爲1,abandon會先走1的步長,而後再走4的步長(假設走了4的步長後,直接插入成功),那麼當access進行插入的時候,它會判斷1,4對應步長下,是否能夠插入數據,很明顯當abandon與access進行插入的時候,他們都判斷了1步長對應的數據。
爲了解決線性探測與二次探測帶來的彙集問題。咱們還可使用再哈希法,從上文咱們已經瞭解了,二次探測出現彙集問題的緣由是由於所探測的步長是固定的。解決這個問題的最好辦法就是是步長是變化的就好了。那麼咱們就能夠另外一哈希函數(用另外一哈希函數的緣由是,咱們要限定始終在數組範圍內進行查詢)根據關鍵字(key),來動態的計算步長就好了。
注意:
那麼修改咱們上面的僞代碼:
public void insert(int key ,Word word){
int hashVal = hashMethod(key);//經過hash函數計算獲得對應的數組下標
int step= hashStep(key);
while(words[hashVal]!=null){
hashVal+=step;
hashVal %=words.size();
}
words[hashVal]=word;//找到空數據單元,進行賦值操做
}
//牛逼的計算步長的方法,其中constant是質數 且小於數組容量, 那麼步長的範圍爲大於等於0小於等constant
public int hashStep(Key key){
return constant -(key%constant);
}
複製代碼
注意:這裏又有同窗要問了,爲何要使用質數,咱們都知道質數在在大於1的天然數中,除了1和它自己之外再也不有其餘因數。試想,若是當前咱們的數組長度爲10,經過哈希計算後的數組下標是0,且咱們計算後的步長爲5,那麼探測序列就是,0,5,10,0,5,10。程序會一直探測直到崩潰。
上面咱們介紹了開放地址法,它們共同點是在數組中尋找空的數據單元進行新的數據插入,而鏈地址法是在每一個數據單元中設置鏈表,當發生衝突時,直接將新的數據添加到鏈表中。具體實現以下圖所示:
由於鏈地址法是基於鏈表的,它是不須要進行探測序列的。由於咱們能夠直接將元素放在對應末尾。
上面咱們討論了,開放地址法與鏈地址法。試想一種狀況,當咱們的數組快滿時,增刪查數據會變得很慢(由於要去探測空的數據單元),這個時候咱們就須要對數組進行擴展,擴展的時機是什麼呢?
還記得咱們上文提到的裝填因子(當前數組存儲數據與數組容量的比例),咱們不可能等到數組快滿時,才進行擴展操做,由於會影響效率。因此咱們通常狀況下會在裝填因子大於或等於0.75的狀況下進行數組的擴展(裝填因子過小,擴展頻率太快,裝填因子太大,影響數據操做效率)。
注意:
這裏確定有不少同窗要問,我爲啥不能複製到相同的位置上呢?若是你還記得,數據的位置咱們是經過哈希函數來計算的,也就是咱們對數組長度進行取餘操。假如在新數組中咱們須要對某個數據進行查找的時候。由於不是不一樣的數組長度。那麼計算的位置確定不一樣。咱們就會找不到它,可是它又確實在數組中存在。因此就會形成數據混亂的狀況。
站在巨人的肩膀上。能夠看得更遠。 《Java數據結構和算法》第二版
最後,附上我寫的一個基於Kotlin 仿開眼的項目SimpleEyes(ps: 其實在我以前,已經有不少小朋友開始仿這款應用了,可是我以爲要作就作好。因此個人項目和其餘的人應該不一樣,不只僅是簡單的一個應用。可是,可是。可是。重要的話說三遍。還在開發階段,不要打我),歡迎你們follow和start.