程式設計師修仙之路--把使用者訪問記錄優化到極致

架構師修行之路發表於2019-03-15

image

祝願大家不要像菜菜這般苦逼,年中獎大大滴 在沒有年終獎的日子裡,工作依然還要繼續.....一張冰與火的圖盡顯無奈

image

還記得菜菜不久之前設計的使用者空間嗎?沒看過的同學請進傳送門=》設計高效能訪客記錄系統

還記得遺留的什麼問題嗎?菜菜來重複一下,在使用者訪問記錄的快取中怎麼來判斷是否有當前使用者的記錄呢?連結串列雖然是我們這個業務場景最主要的資料結構,但並不是當前這個問題最好的解決方案,所以我們需要一種能快速訪問元素的資料結構來解決這個問題?那就是今天我們要談一談的 雜湊表

雜湊表

雜湊表(Hash table,也叫雜湊表),是根據關鍵碼值(Key value)而直接進行訪問的資料結構。也就是說,它通過把關鍵碼值對映到表中一個位置來訪問記錄,以加快查詢的速度。這個對映函式叫做雜湊函式,存放記錄的陣列叫做雜湊表。 雜湊表其實可以約等於我們常說的Key-Value形式。 雜湊表用的是陣列支援按照下標隨機訪問資料的特性,所以雜湊表其實就是陣列的一種擴充套件,由陣列演化而來。可以說,如果沒有陣列,就沒有雜湊表。為什麼要用陣列呢?因為陣列按照下標來訪問元素的時間複雜度為O(1),不明白的同學可以參考菜菜以前的關於陣列的文章。既然要按照陣列的下標來訪問元素,必然也必須考慮怎麼樣才能把Key轉化為下標。這就是接下來要談一談的雜湊函式。

雜湊函式

雜湊函式通俗來講就是把一個Key轉化為陣列下標的黑盒。雜湊函式在雜湊表中起著非常關鍵的作用。 雜湊函式,顧名思義,它是一個函式。我們可以把它定義成hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過雜湊函式計算得到的雜湊值。 那一個雜湊函式有哪些要求呢?

  1. 雜湊函式計算得到的值是一個非負整數值。
  2. 如果 key1 = key2,那hash(key1) == hash(key2)
  3. 如果 key1 ≠ key2,那hash(key1) ≠ hash(key2)

簡單說一下以上三點,第一點:因為雜湊值其實就是陣列的下標,所以必須是非負整數(>=0),第二點:同一個key計算的雜湊值必須相同。 重點說一下第三點,其實第三點只是理論上的,我們想象著不同的Key得到的雜湊值應該不同,但是事實上,這一點很難做到。我們可以反證一下,如果這個公式成立,我計算無限個Key的雜湊值,那雜湊表底層的陣列必須做到無限大才行。像業界比較著名的MD5、SHA等雜湊演算法,也無法完全避免這樣的衝突。當然如果底層的陣列越小,這種衝突的機率就越大。所以一個完美的雜湊函式其實是不存在的,即便存在,付出的時間成本,人力成本可能超乎想象。

雜湊衝突

既然再好的雜湊函式都無法避免雜湊衝突,那我們就必須尋找其他途徑來解決這個問題。

  1. 定址 如果遇到衝突的時候怎麼辦呢?方法之一是在衝突的位置開始找陣列中空餘的空間,找到空餘的空間然後插入。就像你去商店買東西,發現東西賣光了,怎麼辦呢?找下一家有東西賣的商家買唄。 不管採用哪種探測方法,當雜湊表中空閒位置不多的時候,雜湊衝突的概率就會大大提高。為了儘可能保證雜湊表的操作效率,一般情況下,我們會盡可能保證雜湊表中有一定比例的空閒槽位。我們用裝載因子(load factor)來表示空位的多少。

雜湊表的裝載因子 = 填入表中的元素個數 / 雜湊表的長度

裝載因子越大,說明空閒位置越少,衝突越多,雜湊表的效能會下降. 假設雜湊函式為 f=(key%1000),如下圖所示

image

  1. 鏈地址法(拉鍊法) 拉鍊法屬於一種最常用的解決雜湊值衝突的方式。基本思想是陣列的每個元素指向一個連結串列,當雜湊值衝突的時候,在連結串列的末尾增加新元素。查詢的時候同理,根據雜湊值定位到陣列位置之後,然後沿著連結串列查詢元素。如果雜湊函式設計的非常糟糕的話,相同的雜湊值非常多的話,雜湊表元素的查詢會退化成連結串列查詢,時間複雜度退化成O(n)

    image

  2. 再雜湊法 這種方式本質上是計算多次雜湊值,那就必然需要多個雜湊函式,在產生衝突時再使用另一個雜湊函式計算雜湊值,直到衝突不再發生,這種方法不易產生“聚集”,但增加了計算時間。

  3. 建立一個公共溢位區 至於這種方案網路上介紹的比較少,一般應用的也比較少。可以這樣理解:雜湊值衝突的元素放到另外的容器中,當然容器的選擇有可能是陣列,有可能是連結串列甚至佇列都可以。但是無論是什麼,想要保證雜湊表的優點還是需要慎重考慮這個容器的選擇。

擴充套件閱讀

  1. 這裡需要在強調一次,雜湊表底層依賴的是陣列按照下標訪問的特性(時間複雜度為O(1)),而且一般雜湊表為了避免大量衝突都有裝載因子的定義,這就涉及到了陣列擴容的特性:需要為新陣列開闢空間,並且需要把元素copy到新陣列。如果我們知道資料的儲存量或者資料的大概儲存量,在初始化雜湊表的時候,可以儘量一次性分配足夠大的空間。避免之後的陣列擴容弊端。事實證明,在記憶體比較緊張的時候,優先考慮這種一次性分配的方案也要比其他方案好的多。
  2. 雜湊表的定址方案中,有一種特殊情況:如果我尋找到陣列的末尾仍然無空閒位置,怎麼辦呢?這讓我想到了迴圈連結串列,陣列也一樣,可以組裝一個迴圈陣列。末尾如果無空位,就可以繼續在陣列首位繼續搜尋。
  3. 關於雜湊表元素的刪除,我覺得有必要說一說。首先基於拉鍊方式的雜湊表由於元素在連結串列中,所有刪除一個元素的時間複雜度和連結串列是一樣的,後續的查詢也沒有任何問題。但是定址方式的雜湊表就不同了,我們假設一下把位置N元素刪除,那N之後相同雜湊值的元素就搜尋不出來了,因為N位置已經是空位置了。雜湊表的搜尋方式決定了空位置之後的元素就斷片了....這也是為什麼基於拉鍊方式的雜湊表更常用的原因之一吧。
  4. 在工業級的雜湊函式中,元素的雜湊值做到儘量平均分佈是其中的要求之一,這不僅僅是為了空間的充分利用,也是為了防止大量的hashCode落在同一個位置,設想在拉鍊方式的極端情況下,查詢一個元素的時間複雜度退化成在連結串列中查詢元素的時間複雜度O(n),這就導致了雜湊表最大特性的丟失。
  5. 拉鍊方式實現的連結串列中,其實我更傾向於使用雙向連結串列,這樣在刪除一個元素的時候,雙向連結串列的優勢可以同時發揮出來,這樣可以把雜湊表刪除元素的時間複雜度降低為O(1)。
  6. 在雜湊表中,由於元素的位置是雜湊函式來決定的,所有遍歷一個雜湊表的時候,元素的順序並非是新增元素先後的順序,這一點需要我們在具體業務應用中要注意。

Net Core c# 程式碼

有幾個地方菜菜需要在強調一下:

  1. 在當前專案中用的分散式框架為基於Actor模型的Orleans,所以我每個使用者的訪問記錄不必擔心多執行緒問題。
  2. 我沒用使用hashtable這個資料容器,是因為hashtable太容易發生裝箱拆箱的問題。
  3. 使用雙向連結串列是因為查詢到了當前元素,相當於也查詢到了上個元素和下個元素,當前元素的刪除操作時間複雜度可以為O(1)

使用者訪問記錄的實體

 class UserViewInfo
    {
        //使用者ID
        public int UserId { get; set; }
        //訪問時間,utc時間戳
        public int Time { get; set; }
        //使用者姓名
        public string UserName { get; set; }
    }
複製程式碼

使用者空間新增訪問記錄的程式碼

class UserSpace
    {
        //快取的最大數量
        const int CacheLimit = 1000;
        //這裡用雙向連結串列來快取使用者空間的訪問記錄
        LinkedList<UserViewInfo> cacheUserViewInfo = new LinkedList<UserViewInfo>();
        //這裡用雜湊表的變種Dictionary來儲存訪問記錄,實現快速訪問,同時設定容量大於快取的數量限制,減小雜湊衝突
        Dictionary<int, UserViewInfo> dicUserView = new Dictionary<int, UserViewInfo>(1250);

        //新增使用者的訪問記錄
        public void AddUserView(UserViewInfo uv)
        {
            //首先查詢快取列表中是否存在,利用hashtable來實現快速查詢
            if (dicUserView.TryGetValue(uv.UserId, out UserViewInfo currentUserView))
            {
                //如果存在,則把該使用者訪問記錄從快取當前位置移除,新增到頭位置
                cacheUserViewInfo.Remove(currentUserView);
                cacheUserViewInfo.AddFirst(currentUserView);
            }
            else
            {
                //如果不存在,則新增到快取頭部 並新增到雜湊表中
                cacheUserViewInfo.AddFirst(uv);
                dicUserView.Add(uv.UserId, uv);
            }
            //這裡每次都判斷一下快取是否超過限制
            if (cacheUserViewInfo.Count > CacheLimit)
            {
                //移除快取最後一個元素,並從hashtable中刪除,理論上來說,dictionary的內部會兩個指標指向首元素和尾元素,所以查詢這兩個元素的時間複雜度為O(1)
                var lastItem = cacheUserViewInfo.Last.Value;
                dicUserView.Remove(lastItem.UserId);
                cacheUserViewInfo.RemoveLast();
            }
        }
    }
複製程式碼

新增關注,檢視更精美版本,收穫更多精彩

image

相關文章