HashMap
- HashMap的數據結構:HashMap實際上是一個數組和鏈表(“鏈表散列”)的數據結構。底層就是一個數組結構,數組中的每一項又是一個鏈表。
- hashCode是一個對象的標識,Java中對象的hashCode是一個int類型值。通過hashCode來算出指定數組的索引可以快速定位到要找的對象在數組中的位置,之后再遍歷鏈表找到對應值,理想情況下時間復雜度為O(1),並且不同對象可以擁有相同的hashCode(hash碰撞)。發生碰撞后會把相同hashcode的對象放到同一個鏈表里,但是在數組大小不變的情況下,存放鍵值對越多,查找的時間效率也會降低
- 擴容可以解決該問題,而負載因子決定了什么時候擴容,負載因子是已存鍵值對的數量和總的數組長度的比值。默認情況下負載因子為0.75,我們可在初始化HashMap的時候自己修改。閥值 = 當前數組長度✖負載因子
- hashmap中默認負載因子為0.75,長度默認是16,默認情況下第一次擴容判斷閥值是16 ✖ 0.75 = 12;所以第一次存鍵值對的時候,在存到第13個鍵值對時就需要擴容了,變成16X2=32。
put流程
- 對key hash,二次hash,hash擾亂函數,減少hash碰撞
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
}
獲取對象的hashcode以后,先進行移位運算,然后再和自己做異或運算,即:hashcode ^ (hashcode >>> 16),這一步甚是巧妙,是將高16位移到低16位,這樣計算出來的整型值將“具有”高位和低位的性質
- 通過hash算出數組角標(indexfor())
- 添加元素,看是否需要擴容,需要的話變數組變成原來的2倍,把舊的拷貝到新的數組上去,然后舊的指針指向新的。
- 如果key相同的覆蓋;沒有的話添加元素
get流程
- get對key hash,找到數組角標(indexfor())
- 如果hash相同key相同就找到了
- 如果hash相同key不相同,找鏈表的下一個(通過值找)
其他問題
- 1.7 和 1.8 數據結構有什么不同?
1.8 增加了轉換為紅⿊樹 - 插⼊數據的⽅式
1.7 的鏈表從前⾯插⼊,1.8 的鏈表從后⾯插⼊ - HashMap 什么時候會把鏈表轉化為紅⿊樹?
鏈表⻓度超過 8 ,並且數組⻓度不⼩於 64
在 JDK1.8 版本中,為了對 HashMap 做進一步優化,引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換為紅黑樹。可以利用紅黑樹快速增刪改查的特點,提高 HashMap 的性能。當紅黑樹結點個數少於 8 個的時候,又會將紅黑樹轉化為鏈表。因為在數據量較小的情況下,紅黑樹要維護平衡,比起鏈表來,性能上的優勢並不明顯。
優化hashmap
- HashMap 默認的初始大小是 16,當然這個默認值是可以設置的,如果事先知道大概的數據量有多大,可以通過修改默認初始大小,減少動態擴容的次數,這樣會大大提高 HashMap 的性能。
ArrayMap是Android專門針對內存優化而設計的,用於取代Java API中的HashMap數據結構。為了更進一步優化key是int類型的Map,Android再次提供效率更高的數據結構SparseArray,可避免自動裝箱過程。對於key為其他類型則可使用ArrayMap。HashMap的查找和插入時間復雜度為O(1)的代價是犧牲大量的內存來實現的,而SparseArray和ArrayMap性能略遜於HashMap,但更節省內存。
- SparseBooleanArray: 當map的結構為Map<Integer,Boolean>的時候使用,效率較高。
- SparseLongArray: 當map的結構為Map<Integer,Long>的時候使用,效率較高。
- LongSparseArray: 當map的結構為Map<Long,Value>的時候使用,效率較高。
- ArraySet:和ArrayMap的目的類似,用來提高HashSet的效率。使用方法跟HashSet類似
ArrayMap的key是任意對象,list等等,一般是存一個鍵值,獲取數據簡單
map.keyAt(0)
map.valueAt(0)
- ArrayMap的內部實現是兩個數組,一個int數組是存儲對象數據對應下標,一個對象數組保存key和value,內部使用二分法對key進行排序,所以在添加、刪除、查找數據的時候,都會使用二分法查找,只適合於小數據量操作, 通常情況下要比傳統的HashMap慢,因為查找是用二分查找法搜索,添加和刪除需要對數組進行添加和刪除。
- 為了提高性能,該容器提供了一個優化:當刪除key鍵時,不是立馬刪除這一項,而是留下需要刪除的選項給一個刪除的標記。該條目可以被重新用於相同的key,或者被單個垃圾收集器逐步刪除完全部的條目后壓縮。
- 為了減少頻繁地創建和回收Map對象,ArrayMap采用了兩個大小為10的緩存隊列來分別保存大小為4和8的Map對象。為了節省內存有更加保守的內存擴張(擴容的少)以及內存收縮策略(gc的頻繁(刪除不用的空間,新的數組))
- 當mSize大於或等於mHashes數組長度時則擴容,完成擴容后需要將老的數組拷貝到新分配的數組,並釋放老的內存。
- 當map個數滿足條件 osize<4時,則擴容后的大小為4;
- 當map個數滿足條件 4<= osize < 8時,則擴容后的大小為8;當map個數滿足條件 osize>=8時,則擴容后的大小為原來的1.5倍;
- 可見ArrayMap大小在不斷增加的過程,size的取值一般情況依次會是4,8,12,18,27,40,60
HashMap存對象
HashMap是基於哈希表的Map接口的非同步實現。此實現提供所有可選的映射操作,並允許使用null值和null鍵。此類不保證映射的順序,特別是它不保證該順序恆久不變。如果要用對象作為key的話需要重新該對象的equals方法和hashCode方法。
new一個新的對象時,地址變了,不能保證hash值和equals結果還是一樣。所以取不到對應的value。
Map<People,Integer> map = new HashMap<People, Integer>();
map.put(new People("liu",18),5);
People p = new People("liu",18);
System.out.println(map.get(p));
LinkedHashMap
LinkedHashMap 是通過散列表和鏈表組合在一起實現的。實際上,它不僅支持按照插入順序遍歷數據,還支持按照訪問順序來遍歷數據。你可以看下面這段代碼:
// 10是初始大小,0.75是裝載因子,true是表示按照訪問時間排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
m.put(3, 26);
m.get(5);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
這段代碼打印的結果是 1,2,3,5、
每次調用 put() 函數,往 LinkedHashMap 中添加數據的時候,都會將數據添加到鏈表的尾部,所以,在前四個操作完成之后,鏈表中的數據是下面這樣:
在第 8 行代碼中,再次將鍵值為 3 的數據放入到 LinkedHashMap 的時候,會先查找這個鍵值是否已經有了,然后,再將已經存在的 (3,11) 刪除,並且將新的 (3,26) 放到鏈表的尾部。所以,這個時候鏈表中的數據就是下面這樣:
當第 9 行代碼訪問到 key 為 5 的數據的時候,我們將被訪問到的數據移動到鏈表的尾部。所以,第 9 行代碼之后,鏈表中的數據是下面這樣:
從上面的分析,你有沒有發現,按照訪問時間排序的 LinkedHashMap 本身就是一個支持 LRU 緩存淘汰策略的緩存系統?實際上,它們兩個的實現原理也是一模一樣的。我也就不再啰嗦了。
LinkedHashMap 是通過雙向鏈表和散列表這兩種數據結構組合實現的。LinkedHashMap 中的“Linked”實際上是指的是雙向鏈表,並非指用鏈表法解決散列沖突。
散列表這種數據結構雖然支持非常高效的數據插入、刪除、查找操作,但是散列表中的數據都是通過散列函數打亂之后無規律存儲的。也就說,它無法支持按照某種順序快速地遍歷數據。如果希望按照順序遍歷散列表中的數據,那我們需要將散列表中的數據拷貝到數組中,然后排序,再遍歷。
因為散列表是動態數據結構,不停地有數據的插入、刪除,所以每當我們希望按順序遍歷散列表中的數據的時候,都需要先排序,那效率勢必會很低。為了解決這個問題,我們將散列表和鏈表(或者跳表)結合在一起使用。
least recentlly use
最少最近使用算法,就是使用的LinkedHashMap
- 會將內存控制在一定的大小內, 這個最大值可以自己定,超出最大值時會自動回收。他內部是是一個LinkedHashMap存儲外界的緩存對象,提供了get,put方法來操作,當緩存滿了,lru會移除較早使用的緩存對象,把新的添加進來。
- HashMap是無序的,而LinkedHashMap默認實現是按插入順序排序的,怎么存怎么取。LinkedHashMap每次調用get(也就是從內存緩存中取圖片),則將該對象移到鏈表的尾端。調用put插入新的對象也是存儲在鏈表尾端,這樣當內存緩存達到設定的最大值時,將鏈表頭部的對象(近期最少用到的)移除。
- 內存中使用LRUCache是最合適的。如果用HashMap來實現,不是不可以,但需要注意在合適的時候釋放緩存,還得控制緩存的大小。
public class BitmapCache implements ImageCache {
private LruCache<String, Bitmap> mCache;
public BitmapCache() {
long maxSize = Runtime.getRuntime().maxMemory() / 8;
// int maxSize = 10 * 1024 * 1024;
mCache = new LruCache<String, Bitmap>(maxSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
//獲取圖片占用內存大小
return bitmap.getRowBytes() * bitmap.getHeight();
}
};
}
@Override
public Bitmap getBitmap(String url) {
return mCache.get(url);
}
@Override
public void putBitmap(String url, Bitmap bitmap) {
mCache.put(url, bitmap);
}
}
散列表
散列表的英文叫“Hash Table”,我們平時也叫它“哈希表”或者“Hash 表"
散列表用的是數組支持按照下標隨機訪問數據的特性,所以散列表其實就是數組的一種擴展,由數組演化而來。可以說,如果沒有數組,就沒有散列表。
其中,參賽選手的編號我們叫作鍵(key)或者關鍵字。我們用它來標識一個選手。我們把參賽編號轉化為數組下標的映射方法就叫作散列函數(或“Hash 函數”“哈希函數”),而散列函數計算得到的值就叫作散列值(或“Hash 值”“哈希值”)
散列表用的就是數組支持按照下標隨機訪問的時候,時間復雜度是 O(1) 的特性。我們通過散列函數把元素的鍵值映射為下標,然后將數據存儲在數組中對應下標的位置。當我們按照鍵值查詢元素時,我們用同樣的散列函數,將鍵值轉化數組下標,從對應的數組下標的位置取數據。
時間復雜度
插入一個數據,最好情況下,不需要擴容,最好時間復雜度是 O(1)。最壞情況下,散列表裝載因子過高,啟動擴容,我們需要重新申請內存空間,重新計算哈希位置,並且搬移數據,所以時間復雜度是 O(n)。用攤還分析法,均攤情況下,時間復雜度接近最好情況,就是 O(1)
然后遍歷鏈表查找或者刪除。那查找或刪除操作的時間復雜度是多少呢?實際上,這兩個操作的時間復雜度跟鏈表的長度 k 成正比,也就是 O(k)。對於散列比較均勻的散列函數來說,理論上講,k=n/m,其中 n 表示散列中數據的個數,m 表示散列表中“槽”的個數。
散列函數
散列函數,顧名思義,它是一個函數。我們可以把它定義成 hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過散列函數計算得到的散列值。
該如何構造散列函數呢?我總結了三點散列函數設計的基本要求:
- 散列函數計算得到的散列值是一個非負整數;
- 如果 key1 = key2,那 hash(key1) == hash(key2);
- 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)
我來解釋一下這三點。其中,第一點理解起來應該沒有任何問題。因為數組下標是從 0 開始的,所以散列函數生成的散列值也要是非負整數。第二點也很好理解。相同的 key,經過散列函數得到的散列值也應該是相同的。
第三點理解起來可能會有問題,我着重說一下。這個要求看起來合情合理,但是在真實的情況下,要想找到一個不同的 key 對應的散列值都不一樣的散列函數,幾乎是不可能的。即便像業界著名的MD5、SHA、CRC等哈希算法,也無法完全避免這種散列沖突。而且,因為數組的存儲空間有限,也會加大散列沖突的概率。
散列沖突
1.開放尋址法
線性探測
我們往散列表中插入數據時,如果某個數據經過散列函數散列之后,存儲位置已經被占用了,我們就從當前位置開始,依次往后查找,看是否有空閑位置,直到找到為止。
當數據量比較小、裝載因子小的時候,適合采用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列沖突的原因。
ThreadLocalMap 是通過線性探測的開放尋址法來解決沖突
散列表的裝載因子=填入表中的元素個數/散列表的長度
裝載因子越大,說明空閑位置越少,沖突越多,散列表的性能會下降。
2.鏈表法
Java 中 LinkedHashMap 就采用了鏈表法解決沖突
如何設計散列函數?
如何設計一個可以應對各種異常情況的工業級散列表,來避免在散列沖突的情況下,散列表性能的急劇下降,並且能抵抗散列碰撞攻擊?
首先,散列函數的設計不能太復雜。過於復雜的散列函數,勢必會消耗很多計算時間,也就間接的影響到散列表的性能。其次,散列函數生成的值要盡可能隨機並且均勻分布,這樣才能避免或者最小化散列沖突,而且即便出現沖突,散列到每個槽(鏈表)里的數據也會比較平均,不會出現某個槽內數據特別多的情況。
裝載因子過大了怎么辦?
裝載因子越大,說明散列表中的元素越多,空閑位置越少,散列沖突的概率就越大。不僅插入數據的過程要多次尋址或者拉很長的鏈,查找的過程也會因此變得很慢。
擴容解決
實際上,對於動態散列表,隨着數據的刪除,散列表中的數據會越來越少,空閑空間會越來越多。
避免低效地擴容
我舉一個極端的例子,如果散列表當前大小為 1GB,要想擴容為原來的兩倍大小,那就需要對 1GB 的數據重新計算哈希值,並且從原來的散列表搬移到新的散列表,聽起來就很耗時,是不是?
為了解決一次性擴容耗時過多的情況,我們可以將擴容操作穿插在插入操作的過程中,分批完成。當裝載因子觸達閾值之后,我們只申請新空間,但並不將老的數據搬移到新散列表中。
當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,我們都重復上面的過程。經過多次插入操作之后,老的散列表中的數據就一點一點全部搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操作就都變得很快了。
這期間的查詢操作怎么來做呢?對於查詢操作,為了兼容了新、老散列表中的數據,我們先從新散列表中查找,如果沒有找到,再去老的散列表中查找。
部分內容摘抄至極客時間《數據結構與算法之美》,更多精彩內容,歡迎訂閱老師課程。