哈希表也叫散列表,哈希表是一種數據結構,它提供了快速的插入操做和查找操做,不管哈希表總中有多少條數據,插入和查找的時間複雜度都是爲O(1),由於哈希表的查找速度很是快,因此在不少程序中都有使用哈希表,例如拼音檢查器。java
哈希表也有本身的缺點,哈希表是基於數組的,咱們知道數組建立後擴容成本比較高,因此當哈希表被填滿時,性能降低的比較嚴重。算法
哈希表採用的是一種轉換思想,其中一箇中要的概念是如何將鍵或者關鍵字轉換成數組下標?在哈希表中,這個過程有哈希函數來完成,可是並非每一個鍵或者關鍵字都須要經過哈希函數來將其轉換成數組下標,有些鍵或者關鍵字能夠直接做爲數組的下標。咱們先來經過一個例子來理解這句話。數組
咱們上學的時候,你們都會有一個學號1-n號中的一個號碼,若是咱們用哈希表來存放班級裏面學生信息的話,咱們利用學號做爲鍵或者關鍵字,這個鍵或者關鍵字就能夠直接做爲數據的下標,不須要經過哈希函數進行轉化。若是咱們須要安裝學生姓名做爲鍵或者關鍵字,這時候咱們就須要哈希函數來幫咱們轉換成數組的下標。微信
哈希函數的做用是幫咱們把非int的鍵或者關鍵字轉化成int,能夠用來作數組的下標。好比咱們上面說的將學生的姓名做爲鍵或者關鍵字,這是就須要哈希函數來完成,下圖是哈希函數的轉換示意圖。數據結構
哈希函數的寫法有不少中,咱們來看看HashMap中的哈希函數多線程
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
HashMap中利用了hashCode來完成這個轉換。哈希函數無論怎麼實現,都應該知足下面三個基本條件:分佈式
第一點:由於數組的下標是從0開始,因此哈希函數生成的哈希值也應該是非負數函數
第二點:同一個key生成的哈希值應該是同樣的,由於咱們須要經過key查找哈希表中的數據微服務
第三點:看起來很是合理,可是兩個不同的值經過哈希函數以後可能才生相同的值,由於咱們把巨大的空間轉出成較小的數組空間時,不能保證每一個數字都映射到數組空白處。因此這裏就會才生衝突,在哈希表中咱們稱之爲哈希衝突性能
哈希衝突是不可避免的,咱們經常使用解決哈希衝突的方法有兩種開放地址法和鏈表法
在開放地址法中,若數據不能直接存放在哈希函數計算出來的數組下標時,就須要尋找其餘位置來存放。在開放地址法中有三種方式來尋找其餘的位置,分別是線性探測、二次探測、再哈希法
在線性探測哈希表中,數據的插入是線性的查找空白單元,例如咱們將數88通過哈希函數後獲得的數組下標是16,可是在數組下標爲16的地方已經存在元素,那麼就找17,17還存在元素就找18,一直往下找,直到找到空白地方存放元素。咱們來看下面這張圖
咱們向哈希表中添加一個元素錢多多
,錢多多
通過哈希函數後獲得的數組下標爲0
,可是在0
的位置已經有張三
了,因此下標往前移,直到下標4
才爲空,因此就將元素錢多多
添加到數組下標爲4
的地方。
線性探測哈希表的插入實現起來也很是簡單,咱們來看看哈希表的插入代碼
/** * 哈希函數 * @param key * @return */ private int hash(int key) { return (key % size); } /** * 插入 * @param student */ public void insert(Student student){ int key = student.getKey(); int hashVal = hash(key); while (array[hashVal] !=null && array[hashVal].getKey() !=-1){ ++hashVal; // 若是超過數組大小,則從第一個開始找 hashVal %=size; } array[hashVal] = student; }
測試插入
public static void main(String[] args) { LinearProbingHash hash = new LinearProbingHash(10); Student student = new Student(1,"張三"); Student student1 = new Student(2,"王強"); Student student2 = new Student(5,"張偉"); Student student3 = new Student(11,"寶強"); hash.insert(student); hash.insert(student1); hash.insert(student2); hash.insert(student3); hash.disPlayTable(); }
按照上面學習的線性探測知識,student
和student2
哈希函數獲得的值應該都爲1
,因爲1
已經被student
佔據,下標爲2
的位置被student1
佔據,因此student2
只能存放在下標爲3
的位置。下圖爲測試結果。
線性探測哈希表的查找過程有點兒相似插入過程。咱們經過散列函數求出要查找元素的鍵值對應的散列值,而後比較數組中下標爲散列值的元素和要查找的元素。若是相等,則說明就是咱們要找的元素;不然就順序日後依次查找。若是遍歷到數組中的空閒位置,尚未找到,就說明要查找的元素並無在哈希表中。
線性探測哈希表的查找代碼
/** * 查找 * @param key * @return */ public Student find(int key){ int hashVal = hash(key); while (array[hashVal] !=null){ if (array[hashVal].getKey() == key){ return array[hashVal]; } ++hashVal; hashVal %=size; } return null; }
線性探測哈希表的刪除相對來講比較複雜一點,咱們不能簡單的把這一項數據刪除,讓它變成空,爲何呢?
線性探測哈希表在查找的時候,一旦咱們經過線性探測方法,找到一個空閒位置,咱們就能夠認定哈希表中不存在這個數據。可是,若是這個空閒位置是咱們後來刪除的,就會致使原來的查找算法失效。原本存在的數據,會被認定爲不存在。?
所以咱們須要一個特殊的數據來頂替這個被刪除的數據,由於咱們的學生學號都是正數,因此咱們用學號等於-1
來表明被刪除的數據。
這樣會帶來一個問題,如何在線性探測哈希表中作了屢次操做,會致使哈希表中充滿了學號爲-1
的數據項,使的哈希表的效率降低,因此不少哈希表中沒有提供刪除操做,即便提供了刪除操做的,也儘可能少使用刪除函數。
線性探測哈希表的刪除代碼實現
/** * 刪除 * @param key * @return */ public Student delete(int key){ int hashVal = hash(key); while (array[hashVal] !=null){ if (array[hashVal].getKey() == key){ Student temp = array[hashVal]; array[hashVal]= noStudent; return temp; } ++hashVal; hashVal %=size; } return null; }
在線性探測哈希表中,數據會發生彙集,一旦彙集造成,它就會變的愈來愈大,那些哈希函數後落在彙集範圍內的數據項,都須要一步一步日後移動,而且插入到彙集的後面,所以彙集變的越大,彙集增加的越快。這個就像咱們在逛超市同樣,當某個地方人不少時,人只會愈來愈多,你們都只是想知道這裏在幹什麼。
二次探測是防止彙集產生的一種嘗試,思想是探測相隔較遠的單元,而不是和原始位置相鄰的單元。在線性探測中,若是哈希函數獲得的原始下標是x,線性探測就是x+1,x+2,x+3......,以此類推,而在二次探測中,探測過程是x+1,x+4,x+9,x+16,x+25......,以此類推,到原始距離的步數平方,爲了方便理解,咱們來看下面這張圖
仍是使用線性探測中的例子,在線性探測中,咱們從原始探測位置每次日後推一位,最後找到空位置,在線性探測中咱們找到錢多多
的存儲位置須要通過4步。在二次探測中,每次是原始距離步數的平方,因此咱們只須要兩次就找到錢多多
的存儲位置。
二次探測消除了線性探測的彙集問題,這種彙集問題叫作原始彙集,然而,二次探測也產生了新的彙集問題,之因此會產生新的彙集問題,是由於全部映射到同一位置的關鍵字在尋找空位時,探測的位置都是同樣的。
好比講一、十一、2一、3一、41依次插入到哈希表中,它們映射的位置都是1,那麼11須要以一爲步長探測,21須要以四爲步長探測,31須要爲九爲步長探測,41須要以十六爲步長探測,只要有一項映射到1的位置,就須要更長的步長來探測,這個現象叫作二次彙集。
二次彙集不是一個嚴重的問題,由於二次探測不怎麼使用,這裏我就不貼出二次探測的源碼,由於雙哈希是一種更加好的解決辦法。
雙哈希是爲了消除原始彙集和二次彙集問題,無論是線性探測仍是二次探測,每次的探測步長都是固定的。雙哈希是除了第一個哈希函數外再增長一個哈希函數用來根據關鍵字生成探測步長,這樣即便第一個哈希函數映射到了數組的同一下標,可是探測步長不同,這樣就可以解決彙集的問題。
第二個哈希函數必須具有以下特色
stepSize = constant-(key%constant);
形式的哈希函數效果很是好,constant
是一個質數而且小於數組容量咱們將上面的添加改變成雙哈希探測,示意圖以下:
雙哈希的哈希表寫起來來線性探測差很少,就是把探測步長經過關鍵字來生成
/** * 根據關鍵字生成探測步長 * @param key * @return */ private int stepHash(int key) { return 7 - (key % 7); }
/** * 雙哈希插入 * * @param student */ public void insert(Student student) { int key = student.getKey(); int hashVal = hash(key); // 獲取步長 int stepSize = stepHash(key); while (array[hashVal] != null && array[hashVal].getKey() != -1) { hashVal +=stepSize; // 若是超過數組大小,則從第一個開始找 hashVal %= size; } array[hashVal] = student; }
/** * 雙哈希查找 * * @param key * @return */ public Student find(int key) { int hashVal = hash(key); int stepSize = stepHash(key); while (array[hashVal] != null) { if (array[hashVal].getKey() == key) { return array[hashVal]; } hashVal +=stepSize; hashVal %= size; } return null; }
/** * 雙哈希刪除 * * @param key * @return */ public Student delete(int key) { int hashVal = hash(key); int stepSize = stepHash(key); while (array[hashVal] != null) { if (array[hashVal].getKey() == key) { Student temp = array[hashVal]; array[hashVal] = noStudent; return temp; } hashVal +=stepSize; hashVal %= size; } return null; }
雙哈希的實現比較簡單,可是雙哈希有一個特別高的要求就是表的容量須要是一個質數,爲何呢?
假設咱們哈希表的容量爲15,某個關鍵字通過雙哈希函數後獲得的數組下標爲0,步長爲5。那麼這個探測過程是0,5,10,0,5,10,一直只會嘗試這三個位置,永遠找不到空白位置來存放,最終會致使崩潰。
若是咱們哈希表的大小爲13,某個關鍵字通過雙哈希函數後獲得的數組下標爲0,步長爲5。那麼這個探測過程是0,5,10,2,7,12,4,9,1,6,11,3。會查找到哈希表中的每個位置。
使用開放地址法,無論使用那種策略都會有各類問題,開放地址法不怎麼使用,在開放地址法中使用較多的是雙哈希策略。
開放地址法中,經過在哈希表中再尋找一個空位解決衝突的問題,還有一種更加經常使用的辦法是使用鏈表法來解決哈希衝突。鏈表法相對簡單不少,鏈表法是每一個數組對應一條鏈表。當某項關鍵字經過哈希後落到哈希表中的某個位置,把該條數據添加到鏈表中,其餘一樣映射到這個位置的數據項也只須要添加到鏈表中,並不須要在原始數組中尋找空位來存儲。下圖是鏈表法的示意圖。
鏈表法解決哈希衝突代碼比較簡單,可是代碼比較多,由於須要維護一個鏈表的操做,咱們這裏採用有序鏈表,有序鏈表不能加快成功的查找,可是能夠減小不成功的查找時間,由於只要有一項比查找值大,就說明沒有咱們須要查找的值,刪除時間跟查找時間同樣,有序鏈表可以縮短刪除時間。可是有序鏈表增長了插入時間,咱們須要在有序鏈表中找到正確的插入位置。
public class SortedLinkList { private Link first; public SortedLinkList(){ first = null; } /** *鏈表插入 * @param link */ public void insert(Link link){ int key = link.getKey(); Link previous = null; Link current = first; while (current!=null && key >current.getKey()){ previous = current; current = current.next; } if (previous == null) first = link; else previous.next = link; link.next = current; } /** * 鏈表刪除 * @param key */ public void delete(int key){ Link previous = null; Link current = first; while (current !=null && key !=current.getKey()){ previous = current; current = current.next; } if (previous == null) first = first.next; else previous.next = current.next; } /** * 鏈表查找 * @param key * @return */ public Link find(int key){ Link current = first; while (current !=null && current.getKey() <=key){ if (current.getKey() == key){ return current; } current = current.next; } return null; } public void displayList(){ System.out.print("List (first-->last): "); Link current = first; while (current !=null){ current.displayLink(); current = current.next; } System.out.println(" "); } }
在鏈表法中因爲產生哈希衝的元素都存放在鏈表中,因此鏈表法的插入很是簡單,只須要在對應下標的鏈表中添加一個元素便可。
/** * 鏈表法插入 * * @param data */ public void insert(int data) { Link link = new Link(data); int key = link.getKey(); int hashVal = hash(key); array[hashVal].insert(link); }
/** * 鏈表法-查找 * * @param key * @return */ public Link find(int key) { int hashVal = hash(key); return array[hashVal].find(key); }
鏈表法中的刪除就不須要向開放地址法那樣將元素置爲某個特定值,鏈表法中只須要找到相應的鏈表將這一項直接移除。
/** * 鏈表法-刪除 * * @param key */ public void delete(int key) { int hashVal = hash(key); array[hashVal].delete(key); }
在哈希表中執行插入和搜索操做均可以達到O(1)的時間複雜度,在沒有哈希衝突的狀況下,只須要使用一次哈希函數就能夠插入一個新數據項或者查找到一個已經存在的數據項。
若是發生哈希衝突,插入和查找的時間跟探測長度成正比關係,探測長度取決於裝載因子,裝載因子是用來表示空位的多少
裝載因子的計算公式:
裝載因子 = 表中已存的元素 / 表的長度
裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會降低。
若是使用開放地址法,對於小型的哈希表,雙哈希法要比二次探測的效果好,若是內存充足而且哈希表一經建立,就再也不修改其容量,在這種狀況下,線性探測效果相對比較好,實現起來也比較簡單,在裝載因子低於0.5的狀況下,基本沒有什麼性能降低。
若是在建立哈希表時,不知道將來存儲的數據有多少,使用鏈表法要比開放地址法好,若是使用開放地址法,隨着裝載因子的變大,性能會直線降低。
當二者均可以選時,使用鏈表法,由於鏈表法對應不肯定性更強,當數據超過預期時,性能不會直線降低。
哈希表在JDK中有很多的實現,例如HahsMap
、HashTable
等,對哈希表感興趣的能夠閱讀本文後去查看JDK的相應實現,相信這能夠加強你對哈希表的理解。
打個小廣告,平頭哥給你們整理了一份較全面的 Java 學習資料,包含Java基礎、Java進階、多線程、虛擬機、微服務、Springboot、分佈式組件等,歡迎掃碼關注微信公衆號:「平頭哥的技術博文」領取,一塊兒進步吧。