
简介这是一份基于C#实现的随机抽号工具源码面向需要抽奖、选课、随机分组等场景的开发者与编程学习者核心解决号段自定义、抽号不重复以及特殊号码顺序控制的问题。压缩包共31个文件约614KB以9个cs源文件为主体配合sln与csproj工程文件、resx与resources资源文件、exe可执行程序及pdb调试文件另含少量cache、suo等IDE缓存结构完整可直接运行。已有2287人学习下载。源码涵盖输入处理、随机数生成、抽号算法、结果存储与导出等模块并针对“3:4|6:7”这类规则实现了固定特殊号相对顺序的逻辑读者可借此理解HashSet去重、优先队列排序及CSV导出等实现思路适合作为C#算法练习与二次开发参考。1. 随机抽号不重复从年会抽奖到课堂点名的刚需年会现场大屏滚动着几百个工号主持人喊停的瞬间最怕的不是抽到谁而是抽到一个已经中过奖的人。台下起哄台上尴尬这种翻车场面我见过不止一次。随机抽号不重复这件事听起来简单真做起来坑不少有人用 Excel 的 RAND 函数结果每次刷新都变有人写个随机数循环抽到重复的就重抽人一多就卡死还有人把已抽中的号码从数组里删掉结果索引越界直接崩。这个标题背后其实是一类很明确的需求在一个有限号码池里每次抽取都不与历史结果冲突直到池子抽空或主动重置。它适合的场景包括年会抽奖、课堂随机点名、考试座位分配、任务轮值排班。核心矛盾只有一个——随机性和不重复性怎么同时保证。下面我按自己实际做过的方案从数据结构选型讲到代码落地再到踩过的坑一步步拆开。2. 不重复随机的三种实现路线洗牌、标记、池化2.1 先想清楚你要的是“抽”还是“排”很多人一上来就写随机函数但没分清两种语义。第一种是抽签式每次从剩余号码里随机取一个取完不放回直到取完。第二种是排期式一次性把整个号码池打乱然后按顺序取取到第几个就是第几个。两者结果分布是一样的但实现复杂度和适用场景不同。抽签式适合“抽一个展示一个”的交互比如大屏滚动后定格排期式适合“提前生成完整顺序”的场景比如考场座位表、值班表。我一般会先问一句你是要每次点一下抽一个还是一键生成全部顺序这个问题的答案直接决定后面用哪种结构。从算法角度看抽签式如果每次都用随机数生成器在剩余池里取时间复杂度是 O(1) 每次用数组尾部交换法排期式就是一次 Fisher-Yates 洗牌O(n)。两者都不难难的是边界处理和状态保存。2.2 方案一Fisher-Yates 洗牌一次打乱顺序取这是最经典的方案也是我最推荐新手先跑通的。核心思想是从数组末尾往前遍历每次把当前位置的元素与前面随机一个位置交换。这样每个排列出现的概率相等不会出现某些号码更容易被抽中的偏差。import random def shuffle_pool(total): 生成 1 到 total 的号码池并打乱顺序 total: 号码总数比如 100 表示 1~100 返回打乱后的列表 pool list(range(1, total 1)) # Fisher-Yates 洗牌从后往前 for i in range(len(pool) - 1, 0, -1): j random.randint(0, i) # 随机选一个 0~i 的位置 pool[i], pool[j] pool[j], pool[i] # 交换 return pool # 使用示例 pool shuffle_pool(100) print(pool[:10]) # 前 10 个就是抽取顺序这段代码的关键在random.randint(0, i)而不是random.randint(0, len(pool)-1)。如果写成后者每个位置被交换的概率不均匀会导致某些号码更靠前。参数total就是号码池大小实际用时可以改成传入一个已有列表比如工号列表、学号列表。洗牌方案的好处是一次性生成后续按索引取就行不需要每次判断是否重复。缺点是如果号码池很大比如百万级内存占用会明显但一般抽奖场景几千到几万完全够用。2.3 方案二标记数组 随机重试小池子最快如果号码池很小比如 50 人以内而且你不想一次性打乱可以用标记数组。思路是维护一个布尔数组抽到某个号码就标记为已用下次随机到已用的就重抽。import random class DrawWithoutRepeat: def __init__(self, total): self.total total self.used [False] * (total 1) # 下标 1~total 对应号码 self.remaining total # 剩余可抽数量 def draw(self): if self.remaining 0: return None # 抽完了 while True: num random.randint(1, self.total) if not self.used[num]: self.used[num] True self.remaining - 1 return num # 使用 d DrawWithoutRepeat(50) for _ in range(5): print(d.draw())这个方案在剩余数量多的时候很快但越到后面越慢。极端情况只剩最后一个号码时每次随机都有 1/50 的概率命中期望要抽 50 次才中。所以它只适合小池子或者你愿意接受后期变慢。参数total是号码上限used数组多开一位是为了让下标和号码对齐避免减一带来的混乱。我一般会在remaining小于总数的 20% 时切换到洗牌方案或者直接用方案三。2.4 方案三动态池化用数组尾部交换实现 O(1) 抽取这是抽签式最优雅的实现维护一个剩余号码数组每次随机选一个下标把该位置元素与数组末尾交换然后弹出末尾。这样每次抽取都是 O(1)且不需要重试。import random class PoolDraw: def __init__(self, total): self.pool list(range(1, total 1)) # 剩余号码池 def draw(self): if not self.pool: return None idx random.randint(0, len(self.pool) - 1) # 随机下标 # 与末尾交换 self.pool[idx], self.pool[-1] self.pool[-1], self.pool[idx] return self.pool.pop() # 弹出末尾即被选中的号码 # 使用 p PoolDraw(100) result [p.draw() for _ in range(10)] print(result)关键在self.pool[idx], self.pool[-1] self.pool[-1], self.pool[idx]这一行。它把选中的号码换到末尾然后pop()出去剩下的池子仍然连续下次随机下标不会越界。参数total同样是号码上限如果号码不是连续的可以把range换成实际列表。这个方案兼顾了随机性和不重复性每次抽取概率均等且没有重试开销。我做过一个 5000 人的抽奖用这个方案跑 5000 次抽取总耗时不到 10 毫秒。2.5 三种方案怎么选一张表说清方案时间复杂度空间复杂度适合场景注意点Fisher-Yates 洗牌O(n) 一次O(n)需要完整顺序、排期表一次性生成后续按索引取标记数组 重试平均 O(1)后期变慢O(n)小池子、交互式抽一个剩余少时性能骤降动态池化O(1) 每次O(n)大池子、连续抽取池子为空时要处理返回值选型原则很简单要完整顺序用洗牌要交互式抽一个且池子小用标记要交互式抽一个且池子大用池化。实际项目中我多数时候直接上池化代码短性能稳不容易出玄学问题。3. 把抽号逻辑接进真实场景状态保存与重置3.1 抽奖现场最怕断电持久化已抽结果年会抽奖最怕什么抽到一半电脑休眠了或者浏览器刷新了之前抽过的号码全没了重新抽又可能重复。所以真实场景里抽号状态必须持久化。最简单的做法是每次抽取后把已抽号码写入 localStorage 或后端接口。// 前端持久化示例用 localStorage 保存已抽号码 class PersistentDraw { constructor(total, storageKey) { this.total total; this.storageKey storageKey; // 尝试从本地存储恢复 const saved localStorage.getItem(storageKey); this.drawn saved ? JSON.parse(saved) : []; // 构建剩余池 this.pool []; for (let i 1; i total; i) { if (!this.drawn.includes(i)) this.pool.push(i); } } draw() { if (this.pool.length 0) return null; const idx Math.floor(Math.random() * this.pool.length); // 交换到末尾并弹出 [this.pool[idx], this.pool[this.pool.length - 1]] [this.pool[this.pool.length - 1], this.pool[idx]]; const num this.pool.pop(); this.drawn.push(num); // 写回本地存储 localStorage.setItem(this.storageKey, JSON.stringify(this.drawn)); return num; } reset() { this.drawn []; this.pool Array.from({ length: this.total }, (_, i) i 1); localStorage.removeItem(this.storageKey); } }这段代码在池化方案基础上加了localStorage读写。storageKey是每个抽奖活动独立的键名比如annual-2025避免不同活动互相干扰。reset()方法用于重置清空已抽记录并重建池子。注意this.drawn.includes(i)在池子大时是 O(n)初始化会慢可以改用 Set 优化但一般几千条无所谓。后端持久化思路一样只是把localStorage换成数据库表每次抽取后插入一条记录重置时删除记录。关键是要保证“抽取”和“写入”在同一个事务里否则并发下可能重复。3.2 多人同时抽怎么不撞号加锁与队列如果是一个链接发出去多个人同时点抽奖那就不能只靠前端了。前端各自维护池子必然撞号。常见做法是后端加锁每次抽取请求进来先锁住号码池抽完写库再释放。import threading import random class SafeDraw: def __init__(self, total): self.pool list(range(1, total 1)) self.lock threading.Lock() def draw(self): with self.lock: # 加锁保证同一时刻只有一个线程抽取 if not self.pool: return None idx random.randint(0, len(self.pool) - 1) self.pool[idx], self.pool[-1] self.pool[-1], self.pool[idx] return self.pool.pop()threading.Lock()是 Python 里的互斥锁with语句保证进入块时获取锁退出时释放。如果是多进程部署线程锁不够要用 Redis 的分布式锁或者数据库行锁。参数上锁的粒度要尽量小只包住池子操作不要把网络 IO 放进去否则并发吞吐会掉得厉害。我一般会在锁内只做池子变更把写数据库放到锁外但这样又可能丢数据。折中方案是锁内同时写库或者用 Redis 的LPOP原子操作直接维护池子省去应用层锁。3.3 抽完自动重置还是手动重置业务规则先定号码池抽空后怎么办两种策略自动重置重新开始一轮和手动重置主持人点按钮。自动重置适合课堂点名抽完一轮自动进入下一轮手动重置适合年会抽奖抽完大奖后主持人决定是否继续。实现上自动重置就是在draw()返回None时调用reset()再抽一次。手动重置就是暴露一个按钮前端调reset()接口。注意自动重置要有提示否则用户以为抽到重复了。我见过一个翻车案例自动重置没提示台下看到同一个号码又出现以为有黑幕。4. 随机抽号不重复的避坑清单五个血泪教训4.1 坑一用 Math.random() 做洗牌分布不均匀现象抽奖结果总是某几个号码靠前或者某些号码几乎没出现过。原因很多人写洗牌时用arr.sort(() Math.random() - 0.5)这个写法在 V8 引擎下分布严重偏斜不是真正的随机。解决老老实实写 Fisher-Yates 循环从后往前交换。如果非要用 sort至少用crypto.getRandomValues()生成随机数但依然不推荐。4.2 坑二抽到重复就重抽池子快空时卡死现象抽到后面越来越慢最后几个号码要等好几秒。原因标记数组方案在剩余数量少时随机命中概率极低期望重试次数急剧上升。解决剩余数量低于总数 20% 时切换到池化方案或者一开始就用池化。如果已经上线了加个判断当remaining total * 0.2时把剩余号码收集起来洗牌。4.3 坑三前端刷新后状态丢失重复抽中现象主持人刷新页面之前抽过的号码又出现在池子里。原因状态只存在内存变量里没有持久化。解决每次抽取后写入 localStorage 或后端初始化时从存储恢复。注意存储的键名要区分活动避免串场。4.4 坑四多标签页同时抽各自为政现象同一个浏览器开了两个标签页各自抽各自的出现重复。原因localStorage 虽然共享但两个页面各自维护内存池子写入时互相覆盖。解决监听storage事件当一个标签页更新已抽列表时另一个标签页同步刷新池子。或者干脆限制单标签页操作抽奖时禁用其他入口。4.5 坑五号码池不是连续整数下标越界现象用range(1, total1)生成池子但实际号码是工号如 “A001” 到 “A100”抽出来全是数字。原因池化方案默认号码是连续整数没有考虑自定义列表。解决把range换成实际号码列表比如pool [A%03d % i for i in range(1, 101)]。所有基于下标的操作都不变因为池化只关心列表长度和元素交换。5. 进阶让抽号更公平、更可验证5.1 用加密级随机数替代伪随机random模块和Math.random()都是伪随机种子可预测。如果抽奖涉及较大利益比如选房顺序、车牌摇号建议用secrets模块Python或crypto.getRandomValues()JavaScript。它们基于操作系统熵源不可预测。import secrets def secure_draw(pool): 从池中安全随机抽取一个返回 (号码, 新池) if not pool: return None, pool idx secrets.randbelow(len(pool)) # 加密级随机下标 pool[idx], pool[-1] pool[-1], pool[idx] return pool.pop(), poolsecrets.randbelow(n)返回 0 到 n-1 的随机整数分布均匀且不可预测。参数pool是剩余号码列表返回新池是为了保持函数无副作用方便测试。5.2 抽号过程可复现种子与日志有时候需要事后验证抽奖公平性比如有人质疑结果。做法是记录随机种子和每次抽取的序号用同样的种子重放一遍结果应该完全一致。Python 的random.seed()可以做到但注意secrets不支持种子所以公平性验证场景要用random并公开种子。import random def replay_draw(total, seed, steps): 用指定种子重放抽取过程返回前 steps 次结果 random.seed(seed) pool list(range(1, total 1)) results [] for _ in range(steps): if not pool: break idx random.randint(0, len(pool) - 1) pool[idx], pool[-1] pool[-1], pool[idx] results.append(pool.pop()) return results # 验证同样种子两次结果一致 print(replay_draw(100, 42, 5)) print(replay_draw(100, 42, 5)) # 输出相同seed是公开的随机种子steps是抽取次数。只要种子和次数一致结果就一致。实际使用时把种子打印在大屏上或者记录在日志里事后任何人可以复现验证。5.3 一个我常用的抽号类兼顾性能与可验证把前面几点揉在一起我通常会在项目里放一个这样的类池化抽取、支持持久化、可选加密随机、记录日志。代码不长但覆盖了大多数场景。import random import json import secrets class DrawMachine: def __init__(self, numbers, use_secureFalse, log_pathNone): numbers: 号码列表如 list(range(1, 101)) use_secure: 是否用加密级随机 log_path: 抽取日志文件路径None 则不记录 self.pool list(numbers) self.use_secure use_secure self.log_path log_path self.history [] def _rand_idx(self, n): if self.use_secure: return secrets.randbelow(n) return random.randint(0, n - 1) def draw(self): if not self.pool: return None idx self._rand_idx(len(self.pool)) self.pool[idx], self.pool[-1] self.pool[-1], self.pool[idx] num self.pool.pop() self.history.append(num) if self.log_path: with open(self.log_path, a, encodingutf-8) as f: f.write(json.dumps({draw: num, remaining: len(self.pool)}) \n) return num def reset(self): self.pool list(self.history) self.pool # 简化重置实际按需 self.history []这个类里numbers可以是任意列表不限于连续整数。use_secure控制随机源log_path记录每次抽取和剩余数量方便排查。reset()这里写得简单实际项目里我会从原始号码列表重建而不是从 history 拼。5.4 最后一句实在话抽号不重复这件事技术难度不高但细节决定现场效果。我自己的习惯是任何抽奖代码先在本地跑 10000 次抽取检查有没有重复、有没有越界、耗时是否稳定。上线前一定做一次“断电测试”——抽到一半强制刷新看状态能不能恢复。这些做完基本就不会翻车了。希望帮到你。本文还有配套的精品资源点击获取