哈希、HashMap原理及源碼、Hash的一些應用面試題
<li class="edit" style="display: none;">
<a class="btn-noborder" href="http://write.blog.csdn.net/postedit/47670047">
<i class="icon iconfont icon-bianji"></i><span class="txt">編輯</span>
</a>
</li>
<li class="del" style="display: none;">
<a class="btn-noborder" onclick="javascript:deleteArticle(fileName);return false;">
<i class="icon iconfont icon-shanchu"></i><span class="txt">刪除</span>
</a>
</li>
</ul>
</div>
<div id="article_content" class="article_content csdn-tracking-statistics" data-mod="popu_307" data-dsm="post" style="overflow: hidden;">
一、哈希定義
Hash,一般翻譯做“散列”,也有直接音譯爲”哈希”的,就是把任意長度的輸入(又叫做預映射, pre-image),通過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不 同的輸入可能會散列成相同的輸出,而不可能從散列值來唯一的確定輸入值。
數學表述爲:h = H(M) ,其中H( )–單向散列函數,M–任意長度明文,h–固定長度散列值。
二、基本概念
1、若結構中存在和關鍵字K相等的記錄,則必定在f(K)的存儲位置上。由此,不需比較便可直接取得所查記錄。稱這個對應關係f爲散列函數(Hash function),按這個思想建立的表爲散列表。
對不同的關鍵字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2),這種現象稱衝突。具有相同函數值的關鍵字對該散列函數來說稱做同義詞。綜上所述,根據散列函數H(key)和處理衝2、突的方法將一組關鍵字映象到一個有限的連續的地址集(區間)上,並以關鍵字在地址集中的“象” 作爲記錄在表中的存儲位置,這種表便稱爲散列表,這一映象過程稱爲散列造表或散列,所得的存儲位置稱散列地址。
3、若對於關鍵字集合中的任一個關鍵字,經散列函數映象到地址集合中任何一個地址的概率是相等的,則稱此類散列函數爲均勻散列函數(Uniform Hash function),這就是使關鍵字經過散列函數得到一個“隨機的地址”,從而減少衝突。
三、常用的構造散列函數的方法
散列函數能使對一個數據序列的訪問過程更加迅速有效,通過散列函數,數據元素將被更快地定位ǐ
1. 直接尋址法:取關鍵字或關鍵字的某個線性函數值爲散列地址。即H(key)=key或H(key) = a·key + b,其中a和b爲常數(這種散列函數叫做自身函數)
2. 數字分析法
3. 平方取中法
4. 摺疊法
5. 隨機數法
6. 除留餘數法:取關鍵字被某個不大於散列表表長m的數p除後所得的餘數爲散列地址。即 H(key) = key MOD p, p<=m。不僅可以對關鍵字直接取模,也可在摺疊、平方取中等運算之後取模。對p的選擇很重要,一般取素數或m,若p選的不好,容易產生同義詞。
四、處理衝突的方法
1. 開放尋址法;Hi=(H(key) + di) MOD m, i=1,2,…, k(k<=m-1),其中H(key)爲散列函數,m爲散列表長,di爲增量序列,可有下列三種取法:
1. di=1,2,3,…, m-1,稱線性探測再散列;
2. di=1^2, (-1)^2, 2^2,(-2)^2, (3)^2, …, ±(k)^2,(k<=m/2)稱二次探測再散列;
3. di=僞隨機數序列,稱僞隨機探測再散列。 ==
2. 再散列法:Hi=RHi(key), i=1,2,…,k RHi均是不同的散列函數,即在同義詞產生地址衝突時計算另一個散列函數地址,直到衝突不再發生,這種方法不易產生“聚集”,但增加了計算時間。
3. 鏈地址法(拉鍊法)
4. 建立一個公共溢出區
五、Hash算法的時間複雜度
無衝突的hash table複雜度是O(1),一般是O(c),c爲哈希關鍵字衝突時查找的平均長度,最壞情況仍然是O(N)。
六、HashMap
1、一個對象當HashMap的key時,必須覆蓋hashCode()和equals()方法,hashCode()的返回值儘可能的分散。
2、當HashMap的entry的數組足夠大,key的hash值足夠分散時,即是可以實現一個entry數組下標最多隻對應了一個entry,此時get方法的時間複雜度可以達到O(1)。
3、在數組長度和get方法的速度上要達到一個平衡。數組比較長碰撞出現的概率就比較小,所以get方法獲取值時就比較快,但浪費了比較多的空間;當數組長度沒有冗餘時,碰撞出現的概率比較大,雖然節省了空間,但會犧牲get方法的時間。
4、HashMap有默認的裝載因子loadFactor=0.75,默認的entry數組的長度爲16。裝載因子的意義在於使得entry數組有冗餘,默認即允許25%的冗餘,當HashMap的數據的個數超過12(16*0.75)時即會對entry數組進行第一次擴容,後面的再次擴容依次類推。
5、HashMap每次擴容一倍,resize時會將已存在的值從新進行數組下標的計算,這個是比較浪費時間的。在平時使用中,如果能估計出大概的HashMap的容量,可以合理的設置裝載因子loadFactor和entry數組初始長度即可以避免resize操作,提高put的效率。
6、HashMap不是線程安全的,多線程環境下可以使用Hashtable或ConcurrentHashMap。
7、我們能否讓HashMap同步?
HashMap可以通過下面的語句進行同步:
Map m = Collections.synchronizeMap(hashMap);
1、map是一種key、value形式的鍵值對,將hash表和map結合即形成了HashMap。
2、Java中HashMap的數據是以Entry數組的形式存放的,HashMap通過對key進行hash運算得到一個數組下標,然後將數據存放到Entry數組對應的位置,又因爲不同的key進行hash運算可能會得到一個相同的數組下標,爲了解決碰撞覆蓋衝突,所以Entry本身又是一個鏈表的結構,即以後不同的key相同數組下標的數據的next會被賦值爲已存在Entry鏈表,新的Entry會替換數組值。
3、HashMap的存儲數據的示例圖如下:
圖1:
圖2:
- public V put(K key, V value) {
- if (table == EMPTY_TABLE) {
- inflateTable(threshold);
- }
- if (key == null)
- return putForNullKey(value);// HashMap接收key爲null的數據
- int hash = hash(key);//對key的hashCode再進行hash運算
- int i = indexFor(hash, table.length);//根據hash值和entry數組的大小計算出新增數據應該存放的數組位置
- for (Entry<K,V> e = table[i]; e != null; e = e.next) {
- // for循環遍歷找到的數組下標的entry,如果hash值和key都相等,則覆蓋原來的value值
- Object k;
- if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
- V oldValue = e.value;
- e.value = value;
- e.recordAccess(this);
- return oldValue;
- }
- }
- modCount++;
- //如果上面for循環沒有找到相同的hash和key,則增加一個entry
- addEntry(hash, key, value, i);
- return null;
- }
- void addEntry(int hash, K key, V value, int bucketIndex) {
- if ((size >= threshold) && (null != table[bucketIndex])) {
- resize(2 * table.length); //如果size超過threshold,則擴充table大小。再散列
- hash = (null != key) ? hash(key) : 0;
- bucketIndex = indexFor(hash, table.length);
- }
- createEntry(hash, key, value, bucketIndex);
- }
- void createEntry(int hash, K key, V value, int bucketIndex) {
- Entry<K,V> e = table[bucketIndex];
- //new 一個新的entry,賦值給當前下標數組
- table[bucketIndex] = new Entry<>(hash, key, value, e); //參數e, 是Entry.next
- size++;
- }
- Entry(int h, K k, V v, Entry<K,V> n) {
- value = v;
- //在HashMap的Entry中有四個變量,key、value、hash、next,其中next用於在hash方法添加值衝突時候,所指向的下一個值。
- next = n;//即將原來數組下標對應的entry賦值給新的entry的next
- key = k;
- hash = h;
- }
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);// HashMap接收key爲null的數據
int hash = hash(key);//對key的hashCode再進行hash運算
int i = indexFor(hash, table.length);//根據hash值和entry數組的大小計算出新增數據應該存放的數組位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
// for循環遍歷找到的數組下標的entry,如果hash值和key都相等,則覆蓋原來的value值
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//如果上面for循環沒有找到相同的hash和key,則增加一個entry
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); //如果size超過threshold,則擴充table大小。再散列
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
//new 一個新的entry,賦值給當前下標數組
table[bucketIndex] = new Entry<>(hash, key, value, e); //參數e, 是Entry.next
size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
//在HashMap的Entry中有四個變量,key、value、hash、next,其中next用於在hash方法添加值衝突時候,所指向的下一個值。
next = n;//即將原來數組下標對應的entry賦值給新的entry的next
key = k;
hash = h;
}
(1)對key的hashcode進行hash計算,獲取應該保存到數組中的index。
(2)判斷index所指向的數組元素是否爲空,如果爲空則直接插入。
(3)如果不爲空,則依次查找entry中next所指定的元素,判讀key是否相等,如果相等,則替換久的值,返回。
(4)如果都不相等,則將此鏈表頭元素賦值給待插入entry的next變量,讓後將待插入元素插入到entry數組中去。
(5)當然HashMap裏面也包含一些優化方面的實現,這裏也說一下。比如:Entry[]的長度一定後,隨着map裏面數據的越來越長,這樣同一個index的鏈就會很長,會不會影響性能?HashMap裏面設置一個因子,隨着map的size越來越大,Entry[]會以一定的規則加長長度。
PS: HashMap,也是先判斷hashcode(hashcode相同,所以它們的table位置相同,‘碰撞’會發生。因爲HashMap使用鏈表存儲對象,這個Entry(包含有鍵值對的Map.Entry對象)會存儲在鏈表中。),再判斷equals,如果都相同,則表示:在集合添加中,認爲是同一個”東西”,覆蓋舊值。【==、equals、hashcode的區別和聯繫】
5、HashMap的get方法源碼解析
- public V get(Object key) {
- if (key == null)//key爲null時特別處理
- return getForNullKey();
- Entry<K,V> entry = getEntry(key);
- return null == entry ? null : entry.getValue();
- }
- final Entry<K,V> getEntry(Object key) {
- if (size == 0) {
- return null;
- }
- int hash = (key == null) ? 0 : hash(key);
- //indexFor(hash, table.length) 根據hash值和數組長度計算出下標,然後遍歷Entry鏈表
- for (Entry<K,V> e = table[indexFor(hash, table.length)];
- e != null;
- e = e.next) {
- Object k;
- if (e.hash == hash &&
- ((k = e.key) == key || (key != null && key.equals(k))))
- return e;
- }
- return null;
- }
public V get(Object key) {
if (key == null)//key爲null時特別處理
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
//indexFor(hash, table.length) 根據hash值和數組長度計算出下標,然後遍歷Entry鏈表
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}</pre><br>
6、確定數組index:hashcode % table.length取模
按位取並,作用上相當於取模mod或者取餘%。這意味着數組下標相同,並不表示hashCode相同。
static int indexFor(int h, int length) {
return h & (length-1);
}
7、解決hash衝突的辦法
開放定址法(線性探測再散列,二次探測再散列,僞隨機探測再散列)
再哈希法
鏈地址法
建立一個公共溢出區
Java中hashmap的解決辦法就是採用的鏈地址法。
8、再散列rehash過程
當哈希表的容量超過默認容量時,必須調整table的大小。當容量已經達到最大可能值時,那麼該方法就將容量調整到Integer.MAX_VALUE返回,這時,需要創建一張新表,將原表的映射到新表中。
- void resize(int newCapacity) {
- Entry[] oldTable = table;
- int oldCapacity = oldTable.length;
- if (oldCapacity == MAXIMUM_CAPACITY) {
- threshold = Integer.MAX_VALUE;
- return;
- }
- Entry[] newTable = new Entry[newCapacity];
- transfer(newTable, initHashSeedAsNeeded(newCapacity));
- table = newTable;
- threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
- }
- void transfer(Entry[] newTable, boolean rehash) {
- int newCapacity = newTable.length;
- for (Entry<K,V> e : table) {
- while(null != e) {
- Entry<K,V> next = e.next;
- if (rehash) {
- e.hash = null == e.key ? 0 : hash(e.key);
- }
- int i = indexFor(e.hash, newCapacity);
- e.next = newTable[i];
- newTable[i] = e;
- e = next;
- }
- }
- }
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}</pre><br>
八、Hash應用例子
搜索引擎會通過日誌文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度爲1-255字節。假設目前有一千萬個記錄(這些查詢串的重複度比較高,雖然總數是1千萬,但如果除去重複後,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就是越熱門。),請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。
第一步:Query統計
1、直接排序法
首先我們最先想到的的算法就是排序了,首先對這個日誌裏面的所有Query都進行排序,然後再遍歷排好序的Query,統計每個Query出現的次數了。
但是題目中有明確要求,那就是內存不能超過1G,一千萬條記錄,每條記錄是255Byte,很顯然要佔據2.375G內存,這個條件就不滿足要求了。
讓我們回憶一下數據結構課程上的內容,當數據量比較大而且內存無法裝下的時候,我們可以採用外排序的方法來進行排序,這裏我們可以採用歸併排序,因爲歸併排序有一個比較好的時間複雜度O(nlogn)。
排完序之後我們再對已經有序的Query文件進行遍歷,統計每個Query出現的次數,再次寫入文件中。
綜合分析一下,排序的時間複雜度是O(nlogn),而遍歷的時間複雜度是O(n),因此該算法的總體時間複雜度就是O(n+nlogn)=O(nlogn)。
2、Hash Table法
在第1個方法中,我們採用了排序的辦法來統計每個Query出現的次數,時間複雜度是O(nlogn),那麼能不能有更好的方法來存儲,而時間複雜度更低呢?
題目中說明了,雖然有一千萬個Query,但是由於重複度比較高,因此事實上只有300萬的Query,每個Query 255Byte,因此我們可以考慮把他們都放進內存中去,而現在只是需要一個合適的數據結構,在這裏,Hash Table絕對是我們優先的選擇,因爲Hash Table的查詢速度非常的快,幾乎是O(1)的時間複雜度。
那麼,我們的算法就有了:維護一個Key爲Query字串,Value爲該Query出現次數的HashTable,每次讀取一個Query,如果該字串不在Table中,那麼加入該字串,並且將Value值設爲1;如果該字串在Table中,那麼將該字串的計數加一即可。最終我們在O(n)的時間複雜度內完成了對該海量數據的處理。
本方法相比算法1:在時間複雜度上提高了一個數量級,爲O(n),但不僅僅是時間複雜度上的優化,該方法只需要IO數據文件一次,而算法1的IO次數較多的,因此該算法2比算法1在工程上有更好的可操作性。
第二步:找出Top 10
算法一:普通排序
我想對於排序算法大家都已經不陌生了,這裏不在贅述,我們要注意的是排序算法的時間複雜度是O(nlogn),在本題目中,三百萬條記錄,用1G內存是可以存下的。
算法二:部分排序
題目要求是求出Top 10,因此我們沒有必要對所有的Query都進行排序,我們只需要維護一個10個大小的數組,初始化放入10個Query,按照每個Query的統計次數由大到小排序,然後遍歷這300萬條記錄,每讀一條記錄就和數組最後一個Query對比,如果小於這個Query,那麼繼續遍歷,否則,將數組中最後一條數據淘汰,加入當前的Query。最後當所有的數據都遍歷完畢之後,那麼這個數組中的10個Query便是我們要找的Top10了。
不難分析出,這樣,算法的最壞時間複雜度是N*K, 其中K是指top多少。
算法三:堆
在算法二中,我們已經將時間複雜度由NlogN優化到NK,不得不說這是一個比較大的改進了,可是有沒有更好的辦法呢?
分析一下,在算法二中,每次比較完成之後,需要的操作複雜度都是K,因爲要把元素插入到一個線性表之中,而且採用的是順序比較。這裏我們注意一下,該數組是有序的,一次我們每次查找的時候可以採用二分的方法查找,這樣操作的複雜度就降到了logK,可是,隨之而來的問題就是數據移動,因爲移動數據次數增多了。不過,這個算法還是比算法二有了改進。
基於以上的分析,我們想想,有沒有一種既能快速查找,又能快速移動元素的數據結構呢?回答是肯定的,那就是堆。
藉助堆結構,我們可以在log量級的時間內查找和調整/移動。因此到這裏,我們的算法可以改進爲這樣,維護一個K(該題目中是10)大小的小根堆,然後遍歷300萬的Query,分別和根元素進行對比。
思想與上述算法二一致,只是算法在算法三,我們採用了最小堆這種數據結構代替數組,把查找目標元素的時間複雜度有O(K)降到了O(logK)。
那麼這樣,採用堆數據結構,算法三,最終的時間複雜度就降到了N‘logK,和算法二相比,又有了比較大的改進。
總結:
至此,算法就完全結束了,經過上述第一步、先用Hash表統計每個Query出現的次數,O(N);然後第二步、採用堆數據結構找出Top 10,N*O(logK)。所以,我們最終的時間複雜度是:O(N)+N’*O(logK)。(N爲1000萬,N’爲300萬)。如果各位有什麼更好的算法,歡迎留言評論。
九、參考:
http://www.cnblogs.com/wangjy/archive/2011/09/08/2171638.html
Java集合類之HashMap源碼分析: http://www.open-open.com/lib/view/open1363245226500.html#
http://kb.cnblogs.com/page/189480/
http://blog.csdn.net/paincupid/article/details/47699735
十、轉載請註明: http://blog.csdn.net/paincupid/article/details/47670047