Java中的HashMap類實現簡介

1 數據查詢問題

  HashMap的出現主要來着與對查詢操做速度的要求。實際中,假若有一個表,一般須要快速查詢到某個數值是否包含在該表中。

1.1 一個實際問題,整數數組

  如何快速的在一個數據集合A中查詢是否包含某個數據a
  例如:一個int[100]數組A,包含了100個數據,如何查找這100個數據中包含「98」這個數。
  • 方法一:使用for循環,將98依次與數組中的每一個數進行比較
  • 方法二:將數組進行升序排列,而後使用二分法查找。
  但不論逐個比較仍是二分法,都須要屢次比較才能查詢到結果,假如每次比較須要的時間相同,那這就意味這每次查詢都須要不一樣的查詢時間,有時短,有時須要很長,從時間複雜度的角度考慮,逐個比較的時間複雜度是O(n),二分法的時間複雜度是O(lgn)。
  能夠想象,理想狀態應該是每次查詢花費的時間相同,有個最大值,這樣就能夠自信的向人介紹本身的查詢算法:好比個人算法每次查詢用時不超過1ms。
如何實現
  一種方法是,簡單的犧牲空間換取時間:
  假設數組中都爲正數,Java中32位的int,可表示的正數範圍是0到2147483647,共2147483648個數值。
  一、創建一個新數組int[] Ints[2147483648],包含2147483648個位置,全部數據都初始化爲-1。
 
  二、將以前100個數值的數組裏的數據依次按照如下規則保存在新數組中:
  若是數據爲i,則將其保存到新數組的Ints [i]位置,98放到Ints [98]
 
  三、如今若是查詢98是否在數組中,那麼只須要比較int[98]中的數據是-1仍是98便可。
  這樣就能夠保證每次查詢只須要進行1次比較,查詢速度快。但這個方法的缺點很明顯:
  佔用了太多空間,2147483648個位置的32位int類型數組,要佔居大約8GB的存儲空間,對目前只有幾G內存的計算機顯然是不現實的。


1.2 另外一個實際問題,號碼簿

  若是有一個手機號碼簿,若是快速查詢某個號碼是否已經在號碼簿中
  假如手機號碼都爲11位,號碼簿中共有10個號碼,且後4位各不相同:
{ 
   286 3545 1285
   250 4592 8502
   239 2085 1032
   230 1932 0543
   259 1937 1408
   251 8592 1459
   252 2309 7934
   249 2942 9285
   289 0103 8482
   279 0094 1342
}

  如何快速查詢號碼251 8592 1459是否在號碼簿中。
  根據上一個示例,因爲手機號碼數值太大沒法用int類型表示,只能採用long類型表示。那能夠定義一個包含1000 0000 0000個數值的long[]數組,但這明顯不現實。不過不論根據平常經驗仍是前面的假設,號碼簿中手機號碼的後4位一般是不一樣的,那就能夠有定義一個包含1 0000個數值的long[]數組L,以手機後4位爲索引值,將電話號碼保存在數組中:
  好比251 8592 1459就能夠保存在L[1459]中
  這樣查詢號碼251 8592 1459是否在號碼簿中,只須要查詢L[1459]的數值是否等於251 8592 1459便可。這樣既節省了空間也加快的查詢速度。

1.2.1 衝突(collisions)

  從上面得例子能夠看出爲了節省空間,只取了手機號後4位,若是兩個手機號的後4位相同,那麼就會產生衝突,這是爲了節省空間帶來的必然結果。爲解決衝突狀況,能夠這樣:
long[]數組L中再也不直接保存手機號碼,而是保存一個地址,這個地址指向一個鏈表,鏈表中保存着電話號碼和指向下個電話號碼的地址,當兩個手機號後4位相同時,只須要將其連接到相應鏈表中便可,好比下圖:
 

1.2.2 空間利用率

  號碼簿的例子中,建立了1 0000個元素的數組,只存放了10個數據,那麼空間利用率只有0.001。能夠想象隨着號碼增多,空間利用率提升,但出現衝突的機率越大,查詢操做的耗時越長。

2  HashMap<K,V>的字面解釋

2.1  Hash,有道詞典中的解釋

中文:
n. 剁碎的食物;混雜,拼湊;從新表述
vt. 搞糟,把…弄亂;切細;推敲
英文:
n.
1. chopped meat mixed with potatoes and browned
2. purified resinous extract of the hemp plant; used as a hallucinogen
v.
chop up



  在計算機科學中,一般指直接或者間接使用了Hash Function來實現功能的實體。
  Hash Function,中文一般翻譯爲哈希函數或者散列函數
  字面理解哈希函數就是將一個變量「切碎」後變成另外一個變量的函數。


2.2  Map有道詞典中的解釋

vt. 映射;計劃;繪製地圖;肯定基因在染色體中的位置
n. 地圖;示意圖;染色體圖
vi. 基因被安置
n.
1. a diagrammatic representation of the earth's surface (or part of it)
2. a function such that for every element of one set there is a unique element of another set
v. 
6. to establish a mapping (of mathematical elements or sets)

  能夠看出HashMap中的map這裏取的是數學中的概念,將一個值「映射」到另外一個值
  HashMap<K,V>中K表明key,V表明Value,中文一般翻譯爲鍵(key)、值(value)
  綜上,HashMap<K,V>就是一個用來存儲<鍵、值>數據對的機制,其中鍵key「映射」到保存值(value)的存儲地址,映射過程使用了哈希函數。也就是鍵(key)通過哈希函數運算後能夠獲得值(value)的地址。
  對上面電話號碼簿的例子,電話號碼簿體現爲HashMap<K,V>的一個實例,鍵key爲手機號,值(value)也爲手機號。鍵(手機號)通過哈希函數運算(取手機號後4位)後能夠獲得值(手機號)的地址。

3  一個更復雜的例子——花名冊

  假若有一個花名冊,如何快速查詢某我的好比「張三」是否在花名冊中。
  這個問題與前2個問題的區別是,要查詢的數據不是單個數字,這就很難利用前2個示例中的方法構建一個易於查詢的花名冊。可是能夠試想,假如能夠經過某種運算將名字變成一個0到10000之間的一個數字,並且名字不一樣時,產生的數字不一樣,那麼就能夠利用上述的方法構建一個易於查詢的花名冊。
  該運算在下文「如何設計合適的哈希函數」一節中有介紹。

4  哈希函數(Hash Function)的定義

  上例中某種運算(將名字變成一個0到10000之間的一個數字)就能夠被稱做是哈希函數。
  哈希函數更專業的定義是:哈希函數是任意一種算法,它能夠將任意長度的原數據映射爲固定長度的結果數據。
  由於哈希函數一般將可變長度的原數據,「切碎(hash)」成固定長度數據,對各部分處理後造成一個固定長度的數據,因此被形象的稱爲哈希函數。
  號碼簿問題中,取電話號碼中的後4位這個運算,就是將一個長數據映射爲了一個短數據,因此也能夠稱爲哈希函數。
  因爲產生的數據長度固定,因此結果數據就能夠用來做爲數組的索引值,在相應位置保存原數據,就能夠加快查詢。
  • 從十進制角度看,若是產生的數據在0-10000之間,也就是4位十進制數時,就能夠建立一個10000個數據的數組,用哈希函數的結果作爲索引值。
  • 從二進制角度看,若是產生的數據在0-0x7F之間,也就是8位二進制數時,就能夠建立一個128個數據的數組,用哈希函數的結果作爲索引值。

5  如何設計合適的哈希函數

  能夠想象爲了減小衝突,加快查詢,不一樣原數據通過哈希運算後產生的數值應該最大可能的不一樣。因此一個優秀的哈希函數必然具備這樣的性質。
   注意:如下內容的敘述從數學理論的角度並不徹底嚴密與準確,且缺乏證實。更嚴謹的學習應該查看相關著做或者參加專門課程。
質數與求模運算正好具備這樣的性質:
  假若有一個質數Z,其遠大於數S,那麼對於運算:
     ( n * Z ) % S
  其中n表明從1到無窮的任意整數,*爲乘法運算,%爲求模運算
  對應任意n,運算的結果均勻的分佈在0到S之間。
  好比對於質數211和數8:
(1*211) % 8 = 3     (67*211) % 8 = 1
(2*211) % 8 = 6     (68*211) % 8 = 4
(3*211) % 8 = 1     (69*211) % 8 = 7
(4*211) % 8 = 4     (70*211) % 8 = 2
(5*211) % 8 = 7     (71*211) % 8 = 5
(6*211) % 8 = 2     (72*211) % 8 = 0
(7*211) % 8 = 5     (73*211) % 8 = 3
(8*211) % 8 = 0     (74*211) % 8 = 6



  因此對於上面花名冊的例子,若是能夠將名字通過哈希運算獲得0到10000之間的數值,就能夠實現快速查詢。因爲字符在電腦中一般用Unicode代碼表示,查出名字的Unicode代碼,「張」的Unicode十進制代碼爲24352,「三」的Unicode十進制代碼爲19977,選取質數9656717,進行如下運算:((24352 + 19977) * 9656717) % 10000 = 5168。這樣就獲得了0到10000之間的數值,參照以前的例子,就能夠構造一個數組來加快查詢。
  Unicode代碼查詢網址:
  http://www.unicode.org/charts/unihan.html
  質數表,Table of Primes from 1 to 1 000 000 000 000:
  http://www.walter-fendt.de/m14e/primes.htm


5.1  java.lang.String類中字符串的哈希函數

  在Oracle公司的Java API實現中,String類的hashcode()函數計算了字符串的哈希值,源代碼以下。從註釋和程序中能夠看出,計算公式爲hashall = s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1],是將字符串各個字符的UTF-16代碼乘以31的ni次方後相加獲得的。31爲質數,31^(ni)雖然不是質數,可是性質接近質數。但並無發現顯式的求模運算%,這是由int類型數據算術運算後獲得的,若是值超過了int類型的最大值時,高位被自動拋棄,這就至關於對2147483648(十六進制0x7FFF)求模,因此結果在0到2147483648之間。
/**
     * Returns a hash code for this string. The hash code for a
     * <code>String</code> object is computed as
     * <blockquote><pre>
     * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
     * </pre></blockquote>
     * using <code>int</code> arithmetic, where <code>s[i]</code> is the
     * <i>i</i>th character of the string, <code>n</code> is the length of
     * the string, and <code>^</code> indicates exponentiation.
     * (The hash value of the empty string is zero.)
     *
     * @return  a hash code value for this object.
     */
    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;


            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

6  Java API中的HashMap類實現簡介

6.1  HashMap類中哈希值的計算方法

  經過查看其源代碼,能夠看出HashMap類中哈希值的計算方法。
  類中的哈希值是經過final int hash(Object k)函數實現的,首先根據鍵(key)對象的hashcode函數計算鍵對象的hash值:k.hashCode(),而後內部再進行相應的移位和求異或運算,獲得內部使用的hash值。能夠看出hash值由int類型表示,則其值在0到Interger.MAX_VALUE之間。但實際內部存儲用的數組長度由HashMap的容量決定,因此根據hash值獲得對象在數組中的索引值,還須要近一步計算,下段中進行了說明。
/**
     * Retrieve object hash code and applies a supplemental hash function to the
     * result hash, which defends against poor quality hash functions.  This is
     * critical because HashMap uses power-of-two length hash tables, that
     * otherwise encounter collisions for hashCodes that do not differ
     * in lower bits. Note: Null keys always map to hash 0, thus index 0.
     */
    final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {//因爲沒看完整的源代碼,此處目的沒看明白,根據字面理解多是其它基於此類的之類,若是不滿意默認的哈希函數算法,可使用此算法代替。
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }


        h ^= k.hashCode();//計算鍵對象的hash值,以後與0求異或運算


        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4); //移位異或運算等,使hash值更分散下降衝突可能
    }


6.2  根據鍵(key)對象查詢值(value)對象

  根據鍵對象查詢<K,V>對象的方法,涉及到的源代碼以下。
  首先public V get(Object key)函數中,調用getEntry(key)函數,由鍵對象得到相應值的Entry<K,V>的地址entry。
  從Entry<K,V>源代碼(這裏沒有粘貼過來)能夠看出,Entry<K,V>是類中定義的新類,繼承至Map.Entry<K,V>。該對象中保存了鍵(key)對象和相應的值(value)對象,幷包含有指向下個Entry<K,V>地址的變量,這樣能夠實現鏈表功能,用於解決衝突。若是衝突產生時(不一樣鍵對象的hash值相同),將hash值相同的對象其依次放在此鏈表中。
  getEntry(key)函數中首先由hash(key)計算鍵對象的hash值。
  而後由indexFor(hash, table.length)函數根據hash值得到Entry<K,V>[]數組的索引值,該函數中h & (length-1)運算將hash值由原來的0到Interger.MAX_VALUE之間映射到0到(length-1)之間,這樣就能夠看成該數組的索引值。
  而後Entry<K,V> e = table[indexFor(hash, table.length)]根據索引值,將須要的數據找到。
  table是Entry<K,V>[]類型的數組,其中保存了指向相應Entry<K,V>的地址。
  for程序段中,若是有衝突,則依次遍歷此鏈表,找到與指定鍵對象對應的值對象。將Entry<K,V>對象返回get(Object key)函數。
  最後get(Object key)函數調用entry.getValue()得到相應的值對象。


/**
     * Returns the value to which the specified key is mapped,
     * or {@code null} if this map contains no mapping for the key.
     *
     * <p>More formally, if this map contains a mapping from a key
     * {@code k} to a value {@code v} such that {@code (key==null ? k==null :
     * key.equals(k))}, then this method returns {@code v}; otherwise
     * it returns {@code null}.  (There can be at most one such mapping.)
     *
     * <p>A return value of {@code null} does not <i>necessarily</i>
     * indicate that the map contains no mapping for the key; it's also
     * possible that the map explicitly maps the key to {@code null}.
     * The {@link #containsKey containsKey} operation may be used to
     * distinguish these two cases.
     *
     * @see #put(Object, Object)
     */
    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);


        return null == entry ? null : entry.getValue();
    }


    /**
     * Returns the entry associated with the specified key in the
     * HashMap.  Returns null if the HashMap contains no mapping
     * for the key.
     */
    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key);
        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;
    }


    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

6.3  HashMap類的容量

  從以前的例子中,能夠知道查詢速度的改進是因爲用空間換取了時間,因此HashMap類的容量越大,效率越高,可是空間佔用約多。
  通過權衡,類中定義了填充率(loadFactor),默認爲0.75;容量(capacity),默認值爲16。源代碼以下:
/**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;


    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;


    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }



  類始終保持類中保存的數據量小於門限(threshold) = 容量(capacity)* 填充率(loadFactor)。每次添加的新的數據時,都檢測數據量(size)是否超過門限(threshold)。若是超限則調用resize(2 * table.length)函數,將類的容量增大。源代碼以下:
/**
     * Adds a new entry with the specified key, value and hash code to
     * the specified bucket.  It is the responsibility of this
     * method to resize the table if appropriate.
     *
     * Subclass overrides this to alter the behavior of put method.
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }


        createEntry(hash, key, value, bucketIndex);
    }

6.4  調整HashMap類的容量對性能的影響

  調整HashMap類的容量的函數resize(int newCapacity)源代碼以下,從新調整大小,須要新建一個Entry[]數組,而後調用transfer(newTable, rehash)函數將以前數組中的值調整到新數組中。
  transfer(newTable, rehash)函數中調用hash(e.key)函數從新計算了鍵對象的哈希值,根據哈希值將舊Entry[]數組中數據放到新Entry[]數組中。
  因此調整HashMap類的容量形成了如下影響:
  • 新建一個Entry[]數組,須要格外的空間
  • 從新計算了鍵對象的哈希值,須要格外的運行時間
  • 因爲Entry[]數組長度變化,各元素在HashMap中的內部位置發生了改變
  綜上,要根據時間狀況,設計HashMap類的容量和填充率,盡少調整容量的次數。


/**
     * Rehashes the contents of this map into a new array with a
     * larger capacity.  This method is called automatically when the
     * number of keys in this map reaches its threshold.
     *
     * If current capacity is MAXIMUM_CAPACITY, this method does not
     * resize the map, but sets threshold to Integer.MAX_VALUE.
     * This has the effect of preventing future calls.
     *
     * @param newCapacity the new capacity, MUST be a power of two;
     *        must be greater than current capacity unless current
     *        capacity is MAXIMUM_CAPACITY (in which case value
     *        is irrelevant).
     */
    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];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash);
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }


    /**
     * Transfers all entries from current table to newTable.
     */
    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;
            }
        }
    }



6.5  最後一個例子,電話簿PhoneBook 

  以前示例中的電話簿中沒有人名,這裏添加人名。PhoneBook 擴展了HashMap類。這樣能夠直接使用其函數。電話簿中的每條內容由<String 人名, String 號碼>組成。將人名做爲鍵,號碼做爲值,因此能夠根據人名得到他/她的電話號碼。
  因爲使用了字符串做爲鍵,因此能夠利用其已經實現的hashcode()函數實現hash值的計算。
  因爲HashMap要求鍵值各不相同,因此此電話簿,不能有重名,還須要進一步改進。


import java.util.HashMap;


// PhoneBook 擴展了HashMap類。這樣能夠直接使用其函數。
// 電話簿中的每條內容由<String 人名, String 號碼>組成。
// 將人名做爲鍵,號碼做爲值,因此能夠根據人名得到他/她的電話號碼
public class PhoneBook extends HashMap<String,String> {
    PhoneBook(){
        super();
    }
    //測試
    public static void main(String[] args) {
        PhoneBook pb = new PhoneBook();
        String[][] intial = new String[][]{
                { "張三","286 3545 1285" },
                { "李四","250 4592 8502" },
                { "王五","239 2085 1032" },
                { "趙六","230 1932 0543" },
                { "王二麻子","259 1937 1408" },
                { "段譽","251 8592 1459" },
                { "王語嫣","252 2309 7934" },
                { "虛竹","249 2942 9285" },
                { "夢姑","289 0103 8482" },
                { "喬峯","279 0094 1342" }
        };
        //將電話保存在電話簿中
        for(int i = 0; i < intial.length; i++) {
            pb.put(intial[i][0], intial[i][1]);
        }
        //測試
        System.out.println("電話簿中共保存了" + pb.size() + "個電話號碼。" );
        
        String name = new String("喬峯");
        Boolean bl = pb.containsKey(name);//查詢是否包含該人名
        System.out.println("電話簿中" + ( bl ? "查到" : "未查到" ) + name 
                + "的電話號碼。" 
                + ( bl ? ("電話號碼是" + pb.get(name) + "。") : ""));
        //測試
        name = new String("王語嫣");
        bl = pb.containsKey(name);
        System.out.println("電話簿中" + ( bl ? "查到" : "未查到" ) + name 
                + "的電話號碼。" 
                + ( bl ? ("電話號碼是" + pb.get(name) + "。") : ""));
        //測試
        name = new String("星秀老仙");
        bl = pb.containsKey(name);
        System.out.println("電話簿中" + ( bl ? "查到" : "未查到" ) + name 
                + "的電話號碼。" 
                + ( bl ? ("電話號碼是" + pb.get(name) + "。") : ""));


    
}



  運行程序後,根據輸出能夠看出電話簿正常工做:
電話簿中共保存了10個電話號碼。
電話簿中查到喬峯的電話號碼。電話號碼是279 0094 1342。
電話簿中查到王語嫣的電話號碼。電話號碼是252 2309 7934。
電話簿中未查到星秀老仙的電話號碼。




7  參考資料

[1] Hash function http://en.wikipedia.org/wiki/Hash_function [2] 麻省理工學院公開課:算法導論> 哈希表 http://v.163.com/movie/2010/12/R/E/M6UTT5U0I_M6V2TG4RE.html [3] Java官方API(Oracle Java SE7)源代碼,下載安裝JDK後,源代碼位於安裝根目錄的src.zip文件中  http://www.oracle.com/technetwork/java/javase/downloads/jdk7-downloads-1880260.html [4] OpenJDK源代碼下載(包括了HotSpot虛擬機、各個系統下API的源代碼,其中API源代碼位於openjdk\jdk\src\share\classes文件夾下):  https://jdk7.java.net/source.html
相關文章
相關標籤/搜索