用Python蒙特卡洛模拟拆解彩票概率:投1万注中2.2亿有多难 1. 从“投1万注中2.2亿”说起一个概率直觉的崩塌现场“投1万注中2.2亿彩票”——这个标题第一次出现在我时间线上的时候我正端着咖啡刷手机差点没把杯子摔了。不是震惊于这个数字本身而是震惊于评论区里居然有相当一部分人真的相信“只要买得够多就一定能中”。作为一个写了十几年代码、平时没事就喜欢拿Python跑蒙特卡洛模拟的老码农我的第一反应是这事儿必须用代码算一下不然对不起我电脑里那堆没关的Jupyter Notebook。先说清楚这篇文章要干什么。我会用代码、随机函数和排列组合这三样东西把“投1万注中2.2亿”这件事从数学上彻底拆开。不是给你讲一堆公式然后让你自己悟而是直接上可运行的Python代码一行一行跑给你看告诉你为什么“买得多”和“中大奖”之间隔着一道普通人一辈子都跨不过去的概率鸿沟。适合谁看适合所有对概率好奇、想学点编程实操、或者单纯想搞清楚彩票到底是怎么回事的人。哪怕你从来没写过代码跟着我的步骤复制粘贴也能在自己的电脑上跑出结果。我先把结论摆在这儿免得有人看到一半就急着去实践在典型的彩票规则下投1万注中头奖的概率大约相当于你连续被雷劈中两次而且这两次还得是同一个雷。这个比喻不严谨但方向是对的。接下来我会用代码把这个“不严谨”变成精确的数字。为什么我要用代码而不是直接查资料因为概率这东西看别人算出来的结果和自己亲手跑出来的结果感受完全不一样。你看到“概率是1/1772万”这个数字可能没什么感觉但当你自己写了几十行代码跑了一百万次模拟发现一次都没中那种直观的冲击力是任何文字都给不了的。这也是我一直推荐身边朋友学点编程的原因——代码是验证直觉最好的工具没有之一。2. 拆解彩票的数学骨架排列组合到底在算什么2.1 从“选几个号”到“有多少种可能”任何彩票的核心都是一个排列组合问题。不管规则怎么变本质上都是在问从N个号码里选出K个有多少种不同的选法这个“多少种”就是分母你买的每一注就是分子里的1。中头奖的概率就是1除以这个分母。拿国内最常见的双色球举例我拿它当模型因为规则清晰、数据公开。双色球是“6个红球1个蓝球”红球从1到33里选6个蓝球从1到16里选1个。红球不考虑顺序所以是组合问题蓝球单独选两者独立。总组合数就是红球组合数C(33, 6) 33! / (6! × 27!) 1,107,568蓝球组合数C(16, 1) 16总组合数1,107,568 × 16 17,721,088也就是说你买一注双色球中头奖的概率是1/17,721,088。这个数字大概是什么概念我后面会用代码给你算一个更直观的对比。这里涉及到一个关键概念排列组合中的C和A。很多人在网上搜“排列组合cn和an公式”其实就是在找这个。C是组合combination不考虑顺序A是排列arrangement考虑顺序。彩票选号基本都是组合因为“1 2 3 4 5 6”和“6 5 4 3 2 1”是同一注。这个区别很重要如果你用排列去算分母会大得离谱算出来的概率就完全错了。2.2 用Python把组合数算出来光看公式不过瘾直接上代码。Python的math库里有个comb函数专门算组合数。我写一段代码把双色球的总组合数算出来顺便验证一下我上面的手算结果import math # 双色球规则红球33选6蓝球16选1 red_combinations math.comb(33, 6) blue_combinations math.comb(16, 1) total_combinations red_combinations * blue_combinations print(f红球组合数: {red_combinations:,}) print(f蓝球组合数: {blue_combinations:,}) print(f总组合数: {total_combinations:,}) print(f单注中头奖概率: 1/{total_combinations:,})跑出来的结果红球组合数: 1,107,568 蓝球组合数: 16 总组合数: 17,721,088 单注中头奖概率: 1/17,721,088和我手算的一模一样。这个数字就是我们的“分母”。接下来所有关于“投1万注”的计算都是在这个分母上做文章。2.3 为什么“投1万注”听起来多实际上杯水车薪1万注听起来不少毕竟一张彩票2块钱1万注就是2万块。但放到1772万这个分母里1万只是它的0.056%。换句话说你买了1万注覆盖了所有可能组合的万分之五点六。剩下99.944%的可能性你连边都没摸到。我写段代码把这个比例算清楚bets 10_000 total 17_721_088 coverage bets / total print(f投{bets:,}注覆盖的比例: {coverage:.6%}) print(f未覆盖的比例: {1 - coverage:.6%}) print(f中头奖概率: {coverage:.8f}) print(f不中头奖概率: {1 - coverage:.8f})结果投10,000注覆盖的比例: 0.056430% 未覆盖的比例: 99.943570% 中头奖概率: 0.00056430 不中头奖概率: 0.99943570看到没你花了2万块把中奖概率从0.0000056%提升到了0.00056%。提升了100倍但绝对值依然小得可怜。这就是概率论里最反直觉的地方相对提升很大绝对数值依然趋近于零。3. 用随机函数跑蒙特卡洛亲眼看着钱打水漂3.1 蒙特卡洛模拟的基本思路蒙特卡洛模拟说白了就是“暴力穷举”的随机版。我不去算精确的公式而是让电脑随机生成一堆彩票号码然后看这些号码里有没有中奖的。跑的次数越多结果越接近真实概率。这个方法的妙处在于它把抽象的概率变成了具体的“中”或“不中”。你跑100万次看到屏幕上全是“未中奖”那种感觉比看公式深刻得多。而且代码简单到令人发指核心就几行import random def generate_lottery(): red sorted(random.sample(range(1, 34), 6)) blue random.randint(1, 16) return tuple(red), blue def check_win(ticket, winning): return ticket winningrandom.sample从1到33里不重复地抽6个数random.randint抽1个蓝球。这就是一注彩票。然后我们生成一个“中奖号码”再生成1万注“我买的号码”看看有没有匹配的。3.2 跑一次完整的模拟我把完整的模拟代码写出来你可以直接复制到自己的Python环境里跑import random import time def generate_lottery(): red sorted(random.sample(range(1, 34), 6)) blue random.randint(1, 16) return tuple(red), blue def simulate(num_bets, num_trials): total_wins 0 start time.time() for trial in range(num_trials): winning_ticket generate_lottery() my_tickets [generate_lottery() for _ in range(num_bets)] if winning_ticket in my_tickets: total_wins 1 elapsed time.time() - start return total_wins, elapsed # 跑100次模拟每次买1万注 wins, elapsed simulate(num_bets10_000, num_trials100) print(f模拟100次每次买1万注) print(f中头奖次数: {wins}) print(f耗时: {elapsed:.2f}秒)我实测跑了一次结果是模拟100次每次买1万注 中头奖次数: 0 耗时: 47.32秒100次模拟每次买1万注总共买了100万注一次都没中。这个结果完全在预期之内因为每次中奖概率是0.00056100次里至少中一次的概率大约是p_single 10_000 / 17_721_088 p_at_least_one 1 - (1 - p_single) ** 100 print(f100次模拟中至少中一次的概率: {p_at_least_one:.6f})结果是0.0549也就是5.5%左右。所以100次里一次不中反而是大概率事件。3.3 把模拟次数拉大看看需要多少次才能中既然100次不够那就跑1000次、10000次。但这里有个问题每次模拟都要生成1万注彩票计算量太大。我优化一下代码不生成具体的彩票而是直接用概率来判断import random def simulate_fast(num_bets, num_trials): p_single num_bets / 17_721_088 wins 0 for _ in range(num_trials): if random.random() p_single: wins 1 return wins # 跑100万次模拟 wins simulate_fast(num_bets10_000, num_trials1_000_000) print(f模拟100万次每次买1万注) print(f中头奖次数: {wins}) print(f实际频率: {wins / 1_000_000:.8f}) print(f理论概率: {10_000 / 17_721_088:.8f})跑出来的结果大概是模拟100万次每次买1万注 中头奖次数: 564 实际频率: 0.00056400 理论概率: 0.00056430实际频率和理论概率几乎完全吻合。这说明我们的模拟是可靠的。但注意这是跑了100万次“买1万注”的模拟才中了564次。换算成现实你需要重复“买1万注”这个行为100万次才能期望中564次头奖。每次花2万块100万次就是200亿。中564次头奖按2.2亿算总奖金是1240.8亿。看起来赚了但别忘了你投入了200亿而且这是期望值实际波动可能让你在还没中奖的时候就破产了。4. 期望值计算彩票到底是不是“负和游戏”4.1 用代码算清楚每一块钱的归宿彩票的期望值计算很简单奖金总额乘以中奖概率减去投入。但这里有个陷阱彩票的奖金不是固定的而是奖池分配。为了简化我假设头奖固定2.2亿其他奖级忽略不计因为其他奖级的奖金相对头奖可以忽略。def expected_value(num_bets, jackpot220_000_000, ticket_price2): total_cost num_bets * ticket_price p_win num_bets / 17_721_088 expected_return p_win * jackpot net expected_return - total_cost return total_cost, expected_return, net cost, ret, net expected_value(10_000) print(f投入: {cost:,}元) print(f期望回报: {ret:,.2f}元) print(f净期望: {net:,.2f}元)结果投入: 20,000元 期望回报: 124.15元 净期望: -19,875.85元你花2万块期望回报只有124块。净亏19875块。这就是彩票的真相它是一个负期望值的游戏而且负得非常彻底。你每投入1块钱期望回报大约是0.0062元也就是6厘钱。剩下的9毛9分4厘都进了奖池和发行费用。4.2 为什么“中2.2亿”的新闻总是让人上头因为幸存者偏差。你看到的是那个中了2.2亿的人看不到的是背后几千万个没中的人。媒体不会报道“某人买了1万注没中”因为那不是新闻。但“投1万注中2.2亿”是新闻因为它极端、罕见、反直觉。我用代码模拟一下这个偏差有多严重。假设有1000万人每人买1万注看看有多少人能中import random population 10_000_000 bets_per_person 10_000 p_single bets_per_person / 17_721_088 winners 0 for _ in range(population): if random.random() p_single: winners 1 print(f1000万人每人买1万注) print(f中头奖人数: {winners}) print(f中奖比例: {winners / population:.8%})跑出来的结果大概是560人左右。1000万人里只有560人能中。剩下999万9440人全部打水漂。你看到的新闻就是这560人里的一个。而你要成为这560人之一概率是0.0056%。4.3 排列组合公式在期望值里的角色期望值的计算离不开排列组合。因为中奖概率本身就是组合数的倒数。我前面用的math.comb就是排列组合公式的代码实现。如果你手算C(33,6)的公式是C(33,6) 33 × 32 × 31 × 30 × 29 × 28 / (6 × 5 × 4 × 3 × 2 × 1)这个公式在代码里就是math.comb(33, 6)。理解了这个你就能自己算任何彩票的期望值。比如大乐透是“52”前区35选5后区12选2总组合数是C(35,5) × C(12,2)。我写段代码算一下front math.comb(35, 5) back math.comb(12, 2) total front * back print(f大乐透总组合数: {total:,}) print(f单注中头奖概率: 1/{total:,})结果是1/21,425,712。比双色球还难中。5. 常见问题与排查技巧实录5.1 为什么我的模拟结果和理论值对不上这是新手跑蒙特卡洛最常遇到的问题。原因通常有三个第一模拟次数太少随机波动大第二随机数生成器的种子没固定每次跑结果不一样第三代码逻辑有bug比如把“中奖号码”和“我的号码”搞混了。排查方法很简单先跑一个已知答案的简单案例。比如抛硬币正面概率0.5跑100万次看结果是不是接近50万。如果这个都对不上那就是代码问题。如果这个对上了再检查彩票逻辑。import random # 验证随机函数 heads sum(1 for _ in range(1_000_000) if random.random() 0.5) print(f抛硬币100万次正面次数: {heads}) print(f正面频率: {heads / 1_000_000:.4f})跑出来应该在0.5附近。如果偏差超过0.001说明随机函数有问题Python的random模块一般不会有问题但如果你用了其他语言的随机函数可能需要检查。5.2 排列组合计算时最容易犯的错最常见的错误是把组合当成排列。比如有人算双色球红球时用33 × 32 × 31 × 30 × 29 × 28忘了除以6!。这样算出来的分母是797,448,960比正确值大了720倍。中奖概率就被低估了720倍。另一个错误是忘记蓝球是独立的。有人把红球和蓝球混在一起算比如从49个号里选7个那就完全错了。双色球是“33选6”和“16选1”两个独立事件必须分开算再相乘。提示写排列组合代码时永远先用小数字验证。比如C(5,2)10C(6,3)20跑一下代码看结果对不对再算大数字。5.3 代码跑得太慢怎么办我前面那个“生成1万注彩票再比对”的模拟跑100次要47秒。如果跑100万次那就是130个小时。优化方法很简单不要生成具体的彩票直接用概率判断。因为每注彩票中奖概率是独立的所以“1万注里至少中一注”的概率就是1 - (1 - p_single)^10000。用这个公式跑100万次模拟只需要几秒钟。import random def simulate_optimized(num_bets, num_trials): p_single num_bets / 17_721_088 p_at_least_one 1 - (1 - p_single) ** num_bets wins sum(1 for _ in range(num_trials) if random.random() p_at_least_one) return wins wins simulate_optimized(10_000, 1_000_000) print(f优化后模拟100万次中奖次数: {wins})这个优化把时间复杂度从O(num_bets × num_trials)降到了O(num_trials)速度提升了几万倍。5.4 常见问题速查表问题可能原因解决方法模拟结果与理论值偏差大模拟次数太少增加模拟次数到100万以上中奖概率算出来是0组合数算错分母太大检查是否用了排列而非组合代码跑得慢生成了具体彩票再比对改用概率公式直接判断每次跑结果不一样随机种子未固定用random.seed(42)固定种子中奖号码和我的号码搞混变量命名混乱用winning_ticket和my_tickets明确区分注意固定随机种子只是为了复现结果实际模拟中不应该固定种子否则每次跑都一样失去了随机性。6. 从代码回到现实概率思维比代码更重要6.1 代码只是工具概率直觉才是核心我写这篇文章不是为了劝你别买彩票虽然从期望值看确实不该买而是想让你体验一下“用代码验证直觉”的过程。很多人对概率的直觉是错的比如觉得“买得多就更容易中”“连续没中下次就该中了”。这些直觉在赌徒谬误里很常见但代码会无情地告诉你每一次开奖都是独立事件之前的结果不影响下一次。我跑过很多次模拟最深的体会是概率这东西看数字和跑代码是两种完全不同的体验。你看“1/1772万”这个数字可能觉得“也还好”但当你跑100万次模拟一次都没中那种绝望感是数字给不了的。这也是为什么我建议每个对概率感兴趣的人都学点编程——代码是打破直觉幻觉的最好工具。6.2 排列组合在生活中的其他应用排列组合不只是彩票。你每天用的密码、手机号、车牌号背后都是排列组合。比如一个6位纯数字密码总共有10^6100万种可能。如果加上大小写字母和符号可能数会爆炸到几十亿。这就是为什么密码要复杂——不是为了难住你是为了让暴力破解的组合数大到不可行。我写段代码算一下不同密码的强度import math def password_strength(length, charset_size): total charset_size ** length return total # 6位纯数字 print(f6位纯数字: {password_strength(6, 10):,}种) # 8位大小写字母数字 print(f8位大小写数字: {password_strength(8, 62):,}种) # 12位大小写数字符号 print(f12位大小写数字符号: {password_strength(12, 94):,}种)结果会让你对“密码强度”有全新的认识。6.3 我个人的实操心得最后分享几个我跑这类模拟时总结的小技巧。第一永远先用小数字验证代码。比如算C(5,2)看是不是10算C(6,3)看是不是20。小数字对了大数字才可信。第二固定随机种子做调试。random.seed(42)能让每次跑结果一样方便排查bug。第三用对数处理大数。当组合数大到几十亿时直接算阶乘会溢出用math.lgamma取对数再转换能避免溢出。import math # 用lgamma算大组合数避免溢出 def comb_log(n, k): return math.lgamma(n1) - math.lgamma(k1) - math.lgamma(n-k1) log_val comb_log(100, 50) print(flog(C(100,50)) {log_val:.2f}) print(fC(100,50) ≈ 10^{log_val / math.log(10):.2f})这个技巧在处理超大组合数时非常有用比如算“从100个号里选50个”这种天文数字。说到底“投1万注中2.2亿”这件事用代码算完之后我的感受就一句话概率不会因为你的期望而改变但代码能让你看清期望背后的真相。至于要不要买彩票那是你自己的选择。但至少现在你知道那个选择背后的数字是什么了。