☰
Go 实现拼手气红包拆分算法:二倍均值法与线段切割法
2026/10/16 1:02:04 网站建设 项目流程

很多做后端的朋友应该都遇到过这道题:某大厂面试官让你用 Go 手写一个拼手气红包拆分函数,输入总金额和人数,输出每个人分到多少钱。我第一次看到这道题时觉得挺简单,不就是随机吗?真正动手才发现,这题考的不只是随机,还有整数精度、边界条件、随机分布、并发控制整整四件事。

这篇文章会从"把需求翻译成代码约束"开始,给出两个能直接运行的拆包算法:二倍均值法和线段切割法。后半部分会专门讲并发抢红包时怎么保证不超发、不重复,以及预生成方案的思路。最后附上测试思路和面试官大概率会追问的几个问题。

整篇文章所有代码都可以复制到本地跑,环境是 Go 1.20+,没有外部依赖。适合刚学 Go 想做点项目练手的人,也适合准备后端面试、想搞懂抢红包背后逻辑的人。

1. 这道题到底在考什么:从需求到约束条件

1.1 先把需求翻译成可实现的规则

面试题通常不会给你一份详细需求文档,而是简单一句话:"实现拼手气红包的拆分算法。"这时候最忌讳的是一上来就写随机数,先把隐含需求拆开:

  • 输入是总金额和红包个数,输出是一个金额列表。
  • 每个红包金额必须大于等于最小货币单位,也就是 1 分钱。
  • 所有红包金额加起来必须等于总金额,不能多也不能少。
  • 金额要"看起来随机",但不能出现某个人拿到 0 分钱的情况。
  • 最后一个红包不能变成负数,也不能是 0。

如果用元作为单位,1 分钱就是 0.01 元,这套规则在浮点数下很难处理。所以我一律把金额换算成"分",全部用整数做运算。输入 100 元就当成 10000 分,输出结果再除以 100 展示。

提示:面试时先把自己对需求的理解说一遍,再动手写代码。这一步非常加分,因为很多人直接开写,最后边界条件一塌糊涂。

1.2 金额单位的选择:为什么绕开浮点数

Go 里的float64看似方便,但做金额计算时它是一个典型的陷阱。0.1 在二进制里是无限循环小数,0.01+0.01+0.01在浮点数里并不是精确的0.03。如果红包拆出 10 份,每份都做浮点运算,最后一份金额很可能出现0.009999...这种数字,发给用户就是"金额不对"的投诉。

所以规范做法是:金额以分为单位,全部用int64保存。int64的范围是 9223372036854775807,即使用来存金额单位是"分",也足够覆盖所有现实场景。

面试官如果追问"为什么不用 float64",你可以从两个角度回答:

  • 钱是离散的,分是最小单位,应该用整数模型建模。
  • 浮点数的误差会累积,尤其在"最后一次金额 = 剩余总额"这种收尾逻辑里格外致命。

1.3 边界条件才是真正的丢分点

很多人写出来的主流程没问题,但一测试边界就挂。我在实现前会先列一个边界表:

输入期望结果说明
total=100, count=0返回错误份数必须大于 0
total=100, count=200返回错误每份至少 1 分,总额不够分
total=100, count=100全部返回 1唯一合法解
total=100, count=1返回 [100]单份直接拿全部
total=0, count=1返回 [0]极端情况,视业务而定

其中最容易忽略的是count=1。如果不提前处理,循环里会出现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是int64,count是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 分。

举个例子,total=5, count=4。第一轮人均 1.25 分,maxRand=2,随机区间是[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 两种算法放一起看:差异比想象中大

我写了个简单程序,用total=10000, count=100分别跑两种算法,看单包最大值和分布情况,结果差异很明显:

维度二倍均值法线段切割法
时间复杂度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, current+1) { return p.amounts[current], true } } }

这里用了 CAS(Compare And Swap)而不是直接current + 1后赋值。直接赋值会有窗口期:两个 goroutine 同时读到一个 current,同时把自己计算的下标写回去,导致同金额被发两次。CAS 保证只有一个 goroutine 能把游标从current改成current+1,失败的 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,要么统一走顶层函数。

这三个坑都不算深,但每一个都能让初版代码在真实场景里翻车。我自己练完这道题最大的感受是:算法题里最值钱的往往不是那个漂亮的随机公式,而是你对边界条件和并发模型的理解是否完整。能把这段代码讲清楚,比写出代码本身更能证明你是一个靠谱的后端工程师。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询