该文具体出自哪里,不是很肯定,而我是在某个微信公众号上看到的~文中的内容比较有启发性的~面试
某海量用户网站,用户拥有积分,积分可能会在使用过程当中随时更新。如今要为该网站设计一种算法,在每次用户登陆时显示其当前积分排名。用户最大规模为2亿;积分为非负整数,且小于100万。算法
PS:听说这是迅雷的一道面试题,不过问题自己具备很强的真实性,因此本文打算按照真实场景来考虑,而不局限于面试题的理想环境。数据库
首先,咱们用一张用户积分表user_score来保存用户的积分信息。数组
表结构:缓存
示例数据:微信
下面的算法会基于这个基本的表结构来进行。数据结构
首先,很容易想到的解决方案是,用一条简单的SQL语句查询出积分大于该用户积分的用户数量:并发
select 1 + count(t2.uid) as rankfrom user_score t1, user_score t2
where t1.uid = @uid and t2.score > t1.score
对于4号用户咱们能够获得下面的结果:app
算法特色高并发
- 优势:简单,利用了SQL的功能,不须要复杂的查询逻辑,也不引入额外的存储结构,对小规模或性能要求不高的应用不失为一种良好的解决方案。
- 缺点:须要对user_score表进行全表扫描,还须要考虑到查询的同时如有积分更新会对表形成锁定。在海量数据规模和高并发的应用中,性能是没法接受的。
在许多应用中缓存是解决性能问题的重要途径,咱们天然会想,是否能够把用户排名用Memcached缓存呢?不过再想发现,缓存彷佛帮不上什么忙,由于用户排名是一个全局性的统计性指标,而并不是用户的私有属性,其余用户的积分变化可能会立刻影响到本用户的排名。但真实应用中的积分变化实际上是有必定规律,一般一个用户的积分不会忽然暴增暴减,通常用户老是要在低分区混迹很长一段时间才会慢慢升入高分区,也就是说用户积分的分布是有区段的,咱们进一步注意到高分区用户积分的细微变化其实对低分段用户的排名影响不大。因而,咱们能够想到按积分区段进行统计的方法,引入一张分区积分表 score_range:
表结构:
数据示例:
表示 [from_score, to_score) 区间有count个用户。若按每1000分来划分一个区间,则有[0, 1000), [1000, 2000), …, [999000, 1000000),共1000个区间,之后对用户积分的更新要相应地更新score_range表的区间值。在分区积分表的辅助下查询积分为s的用户排名,首先肯定其所属区间,把高于s的积分区间的count值累加,再查询出该用户在本区间内的排名,两者相加便可得到用户的排名。
乍一看,这个方法貌似经过区间聚合减小了查询计算量,实则否则。最大的问题在于如何查询用户在本区间内的排名呢?若是是在算法1中的SQL中加上积分条件:
select 1 + count(t2.uid) as rankfrom user_score t1, user_score t2
where t1.uid = @uid and t2.score > t1.score and t2.score < @to_score
在理想状况下,因为把t2.score的范围限制在了1000之内,若是对score字段创建索引,咱们指望本条SQL语句将经过索引大大减小扫描的user_score表的行数。不过真实状况并不是如此,t2.score的范围在1000之内并不意味着该区间内的用户数也是1000,由于这里有积分相同的状况存在!二八定律告诉咱们,前20%的低分区每每集中了80%的用户,这就是说对于大量低分区用户进行区间内排名查询的性能远不及对少数的高分区用户,因此在通常状况下这种分区方法不会带来实质性的性能提高。
算法特色
- 优势:注意到了积分区间的存在,并经过预先聚合消除查询的全表扫描。
- 缺点:积分非均匀分布的特色使得性能提高并不理想。
均匀分区查询算法的失败是因为积分分布的非均匀性,那么咱们天然就会想,能不能按二八定律,把score_range表设计为非均匀区间呢?好比,把低分区划密集一点,10分一个区间,而后逐渐变成100分,1000分,10000分 … 固然,这不失为一种方法,不过这种分法有必定的随意性,不容易把握好,并且整个系统的积分分布会随着使用而逐渐发生变化,最初的较好的分区方法可能会变得不适应将来的状况了。咱们但愿找到一种分区方法,既能够适应积分非均匀性,又能够适应系统积分分布的变化,这就是树形分区。
咱们能够把[0, 1,000,000)做为一级区间;再把一级区间分为两个2级区间[0, 500,000), [500,000, 1,000,000),而后把二级区间二分为4个3级区间[0, 250,000), [250,000, 500,000), [500,000, 750,000), [750,000, 1,000,000),依此类推,最终咱们会获得1,000,000个21级区间[0,1), [1,2) … [999,999, 1,000,000)。这其实是把区间组织成了一种平衡二叉树结构,根结点表明一级区间,每一个非叶子结点有两个子结点,左子结点表明低分区间,右子结点表明高分区间。树形分区结构须要在更新时保持一种不变量(Invariant):非叶子结点的count值老是等于其左右子结点的count值之和。
之后,每次用户积分有变化所须要更新的区间数量和积分变化量有关系,积分变化越小更新的区间层次越低。整体上,每次所须要更新的区间数量是用户积分变量的log(n),也就是说若是用户积分一次变化在百万级,更新区间的数量在二十这个级别。在这种树形分区积分表的辅助下查询积分为s的用户排名,其实是一个在区间树上由上至下、由粗到细一步步肯定s所在位置的过程。
好比,若积分为499,000,排名初始值为0。首先,它位于左子树[0, 500,000)区间,此时的用户排名是其右子树[500,000, 1,000,000)的用户数count值累加到该用户排名上,接着,它位于[250,000, 500,000),因此不用累加count到排名变量,直接进入下一级区间;再次,它属于4级区间的…;直到最后咱们把用户积分精肯定位在21级区间[499,000, 499,001),整个累加过程完成,得出排名!
虽然,该算法更新和查询都涉及到若干个操做,但若为区间的from_score和to_score创建索引,这些操做都是基于键的查询和更新,不会产生表扫描,所以效率更高。另外,本算法并不依赖于关系数据模型和SQL运算,能够轻易地改造为NoSQL等其余存储方式,而基于键的操做也很容易引入缓存机制进一步优化性能。进一步,咱们能够估算一下树形区间的数目大约为2,000,000,考虑每一个结点的大小,整个结构只占用几十M空间。因此,咱们彻底能够在内存创建区间树结构,并经过user_score表在O(n)的时间内初始化区间树,而后排名的查询和更新操做均可以在内存进行。通常来说,一样的算法,从数据库到内存算法的性能提高经常能够达到10^5以上;所以,本算法能够达到很是高的性能。
算法特色
- 优势:结构稳定,不受积分分布影响;每次查询或更新的复杂度为积分最大值的O(log(n))级别,且与用户规模无关,能够应对海量规模;不依赖于SQL,容易改造为NoSQL或内存数据结构。
- 缺点:算法相对更复杂。
算法3虽然性能较高,达到了积分变化的O(log(n))的复杂度,可是实现上比较复杂。另外,O(log(n))的复杂度只在n特别大的时候才显出它的优点,而实际应用中积分的变化状况每每不会太大,这时和O(n)的算法相比每每没有明显的优点,甚至可能更慢。
考虑到这一状况,仔细观察一下积分变化对排名的具体影响,能够发现某用户的积分从s变为s+n,积分小于s或者大于等于s+n的其余用户排名实际上并不会受到影响,只有积分在[s,s+n)区间内的用户排名会降低1位。咱们能够用于一个大小为1,000,000的数组表示积分和排名的对应关系,其中 rank[s]表示积分s所对应的排名。初始化时,rank数组能够由user_score表在O(n)的复杂度内计算而来。用户排名的查询和更新基于这个数组来进行。查询积分s所对应的排名直接返回rank[s]便可,复杂度为O(1);当用户积分从s变为s+n,只须要把rank[s]到 rank[s+n-1]这n个元素的值增长1便可,复杂度为O(n)。
算法特色
- 优势:积分排名数组比区间树更简单,易于实现;排名查询复杂度为O(1);排名更新复杂度O(n),在积分变化不大的状况下很是高效。
- 缺点:当n比较大时,须要更新大量元素,效率不如算法3。
上面介绍了用户积分排名的几种算法,算法1简单易于理解和实现,适用于小规模和低并发应用;算法3引入了更复杂的树形分区结构,可是 O(log(n))的复杂度性能优越,能够应用于海量规模和高并发;算法4采用简单的排名数组,易于实现,在积分变化不大的状况下性能不亚于算法3。本问题是一个开放性的问题,相信必定还有其余优秀的算法和解决方案,欢迎探讨!