Map和Set,简单模拟实现哈希表以及哈希表部分底层源码的分析
目錄
- Map和Set的簡單介紹
- 降低哈希沖突發生的概率以及當沖突發生時如何解決哈希沖突
- 簡單模擬實現哈希表--1.key為整形;2.key為引用類型
- 哈希表部分底層源碼的分析
1.Map和Set的簡單介紹
1.1.Map的說明
Map :Key-Value 模型,什么是key - value模型呢,就比如梁山好漢的江湖綽號:豹子頭 - 林沖 等等。Map 中存儲的就是 key-value的鍵值對, Map 是一個接口類,該類沒有繼承自 Collection ,該類中存儲的是 <K,V> 結構的鍵值對,并且 K 一定是唯一的,不 能重復 。1.2.Map方法的介紹
| 方法 | 解釋 |
| V get (Object key) | 返回 key 對應的 value |
| V getOrDefault (Object key, V defaultValue) | 返回 key 對應的 value , key 不存在,返回默認值 |
| V put (K key, V value) | 設置 key 對應的 value |
| V remove (Object key) | 刪除 key 對應的映射關系 |
| Set<K> keySet () | 返回所有 key 的不重復集合 |
| Collection<V> values () | 返回所有 value 的可重復集合 |
| Set<Map.Entry<K, V>> entrySet () | 返回所有的 key-value 映射關系 |
| boolean containsKey (Object key) | 判斷是否包含 key |
| boolean containsValue (Object value) | 判斷是否包含 value |
| Map 底層結構 | TreeMap | HashMap |
| 底層結構 | 紅黑樹 | 哈希桶 |
| 插入 / 刪除 / 查找時間 復雜度 | O(log2^N) | O(1) |
| 是否有序 | 關于key有序 | 無序 |
| 線程安全 | 不安全 | 不安全 |
| 插入/刪除/查找區別 | 需要進行元素比較 | 通過哈希函數計算哈希地址 |
| 比較與覆寫 | key必須能夠比較,否則會拋出 ClassCastException異常 | 自定義類型需要覆寫equals和 hashCode方法 |
| 應用場景 | 需要 Key 有序場景下 | Key 是否有序不關心,需要更高的 時間性能 |
?所以下面這段代碼運行起來一定會把Set集合中存放的map中的每一個元素都輸出出來:
public static void main(String[] args) {Map<String, Integer> map = new HashMap<>();map.put("hello",2);map.put("world",1);map.put("bit",3);Set<Map.Entry<String, Integer>> entrySet = map.entrySet();for (Map.Entry<String,Integer> entry:entrySet) {System.out.println("key: "+entry.getKey()+" val: "+entry.getValue());} }該內部類Entry提供的一些方法也是比較重要的:
| 方法 | 解釋 |
| K getKey () | 返回 entry 中的 key |
| V getValue () | 返回 entry 中的 value |
| V setValue(V value) | 將鍵值對中的 value 替換為指定 value |
1.3.Set的說明
Set 與 Map 主要的不同有兩點: Set 是繼承自 Collection 的接口類, Set 中只存儲了 Key 。1.4.Set方法的介紹
| 方法 | 解釋 |
| boolean add (E e) | 添加元素,但重復元素不會被添加成功 |
| void clear () | 清空集合 |
| boolean contains (Object o) | 判斷 o 是否在集合中 |
| Iterator<E> iterator () | 返回迭代器 |
| boolean remove (Object o) | 刪除集合中的 o |
| int size() | 返回set 中元素的個數 |
| boolean isEmpty() | 檢測 set 是否為空,空返回 true ,否則返回 false |
| Object[] toArray() | 將 set 中的元素轉換為數組返回 |
| boolean containsAll(Collection<?> c) | 集合 c 中的元素是否在 set 中全部存在,是返回 true ,否則返回false |
| boolean addAll(Collection<? extends E> c) | 將集合 c 中的元素添加到 set 中,可以達到去重的效果 |
Set的注意事項:
1. Set 是繼承自 Collection 的一個接口類。 2. Set 中只存儲了 key ,并且要求 key 一定要唯一。 3. Set 的底層是使用 Map 來實現的,其使用 key 與 Object 的一個默認對象作為鍵值對插入到 Map 中的。 4. Set 最大的功能就是對集合中的元素進行去重。 5. 實現 Set 接口的常用類有 TreeSet 和 HashSet ,還有一個 LinkedHashSet , LinkedHashSet 是在 HashSet 的基礎上維護了一個雙向鏈表來記錄元素的插入次序。 6. Set 中的 Key 不能修改,如果要修改,先將原來的刪除掉,然后再重新插入。 7. Set 中不能插入 null 的 key 。 TreeSet 和 HashSet 的區別 :| Set 底層結構 | TreeSet | HashSet |
| 底層結構 | 紅黑樹 | 哈希桶 |
| 插入/刪除/查找時間復雜度 | O(log2^N) | O(1) |
| 是否有序 | 關于 Key 有序 | 不一定有序 |
| 線程安全 | 不安全 | 不安全 |
| 插入/刪除/查找區別 | 按照紅黑樹的特性來進行插入和刪除 | 1. 先計算key哈希地址 2. 然后進行 插入和刪除 |
| 比較與覆寫 | key必須能夠比較,否則會拋出 ClassCastException異常 | 自定義類型需要覆寫equals和 hashCode方法 |
| 應用場景 | 需要Key有序場景下 | Key 是否有序不關心,需要更高的 時間性能 |
為什么HashMap和HashSet無序,而TreeMap和TreeSet有序??后面會解釋到。
2.降低哈希沖突發生的概率以及當沖突發生時如何解決哈希沖突
2.1.概念
不同關鍵字通過相同哈希哈數計算出相同的哈希地址,該種現象稱為哈希沖突或哈希碰撞。 把具有不同關鍵碼而具有相同哈希地址的數據元素稱為“同義詞”。?2.2.降低哈希沖突的發生的概率
兩種解決方法
1.設計好的哈希函數;2.降低負載因子
2.2.1.設計好的哈希函數
哈希函數設計原則:- 哈希函數的定義域必須包括需要存儲的全部關鍵碼,而如果散列表允許有m個地址時,其值域必須在0到m-1之間。
- 哈希函數計算出來的地址能均勻分布在整個空間中。
- 哈希函數應該比較簡單。
常用的兩種哈希函數
1. 直接定制法 取關鍵字的某個線性函數為散列地址: Hash ( Key ) = A*Key + B 優點:簡單、均勻。 缺點:需要事先知道關 鍵字的分布情況 使用場景:適合查找比較小且連續的情況。 力扣上這道題可以幫助我們理解: 字符串中第一個只出現一次字符2.?除留余數法
設散列表中允許的 地址數為 m ,取一個不大于 m ,但最接近或者等于 m 的質數 p 作為除數,按照哈希函數: Hash(key) = key% p(p<=m), 將關鍵碼轉換成哈希地址?2.2.2.降低負載因子
下圖是沖突率和負載因子的關系圖:
?從圖中我們可以直到要想降低沖突的概率,只能減小負載因子,而負載因子又取決于數組的長度。
公式:? ?負載因子 = 哈希表中元素的個數 / 數組的長度
因為哈希表中的已有的元素個數是不可變的,所以我們只能通過增大數組長度來降低負載因子。
2.3.當沖突發生時如何解決哈希沖突(簡單介紹)
解決哈希沖突 兩種常見的方法是: 閉散列 和 開散列 閉散列:有兩種(線性探測法&&二次探測法) 閉散列:也叫開放定址法,當發生哈希沖突時,如果哈希表未被裝滿,說明在哈希表中必然還有空位置,那么可以 把 key 存放到沖突位置中的 “ 下一個 ” 空位置中去。 開散列:它的叫法有很多,也叫做哈希桶/鏈地址法/拉鏈法 開散列: 首先對關鍵碼集合用散列函數計算散列地址,具有相同地址的關鍵碼歸于同一子 集合,每一個子集合稱為一個桶,各個桶中的元素通過一個單鏈表鏈接起來,各鏈表的頭結點存儲在哈希表中。開散列,可以認為是把一個在大集合中的搜索問題轉化為在小集合中做搜索了。?參照下圖:3.簡單模擬實現哈希表
3.1.哈希表概念
我們之前學過的順序結構和平衡樹中,查找一個元素時,都要經過關鍵碼的多次比較。順序查找的效率O(N),平衡樹的查找效率O(logN)。這些都不是我們想要的搜索方法,我們想要的搜索方法是O(1),可以不經過任何比較,一次直接從表中得到要搜索的元素。 如果構造一種存儲結構,通過某種函數(hashFunc)使元素的存儲位置與它的關鍵碼之間能夠建立一一映射的關系,那么在查找時通過該函數可以很快找到該元素。向該結構中插入元素時以某種哈希函數插入,取元素的時候,也通過該哈希函數取出來,該方式即為哈希(散列)方法,構造出來的結構稱為哈希表(Hash?Table)(或者稱散列表)
但該種方法插入元素的時候,也有一定的缺陷,就是一定會存在哈希沖突,但是可以接受。
3.2.哈希表的簡單實現
代碼實現
public class HashBuck {static class Node {public int key;public int val;public Node next;public Node(int key, int val) {this.key = key;this.val = val;}}public Node[] array;public int usedSize;public static final double DEFAULT_LOAD_FACTOR = 0.75;//負載因子public static final int DEFAULT_SIZE = 8;public HashBuck() {this.array = new Node[DEFAULT_SIZE];}//插入數據public void put(int key, int val) {Node node = new Node(key, val);int index = key % array.length;Node cur = array[index];//檢查桶里面有無相同key的元素,有則覆蓋val,沒有則頭插while(cur != null) {if(cur.key == key) {cur.val = val;return;}cur = cur.next;}//沒有return就進行頭插,底層是尾插node.next = array[index];array[index] = node;this.usedSize++;//檢查負載因子if(loadFactor() >= DEFAULT_LOAD_FACTOR) {reSize();}}private double loadFactor() {return this.usedSize * 1.0 / array.length;}//擴容private void reSize() {//申請一個兩倍大小的數組Node[] newArray = new Node[2 * array.length];//重新哈希for (int i = 0; i < array.length; i++) {Node cur = array[i];while(cur != null) {//找每個下標中哈希桶里的每個結點重新哈希后的下標int index = cur.key % newArray.length;Node curNext = cur.next;//注意先保存cur.next = newArray[index];newArray[index] = cur;cur = cur.next;}}array = newArray;}//根據key獲取valpublic int get(int key) {int index = key % array.length;Node cur = array[index];while(cur != null) {if(cur.key == key) {return cur.val;}cur = cur.next;}return -1;} }?說明:以上的代碼只是簡單的實現了兩個重要的函數:插數據和取數據
并且只是簡單的實現,底層的樹化并沒有實現。
問題--》
問題一:以上代碼的key是整形,所以找地址的時候,可以直接用 key % array.length,如果我的key是一個引用類型呢???,我怎么找地址???
下面這段代碼,兩者的 id 都一樣,運行結果卻不一樣,這就和我們剛剛的相同的key發生沖突就不一致了。
class Person {public String id;public Person(String id) {this.id = id;}@Overridepublic String toString() {return "Person{" +"id=" + id +'}';} } public class Test {public static void main(String[] args) {Person person1 = new Person("10101");Person person2 = new Person("10101");System.out.println(person1.hashCode());System.out.println(person2.hashCode());} }正確的處理方法:重寫hashCode()方法
class Person {public String id;public Person(String id) {this.id = id;}@Overridepublic String toString() {return "Person{" +"id=" + id +'}';}@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;Person person = (Person) o;return id == person.id;}@Overridepublic int hashCode() {return Objects.hash(id);} } public class Test {public static void main(String[] args) {Person person1 = new Person("10101");Person person2 = new Person("10101");System.out.println(person1.hashCode());System.out.println(person2.hashCode());} }1.為什么引用類型就要談到 hashCode() ??
因為如果key是引用類型,就不能通過模上數組的長度來尋址了。而 hashCode() 作用就是返回對象的哈希代碼值,簡單來說,他就是一個整數
2.按道理來說,學號相同的兩個對象應該是同一個人,為什么重寫 hashCode(),返回對象的哈希代碼值才會一樣,不重寫為什么會導致最終在數組中尋找的地址不相同??
因為底層的hashCode()是Object類的方法,底層是由C/C++代碼寫的,我們是看不到,但是因為它是根據對象的存儲位置來返回的哈希代碼值,這里就可以解釋了,person1和person2本質上就是兩個不同的對象,在內存中存儲的地址也不同,所以最終返回的哈希代碼值必然是不相同的,哈希代碼值不同,那么在數組中根據 hash % array.length 尋找的地址也就不相同。而重寫 hashCode() 方法之后,咱們根據 Person 中的成員變量 id 來返回對應的哈希代碼值,這就相當于當一個對象,多次調用,那么返回的哈希代碼值就必然相同。
?所以我們的哈希表的實現就可以相應的改寫成這樣:
public class HashBuck<K,V> {static class Node<K,V> {public K key;public V val;public Node<K,V> next;public Node(K key,V val) {this.key = key;this.val = val;}}//往期泛型博客有具體講到數組為什么這樣寫public Node<K,V>[] array = (Node<K,V>[]) new Node[10];public int usedSize;public static final double DEFAULT_LOAD_FACTOR = 0.75;public void put(K key, V val) {Node<K,V> node = new Node<>(key,val);int hash = key.hashCode();int index = hash % array.length;Node<K,V> cur = array[index];while(cur != null) {if(cur.key.equals(key)) {cur.val = val;return;}cur = cur.next;}//頭插node.next = array[index];array[index] = node;this.usedSize++;if(loadFactor() >= DEFAULT_LOAD_FACTOR) {reSize();}}private double loadFactor() {return this.usedSize * 1.0 / array.length;}private void reSize() {Node<K,V>[] newArray = (Node<K, V>[]) new Node[2 * array.length];for (int i = 0; i < array.length; i++) {Node<K,V> cur = array[i];while (cur != null) {Node<K,V> curNext = cur.next;int hash = cur.key.hashCode();int index = hash % newArray.length;cur.next = newArray[index];newArray[index] = cur;cur = cur.next;}}array = newArray;}public V get(K key) {int hash = key.hashCode();int index = hash % array.length;Node<K,V> cur = array[index];while(cur != null) {if(cur.key == key) {return cur.val;}cur = cur.next;}return null;} } 性能分析 雖然哈希表一直在和沖突做斗爭,但在實際使用過程中,我們認為哈希表的沖突率是不高的,沖突個數是可控的,也就是每個桶中的鏈表的長度是一個常數,所以,通常意義下,我們認為哈希表的插入 / 刪除 / 查找時間復雜度是 O(1)面試問題一:hashCode()和equals() 在HashMap中的作用分別是什么???
hashCode():用來找元素在數組中的位置;
equals():用來比較數組下鏈表中的每個元素的 key 與我的 key 是否相同。
equals也一樣,如果不重寫,上面的person1和person2的比較結果必然是不相同。
hashCode()和equals()就好比查字典,比如要查美麗,肯定要先查美字在多少頁--hashCode(),然后它的組詞有美景,美女,美麗,equals()就能找到美麗。
面試問題二:如果hashCode一樣,那么equals一定一樣嗎? 如果equals一樣,hashCode一定一樣嗎??
答案肯定是不一定,一定。
同一個地址下鏈表中的key不一定一樣,就好比數組長度為10,4和14找到的都是4下標。
而equals一樣,hashCode就一定一樣,4和4肯定都在4下標。
所以這時候再回過頭來看HashMap數據的打印時,就能明白HashMap和HashSet為什么無序了,它本身就不是一個順序結構,,
?至于TreeMap和TreeSet為啥有序,這就和我們之前學過的優先級隊列是一個道理了。(整形的key,輸出時,自然而然就排好序了,如果key是引用類型,則需要實現Comparable接口,或者傳比較器)
4.哈希表部分底層源碼的分析
哈希表底層部分成員屬性的分析:?
面試問題:以下兩個桶的數組容量分別是多大?
HashMap<String,Integer> map = new HashMap<>(19); //桶1HashMap<String,Integer> map = new HashMap<>(); //桶2剛剛我們分析了成員屬性和成員方法,桶的只是定義了,并沒有看見給桶開辟大小??那我們如何put 進去元素呢?
首先可以確定的是桶 2 的大小為 0,至于為什么沒開辟空間也可以 put 元素,我們就需要分析底層的 put 函數,接下來我們帶著疑惑繼續分析源碼,,
??結論:
1.桶2的默認大小是0,但是在put進去第一個元素時,它的容量就擴容為了16.
2.我們可以看到底層尋址的方式不是 hash?% array.length,而是 (n-1) & hash,因為?JDK規定數組的長度必須是 2 的某個次冪。因為當 n 是 2 的某個次冪時,hash % array.length 與(n-1) & hash 得到的值是一樣的,并且位運算的效率高。所以桶1的容量就不是19,而是2的某個次冪向上取整,所以桶1大小為32,我們可以繼續看帶一個參數的構造方法的源碼:
本期到此結束,謝謝觀看!!!
總結
以上是生活随笔為你收集整理的Map和Set,简单模拟实现哈希表以及哈希表部分底层源码的分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 从猎豹移动到瑞幸咖啡,看中国企业在海外的
- 下一篇: 高斯模糊原理