原创

随机整数取模为什么有偏差:从256个状态分成10组说起

把一个随机整数取模10,看起来就得到了0到9。即使原始状态完全均匀,这一步也不一定均匀:如果原始可选状态数不能被10整除,总有一些余数分到更多来源。

这个问题不需要跑百万次随机数来猜。把模型缩小到恰好256个等可能状态,逐个枚举,就能准确看出偏差。余数0到5各有26个来源,6到9各有25个;“每个值都出现过”显然不能证明它们概率相同。

import java.util.*;
public class ModuloBias {
    public static void main(String[] args) {
        int[] modulo = new int[10], rejectedTail = new int[10];
        for (int value = 0; value < 256; value++) {
            modulo[value % 10]++;
            if (value < 250) rejectedTail[value % 10]++;
        }
        System.out.println("modulo=" + Arrays.toString(modulo));
        System.out.println("rejection=" + Arrays.toString(rejectedTail));
        System.out.println("abs_min=" + Math.abs(Integer.MIN_VALUE));
        Random random = new Random(123);
        boolean inRange = true;
        for (int i = 0; i < 10000; i++) {
            int value = random.nextInt(10); inRange &= value >= 0 && value < 10;
        }
        System.out.println("bounded_ok=" + inRange);
        try { random.nextInt(0); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { System.out.println("bound_zero=REJECTED"); }
    }
}

JDK 17中保存为 ModuloBias.java,执行 java ModuloBias.java:

modulo=[26, 26, 26, 26, 26, 26, 25, 25, 25, 25]
rejection=[25, 25, 25, 25, 25, 25, 25, 25, 25, 25]
abs_min=-2147483648
bounded_ok=true
bound_zero=REJECTED

拒绝多出来的尾部,再做等分

在这个八位模型里,拒绝250到255,剩下250个状态可以均匀分成10组。输出里的第二份计数每组都是25。这是有限状态枚举,不是某台机器跑出来的性能或概率估计。

受限随机API可以针对给定边界采用相应算法,调用方通常应直接使用nextInt(bound),而不是自行拿nextInt再取模。具体实现不必等于样本的八位拒绝器;这个模型用于解释为什么需要处理不能整除的尾部。

边界bound必须为正,结果包含零、不包含bound。若要闭区间,不能不检查范围就把上界加一,特别是接近Integer.MAX_VALUE时。起止范围也应使用对应重载或经过检查的表达式,不把范围错误留给生成后的补丁。

如果随机源只给一个字节,拒绝路径怎么测

有些适配边界拿到的是八位数值,而不是直接提供nextInt(bound)的对象。把它分配到十种奖项时,可以用前面的有限模型写出一份小拒绝器;但必须先声明每次源字节独立且等可能;仅有长期边际频率均匀,不足以替相关的取值序列建立这份保证。若来源已经偏向某些字节,过滤尾部不会替它恢复均匀,更不会让一个可预测来源变成秘密。

下面的 ByteRejection.java 主要用于检验拒绝流程。固定输入250、255、9,让前两个值必定被拒绝,第三次才选出9;这比在测试里希望“随机碰到两次拒绝”稳定得多。

import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntSupplier;
public class ByteRejection {
    static int choose(int bound, IntSupplier nextByte, int maxDraws) {
        if (bound < 1 || bound > 256 || maxDraws < 1 || maxDraws > 1000)
            throw new IllegalArgumentException("invalid draw policy");
        int limit = 256 - 256 % bound;
        for (int attempt = 0; attempt < maxDraws; attempt++) {
            int value = nextByte.getAsInt();
            if (value < 0 || value > 255) throw new IllegalArgumentException("byte source outside range");
            if (value < limit) return value % bound;
        }
        throw new IllegalStateException("draw budget exhausted");
    }
    public static void main(String[] args) {
        int[] scripted = {250,255,9};
        AtomicInteger position = new AtomicInteger();
        int value = choose(10,() -> scripted[position.getAndIncrement()],8);
        System.out.println("choice=" + value + ", source calls=" + position.get());
        System.out.println("bound one=" + choose(1,() -> 255,1));
        System.out.println("bound 256=" + choose(256,() -> 255,1));
        try { choose(10,() -> 255,8); }
        catch (IllegalStateException e) { System.out.println(e.getMessage()); }
        try { choose(10,() -> 256,8); }
        catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
        if (value!=9 || position.get()!=3) throw new AssertionError("rejection sequence");
    }
}
choice=9, source calls=3
bound one=0
bound 256=255
draw budget exhausted
byte source outside range

limit随bound计算。十种结果时只接受0到249;bound为一时所有字节都对应零,bound为256时每个字节直接对应自身。这里限制bound不超过256,是当前单字节模型的前提,不代表Java受限随机接口只有这点范围。

循环允许拒绝后重新取源值,不是对同一个被拒绝的值反复取模。一直返回255的伪源会耗尽次数预算,程序明确失败,不返回一个“兜底随机值”。如果在超时后固定返回零,或者强行使用最后一个尾部值,原本证明过的等概率关系就会被新的异常分支改变。

次数预算防止演示在坏源上无限运行,但不提供硬时间限制:一次nextByte调用本身也可能阻塞。真实设备或远端来源还需要自身读取截止时间。普通应用已经使用JDK随机源时,直接采用nextInt(bound)通常更合适,不需要把本实验重新包装成一套生产随机库。

业务还应独立核对抽取范围与资格。十种等概率编号只表示编号机会一致,若每个编号代表的奖品库存不同,或者某些用户不具备领取资格,最终业务概率需要自己的模型。不能在抽到不合格项以后随意改成另一个固定编号,还继续向用户声称整个流程均匀。

这个测试保存了三类证据:一次明确的拒绝序列、连续拒绝后的失败、超出八位范围的源值拒绝。它们检验控制流与边界,不把固定脚本输入冒称真实随机分布。真正要证明映射均匀,仍然依赖最开始的有限状态计数及来源假设。

Math.abs 还会带来额外一个坑

普通nextInt可能为负,调用者经常先Math.abs再取模。Integer.MIN_VALUE的正绝对值超出int表示范围,Math.abs仍返回负数,余数也可能为负。样本直接打印这个边界,避免用一次随机运行“没碰到”来忽略它。

即便绕过最小负数,绝对值映射和取模仍不自动消除分布偏差。把代码修到不产生负数,与把每个目标值的概率修正确,是两项不同要求。

同样不建议 random.nextInt() & 0x7fffffff 然后直接取模来充当通用无偏实现。它虽然得到非负值,仍需考虑状态空间与bound是否整除;解决一项表示问题,不代表分布问题随之消失。

随机样本只能做辅助核对

程序用固定种子生成一万次nextInt(10),只验证结果确实落在范围内,没有断言每组恰好一千次。真正随机抽样本来就会波动,测试要求相同频率反而会拒绝正确实现。

需要验证算法分布时,可以分析有限映射、使用恰当的统计方法并控制误判概率;普通单元测试则重点核对范围、非法参数和确定的回归序列。不要因为某次频率看起来接近,就给一个有数学偏差的映射签字。

这里使用java.util.Random,是为了可复现演示,不适合生成密码重置令牌或其他需要不可预测性的秘密。无偏不等于不可预测;SecureRandom解决另一组安全要求,也仍应使用受限API而非随意截取结果。

若业务还要求不重复,例如抽取互不相同的题目编号,需要无放回抽样模型。连续调用nextInt不会自动避免重复,简单遇到重复再重试的成本也会随占满比例变化。先确认要的是独立随机值、均匀子集还是打乱顺序,才能选择对应算法,而不是把所有随机需求压成一条百分号表达式。

正文到此结束
评论插件初始化中...
Loading...
本文目录