這篇博客主要探討Hash表中的一些原理/概念,及根據這些原理/概念,自己設計一個用來存放/查找數據的Hash表,並且與JDK中的HashMap類進行比較。
我們分一下七個步驟來進行。
一。 Hash表概念
二 . Hash構造函數的方法,及適用範圍
三. Hash處理衝突方法,各自特徵
四. Hash查找過程
五. 實現一個使用Hash存數據的場景-------Hash查找算法,插入算法
六. JDK中HashMap的實現
七. Hash表與HashMap的對比,性能分析
一。 Hash表概念
在查找表中我們已經說過,在Hash表中,記錄在表中的位置和其關鍵字之間存在着一種確定的關係。這樣 我們就能預先知道所查關鍵字在表中的位置,從而直接通過下標找到記錄。使ASL趨近與0.
1) 哈希(Hash)函數是一個映象,即: 將關鍵字的集合映射到某個地址集合上,它的設置很靈活,只要這個地 址集合的大小不超出允許範圍即可;
2) 由於哈希函數是一個壓縮映象,因此,在一般情況下,很容易產生“衝突”現象,即: key1¹ key2,而 f (key1) = f(key2)。
3). 只能儘量減少衝突而不能完全避免衝突,這是因爲通常關鍵字集合比較大,其元素包括所有可能的關鍵字, 而地址集合的元素僅爲哈希表中的地址值
在構造這種特殊的“查找表” 時,除了需要選擇一個“好”(儘可能少產生衝突)的哈希函數之外;還需要找到一 種“處理衝突” 的方法。
二 . Hash構造函數的方法,及適用範圍
- 直接定址法
- 數字分析法
- 平方取中法
- 摺疊法
- 除留餘數法
- 隨機數法
(1)直接定址法:
哈希函數爲關鍵字的線性函數,H(key) = key 或者 H(key) = a ´ key + b
此法僅適合於:地址集合的大小 = = 關鍵字集合的大小,其中a和b爲常數。
(2)數字分析法:
假設關鍵字集合中的每個關鍵字都是由 s 位數字組成 (u1, u2, …, us),分析關鍵字集中的全體, 並從中提取分佈均勻的若干位或它們的組合作爲地址。
此法適於:能預先估計出全體關鍵字的每一位上各種數字出現的頻度。
(3)平方取中法:
以關鍵字的平方值的中間幾位作爲存儲地址。求“關鍵字的平方值” 的目的是“擴大差別” ,同 時平方值的中間各位又能受到整個關鍵字中各位的影響。
此法適於:關鍵字中的每一位都有某些數字重複出現頻度很高的現象。
(4)摺疊法:
將關鍵字分割成若干部分,然後取它們的疊加和爲哈希地址。兩種疊加處理的方法:移位疊加:將分 割後的幾部分低位對齊相加;間界疊加:從一端沿分割界來回摺疊,然後對齊相加。
此法適於:關鍵字的數字位數特別多。
(5)除留餘數法:
設定哈希函數爲:H(key) = key MOD p ( p≤m ),其中, m爲表長,p 爲不大於 m 的素數,或 是不含 20 以下的質因子
(6)隨機數法:
設定哈希函數爲:H(key) = Random(key)其中,Random 爲僞隨機函數
此法適於:對長度不等的關鍵字構造哈希函數。
實際造表時,採用何種構造哈希函數的方法取決於建表的關鍵字集合的情況(包括關鍵字的範圍和形態),以及哈希表 長度(哈希地址範圍),總的原則是使產生衝突的可能性降到儘可能地小。
三. Hash處理衝突方法,各自特徵
“處理衝突” 的實際含義是:爲產生衝突的關鍵字尋找下一個哈希地址。
- 開放定址法
- 再哈希法
- 鏈地址法
(1)開放定址法:
爲產生衝突的關鍵字地址 H(key) 求得一個地址序列: H0, H1, H2, …, Hs 1≤s≤m-1,Hi = ( H(key) +di ) MOD m,其中: i=1, 2, …, s,H(key)爲哈希函數;m爲哈希表長;
(2)鏈地址法:
將所有哈希地址相同的記錄都鏈接在同一鏈表中。
(3)再哈希法:
方法:構造若干個哈希函數,當發生衝突時,根據另一個哈希函數計算下一個哈希地址,直到衝突不再發 生。即:Hi=Rhi(key) i=1,2,……k,其中:Rhi——不同的哈希函數,特點:計算時間增加
四. Hash查找過程
對於給定值 K,計算哈希地址 i = H(K),若 r[i] = NULL 則查找不成功,若 r[i].key = K 則查找成功, 否則 “求 下一地址 Hi” ,直至r[Hi] = NULL (查找不成功) 或r[Hi].key = K (查找成功) 爲止。
五. 實現一個使用Hash存數據的場景-------Hash查找算法,插入算法
假設我們要設計的是一個用來保存中南大學所有在校學生個人信息的數據表。因爲在校學生數量也不是特別巨大(8W?),每個學生的學號是唯一的,因此,我們可以簡單的應用直接定址法,聲明一個10W大小的數組,每個學生的學號作爲主鍵。然後每次要添加或者查找學生,只需要根據需要去操作即可。
但是,顯然這樣做是很腦殘的。這樣做系統的可拓展性和複用性就非常差了,比如有一天人數超過10W了?如果是用來保存別的數據呢?或者我只需要保存20條記錄呢?聲明大小爲10W的數組顯然是太浪費了的。
如果我們是用來保存大數據量(比如銀行的用戶數,4大的用戶數都應該有3-5億了吧?),這時候我們計算出來的HashCode就很可能會有衝突了, 我們的系統應該有“處理衝突”的能力,此處我們通過掛鏈法“處理衝突”。
如果我們的數據量非常巨大,並且還持續在增加,如果我們僅僅只是通過掛鏈法來處理衝突,可能我們的鏈上掛了上萬個數據後,這個時候再通過靜態搜索來查找鏈表,顯然性能也是非常低的。所以我們的系統應該還能實現自動擴容,當容量達到某比例後,即自動擴容,使裝載因子保存在一個固定的水平上。
綜上所述,我們對這個Hash容器的基本要求應該有如下幾點:
滿足Hash表的查找要求(廢話)
能支持從小數據量到大數據量的自動轉變(自動擴容)
使用掛鏈法解決衝突
好了,既然都分析到這一步了,咱就閒話少敘,直接開始上代碼吧。
-
public class MyMap<K, V> { private int size;// 當前容量 private static int INIT_CAPACITY = 16;// 默認容量 private Entry<K, V>[] container;// 實際存儲數據的數組對象 private static float LOAD_FACTOR = 0.75f;// 裝載因子 private int max;// 能存的最大的數=capacity*factor // 自己設置容量和裝載因子的構造器 public MyMap(int init_Capaticy, float load_factor) { if (init_Capaticy < 0) throw new IllegalArgumentException("Illegal initial capacity: " + init_Capaticy); if (load_factor <= 0 || Float.isNaN(load_factor)) throw new IllegalArgumentException("Illegal load factor: " + load_factor); this.LOAD_FACTOR = load_factor; max = (int) (init_Capaticy * load_factor); container = new Entry[init_Capaticy]; } // 使用默認參數的構造器 public MyMap() { this(INIT_CAPACITY, LOAD_FACTOR); } /** * 存 * * @param k * @param v * @return */ public boolean put(K k, V v) { // 1.計算K的hash值 // 因爲自己很難寫出對不同的類型都適用的Hash算法,故調用JDK給出的hashCode()方法來計算hash值 int hash = k.hashCode(); //將所有信息封裝爲一個Entry Entry<K, V> temp = new Entry(k, v, hash); if (setEntry(temp, container)) { // 大小加一 size++; return true; } return false; } /** * 擴容的方法 * * @param newSize 新的容器大小 */ private void reSize(int newSize) { // 1.聲明新數組 Entry<K, V>[] newTable = new Entry[newSize]; max = (int) (newSize * LOAD_FACTOR); // 2.複製已有元素,即遍歷所有元素,每個元素再存一遍 for (int j = 0; j < container.length; j++) { Entry<K, V> entry = container[j]; //因爲每個數組元素其實爲鏈表,所以………… while (null != entry) { setEntry(entry, newTable); entry = entry.next; } } // 3.改變指向 container = newTable; } /** * 將指定的結點temp添加到指定的hash表table當中 * 添加時判斷該結點是否已經存在 * 如果已經存在,返回false * 添加成功返回true * * @param temp * @param table * @return */ private boolean setEntry(Entry<K, V> temp, Entry[] table) { // 根據hash值找到下標 int index = indexFor(temp.hash, table.length); //根據下標找到對應元素 Entry<K, V> entry = table[index]; // 3.若存在 if (null != entry) { // 3.1遍歷整個鏈表,判斷是否相等 while (null != entry) { //判斷相等的條件時應該注意,除了比較地址相同外,引用傳遞的相等用equals()方法比較 //相等則不存,返回false if ((temp.key == entry.key || temp.key.equals(entry.key)) && temp.hash == entry.hash && (temp.value == entry.value || temp.value.equals(entry.value))) { return false; } //不相等則比較下一個元素 else if (temp.key != entry.key && temp.value != entry.value) { //到達隊尾,中斷循環 if (null == entry.next) { break; } // 沒有到達隊尾,繼續遍歷下一個元素 entry = entry.next; } } // 3.2當遍歷到了隊尾,如果都沒有相同的元素,則將該元素掛在隊尾 addEntry2Last(entry, temp); } else { // 4.若不存在,直接設置初始化元素 setFirstEntry(temp, index, table); } return true; } private void addEntry2Last(Entry<K, V> entry, Entry<K, V> temp) { if (size > max) { reSize(container.length * 4); } entry.next = temp; } /** * 將指定結點temp,添加到指定的hash表table的指定下標index中 * * @param temp * @param index * @param table */ private void setFirstEntry(Entry<K, V> temp, int index, Entry[] table) { // 1.判斷當前容量是否超標,如果超標,調用擴容方法 if (size > max) { reSize(table.length * 4); } // 2.不超標,或者擴容以後,設置元素 table[index] = temp; //!!!!!!!!!!!!!!! //因爲每次設置後都是新的鏈表,需要將其後接的結點都去掉 //NND,少這一行代碼卡了哥哥7個小時(代碼重構) temp.next = null; } /** * 取 * * @param k * @return */ public V get(K k) { Entry<K, V> entry = null; // 1.計算K的hash值 int hash = k.hashCode(); // 2.根據hash值找到下標 int index = indexFor(hash, container.length); // 3。根據index找到鏈表 entry = container[index]; // 3。若鏈表爲空,返回null if (null == entry) { return null; } // 4。若不爲空,遍歷鏈表,比較k是否相等,如果k相等,則返回該value while (null != entry) { if (k == entry.key || entry.key.equals(k)) { return entry.value; } entry = entry.next; } // 如果遍歷完了不相等,則返回空 return null; } /** * 根據hash碼,容器數組的長度,計算該哈希碼在容器數組中的下標值 * * @param hashcode * @param containerLength * @return */ public int indexFor(int hashcode, int containerLength) { return hashcode & (containerLength - 1); } /** * 用來實際保存數據的內部類,因爲採用掛鏈法解決衝突,此內部類設計爲鏈表形式 * * @param <K>key * @param <V> value */ class Entry<K, V> { Entry<K, V> next;// 下一個結點 K key;// key V value;// value int hash;// 這個key對應的hash碼,作爲一個成員變量,當下次需要用的時候可以不用重新計算 // 構造方法 Entry(K k, V v, int hash) { this.key = k; this.value = v; this.hash = hash; } //相應的getter()方法 } }
代碼中有相當清楚的註釋了
在文章的最後這裏,我要強烈的宣泄下感情
MLGBD,本來以爲分析的挺到位了,寫出這個東西也就最多需要個把小時吧
結果因爲通宵作業,腦袋運轉不靈
硬是花了哥三個小時才寫出了
好不容易些出來了
我日
看着代碼比較混亂
然後就對代碼重構了下
把邏輯抽象清楚,進行重構就花了個多小時
好不容易構造好了
就開始了TMD的一直報錯了----------大數據量測試時到大概5000就死循環了
各種調試,各種分析都覺得沒錯誤
最後花了哥7個小時終於找出來了
我擦
第一次初始化加的時候,因爲每個元素的next都是空的
而擴充容量resize()時,因爲衝突處理是鏈式結構的
當將他們重新hash添加的時候,重複的這些鳥元素的next是有元素的
一定要設置爲null
七.性能分析:
1.因爲衝突的存在,其查找長度不可能達到O(1)
2哈希表的平均查找長度是裝載因子a 的函數,而不是 n 的函數。
3.用哈希表構造查找表時,可以選擇一個適當的裝填因子 ,使得平均查找長度限定在某個範圍內。
最後給出我們這個HashMap的性能
測試代碼
- public class Test {
- public static void main(String[] args) {
- MyMap<String, String> mm = new MyMap<String, String>();
- Long aBeginTime=System.currentTimeMillis();//記錄BeginTime
- for(int i=0;i<1000000;i++){
- mm.put(""+i, ""+i*100);
- }
- Long aEndTime=System.currentTimeMillis();//記錄EndTime
- System.out.println("insert time-->"+(aEndTime-aBeginTime));
- Long lBeginTime=System.currentTimeMillis();//記錄BeginTime
- mm.get(""+100000);
- Long lEndTime=System.currentTimeMillis();//記錄EndTime
- System.out.println("seach time--->"+(lEndTime-lBeginTime));
- }
- }
100W個數據時,全部存儲時間爲1S多一點,而搜尋時間爲0
insert time-->1536 seach time--->0 |