Hash表分析以及Java實現
這篇部落格主要探討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表的查詢要求(廢話)
能支援從小資料量到大資料量的自動轉變(自動擴容)
使用掛鏈法解決衝突
好了,既然都分析到這一步了,咱就閒話少敘,直接開始上程式碼吧。
Java程式碼
-
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的效能
測試程式碼
Java程式碼- 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 |