资讯动态

Go 实现拼手气红包拆分算法:二倍均值法与线段切割法

发布时间:2026/10/16 1:02:06 来源:尧图企业网站定制
很多做后端的朋友应该都遇到过这道题某大厂面试官让你用 Go 手写一个拼手气红包拆分函数输入总金额和人数输出每个人分到多少钱。我第一次看到这道题时觉得挺简单不就是随机吗真正动手才发现这题考的不只是随机还有整数精度、边界条件、随机分布、并发控制整整四件事。这篇文章会从把需求翻译成代码约束开始给出两个能直接运行的拆包算法二倍均值法和线段切割法。后半部分会专门讲并发抢红包时怎么保证不超发、不重复以及预生成方案的思路。最后附上测试思路和面试官大概率会追问的几个问题。整篇文章所有代码都可以复制到本地跑环境是 Go 1.20没有外部依赖。适合刚学 Go 想做点项目练手的人也适合准备后端面试、想搞懂抢红包背后逻辑的人。1. 这道题到底在考什么从需求到约束条件1.1 先把需求翻译成可实现的规则面试题通常不会给你一份详细需求文档而是简单一句话实现拼手气红包的拆分算法。这时候最忌讳的是一上来就写随机数先把隐含需求拆开输入是总金额和红包个数输出是一个金额列表。每个红包金额必须大于等于最小货币单位也就是 1 分钱。所有红包金额加起来必须等于总金额不能多也不能少。金额要看起来随机但不能出现某个人拿到 0 分钱的情况。最后一个红包不能变成负数也不能是 0。如果用元作为单位1 分钱就是 0.01 元这套规则在浮点数下很难处理。所以我一律把金额换算成分全部用整数做运算。输入 100 元就当成 10000 分输出结果再除以 100 展示。提示面试时先把自己对需求的理解说一遍再动手写代码。这一步非常加分因为很多人直接开写最后边界条件一塌糊涂。1.2 金额单位的选择为什么绕开浮点数Go 里的float64看似方便但做金额计算时它是一个典型的陷阱。0.1 在二进制里是无限循环小数0.010.010.01在浮点数里并不是精确的0.03。如果红包拆出 10 份每份都做浮点运算最后一份金额很可能出现0.009999...这种数字发给用户就是金额不对的投诉。所以规范做法是金额以分为单位全部用int64保存。int64的范围是 9223372036854775807即使用来存金额单位是分也足够覆盖所有现实场景。面试官如果追问为什么不用 float64你可以从两个角度回答钱是离散的分是最小单位应该用整数模型建模。浮点数的误差会累积尤其在最后一次金额 剩余总额这种收尾逻辑里格外致命。1.3 边界条件才是真正的丢分点很多人写出来的主流程没问题但一测试边界就挂。我在实现前会先列一个边界表输入期望结果说明total100, count0返回错误份数必须大于 0total100, count200返回错误每份至少 1 分总额不够分total100, count100全部返回 1唯一合法解total100, count1返回 [100]单份直接拿全部total0, count1返回 [0]极端情况视业务而定其中最容易忽略的是count1。如果不提前处理循环里会出现peopleLeft count - i 0然后remain / peopleLeft直接除零崩溃。另一个是total count这时候无论怎么拆都不可能让每个人都拿到 1 分必须提前返回错误。这些逻辑我全部放在了拆包函数的最前面。2. 二倍均值法一个能写进简历的拆包算法2.1 算法直觉每人最多拿平均的两倍二倍均值法是这道题最主流的解法思路很直白每个人抢之前先看一眼当前剩余金额和剩余人数算出当前人均金额然后在这个人均金额的 2 倍范围内随机取值。为什么是 2 倍因为这样可以保证每次随机值的期望等于当前人均金额整体公平性不会跑偏。单个红包不会出现夸张的大包最多是平均数的两倍左右体验上比较可控。最后一份不会变成 0这一点下面单独证明。处理方式是把每人至少 1 分作为一种隐含保障。最后一轮不随机直接把剩余金额全部给最后一个人这样一定不会出现 0。我把这个过程用生活例子解释一下。假设 10 个人分 100 块钱第一个人当前人均是 10 块就在 0.01 到 20 块之间随机一个数。这个人可能拿 1 块也可能拿 18 块但不会出现 50 块的极端大包。他拿完之后剩余的钱继续按同样规则分给剩下的人直到最后一个拿剩余的钱。整个过程像是一根线被慢慢抽走但每个人都在当前均值两倍这条安全绳里。2.2 Go 实现十分钟能跑通的版本我写的版本把所有金额单位都视为分total是int64count是int。最小单位用常量minUnit表示默认是 1 分。package red import ( errors math/rand ) const minUnit int64 1 // 金额单位分 // SplitByTwoTimesMean 使用二倍均值法拆分红包。 // total 单位是分count 是红包个数。 func SplitByTwoTimesMean(total int64, count int) ([]int64, error) { if count 0 { return nil, errors.New(count must be positive) } if count 1 { if total 0 { return nil, errors.New(total must be non-negative) } return []int64{total}, nil } if total int64(count)*minUnit { return nil, errors.New(total is too small for every person to get 1 fen) } amounts : make([]int64, 0, count) remain : total for i : 0; i count-1; i { peopleLeft : int64(count - i) // 当前人均金额的两倍作为随机区间上限 maxRand : (remain / peopleLeft) * 2 // rand.Int63n(n) 返回 [0, n)所以这里生成 [1, maxRand-1] if maxRand 1 { return nil, errors.New(unexpected: no room to randomize) } amount : rand.Int63n(maxRand-1) 1 amounts append(amounts, amount) remain - amount } // 最后一个红包拿剩余全部金额 amounts append(amounts, remain) return amounts, nil }几个细节说明一下我用了全局函数rand.Int63n它是并发安全的适合面试题的简单场景。如果你在业务代码里用自定义rand.Rand实例要注意并发安全后面第 4 节会展开讲。maxRand : (remain / peopleLeft) * 2这里用的是整数除法向下取整。由于remain peopleLeft可以保证maxRand 2所以maxRand-1 1随机区间不会被打破。最后一笔直接remain - amount最后 append不需要再随机一次因为前面已经保证剩余金额足够每个剩余的人拿到至少 1 分。2.3 为什么最后一份永远不会是 0这个问题面试官几乎必问。证明思路其实不复杂但我第一次写的时候并没有认真推过后来补上了。在每一轮开始前当前剩余金额remain一定大于等于剩余人数peopleLeft也就是每人至少还有 1 分可拿。本轮随机金额amount小于等于maxRand - 1而maxRand (remain / peopleLeft) * 2。可以近似理解为amount 2 * remain / peopleLeft - 1。那么本轮结束后剩余金额满足remain remain - (2*remain/peopleLeft - 1)也就是remain remain * (1 - 2/peopleLeft) 1因为remain peopleLeft可以推出remain peopleLeft - 1剩余人数变成peopleLeft - 1但剩余金额至少还有peopleLeft - 1所以下一轮仍然满足每人至少 1 分的前提。一路推到最后最后一个红包拿到的就是剩余全部金额天然大于等于 1 分。举个例子total5, count4。第一轮人均 1.25 分maxRand2随机区间是[1,1]只能取 1 分。即使极端情况下某个时刻一个人拿走了 2 分剩下的金额也刚好够剩下的人每人 1 分。这就是整个算法的安全边界。3. 线段切割法把随机性从逐个升级到整体3.1 切割点的本质一次决定所有金额二倍均值法是边抢边算线段切割法是一次算完。思路是把总金额看成一条线段在这条线段上随机切count-1刀切成count段每段长度就是一个红包金额。为了让每个红包至少 1 分我先从总金额里扣掉count分作为保底剩下的金额池pool total - count再去切割。这样每段额外加 1 分即使某个人在额外池里分到 0他的最终金额也是 1 分不会非法。线段切割法和二倍均值法最大的区别在于方差二倍均值法每个红包都被限制在当前均值的两倍以内手气最佳再大也有限线段切割法则允许切割点集中在一端某个人拿到的金额可能接近总金额的很大比例更接近现实中有人抢到大包的体验。3.2 Go 实现允许切点重合的版本这里有一个很多人会踩的坑如果你把总金额直接拿来切割切点重合时会出现 0 金额红包。所以需要去重去重逻辑又会让代码变复杂。我采用先扣保底、后切额外池的思路额外池的切割点允许重合因为重合只会让某个人在额外池里拿 0加上保底的 1 分后仍然合法。package red import ( errors math/rand sort ) // SplitByLineCut 使用线段切割法拆分红包。 // 先给每人保底 1 分剩余金额池随机切片。 func SplitByLineCut(total int64, count int) ([]int64, error) { if count 0 { return nil, errors.New(count must be positive) } if total int64(count)*minUnit { return nil, errors.New(total is too small for every person to get 1 fen) } if count 1 { return []int64{total}, nil } pool : total - int64(count) // 额外金额池 cuts : make([]int64, count-1) for i : 0; i count-1; i { cuts[i] rand.Int63n(pool 1) // 在 [0, pool] 之间随机切割 } sort.Slice(cuts, func(i, j int) bool { return cuts[i] cuts[j] }) amounts : make([]int64, count) prev : int64(0) for i, p : range cuts { amounts[i] p - prev 1 prev p } amounts[count-1] pool - prev 1 return amounts, nil }这段代码有一个特点切点允许落在 0 和 pool 上。落在 0 上意味着第一个人的额外金额是 0落在 pool 上意味着最后一个人的额外金额是 0。这两种情况都合法不需要特殊处理。复杂度上生成切点是 O(count)排序是 O(count log count)。如果 count 非常大比如一次发十万个红包排序会成为主要开销不过在现在的服务器上十万级别的排序通常在毫秒到几十毫秒之间不是瓶颈。我建议你自己跑一下Benchmark实测不要凭感觉过早优化。3.3 两种算法放一起看差异比想象中大我写了个简单程序用total10000, count100分别跑两种算法看单包最大值和分布情况结果差异很明显维度二倍均值法线段切割法时间复杂度O(count)O(count log count)空间复杂度O(count)O(count)单个红包上限约两倍人均可接近总金额方差小整体偏平均大容易出现大包是否能边抢边算可以一般需要全量预生成最后一份金额剩余金额非负由切点决定非负同样是 100 元拆 100 份二倍均值法的大包很难超过 2 元左右整体金额感觉很稳线段切割法可能出现某个包十几块同时大量红包只有 1 分钱手气最佳会非常刺激。真实业务中到底用哪种这取决于产品想要什么样的用户体验。如果红包金额整体差别不大用二倍均值法如果想要抢到大包的惊喜感线段切割法更合适。面试时把这张对比表讲清楚比单纯背代码强得多。4. 从算法到线上并发拆包与预生成方案4.1 同一瞬间上百人开抢谁来保证不重复算法本身做对了还不够面试官接下来一定会问如果这个红包同时被几百个人抢怎么保证每个人拿到的金额不重复如果你在每次抢红包时实时调用SplitByTwoTimesMean每个请求都会重新生成一组金额请求之间没有共享状态必然出现重复。所以正确做法是先把一组金额生成好抢红包只是顺序从里面取一个。我写了一个简单的分配器用原子操作维护游标。因为红包列表本身只读多个 goroutine 同时读不会冲突唯一的竞争点是当前该取第几个。import ( sync/atomic ) type Packet struct { amounts []int64 idx int64 } // Pop 并发安全地从红包列表中取出一份金额。 func (p *Packet) Pop() (int64, bool) { for { current : atomic.LoadInt64(p.idx) if current int64(len(p.amounts)) { return 0, false } if atomic.CompareAndSwapInt64(p.idx, current, current1) { return p.amounts[current], true } } }这里用了 CASCompare And Swap而不是直接current 1后赋值。直接赋值会有窗口期两个 goroutine 同时读到一个 current同时把自己计算的下标写回去导致同金额被发两次。CAS 保证只有一个 goroutine 能把游标从current改成current1失败的 goroutine 会重新循环再取一次。注意如果你用sync.Mutex加锁也能解决问题但高并发下锁的竞争成本会高一些。原子操作在只有一个整型游标需要协调的场景里更轻量。4.2 预生成红包列表用空间换一致性预生成方案其实很简单发红包的瞬间调用一次拆分函数得到一个完整的金额列表[]int64存入Packet。之后每个用户来抢直接Pop()不需要重新计算任何随机数。这个方案有两个明显好处一致性非常好金额在发红包那一刻就固定了之后不论谁抢、按什么顺序抢总和都不会变。抢红包接口非常快一次Pop()就是一次原子操作比实时调用拆包函数快一个数量级。代价是需要额外占用内存。如果红包有 10 万份int64列表大概占用 80 万字节也就是不到 1MB完全可以接受。即使一次发 1000 万份也才 80MB在单机服务里属于可承受范围。4.3 分布式场景的扩展思路如果红包量特别大或者服务是多实例部署单机内存就存不下或者说需要共享状态。我见过比较常见的扩展是把红包金额列表直接放进 Redis用 List 结构存。发红包时把金额依次RPUSH进一个 Key用户抢红包时执行LPOP这个操作是原子的多个实例并发也不会拿到同一个元素。再配合 Lua 脚本检查当前已抢数量小于总份数可以避免网络重试导致的超发。-- 伪代码Redis Lua 脚本 local key KEYS[1] local amount redis.call(LPOP, key) if amount then return amount end return nil十几行代码就能解决分布式并发拆包的问题面试时提一嘴这个思路会显得你对真实系统有概念。5. 测试、基准测试与面试官追问5.1 一套能反复验证的测试方法写完算法我建议把测试也写全。这不仅是给自己兜底面试现场如果时间充足能写出测试用例也是一个很亮的加分项。先做表驱动测试覆盖边界条件func TestSplit(t *testing.T) { cases : []struct { name string total int64 count int wantErr bool }{ {invalid count, 100, 0, true}, {total too small, 100, 200, true}, {single packet, 100, 1, false}, {all people get min, 100, 100, false}, {normal case, 10000, 100, false}, } for _, tc : range cases { t.Run(tc.name, func(t *testing.T) { amounts, err : SplitByTwoTimesMean(tc.total, tc.count) if tc.wantErr { if err nil { t.Fatal(expected error) } return } if err ! nil { t.Fatalf(unexpected error: %v, err) } // 验证总数、份数、每份金额 if len(amounts) ! tc.count { t.Fatalf(got %d packets, want %d, len(amounts), tc.count) } var sum int64 for _, v : range amounts { if v minUnit { t.Fatalf(amount %d min unit, v) } sum v } if sum ! tc.total { t.Fatalf(sum%d, want %d, sum, tc.total) } }) } }除了单测我还会写一个随机统计测试连续跑 10000 次记录单包最大值、最小值和均值。二倍均值法的平均值应该稳定在 total/count 附近最大值稳定在 2 倍人均左右线段切割法最大值会明显大得多。这种测试能帮你直观感受到两种算法的行为差异。并发测试也很重要。构造一个 1000 份的Packet启动 100 个 goroutine 去抢统计成功弹出的总次数和金额总和是否符合预期。我在本地跑了很多次CAS 方案没有一次出现重复或总数不对的情况。5.2 高频追问与答题要点我把这题常见的追问和答题思路整理成一个清单你可以直接拿来对着练为什么用 int64 而不是 float64钱的最小单位是分整数模型无误差浮点会累积误差。每个红包金额的最大值是多少二倍均值法约等于人均的两倍线段切割法可能接近总金额。怎么保证最后一个人金额不是 0二倍均值法最后一轮直接取剩余全部金额前面的随机区间有数学保证线段切割法有保底 1 分 切额外池。并发抢红包怎么防超发预生成金额列表 原子游标或者 Redis List 的 LPOP。先抢的人是不是更占便宜二倍均值法中每次随机区间的期望都是当前均值整体期望一致不存在明显先手优势。如果想控制最大金额怎么办可以把随机区间的倍数从 2 改成别的值比如 1.5这时手气最佳会更接近人均但最后一份的数学保证要重新推。如果一次红包有一万人抢性能瓶颈在哪二倍均值法是纯 O(count)瓶颈在随机数生成线段切割法瓶颈在排序。这些问题能答上来一半面试基本就稳了。更重要的是你会发现这些问题没有一个脱离提前定义规则、保证边界、解决并发冲突这三件事。5.3 我自己在实现过程中踩过的三个坑最后分享几个练习这题时真实踩过的坑希望能帮你省点时间。第一个坑是浮点精度。最早我用float64存金额拆 100 份时最后一份出现过0.009999当时还以为是随机数的问题排查半天才发现是浮点误差。换成分为单位的int64后彻底消失。这个教训很典型凡是涉及钱的场景一定要用最小单位的整数。第二个坑是最后一份为 0。第一版二倍均值法我写的是rand.Int63n(maxRand) 1随机区间是[1, maxRand]没有把maxRand本身排除。当剩余金额刚好等于剩余人数时一旦有人取到maxRand剩下的人分到 0。改成rand.Int63n(maxRand-1) 1之后这个 bug 才消失。第三个坑是共享随机源的并发安全。有一版我把rand.New(rand.NewSource(seed))变成了包级变量多个 goroutine 一起调用它的Int63n结果用go test -race一跑就报 data race。包级rand.Int63n内部自带锁所以面试代码里直接用全局函数反而更省事。真正写业务服务时要么用独立的随机源拆分到每个 goroutine要么统一走顶层函数。这三个坑都不算深但每一个都能让初版代码在真实场景里翻车。我自己练完这道题最大的感受是算法题里最值钱的往往不是那个漂亮的随机公式而是你对边界条件和并发模型的理解是否完整。能把这段代码讲清楚比写出代码本身更能证明你是一个靠谱的后端工程师。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑