想了解更多數據結構以及算法題,可以關注微信公衆號“數據結構和算法”,每天一題爲你精彩解答。也可以掃描下面的二維碼關注
基礎知識
散列表也叫哈希表,是根據鍵值對(key,value)進行訪問的一種數據結構。他是把一對(key,value)通過key的哈希值來映射到數組中的,也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散列表。
1,HashMap
散列表中最常見的應該就是HashMap了,HashMap的實現原理非常簡單,他其實就是數組加鏈表的一種數據結構。如果映射在數組中出現了衝突,他會以鏈表的形式存在。我們來看一下他的數據結構是什麼樣的。
上面的圖有兩處非常明顯的錯誤,不知道大家有沒有發現,如果對HashMap源碼比較熟悉的估計一眼就能看的出來。首先是數組的長度必須是2的n次冪,這裏長度是9,明顯有錯,然後是entry的個數不能大於數組長度的75%,如果大於就會觸發擴容機制進行擴容,這裏明顯是大於75%。我爲什麼要畫這個錯誤的圖呢,因爲在網上確實看到過不少這樣不嚴謹的圖,希望大家能夠看清楚。那麼正確的圖應該是這樣的。
數組的長度即是2的n次冪,而他的size又不大於數組長度的75%。
HashMap的實現原理是先要找到要存放數組的下標,如果是空的就存進去,如果不是空的就判斷key值是否一樣,如果一樣就替換,如果不一樣就以鏈表的形式存在。
在java中1.7及以前的版本如果以鏈表的形式存在,在插入的時候採用的是頭插法。
在1.8是尾插法。並且在java1.8中如果鏈表的長度大於8的時候會轉爲紅黑樹。
在HashMap中,數組的大小是2n,無論你初始化的時候傳的值是多少,他都會初始化爲2n,並且這個2^n是大於等於你初始化值的最小值,比如初始化的時候傳的值是17,他會計算得到32。關於怎麼計算的,我們有3種方式,第一種就是通過while循環,我們來看下代碼
public static int tableSizeFor(int initialCapacity) {
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
return capacity;
}
這種解法是最簡單的,一眼就能看懂,還有兩種解法我們也可以看下
public static int tableSizeFor(int i) {
i--;
i |= i >>> 1;
i |= i >>> 2;
i |= i >>> 4;
i |= i >>> 8;
i |= i >>> 16;
return i + 1;
}
原理比較簡單,就是把最左邊的1往右全部鋪開,最後在加上1就是我們要求的結果。這裏第二行減1的目的是防止i等於2^n的時候結果會放大。比如當i=32的時候如果我們在第2行不減1,會導致結果爲64。我們再來看另一種寫法
public static int tableSizeFor(int i) {
if ((i & (i - 1)) == 0)
return i;
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return (i - (i >>> 1)) << 1;
}
在第2-3行實現判斷是不是2^n, 如果是就直接返回,第4-8行也是把i最左邊的1往右全部鋪開,第9行i-(i>>>1)表示的是把i最左邊的1保留,其他的全部置爲0,通俗一點也就是他返回的是小於i的最大的2^n,然後再往左移一位就是我們要求的結果。我們就以i等於17爲例用最後一個方法來畫個圖分析一下。
2,ArrayMap
除了使用數組和鏈表以外,我們能不能只使用一種數據結構呢,比如數組,當然也是可以的。大家可能會懷疑,如果只使用一種數據結構的話,映射出現了衝突怎麼辦,其實也很好解決。ArrayMap的實現原理是使用兩個數組,一個存放hash值,一個存放key和value,其中存放key和value的數組長度是存放hash值數組長度的二倍,其中存放hash值的數組必須是排序的。如果hash值出現了衝突,說明hash值最終的計算是一樣的,那麼在hash數組中肯定是挨着的,所以查找的時候如果hash值有重複的就會把重複的也查找一遍。我們來看ArrayMap中的一段代碼
int indexOf(Object key, int hash) {
final int N = mSize;
// Important fast case: if nothing is in here, nothing to look for.
if (N == 0) {
return ~0;
}
int index = binarySearchHashes(mHashes, N, hash);
// If the hash code wasn't found, then we have no entry for this key.
if (index < 0) {
return index;
}
// If the key at the returned index matches, that's what we want.
if (key.equals(mArray[index<<1])) {
return index;
}
// Search for a matching key after the index.
int end;
for (end = index + 1; end < N && mHashes[end] == hash; end++) {
if (key.equals(mArray[end << 1])) return end;
}
// Search for a matching key before the index.
for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
if (key.equals(mArray[i << 1])) return i;
}
// Key not found -- return negative value indicating where a
// new entry for this key should go. We use the end of the
// hash chain to reduce the number of array entries that will
// need to be copied when inserting.
return ~end;
}
我們看到第23-30行,如果hash值一樣,在查找的時候不光往前查找而且還會往後查找。他的數據結構是這樣的。
3,SparseArray
在散列表中如果可以確定key值都是int類型,那麼又可以簡化,直接用key值當hash值存儲即可,和ArrayMap一樣只需要兩個數組即可,一個是存放key的,一個是存放value的,不同的是這兩個數組的長度都是一樣的。這種情況下就不會出現hash值一樣的問題了,因爲這個時候如果hash值一樣的話,那麼他們的key肯定是一樣的,而在散列表中是不可能存在了,假如在插入數據的時候有一樣的key,那麼他的value是要被替換掉的,所以不會出現兩個完全一樣的key。他的數據結構圖是這樣的
4,ThreadLocalMap
在java語言中還有一個關於散列表的,那就是ThreadLocalMap,這個類是ThreadLocal的一個靜態內部類,一般我們用不到。如果出現hash衝突的時候他的實現原理和上面的幾種也都不太一樣。存儲的時候他首先會根據hash值映射到指定的數組,如果當前位置爲空就直接存進去,如果不爲空就往後找,找他的下一個,我們來看其中的一段代碼
/**
* Increment i modulo len.
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
總結:
散列表大家第一個想到的就是HashMap,需要數組加鏈表的方式才能實現,通過我們上面的分析,其實我們不需要鏈表也能實現。散列表的實現原理其實很簡單。他的核心是當我們的hash值出現衝突的時候該怎麼解決。