CS-Notes 剑指 Offer 60:n 个骰子的点数和概率分布——动态规划与滚动数组空间优化
本篇是 CS-Notes 剑指 Offer 题解(动态规划分类)中第 60 题的完整解析:把 n 个骰子扔在地上,求点数和为 s 的概率。读完本篇,你将掌握该题的状态定义与状态转移方程的推导、O(N²) 空间二维 DP 与 O(N) 空间滚动数组两种实现,以及概率换算、整数溢出与整除截断等实战坑点,能独立完成面试中"概率型 DP"类题目的建模与编码。
1. 题目与问题的数学结构
题目原文(见 notes/60. n 个骰子的点数.md):
把 n 个骰子扔在地上,求点数和为 s 的概率。
原题收录于 Lintcode 的 Dices Sum 问题(LeetCode 剑指 Offer 60 / LCOF 60 亦为同一题)。要求返回点数和从 n 到 6n 的每一个取值对应的概率,返回结构为 List<Map.Entry<Integer, Double>>:key 是点数和,value 是该点数和出现的概率。
在写代码之前,先明确几个决定状态空间的数学事实:
- 点数和的范围:每个骰子最小点数为 1、最大点数为 6,因此 n 个骰子的点数和最小为
n,最大为6 * n,中间每个整数取值都可能出现,共5n + 1个合法点数和; - 样本空间:n 个骰子共有
6^n种等可能结果,因此点数和 s 的概率 = 「点数和为 s 的组合数」÷6^n; - 分布形态:随着 n 增大,点数和的概率分布逐渐呈钟形,向期望值
3.5n集中(面试中可补充说明这是中心极限定理的直观体现)。
所以解题的关键归结为:如何高效求出 n 个骰子点数和恰好为 j 的组合数 count(j)。这正是典型的二维递推问题。
2. 解法一:二维数组 DP(空间 O(N²))
2.1 状态定义与转移方程
原文档给出的状态定义:使用二维数组 dp 存储点数出现的次数,dp[i][j] 表示前 i 个骰子产生点数 j 的次数。
状态转移只需考虑第 i 个骰子的点数 k(k ∈ [1, 6]):前 i 个骰子凑出点数 j,等价于前 i-1 个骰子凑出点数 j-k,再让第 i 个骰子掷出 k。于是:
dp[i][j] = Σ dp[i-1][j-k] (k = 1..6,且 j-k >= 0)
边界条件:只有 1 个骰子时,点数 1~6 各出现一次,即 dp[1][1] = dp[1][2] = ... = dp[1][6] = 1。
2.2 完整代码(源自原笔记)
public List<Map.Entry<Integer, Double>> dicesSum(int n) {
final int face = 6;
final int pointNum = face * n;
long[][] dp = new long[n + 1][pointNum + 1];
for (int i = 1; i <= face; i++)
dp[1][i] = 1;
for (int i = 2; i <= n; i++)
for (int j = i; j <= pointNum; j++) /* 使用 i 个骰子最小点数为 i */
for (int k = 1; k <= face && k <= j; k++)
dp[i][j] += dp[i - 1][j - k];
final double totalNum = Math.pow(6, n);
List<Map.Entry<Integer, Double>> ret = new ArrayList<>();
for (int i = n; i <= pointNum; i++)
ret.add(new AbstractMap.SimpleEntry<>(i, dp[n][i] / totalNum));
return ret;
}
代码细节说明:
pointNum = face * n即最大点数和 6n,第二维开到pointNum + 1,索引直接对应点数取值,不存在无用的稀疏空间;- 内层
for (int j = i; ...)从i开始而非0,因为"用 i 个骰子最小点数为 i",j < i的状态恒为 0,直接跳过减少无效计算; - 计数用
long类型:以 int 计数的话,n 较大时组合数会溢出(见第 5 节); - 最后统一换算概率:概率 =
dp[n][i] / totalNum,其中totalNum = 6^n。注意原代码中dp[n][i]是long,除以double的totalNum会自动完成浮点除法,不会发生整除截断。
2.3 复杂度
- 时间:状态数为
(n+1) × (6n+1),每个状态枚举最多 6 个 k,即 O(6n²); - 空间:原文档标注为 O(N²),即整个
dp表的规模。
3. 解法二:滚动数组(空间 O(N))
3.1 优化原理
观察转移方程 dp[i][j] 只依赖上一层 dp[i-1][*],不依赖 dp[i-2][*] 及更早的层。因此只需保留相邻两层即可,用 long[2][pointNum + 1] 代替 long[n+1][pointNum+1]:
- 用
flag作为"旋转标记":第 i 层写在dp[flag],读取上一层dp[1 - flag]; - 每轮迭代开始时对
dp[flag]整行清零,避免残留上一轮(该数组上一次被写入的是 i-2 层的数据); i从 2 到 n,每轮末尾flag = 1 - flag翻转指向。
循环结束后,最后一层数据落在 dp[1 - flag](注意不是 dp[flag]——最后一次翻转后 flag 指向的是待清空的空层)。
3.2 完整代码(源自原笔记)
public List<Map.Entry<Integer, Double>> dicesSum(int n) {
final int face = 6;
final int pointNum = face * n;
long[][] dp = new long[2][pointNum + 1];
for (int i = 1; i <= face; i++)
dp[0][i] = 1;
int flag = 1; /* 旋转标记 */
for (int i = 2; i <= n; i++, flag = 1 - flag) {
for (int j = 0; j <= pointNum; j++)
dp[flag][j] = 0; /* 旋转数组清零 */
for (int j = i; j <= pointNum; j++)
for (int k = 1; k <= face && k <= j; k++)
dp[flag][j] += dp[1 - flag][j - k];
}
final double totalNum = Math.pow(6, n);
List<Map.Entry<Integer, Double>> ret = new ArrayList<>();
for (int i = n; i <= pointNum; i++)
ret.add(new AbstractMap.SimpleEntry<>(i, dp[1 - flag][i] / totalNum));
return ret;
}
一个值得注意的边界情况:n = 1 时外层 for 循环一次都不执行,flag 保持初值 1,dp[1 - flag] 即 dp[0]——恰好是初始化时写入单骰子边界的那一层,结果依然正确,无需特判。
3.3 复杂度
- 时间:O(6n²),与解法一相同(多了一次 O(6n) 的清零,不改变量级);
- 空间:O(N),原文档标注的 O(N) 即
long[2][6n+1]的规模,相比 O(N²) 显著下降。
4. 两种解法对比
| 维度 | 二维数组 DP | 滚动数组 DP |
|---|---|---|
| 状态 | dp[i][j]:前 i 个骰子点数和为 j 的次数 |
同左,仅保留相邻两层 |
| 空间复杂度 | O(N²) | O(N) |
| 时间复杂度 | O(6n²) | O(6n²) |
| 额外操作 | 无 | 每轮对当前层整行清零,注意最终读取层为 dp[1 - flag] |
| 适用场景 | 需要回溯每层中间结果、教学讲解 | 面试默认选择:空间最优且不易出错 |
滚动数组写法是"二维 DP 降维"的通用技巧,本仓库同一分类的 47. 礼物的最大价值 同样把按行递推的 DP 压缩成了一维数组;而 10.1 斐波那契数列、42. 连续子数组的最大和 等题则展示了滚动变量(把两层进一步压缩为两个变量)的极限形态。骰子这道题的滚动数组保留了"层"的结构,是最易理解的中间形态。
5. 实现要点与常见坑
- 计数溢出:n 个骰子点数和的组合数增长极快,
dp计数必须用long(原代码即如此)。若用int,n 稍大就会溢出为负数,导致概率为负。 - 整除截断:概率换算必须保证浮点除法。
dp[n][i] / totalNum中totalNum是double(Math.pow(6, n)返回 double),自动触发浮点除法;若自行改用整型6^n计算总数,则必须显式写dp[n][i] * 1.0 / totalNum。 - 内层起点是 i 而不是 1:
j从i(i 个骰子的最小和)开始,既正确又省掉了必然为 0 的状态;同时k <= j的约束保证j - k >= 0,不会越界访问负下标。 - 滚动数组的清零与读取层:不清零会混入 i-2 层的旧值;结束时读取层是
dp[1 - flag]而非dp[flag],这是滚动写法最常见的两处失分点。 - 概率归一化验证:所有点数和的概率之和应恰好等于 1,可作为单测断言快速验证实现正确性。
6. 总结
"n 个骰子的点数"是概率型 DP 的代表题:状态 dp[i][j] = 前 i 个骰子点数和为 j 的次数,转移枚举最后一颗骰子的 6 种点数,最后用 dp[n][j] / 6^n 统一换算概率;实现上优先使用滚动数组把空间压到 O(N)。本题与 剑指 Offer 题解 - 目录 中动态规划分类的其他题目共享同一套"定义状态 → 推导转移 → 降维省空间"的解题框架,建议结合该目录一并练习。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
