生日悖论:50人至少两人生日相同的概率与组合计数
生日悖论这个问题,我在面试新人的时候特别喜欢拿来当开胃菜。标题写的是“50个人至少有两个人生日相同的概率”,乍一看像是一道普通的概率题,但它背后藏着的东西远比一个数字多——它牵扯到组合计数、补事件思维、数值稳定性,甚至是“直觉为什么骗人”这件事本身。很多人第一次听到答案时那个表情,我到现在都记得:先是愣住,然后皱眉,最后掏出手机开始算。这篇东西就是把这个过程完整拆开,从最朴素的建模假设一路讲到代码实现和踩坑记录,适合刚接触概率的程序员、准备面试的同学,也适合任何对“反直觉结论”感兴趣的人。核心关键词就三个:生日悖论、补事件、组合计数,我会让它们自然出现在该出现的地方,而不是硬塞。
1. 问题建模与思路拆解
1.1 先把“生日相同”这件事说清楚
动手算之前,得先把题目翻译成机器能理解的语言。50个人,每个人有一个生日,问“至少有两个人生日相同”的概率。这里有几个隐含假设必须先钉死,否则算出来的数字没有意义。
第一个假设是每个人的生日在365天里均匀分布。现实里当然不是这样,出生率有明显的季节性波动,某些月份确实人多一些。但作为一道标准概率题,均匀分布是默认前提,而且这个简化带来的偏差其实不大——后面我会专门聊这件事。
第二个假设是人与人之间的生日相互独立。也就是说,A的生日不影响B的生日。这个假设在现实中基本成立,除非是双胞胎或者刻意安排,正常人群里可以认为独立。
第三个假设是不考虑2月29日。闰年每四年一次,2月29日出生的人占比约1/1461,把它单独拎出来会让模型复杂一大截,而收益微乎其微。标准做法就是按365天算,这也是绝大多数教材和面试题的口径。
把这三条钉死之后,问题就变成了一个干净的数学模型:50个独立同分布的随机变量,每个取值在1到365之间均匀分布,求至少有一对取值相等的概率。
1.2 为什么正着算会把自己算死
新手最容易掉进去的坑,就是试图直接枚举“至少一对相同”的所有情况。你想想这个分类有多恶心:恰好一对相同、恰好两对相同、三对相同、一对三个人相同、两对各自相同……这些情况之间还互相重叠,容斥原理写出来能占满一整块黑板。50个人的规模下,这种正面强攻的思路基本等于自杀。
正确的姿势是算补事件。“至少有一对相同”的反面是“所有人两两都不相同”,而后者是一个干净利落的连乘结构。这个转换是整个解法的灵魂,也是我反复跟新人强调的思维习惯:当“至少一个”出现时,先想想它的补事件是不是更好算。这不是什么高深技巧,但在实际工程里救命的次数多得数不清——判断“是否存在冲突”往往比“统计冲突有多少种”简单一个数量级。
补事件的好处在于,它把“任意两人”这种纠缠的关系,拆成了“第一个人随便选、第二个人避开第一个、第三个人避开前两个……”这样一条清晰的链。每一步只需要关心“当前这个人不能落在已经用掉的那些日子里”,状态极其简单。
1.3 连乘公式是怎么一步步长出来的
现在把这条链具体化。假设我们按顺序安排这50个人的生日。
第1个人:365天随便挑,有365种选择,概率是365/365 = 1。
第2个人:要跟前一个人不同,只能从剩下的364天里挑,概率是364/365。
第3个人:要跟前两个都不同,剩363天,概率363/365。
以此类推,第k个人要跟前k-1个人都不同,概率是(365-(k-1))/365。
因为独立性,这50个事件同时发生的概率就是它们相乘:
P(全不相同) = (365/365) × (364/365) × (363/365) × … × (316/365)
注意最后一项,第50个人要避开前面49个已经占用的日子,剩下365-49=316天,所以是316/365。这个连乘一共有50个因子,从365/365一路降到316/365。
那么题目要的答案就是:
P(至少一对相同) = 1 - P(全不相同) = 1 - ∏(k=0 到 49) (365-k)/365
公式本身不复杂,但这里有个细节值得停一下:为什么是从365往下数到316,而不是别的?因为50个人最多占用50个不同的日子,所以可用天数从365递减到365-49=316。这个“递减的起点和终点”是很多人写代码时容易搞错的地方,我见过有人写成从365数到315(多减了一个),也见过从364开始数的(漏了第一个人的1)。写的时候最好在纸上把前两项和最后一项都列出来对一遍。
2. 核心计算与数值细节
2.1 手算不现实,但估算很有必要
50个因子连乘,手算是不可能的,但完全不估算直接上代码也不太好——你没法判断跑出来的数字对不对。所以我习惯先做一个快速估算,给结果一个心理预期。
这里可以用一个经典的不等式:当x很小时,ln(1-x) ≈ -x。把连乘取对数:
ln(P) = Σ ln(1 - k/365),k从0到49
≈ -Σ k/365 = -(0+1+2+…+49)/365
等差数列求和:0+1+…+49 = 49×50/2 = 1225
所以 ln(P) ≈ -1225/365 ≈ -3.356
P ≈ e^(-3.356) ≈ 0.0349
于是答案约等于 1 - 0.0349 = 0.965,也就是96.5%左右。这个估算和精确值非常接近,说明思路没问题。我特别喜欢这个估算过程,因为它把“为什么50个人就接近必然”这件事讲透了:关键量是人数平方除以天数,50的平方是2500,除以365约等于6.8,再除以2得到3.4,正好落在e的指数上。人数翻倍,指数里的量翻四倍,这就是为什么阈值来得比直觉早那么多。
2.2 精确计算与浮点数的坑
估算归估算,正式实现还是得老老实实连乘。用Python写出来就几行:
def birthday_prob(n, days=365): p_no_share = 1.0 for k in range(n): p_no_share *= (days - k) / days return 1 - p_no_share print(birthday_prob(50)) # 约 0.9703735795779884跑出来是0.9704左右,比估算的0.965略高,因为估算里用了ln(1-x)≈-x的近似,实际值更精确。这个差异在可接受范围内,估算的作用本来就是定性和定量地给个方向。
但这里有个隐藏的坑:浮点数的累积误差。50次连乘,每次都是接近1的数相乘,结果会逐渐变小。双精度浮点数的有效位数大约15到16位十进制,50次乘法下来,误差会累积到1e-15量级。对于这道题来说完全无所谓,因为答案只需要几位有效数字。但如果你把n加到几千甚至上万,或者需要极高精度,就得考虑用对数求和再取指数的方式:
import math def birthday_prob_log(n, days=365): log_p = sum(math.log((days - k) / days) for k in range(n)) return 1 - math.exp(log_p)对数域里做加法比原域做乘法稳定得多,这是数值计算里的通用套路。什么时候该切换?我的经验是:连乘因子超过几百个、或者因子本身量级差异很大时,就该上对数了。这道题50个因子还远没到那个程度,但知道这个切换点在哪,比记住某一行代码更有价值。
2.3 换个角度:从“对数”到“指数”的直觉
还有一个更优雅的视角,能把整个问题压缩成一个公式。前面估算时我们得到 ln(P) ≈ -n(n-1)/(2×365),那么:
P(至少一对相同) ≈ 1 - e^(-n(n-1)/(2×365))
这个近似公式在n不太大时精度相当好。它的价值不在于替代精确计算,而在于揭示了问题的尺度。令这个概率等于0.5,解出n:
e^(-n(n-1)/730) = 0.5 n(n-1)/730 = ln2 ≈ 0.693 n(n-1) ≈ 506 n ≈ 23
这就是著名的“23个人就有一半概率撞生日”。23这个数字之所以让人震惊,是因为大多数人下意识会想“365天里得有几十上百人才可能撞上吧”。直觉错在哪?错在把“某个人和我同生日”和“任意两个人同生日”混为一谈了。前者确实需要约253人才到50%,后者只需要23人,因为配对数是人数平方级别的:23个人有253对,50个人有1225对。碰撞的机会藏在配对数里,而不是人数里。这个区分是我认为这道题最值得带走的东西。
3. 完整实操与验证流程
3.1 从公式到可复现的代码
把上面的思路整理成一个完整、可复现的实现,顺便加上几个实用功能:支持自定义天数(方便验证闰年场景)、支持批量计算不同人数、输出对照表。这样一份代码既能交作业,也能当工具用。
import math def prob_at_least_one_shared(n, days=365): """返回 n 个人中至少一对生日相同的概率""" if n > days: return 1.0 # 抽屉原理,必然相同 p_no_share = 1.0 for k in range(n): p_no_share *= (days - k) / days return 1.0 - p_no_share def prob_table(max_n, days=365): print(f"{'人数':>4} | {'至少一对相同':>12} | {'全不相同':>12}") print("-" * 36) for n in range(1, max_n + 1): p = prob_at_least_one_shared(n, days) print(f"{n:>4} | {p:>12.6f} | {1-p:>12.6f}") prob_table(60)这段代码里有个边界处理值得说:if n > days: return 1.0。抽屉原理告诉我们,人数超过天数时必然有重复,直接返回1,避免循环里出现负数因子导致结果错乱。这种边界检查在实际代码里经常被忽略,然后在上线后以“某个极端输入把服务搞崩了”的形式还债。
跑一下前几个关键节点,结果如下:
| 人数 | 至少一对相同概率 | 全不相同概率 |
|---|---|---|
| 10 | 0.116948 | 0.883052 |
| 20 | 0.411438 | 0.588562 |
| 23 | 0.507297 | 0.492703 |
| 30 | 0.706316 | 0.293684 |
| 40 | 0.891232 | 0.108768 |
| 50 | 0.970374 | 0.029626 |
| 60 | 0.994123 | 0.005877 |
50个人对应0.9704,也就是约97%的概率至少有一对相同。这个数字就是标题的答案。注意23人那一行刚好跨过0.5,验证了前面的解析推导。
3.2 用蒙特卡洛模拟做交叉验证
公式算出来的数字,怎么确认它是对的?最直接的办法是蒙特卡洛模拟:真的随机生成50个生日,看有没有重复,重复很多次,统计频率。这是我在实际项目里验证任何概率模型的标准动作——解析解和模拟解对上了,心里才踏实。
import random def simulate(n, trials=100000, days=365): hits = 0 for _ in range(trials): birthdays = [random.randint(1, days) for _ in range(n)] if len(set(birthdays)) < n: # 有重复 hits += 1 return hits / trials print(simulate(50)) # 约 0.970 左右,随试验次数波动跑10万次,结果会稳定在0.970附近,和解析解吻合。这里有个实操心得:模拟次数要够。如果只跑1000次,标准误大约是sqrt(0.97×0.03/1000)≈0.0054,也就是结果会在0.965到0.975之间晃,看起来“不太准”,其实是样本量不够。我一般至少跑10万次,标准误降到0.0005量级,肉眼就能看出和解析解是否一致。用set判断重复是个小技巧,比双重循环快得多,因为集合的查找是O(1)。
模拟还有一个额外好处:它能帮你发现建模假设的问题。比如你把天数改成366(考虑闰年),模拟和公式应该同步变化;如果两者对不上,说明代码里有bug。这种“双通道验证”的习惯,比单纯相信某一边要可靠得多。
3.3 参数敏感性:人数和天数怎么影响结果
光算50人这一个点不够,实际工作中更关心的是参数变化时结果怎么动。我做了两组对照,一组固定天数365、扫人数,一组固定人数50、扫天数。
人数的影响前面表格已经展示了,规律很清楚:概率随人数增长得非常快,20人刚过40%,30人就到70%,40人接近90%。这种“陡峭”正是反直觉的来源——人们习惯线性外推,但这里是指数级的。
天数的影响更有意思。固定50人,把天数从365改到其他值:
| 天数 | 至少一对相同概率 |
|---|---|
| 100 | 0.99999+ |
| 200 | 0.99790 |
| 365 | 0.97037 |
| 500 | 0.91778 |
| 1000 | 0.71257 |
天数越多,碰撞越难,符合直觉。但注意即使天数翻到1000,50人仍有71%的概率撞上,这个量级依然不低。这提醒我们:碰撞概率对“人数/天数”的比值敏感,而不是对绝对人数敏感。50/365≈0.137,50/1000=0.05,比值降了但没降到底,所以概率还是很高。真正让概率掉到很低,需要人数远小于天数的平方根量级——sqrt(365)≈19,这就是为什么20人左右概率才刚过40%。
4. 常见误区与排查实录
4.1 那些年踩过的建模坑
第一个高频误区是把问题理解成“有人和我同生日”。这两件事概率差了一个数量级。50个人里“至少有一对相同”是97%,“至少有一个和我相同”只有约1-(364/365)^49≈12.7%。区别在于前者有1225对组合可以碰撞,后者只有49对。我见过不少人在白板上算着算着就把这两个混了,最后得出一个自相矛盾的结论。排查方法很简单:明确问自己“碰撞的主体是谁”,是任意两人,还是某个特定的人。
第二个坑是忘记独立性假设。连乘公式成立的前提是每个人生日独立。如果题目改成“一家人”或者“同一个月出生的人”,独立性就破了,公式直接失效。实际场景里遇到相关性数据,必须换模型,不能硬套。
第三个坑是边界条件。前面提过n>days的情况,还有n=0和n=1。n=0时概率应该是0(没有人,谈不上相同),n=1时也是0。代码里如果循环从k=0开始且没做边界处理,n=0会返回1-1=0,碰巧对;但n=1时循环一次,p_no_share=365/365=1,返回0,也对。不过这种“碰巧对”不能依赖,显式写清楚边界更稳妥。
4.2 数值问题的排查速查表
实际写代码时,数值问题最隐蔽,因为结果看起来“差不多对”,但细看有偏差。我整理了一张速查表,覆盖这道题最常见的几类问题:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 结果略小于预期 | 用了ln(1-x)≈-x近似 | 对比精确连乘 | 换精确公式或减小近似误差 |
| 结果随运行波动 | 用了蒙特卡洛但次数太少 | 增大trials看是否收敛 | 至少10万次 |
| n很大时结果为负 | 浮点下溢,p_no_share变0 | 打印中间值 | 改用对数域计算 |
| n>365时结果异常 | 未处理抽屉原理边界 | 检查n与days关系 | 加边界判断 |
| 模拟与公式差很多 | 随机数种子或分布问题 | 固定种子复现 | 检查randint范围 |
这张表里最值得展开的是“浮点下溢”。当n接近365时,p_no_share会变得极小,双精度浮点数最小正数约1e-308,理论上365个因子连乘可能触及下溢,导致p_no_share变成0,答案变成1。虽然这道题n=50远没到那一步,但如果你把代码复用到“1000人、1000天”的场景,就会撞上。对数域计算是标准解法,前面已经给过代码。
4.3 独家避坑心得
说几个文档里不会写、但实际做的时候很有用的点。
第一,先估算再编码。我养成这个习惯是因为吃过亏:有次直接写代码,跑出来一个数,看着挺像那么回事就交了,后来发现循环范围写错,结果偏了0.3。如果先手算个大概(比如用n²/2d估算),一眼就能看出0.3的偏差不对劲。估算不需要精确,它就是个哨兵。
第二,模拟验证时固定随机种子。调试阶段用固定种子,保证每次运行结果一致,方便对比修改前后的差异。等逻辑稳定了再去掉种子看真实波动。这个习惯能省掉大量“到底是代码变了还是随机性变了”的困惑。
第三,把中间量打印出来。连乘过程中,p_no_share从1一路降到0.03,这个下降曲线本身就有信息量。如果某一步突然掉得异常快或慢,说明那一步的因子有问题。我调试数值代码时,习惯每隔10步打印一次中间值,比只看最终结果高效得多。
第四,别迷信“标准答案”。这道题的答案0.9704是建立在365天均匀独立假设上的。如果题目背景换成“某个特定人群”,比如运动员(出生月份可能有集中趋势),真实概率会偏离这个值。工程上遇到类似问题,先确认假设是否成立,再谈数字。假设错了,算得再准也是错的。
5. 从这道题延伸出去的东西
5.1 哈希碰撞:生日悖论的真实应用
生日悖论不只是道面试题,它是哈希碰撞分析的基石。哈希函数把任意输入映射到固定长度的空间,比如32位哈希有约43亿个可能值。按生日悖论的逻辑,随机输入下,大约sqrt(43亿)≈6.5万次就能有50%概率撞上碰撞。这个数字远小于“43亿”给人的安全感,所以设计哈希表、做数据去重、评估ID生成方案时,都必须把碰撞概率算进去。
我做过一个去重系统,最初用32位哈希做指纹,觉得43亿空间够大了。后来按生日悖论一算,数据量到十万级时碰撞概率已经不容忽视,赶紧换成64位。这个教训很典型:空间大小不等于安全边界,碰撞看的是平方根量级。同样的逻辑适用于UUID、会话ID、缓存键的设计,凡是“随机生成一个标识符”的场景,都该用生日悖论的公式估一下碰撞风险。
5.2 为什么直觉总是输给数学
这道题最迷人的地方,是它精准地击中了人类直觉的盲区。我们的大脑擅长处理线性关系,对平方、指数这类增长缺乏本能感受。50个人,直觉上觉得“365天里才占了一小部分”,但配对数1225对,已经远超365了。当配对数超过天数时,碰撞几乎是必然的——这就是为什么50人对应97%。
我在带新人的时候,会刻意让他们先猜答案再计算。大多数人猜20%到40%,实际97%,落差巨大。这个落差本身就是最好的教学素材:它让人亲身体会到“直觉需要被计算校准”。这种校准能力,在写代码、做架构、评估风险时,比记住任何一个公式都重要。
5.3 一个可以继续玩的方向
如果想把这道题吃得更透,可以试试几个变体。一是放宽均匀假设,用真实的出生月份分布数据重算,看看概率偏移多少——我试过用带季节波动的分布,50人的概率会略微上升,因为分布越集中越容易撞。二是改成“至少三个人相同”,这就不是简单补事件能解决的了,需要容斥或者递推,难度上一个台阶。三是把天数改成变量d,推导一般公式,看看d趋于无穷时概率怎么衰减,能加深对“平方根阈值”的理解。
这些变体我陆陆续续都写过代码,最有意思的是第三个:当d很大时,n≈1.177×sqrt(d)对应50%概率,这个1.177就是sqrt(2ln2)。把这个常数记住,以后遇到任何“随机碰撞”问题,都能秒估阈值,比翻公式快得多。
最后分享一个我自己的习惯:每次算完这类概率题,我都会问一句“如果假设不成立会怎样”。这道题里,均匀和独立两个假设一旦松动,答案就会漂移。养成这个追问的习惯,比会算一道题有价值得多——因为现实世界里的问题,几乎从来没有干净的假设。