散列表的基本原理與實現

本篇博文主要介紹散列表(Hash Table)這一常見數據結構的原理與實現。因爲我的水平有限,文章中不免存在不許確或是不清晰的地方,但願你們能夠指正:)node

1、概述 

   符號表是一種用於存儲鍵值對(key-value pair)的數據結構,咱們日常常用的數組也能夠看作是一個特殊的符號表,數組中的「鍵」即爲數組索引,值爲相應的數組元素。也就是說,當符號表中全部的鍵都是較小的整數時,咱們可使用數組來實現符號表,將數組的索引做爲鍵,而索引處的數組元素即爲鍵對應的值,可是這一表示僅限於全部的鍵都是比較小的整數時,不然可能會使用一個很是大的數組。散列表是對以上策略的一種「升級」,可是它能夠支持任意的鍵而並無對它們作過多的限定。對於基於散列表實現的符號表,若咱們要在其中查找一個鍵,須要進行如下步驟:算法

  • 首先咱們使用散列函數將給定鍵轉化爲一個「數組的索引」,理想狀況下,不一樣的key會被轉爲不一樣的索引,但在實際應用中咱們會遇到不一樣的鍵轉爲相同的索引的狀況,這種狀況叫作碰撞。解決碰撞的方法咱們後面會具體介紹。
  • 獲得了索引後,咱們就能夠像訪問數組同樣,經過這個索引訪問到相應的鍵值對。

    以上就是散列表的核心思想,散列表是時空權衡的經典例子。當咱們的空間無限大時,咱們能夠直接使用一個很大的數組來保存鍵值對,並用key做爲數組索引,由於空間不受限,因此咱們的鍵的取值能夠無窮大,所以查找任何鍵都只需進行一次普通的數組訪問。反過來,若對查找操做沒有任什麼時候間限制,咱們就能夠直接使用鏈表來保存全部鍵值對,這樣把空間的使用降到了最低,但查找時只能順序查找。在實際的應用中,咱們的時間和空間都是有限的,因此咱們必須在二者之間作出權衡,散列表就在時間和空間的使用上找到了一個很好的平衡點。散列表的一個優點在於咱們只需調整散列算法的相應參數而無需對其餘部分的代碼作任何修改就可以在時間和空間的權衡上作出策略調整。數組

 

2、散列函數

   介紹散列函數前,咱們先來介紹幾個散列表的基本概念。在散列表內部,咱們使用桶(bucket來保存鍵值對,咱們前面所說的數組索引即爲桶號,決定了給定的鍵存於散列表的哪一個桶中。散列表所擁有的桶數被稱爲散列表的容量(capacity緩存

   如今假設咱們的散列表中有M個桶,桶號爲0到M-1。咱們的散列函數的功能就是把任意給定的key轉爲[0, M-1]上的整數。咱們對散列函數有兩個基本要求:一是計算時間要短,二是儘量把鍵分佈在不一樣的桶中。對於不一樣類型的鍵,咱們須要使用不一樣的散列函數,這樣才能保證有比較好的散列效果。數據結構

     咱們使用的散列函數應該儘量知足均勻散列假設,如下對均勻散列假設的定義來自於Sedgewick的《算法》一書:ide

(均勻散列假設)咱們使用的散列函數可以均勻並獨立地將全部的鍵散佈於0到M – 1之間。函數

    以上定義中有兩個關鍵字,第一個是均勻,意思是咱們對每一個鍵計算而得的桶號有M個「候選值」,而均勻性要求這M個值被選中的機率是均等的;第二個關鍵字是獨立,它的意思是,每一個桶號被選中與否是相互獨立的,與其餘桶號是否被選中無關。這樣一來,知足均勻性與獨立性可以保證鍵值對在散列表的分佈儘量的均勻,不會出現「許多鍵值對被散列到同一個桶,而同時許多桶爲空」的狀況。性能

    顯然,設計一個較好的知足均勻散列假設的散列函數是不容易的,好消息是一般咱們無需設計它,由於咱們能夠直接使用一些基於機率統計的高效的實現,好比Java中許多經常使用的類都重寫了hashCode方法(Object類的hashCode方法默認返回對象的內存地址),用於爲該類型對象返回一個hashCode,一般咱們用這個hashCode除以桶數M的餘數就能夠獲取一個桶號。下面咱們以Java中的一些類爲例,來介紹一下針對不一樣數據類型的散列函數的實現。this

1. String類的hashCode方法

    String類的hashCode方法以下所示spa

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;
}

 

    hashCode方法中的value是一個char[]數組,存儲中字符串的的每字符。咱們能夠看到在方法的最開始咱們會把hash賦給h,這個hash就表示以前計算的hashCode,這樣以來若以前已經計算過這個字符串對象的hashCode,此次咱們就無需再計算了,直接返回以前計算過得便可。這種把hashCode緩存的策略只對不可變對象有效,由於不可變對象的hashCode是不會變的。

    根據上面的代碼咱們能夠知道,若h爲null,意味着咱們是第一次計算hashCode,if語句體中就是hashCode的具體計算方法。假設咱們的字符串對象str包含4個字符,ck表示的是字符串中的第k個字符(從0開始計數),那麼str的hashCode就等於:31 * (31 * (31 * c0 + c1) + c2) +c3。

 

2. 數值類型的hashCode方法

    這裏咱們以Integer和Double爲例,介紹一下數值類型的hashCode方法的通常實現。

    Integer類的hashCode方法以下:

public int hashCode() {
    return Integer.hashCode(value);
}

public static int hashCode(int value) {
    return value;
}

 

    其中value表示Integer對象所包裝的整型值,因此Integer類的hashCode方法僅僅是簡單的返回了自身的值。

    咱們再來看一下Double類的hashCode方法:

@Override

public int hashCode() {

    return Double.hashCode(value);

}

public static int hashCode(double value) {

    long bits = doubleToLongBits(value);

    return (int)(bits ^ (bits >>> 32));

}

    咱們能夠看到Double類的hashCode方法首先會將它的值轉爲long類型,而後返回低32位和高32位的異或的結果做爲hashCode。

 

3. Date類的hashCode方法

    前面咱們介紹的數據類型均可以看作一種數值型(String能夠看作一個整型數組),那麼對於非數值類型對象的hashCode要怎麼計算呢,這裏咱們以Date類爲例簡單的介紹一下。Date類的hashCode方法以下:

public int hashCode() {

    long ht = this.getTime();

    return (int) ht ^ (int) (ht >> 32);

}

    咱們能夠看到,它的hashCode方法的實現很是簡單,只是返回了Date對象所封裝的時間的低32位和高32位的異或結果。從Date類的hashCode的實現咱們能夠了解到,對於非數值類型的hashCode的計算,咱們須要選取一些能區分各個類實例的實例域來做爲計算的因子。好比對於Date類來講,一般具備相同的時間的Date對象咱們認爲它們相等,所以也就具備相同的hashCode。這裏咱們須要說明一下,對於等價的兩個對象(也就是調用equals方法返回true),它們的hashCode必須相同,而反之則否則。

 

4. 由hashCode獲取桶號

    前面咱們介紹了計算對象hashCode的一些方法,那麼咱們獲取了hashCode以後,如何進一步獲得桶號呢?一個直接的辦法就是直接拿獲得的hashCode除以capacity(桶的數量),而後用所得的餘數做爲桶號。不過在Java中,hashCode是int型的,而Java中的int型均爲有符號,因此咱們要是直接使用返回的hashCode的話可能會獲得一個負數,顯然桶號是不能爲負的。因此咱們先將返回的hashCode轉變爲一個非負整數,再用它除以capacity取餘數,做爲key的對應桶號,具體代碼以下:

private int hash(K key) {

    return (x.hashCode() & 0x7fffffff) % M;

} 

    如今咱們已經知道了如何經過一個鍵獲取桶號,那麼接下來咱們來介紹使用散列表查找的第二步——處理碰撞。

 

3、使用拉鍊法處理碰撞

    使用不一樣的碰撞處理方式,咱們便獲得了散列表的不一樣實現。首先咱們要介紹的是使用拉鍊法來處理碰撞的散列表的實現。以這種方式實現的散列表,每一個桶裏都存放了一個鏈表。初始時全部鏈表均爲空,當一個鍵被散列到一個桶時,這個鍵就成爲相應桶中鏈表的首結點,以後若再有一個鍵被散列到這個桶(即發生碰撞),第二個鍵就會成爲鏈表的第二個結點,以此類推。這樣一來,當桶數爲M,散列表中存儲的鍵值對數目爲N時,平均每一個桶中的鏈表包含的結點數爲N / M。所以,當咱們查找一個鍵時,首先經過散列函數肯定它所在的桶,這一步所需時間爲O(1);而後咱們依次比較桶中結點的鍵與給定鍵,若相等則找到了指定鍵值對,這一步所需時間爲O(N / M)。因此查找操做所需的時間爲O(N / M),而一般咱們都可以保證N是M的常數倍,因此散列表的查找操做的時間複雜度爲O(1),同理咱們也能夠獲得插入操做的複雜度也爲O(1)。

    理解了以上的描述,實現基於拉鍊法的散列表也就很容易了,這裏簡單起見,咱們直接使用前面的SeqSearchList做爲桶中的鏈表,參考代碼以下:

public class ChainingHashMap<K, V>  {
    private int num; //當前散列表中的鍵值對總數
    private int capacity; //桶數
    private SeqSearchST<K, V>[] st; //鏈表對象數組

    public ChainingHashMap(int initialCapacity) {
        capacity = initialCapacity;
        st = (SeqSearchST<K, V>[]) new Object[capacity];
        for (int i = 0; i < capacity; i++) {
            st[i] = new SeqSearchST<>();
        }
    }
    
    private int hash(K key) {
        return (key.hashCode() & 0x7fffffff) % capacity;
    }

    
    public V get(K key) {
        return st[hash(key)].get(key);
    }

    public void put(K key, V value) {
        st[hash(key)].put(key, value);
    }

} 

    在上面的實現中,咱們固定了散列表的桶數,當咱們明確知道咱們要插入的鍵值對數目最多隻能到達桶數的常數倍時,固定桶數是徹底可行的。可是若鍵值對數目會增加到遠遠大於桶數,咱們就須要動態調整桶數的能力。實際上,散列表中的鍵值對數與桶數的比值叫作負載因子(load factor)。一般負載因子越小,咱們進行查找所需時間就越短,而空間的使用就越大;若負載因子較大,則查找時間會變長,可是空間使用會減少。好比,Java標準庫中的HashMap就是基於拉鍊法實現的散列表,它的默認負載因子爲0.75。HashMap實現動態調整桶數的方式是基於公式loadFactor = maxSize / capacity,其中maxSize爲支持存儲的最大鍵值對數,而loadFactor和capacity(桶數)都會在初始化時由用戶指定或是由系統賦予默認值。當HashMap中的鍵值對的數目達到了maxSize時,就會增大散列表中的桶數。

   以上代碼中還用到了SeqSearchST,實際上這就是一個基於鏈表的符號表實現,支持向其中添加key-value pair,查找指定鍵時使用的是順序查找,它的代碼以下:

public class SeqSearchST<K, V> {
    private Node first;

    private class Node {
        K key;
        V val;
        Node next;
        public Node(K key, V val, Node next) {
            this.key = key;
            this.val = val;
            this.next = next;
        }
    }

    public V get(K key) {
        for (Node node = first; node != null; node = node.next) {
            if (key.equals(node.key)) {
                return node.val;
            }
        }
        return null;
    }

    public void put(K key, V val) {
        //先查找表中是否已存在相應key
        Node node;
        for (node = first; node != null; node = node.next) {
            if (key.equals(node.key)) {
                node.val = val;
                return;
            }
        }
        //表中不存在相應key
        first = new Node(key, val, first);
    }

}

 

 

4、使用線性探測法處理碰撞

1. 基本原理與實現

    線性探測法是另外一種散列表的實現策略的具體方法,這種策略叫作開放定址法。開放定址法的主要思想是:用大小爲M的數組保存N個鍵值對,其中M > N,數組中的空位用於解決碰撞問題。

    線性探測法的主要思想是:當發生碰撞時(一個鍵被散列到一個已經有鍵值對的數組位置),咱們會檢查數組的下一個位置,這個過程被稱做線性探測。線性探測可能會產生三種結果:

  • 命中:該位置的鍵與要查找的鍵相同;
  • 未命中:該位置爲空;
  • 該位置的鍵和被查找的鍵不一樣。

    當咱們查找某個鍵時,首先經過散列函數獲得一個數組索引後,以後咱們就開始檢查相應位置的鍵是否與給定鍵相同,若不一樣則繼續查找(若到數組末尾也沒找到就折回數組開頭),直到找到該鍵或遇到一個空位置。由線性探測的過程咱們能夠知道,若數組已滿的時候咱們再向其中插入新鍵,會陷入無限循環之中。

    理解了以上原理,要實現基於線性探測法的散列表也就不難了。這裏咱們使用數組keys保存散列表中的鍵,數組values保存散列表中的值,兩個數組同一位置上的元素共同肯定一個散列表中的鍵值對。具體代碼以下:

public class LinearProbingHashMap<K, V> {
    private int num; //散列表中的鍵值對數目
    private int capacity; 
    private K[] keys;
    private V[] values;

    public LinearProbingHashMap(int capacity) {
        keys = (K[]) new Object[capacity];
        values = (V[]) new Object[capacity];
        this.capacity = capacity;
    }

    private int hash(K key) {
        return (key.hashCode() & 0x7fffffff) % capacity;
    }

    public V get(K key) {
        int index = hash(key);
        while (keys[index] != null && !key.equals(keys[index])) {
            index = (index + 1) % capacity;
        }
        return values[index]; //若給定key在散列表中存在會返回相應value,不然這裏返回的是null
    }

    public void put(K key, V value) {
        int index = hash(key);
        while (keys[index] != null && !key.equals(keys[index])) {
            index = (index + 1) % capacity;
        }
        if (keys[index] == null) {
            keys[index] = key;
            values[index] = value;
            return;
        }
        values[index] = value;
        num++;
    }
}

 

2. 動態調整數組大小

    在咱們上面的實現中,數組的大小爲桶數的2倍,不支持動態調整數組大小。而在實際應用中,當負載因子(鍵值對數與數組大小的比值)接近1時,查找操做的時間複雜度會接近O(n),而當負載因子爲1時,根據咱們上面的實現,while循環會變爲一個無限循環。顯然咱們不想讓查找操做的複雜度退化至O(n),更不想陷入無限循環。因此有必要實現動態增加數組來保持查找操做的常數時間複雜度。當鍵值對總數很小時,若空間比較緊張,能夠動態縮小數組,這取決於實際狀況。

    要實現動態改變數組大小,只須要在上面的put方法最開始加上一個以下的判斷:

    if (num == capacity / 2) {
        resize(2 * capacity);
    }

    resize方法的邏輯也很簡單:

    private void resize(int newCapacity) {
        LinearProbingHashMap<K, V> hashmap = new LinearProbingHashMap<>(newCapacity);
        for (int i = 0; i < capacity; i++) {
            if (keys[i] != null) {
                hashmap.put(keys[i], values[i]);
            }
        }
        keys  = hashmap.keys;
        values = hashmap.values;
        capacity = hashmap.capacity;
    }

 

    關於負載因子與查找操做的性能的關係,這裏貼出《算法》(Sedgewick等)中的一個結論:

在一張大小爲M並含有N = a*M(a爲負載因子)個鍵的基於線性探測的散列表中,若散列函數知足均勻散列假設,命中和未命中的查找所需的探測次數分別爲:

~ 1/2 * (1 + 1/(1-a))和~1/2*(1 + 1/(1-a)^2)

    關於以上結論,咱們只須要知道當a約爲1/2時,查找命中和未命中所需的探測次數分別爲1.5次和2.5次。還有一點就是當a趨近於1時,以上結論中的估計值的精度會降低,不過咱們在實際應用中不會讓負載因子接近1,爲了保持良好的性能,在上面的實現中咱們應保持a不超過1/2。

 

5、參考資料

  《算法(第四版》(Sedgewick等)

相關文章
相關標籤/搜索