如何设计微信红包的金额分配算法?
设计微信红包的金额分配算法是一个非常经典的面试题,也是一个有趣的数学与工程结合的问题。
设计这个算法的核心目标有以下几点:
- 随机性:每个人抢到的金额需要随机。
- 总额匹配:所有人的金额加起来必须严格等于总金额。
- 最小限制:每个人至少要抢到 0.01 元。
- 公平性(心理上):虽然是随机,但不能让先抢的人把钱全抢光了,也不能让大部分人都只拿 0.01 元。
- 性能与并发:微信红包的并发量极大,计算必须简单高效。
目前业界公认微信采用的是 “二倍均值法” (Double Mean Method)。
1. 核心算法:二倍均值法
基本思路:
每次分配金额时,剩余金额为 ,剩余人数为 。
那么,当前剩余的人均金额为 。
算法规定,生成的随机金额范围是 。
公式:
为什么要乘 2?
在数学上,在 之间均匀分布的随机数,其数学期望(平均值)是 。
如果我们希望生成的随机数期望值等于“当前的人均金额” (),那么随机范围的上限 就应该是人均金额的 2 倍。
这样设计的好处是:无论你是第几个来抢红包的,从统计学期望上来看,你抢到的钱都差不多是人均金额。
举例推演
假设总金额 100 元,分给 10 个人。
- 第1人:
- 剩余金额 100,剩余人数 10。
- 均值 = 10。
- 随机范围 = 。
- 假设随机到了 12 元。
- 第2人:
- 剩余金额 88,剩余人数 9。
- 均值 = 9.77。
- 随机范围 = 。
- 假设随机到了 4 元。
- 第3人:
- 剩余金额 84,剩余人数 8。
- 均值 = 10.5。
- 随机范围 = 。
- ...以此类推。
注意:最后一个人不需要计算,直接拿走剩下的所有钱。
2. 代码实现 (Python 示例)
在工程实现中,绝对不能使用浮点数 (float/double) 进行金额计算,因为会有精度丢失问题(例如 0.1 + 0.2 != 0.3)。
必须将金额转换为“分” (整数) 进行计算。
import random
def allocate_red_packet(total_amount, total_people):
"""
:param total_amount: 总金额(单位:元)
:param total_people: 总人数
:return: 分配金额列表(单位:元)
"""
# 1. 转换为“分”进行整数计算
rest_amount = int(total_amount * 100)
rest_people = total_people
result = []
# 2. 异常检查
if rest_amount < rest_people * 1:
raise ValueError("总金额不足以支付每人1分钱")
# 3. 循环分配(前 N-1 个人)
for i in range(total_people - 1):
# 核心算法:二倍均值法
# 随机范围:[1, 剩余人均 * 2 - 1]
# 为什么要 -1?因为要保证范围是开区间或者留余地,这里简化逻辑
# 计算最大可用金额:
# 必须给后面剩下的人留至少每人1分钱
# max_val = 剩余金额 - (剩余人数-1) * 1
# 算法限制上限 = (剩余金额 / 剩余人数) * 2
avg = rest_amount / rest_people
upper_bound = int(avg * 2)
# 保证随机上限不低于1分,且不超过剩余总额(虽然二倍均值通常不会超,但要防止极端情况)
# 关键点:如果 upper_bound 很小(比如剩下钱很少),至少要能随到1
upper_bound = max(1, upper_bound)
# 生成随机金额
# random.randint(a, b) 是包含 b 的,所以这里要注意边界
amount = random.randint(1, upper_bound)
# 修正:必须保证剩下的钱够后面的人分
# 如果这次随多了,导致剩下的钱不够每人1分,就要截断
min_remaining = (rest_people - 1) * 1
if rest_amount - amount < min_remaining:
amount = rest_amount - min_remaining
result.append(amount)
rest_amount -= amount
rest_people -= 1
# 4. 最后一个人拿走剩下的
result.append(rest_amount)
# 5. 将“分”转回“元”
return [x / 100.0 for x in result]
# 测试
amounts = allocate_red_packet(100, 10)
print(f"分配结果: {amounts}")
print(f"总金额验证: {sum(amounts)}")
3. 另一种思路:线段切割法 (Line Segment Cutting)
虽然二倍均值法是主流,但它有一个特点:方差随着人数减少而变小。也就是说,第一个人抢到的金额波动很大(可能极多也可能极少),而最后几个人抢到的金额通常会在平均值附近。
如果你想要一种“完全随机、每个人概率分布完全一致”的算法,可以使用线段切割法。
思路:
- 把总金额 想象成一条长为 的线段。
- 要在 个人中分,就需要在绳子上切 刀。
- 在 之间随机产生 个不重复的切割点。
- 对切割点进行排序。
- 计算相邻两个点之间的距离,即为每个人的金额。
缺点:
这种算法虽然数学上分布更均匀,但在工程上处理“每人至少 0.01 元”这个约束比较麻烦,往往需要反复生成或事后修正,效率不如二倍均值法高。
4. 系统架构层面的设计 (高并发处理)
在微信这种亿级流量的场景下,算法只是冰山一角,真正的难点在于数据一致性和并发性能。
实际流程通常是“预分配” (Pre-allocation):
发红包时:
- 用户发出 100 元红包给 10 个人。
- 后端服务器立刻运行上述算法,计算出 10 个金额:
[12.3, 5.4, ..., 8.1]。 - 将这 10 个数字打乱顺序(Shuffle),存入 Redis 的 List (队列) 中。
- 同时在数据库记录一条红包记录。
抢红包时:
- 大量用户并发点击“开”。
- 请求到达服务器,服务器直接对 Redis 的 List 执行
LPOP(左弹出) 操作。 - 因为 Redis 是单线程原子操作,所以天然线程安全,不会超发。
LPOP拿到多少钱,用户就得多少钱。- 如果
LPOP返回空,说明红包抢完了。
入账:
- 抢到红包后,异步写入数据库,给用户账户加钱。
这种架构的优点:
- 速度极快:抢红包时不需要进行复杂的计算,只是读内存队列。
- 无锁:利用 Redis 原子性避免了数据库行锁。
- 体验好:用户点击时几乎没有延迟。
总结
设计微信红包算法的三个层次:
- 数学层:使用 二倍均值法 保证期望值平等,兼顾趣味性。
- 数据层:使用 整数 (分) 运算,避免浮点数精度灾难。
- 架构层:使用 预生成 + Redis 队列 应对高并发,将计算压力转移到发红包阶段,让抢红包阶段极其轻量。