拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写31选7摇号程序:随机抽样与洗牌算法实践

手写31选7摇号程序:随机抽样与洗牌算法实践

你有没有想过自己动手写一个摇号程序?就是那种在电视节目里经常看到的“大球滚动,一个个号码掉出来”的效果,自己也能在电脑上模拟一遍。今天我就把这个小项目完整拆给你看——一个 31 选 7 的模拟彩票摇号小游戏。剥掉“彩票”这层外衣,它本质上是一个极其典型的随机抽样程序:从 1 到 31 的数字池里,不放回地抽出 7 个号码,再按升序展示结果。这个项目非常适合拿来练手,因为它麻雀虽小五脏俱全,涉及随机数生成、去重算法、边界校验、交互界面这些基础但关键的问题,而这些知识换个场景就能直接用到活动抽奖、随机分组、排班轮值等真实需求上。不管你是刚接触编程的新手,还是想快速交一个小工具的老手,都可以照着下面的思路自己实现一遍,整个过程不会超过一个下午。

1. 为什么选“31选7”这个玩法

我在设计这个小游戏时,第一件事不是打开编辑器,而是先把规则在纸上写清楚。很多人一上来就写代码,写到一半才发现规则没定明白,后面全是补丁,这是最容易踩的坑。

1.1 规则拆解:从数字池里做一次不放回抽样

“31选7”的规则其实只有两条:

  • 号码范围是 1 到 31,一共 31 个候选号码。
  • 开奖结果是“不放回”地抽 7 个号码,不重复,顺序不分先后,最终按从小到大排列展示。

这里要注意一个容易混淆的点:物理摇奖机摇出号码时有一个“先后顺序”,但核对号码时只看最终集合,不看摇出顺序。所以你在做模拟程序时,需要先决定是“保留摇出顺序”还是“最终升序展示”。我选择的是最终升序,因为人眼核对号码时更习惯看从小到大排列的格式,这跟你在彩票店看到的开奖结果单排版逻辑一致。

用一个生活化的类比:这就像把 31 张扑克牌洗乱,每张牌写一个号码,然后翻开最上面的 7 张。洗牌的动作天然保证了号码不会重复,而且每张牌被翻到的概率理论上完全均等。“不放回”是核心,一旦某个号码被抽中,它就不该再出现在结果里。

1.2 动手前的功能取舍:别让第一版变得复杂

我在动手前列了一张“要不要做”的清单,这个习惯帮我省了不少时间:

  • 要不要图形界面?第一版不要,先用控制台把核心逻辑跑通,否则调试会很痛苦。
  • 要不要滚动动画?需要,但第一版可以不做,先验证结果正确,动画只是锦上添花。
  • 要不要加“特别号”?可以加,但放在扩展版本里,避免第一版逻辑复杂化。
  • 要不要保存历史记录?可以加,但第一版不做,存档功能不影响“摇号”本身。

这个取舍背后是一个很重要的工程习惯:先做最小可用版本,再逐步加功能。如果一开始就想做得跟正式开奖系统一样,很容易陷入动画、音效、数据库的泥潭,最后连最核心的随机逻辑都没写对。我见过太多人第一版就想搞“大而全”,结果两周过去了项目还是半成品。

功能项第一版后续扩展
控制台输出要做保留
图形界面不做增加
滚动动画不做增加
特别号不做增加
历史记录/统计不做增加
随机源可复现内部使用增加开关

2. 核心技术点:随机、去重、排序与公平性

这个小游戏的全部技术含量都集中在“如何从 31 个号码里均匀随机地抽出 7 个不重复的号码”这句话上。拆开来看,就是四个词:随机、去重、排序、公平。

2.1 随机数从哪里来:伪随机不是洪水猛兽

几乎所有编程语言自带的随机数函数都是“伪随机”的。它不是真正的无法预测,而是由一个种子值和一套算法生成的一段“看起来随机”的数列。对于小游戏来说,伪随机完全够用;但如果你要模拟一次严肃的抽奖活动,那就得考虑密码学安全随机数了,比如 Python 的secrets模块。这个区别后面我会专门讲。

我见过很多新手写出类似rand() % 31 + 1的代码,这是一个经典陷阱。原因是取模运算会让概率分布不一定均匀——当随机数上限不是 31 的整数倍时,某些数字出现的概率会比其他数字略高一点。虽然在小样本里看不出来,但它会在统计测试中露馅。更稳妥的做法是使用语言自带的随机区间函数:Python 用random.randint(1, 31),JavaScript 用Math.floor(Math.random() * 31) + 1,C 语言则可以用rand() / (RAND_MAX + 1.0) * 31 + 1。

2.2 不放回抽样的两种主流写法:集合去重 vs 洗牌取前

“不放回抽样”最直观的写法是集合去重法,用 Python 写出来是这样的:

import random result = set() while len(result) < 7: result.add(random.randint(1, 31))

这段代码逻辑很简单:不断生成随机数,放进集合里,因为集合天然不允许重复,直到集合里有 7 个号码为止。它的优点是代码短、好理解;缺点是当“样本池很大、抽取数量接近总数”时,重复概率会急剧上升,极端情况下会做大量无用随机。比如从 1000 个号码里抽 999 个,最后几个号码可能要循环几百次才能凑齐。

所以在这个项目里,我更推荐第二种方案:洗牌取前法。原理就是前面说的扑克牌类比:

import random def draw(total=31, count=7): pool = list(range(1, total + 1)) random.shuffle(pool) return sorted(pool[:count])

先构造一个包含 1 到 31 的数组,然后把这 31 个元素当扑克牌洗乱,最后直接取前 7 个。这个过程无论是时间还是空间都非常稳定,而且不会出现死循环。更重要的是,它在概念上最接近物理摇号机:把号码球放进机器里搅乱,然后让球掉出来。不要小看这一步,很多抽奖程序的 bug 都出在“重复”和“概率不匀”上。

2.3 展示细节:排序和补零隐藏着不小的阅读体验提升

摇出 7 个号码后,我建议做两件简单但很提升体验的事:升序排列和数字补零。把 1 显示成 01,把 7 显示成 07,表面上看只是格式化,实际上大大降低了用户核对号码时的出错率。对应代码是:

display = " ".join(f"{n:02d}" for n in result) print(display)

如果不做补零,显示结果可能是1 7 13 22 25 30 31,用户要对着屏幕反应一下才能确认中间那个 7 到底是 7 还是 07。补零之后变成01 07 13 22 25 30 31,一眼就能看清。这个细节在写控制台程序时很容易被忽略,但真正做出来对比一下你就知道差别了。

3. 完整实现:从控制台到图形界面

我实际开发时是按三个版本走下来的:控制台版、Tkinter 图形版、Web 版。每个版本解决一个不同层次的问题,核心随机逻辑完全一致,但呈现给用户的体验差别很大。

3.1 最简版:十分钟写出控制台摇号程序

先上一个可以直接跑起来的最简版本,它包含了完整的主循环、输入输出和退出逻辑:

import random def draw(total=31, count=7): pool = list(range(1, total + 1)) random.shuffle(pool) return sorted(pool[:count]) def main(): print("31选7 模拟摇号小游戏") while True: input("按回车键摇号...") nums = draw() print("本期号码:", " ".join(f"{n:02d}" for n in nums)) choice = input("继续摇号?(输入 n 退出,其他键继续): ") if choice.lower() == "n": break if __name__ == "__main__": main()

这里有几个值得注意的设计点:

  • draw()函数只负责返回号码列表,不负责打印。这个分离很重要,它让核心逻辑可以被单独测试。
  • random.shuffle(pool)是原地打乱数组,不会生成新数组,内存开销更小。
  • 用sorted对结果升序,保证每次输出格式稳定。
  • 主循环里的input("按回车键摇号...")相当于是控制台版的“摇号按钮”,按一次摇一次,符合直觉。

运行效果大概是这样的:

31选7 模拟摇号小游戏 按回车键摇号... 本期号码: 04 07 11 15 19 25 30 继续摇号?(输入 n 退出,其他键继续):

3.2 进阶版:Tkinter 图形界面五分鐘跑起来

控制台版能跑通之后,我建议立刻做一版带按钮的图形界面。Python 自带的 Tkinter 够用,不需要额外安装第三方库。

先看最基础的版本:

import tkinter as tk import random def draw(): pool = list(range(1, 32)) random.shuffle(pool) return sorted(pool[:7]) def on_click(): nums = draw() var.set(" ".join(f"{n:02d}" for n in nums)) root = tk.Tk() root.title("31选7 模拟摇号") var = tk.StringVar(value="点击按钮开始摇号") tk.Label(root, textvariable=var, font=("Consolas", 24)).pack(padx=30, pady=30) tk.Button(root, text="摇号", command=on_click, font=("Arial", 14)).pack(pady=10) root.mainloop()

这版已经能用了,但体验还是有点“干”。我想让它更接近真实摇号机的效果,于是加了一段滚动动画:连续刷新 label 上的数字,最后定格在最终结果。

def animate(times=10): if times > 0: temp = draw() var.set(" ".join(f"{n:02d}" for n in temp)) root.after(50, animate, times - 1) else: final = draw() var.set(" ".join(f"{n:02d}" for n in final))

注意动画阶段我直接调draw()显示临时号码,这样有个隐患:用户可能把动画过程中的数字误认为最终结果。所以在按钮文字和界面布局上,我特意在动画阶段不显示“本期号码”字样,只有最终停住时才展示正式结果。如果你希望更严谨,也可以在动画阶段从一组预先准备好的“噪音号码”里取数,不让人误读成正式开奖结果。

3.3 Web 版:浏览器里点两下就能玩

后来我想把它发给朋友演示,Python 版还得装环境,不太方便,于是顺手写了个 Web 版。核心逻辑用 JavaScript 实现,代码量比 Python 还短:

function draw() { const pool = Array.from({ length: 31 }, (_, i) => i + 1); for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; } return pool.slice(0, 7).sort((a, b) => a - b); }

这段代码包含三个步骤:第一步用Array.from生成 1 到 31 的数组;第二步用手写 Fisher-Yates 洗牌算法从后往前交换元素;第三步用slice(0, 7)取前 7 个,再用sort排序。

这里有一个高频坑:sort()如果不传比较函数,会按字符串排序,9 会排在 10 后面,显示结果变成01 09 10 11 19 20 21里的顺序错乱。所以一定要传(a, b) => a - b。Web 版的好外是分享零成本,浏览器打开就能玩;缺点是如果未来要做“更严肃”的随机场景,原生Math.random()并不适合作为唯一随机源,不过在小游戏场景下完全没问题。

4. 容易被忽略的坑:边界、测试与公平性

说实话,写这个程序最花时间的不是“能跑”,而是“跑得对”。随机程序有一个天然难题:每次结果都不一样,你怎么知道它是对的?所以测试和边界条件必须单独拿出来讲。

4.1 边界条件比功能逻辑更容易出错

我整理了一张测试用例表,建议你照着跑一遍:

输入参数期望结果说明
draw(31, 7)7 个不重复号码,范围 1~31正常流程
draw(31, 31)返回 1~31 全部号码等价于完整洗牌
draw(31, 0)返回空列表合法但不常见
draw(20, 7)7 个不重复号码,范围 1~20参数化后仍可用
draw(10, 20)程序应给出明确报错提取数量不能大于池子大小

在代码里加上参数校验并不复杂,但能避免很多使用上的困惑:

def draw(total=31, count=7): if count < 0 or count > total: raise ValueError("count 必须在 0 到 total 之间") pool = list(range(1, total + 1)) random.shuffle(pool) return sorted(pool[:count])

4.2 用固定种子实现可复现测试

随机程序调试时最容易遇到的问题是:复现不了。你怀疑某次输出有问题,想再跑一遍看看,结果号码全变了。这时候就要用到随机种子。固定种子后,只要算法不变,每次运行结果一致。

random.seed(42) assert draw(31, 7) == [...] # 换成你手动算好的预期结果

在自动化测试里,这种方式能精准定位“是不是某次改动导致逻辑错误”。但注意:正式运行时不应该固定种子,否则每次摇出来的号码都一样,那就真闹笑话了。

4.3 公平性的验证与“真随机”的边界

要验证摇号是否“公平”,不能只跑一次,得跑很多次做统计。我习惯用 10 万次抽样来验证每个号码出现的频率。理论上来讲,31 个号码里抽 7 个,每个号码被抽中的概率是 7/31,约等于 22.58%。用 Python 统计一下:

from collections import Counter c = Counter() for _ in range(100000): c.update(draw(31, 7)) for num in range(1, 32): print(num, c[num], c[num] / 100000)

如果某个号码的出现频率明显偏离 22.58%,那大概率是随机算法有问题。我当时实际跑出来的结果在 22.4%~22.7% 之间波动,属于正常范围。

这里也要说清楚:普通random模块对学习项目足够,但它是一个伪随机算法。如果你打算把程序用于真实的抽奖活动,我建议至少换成secrets模块,它基于系统级随机源,不可预测性更高。不过这个项目从头到尾定位是“模拟小游戏”,不涉及真实投注、奖池和金钱交易,所以伪随机完全够用。

5. 玩法扩展:从小游戏到“产品级”摇号工具

核心逻辑一旦稳定,扩展功能就变得非常快。这一节我讲三个最实用的扩展方向:特别号、历史记录、动画与部署。

5.1 增加特别号:洗牌法天然支持

很多类似的玩法会加一个“特别号”:先摇出 7 个正选号码,再从剩下的号码中摇出 1 个特别号。用洗牌法实现非常简单:

def draw_with_special(total=31, count=7): pool = list(range(1, total + 1)) random.shuffle(pool) normal = sorted(pool[:count]) special = pool[count] return normal, special

为什么可以直接取pool[count]?因为洗牌之后整个数组已经是随机排列的,第 8 个位置自然就是“从剩余号码中随机选出的一个”。如果使用集合去重法,你得先把 7 个正选号码排除掉,再从剩下的号码里随机选,逻辑就绕了一圈。这就是洗牌法在这个场景下的额外好处。

5.2 增加历史记录与概率统计

运行几次之后,用户往往想看历史记录。这个功能用 CSV 文件几十行就能实现:

import csv with open("records.csv", "a", newline="") as f: writer = csv.writer(f) writer.writerow(draw(31, 7))

保存之后还可以配合matplotlib画个柱状图,直观观察 1000 次摇号后每个号码出现的频率是否接近 22.58%。这其实是一个很有意思的概率统计教学案例,比课本上的抽象概念直观多了。

5.3 动画、音效与跨平台部署

如果想把体验做得更“像摇号机”,Tkinter 里可以用after连续刷新 label,Web 里可以用 CSS 动画做号码滚动效果,Unity 里甚至可以做成 3D 球体物理碰撞。但无论渲染层换成什么,核心随机逻辑都可以复用洗牌取前法。所以前期把核心算法写对、写干净,后期换个壳就能上线,这才是这个项目最有迁移价值的部分。

6. 这个项目的复盘与我的实操心得

最后聊几句个人经验,也算给整篇收个尾。这类“模拟摇号小游戏”看起来很简单,但把它真正做严谨,需要处理随机、去重、边界、公平性、交互体验一大堆细节,是非常好的编程练手项目。

6.1 开发顺序直接影响心态

我强烈建议按这个顺序来:规则定义 → 控制台核心逻辑 → 参数校验 → 自动化测试 → 图形界面 → 扩展功能。每完成一步,程序都能运行,都有反馈,不会到了最后才“一把梭”然后面对一个完全没法调试的黑盒子。先跑通控制台版,再套图形界面,这个思路适合几乎所有小工具类项目。

6.2 调试时多用种子,发布时慎用固定种子

固定随机种子是调试利器,但如果你忘了把它关掉就发布了,用户看到的每次摇号结果都一样,那就彻底翻车了。我习惯把种子参数设计成一个可选参数:测试时传固定值,正常运行时完全不传。

6.3 模拟类项目一定要想清楚“边界”

这个项目只是模拟器,用来学习随机抽样、程序设计和交互逻辑,不应该和任何真实投注、资金、奖品挂钩。我在给朋友演示时也会强调这句话。理解了这一点,你才可以把全部注意力放在技术上,而不是给一个娱乐工具背上不该有的包袱。

如果你也准备动手写一个自己的摇号小游戏,我建议就从那个最简控制台版开始,慢慢改成图形界面,再加特别号、历史记录、概率统计。这个过程比你直接下载别人的成品代码要收获大得多。

返回列表