程序员修仙之路--把用户访问记录优化到极致

image

祝愿你们不要像菜菜这般苦逼,年中奖大大滴 在没有年终奖的日子里,工做依然还要继续.....一张冰与火的图尽显无奈 算法

image

还记得菜菜不久以前设计的用户空间吗?没看过的同窗请进传送门=》设计高性能访客记录系统c#

还记得遗留的什么问题吗?菜菜来重复一下,在用户访问记录的缓存中怎么来判断是否有当前用户的记录呢?链表虽然是咱们这个业务场景最主要的数据结构,但并非当前这个问题最好的解决方案,因此咱们须要一种能快速访问元素的数据结构来解决这个问题?那就是今天咱们要谈一谈的 散列表数组

散列表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它经过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫作散列函数,存放记录的数组叫作散列表。 散列表其实能够约等于咱们常说的Key-Value形式。 散列表用的是数组支持按照下标随机访问数据的特性,因此散列表其实就是数组的一种扩展,由数组演化而来。能够说,若是没有数组,就没有散列表。为何要用数组呢?由于数组按照下标来访问元素的时间复杂度为O(1),不明白的同窗能够参考菜菜之前的关于数组的文章。既然要按照数组的下标来访问元素,必然也必须考虑怎么样才能把Key转化为下标。这就是接下来要谈一谈的散列函数。缓存

散列函数

散列函数通俗来说就是把一个Key转化为数组下标的黑盒。散列函数在散列表中起着很是关键的做用。 散列函数,顾名思义,它是一个函数。咱们能够把它定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示通过散列函数计算获得的散列值。 那一个散列函数有哪些要求呢?bash

  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

  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
相关文章
相关标签/搜索