Post

拆解一下随机红包背后的逻辑

拆解一下随机红包背后的逻辑

随机红包已经成为了日常生活中的一个消遣活动,我们经常会当作一种游戏来玩,靠最佳手气来接力。在节假日是一种活跃气氛的常见娱乐方式,今天就来拆解一下随机红包背后的逻辑。

随机红包效果

先来看一看随机红包的效果,从表象一步一步深入解析,根据效果来一步步推导背后的逻辑。

  • 确保每个人都能领取到红包
  • 确保每个红包的金额差异化不能过大

有了这两个目标,就有了方向,来一一实现

确保每个人都能领取到红包

为了确保每个人都能领取到红包,既每个人都至少能领取到最小金额,我们就得有一个最小金额,先来定义一下

1
2
// 最小领取金额(单位:分)
private final static BigInteger MIN_AMOUNT = BigInteger.ONE;

有了最小金额,也需要确保数据的有效性,例如,不可能将5分钱平均分10份。

再来定义一下相关的变量

1
2
3
4
// 总金额(单位:分)
private BigInteger totalAmount;
// 领取数量
private BigInteger totalPerson;

也需要对数据的有效性进行验证

我们再来写一下验证逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 验证数据的有效性
*/
public void valid() {
   // 检查总金额是否有效
   if (this.totalAmount == null || this.totalAmount.compareTo(BigInteger.ZERO) != 1) {
      throw new RuntimeException("RedEnvelope total amount must greater than 0");
   }
   // 检查领取数量是否有效
   if (this.totalPerson == null || this.totalPerson.compareTo(BigInteger.ZERO) != 1) {
      throw new RuntimeException("RedEnvelope total amount must greater than 0");
   }
   // 检查是否可分配
   if (MIN_AMOUNT.multiply(this.totalPerson).compareTo(this.totalAmount) == 1) {
      throw new RuntimeException("RedEnvelope unable to allocate");
   }
}

有了这些逻辑,我们就确保了数据的有效性。

目前,还是无法确保每个人都能领取到红包,举例说明,有1块钱,分配给10个人,如果第一个人直接领取到了1块钱,那么剩下的人就没有金额可以领取了,所以每个人领取的时候都至少要保证剩下的人可以领取到最小金额,每个人都有领取的金额上限。

这个金额上限怎么定呢,随着领取的人数增多,剩余金额也在减少,那么这个金额上限应该是动态变化的。

例如,有1块钱,分配给3个人

  • 第一个人领取的金额就是 0.01 <= x <=0.08,(0.08 = 1 - 2 * MIN_AMOUNT),假设领取到了0.02
  • 第二个人领取的金额就是 0.01 <= x <=0.07,(0.07 = 1 - 0.02 - 1 * MIN_AMOUNT),假设领取到了0.03
  • 第三个人领取的金额就是 1 - 0.02 - 0.03 = 0.05

我们来总结一下这个公式

  • 对于最后一个
\[amount = 剩余金额\]
  • 对于非最后一个
\[MIN\_AMOUNT \quad\leq\quad amount \quad\leq\quad 剩余金额 - 剩余人数 \times MIN\_AMOUNT\]

有了公式,我们可以编写代码来实现

先声明需要使用的变量

1
2
3
4
5
6
// 剩余金额(单位:分)
private BigInteger remainingAmount;
// 剩余领取数量
private BigInteger remainingPerson;
// 随机数对象
private Random random = new Random();

再写一下领取逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public BigInteger next() {
    BigInteger allocateAmount;
    if (this.remainingPerson.equals(BigInteger.ZERO)) {
        throw new RuntimeException("RedEnvelope already allocated");
    }
    if (this.remainingPerson.equals(BigInteger.ONE)) {
        allocateAmount = this.remainingAmount;
        this.remainingAmount = BigInteger.ZERO;
        this.remainingPerson = BigInteger.ZERO;
    } else {
    		BigInteger maxAmount = this.remainingAmount.subtract(this.remainingPerson.subtract(BigInteger.ONE).multiply(MIN_AMOUNT));
    		// 由于nextInt是左闭右开,所以在设置maxAmount时,需要加上MIN_AMOUNT,这样就可以左闭右闭了
    		allocateAmount = BigInteger.valueOf(random.nextInt(MIN_AMOUNT.intValue(), maxAmount.add(MIN_AMOUNT).intValue()));
    		this.remainingAmount = this.remainingAmount.subtract(allocateAmount);
    		this.remainingPerson = this.remainingPerson.subtract(BigInteger.ONE);
  	}
  	return allocateAmount;
}

到这里,红包的功能算是实现了,但是满不满足第二个目标呢,我们来看统计一下

image-20250411135807023

这是统计了1000次的红包领取金额分布图,可以清晰的看到,分布很不均匀,越先领取的,抽取的大金额的占比就越高。

确保每个红包的金额差异化不能过大

随机红包的功能已经完成了,但是每次领取的金额差异化太大,我们想到了领取金额的公式

\[MIN\_AMOUNT \quad\leq\quad amount \quad\leq\quad 剩余金额 - 剩余人数 \times MIN\_AMOUNT\]

那是不是控制amount的领取范围就好了

\[MIN\_AMOUNT \quad\leq\quad amount \quad\leq\quad ?\]

那这个 ? 的范围怎么确定呢,既要保持领取金额的概率均等,又要随机出来的数据具有乐趣性。

金额概率均等

想要金额概率均等,我们先确定一个值那就是平均数,如果不是随机,按照人数平均分配,就可以保证每个领取金额都相等。如果也想要随机金额的概率差不多均等,那领取的金额肯定是在平均数的基础上浮动一些。

数据具有乐趣性

有了这个基调,我们就可以调整浮动的比例,调整的过大就会和最开始一样,越先领取的金额越大,失去了平衡性,调整的过小,也会导致不平衡,会导致越先领取的金额越小。

什么浮动算是比较平衡呢,那就是抽取到平均数的概率是50%,所以这个浮动的比例我们调整成上浮2倍。

调整一下相关的逻辑代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public BigInteger next() {
    BigInteger allocateAmount;
    if (this.remainingPerson.equals(BigInteger.ZERO)) {
        throw new RuntimeException("RedEnvelope already allocated");
    }
    if (this.remainingPerson.equals(BigInteger.ONE)) {
        allocateAmount = this.remainingAmount;
        this.remainingAmount = BigInteger.ZERO;
        this.remainingPerson = BigInteger.ZERO;
    } else {
        BigInteger[] averageAndRemainder = this.remainingAmount.divideAndRemainder(this.remainingPerson);
        BigInteger maxAmount = averageAndRemainder[0].multiply(BigInteger.valueOf(2)).add((averageAndRemainder[1].compareTo(BigInteger.ZERO) == 0 ? BigInteger.ZERO : MIN_AMOUNT));
        allocateAmount = BigInteger.valueOf(random.nextInt(MIN_AMOUNT.intValue(), maxAmount.intValue()));
        this.remainingAmount = this.remainingAmount.subtract(allocateAmount);
        this.remainingPerson = this.remainingPerson.subtract(BigInteger.ONE);
    }
    return allocateAmount;
}

再来测试一下差异化

image-20250411152244178

目前来看每次领取的金额基本是均等的。


这是完整的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import java.math.BigInteger;
import java.util.Random;

public class RedEnvelope {
    // 最小领取金额(单位:分)
    private final static BigInteger MIN_AMOUNT = BigInteger.ONE;
    // 总金额(单位:分)
    private final BigInteger totalAmount;
    // 剩余金额(单位:分)
    private BigInteger remainingAmount;
    // 领取数量
    private final BigInteger totalPerson;
    // 剩余领取数量
    private BigInteger remainingPerson;
    // 随机数 
    private final Random random = new Random();

    public RedEnvelope(BigInteger totalAmount, BigInteger totalPerson) {
        this.remainingAmount = this.totalAmount = totalAmount;
        this.remainingPerson = this.totalPerson = totalPerson;
        valid();
    }

    /**
     * 验证数据的有效性
     */
    private void valid() {
        // 检查总金额是否有效
        if (this.totalAmount == null || this.totalAmount.compareTo(BigInteger.ZERO) != 1) {
            throw new RuntimeException("RedEnvelope total amount must greater than 0");
        }
        // 检查领取数量是否有效
        if (this.totalPerson == null || this.totalPerson.compareTo(BigInteger.ZERO) != 1) {
            throw new RuntimeException("RedEnvelope total amount must greater than 0");
        }
        // 检查是否可分配
        if (MIN_AMOUNT.multiply(this.totalPerson).compareTo(this.totalAmount) == 1) {
            throw new RuntimeException("RedEnvelope unable to allocate");
        }
    }

    public BigInteger next() {
        BigInteger allocateAmount;
        if (this.remainingPerson.equals(BigInteger.ZERO)) {
            throw new RuntimeException("RedEnvelope already allocated");
        }
        if (this.remainingPerson.equals(BigInteger.ONE)) {
            allocateAmount = this.remainingAmount;
            this.remainingAmount = BigInteger.ZERO;
            this.remainingPerson = BigInteger.ZERO;
        } else {
            BigInteger[] averageAndRemainder = this.remainingAmount.divideAndRemainder(this.remainingPerson);
            BigInteger maxAmount = averageAndRemainder[0].multiply(BigInteger.valueOf(2)).add((averageAndRemainder[1].equals(BigInteger.ZERO) ? BigInteger.ZERO : MIN_AMOUNT));
            allocateAmount = BigInteger.valueOf(random.nextInt(MIN_AMOUNT.intValue(), maxAmount.intValue()));
            this.remainingAmount = this.remainingAmount.subtract(allocateAmount);
            this.remainingPerson = this.remainingPerson.subtract(BigInteger.ONE);
        }
        return allocateAmount;
    }
}
This post is licensed under CC BY 4.0 by the author.