请参考这篇文章#【概率】从英语“七选五”到错位排列,其实核心内容也和下面的大差不差,只不过下面代码甚至能计算正解项不足的情况,例如7选5但正解项小于5的排列可能性,也就是除了选项是干扰外,槽(空位)也是干扰,这多少有点极端(小声)。
起因是看到长河劫的视频#【日常研究】摆烂小能手掀起的数学问题
看到这题时我采用的思路是递归,而不是大家通常采用的递推。
思路
我这里的假设是:
- 有n个信封;
- 有效信封的个数为v(也就是有可能投对的个数为v);
- 从n个信封中任选k个进行投递;
- 投递正确的个数记为t;
于是有函数F(n,k,v,t),它返回值代表的是有多少种排列(投递方式)可能性。
然后这个函数F(n,k,v,t)满足这样的递归计算:
上面的递归式分为三项(每项均不为负数),意思分别是:
- 命中:选择了唯一的
1个能让它正确的有效值,乘数为1,随后有效值为v-1个,需命中t-1个; - 未命中,消耗的是无效值:从
n-v个无效值中选择了其中一个,乘数为n-v,随后有效值为v-1个(因为该槽对应的有效值失效了所以减1),仍需命中t个; - 未命中,且额外消耗一个有效值:从
v-1个有效值中选择了其中一个,乘数为v-1,随后有效值为v-1-1个,仍需命中t个;
所谓“消耗额外有效值”,就相当于给定abcde填入槽位ABC。
- 槽位A填入的是无效值d,此时剩余槽位的有效值个数为两个;
- A填入的是b或者c,此时剩余槽位有效值个数仅为一个(也就是当A填入b或c时,槽位BC无论怎么填,最多只会正确一个);
上面的思路其实没有经过严格证明,都是凭感觉来的。而且我也不是数学仔,这些证明啥的完全不会啊。
连递归函数的临界值都是一点一点试出来的。
Python代码:
# import math#math.factorial可计算阶乘,但感觉没必要
import functools
def A(n,k):
'''
n选k全排列,
k为0时返回1
'''
return functools.reduce(lambda a,b:a*b,range(n-k+1,n+1)) if k>0 else 1
def Func(n:int,k:int,v:int,t:int,cache:dict):
'''
n为总个数;
k为选中个数;
v为总数中的有效值个数;
t为选中的正确个数(目标值);
cache为缓存,减少重复计算;
'''
if(v<0):
v=0
if(n<1):
return 0#无效的
if(k<1):
return 0#无效的
if(t<0 or v<t or k<t):
return 0#无效的
r=cache.get((n,k,v,t))
if(r!=None):
return r
if(k==1):#仅挑1个值。以它作为临界点
if(t==0):
r=A(n-v,k)
else:
r=v*A(n-v-1,k-1)
if(r==None):#这递归式不难找,难的是抓边界值,一点一点瞎试的
v1=1*Func(n-1,k-1,v-1,t-1,cache)#命中
v2=(n-v)*Func(n-1,k-1,v-1,t,cache)#未命中,未消耗v
v3=(v-1)*Func(n-1,k-1,v-1-1,t,cache) if v>0 else 0#未命中,消耗v
r=v1+v2+v3
return cache.setdefault((n,k,v,t),r)
if True:
cache={}
nkLst=[
(2,2),
(3,3),
# (4,4),
# (5,5),
# (7,7),
(10,10),
# (15,15),
# (20,20),
# (50,50),#似乎随着nk值的增大,命中0和命中1的概率会趋近于36.78%
# (7,5),#7选5
]
n,k=nkLst[-1]
total=A(n,k)
for t in range(k+1):
val=Func(n,k,k,t,cache)
p=n#精度
print(f'{n:2}选{k:2},命中{t:2}:{round(val*100/total,6):{p+3}.{p}f}% 【{val:<{p}}】')
print(f' 总数:{sum(Func(n,k,k,t,cache) for t in range(k+1))}')
print(f'全排列:{total}')#如果总数与全排列不一致,那么就说明Func有bug。修了好久才修好的。
代码支持7选5之类的情况。
不同的值运行结果如下:
上面的n选n结果中,有几个奇怪的地方是:
- n足够大时(看上去n>=10)命中0的概率逼近
36.7879%; - n为奇数时命中1的次数比命中0恰好多1,偶数则刚好相反;
不是搞数学的,不感兴趣,摸了。