程序員修仙之路--把用戶訪問記錄優化到極致

image

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

還記得菜菜不久以前設計的用戶空間嗎?沒看過的同窗請進傳送門=》設計高性能訪客記錄系統算法

還記得遺留的什麼問題嗎?菜菜來重複一下,在用戶訪問記錄的緩存中怎麼來判斷是否有當前用戶的記錄呢?鏈表雖然是咱們這個業務場景最主要的數據結構,但並非當前這個問題最好的解決方案,因此咱們須要一種能快速訪問元素的數據結構來解決這個問題?那就是今天咱們要談一談的 散列表c#

散列表

散列表(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的散列值,那散列表底層的數組必須作到無限大才行。像業界比較著名的MD五、SHA等哈希算法,也沒法徹底避免這樣的衝突。固然若是底層的數組越小,這種衝突的概率就越大。因此一個完美的散列函數實際上是不存在的,即使存在,付出的時間成本,人力成本可能超乎想象。緩存

散列衝突

既然再好的散列函數都沒法避免散列衝突,那咱們就必須尋找其餘途徑來解決這個問題。網絡

  1. 尋址

若是遇到衝突的時候怎麼辦呢?方法之一是在衝突的位置開始找數組中空餘的空間,找到空餘的空間而後插入。就像你去商店買東西,發現東西賣光了,怎麼辦呢?找下一家有東西賣的商家買唄。
無論採用哪一種探測方法,當散列表中空閒位置很少的時候,散列衝突的機率就會大大提升。爲了儘量保證散列表的操做效率,通常狀況下,咱們會盡量保證散列表中有必定比例的空閒槽位。咱們用裝載因子(load factor)來表示空位的多少。數據結構

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

裝載因子越大,說明空閒位置越少,衝突越多,散列表的性能會降低. 假設散列函數爲 f=(key%1000),以下圖所示
image多線程

  1. 鏈地址法(拉鍊法)

拉鍊法屬於一種最經常使用的解決散列值衝突的方式。基本思想是數組的每一個元素指向一個鏈表,當散列值衝突的時候,在鏈表的末尾增長新元素。查找的時候同理,根據散列值定位到數組位置以後,而後沿着鏈表查找元素。若是散列函數設計的很是糟糕的話,相同的散列值很是多的話,散列表元素的查找會退化成鏈表查找,時間複雜度退化成O(n)
image框架

  1. 再散列法

這種方式本質上是計算屢次散列值,那就必然須要多個散列函數,在產生衝突時再使用另外一個散列函數計算散列值,直到衝突再也不發生,這種方法不易產生「彙集」,但增長了計算時間。分佈式

  1. 創建一個公共溢出區

至於這種方案網絡上介紹的比較少,通常應用的也比較少。能夠這樣理解:散列值衝突的元素放到另外的容器中,固然容器的選擇有多是數組,有多是鏈表甚至隊列均可以。可是不管是什麼,想要保證散列表的優勢仍是須要慎重考慮這個容器的選擇。函數

擴展閱讀

  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

相關文章
相關標籤/搜索