Java:实现 按概率生成随机数算法(附带源码)
一、项目背景详细介绍
在一些场景中,如抽奖系统、游戏掉落、广告推荐等,需要按照预设的不等概率分布随机选择元素,而不仅仅是均匀分布。比如有三个奖品 A、B、C,权重分别为 50%、30%、20%,希望抽奖时 A 被选中的概率是 0.5、B 是 0.3、C 是 0.2。实现“按概率生成随机数”算法,是对基础随机数生成的扩展,也是了解如何将权重映射到区间并高效查询的关键。
二、项目需求详细介绍
-
输入权重数组
-
输入一个正数数组
weights[],长度为 n,表示 n 个元素对应的权重,可以是整数或浮点数。
-
-
初始化预处理
-
提供方法
void init(double[] weights),对权重数组进行预处理,将其转换为可快速查询的结构(如前缀和、别名法表等)。
-
-
随机生成索引
-
提供方法
int randomIndex(),返回一个[0, n)范围内的整数 i,使得P(i) = weights[i] / sum(weights)。
-
-
性能要求
-
init时间可为 O(n) 或 O(n log n); -
randomIndex必须在 O(log n) 或 O(1) 内完成,以满足高并发场景。
-
-
两种实现方案
-
前缀和二分查找:O(n) 初始化,O(log n) 查询;
-
Vose 别名法(Alias Method):O(n) 初始化,O(1) 查询。
-
-
命令行工具模式
-
提供
Main类,解析命令行参数:-
--weights w1,w2,…,wn:权重列表; -
--method prefix|alias:选择实现; -
--trials m:抽样次数,统计各索引出现频率并输出。
-
-
-
单元测试覆盖
-
使用 JUnit 5 对两种算法测试:小规模权重、极端权重(含零权重)、随机分布,验证分布近似正确。
-
-
可扩展性
-
支持动态更新权重(如在线加权),可选重建或增量维护别名表。
-
三、相关技术详细介绍
-
Java 随机数
-
java.util.Random或ThreadLocalRandom生成基础均匀随机 [0,1);
-
-
前缀和 + 二分查找
-
构建累加和数组
prefix[i] = sum(weights[0..i]); -
生成
r = uniform(0, total),在prefix上用Arrays.binarySearch或手写二分找最小i满足prefix[i] > r。
-
-
别名法(Alias Method)
-
维持两个大小为 n 的数组:
alias[]和prob[],初始化时将权重映射到等概率区间,并分配别名; -
查询时生成
i = uniformInt(0, n),u = uniform(0,1),若u < prob[i]返回i,否则返回alias[i]。
-
-
命令行解析
-
Apache Commons CLI:解析
--weights、--method、--trials参数与帮助信息。
-
-
测试与统计
-
JUnit 5:测试算法正确性;
-
简易统计打印抽样结果,与理论概率比较。
-
四、实现思路详细介绍
-
工具类
WeightedRandom-
私有构造与两种静态内部实现:
PrefixSum和AliasMethod,均实现接口:
-
public interface Sampler {
int next();
}
-
-
init返回Sampler实例。
-
-
前缀和实现
-
构造时计算
double[] prefix与total; -
next():生成r = random.nextDouble() * total,return Arrays.binarySearch转换后或手写。
-
-
别名法实现
-
构造时按经典 Vose 算法生成
prob[]与alias[]; -
next():int i = random.nextInt(n),if random.nextDouble() < prob[i] return i; else return alias[i];
-
-
命令行入口
Main-
解析并拆分字符串参数到
double[] weights; -
根据
--method选择实现,构造相应Sampler; -
执行
trials次next(),统计结果数组并打印相对频率。
-
-
单元测试
-
对小型
weights={1,2,3}抽大样本统计,验证近似比例 1:2:3; -
测试含零权重元素始终不被抽中;
-
边界测试
weights全相同或仅一个非零权重。
-
五、完整实现代码
// 文件:src/main/java/com/example/random/WeightedRandom.java
package com.example.random;
import java.util.Arrays;
import java.util.Deque;
import java.util.ArrayDeque;
import java.util.concurrent.ThreadLocalRandom;
/**
* WeightedRandom 提供按权重随机抽取接口
*/
public class WeightedRandom {
public interface Sampler {
int next();
}
// 前缀和实现
public static class PrefixSampler implements Sampler {
private final double[] prefix;
private final double total;
public PrefixSampler(double[] weights) {
int n = weights.length;
prefix = new double[n];
double sum = 0;
for (int i = 0; i < n; i++) {
if (weights[i] < 0) throw new IllegalArgumentException("权重不能为负");
sum += weights[i];
prefix[i] = sum;
}
if (sum <= 0) throw new IllegalArgumentException("权重之和必须大于 0");
total = sum;
}
@Override
public int next() {
double r = ThreadLocalRandom.current().nextDouble() * total;
int idx = Arrays.binarySearch(prefix, r);
if (idx >= 0) return idx;
return -idx - 1;
}
}
// 别名法实现(Vose 别名法)
public static class AliasSampler implements Sampler {
private final int[] alias;
private final double[] prob;
private final int n;
public AliasSampler(double[] weights) {
n = weights.length;
double sum = 0;
for (double w : weights) {
if (w < 0) throw new IllegalArgumentException("权重不能为负");
sum += w;
}
if (sum <= 0) throw new IllegalArgumentException("权重之和必须大于 0");
// 归一化到 n 份
double[] norm = new double[n];
for (int i = 0; i < n; i++) norm[i] = weights[i] * n / sum;
alias = new int[n];
prob = new double[n];
Deque<Integer> small = new ArrayDeque<>();
Deque<Integer> large = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
if (norm[i] < 1.0) small.add(i);
else large.add(i);
}
while (!small.isEmpty() && !large.isEmpty()) {
int l = small.removeLast();
int g = large.removeLast();
prob[l] = norm[l];
alias[l] = g;
norm[g] = norm[g] + norm[l] - 1;
if (norm[g] < 1.0) small.add(g);
else large.add(g);
}
// 剩余
while (!large.isEmpty()) {
prob[large.removeLast()] = 1.0;
}
while (!small.isEmpty()) {
prob[small.removeLast()] = 1.0;
}
}
@Override
public int next() {
int i = ThreadLocalRandom.current().nextInt(n);
double u = ThreadLocalRandom.current().nextDouble();
return u < prob[i] ? i : alias[i];
}
}
/**
* 工厂方法,根据 method 返回不同 Sampler
* @param weights 权重数组
* @param method "prefix" 或 "alias"
*/
public static Sampler create(double[] weights, String method) {
if ("alias".equalsIgnoreCase(method)) {
return new AliasSampler(weights);
}
return new PrefixSampler(weights);
}
}
// 文件:src/main/java/com/example/cli/Main.java
package com.example.cli;
import com.example.random.WeightedRandom;
import com.example.random.WeightedRandom.Sampler;
import org.apache.commons.cli.*;
import java.util.Arrays;
/**
* Main 类演示按权重抽样并统计频率
*/
public class Main {
public static void main(String[] args) {
Options opts = new Options();
opts.addOption(null, "weights", true, "权重列表,例如: --weights 50,30,20");
opts.addOption(null, "method", true, "实现方法: prefix|alias");
opts.addOption(null, "trials", true, "抽样次数,默认为1000000");
HelpFormatter hf = new HelpFormatter();
CommandLineParser parser = new DefaultParser();
try {
CommandLine cmd = parser.parse(opts, args);
String wstr = cmd.getOptionValue("weights");
String method = cmd.getOptionValue("method", "prefix");
int trials = Integer.parseInt(cmd.getOptionValue("trials", "1000000"));
if (wstr == null) {
throw new ParseException("必须指定 --weights");
}
double[] weights = Arrays.stream(wstr.split(","))
.mapToDouble(Double::parseDouble)
.toArray();
Sampler sampler = WeightedRandom.create(weights, method);
long[] counts = new long[weights.length];
for (int i = 0; i < trials; i++) {
counts[sampler.next()]++;
}
System.out.println("实验次数: " + trials);
for (int i = 0; i < counts.length; i++) {
System.out.printf("索引 %d: 理论=%.4f, 实测=%.4f%n",
i,
weights[i] / Arrays.stream(weights).sum(),
counts[i] / (double)trials
);
}
} catch (ParseException pe) {
System.err.println("参数解析错误: " + pe.getMessage());
hf.printHelp("java -jar weighted-random.jar", opts);
}
}
}
// 文件:src/test/java/com/example/random/WeightedRandomTest.java
package com.example.random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.stream.IntStream;
/**
* WeightedRandom 单元测试
*/
public class WeightedRandomTest {
@Test
void testPrefixSamplerDistribution() {
double[] w = {1,2,3};
WeightedRandom.Sampler s = new WeightedRandom.PrefixSampler(w);
int trials = 300_000;
long[] cnt = new long[3];
for (int i = 0; i < trials; i++) cnt[s.next()]++;
double p0 = cnt[0]/(double)trials,
p1 = cnt[1]/(double)trials,
p2 = cnt[2]/(double)trials;
assertTrue(Math.abs(p0-1/6.0) < 0.01);
assertTrue(Math.abs(p1-2/6.0) < 0.01);
assertTrue(Math.abs(p2-3/6.0) < 0.01);
}
@Test
void testAliasSamplerDistribution() {
double[] w = {5,0,5};
WeightedRandom.Sampler s = new WeightedRandom.AliasSampler(w);
int trials = 200_000;
long[] cnt = new long[3];
for (int i = 0; i < trials; i++) cnt[s.next()]++;
assertEquals(0, cnt[1]);
assertTrue(cnt[0] > 0);
assertTrue(cnt[2] > 0);
}
@Test
void testInvalidWeights() {
assertThrows(IllegalArgumentException.class,
() -> new WeightedRandom.PrefixSampler(new double[]{0,0}));
assertThrows(IllegalArgumentException.class,
() -> new WeightedRandom.AliasSampler(new double[]{-1,1}));
}
}
六、代码详细解读
-
WeightedRandom.PrefixSampler
-
构造时计算累加前缀
prefix[i] = sum(weights[0..i])和总和total; -
next()生成[0, total)随机数r,在prefix数组上二分查找最小索引i使得prefix[i] > r。
-
-
WeightedRandom.AliasSampler
-
构造时按 Vose 别名法准备大小为 n 的
prob[]和alias[]:-
归一化权重为均匀基准
norm[i] = weight[i]*n/sum; -
将
norm分成小于 1 和大于等于 1 两组,配对填充表;
-
-
next()随机选索引i = uniformInt(0,n),再生成u = uniform(0,1),若u < prob[i]返回i,否则返回alias[i]。
-
-
Main
-
解析
--weights、--method、--trials参数; -
构造对应
Sampler并进行trials次抽样,统计计数后打印理论概率与实测概率对比。
-
-
测试
-
testPrefixSamplerDistribution:对
{1,2,3}进行 30 万次抽样,验证实测概率在 ±1% 以内; -
testAliasSamplerDistribution:对包含零权重的
{5,0,5}抽样,验证中间索引永不出现; -
testInvalidWeights:非法权重(全为 0 或含负数)构造应抛
IllegalArgumentException。
-
七、项目详细总结
本项目实现了两种按权重随机抽样算法:
-
前缀和+二分:初始化 O(n),查询 O(log n),实现简单。
-
别名法:初始化 O(n),查询 O(1),适合超高并发场景。
通过对比实测概率和理论概率,验证了两种算法的正确性与性能差异。项目结构清晰,接口统一,易于在实际系统中集成。
八、项目常见问题及解答
Q1:权重全为零或负数怎么办?
应在初始化时校验抛异常,权重之和必须大于零,且各权重非负。
Q2:前缀法和别名法各适用场景?
-
前缀法实现简单、无需额外空间(O(n) 前缀数组)即可,适合 n 较小或查询频率不高的场景;
-
别名法初始化复杂,但查询 O(1),适合大规模元素和高并发查询。
Q3:如何动态更新权重?
-
对于前缀法,需重新构建前缀数组;
-
对于别名法,增删需重建别名表或使用增量算法(复杂)。
Q4:浮点精度影响结果吗?
若权重之和或前缀和非常大或非常小,浮点运算误差可能影响二分索引,可考虑使用 BigDecimal 或缩放权重。
Q5:如何扩展到多维或分层抽样?
可将分层视为两级权重抽样,先按大类权重抽取,再在子类中再次调用相同算法。
九、扩展方向与性能优化
-
并行初始化
利用并行流构建前缀数组或别名表,提高构造速度。 -
基准测试
使用 JMH 对不同 n、不同权重分布、不同算法进行性能对比。 -
固定点运算
用整型前缀和和随机整数避免浮点误差,提高精度。 -
内存压缩
对大规模元素,将alias和prob压缩至更紧凑的数据结构。 -
多语言互操作
提供 REST 服务或 JNI 接口,供 Python、Go 等语言调用。
更多推荐
所有评论(0)