很多 Java 初学者都有这个困惑:单看数组、集合、循环、异常都懂,但真要独立写一个像样的项目,就不知道从哪里下手。我一直推荐一个被低估的练手项目——双色球&大乐透随机选号生成器。它规则清晰、边界明确,却能自然牵扯出随机数源选择、不重复抽样算法、参数校验、格式化输出这一串非常工程化的问题。这篇文章就把我从一个 5 行代码工具类迭代到完整可复用工具类的全过程写透,包括原理、完整 Java 实现、验证手段和踩过的坑。
先说清楚:这个项目本质上是解决“从 33 个红球里抽 6 个不重复、从 16 个蓝球里抽 1 个”这类不重复抽样问题。听懂这句话,后面所有代码都是围绕它转。文章适合两类人:一是学完 Java 基础想找项目练手的初学者,二是想把自己的代码写得健壮、可复用、经得起推敲的开发者。下面直接进入正题。
1. 需求拆解:双色球和大乐透规则背后的算法约束
1.1 两条玩法的规则明细
把规则说清楚,代码才不会写歪。
双色球分两个区:红球 1~33,每注选 6 个;蓝球 1~16,每注选 1 个。红球内部不允许重复,蓝球和红球互相独立。大乐透也是两区:前区 1~35,每注选 5 个;后区 1~12,每注选 2 个。前区内部不能重复,后区内部也不能重复。
注意一个关键点:这两个玩法的规则模型是完全一样的,都是“从长度为 n 的连续整数区间里,等概率抽出 k 个不重复的整数”。所以没必要分别写两套逻辑,一个好的抽号方法应该同时服务这两种玩法。双色球就是draw(1, 33, 6)加draw(1, 16, 1),大乐透就是draw(1, 35, 5)加draw(1, 12, 2)。这样一个抽象,代码复用率直接拉满。
1.2 把需求抽象成一个抽样问题
从算法角度拆解,随机选号本质上是一个不重复等概率抽样问题:从候选集合[start, end]中抽取count个元素,要求每个元素被选中的概率严格相等,且最终结果互相不重复。
很多人第一反应是“随机生成 6 个数就行”,但随即就会遇到重复问题:如果直接生成 6 个 1~33 的随机整数,这 6 个数字可能撞车。要解决碰撞,最朴素的想法是生成一个就存一个,遇到重复就重新生成——这就是后面要讲的 Set 去重方案。另一种思路是借鉴洗牌:把 33 个球排成一排,随机打乱顺序,然后取前 6 个,这就是 Fisher-Yates 洗牌方案。两种方案在 33 选 6 这种小规模下都能跑,但背后的性能和适用边界差距很大,第 3 章会专门对比。
在这里我们可以顺手算一下组合总数,对理解需求边界有帮助:双色球红球组合数是C(33, 6) = 1,107,568,再乘 16 个蓝球,总组合数是 17,721,088;大乐透前区C(35, 5) = 324,632,后区C(12, 2) = 66,总组合数是 21,425,712。两个玩法的一等奖概率都在千万分之一这个量级。这说明如果采用“枚举所有组合再随机取一个索引”的思路,你得在内存里摆下 1700 多万个组合对象,非常不现实。而按号码级别做均匀采样,每个号码等概率出现,组合自然也是等概率的,这才是正确且高效的做法。
1.3 需求边界:这个工具做什么、不做什么
写代码之前必须明确边界,否则需求会无限膨胀:
- 只做随机选号,不做任何预测。历史开奖结果、冷热号、走势图这些概念对下一次开奖没有任何数学意义。
- 不考虑中奖概率加权。所有号码一视同仁。
- 不考虑历史号码过滤。如果你要求“生成的 5 注号码彼此不重复”,那是另一个更复杂的全局去重问题,本文会在第 5 章简单延伸,但不是核心需求。
把这个边界想清楚,后续设计就不会跑偏。这个工具的价值在于帮你生成一组完全随机的号码组合,它承担的是“输出格式合法、概率均匀”的职责,而不是替你“分析”什么。
2. 随机数源选型:Random、ThreadLocalRandom 与 SecureRandom 怎么选
2.1 三种随机数源的实现与差异
Java 里最常见的三个随机数源,我整理了一张表,先看结论:
| 随机源 | 实现原理 | 线程安全 | 是否可预测 | 性能 |
|---|---|---|---|---|
java.util.Random | 线性同余生成器(LCG),48 位种子 | 安全(CAS 更新种子) | 可预测 | 高 |
ThreadLocalRandom | 每个线程独立种子,无共享状态 | 安全 | 可预测 | 极高 |
SecureRandom | 基于操作系统熵源 | 安全 | 不可预测 | 较低 |
Random的实现核心是一个线性同余公式:newSeed = (oldSeed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1),然后取高 32 位作为输出。这种算法速度快,但它是伪随机——如果两个实例种子相同,生成的序列完全一致。而且理论上,一旦别人拿到你足够多的输出值,是可以逆向推算出后续序列的。
ThreadLocalRandom是 JDK 7 引入的高并发优化版。它不给全局共享的种子加锁,而是把种子放到每个线程自己的字段里,彻底消除了锁竞争,所以在多线程场景下性能最好。但它同样基于确定性算法,属于可预测的伪随机。
SecureRandom走的是另一条路。它从操作系统的熵源(Linux 上是/dev/urandom或更底层的 getrandom 系统调用,Windows 上是 CryptoAPI)收集真随机性,即使你把它的输出全拿走,也无法推导出后续内容。代价是每次生成可能涉及系统调用,吞吐量比前两者低一个数量级。
2.2 我的选型建议与可插拔设计
具体到彩票随机选号这个场景,说实话Random已经完全够用。毕竟中奖概率千万分之一,你拿线性同余生成的伪随机序列和拿系统熵源生成的真随机序列,结果对用户来说没有可感知的区别。真正需要SecureRandom的场景是什么?是抽奖系统、令牌生成、密码学相关的安全敏感场景——那些地方如果有人能预测序列,游戏就结束了。
但作为工程师,更好的做法是不要把随机源写死。我推荐用构造器注入的方式:
LotteryUtil util = new LotteryUtil(); // 默认 Random LotteryUtil util = new LotteryUtil(new SecureRandom()); // 安全场景 LotteryUtil util = new LotteryUtil(new Random(42L)); // 固定种子,测试复现这样做有三个好处:
- 测试时传入固定种子
Random(42L),程序每次生成的号码完全一致,适合做回归断言。 - 生产环境想升级随机源,不用改任何核心逻辑。
- 代码职责更清晰,核心抽样算法只依赖
Random的抽象能力,不关心具体实现。
有一个细节要提醒:ThreadLocalRandom不是通过new创建的,而是通过ThreadLocalRandom.current()获取当前线程自己的实例。所以如果要用它做注入,得写成方法内直接调用,或者用Supplier<Random>的方式动态获取,不能简单塞进构造器,否则跨线程使用会出问题。这个坑我放到第 6 章单独讲。
3. 不重复抽样:为什么我更推荐 Fisher-Yates 洗牌法
3.1 方案一:HashSet 去重重试法
先看最直观的写法:
public static List<Integer> drawWithSet(int start, int end, int count) { Set<Integer> set = new HashSet<>(); Random random = new Random(); while (set.size() < count) { int num = random.nextInt(end - start + 1) + start; set.add(num); } List<Integer> result = new ArrayList<>(set); Collections.sort(result); return result; }这代码逻辑一目了然:生成随机数,塞进 Set,Set 天然去重,直到数量够。对于 33 选 6,平均只需要调用nextInt约 6.5 次就能完成,性能损失可以忽略。
但它有个隐患:当count接近end - start + 1时,碰撞会非常严重。比如从 1~35 选 34 个,前 33 个都能顺利加入,最后一个要反复抽到唯一的那个缺失数字,期望重试次数是 35 次。如果极端的1~35 选 35,理论上会出现无限重试——虽然概率极小,但这是个活锁风险,在实时系统里不可接受。
从算法复杂度角度,Set 方法的时间复杂度是O(k * 平均重试次数),而这个“平均重试次数”会随着k/n增大急剧上升。写通用工具类时,这种不确定性是很糟糕的。
3.2 方案二:部分 Fisher-Yates 洗牌法
Fisher-Yates 洗牌是教科书经典:把候选数组从后往前遍历,每一轮把当前位置和随机位置交换。经过 n 次交换后,数组就是完全随机排列的。
彩票场景其实不需要把整个数组都洗一遍,只需要决定“取哪几个”。于是有更省时间的“部分洗牌”版本:
public static List<Integer> drawWithShuffle(int start, int end, int count) { int total = end - start + 1; int[] pool = new int[total]; for (int i = 0; i < total; i++) { pool[i] = start + i; } Random random = new Random(); // 只需要洗“尾部 count 个位置”,前面的位置不关心 for (int i = total - 1; i >= total - count; i--) { int j = random.nextInt(i + 1); int tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp; } List<Integer> result = new ArrayList<>(count); for (int i = total - count; i < total; i++) { result.add(pool[i]); } Collections.sort(result); return result; }原理很简单:每次从[0, i]里随机挑一个下标j,把pool[i]和pool[j]交换。因为每轮参与交换的元素集合都在缩小,且每一步都是等概率选择,所以最终尾部的count个元素构成的子集,数学上严格等概率。
这个方案的优势:
- 无重试、无碰撞。运行次数是固定的,
count次交换加一次数组遍历。 - 均匀性可证明。每一步
nextInt(i+1)都是均匀的,组合的层次上自然也是均匀的。 - 代码非常短,而且逻辑容易用肉眼看明白。
代价是需要一个长度为total的int[]数组。对双色球 33 个红球来说,微不足道。
3.3 方案三:Floyd 采样法(处理超大区间)
如果区间非常大,比如要从 1 到 1 亿之间选 6 个不重复数字,这时候给洗牌法初始化一个 1 亿长度的数组,占用 400MB 内存,基本不可行。Floyd 采样算法是更好的选择,它只用大小为count的集合:
public static Set<Integer> drawWithFloyd(int start, int end, int count, Random rnd) { int total = end - start + 1; Set<Integer> result = new LinkedHashSet<>(); for (int i = total - count; i < total; i++) { int candidate = rnd.nextInt(i + 1); if (!result.contains(candidate)) { result.add(candidate); } else { result.add(i); } } return result; }这段代码的精妙之处在于:生成[0, i]的随机候选时,如果候选已经在集合里,就把当前轮次对应的i放进去。用数学归纳法可以证明,任意一个元素被选中的概率都是count / total。Floyd 算法复杂度是O(k log k),空间只有O(k),完全不依赖区间长度。
但在 33 选 6 这个场景,我并不推荐用它,因为LinkedHashSet有装箱和哈希开销,几乎必然比int[]的洗牌慢。选算法一定要看数据规模,不能一上来就堆高级知识。
3.4 三种方案如何取舍
汇总一下,决策依据其实就一张表:
| 方案 | 时间复杂度 | 空间复杂度 | 是否重试 | 适用场景 |
|---|---|---|---|---|
| Set 去重 | O(k × 平均重试) | O(k) | 是,k 接近 n 时恶化 | k 远小于 n,实现简单 |
| 部分 Fisher-Yates | O(n + k) | O(n) | 否 | n 在可接受内存范围内,通用性最强 |
| Floyd 采样 | O(k log k) | O(k) | 否 | n 极大(上亿)、k 相对很小 |
我的建议是:双色球和大乐透都采用部分 Fisher-Yates。因为 33、35、16、12 这些候选集都非常小,初始化数组的代价几乎为零,而且换来的是确定性的执行时间和可证明的均匀性,对工具类而言这是最稳妥的选择。Floyd 当成储备知识,在将来处理超大区间采样时自然会用上。
4. 代码实战:一个可复用的 LotteryUtil 工具类
4.1 环境准备与类设计
环境要求很低:JDK 8 以上都行,我在 JDK 17 上验证过,代码完全兼容。不需要任何第三方依赖,纯 JDK 直接编译运行。项目结构也很简单:
src/ me/example/lottery/ LotteryUtil.java LotteryVerify.java Main.java核心类LotteryUtil的设计要点有三条:
- 核心方法
draw(int start, int end, int count)只解决“区间抽样”一个问题。 - 双色球和大乐透方法只是对
draw的组合调用,不重复逻辑。 - 随机源通过构造器注入,默认
Random,可灵活换成SecureRandom或固定种子。
4.2 核心 draw 方法实现
package me.example.lottery; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Random; public class LotteryUtil { private final Random random; public LotteryUtil() { this(new Random()); } public LotteryUtil(Random random) { this.random = random; } /** * 从 [start, end] 区间抽取 count 个不重复整数。 * 采用部分 Fisher-Yates 洗牌,保证每个子集被抽中的概率完全相同。 */ public List<Integer> draw(int start, int end, int count) { if (start > end) { throw new IllegalArgumentException("start(" + start + ") must <= end(" + end + ")"); } int total = end - start + 1; if (count < 0 || count > total) { throw new IllegalArgumentException("count(" + count + ") must between 0 and " + total); } int[] pool = new int[total]; for (int i = 0; i < total; i++) { pool[i] = start + i; } // 只需要保证“尾部 count 个位置”完成随机交换 for (int i = total - 1; i >= total - count; i--) { int j = random.nextInt(i + 1); int tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp; } List<Integer> result = new ArrayList<>(count); for (int i = total - count; i < total; i++) { result.add(pool[i]); } Collections.sort(result); return result; } }有两个细节值得说明。第一,random.nextInt(i + 1)返回的是[0, i]闭区间的随机数,因为Random.nextInt(bound)是左闭右开的,传i+1才能覆盖到i。第二,为什么最后要Collections.sort?因为用户看号码习惯从小到大排列,排序不影响随机性——选出的子集已经确定,只是展示顺序变了。
4.3 双色球、大乐透封装与输出格式化
接着把两种玩法封装成方法,同时定义一个不可变的号码对象:
/** * 双色球:红球 33 选 6 + 蓝球 16 选 1 */ public LotteryTicket doubleColorBall() { List<Integer> red = draw(1, 33, 6); List<Integer> blue = draw(1, 16, 1); return new LotteryTicket(red, blue); } /** * 大乐透:前区 35 选 5 + 后区 12 选 2 */ public LotteryTicket superLotto() { List<Integer> front = draw(1, 35, 5); List<Integer> back = draw(1, 12, 2); return new LotteryTicket(front, back); } public static class LotteryTicket { private final List<Integer> mainNumbers; private final List<Integer> bonusNumbers; public LotteryTicket(List<Integer> mainNumbers, List<Integer> bonusNumbers) { // 防御性拷贝 + 不可变包装,防止外部修改内部状态 this.mainNumbers = Collections.unmodifiableList(new ArrayList<>(mainNumbers)); this.bonusNumbers = Collections.unmodifiableList(new ArrayList<>(bonusNumbers)); } public List<Integer> getMainNumbers() { return mainNumbers; } public List<Integer> getBonusNumbers() { return bonusNumbers; } @Override public String toString() { return format(mainNumbers) + " + " + format(bonusNumbers); } private String format(List<Integer> numbers) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < numbers.size(); i++) { if (i > 0) { sb.append(" "); } sb.append(String.format("%02d", numbers.get(i))); } return sb.toString(); } } }LotteryTicket被设计成不可变对象,是因为号码一旦生成就不应该被任何人改掉。即便你拿到getMainNumbers()返回的List,试图add或remove也会直接抛UnsupportedOperationException。防御性拷贝加不可变包装,是 Java 开发里非常推荐的工程实践。
4.4 运行效果
写一个入口验证:
package me.example.lottery; public class Main { public static void main(String[] args) { LotteryUtil util = new LotteryUtil(); System.out.println("=== 双色球 5 注 ==="); for (int i = 0; i < 5; i++) { System.out.println(util.doubleColorBall()); } System.out.println("=== 大乐透 5 注 ==="); for (int i = 0; i < 5; i++) { System.out.println(util.superLotto()); } } }运行后的输出效果如下:
=== 双色球 5 注 === 03 11 18 25 29 33 + 06 01 05 12 22 26 28 + 15 07 09 14 17 23 30 + 02 02 08 13 19 24 32 + 11 04 10 16 21 27 31 + 08 === 大乐透 5 注 === 03 07 12 24 31 + 04 09 05 11 17 22 34 + 02 10 01 09 15 23 28 + 06 11 06 08 19 27 32 + 03 12 02 10 16 25 33 + 01 08String.format("%02d", num)的作用是补零对齐,比如03而不是3,看票面的体验更自然。注意这个格式化只适用于 1~99 的号码,双色球和大乐透区间都满足,所以可以安心用。
5. 验证算法:分布均匀性、卡方检验与性能实测
5.1 频次统计:肉眼先看分布
写完代码只是第一步,还要验证算法真的均匀。最简单的验证思路:跑足够多期,统计每个号码出现的次数,看是否都靠近理论期望。
以双色球红球为例,理论期望次数 = 总注数 × 6 ÷ 33。如果跑了 50 万注,每个红球的理论出现次数是500000 * 6 / 33 ≈ 90909.09。实际统计结果会在期望值附近上下浮动,只要大部分号码的偏差不超过几百分之一,就先认为没问题。
这段验证代码可以直接跑:
package me.example.lottery; import java.util.HashSet; import java.util.List; public class LotteryVerify { public static void main(String[] args) { int trials = 500_000; LotteryUtil util = new LotteryUtil(); int[] redCount = new int[34]; // 下标 1..33 for (int i = 0; i < trials; i++) { List<Integer> red = util.draw(1, 33, 6); // 验证一注内不重复 if (new HashSet<>(red).size() != 6) { throw new IllegalStateException("发现重复号码"); } for (int num : red) { redCount[num]++; } } double expected = trials * 6.0 / 33; System.out.printf("试验次数: %d, 期望每个号码出现: %.2f 次%n", trials, expected); double chiSquare = 0.0; for (int i = 1; i <= 33; i++) { double diff = redCount[i] - expected; chiSquare += diff * diff / expected; System.out.printf("%02d号: %d 次%n", i, redCount[i]); } System.out.printf("卡方统计量: %.4f%n", chiSquare); } }这段代码还顺带做了一个最关键的完整性检查:把一注 6 个红球装进HashSet,如果去重后数量不是 6,说明抽样算法有 bug,直接抛异常终止。
5.2 卡方检验:用统计学判断分布是否均匀
肉眼看频次表只能判断“大差不差”,要严谨就得用卡方检验。卡方统计量的公式是:
chi2 = Σ (observed - expected)² / expected自由度等于号码个数减 1,也就是 32。查卡方分布表,自由度 32、显著性水平 0.05 的临界值约为 46.19。如果算出来的chi2 < 46.19,说明在 5% 显著性水平下,没有足够证据认为号码分布不均匀;反之就要怀疑随机源或算法出了问题。
我在实验里跑过一次 50 万注,卡方统计量通常落在 25~40 之间,远小于 46.19。这个结论和随机数理论是一致的:虽然某个号码可能在某一段统计里多出几个,但这种波动完全在随机误差允许范围内。
你可以在验证代码后面加一行判断:
double criticalValue = 46.19; System.out.println(chiSquare < criticalValue ? "结论:无充分证据证明分布不均匀" : "结论:分布可能存在偏差,请检查随机源");这是把数学工具用到代码里的典型思路,比单纯“跑了几次看起来没问题”专业得多。
5.3 性能实测与结果解读
再验证一下性能,毕竟如果用户要批量生成几百注,不能等半天。我写了一个简单的性能测试:连续生成 100 万注双色球,测耗时。
long start = System.nanoTime(); for (int i = 0; i < 1_000_000; i++) { util.doubleColorBall(); } long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); System.out.println("100 万注耗时: " + costMs + " ms");在我本机(普通笔记本,JDK 17)跑下来,100 万注大约在 500~800 毫秒之间。这个性能对于个人工具甚至单机服务都完全够用。性能瓶颈主要在int[]初始化和最后的Collections.sort,而这两部分都是必要开销。如果你追求极致,可以考虑用Arrays.parallelSort,但在 6 个元素的排序上,并行化的开销反而更大,得不偿失。
还有一个实用技巧:用固定种子做性能回归基准。把new LotteryUtil(new Random(42L))跑 100 万次,记录耗时,以后每次改了代码再跑一次对比,能立刻发现性能退化。
6. 避坑指南:六个容易翻车的细节
6.1 ThreadLocalRandom 的 nextInt 区间是左闭右开
很多人第一次用ThreadLocalRandom.current().nextInt(1, 33),以为取的是 1~33,实际取的是 1~32。因为nextInt(origin, bound)返回[origin, bound)之间的值,bound是不包含的。正确的 1~33 写法是nextInt(1, 34)。这个坑在面试和实际代码里出现的频率都很高,值得单独记一笔。
6.2 循环里反复新建 Random 的隐患
如果你的代码写成这样:
for (int i = 0; i < 100; i++) { Random r = new Random(); int num = r.nextInt(33) + 1; }在比较老的 JDK 版本里,new Random()默认种子取自当前纳秒时间。如果循环执行得足够快,多个实例可能拿到相同的种子,进而生成完全一样的随机序列。JDK 8 之后修正了种子的生成方式,但无论如何,复用同一个Random实例都是更好的习惯——既省对象创建开销,又避免序列碰撞。
6.3 ThreadLocalRandom 实例不能跨线程共享
这是很多并发代码里隐蔽的 Bug。ThreadLocalRandom.current()返回的是当前线程专属的上下文,官方文档明确说不要在多线程之间共享同一个ThreadLocalRandom实例。如果你把它存到类的静态字段,让多个线程一起调用,运行结果是不确定的,甚至可能抛出异常。
所以在多线程批量生成场景下,有两种正确姿势:
- 不用共享随机源,直接在每个线程内部调用
ThreadLocalRandom.current().nextInt(...)。 - 使用
ThreadLocal<Random>包装,每个线程持有自己的Random。
这也是为什么我推荐构造器注入Random而不是直接写死ThreadLocalRandom——注入的抽象给了你并发替换的余地。
6.4 返回不可变列表,别把内部集合暴露出去
如果LotteryTicket的getMainNumbers()直接返回内部List,调用方就能随意clear()或者add(),把已经生成的号码改得面目全非。这是 Java 里典型的“封装泄漏”。解法就是我代码里做的:构造器里先new ArrayList<>(传入List)做防御性拷贝,再用Collections.unmodifiableList包装不可变。成本极低,但能避免一堆诡异问题。
6.5 校验参数的边界值,尤其是 count 为 0 和 total
draw方法的参数校验不能只防“负数”和“太大会越界”,还要处理两个特殊边界:
count = 0:返回空列表,不应该抛异常。count = total:等于把整个区间全选出来,洗牌后返回所有号码,结果就是[start, end]的完整序列。
我在代码里用count < 0 || count > total做校验,恰好让这两种情况都走正常逻辑。这种边界思考方式是写工具类的基本功,很多新人容易漏掉count = 0的情况导致空指针。
6.6 格式化输出保持对齐,但别过度封装
String.format("%02d", num)能为两位数补零,让输出对齐美观,这是彩票场景的正确选择。但我见过有人为了通用性,把格式化逻辑封装成支持任意位数的“万能工具”,越搞越复杂。在这个项目里,号码范围明确限定在 1~99,%02d就是最优解。工程上要拒绝过度设计,能三行写清楚的东西不要去抽象出五个类。
另外提醒一个细节:格式化之前把号码排序是最舒服的阅读顺序。先排序再格式化,这套顺序在LotteryTicket.toString()里已经天然保证。
最后再分享一点点个人体会:这个工具类我前后迭代过好几版,最大的教训不是说算法多难,而是“设计模式”和“防御性编程”这类东西,看着很高深,实际在高价值的小代码里同样值得用。构造器注入、不可变对象、参数校验、边界判断,每一行都是经验堆出来的。你要是能把这个 200 行的工具类吃透,Java 基础阶段最容易被面试官问到的集合、随机数、可变参数、字符串格式化、异常设计,基本都覆盖到了。