手寫HashMap,快手面試官直呼內行!

三分惡發表於2021-11-23

手寫HashMap?這麼狠,面試都捲到這種程度了?

第一次見到這個面試題,是在某個不方便透露姓名的Offer收割機大佬的文章:

手寫HashMap,快手一面卒

這……我當時就麻了,我們都知道HashMap的資料結構是陣列+連結串列+紅黑樹,這是要手撕紅黑樹的節奏嗎?

後來,整理了一些面經,發現這道題在快手的面試出現還比較頻繁,分析這道題應該在快手的面試題庫。那既然頻繁出,肯定不能是手撕紅黑樹——我覺得面試官也多半撕不出來,不撕紅黑樹,那這道題還有點救,慢慢往下看。

認識雜湊表

HashMap其實是資料結構中的雜湊表在Java裡的實現。

雜湊表本質

雜湊表也叫雜湊表,我們先來看看雜湊表的定義:

雜湊表是根據關鍵碼的值而直接進行訪問的資料結構。

就像有人到公司找老三,前臺小姐姐拿手一指,那個牆角的工位就是。

簡單說來說,雜湊表由兩個要素構成:桶陣列雜湊函式

  • 桶陣列:一排工位
  • 雜湊函式:老三在牆角

桶陣列

我們可能知道,有一類基礎的資料結構線性表,而線性表又分兩種,陣列連結串列

雜湊表資料結構裡,儲存元素的資料結構就是陣列,陣列裡的每個單元都可以想象成一個(Bucket)。

假如給若干個程式設計師分配工位:蛋蛋熊大牛兒張三,我們觀察到,這些名字比較有特色,最後一個字都是數字,我們可以把它提取出來作為關鍵碼,這些一來,就可以把他們分配到對應編號的工位,沒分配到的工位就讓它先空著。

元素對映

那麼在這種情況下,我們查詢/插入/刪除的時間複雜度是多少呢?很明顯,都是O(1)

但我們們也不是葫蘆娃,名字不能都叫一二三四五六七之類的,假如來的新人叫南宮大牛,那我們怎麼分配他呢?

這就引入了我們的第二個關鍵要素——雜湊函式

雜湊函式

我們需要在元素和桶陣列對應位置建立一種對映對映關係,這種對映關係就是雜湊函式,也可以叫雜湊函式。

例如,我們一堆無規律的名字諸葛鋼鐵劉華強王司徒張全蛋……我們就需要通過雜湊函式,算出這些名字應該分配到哪一號工位。

雜湊函式

雜湊函式構造

雜湊函式也叫雜湊函式,假如我們資料元素的key是整數或者可以轉換為一個整數,可以通過這些常見方法來獲取對映地址。

  • 直接定址法

    直接根據key來對映到對應的陣列位置,例如1232放到下標1232的位置。

  • 數字分析法

    key的某些數字(例如十位和百位)作為對映的位置

  • 平方取中法

    key平方的中間幾位作為對映的位置

  • 摺疊法

    key分割成位數相同的幾段,然後把它們的疊加和作為對映的位置

  • 除留餘數法

    H(key)=key%p(p<=N),關鍵字除以一個不大於雜湊表長度的正整數p,所得餘數為雜湊地址,這是應用最廣泛的雜湊函式構造方法

雜湊函式構造

在Java裡,Object類裡提供了一個預設的hashCode()方法,它返回的是一個32位int形整數,其實也就是物件在記憶體裡的儲存地址。

但是,這個整數肯定是要經過處理的,上面幾種方法裡直接定址法可以排除,因為我們不可能建那麼大的桶陣列。

而且我們最後計算出來的雜湊地址,儘可能要在桶陣列長度範圍之內,所以我們選擇除留取餘法

雜湊衝突

理想的情況,是每個資料元素經過雜湊函式的計算,落在它獨屬的桶陣列的位置。

但是現實通常不如人意,我們的空間是有限的,設計再好的雜湊函式也不能完全避免雜湊衝突。所謂的雜湊衝突,就是不同的key經過雜湊函式計算,落到了同一個下標。

雜湊衝突

既然有了衝突,就得想辦法解決衝突,常見的解決雜湊衝突的辦法有:

鏈地址法

也叫拉鍊法,看起來,像在桶陣列上再拉一個連結串列出來,把發生雜湊衝突的元素放到一個連結串列裡,查詢的時候,從前往後遍歷連結串列,找到對應的key就行了。

鏈地址法

開放地址法

開放地址法,簡單來說就是給衝突的元素再在桶陣列裡找到一個空閒的位置。

找到空閒位置的方法有很多種:

  • 線行探查法: 從衝突的位置開始,依次判斷下一個位置是否空閒,直至找到空閒位置
  • 平方探查法: 從衝突的位置x開始,第一次增加1^2個位置,第二次增加2^2...,直至找到空閒的位置
  • 雙雜湊函式探查法

……

開放地址法

再雜湊法

構造多個雜湊函式,發生衝突時,更換雜湊函式,直至找到空閒位置。

建立公共溢位區

建立公共溢位區,把發生衝突的資料元素儲存到公共溢位區。

很明顯,接下來我們解決衝突,會使用鏈地址法

好了,雜湊表的介紹就到這,相信你已經對雜湊表的本質有了深刻的理解,接下來,進入coding時間。

HashMap實現

我們實現的簡單的HashMap命名為ThirdHashMap,先確定整體的設計:

  • 雜湊函式:hashCode()+除留餘數法
  • 衝突解決:鏈地址法

整體結構如下:

自定義HashMap整體結構

內部節點類

我們需要定義一個節點來作為具體資料的載體,它不僅要承載鍵值對,同樣還得作為單連結串列的節點:

    /**
     * 節點類
     *
     * @param <K>
     * @param <V>
     */
    class Node<K, V> {
        //鍵值對
        private K key;
        private V value;

        //連結串列,後繼
        private Node<K, V> next;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }

        public Node(K key, V value, Node<K, V> next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

成員變數

主要有四個成員變數,其中桶陣列作為裝載資料元素的結構:

    //預設容量
    final int DEFAULT_CAPACITY = 16;
    //負載因子
    final float LOAD_FACTOR = 0.75f;
    //HashMap的大小
    private int size;
    //桶陣列
    Node<K, V>[] buckets;

構造方法

構造方法有兩個,無參構造方法,桶陣列預設容量,有參指定桶陣列容量。

    /**
     * 無參構造器,設定桶陣列預設容量
     */
    public ThirdHashMap() {
        buckets = new Node[DEFAULT_CAPACITY];
        size = 0;
    }

    /**
     * 有參構造器,指定桶陣列容量
     *
     * @param capacity
     */
    public ThirdHashMap(int capacity) {
        buckets = new Node[capacity];
        size = 0;
    }

雜湊函式

雜湊函式,就是我們前面說的hashCode()和陣列長度取餘。

    /**
     * 雜湊函式,獲取地址
     *
     * @param key
     * @return
     */
    private int getIndex(K key, int length) {
        //獲取hash code
        int hashCode = key.hashCode();
        //和桶陣列長度取餘
        int index = hashCode % length;
        return Math.abs(index);
    }

put方法

我用了一個putval方法來完成實際的邏輯,這是因為擴容也會用到這個方法。

大概的邏輯:

  • 獲取元素插入位置
  • 當前位置為空,直接插入
  • 位置不為空,發生衝突,遍歷連結串列
  • 如果元素key和節點相同,覆蓋,否則新建節點插入連結串列頭部
    /**
     * put方法
     *
     * @param key
     * @param value
     * @return
     */
    public void put(K key, V value) {
        //判斷是否需要進行擴容
        if (size >= buckets.length * LOAD_FACTOR) resize();
        putVal(key, value, buckets);
    }

    /**
     * 將元素存入指定的node陣列
     *
     * @param key
     * @param value
     * @param table
     */
    private void putVal(K key, V value, Node<K, V>[] table) {
        //獲取位置
        int index = getIndex(key, table.length);
        Node node = table[index];
        //插入的位置為空
        if (node == null) {
            table[index] = new Node<>(key, value);
            size++;
            return;
        }
        //插入位置不為空,說明發生衝突,使用鏈地址法,遍歷連結串列
        while (node != null) {
            //如果key相同,就覆蓋掉
            if ((node.key.hashCode() == key.hashCode())
                    && (node.key == key || node.key.equals(key))) {
                node.value = value;
                return;
            }
            node = node.next;
        }
        //當前key不在連結串列中,插入連結串列頭部
        Node newNode = new Node(key, value, table[index]);
        table[index] = newNode;
        size++;
    }

擴容方法

擴容的大概過程:

  • 建立兩倍容量的新陣列
  • 將當前桶陣列的元素重新雜湊到新的陣列
  • 新陣列置為map的桶陣列
    /**
     * 擴容
     */
    private void resize() {
        //建立一個兩倍容量的桶陣列
        Node<K, V>[] newBuckets = new Node[buckets.length * 2];
        //將當前元素重新雜湊到新的桶陣列
        rehash(newBuckets);
        buckets = newBuckets;
    }

    /**
     * 重新雜湊當前元素
     *
     * @param newBuckets
     */
    private void rehash(Node<K, V>[] newBuckets) {
        //map大小重新計算
        size = 0;
        //將舊的桶陣列的元素全部刷到新的桶陣列裡
        for (int i = 0; i < buckets.length; i++) {
            //為空,跳過
            if (buckets[i] == null) {
                continue;
            }
            Node<K, V> node = buckets[i];
            while (node != null) {
                //將元素放入新陣列
                putVal(node.key, node.value, newBuckets);
                node = node.next;
            }
        }
    }

get方法

get方法就比較簡單,通過雜湊函式獲取地址,這裡我省去了有沒有成連結串列的判斷,直接查詢連結串列。

    /**
     * 獲取元素
     *
     * @param key
     * @return
     */
    public V get(K key) {
        //獲取key對應的地址
        int index = getIndex(key, buckets.length);
        if (buckets[index] == null) return null;
        Node<K, V> node = buckets[index];
        //查詢連結串列
        while (node != null) {
            if ((node.key.hashCode() == key.hashCode())
                    && (node.key == key || node.key.equals(key))) {
                return node.value;
            }
            node = node.next;
        }
        return null;
    }

完整程式碼:

完整程式碼

測試

測試程式碼如下:

    @Test
    void test0() {
        ThirdHashMap map = new ThirdHashMap();
        for (int i = 0; i < 100; i++) {
            map.put("劉華強" + i, "你這瓜保熟嗎?" + i);
        }
        System.out.println(map.size());
        for (int i = 0; i < 100; i++) {
            System.out.println(map.get("劉華強" + i));
        }
    }

    @Test
    void test1() {
        ThirdHashMap map = new ThirdHashMap();
        map.put("劉華強1","哥們,你這瓜保熟嗎?");
        map.put("劉華強1","你這瓜熟我肯定要啊!");
        System.out.println(map.get("劉華強1"));
    }

大家可以自行跑一下看看結果。

總結

好了,到這,我們一個簡單的HashMap就實現了,這下,面試快手再也不怕手寫HashMap了。

快手面試官:真的嗎?我不信。我就要你手寫個紅黑樹版的……

瞬間狂暴

當然了,我們也發現,HashMap的O(1)時間複雜度操作是在衝突比較少的情況下,簡單的雜湊取餘肯定不是最優的雜湊函式;衝突之後,連結串列拉的太長,同樣影響效能;我們的擴容和put其實也存線上程安全的問題……

但是,現實裡我們不用考慮那麼多,因為李老爺已經幫我們寫好了,我們只管呼叫就完了。

下一篇,會以面試對線的形式來走進李老爺操刀的HashMap!

點贊關注不迷路,我們們下期見!



參考:

[1].《資料結構與演算法》

[2].構造雜湊函式方法

[3].ACM金牌選手講解LeetCode演算法《雜湊》

相關文章