字节跳动高频编程题—模拟微信发红包

ぐ巨炮叔叔 提交于 2021-01-26 17:34:49

模拟微信发红包,n个人抢总金额为m的红包,请设计一个算法并实现

这个题目是我在上周二面试字节跳动时候遇到的,当时写出来的还是一个暴力版本,回来之后就和朋友交流了一下,很多人也遇到过了,所以这个题目算是字节跳动研发/测试/测开系列经常出的题目,还挺有意思的,记录分享一下 初步想法-暴力版本 说实话,刚开始看到这个题目的时候,我的想法是这样的:

每次在(0, m)这个区间内随机一个值,记为r; 计算一下剩余金额m-r,剩余金额m-r必须大于(n-1)*0.01,不然后面的n-1个人无法完成分配; 按照顺序随机n-1次,最后剩下的金额可以直接当做最后一个红包,不需要随机;

嗯,听上去不错,然后动手实现了一下这个解法版本: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money or too many people") result = [] m = round(m, 2) while n > 1: # 这里需要注意两个细节: # - random.uniform(a, b)的随机区间是≥a&≤b,即[a, b] # - random.uniform(a, b)随机出来的值是0.0012032010230123,保留两位小数之后是有可能出现等于0.00的情况 alloc_result = round(random.uniform(0.01, m-0.01), 2) # (m - alloc_result) < (n * 0.01)的判断是为了保证这一次的随机之后,后续的总金额可以继续分配,否则将重新随机指导满足这个条件 if (m - alloc_result) < (n * 0.01) or alloc_result <= 0.00: continue result.append(alloc_result) n = n - 1 m = m - alloc_result

result.append(round(m, 2))
return result

复制代码 看上去OK的,接下来我用相对正常的数据自测了一下,类似这样: for _ in xrange(10): print money_alloc(10, 5) 复制代码 输出结果如下: [3.73, 6.15, 0.06, 0.03, 0.03] [4.28, 0.8, 1.09, 2.13, 1.7] [0.66, 2.27, 5.5, 1.5, 0.07] [6.55, 1.46, 0.82, 0.2, 0.97] [5.48, 0.47, 0.65, 0.48, 2.92] [6.4, 3.09, 0.29, 0.01, 0.21] [9.94, 0.02, 0.01, 0.01, 0.02] [4.98, 4.97, 0.01, 0.01, 0.03] [8.17, 1.3, 0.18, 0.17, 0.18] [3.49, 5.45, 0.36, 0.3, 0.4] 复制代码 从这个随机结果里面,我们发现了这个解法的一个特点,就是红包金额越来越小,等于说:谁先抢,谁能抢到的红包金额就越大。 接着,我们用相对极限的情况(比如1块钱 ,100个人分)再次测试的时候,悲剧发生了,程序陷入了深深的随机当中无法自拔,究其原因在于越往后,金额的可用区间就越小,随机的压力就越大。 总结一下这个暴力解法:

大众思路,适合钱多、人少的场景,在钱少、人多的情况下会陷入随机死循环; 公平性太差,先抢的优势过大,显然不符合当前微信红包的这种公平性;

暴力版本二 既然钱少、人多的情况下会陷入随机死循环,那么是不是就无解了呢,当然不是 def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money or too many people") result = [] m = round(m, 2) # 加入随机次数统计 random_count = 0 while n > 1: alloc_result = round(random.uniform(0.01, m-0.01), 2) if (m - alloc_result) < (n * 0.01) or alloc_result <= 0.00: random_count += 1 # 随机10次还没有结果,直接给丫来一个0.01,行不行? if random_count > 10: alloc_result = 0.01 random_count = 0 result.append(alloc_result) n = n - 1 m = m - alloc_result continue result.append(alloc_result) n = n - 1 m = m - alloc_result result.append(round(m, 2)) return result 复制代码 这里暴力版本二里面,主要加入了一个随机次数统计值random_count,来避免随机陷入“死循环”,代码逻辑比较简单,就不赘述了 接着我们再次对这个算法进行测试,如下: for _ in xrange(10): print money_alloc(1, random.randint(10, 99)) 复制代码 测试结果如下: [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]

[0.79, 0.02, 0.03, 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]

[0.01, 0.08, 0.01, 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.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.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.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.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实现一下看看: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money or too many people") result = [] m = round(m, 2) while n > 1: avg_money = round(m / n * 2, 2) - 0.01 alloc_result = round(random.uniform(0.01, avg_money), 2) result.append(alloc_result) n = n - 1 m = m - alloc_result result.append(round(m, 2)) return result 复制代码 接着用正常测试用例,测试一下:

10块钱5个人分

for _ in xrange(10): print money_alloc(10, 5)

分配结果, 随机结果在2附近的值一眼看下去还是居多的

[1.83, 0.78, 0.28, 2.74, 4.37] [1.17, 4.13, 0.54, 0.66, 3.5] [1.37, 1.67, 1.3, 5.57, 0.09] [3.49, 2.5, 1.22, 0.75, 2.04] [2.1, 3.2, 0.5, 3.19, 1.01] [2.83, 2.01, 2.12, 1.2, 1.84] [2.97, 0.79, 1.45, 1.52, 3.27] [2.77, 1.64, 1.53, 0.41, 3.65] [3.49, 0.88, 0.39, 3.26, 1.98] [1.79, 3.61, 2.55, 1.21, 0.84] 复制代码 接着用极限测试用例,测试一下:

1块钱50个人分,分配结果(数据太多,随机取了两个)

[0.01, 0.03, 0.01, 0.01, 0.01, 0.02, 0.03, 0.02, 0.03, 0.03, 0.01, 0.01, 0.01, 0.02, 0.02, 0.02, 0.01, 0.02, 0.02, 0.02, 0.02, 0.02, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.02, 0.02, 0.03, 0.02, 0.02, 0.02, 0.02, 0.02, 0.03, 0.02, 0.01, 0.02, 0.03, 0.02, 0.01, 0.01, 0.02, 0.04, 0.03, 0.04, 0.01, 0.02] [0.03, 0.03, 0.02, 0.02, 0.03, 0.03, 0.03, 0.02, 0.02, 0.01, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.02, 0.01, 0.02, 0.03, 0.02, 0.01, 0.03, 0.02, 0.02, 0.03, 0.01, 0.02, 0.03, 0.02, 0.02, 0.01, 0.01, 0.03, 0.02, 0.01, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.02, 0.02, 0.02, 0.01, 0.02, 0.01, 0.01, 0.02] 复制代码 二倍均值法很好的解决了暴力版本当中的公平性问题,让每个人能够抢到的红包差距不会太大 总结一下二倍均值法:

解决了暴力版本中的公平性问题,但实际的微信红包在分配结果上并不是均等的,具体大家应该都有体会

线段切割法 为了让最终的分配结果体现出差异性,更贴近实际使用中的微信抢红包过程,可以考虑线段切割法。 线段切割法的思路大致如下: 1、将红包的分配过程想象成线段切割,红包的总金额为线段的总长度; 2、在线段上标记处N-1个不重复的点,线段就被切割成了N分长度(金额)不同的小线段; 3、标记方法:每次都在(0, m)这个区间随机出一个值,即为标记点; 4、最后计算相邻标记点之间的差距(子线段长度)即为红包金额; 话不多说,直接上Python实现: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money") # 0为线段的起点 result = [0] m = round(m, 2) while n > 1: alloc_result = round(random.uniform(0.01, m - 0.01), 2) if alloc_result in result: continue result.append(alloc_result) n = n - 1 # m为线段的终点 result.append(m) result.sort() return [round(result[index+1]- item, 2) for index, item in enumerate(result) if index < len(result) - 1] 复制代码 测试一下: [1.07, 6.08, 2.85]

[0.04, 0.11, 0.02, 0.02, 0.02, 0.01, 0.01, 0.01, 0.07, 0.02, 0.02, 0.05, 0.12, 0.02, 0.01, 0.01, 0.01, 0.13, 0.02, 0.01, 0.05, 0.03, 0.02, 0.07, 0.01, 0.02, 0.02, 0.01, 0.03, 0.01] 复制代码 OK,到这里似乎所有的问题都已经完美解决了,这个解法看起来好完美。 But...事实真的是这样吗? 现在,我们抛开实际的场景,回归到这个算法本身,不妨测试一下1万块3万人分,测试代码如下: for _ in xrange(5): a = time.time() money_alloc(10000, 30000) b = time.time() print b - a 复制代码 测试结果大概如下: 7.04587507248 7.84848403931 7.50485801697 7.98592209816 8.28649902344 复制代码 在我的电脑上,大概需要耗时7、8秒的样子,这... 不慌不慌,我们先分析一下代码可能的问题:

随机3W+次,这个过程本省确实耗时,但感觉也没有什么改善空间了; alloc_result in result在result很大的时候,查找效率太低,非常耗时,这个必须改掉; result.append(alloc_result)如果是有序插入,那么后续的list.sort就没必要了; 另外,list.sort()耗时么?

什么数据结构的查找效率最高呢?当然是hashmap,也就是dict啦,并且在测试过程中发现list.sort()耗时基本在10ms以内,优化空间不大,所以没有考虑有序插入。 线段切割法-优化版本 最终优化之后的代码如下: def money_alloc(m, n): if n * 0.01 > m: raise ValueError("not enough money") result = [0] # 牺牲一部分空间,提升查重的效率 tmp_dict = dict() m = round(m, 2) while n > 1: alloc_result = round(random.uniform(0.01, m - 0.01), 2) # hash 版本 if alloc_result in tmp_dict: continue tmp_dict[alloc_result] = None result.append(alloc_result) n = n - 1

result.append(m)
result.sort()
return [round(result[index+1]- item, 2) for index, item in enumerate(result) if index < len(result) - 1]

复制代码 优化之后,我们用刚才的测试代码再次测试一下看看效果: 0.197105169296 0.169443130493 0.162744998932 0.167745113373 0.147526979446 复制代码 7秒到200ms的效率提升,不要太直观 至于空间复杂度,留给小伙伴本自己研究吧,希望有更好的版本可以学习一下 问题总结

抢红包算法的三种解法:暴力分配、二倍均值、线段切割; 暴力分配仅仅只能适用于这个问题本身,实际过程中没有任何应用价值; 二倍均值解决了暴力分配的问题,但缺少了大红包的惊喜; 在实际的微信红包分配过程中,线段切割优化和不优化实际上差异应该不至于太大,但是追求性能,优化版本还是有更多的改进空间;

附录:关于random.uniform uniform一般用于生成浮点数,关于random.uniform的功能描述: | uniform(self, a, b) | Get a random number in the range [a, b) or [a, b] depending on rounding. 复制代码 现在看来这个描述并不准确 实际运用时,我一直认为这个随机区间是[a, b],实则不然

random.uniform(0, 1) 0.15407896775722285 random.uniform(0, 1) 0.16189270320003113 random.uniform(0, 0) # a == b 0.0 random.uniform(0, -1) # a > b -0.8838459569306347 random.uniform(0, -100) # a > b -76.93918157758513 复制代码 所以其实a>b的时候也是可以进行浮点数随机的,随机区间并不是绝对意义的最小值是a,最大值是b 你, 学到了吗?

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!