【Python】信封问题

54 阅读4分钟

请参考这篇文章#【概率】从英语“七选五”到错位排列,其实核心内容也和下面的大差不差,只不过下面代码甚至能计算正解项不足的情况,例如7选5但正解项小于5的排列可能性,也就是除了选项是干扰外,槽(空位)也是干扰,这多少有点极端(小声)。


起因是看到长河劫的视频#【日常研究】摆烂小能手掀起的数学问题

看到这题时我采用的思路是递归,而不是大家通常采用的递推。


思路

我这里的假设是:

  • 有n个信封;
  • 有效信封的个数为v(也就是有可能投对的个数为v);
  • 从n个信封中任选k个进行投递;
  • 投递正确的个数记为t;

于是有函数F(n,k,v,t),它返回值代表的是有多少种排列(投递方式)可能性。

然后这个函数F(n,k,v,t)满足这样的递归计算:

F(n,k,v,t)=1F(n1,k1,v1,t1)+(nv)F(n1,k1,v1,t)+(v1)F(n1,k1,v11,t)\begin{aligned} F(n,k,v,t)&=1*F(n-1,k-1,v-1,t-1)\\ &+(n-v)*F(n-1,k-1,v-1,t)\\ &+(v-1)*F(n-1,k-1,v-1-1,t) \end{aligned}

上面的递归式分为三项(每项均不为负数),意思分别是:

  • 命中:选择了唯一的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之类的情况。

不同的值运行结果如下:

7选5.png

3选3.png

5选5.png

10选10.png

20选20.png

上面的n选n结果中,有几个奇怪的地方是:

  • n足够大时(看上去n>=10)命中0的概率逼近36.7879%
  • n为奇数时命中1的次数比命中0恰好多1,偶数则刚好相反;

不是搞数学的,不感兴趣,摸了。