概率趣题——吃豆子


概率趣题——吃豆子

这篇文章是我在知乎上看到的概率题目后,自己分析、解答的过程。从简单程序模拟入手,到递推式和动态规划,最后求出解析解并证明。希望能给你带来启发。

题目

袋子中有白色、黑色两种豆子,且数量相等。每次进行如下操作:

  • 从袋子中随机抽取一颗豆子;
  • 若为黑色,则直接吃掉;
  • 若为白色,则放回重新随机抽取一次,然后不论是什么颜色都直接吃掉。

重复上述操作直到袋中没有豆子。求最后一颗被吃掉的豆子是黑色的概率。

题目分析

显然黑色豆子更有可能被吃掉,所以袋中的白色豆子所占比例应该趋向于越来越大。所以最终所求概率应该小于

假设初始有个白色豆子和个黑色豆子。当时,我们可以计算出概率:

变大时,所求概率会更小。因为随着操作次数的增多,黑色豆子应该越来越少,直到达到某个比例,使得黑色豆子被抽出吃掉的概率和白色豆子相等,这样二者的比例才会稳定。随着,这个比例应该也逐渐趋近于。那么所求概率也应该逐渐趋近于

让我们写个程序看看推测是否正确。

编程模拟

设最后一个被吃掉的豆子是黑色的概率为,其中是初始时白色豆子的数量和黑色豆子的数量。

首先让我们验证

import random
from typing import Tuple

# 用 Tuple[int, int] 描述容器状态(白色豆子数,黑色豆子数)
ContainerState = Tuple[int, int]

# 操作一次(指按题目操作直到吃掉一颗豆子),并返回容器状态
def operate_once(state: ContainerState) -> ContainerState:
    selection = 0 if random.randint(0, state[0] + state[1] - 1) < state[0] else 1
    if selection == 0:
        # 抽到白色豆子,放回并重新抽取
        selection = 0 if random.randint(0, state[0] + state[1] - 1) < state[0] else 1
    if selection == 0:
        return state[0] - 1, state[1]
    else:
        return state[0], state[1] - 1

# 模拟一遍完整的操作过程,并返回最后一个吃掉的豆子的颜色(0 为白色,1 为黑色)
def simulate(n: int) -> int:
    state = n, n
    while state[0] and state[1]:
        state = operate_once(state)
    return 0 if state[0] else 1

# 模拟多次,并计算 P(n);其中 T 为总模拟次数
def simulate_and_calc_prob(n: int, T: int = 100000) -> float:
    num_cases_last_is_black = 0
    num_cases_total = T
    for _ in range(num_cases_total):
        result = simulate(n)
        if result == 1:
            num_cases_last_is_black += 1
    return float(num_cases_last_is_black) / float(num_cases_total)

print("P(1) =", simulate_and_calc_prob(1))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
P(1) = 0.25098

更多的豆子

结果和我们预想的一致。让我们尝试一下更大的

for n in [1, 2, 3, 4, 5, 10, 100]:
    print("P({}) =".format(n), simulate_and_calc_prob(n))
1
2
P(1) = 0.25073
P(2) = 0.16579
P(3) = 0.12502
P(4) = 0.09817
P(5) = 0.08408
P(10) = 0.04548
P(100) = 0.005

更快的程序?

好吧,结果确实随着的增大而逐渐接近零了,这符合我们的预期。但是这个程序太慢了,我们来尝试加速一下这个程序。

注意到每次抽取豆子时,抽到不同豆子的概率与剩余两种豆子的数量有关。假设现在有个白色豆子和个黑色豆子,下一个吃掉的豆子的颜色为白色和黑色的概率分别是:

让我们基于这些公式重写一下程序。

# 操作一次(指按题目操作直到吃掉一颗豆子),并返回容器状态
def operate_once_faster(state: ContainerState) -> ContainerState:
    prob_white = float(state[0]) * float(state[0]) / (float(state[0] + state[1]) * float(state[0] + state[1]))
    selection = 0 if random.random() < prob_white else 1
    if selection == 0:
        return state[0] - 1, state[1]
    else:
        return state[0], state[1] - 1
    
# 模拟一遍完整的操作过程,并返回最后一个吃掉的豆子的颜色(0 为白色,1 为黑色)
def simulate_faster(n: int) -> int:
    state = n, n
    while state[0] and state[1]:
        state = operate_once_faster(state)
    return 0 if state[0] else 1

# 模拟多次,并计算 P(n);其中 T 为总模拟次数
def simulate_and_calc_prob_faster(n: int, T: int = 100000) -> float:
    num_cases_last_is_black = 0
    num_cases_total = T
    for _ in range(num_cases_total):
        result = simulate_faster(n)
        if result == 1:
            num_cases_last_is_black += 1
    return float(num_cases_last_is_black) / float(num_cases_total)

for n in [1, 2, 3, 4, 5, 10, 100]:
    print("P({}) =".format(n), simulate_and_calc_prob_faster(n))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
P(1) = 0.25076
P(2) = 0.1669
P(3) = 0.1241
P(4) = 0.10211
P(5) = 0.08278
P(10) = 0.04681
P(100) = 0.00507

不再模拟

当然刚才这样并不会让程序变快,因为总的模拟次数并没有变少。(虽然我们调用random()函数的次数确实变少了。)但观察这些公式,我们可以发现一个不用多次模拟就能求得概率的方法!

设当前状态为,即有颗白色豆子和颗黑色豆子。我们用表示。那么就是题目所求概率。注意:我们之所以可以这样做,是因为在状态为的情况下,最后一颗是黑色的概率与之前的豆子数、吃豆顺序都已经无关了,即我们常说的“无后效性”。

利用递推的方法,我们可以建立之间的关系:

我们还知道初始条件:

那么我们可以用动态规划的方法求出的值。

from typing import List

# 用动态规划计算 P(x, y)
def calc_prob_by_dp(n: int) -> float:
    # p[x] = P(x, y),其中 y 随着迭代的进行会不断变化。
    p: List[float] = [0.0] * (n + 1)
    # last_p[x] = P(x, y - 1)
    last_p: List[float] = [0.0] * (n + 1)
    
    for y in range(1, n + 1):
        p[0] = 1.0
        for x in range(1, n + 1):
            prob_white = float(x) * float(x) / (float(x + y) * float(x + y))
            p[x] = prob_white * p[x - 1] + (1.0 - prob_white) * last_p[x]
        p, last_p = last_p, p
    
    return last_p[n]

for n in [1, 2, 3, 4, 5, 10, 100]:
    print("P({}) =".format(n), calc_prob_by_dp(n))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
P(1) = 0.25
P(2) = 0.16666666666666666
P(3) = 0.125
P(4) = 0.09999999999999999
P(5) = 0.08333333333333333
P(10) = 0.04545454545454545
P(100) = 0.004950495049504951

画图

虽然得到了数值解,但是我们感觉到,这个题目应该是有解析解的,解析解可以通过递推式求出。但是我这么菜,显然是看不出来如何求解析解的,所以我们从数值解找找规律。

我们来画一下变化的图像。

# 用动态规划计算 P(x, y)
def calc_prob_by_dp_all(n: int) -> List[float]:
    # p[x] = P(x, y),其中 y 随着迭代的进行会不断变化。
    p: List[float] = [0.0] * (n + 1)
    # last_p[x] = P(x, y - 1)
    last_p: List[float] = [0.0] * (n + 1)
    # p_all[n - 1] = P(n, n)
    p_all: List[float] = [0.0] * n
    
    for y in range(1, n + 1):
        p[0] = 1.0
        for x in range(1, n + 1):
            prob_white = float(x) * float(x) / (float(x + y) * float(x + y))
            p[x] = prob_white * p[x - 1] + (1.0 - prob_white) * last_p[x]
        p_all[y - 1] = p[y]
        p, last_p = last_p, p
    
    return p_all

from matplotlib import pyplot as plt

N = 20
ps = calc_prob_by_dp_all(N)
ns = list(range(1, N + 1))
plt.plot(ns, ps)
plt.xlabel('n')
plt.xticks([1, 5, 10, 15, 20])
plt.ylabel('P(n,n)')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

png

结果分析

图像看起来是符合预期的。而且所求概率随着的增大大约是反比例衰减的。

我们注意到:

嗯……看起来:

可是只猜到了的表达式,是无法完成证明的。要想通过递推式证明这个表达式,需要先得到任意的的表达式。我们最好还是画一下图、列一下数据看看。

# 用动态规划计算 P(x, y)
def calc_prob_by_dp_all_xy(n: int) -> List[List[float]]:
    # p[y][x] = P(x, y)
    p: List[List[float]] = [[0.0] * (n + 1) for _ in range(n + 1)]
    
    for y in range(1, n + 1):
        p[y][0] = 1.0
        for x in range(1, n + 1):
            prob_white = float(x) * float(x) / (float(x + y) * float(x + y))
            p[y][x] = prob_white * p[y][x - 1] + (1.0 - prob_white) * p[y - 1][x]
    
    return p

import numpy as np

N = 20
p = calc_prob_by_dp_all_xy(N)
x, y = np.meshgrid(np.arange(1, N + 1), np.arange(1, N + 1))
axes = plt.axes(projection='3d')
axes.plot_surface(x, y, np.array([p[y + 1][1:] for y in range(N)]))
plt.show()
plt.figure()
ps = [p[n][n] for n in ns]
plt.plot(ns, ps)
plt.xlabel('n')
plt.xticks([1, 5, 10, 15, 20])
plt.ylabel('P(n,n)')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

png

png

import pandas as pd
pd.DataFrame(np.array(p)[:-1,:-1])
1
2
012345678910111213141516171819
00.00.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.0000000.000000
11.00.2500000.1111110.0625000.0400000.0277780.0204080.0156250.0123460.0100000.0082640.0069440.0059170.0051020.0044440.0039060.0034600.0030860.0027700.002500
21.00.3333330.1666670.1000000.0666670.0476190.0357140.0277780.0222220.0181820.0151520.0128210.0109890.0095240.0083330.0073530.0065360.0058480.0052630.004762
31.00.3750000.2000000.1250000.0857140.0625000.0476190.0375000.0303030.0250000.0209790.0178570.0153850.0133930.0117650.0104170.0092880.0083330.0075190.006818
41.00.4000000.2222220.1428570.1000000.0740740.0571430.0454550.0370370.0307690.0259740.0222220.0192310.0168070.0148150.0131580.0117650.0105820.0095690.008696
51.00.4166670.2380950.1562500.1111110.0833330.0649350.0520830.0427350.0357140.0303030.0260420.0226240.0198410.0175440.0156250.0140060.0126260.0114420.010417
61.00.4285710.2500000.1666670.1200000.0909090.0714290.0576920.0476190.0400000.0340910.0294120.0256410.0225560.0200000.0178570.0160430.0144930.0131580.012000
71.00.4375000.2592590.1750000.1272730.0972220.0769230.0625000.0518520.0437500.0374330.0324070.0283400.0250000.0222220.0198860.0179030.0162040.0147370.013462
81.00.4444440.2666670.1818180.1333330.1025640.0816330.0666670.0555560.0470590.0404040.0350880.0307690.0272110.0242420.0217390.0196080.0177780.0161940.014815
91.00.4500000.2727270.1875000.1384620.1071430.0857140.0703120.0588240.0500000.0430620.0375000.0329670.0292210.0260870.0234380.0211760.0192310.0175440.016071
101.00.4545450.2777780.1923080.1428570.1111110.0892860.0735290.0617280.0526320.0454550.0396830.0349650.0310560.0277780.0250000.0226240.0205760.0187970.017241
111.00.4583330.2820510.1964290.1466670.1145830.0924370.0763890.0643270.0550000.0476190.0416670.0367890.0327380.0293330.0264420.0239650.0218250.0199640.018333
121.00.4615380.2857140.2000000.1500000.1176470.0952380.0789470.0666670.0571430.0495870.0434780.0384620.0342860.0307690.0277780.0252100.0229890.0210530.019355
131.00.4642860.2888890.2031250.1529410.1203700.0977440.0812500.0687830.0590910.0513830.0451390.0400000.0357140.0320990.0290180.0263690.0240740.0220710.020313
141.00.4666670.2916670.2058820.1555560.1228070.1000000.0833330.0707070.0608700.0530300.0466670.0414200.0370370.0333330.0301720.0274510.0250900.0230260.021212
151.00.4687500.2941180.2083330.1578950.1250000.1020410.0852270.0724640.0625000.0545450.0480770.0427350.0382650.0344830.0312500.0284630.0260420.0239230.022059
161.00.4705880.2962960.2105260.1600000.1269840.1038960.0869570.0740740.0640000.0559440.0493830.0439560.0394090.0355560.0322580.0294120.0269360.0247680.022857
171.00.4722220.2982460.2125000.1619050.1287880.1055900.0885420.0755560.0653850.0572390.0505950.0450930.0404760.0365590.0332030.0303030.0277780.0255640.023611
181.00.4736840.3000000.2142860.1636360.1304350.1071430.0900000.0769230.0666670.0584420.0517240.0461540.0414750.0375000.0340910.0311420.0285710.0263160.024324
191.00.4750000.3015870.2159090.1652170.1319440.1085710.0913460.0781890.0678570.0595610.0527780.0471460.0424110.0383840.0349260.0319330.0293210.0270270.025000

结果再分析

嗯……看起来:

也许我们有:

这个猜得对不对呢?我们验证一下。首先它确实满足我们之前的猜测

接下来我们验证它完全正确。首先关于边界条件,有:

结果不错。然后是递推式:

OHHHHHHH

太棒了,所以初始有颗白色豆子和颗黑色豆子时,最后一颗是黑色的概率是:

对于题目所说的初始时黑色豆子和白色豆子相同(均为)的条件下,最后一颗是黑色的概率是: