模拟微信发红包,n个人抢总金额为m的红包,请设计一个算法并实现
这个题目是我在上周二面试字节跳动时候遇到的,当时写出来的还是一个暴力版本,回来之后就和朋友交流了一下,很多人也遇到过了,所以这个题目算是字节跳动研发/测试/测开系列经常出的题目,还挺有意思的,记录分享一下
初步想法-暴力版本
说实话,刚开始看到这个题目的时候,我的想法是这样的:
- 每次在(0, m)这个区间内随机一个值,记为r;
- 计算一下剩余金额m-r,剩余金额m-r必须大于(n-1)*0.01,不然后面的n-1个人无法完成分配;
- 按照顺序随机n-1次,最后剩下的金额可以直接当做最后一个红包,不需要随机;
嗯,听上去不错,然后动手实现了一下这个解法版本:
1 | python复制代码def money_alloc(m, n): |
看上去OK的,接下来我用相对正常的数据自测了一下,类似这样:
1 | python复制代码for _ in xrange(10): |
输出结果如下:
1 | python复制代码[3.73, 6.15, 0.06, 0.03, 0.03] |
从这个随机结果里面,我们发现了这个解法的一个特点,就是红包金额越来越小,等于说:谁先抢,谁能抢到的红包金额就越大。
接着,我们用相对极限的情况(比如1块钱 ,100个人分)再次测试的时候,悲剧发生了,程序陷入了深深的随机当中无法自拔,究其原因在于越往后,金额的可用区间就越小,随机的压力就越大。
总结一下这个暴力解法:
- 大众思路,适合钱多、人少的场景,在钱少、人多的情况下会陷入随机死循环;
- 公平性太差,先抢的优势过大,显然不符合当前微信红包的这种公平性;
暴力版本二
既然钱少、人多的情况下会陷入随机死循环,那么是不是就无解了呢,当然不是
1 | python复制代码def money_alloc(m, n): |
这里暴力版本二里面,主要加入了一个随机次数统计值random_count,来避免随机陷入“死循环”,代码逻辑比较简单,就不赘述了
接着我们再次对这个算法进行测试,如下:
1 | python复制代码for _ in xrange(10): |
测试结果如下:
1 | python复制代码[0.03, 0.13, 0.16, 0.01, 0.1, 0.2, 0.06, 0.02, 0.01, 0.01, 0.01, 0.01, 0.02, 0.01, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.03] |
OK,感觉还凑合,暴力版本二虽然解决了暴力版本一当中的死循环问题,但是公平性问题还是没有被解决。
接下来介绍一下另外的两种解法:二倍均值法和线段切割法,这两种方法借鉴了小灰的算法思路。
二倍均值法
在暴力版本中,不公平的问题主要体现在前面的区间太大,后面可用的随机区间太小,导致了红包金额严重失衡的问题,所以二倍均值法的核心在于稳定随机的区间。
先介绍一下二倍均值的思路:
- 计算一个平均值,比如10块钱,10个人,人均可得1块钱;
- 第一次随机时,将随机区间定义在(0, 2)之间,随机得到一个值r1,即第一个红包;
- 接着进行第二次随机,计算剩余金额10-r1,计算剩余人均(10-r1)/9,然后在[0, 人均 * 2]中随机出第二个红包;
- 以此类推,完成红包分配的过程;
我当初看到这个思路的时候,有这样的一个疑问:
为什么要将均值*2呢,直接在(0, 均值)这个区间进行随机不行吗?比如说10人分10块钱,第一次为什么不直接在(0, 1)这个区间而要再(0, 2)这个区间呢?
关于随机的问题,有点超纲了,总结来说就是在(a, b)这区间内的随机,那么随机出来的值在(a+b)/2附近的概率更大
按照这个思路继续分析下去,对于上面这个实例来说,基本上可以让每个人抢到的红包都在1块钱左右
我们用Python实现一下看看:
1 | python复制代码def money_alloc(m, n): |
接着用正常测试用例,测试一下:
1 | bash复制代码# 10块钱5个人分 |
接着用极限测试用例,测试一下:
1 | bash复制代码# 1块钱50个人分,分配结果(数据太多,随机取了两个) |
二倍均值法很好的解决了暴力版本当中的公平性问题,让每个人能够抢到的红包差距不会太大
总结一下二倍均值法:
- 解决了暴力版本中的公平性问题,但实际的微信红包在分配结果上并不是均等的,具体大家应该都有体会
线段切割法
为了让最终的分配结果体现出差异性,更贴近实际使用中的微信抢红包过程,可以考虑线段切割法。
线段切割法的思路大致如下:
1、将红包的分配过程想象成线段切割,红包的总金额为线段的总长度;
2、在线段上标记处N-1个不重复的点,线段就被切割成了N分长度(金额)不同的小线段;
3、标记方法:每次都在(0, m)这个区间随机出一个值,即为标记点;
4、最后计算相邻标记点之间的差距(子线段长度)即为红包金额;
话不多说,直接上Python实现:
1 | python复制代码def money_alloc(m, n): |
测试一下:
1 | python复制代码[1.07, 6.08, 2.85] |
OK,到这里似乎所有的问题都已经完美解决了,这个解法看起来好完美。
But…事实真的是这样吗?
现在,我们抛开实际的场景,回归到这个算法本身,不妨测试一下1万块3万人分,测试代码如下:
1 | python复制代码for _ in xrange(5): |
测试结果大概如下:
1 | python复制代码7.04587507248 |
在我的电脑上,大概需要耗时7、8秒的样子,这…
不慌不慌,我们先分析一下代码可能的问题:
- 随机3W+次,这个过程本省确实耗时,但感觉也没有什么改善空间了;
alloc_result in result
在result很大的时候,查找效率太低,非常耗时,这个必须改掉;result.append(alloc_result)
如果是有序插入,那么后续的list.sort就没必要了;- 另外,list.sort()耗时么?
什么数据结构的查找效率最高呢?当然是hashmap,也就是dict啦,并且在测试过程中发现list.sort()耗时基本在10ms以内,优化空间不大,所以没有考虑有序插入。
线段切割法-优化版本
最终优化之后的代码如下:
1 | python复制代码def money_alloc(m, n): |
优化之后,我们用刚才的测试代码再次测试一下看看效果:
1 | python复制代码0.197105169296 |
7秒到200ms的效率提升,不要太直观
至于空间复杂度,留给小伙伴本自己研究吧,希望有更好的版本可以学习一下
问题总结
- 抢红包算法的三种解法:暴力分配、二倍均值、线段切割;
- 暴力分配仅仅只能适用于这个问题本身,实际过程中没有任何应用价值;
- 二倍均值解决了暴力分配的问题,但缺少了大红包的惊喜;
- 在实际的微信红包分配过程中,线段切割优化和不优化实际上差异应该不至于太大,但是追求性能,优化版本还是有更多的改进空间;
附录:关于random.uniform
uniform一般用于生成浮点数,关于random.uniform的功能描述:
1 | python复制代码| uniform(self, a, b) |
现在看来这个描述并不准确
实际运用时,我一直认为这个随机区间是[a, b],实则不然
1 | python复制代码>>> random.uniform(0, 1) |
所以其实a>b的时候也是可以进行浮点数随机的,随机区间并不是绝对意义的最小值是a,最大值是b
你, 学到了吗?
本文转载自: 掘金