题目描述
投掷 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
问题分析
这是一个带约束条件的概率期望问题,需要我们深入理解题目的核心规则:
关键理解点
- 正常情况:硬币投掷时,正面和反面的概率各为 0.5
- 约束条件:当连续出现 cnt 次正面时,下一次投掷必须是反面(概率为 1)
- 目标:计算在这种约束下,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),记忆化存储 + 递归调用栈