Java集合--Hash、Hash沖突
一、Hash
散列表(Hash table,也叫哈希表),是根據鍵(Key)而直接訪問在內存存儲位置的數據結構。也就是說,它通過計算一個關於鍵值的函數,將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。 這個映射函數稱做散列函數,存放記錄的數組稱做散列表。
- 實現Hash算法的關鍵:實現hash算法 、解決hash沖突
1.Hash函數
首先來說hash函數,java中對象都已一個hashCode()方法,那為什麽還需要hash函數呢?hashCode是在jdk中是有符號int類型,這個一個很大的範圍,如果散列表的數組能覆蓋所有int值的話,就不需要hash函數了,當然內存不允許我們維護這麽大的散列表。這時我們需要hash函數將原始hashCode映射到一個很小的數組上去。意思就是將超大超長或不定長的整形數據轉換為唯一(理想情況,對於不同對象hash值應該不相同)的定長的hash值,常見的做法是取模法,也是jdk中的實現方式。
- HashMap的hashCode實現:
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 } 5 6 static int indexFor(int h, int length) { 7 return h & (length-1); 8 }
第一個hash函數有人稱之為“擾動函數”,第二個indexFor函數在jdk8中去掉了,函數內的代碼合並到了putVal中,個人認為這兩個函數合並起來是一個完整的hash函數。
h & (length-1) 這段代碼的作用其實就是取模,假設數組初始化長度為16,那麽length-1的結果為15,對應二進制為00001111,如果我們有一個大小為20的key,對應二進制為00010100,與運算後結果為00000100,對應十進制為4。
這裏數組的長度必須為2的次冪。由於對key進行了取模運算,所以我們知道當length=16的時候,我們會舍棄調掉key高位的值,只保留了低4位。本來int是32位,只是用低4位沖突是不是太容易發生了?
所以第一個“擾動函數”的作用出現了,這個函數將key本身高16和低16位做了異或運算。
盡管實現了如此有效的散列算法,但只是將不同對象之間hash碰撞的概率降低了,還是不能完全保證不發生hash沖突,因此要繼續使用hash表的優點就要解決hash沖突的問題。
二、解決Hash沖突
1.開放定址法(線性探測,二次探測,偽隨機探測)
用開放定址法解決沖突的做法是:當沖突發生時,使用某種探查(亦稱探測)技術在散列表中形成一個探查(測)序列。沿此序列逐個單元地查找,直到找到給定的關鍵字,或者碰到一個開放的地址(即該地址單元為空)為止(若要插入,在探查到開放的地址,則可將待插入的新結點存人該地址單元)。查找時探查到開放的 地址則表明表中無待查的關鍵字,即查找失敗。
當哈希表越來越滿時聚集越來越嚴重,這導致產生非常長的探測長度,後續的數據插入將會非常費時。通常數據超過三分之二滿時性能下降嚴重,因此設計哈希表關鍵確保不會超過這個數據容量的一半,最多不超過三分之二。
- 用開放定址法建立散列表時,建表前須將表中所有單元(更嚴格地說,是指單元中存儲的關鍵字)置空。
- 空單元的表示與具體的應用相關。
按照形成探查序列的方法不同,可將開放定址法區分為線性探查法、線性補償探測法、隨機探測等。
(1)線性探查法(Linear Probing)
- 該方法的基本思想是將散列表T[0..m-1]看成是一個循環向量,若初始探查的地址為d(即h(key)=d),則最長的探查序列為:
d,d+l,d+2,…,m-1,0,1,…,d-1
即:探查時從地址d開始,首先探查T[d],然後依次探查T[d+1],…,直到T[m-1],此後又循環到T[0],T[1],…,直到探查到T[d-1]為止。
- 探查過程終止於三種情況:
①若當前探查的單元為空,則表示查找成功(若是插入則將key寫入其中);
②若當前探查的單元中含有key,則查找成功,但對於插入意味著失敗;
③若探查到T[d-1]時仍未發現空單元,則無論是查找還是插入均意味著失敗(此時表滿,需要擴容)。
- 利用開放地址法的一般形式,線性探查法的探查序列為:
hi=(h(key)+i)%m 0≤i≤m-1 //i=1
- 用線性探測法處理沖突,思路清晰,算法簡單,但存在下列缺點:
①哈希表容量不能完全利用,並且擴容將會是災難的,需要刪除以前標記過的元素並需要從新計算所有元素的位置,在頻繁的刪除和插入時效率變得很低。
②按上述算法建立起來的哈希表,刪除工作非常困難。假如要從哈希表 HT 中刪除一個記錄,按理應將這個記錄所在位置置為空,但我們不能這樣做,而只能標上已被刪除的標記,否則,將會影響以後的查找。
③線性探測法很容易產生堆聚現象。所謂堆聚現象,就是存入哈希表的記錄在表中連成一片。按照線性探測法處理沖突,如果生成哈希地址的連續序列愈長(即不同關鍵字值的哈希地址相鄰在一起愈長),則當新的記錄加入該表時,與這個序列發生沖突的可能性愈大。因此,哈希地址的較長連續序列比較短連續序列生長得快,這就意味著,一旦出現堆聚(伴隨著沖突),就將引起進一步的堆聚。
(2)線性補償探測法
線性補償探測法的基本思想是將線性探測的步長從 1 改為 Q ,即將上述算法中的 hi=(h(key)+i)%m改為:hi=(h(key)+Q)%m,這個Q是根據一定的增長率變化的(1、4、9...),這樣使得數據分布的足夠散亂,不容易出現聚堆現象,而且要求 Q 的變化能使全表得到完整的掃描,以便能探測到哈希表中的所有單元(當然還有其他的線性在散列算法規則,這裏只討論該種方式的再散列)。
(3)隨機探測
隨機探測的基本思想是將線性探測的步長從常數改為隨機數,即令:hi=(h(key)+RN)%m ,其中 RN 是一個隨機數。在實際程序中應預先用隨機數發生器產生一個隨機序列,將此序列作為依次探測的步長。這樣就能使不同的關鍵字具有不同的探測次序,從而可以避免或減少堆聚。基於與線性探測法相同的理由,在線性補償探測法和隨機探測法中,刪除一個記錄後也要打上刪除標記。
2.鏈地址法(拉鏈法)
拉鏈法解決沖突的做法是將所有關鍵字為同義詞的結點鏈接在同一個單鏈表中。若選定的散列表長度為m,則可將散列表定義為一個由m個頭指針組成的指針數 組T[0..m-1]。凡是散列地址為i的結點,均插入到以T[i]為頭指針的單鏈表中。T中各分量的初值均應為空指針。在拉鏈法中,裝填因子α可以大於 1,但一般均取α≤1。
- 與開放定址法相比拉鏈法的優點
①拉鏈法處理沖突簡單,且無堆積現象,即非同義詞決不會發生沖突,因此平均查找長度較短;
②由於拉鏈法中各鏈表上的結點空間是動態申請的,故它更適合於造表前無法確定表長的情況;
③開放定址法為減少沖突,要求裝填因子α較小,故當結點規模較大時會浪費很多空間。而拉鏈法中可取α≥1,且結點較大時,拉鏈法中增加的指針域可忽略不計,因此節省空間;
④在用拉鏈法構造的散列表中,刪除結點的操作易於實現。只要簡單地刪去鏈表上相應的結點即可。而對開放地址法構造的散列表,刪除結點不能簡單地將被刪結 點的空間置為空,否則將截斷在它之後填人散列表的同義詞結點的查找路徑。這是因為各種開放地址法中,空地址單元(即開放地址)都是查找失敗的條件。因此在 用開放地址法處理沖突的散列表上執行刪除操作,只能在被刪結點上做刪除標記,而不能真正刪除結點。
- 拉鏈法的缺點
當發生hash沖突時,需要生成鏈表由此需要額外占用空間,並且需要花一定的時間和精力維護沖突鏈表。在擴容的時候需要把所有元素進行重新hash並分配地址,算法較為復雜繁瑣。
3.再哈希(了解)
再hash法,就是算hashcode的方法不止一個,一個要是算出來重復啦,再用另一個算法去算。使用一定的算法邏輯的到一種在當前情況不會發生hash沖突的hash算法。
4.建立公共溢出區(了解)
建立一個公共溢出區域,就是把沖突的都放在另一個地方,不在表裏面。具體實現不做探討了(不常用)。
Java集合--Hash、Hash沖突