6.4. 投掷硬币

题目描述

投掷 num 次硬币,其中如果有连续 cnt 次投掷硬币都是正面向上,那么第 cnt+1 次投掷硬币就一定是反面向上。求投掷 num 次硬币后,硬币正面向上的次数的期望。

示例 1:

输入:num = 3, cnt = 2
输出:1.37500

解释:
投掷 3 次硬币后有以下 7 种可能(0 表示硬币反面向上,1 表示硬币正面向上):
"000",概率为 1/8,正面次数 = 0
"001",概率为 1/8,正面次数 = 1
"010",概率为 1/8,正面次数 = 1
"011",概率为 1/8,正面次数 = 2
"100",概率为 1/8,正面次数 = 1
"101",概率为 1/8,正面次数 = 2
"110",概率为 1/4,正面次数 = 2

期望 = 0*(1/8) + 1*(1/8)*3 + 2*(1/4) + 2*(1/8)*2 = 1.375

提示:

  • 2 <= num <= 500
  • 1 <= cnt <= 50
  • cnt < num

问题分析

这是一个带约束条件的概率期望问题,需要我们深入理解题目的核心规则:

关键理解点

  1. 正常情况:硬币投掷时,正面和反面的概率各为 0.5
  2. 约束条件:当连续出现 cnt 次正面时,下一次投掷必须是反面(概率为 1)
  3. 目标:计算在这种约束下,num 次投掷中正面出现次数的数学期望

问题建模

我们可以将这个问题建模为一个有限状态机:

  • 状态:当前连续正面的次数(0 到 cnt)
  • 转移规则
    • 状态 0 到 cnt-1:可以投正面(进入下一状态)或反面(回到状态 0)
    • 状态 cnt:强制投反面(回到状态 0)

动态规划思路

定义状态:dp[i][j] 表示还需要投掷 i 次硬币,当前连续正面次数为 j 时,能获得的正面次数的期望值。

状态转移方程

当 j < cnt 时(还没达到连续上限):

dp[i][j] = 0.5 × (1 + dp[i-1][j+1]) + 0.5 × dp[i-1][0]
  • 投正面(概率 0.5):获得 1 个正面,转到状态 j+1
  • 投反面(概率 0.5):获得 0 个正面,转到状态 0

当 j == cnt 时(已达到连续上限,必须投反面):

dp[i][j] = dp[i-1][0]

边界条件

dp[0][j] = 0  (没有硬币可投,期望为 0)

状态转移方程详细解释

状态定义

首先,我们明确状态的定义:

  • dp[i][j] = 还需要投掷 i 次硬币,当前连续正面次数为 j 时,未来能获得的正面次数的期望值

状态转移方程推导

情况1:当 j < cnt 时

条件:当前连续正面次数还没有达到上限,可以自由选择投正面或反面。

分析:在这种情况下,我们有两种选择:

选择A:投正面(概率 = 0.5)
  • 立即收益:获得 1 个正面
  • 未来收益:转移到状态 dp[i-1][j+1](剩余次数减1,连续正面次数加1)
  • 总期望:1 + dp[i-1][j+1]
选择B:投反面(概率 = 0.5)
  • 立即收益:获得 0 个正面
  • 未来收益:转移到状态 dp[i-1][0](剩余次数减1,连续正面次数重置为0)
  • 总期望:0 + dp[i-1][0] = dp[i-1][0]
期望值计算公式

根据期望值的定义:期望 = 各种情况的概率 × 对应的收益

dp[i][j] = P(投正面) × (投正面的期望收益) + P(投反面) × (投反面的期望收益)
         = 0.5 × (1 + dp[i-1][j+1]) + 0.5 × dp[i-1][0]
情况2:当 j == cnt 时

条件:当前连续正面次数已经达到上限,必须投反面。

分析:在这种情况下,我们没有选择:

唯一选择:投反面(概率 = 1.0)
  • 立即收益:获得 0 个正面
  • 未来收益:转移到状态 dp[i-1][0](剩余次数减1,连续正面次数重置为0)
  • 总期望:0 + dp[i-1][0] = dp[i-1][0]
期望值计算公式
dp[i][j] = 1.0 × dp[i-1][0] = dp[i-1][0]

图解状态转移

让我用图来展示状态转移的过程:

当 j < cnt 时的状态转移图
           状态 dp[i][j]
         (还需投i次,连续j个正面)
                  |
                  | 下一次投掷
                  |
        +---------+---------+
        |                   |
   投正面(0.5)          投反面(0.5)
        |                   |
        v                   v
收益: 1个正面           收益: 0个正面
状态: dp[i-1][j+1]     状态: dp[i-1][0]
当 j == cnt 时的状态转移图
           状态 dp[i][cnt]
         (还需投i次,连续cnt个正面)
                  |
                  | 强制投反面
                  |
                  v
             收益: 0个正面
             状态: dp[i-1][0]

具体例子详解

让我们用 num = 3, cnt = 2 来具体演示:

计算 dp[1][0](还需投1次,当前连续0个正面)

dp[1][0] = 0.5 × (1 + dp[0][1]) + 0.5 × dp[0][0]
         = 0.5 × (1 + 0) + 0.5 × 0    [边界条件:dp[0][*] = 0]
         = 0.5 × 1 + 0.5 × 0
         = 0.5

解释

  • 如果投正面(概率0.5):获得1个正面,然后没有剩余投掷,期望 = 1 + 0 = 1
  • 如果投反面(概率0.5):获得0个正面,然后没有剩余投掷,期望 = 0 + 0 = 0
  • 总期望 = 0.5 × 1 + 0.5 × 0 = 0.5

计算 dp[2][0](还需投2次,当前连续0个正面)

dp[2][0] = 0.5 × (1 + dp[1][1]) + 0.5 × dp[1][0]

首先需要计算 dp[1][1]:

dp[1][1] = 0.5 × (1 + dp[0][2]) + 0.5 × dp[0][0]
         = 0.5 × (1 + 0) + 0.5 × 0
         = 0.5

然后:

dp[2][0] = 0.5 × (1 + 0.5) + 0.5 × 0.5
         = 0.5 × 1.5 + 0.5 × 0.5
         = 0.75 + 0.25
         = 1.0

解释

  • 如果第一次投正面(概率0.5):获得1个正面,转到状态dp[1][1],总期望 = 1 + 0.5 = 1.5
  • 如果第一次投反面(概率0.5):获得0个正面,转到状态dp[1][0],总期望 = 0 + 0.5 = 0.5
  • 总期望 = 0.5 × 1.5 + 0.5 × 0.5 = 1.0

计算 dp[1][2](还需投1次,当前连续2个正面)

dp[1][2] = dp[0][0] = 0

解释

  • 因为已经连续2个正面(达到cnt=2的上限),下一次必须投反面
  • 投反面:获得0个正面,然后没有剩余投掷,期望 = 0 + 0 = 0

详细代码实现

Java 实现

/**
 * 6.4. 投掷硬币
 * 动态规划
 */
class Solution {
    public double solve(int num, int cnt) {
        // dp[i][j] 表示还需要投掷i次硬币,当前连续正面次数为j时的期望正面次数
        double[][] dp = new double[num + 1][cnt + 1];

        // 边界条件:dp[0][j] = 0(没有硬币可投,期望为0)
        // 数组默认初始化为0,无需显式设置

        for (int i = 1; i <= num; i++) {
            for (int j = 0; j <= cnt; j++) {
                if (j < cnt) {
                    // 当前连续正面次数未达到上限,可以投正面或反面
                    // 投正面:概率0.5,获得1个正面,转到状态j+1
                    double p1 = 0.5 * (1 + dp[i - 1][j + 1]);
                    // 投反面:概率0.5,获得0个正面,转到状态0
                    double p2 = 0.5 * dp[i - 1][0];
                    dp[i][j] = p1 + p2;
                } else {
                    // j == cnt,连续正面次数已达上限,下一次必须投反面
                    dp[i][j] = dp[i - 1][0];
                }
            }
        }

        // 返回从状态0开始投掷num次的期望
        return dp[num][0];
    }
}

C# 实现

/**
 * 6.4. 投掷硬币
 * 动态规划
 */
public class Solution
{
    public double Solve(int num, int cnt)
    {
        // dp[i,j] 表示还需要投掷i次硬币,当前连续正面次数为j时的期望正面次数
        double[,] dp = new double[num + 1, cnt + 1];

        // 边界条件:dp[0,j] = 0(没有硬币可投,期望为0)
        // 数组默认初始化为0,无需显式设置

        for (int i = 1; i <= num; i++)
        {
            for (int j = 0; j <= cnt; j++)
            {
                if (j < cnt)
                {
                    // 当前连续正面次数未达到上限,可以投正面或反面
                    // 投正面:概率0.5,获得1个正面,转到状态j+1
                    double p1 = 0.5 * (1 + dp[i - 1, j + 1]);
                    // 投反面:概率0.5,获得0个正面,转到状态0
                    double p2 = 0.5 * dp[i - 1, 0];
                    dp[i, j] = p1 + p2;
                }
                else
                {
                    // j == cnt,连续正面次数已达上限,下一次必须投反面
                    dp[i, j] = dp[i - 1, 0];
                }
            }
        }

        // 返回从状态0开始投掷num次的期望
        return dp[num, 0];
    }
}

复杂度分析

时间复杂度

O(num × cnt),需要填充 (num+1) × (cnt+1) 的二维数组

空间复杂度

O(num × cnt),存储二维DP数组


递归+记忆化解法

我们也可以用递归的思路来理解这个问题:

Java 递归实现

import java.util.HashMap;
import java.util.Map;

/**
 * 6.4. 投掷硬币
 * 递归+记忆化解法
 */
class Solution {
    Map<String, Double> memo = new HashMap<>();

    public double solve(int num, int cnt) {
        memo.clear();
        return dfs(num, 0, cnt);
    }

    /**
     * 递归函数
     *
     * @param remaining   剩余的硬币数
     * @param consecutive 连续正面的硬币数
     * @param cnt         总的投掷数
     * @return
     */
    private double dfs(int remaining, int consecutive, int cnt) {
        // 基础情况:没有剩余投掷次数
        if (remaining == 0) {
            return 0;
        }

        String key = remaining + "-" + consecutive;
        if (memo.containsKey(key)) {
            return memo.get(key);
        }

        double ans = 0;
        if (consecutive == cnt) {
            // 连续正面次数已达上限,必须投反面
            ans = dfs(remaining - 1, 0, cnt);
        } else {
            // 可以选择投正面或反面
            // 投正面:概率0.5,获得1个正面
            double p1 = 0.5 * (1 + dfs(remaining - 1, consecutive + 1, cnt));
            // 投反面:概率0.5,获得0个正面
            double p2 = 0.5 * dfs(remaining - 1, 0, cnt);
            ans = p1 + p2;
        }

        memo.put(key, ans);
        return ans;
    }
}

C# 递归实现

/**
 * 6.4. 投掷硬币
 * 递归+记忆化解法
 */
public class Solution
{
    private Dictionary<string, double> memo = new Dictionary<string, double>();

    public double Solve(int num, int cnt)
    {
        memo.Clear();
        return Dfs(num, 0, cnt);
    }

    private double Dfs(int remaining, int consecutive, int cnt)
    {
        // 没有剩余投掷次数
        if (remaining == 0)
        {
            return 0;
        }

        // 记忆化检查
        String key = remaining + ":" + consecutive;
        if (memo.TryGetValue(key, out var dfs))
        {
            return dfs;
        }

        double ret;
        if (consecutive == cnt)
        {
            // 连续正面次数已达上限,必须投反面
            ret = Dfs(remaining - 1, 0, cnt);
        }
        else
        {
            // 可以选择投正面或反面
            // 投正面:概率0.5,获得1个正面
            double p1 = 0.5 * (1 + Dfs(remaining - 1, consecutive + 1, cnt));
            // 投反面:概率0.5,获得0个正面
            double p2 = 0.5 * Dfs(remaining - 1, 0, cnt);
            ret = p1 + p2;
        }

        memo[key] = ret;
        return ret;
    }
}

时间复杂度:O(num × cnt),每个状态最多计算一次

空间复杂度:O(num × cnt),记忆化存储 + 递归调用栈

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值