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) 1C 语言则可以用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(total31, count7): 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(total31, count7): 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, textvariablevar, font(Consolas, 24)).pack(padx30, pady30) tk.Button(root, text摇号, commandon_click, font(Arial, 14)).pack(pady10) root.mainloop()这版已经能用了但体验还是有点“干”。我想让它更接近真实摇号机的效果于是加了一段滚动动画连续刷新 label 上的数字最后定格在最终结果。def animate(times10): 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(total31, count7): 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(total31, count7): 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连续刷新 labelWeb 里可以用 CSS 动画做号码滚动效果Unity 里甚至可以做成 3D 球体物理碰撞。但无论渲染层换成什么核心随机逻辑都可以复用洗牌取前法。所以前期把核心算法写对、写干净后期换个壳就能上线这才是这个项目最有迁移价值的部分。6. 这个项目的复盘与我的实操心得最后聊几句个人经验也算给整篇收个尾。这类“模拟摇号小游戏”看起来很简单但把它真正做严谨需要处理随机、去重、边界、公平性、交互体验一大堆细节是非常好的编程练手项目。6.1 开发顺序直接影响心态我强烈建议按这个顺序来规则定义 → 控制台核心逻辑 → 参数校验 → 自动化测试 → 图形界面 → 扩展功能。每完成一步程序都能运行都有反馈不会到了最后才“一把梭”然后面对一个完全没法调试的黑盒子。先跑通控制台版再套图形界面这个思路适合几乎所有小工具类项目。6.2 调试时多用种子发布时慎用固定种子固定随机种子是调试利器但如果你忘了把它关掉就发布了用户看到的每次摇号结果都一样那就彻底翻车了。我习惯把种子参数设计成一个可选参数测试时传固定值正常运行时完全不传。6.3 模拟类项目一定要想清楚“边界”这个项目只是模拟器用来学习随机抽样、程序设计和交互逻辑不应该和任何真实投注、资金、奖品挂钩。我在给朋友演示时也会强调这句话。理解了这一点你才可以把全部注意力放在技术上而不是给一个娱乐工具背上不该有的包袱。如果你也准备动手写一个自己的摇号小游戏我建议就从那个最简控制台版开始慢慢改成图形界面再加特别号、历史记录、概率统计。这个过程比你直接下载别人的成品代码要收获大得多。
返回列表