你真的瞭解字典(Dictionary)嗎?

從一道親身經歷的面試題提及

半年前,我參加我如今所在公司的面試,面試官給了一道題,說有一個Y形的鏈表,知道起始節點,找出交叉節點.
Y形鏈表
爲了便於描述,我把上面的那條線路稱爲線路1,下面的稱爲線路2.java

思路1

先判斷線路1的第一個節點的下級節點是不是線路2的第一個節點,若是不是,再判斷是否是線路2的第二個,若是也不是,判斷是否是第三個節點,一直到最後一個.
若是第一輪沒找到,再按以上思路處理線路一的第二個節點,第三個,第四個... 找到爲止.
時間複雜度n2,相信若是我用的是這種方法,可確定被Pass了.node

思路2

首先,我遍歷線路2的全部節點,把節點的索引做爲key,下級節點索引做爲value存入字典中.
而後,遍歷線路1中節點,判斷字典中是否包含該節點的下級節點索引的key,即dic.ContainsKey((node.next) ,若是包含,那麼該下級節點就是交叉節點了.
時間複雜度是n.
那麼問題來了,面試官問我了,爲何時間複雜度n呢?你有沒有研究過字典的ContainsKey這個方法呢?難道它不是經過遍歷內部元素來判斷Key是否存在的呢?若是是的話,那時間複雜度仍是n2纔是呀?
我當時支支吾吾,確實不明白字典的工做原理,厚着麪皮說 "不是的,它是經過哈希表直接拿出來的,不用遍歷",面試官這邊是敷衍過去了,但在我內心卻留下了一個謎,已經入職半年多了,欠下的技術債是時候還了.git

帶着問題來閱讀

在看這篇文章前,不知道您使用字典的時候是否有過這樣的疑問.github

  1. 字典爲何能無限地Add呢?
  2. 從字典中取Item速度很是快,爲何呢?
  3. 初始化字典能夠指定字典容量,這是否多餘呢?
  4. 字典的桶buckets 長度爲素數,爲何呢?

無論您之前有沒有在內心問過本身這些問題,也無論您是否已經有了本身得答案,都讓咱們帶着這幾個問題接着往下走.面試

從哈希函數提及

什麼是哈希函數?
哈希函數又稱散列函數,是一種從任何一種數據中建立小的數字「指紋」的方法。
下面,咱們看看JDK中Sting.GetHashCode()方法.c#

public int hashCode() {
        int h = hash;
 //hash default value : 0 
        if (h == 0 && value.length > 0) {
 //value : char storage
            char val[] = value;

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

能夠看到,不管多長的字符串,最終都會返回一個int值,當哈希函數肯定的狀況下,任何一個字符串的哈希值都是惟一且肯定的.
固然,這裏只是找了一種最簡單的字符數哈希值求法,理論上只要能把一個對象轉換成惟一且肯定值的函數,咱們均可以把它稱之爲哈希函數.
這是哈希函數的示意圖.
哈希函數示意圖
因此,一個對象的哈希值是肯定且惟一的!.數組

字典

如何把哈希值和在集合中咱們要的數據的地址關聯起來呢?解開這個疑惑前我來看看一個這樣不怎麼恰當的例子:數據結構

有一天,我不當心幹了什麼壞事,警察叔叔沒有逮到我本人,可是他知道是一個叫阿宇的乾的,他要找我確定先去我家,他怎麼知道我家的地址呢?他不可能在全中國的家庭一個個去遍歷,敲門,問阿宇是大家家的熊孩子嗎?框架

正常應該是經過個人名字,找到個人身份證號碼,而後個人身份證上登記着個人家庭地址(咱們假設一個名字只能找到一張身份證).函數

阿宇-----> 身份證(身份證號碼,家庭住址)------>我家

咱們就能夠把由阿宇找到身份證號碼的過程,理解爲哈希函數,身份證存儲着個人號碼的同時,也存儲着我家的地址,身份證這個角色在字典中就是 bucket,它起一個橋樑做用,當有人要找阿宇家在哪時,直接問它,準備錯的,字典中,bucket存儲着數據的內存地址(索引),咱們要知道key對應的數據的內存地址,問buckets要就對了.

key--->bucket的過程 ~= 阿宇----->身份證 的過程.
Alt text

警察叔叔經過家庭住址找到了我家以後,我家除了住我,還住着我爸,我媽,他敲門的時候,是我爸開門,因而問我爸爸,阿宇在哪,我爸不知道,我爸便問我媽,兒子在哪?我媽告訴警察叔叔,我在書房呢.很好,警察叔叔就這樣把我給逮住了.

字典也是這樣,由於key的哈希值範圍很大的,咱們不可能聲明一個這麼大的數組做爲buckets,這樣就太浪費了,咱們作法時HashCode%BucketSize做爲bucket的索引.

假設Bucket的長度3,那麼當key1的HashCode爲2時,它數據地址就問buckets2要,當key2的HashCode爲5時,它的數據地址也是問buckets2要的.

這就致使同一個bucket可能有多個key對應,即下圖中的Johon Smith和Sandra Dee,可是bucket只能記錄一個內存地址(索引),也就是警察叔叔經過家庭地址找到我家時,正常來講,只有一我的過來開門,那麼,如何找到也在這個家裏的個人呢?我爸記錄這我媽在廚房,我媽記錄着我在書房,就這樣,我就被揪出來了,我爸,我媽,我 就是字典中的一個entry.

Alt text
若是有一天,我媽媽老來得子又生了一個小寶寶,怎麼辦呢?很簡單,我媽記錄小寶寶的位置,那麼個人只能巴結小寶寶,讓小寶寶來記錄個人位置了.
Alt text
Alt text

既然大的原理明白了,是否是要看看源碼,來研究研究代碼中字典怎麼實現的呢?

DictionaryMini

上次在蘇州參加蘇州微軟技術俱樂部成立大會時,有幸參加了蔣金楠 老師講的Asp .net core框架解密,蔣老師有句話讓我印象很深入,"學好一門技術的最好的方法,就是模仿它的樣子,本身造一個出來"因而他弄了個Asp .net core mini,因此我效仿蔣老師,弄了個DictionaryMini

其源代碼我放在了Github倉庫,有興趣的能夠看看:https://github.com/liuzhenyulive/DictionaryMini

我以爲字典這幾個方面值得了解一下:

  1. 數據存儲的最小單元的數據結構
  2. 字典的初始化
  3. 添加新元素
  4. 字典的擴容
  5. 移除元素

字典中還有其餘功能,但我相信,只要弄明白的這幾個方面的工做原理,咱們也就恰中肯綮,他麼問題也就迎刃而解了.

數據存儲的最小單元(Entry)的數據結構

private struct Entry
        {
            public int HashCode;
            public int Next;
            public TKey Key;
            public TValue Value;
        }

一個Entry包括該key的HashCode,以及下個Entry的索引Next,該鍵值對的Key以及數據Vaule.

字典初始化

private void Initialize(int capacity)
        {
            int size = HashHelpersMini.GetPrime(capacity);
            _buckets = new int[size];
            for (int i = 0; i < _buckets.Length; i++)
            {
                _buckets[i] = -1;
            }

            _entries = new Entry[size];

            _freeList = -1;
        }

字典初始化時,首先要建立int數組,分別做爲buckets和entries,其中buckets的index是key的哈希值%size,它的value是數據在entries中的index,咱們要取的數據就存在entries中.當某一個bucket沒有指向任何entry時,它的value爲-1.
另外,頗有意思得一點,buckets的數組長度是多少呢?這個我研究了挺久,發現取的是大於capacity的最小質數.

添加新元素

private void Insert(TKey key, TValue value, bool add)
        {
            if (key == null)
            {
                throw new ArgumentNullException();
            }
            //若是buckets爲空,則從新初始化字典.
            if (_buckets == null) Initialize(0);
            //獲取傳入key的 哈希值
            var hashCode = _comparer.GetHashCode(key);
            //把hashCode%size的值做爲目標Bucket的Index.
            var targetBucket = hashCode % _buckets.Length;
            //遍歷判斷傳入的key對應的值是否已經添加字典中
            for (int i = _buckets[targetBucket]; i > 0; i = _entries[i].Next)
            {
                if (_entries[i].HashCode == hashCode && _comparer.Equals(_entries[i].Key, key))
                {
                    //當add爲true時,直接拋出異常,告訴給定的值已存在在字典中.
                    if (add)
                    {
                        throw new Exception("給定的關鍵字已存在!");
                    }
                    //當add爲false時,從新賦值並退出.
                    _entries[i].Value = value;
                    return;
                }
            }
            //表示本次存儲數據的數據在Entries中的索引
            int index;
            //當有數據被Remove時,freeCount會加1
            if (_freeCount > 0)
            {
                //freeList爲上一個移除數據的Entries的索引,這樣能儘可能地讓連續的Entries都利用起來.
                index = _freeList;
                _freeList = _entries[index].Next;
                _freeCount--;
            }
            else
            {
                //當已使用的Entry的數據等於Entries的長度時,說明字典裏的數據已經存滿了,須要對字典進行擴容,Resize.
                if (_count == _entries.Length)
                {
                    Resize();
                    targetBucket = hashCode % _buckets.Length;
                }
                //默認取未使用的第一個
                index = _count;
                _count++;
            }
            //對Entries進行賦值
            _entries[index].HashCode = hashCode;
            _entries[index].Next = _buckets[targetBucket];
            _entries[index].Key = key;
            _entries[index].Value = value;
            //用buckets來登記數據在Entries中的索引.
            _buckets[targetBucket] = index;
        }

字典的擴容

private void Resize()
        {
            //獲取大於當前size的最小質數
            Resize(HashHelpersMini.GetPrime(_count), false);
        }
 private void Resize(int newSize, bool foreNewHashCodes)
        {
            var newBuckets = new int[newSize];
            //把全部buckets設置-1
            for (int i = 0; i < newBuckets.Length; i++) newBuckets[i] = -1;
            var newEntries = new Entry[newSize];
            //把舊的的Enties中的數據拷貝到新的Entires數組中.
            Array.Copy(_entries, 0, newEntries, 0, _count);
            if (foreNewHashCodes)
            {
                for (int i = 0; i < _count; i++)
                {
                    if (newEntries[i].HashCode != -1)
                    {
                        newEntries[i].HashCode = _comparer.GetHashCode(newEntries[i].Key);
                    }
                }
            }
            //從新對新的bucket賦值.
            for (int i = 0; i < _count; i++)
            {
                if (newEntries[i].HashCode > 0)
                {
                    int bucket = newEntries[i].HashCode % newSize;
                    newEntries[i].Next = newBuckets[bucket];
                    newBuckets[bucket] = i;
                }
            }

            _buckets = newBuckets;
            _entries = newEntries;
        }

移除元素

//經過key移除指定的item
        public bool Remove(TKey key)
        {
            if (key == null)
                throw new Exception();

            if (_buckets != null)
            {
                //獲取該key的HashCode
                int hashCode = _comparer.GetHashCode(key);
                //獲取bucket的索引
                int bucket = hashCode % _buckets.Length;
                int last = -1;
                for (int i = _buckets[bucket]; i >= 0; last = i, i = _entries[i].Next)
                {
                    if (_entries[i].HashCode == hashCode && _comparer.Equals(_entries[i].Key, key))
                    {
                        if (last < 0)
                        {
                            _buckets[bucket] = _entries[i].Next;
                        }
                        else
                        {
                            _entries[last].Next = _entries[i].Next;
                        }
                        //把要移除的元素置空.
                        _entries[i].HashCode = -1;
                        _entries[i].Next = _freeList;
                        _entries[i].Key = default(TKey);
                        _entries[i].Value = default(TValue);
                        //把該釋放的索引記錄在freeList中
                        _freeList = i;
                        //把空Entry的數量加1
                        _freeCount++;
                        return true;
                    }
                }
            }

            return false;
        }

我對.Net中的Dictionary的源碼進行了精簡,作了一個DictionaryMini,有興趣的能夠到個人github查看相關代碼.
https://github.com/liuzhenyulive/DictionaryMini

答疑時間

字典爲何能無限地Add呢

向Dictionary中添加元素時,會有一步進行判斷字典是否滿了,若是滿了,會用Resize對字典進行自動地擴容,因此字典不會向數組那樣有固定的容量.

爲何從字典中取數據這麼快

Key-->HashCode-->HashCode%Size-->Bucket Index-->Bucket-->Entry Index-->Value
整個過程都沒有經過遍歷來查找數據,一步到下一步的目的性時很是明確的,因此取數據的過程很是快.

初始化字典能夠指定字典容量,這是否多餘呢

前面說過,當向字典中插入數據時,若是字典已滿,會自動地給字典Resize擴容.
擴容的標準時會把大於當前前容量的最小質數做爲當前字典的容量,好比,當咱們的字典最終存儲的元素爲15個時,會有這樣的一個過程.
new Dictionary()------------------->size:3
字典添加低3個元素---->Resize--->size:7
字典添加低7個元素---->Resize--->size:11
字典添加低11個元素--->Resize--->size:23

能夠看到一共進行了三次次Resize,若是咱們預先知道最終字典要存儲15個元素,那麼咱們能夠用new Dictionary(15)來建立一個字典.

new Dictionary(15)---------->size:23

這樣就不須要進行Resize了,能夠想象,每次Resize都是消耗必定的時間資源的,須要把OldEnties Copy to NewEntries 因此咱們在建立字典時,若是知道字典的中要存儲的字典的元素個數,在建立字典時,就傳入capacity,免去了中間的Resize進行擴容.

Tips:
即便指定字典容量capacity,後期若是添加的元素超過這個數量,字典也是會自動擴容的.

爲何字典的桶buckets 長度爲素數

咱們假設有這樣的一系列keys,他們的分佈範圍時K={ 0, 1,..., 100 },又假設某一個buckets的長度m=12,由於3是12的一個因子,當key時3的倍數時,那麼targetBucket也將會是3的倍數.

Keys {0,12,24,36,...}
        TargetBucket將會是0.
        Keys {3,15,27,39,...}
        TargetBucket將會是3.
        Keys {6,18,30,42,...}
        TargetBucket將會是6.
        Keys {9,21,33,45,...}
        TargetBucket將會是9.

若是Key的值是均勻分佈的(K中的每個Key中出現的可能性相同),那麼Buckets的Length就沒有那麼重要了,可是若是Key不是均勻分佈呢?
想象一下,若是Key在3的倍數時出現的可能性特別大,其餘的基本不出現,TargetBucket那些不是3的倍數的索引就基本不會存儲什麼數據了,這樣就可能有2/3的Bucket空着,數據大量第彙集在0,3,6,9中.
這種狀況其實時很常見的。 例如,又一種場景,您根據對象存儲在內存中的位置來跟蹤對象,若是你的計算機的字節大小是4,並且你的Buckets的長度也爲4,那麼全部的內存地址都會時4的倍數,也就是說key都是4的倍數,它的HashCode也將會時4的倍數,致使全部的數據都會存儲在TargetBucket=0(Key%4=0)的bucket中,而剩下的3/4的Buckets都是空的. 這樣數據分佈就很是不均勻了.
K中的每個key若是與Buckets的長度m有公因子,那麼該數據就會存儲在這個公因子的倍數爲索引的bucket中.爲了讓數據儘量地均勻地分佈在Buckets中,咱們要儘可能減小m和K中的key的有公因子出現的可能性.那麼,把Bucket的長度設爲質數就是最佳選擇了,由於質數的因子時最少的.這就是爲何每次利用Resize給字典擴容時會取大於當前size的最小質數的緣由.
確實,這一塊可能有點難以理解,我花了好幾天才研究明白,若是小夥伴們沒有看懂建議看看這裏.
https://cs.stackexchange.com/questions/11029/why-is-it-best-to-use-a-prime-number-as-a-mod-in-a-hashing-function/64191#64191

最後,感謝你們耐着性子把這篇文章看完,歡迎fork DictionaryMini進行進一步的研究,謝謝你們的支持.
https://github.com/liuzhenyulive/DictionaryMini

相關文章
相關標籤/搜索