LeetCode 剑指 Offer 60. n个骰子的点数
题目描述

题意分析
同时掷
n个普通六面骰子,每个骰子等概率地出 1 到 6 点。要交出的不是方案数,而是一个概率数组:把所有可能的点数和从小到大排好,第i个位置放的是「点数和取第i小的那个值」出现的概率,返回类型是浮点数组。这句话决定了答案的长度和下标含义,读题时必须先钉死,否则后面算得再对也对不上下标。由此推出数组规格。
n个骰子每个最少 1 点,所以最小点数和是n;每个最多 6 点,所以最大点数和是6n;中间每个整数都可达,因为从任意一种凑法出发,只要把某个还没到 6 的骰子加一点,就能走到下一个和。于是可能的点数和共有6n - n + 1 = 5n + 1个,答案数组长度是5n + 1而不是6n + 1,并且ans[k]对应的点数和是k + n,反过来点数和sum应该写进ans[sum - n]。这个偏移量是本题第一个坑。约束是
1 <= n <= 11。上限小得很反常,正好透露两条信息。一是总的投掷结果数 $6^{11} = 362797056$ 不到 $4 \times 10^8$,普通浮点数装得下,不需要高精度或者取模。二是答案数组最长也只有 $5 \times 11 + 1 = 56$ 个位置,而投掷结果有三亿多种,说明海量结果会归并到同一个点数和上;换句话说信息在「点数和」这个维度上被压缩得极狠,按点数和聚合而不是按每一次具体投掷去枚举,才是这个数据范围在引导的方向。还要注意各个点数和并不等概率。以两个骰子为例,和为 7 有 6 种凑法,和为 2 只有 1 种,所以绝不能偷懒返回
5n + 1个相同的值。边界情形有三处。
n = 1时答案是 6 个 $1/6$;两端的点数和n与6n概率恒为 $1 / 6^n$,可以拿来当第一道自检;所有元素之和必须等于 1,这是最好用的整体自检手段。判题按浮点误差比对,中间过程用整数计数、最后统一做一次除法,比一路带着小数走更稳。
解法:滚动动态规划统计方案数
核心思路
所有投掷序列等概率,因此先统计每个点数和的方案数,最后统一除以总方案数 $6^n$。
设当前已经处理若干个骰子,
dp[s]表示点数和为s的方案数。加入一个新骰子时,它可能掷出 1 到 6 点,所以旧状态dp[s]会分别贡献到next[s + 1]至next[s + 6]。每种完整投掷序列都能按“前面骰子的点数和 + 最后一个骰子的点数”唯一分类,转移因此不重不漏。第
k轮只依赖第k - 1轮,用两个一维数组滚动即可。必须读旧数组、写新数组;若原地累加,本轮刚写入的状态会再次参与转移,相当于重复使用同一个骰子。最终有效点数和是
n到6n,答案下标i对应点数和n + i。
解题步骤
- 初始化一个骰子:
dp[1..6] = 1,总方案数为 6。- 从第 2 个骰子开始,创建全 0 的
next。- 枚举上一轮合法点数和,再枚举新骰子的 6 个点数,执行
next[sum + point] += dp[sum]。- 用
next替换dp,总方案数乘 6。- 将
dp[n..6n]依次除以总方案数,写入长度为5n + 1的答案数组。例如
n = 2时,方案数依次为1,2,3,4,5,6,5,4,3,2,1,总和为 36;两端概率都是 $1/36$,中间点数和 7 的概率是 $6/36$。
代码实现
class Solution {
public double[] dicesProbability(int n) {
long[] dp = new long[6 * n + 1];
for (int point = 1; point <= 6; point++) {
dp[point] = 1;
}
long total = 6;
for (int dice = 2; dice <= n; dice++) {
long[] next = new long[6 * n + 1];
for (int sum = dice - 1; sum <= 6 * (dice - 1); sum++) {
for (int point = 1; point <= 6; point++) {
next[sum + point] += dp[sum];
}
}
dp = next;
total *= 6;
}
double[] ans = new double[5 * n + 1];
for (int sum = n; sum <= 6 * n; sum++) {
ans[sum - n] = (double) dp[sum] / total;
}
return ans;
}
}
func dicesProbability(n int) []float64 {
dp := make([]int64, 6*n+1)
for point := 1; point <= 6; point++ {
dp[point] = 1
}
total := int64(6)
for dice := 2; dice <= n; dice++ {
next := make([]int64, 6*n+1)
for sum := dice - 1; sum <= 6*(dice-1); sum++ {
for point := 1; point <= 6; point++ {
next[sum+point] += dp[sum]
}
}
dp = next
total *= 6
}
ans := make([]float64, 5*n+1)
for sum := n; sum <= 6*n; sum++ {
ans[sum-n] = float64(dp[sum]) / float64(total)
}
return ans
}
复杂度分析
- 时间复杂度:$O(n^2)$。第
k轮有 $5(k-1)+1$ 个有效点数和,每个状态固定枚举 6 个点数。- 空间复杂度:$O(n)$。两个滚动数组的长度均为
6n + 1。
关键点总结
- 状态按“点数和”合并,避免枚举 $6^n$ 个投掷序列。
- 转移按最后一个骰子的点数分类,天然保证不重不漏。
- 计数阶段使用整数,最后只做一次概率除法。
- 答案下标与点数和相差固定偏移
n。
易错点总结
- 答案长度应为
5n + 1,不是6n + 1。- 滚动更新必须读
dp、写next,不能在同一数组上正向累加。- 总方案数是 $6^n$;少乘一次 6 会让概率总和变成 6。
- 写答案时要用
ans[sum - n],否则下标整体错位。- 方案数与总数应使用 64 位类型,避免扩展数据范围时溢出。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1155. 掷骰子等于目标和的方法数 | 中等 | 同样的逐骰子递推,但骰子面数 k 可变、只问单个目标和且要求取模 |
| 1230. 抛掷硬币 | 中等 | 每次新增元素只有两个分支且概率不相等,必须在状态里直接累乘概率 |
| 377. 组合总和 Ⅳ | 中等 | 可选数值由数组给定、每个数可无限次使用,转移退化成一维完全背包顺序 |