散列表(也叫哈希表),是根據(jù)鍵而直接訪問在內(nèi)存存儲位置的數(shù)據(jù)結(jié)構(gòu)。在這篇文章中,我們將介紹散列表的基本原理。通過了解散列表的基本原理,有助于我們理解散列表的工作方式。
1. 介紹
散列表是一種通過與其所包含的元素相關(guān)聯(lián)的對象來訪問元素的容器,這種與元素相關(guān)聯(lián)的對象在散列表中被稱為“鍵”。也就是說,對散列表中元素的添加、查找、修改、刪除等操作必須通過與元素所對應(yīng)的鍵才能進行。因此,散列表中的元素所對應(yīng)的鍵在同一個散列表對象中是唯一且不可變的,即同一個散列表中不會出現(xiàn)兩個相同的鍵;并且在元素確定的情況下,其對應(yīng)的鍵是不可變的。
散列表提供了一種高效的元素使用方式,在理想情況下 [1],訪問元素的時間復(fù)雜度能夠達到O(1)。
2. 基本原理
散列表中存儲的每一個元素都會有唯一一個與之對應(yīng)的鍵,形成一一對應(yīng)的關(guān)系 [2],散列表對某個元素的操作都是通過元素所對應(yīng)的鍵進行,元素在散列表結(jié)構(gòu)中存儲的位置也是由其對應(yīng)的鍵所決定的。
在添加元素時,散列表會先通過一個確定的散列函數(shù) [3],將需要添加的元素所對應(yīng)的鍵轉(zhuǎn)化為一個整數(shù)值,這個數(shù)值可用于確定散列表結(jié)構(gòu)內(nèi)的某個位置,一旦存儲的位置確定后,散列表將把這個需要添加的元素以及其對應(yīng)的鍵以鍵值對的形式存儲在這個位置。
元素添加后,若需要訪問這個元素,就必須向散列表提供與需要訪問的元素對應(yīng)鍵,散列表仍然會通過那個確定的散列函數(shù),將這個指定的鍵轉(zhuǎn)化為一個整數(shù)值,即可根據(jù)這個數(shù)值找到散列元素所存儲的位置。
散列表之所以可這種方式訪問元素,就是其散列函數(shù)能夠保證:對于同一個指定的鍵,在確定的散列表結(jié)構(gòu)中將總是得到同一個數(shù)值。
3. 數(shù)據(jù)結(jié)構(gòu)
為了能夠高效存取,散列表使用了順序結(jié)構(gòu)與鏈?zhǔn)浇Y(jié)構(gòu)混合的方式來實現(xiàn) [4]。散列表的數(shù)據(jù)結(jié)構(gòu)大致如圖 1。其中黑色部分為鏈?zhǔn)浇Y(jié)構(gòu),棕色部分為順序結(jié)構(gòu)。
圖 1 中的棕色部分的順序結(jié)構(gòu)在每一個散列表對象有且僅有一個,它在散列表中被稱為“表”。
之所以采用這樣的結(jié)構(gòu)是有幾個原因,第一,元素的鍵通過散列函數(shù)所轉(zhuǎn)化出的整數(shù),經(jīng)過計算后直接對應(yīng)于順序結(jié)構(gòu)中的某個位置,可實現(xiàn)高效的隨機存??;第二,如果不同的鍵經(jīng)過散列函數(shù)計算后得到了相同的值,則需要使用鏈?zhǔn)浇Y(jié)構(gòu)來表示這些元素在邏輯上被放置于同一個位置。
一旦明白散列表的數(shù)據(jù)結(jié)構(gòu)后,也就不難明白散列表訪問元素時的流程了:
1. 將指定的鍵通過散列函數(shù)轉(zhuǎn)化為一個整數(shù)值 n。
2. 使用這個得到的數(shù)值 n 計算出一個表中的合法位置 x ,訪問這個 x 的位置。
3. 如果表中的 x 位置沒有任何對象,則說明散列表中目前并沒有指定鍵所對應(yīng)的元素。
4. 如果表中的 x 位置有指針指向某個對象,那么這個對象一定是一個鏈?zhǔn)浇Y(jié)構(gòu)的起始。依次檢查鏈?zhǔn)浇Y(jié)構(gòu)的每一個節(jié)點,如果有某一個節(jié)點的鍵與指定的鍵一致,則說明找到元素;如果沒有找到,則說明散列表中目前并沒有指定鍵所對應(yīng)的元素。
4. 元素記錄
散列表中的每一個元素必須擁有一個與之對應(yīng)的鍵,并且在發(fā)生散列沖突的時候,這些元素還要能夠自發(fā)組織為鏈?zhǔn)浇Y(jié)構(gòu)。為了滿足這些要求,散列表的元素以及其對應(yīng)的鍵通常被放置于一種結(jié)構(gòu)體中,這種結(jié)構(gòu)體在散列中被稱作“記錄” [5],如圖 2。
其中,value 為散列表的元素,key 為這個元素對應(yīng)的鍵,next 指向下一個發(fā)生散列沖突的記錄。如果將圖 2 與圖 1 聯(lián)系起來看的話,就是圖 1 中的每一個黑色矩形,其細節(jié)就如圖 2 所示。
5. 散列值與索引
現(xiàn)在已經(jīng)知道了散列表中數(shù)據(jù)結(jié)構(gòu)的形式,那么接下來思考這么兩個問題:1. 散列表如何將指定的鍵轉(zhuǎn)化為整數(shù);2. 如果將散列表的表視作一個數(shù)組,散列表是如果將這個整數(shù)轉(zhuǎn)化為其合法的索引下標(biāo)。
明白了第一個問題,也就明白了什么是散列值。通常,某個對象一旦創(chuàng)建之后,它在內(nèi)存中的地址將是不變的,如果某些語言的內(nèi)存模型并不是這樣,那么它一定會提供某種標(biāo)識一個對象的方式。這種標(biāo)識對象的方式可以將之視為一串二進制值,不論其原本表示的意義是什么,二進制值都可以按照整數(shù)的方式去解析它,最終,總是能夠得到一個整數(shù)值。
明白了第二個問題,也就明白了如何通過散列值得到散列表的表索引 [6]。散列表在設(shè)計上巧妙的利用了按位與運算。二進制的按位與運算是在相同的位上如果兩個值同為 1,則得到 1,否則得到 0 。例如 1010 & 0011 = 0010、111 & 101 = 101。如果以十進制書寫,就是 10 & 3 = 2、7 & 5 = 5,也就是有這樣一個特點: A & B 如果將A、B視為無符號整數(shù),其結(jié)果一定大于等于 0 且小于等于 A 且小于等于 B。一個在區(qū)間 [0, n]的整數(shù),可作為長度為 n+1 的數(shù)組的合法索引。推出:一個長度為 n+1 的數(shù)組,用其長度減 1 的值(n + 1 - 1 = n)去和任意一個二進制數(shù)進行與運算,得到的結(jié)果一定是這個數(shù)組的合法索引。同時,為了能夠最大限度的利用二進制的每一位,最好的方式就是 n 的結(jié)果轉(zhuǎn)化為二進制后每一位都是 1 ,這樣的 n 就是 1, 3, 7, 15, 31...,那么 n+1 就是 2, 4, 8, 16, 32...。[7]
6. 散列沖突
前面章節(jié)中,已經(jīng)知道了散列表根據(jù)元素的鍵獲取散列值的表索引的原理。現(xiàn)在假設(shè)有一個散列表的表長為 8,現(xiàn)在需要添加兩個元素,散列值分別為 0010 和 1010。使用表長減 1 的值分別與鍵的散列值進行按位與運算,得到的結(jié)果都是二進制的 0010,也就是十進制的 2。此時,對于散列值分別為 0010 和 1010 的兩個鍵來說,就出現(xiàn)了相同的索引,稱為“散列沖突”。
一旦發(fā)生了散列沖突,也就是散列表嘗試將多個元素放置于同一個位置,此時,鏈?zhǔn)浇Y(jié)構(gòu)就發(fā)揮作用了。散列表添加元素具體過程如下:
1. 將指定的鍵通過散列函數(shù)轉(zhuǎn)化為一個整數(shù)值 n。
2. 使用這個得到的數(shù)值 n 計算出一個表中的合法位置 x ,嘗試將元素放置于這個 x 的位置。
3. 如果表中的 x 位置沒有任何對象,則根據(jù)指定的元素和對應(yīng)的鍵創(chuàng)建一個新的記錄,然后將表中的 x 位置的指針指向這個新創(chuàng)建的記錄。
4. 如果表中的 x 位置有指向某個已有記錄的指針,則根據(jù)指定的元素和對應(yīng)的鍵創(chuàng)建一個新的記錄,使這個新記錄的 next 指向當(dāng)前表中 x 位置上已有的指針?biāo)赶虻挠涗洠缓髮⒈碇械?x 位置的指針指向這個新創(chuàng)建的記錄。
根據(jù)以上過程可以作出假設(shè),如果不停的向散列表中添加散列沖突的鍵,最終結(jié)果就是這個散列表的表中除那個特定的位置有記錄外,其他位置都是空,并且所有記錄都以鏈?zhǔn)浇Y(jié)構(gòu)的方式排列在一起。此時散列表訪問元素的時間復(fù)雜度變?yōu)?O(n),到達最壞情況。[8]
7. 擴容
如果散列表中放置了很多的元素,即使添加的元素的鍵之間發(fā)生最少次數(shù)的散列沖突 [9],也會使得散列表訪問元素的時間復(fù)雜度增加。此時 [10],散列表將會擴大表的長度以嘗試減少各個鏈?zhǔn)浇Y(jié)構(gòu)的長度,從而改善訪問元素的時間復(fù)雜度。擴容前后的結(jié)構(gòu)對比如圖 3。
由圖 3 可以看出,擴容后的散列表中各個元素所處的鏈?zhǔn)浇Y(jié)構(gòu)的長度都減少了,這就使得散列表的時間復(fù)雜度下級降,趨近于O(1)。
讀完這篇文章,你可以繼續(xù)閱讀《JDK 1.7 HashMap 解析》,該文章將在代碼的層面上繼續(xù)深入解釋散列表。
[1] 理想情況是指:添加元素時,不出現(xiàn)散列沖突、不出現(xiàn)表擴容;得到元素時,已有的所有元素不存在散列沖突。
[2] 一種特殊的情況是:如果有多個鍵,它們對應(yīng)的值都是指針(引用),并且這些指針都指向同一個對象。那么,站在對象的層面上來看,就會出現(xiàn)多個鍵對應(yīng)同一個值。
[3] 散列函數(shù)是一種用于將一個任意類型的對象(包括 Null),根據(jù)代碼中定義的某種標(biāo)識對象的特征,獲取一個具體數(shù)值的函數(shù),得到的數(shù)值被稱為這個對象的“散列值”。散列值與對應(yīng)的對象的之間的關(guān)系通常為:如果兩個對象相同,對應(yīng)的散列值一定相同;如果兩個對象不相同,對應(yīng)的散列值不一定不相同;如果兩個散列值不同,對應(yīng)的對象一定不相同;如果兩個散列值相同,對應(yīng)的對象不一定相同。
[4] 散列表的表的數(shù)據(jù)結(jié)構(gòu)有多種實現(xiàn)方式,在這里我們僅討論其中一種比較典型的方式。
[5] 散列表的記錄的數(shù)據(jù)結(jié)構(gòu)有多種實現(xiàn)方式,在這里我們?nèi)匀粌H討論其中一種比較典型的方式。
[6] 散列表將對象散列值轉(zhuǎn)化為表的索引的方式有多種實現(xiàn)方式,在這里我們還是僅討論其中一種比較典型的方式。
[7] 這也就是散列表的表長度一定是 2 的 n 次冪的原因。(基于我們上面所討論的實現(xiàn)方式)
[8] 這種散列表的最壞情況在我們上面所討論的實現(xiàn)方式中是不可避免的,這種情況是所有鍵對外透露的特征都是相同的,但他們卻彼此不相等。比這種情況稍微好一點的情況是所有鍵對外透露的特征雖然不同,但是在計算索引的函數(shù)中都返回了相同結(jié)果,此時可將鏈?zhǔn)浇Y(jié)構(gòu)由鏈列轉(zhuǎn)為查找樹即可有所改善。
[9] 最少散列沖突是指:當(dāng)添加的鍵的散列值足夠離散,但由于散列表的表不夠長,迫使某些元素的鍵產(chǎn)生散列沖突。例如,一個表長為 8 的散列表,向其中添加 16 個元素,即使在最好情況下,也就是表中每一個位置都放置了記錄,但這些記錄所處的鏈?zhǔn)浇Y(jié)構(gòu)長度都為 2。
[10] 通常,散列表不會在元素被放滿才進行擴容,而是會有一個閾值,當(dāng)散列表添加元素時,散列表中的元素數(shù)量大于這個閾值,并且這個新添加的元素的鍵發(fā)生了散列沖突,就會進行擴容,以最大化利用我們上面所討論的結(jié)構(gòu)。