nc87. 丢棋子问题
1. 暴力搜索 (不合要求, TLE)
令f(n, k) 表示n层楼, k个棋子, 在最差情况下的最少实验次数.
f(n,k) 有下列性质:
- 若n=0, 地板上肯定不碎, 不需要任何实验, 故f=0.
- 若k=1, 只有1个棋子必须逐层操作,在最差情况下第n层楼才摔碎, 需要n次实验. 故f=n.
- 其他情况, 考虑第一枚棋子是在第j层扔下去的, 有两种情况:
- 碎了, 说明第j层以上必定还碎, 所以剩下了k-1枚棋子和j-1层楼, 剩下的实验次数为
f(j-1, k-1), 且进行了一次实验. - 没碎, 说明j层及j层以下必定还不碎, 所以剩下了k枚棋子(注意!!!这一枚棋子还是能用的!!!) 和n-j层楼, 剩下的实验次数为
f(n-j, k), 且进行了一次实验.
而我们不知道第j层会不会碎, 题目要求的是最差情况下的次数, 所以要考虑最坏情况, 也就是以上两种情况的最大值, 且j的取值是不确定的, 取值范围是[1, n]. 因此, 我们要枚举j, 计算j的所有取值中最小的那一个, 才能代表最少实验次数.
实现: (不合要求, 会tle)
class Solution {
public:
int solve(int n, int k) {
if (n == 0) return 0;
if (k == 1) return n;
int res = INT_MAX;
for (int j = 1; j <= n; j++) {
res = min(res, 1+max(solve(j-1, k-1), solve(n-j, k)));
}
return res;
}
};- 时间复杂度: O(n!), 因为每次问题规模只减少1, 却要循环n次, 得出关系式
T(n)=nT(n-1), 故是阶乘复杂度. - 空间复杂度: O(1), 没有额外空间.
2. 动态规划(不合要求, TLE)
根据上面的关系式可知, 问题具有动态规划的转移性质, 因此可以通过动态规划实现.
- 转移关系定义:
dp[n][k]表示n层楼, k个棋子, 在最差情况下的最少实验次数. - 初值:
dp[0][k] = 0, dp[i][1] = i - 最终答案:
dp[n][k]. - 转移方程:
dp[i][j] = min(1+max(dp[k-1][j-1], dp[i-k][j]))
实现: (不合要求, sf)
class Solution {
public:
// 转移方程: `dp[i][j] = min(1+max(dp[k-1][j-1], dp[i-k][j]))`
static const int N = 1e6 + 5;
int dp[N][N];
int solve(int n, int k) {
if (n == 0) return 0;
if (k == 1) return n;
for (int i = 1; i < N; i++) dp[i][1] = i;
for (int i = 1; i < N; i++) {
for (int j = 2; j <= k; j++) {
dp[i][j] = INT_MAX;
for (int p = 1; p <= i; p++) {
dp[i][j] = min(dp[i][j], 1+max(dp[p-1][j-1], dp[i-p][j]));
}
}
}
return dp[n][k];
}
};- 时间复杂度: O(n^2*k), 题目中nk都是1e6, 所以不合要求.
- 空间复杂度: O(nk), 考虑转移式中j这个维度只跟j-1有关, 故可以压缩掉一维, 空间复杂度最多可以做到O(n).
3. 反向思维的动态规划
我们换一种思路. 方法二的dp方程的目标值是基于最少实验次数, 我们反向思考之, dp方程将会变得很简单.
令dp[i][j] 表示i个棋子扔j次, 最多能测多少层. 考虑第i个棋子在最优的楼层扔下去, 有两种情况:
- 碎了, 那么上面就不用测了, 下面能测的是
dp[i-1][j-1]层 - 没碎, 那么下面就不用测了, 上面能测
dp[i][j-1]层 - 第i个棋子扔掉的那一层也能测.
综上, 考虑最差的情形, 可以得到转移式:
dp[i][j] = dp[i-1][j-1] + dp[i][j-1] + 1
显然dp[i][j]是单调递增的, 那么, 我们要求的答案就是, 在i不超过k的情况下, 使得dp[i][j] >= n的最小的j.
根据以上分析, 我们的dp关系可以总结如下:
- 转移关系定义:
dp[i][j]表示i个棋子扔j次, 最多能测多少层. - 初值:
dp[i][1] = 1, 不管有几个棋子, 扔1次只能测1层. - 最终答案:
i不超过k时候, 使得dp[i][j] >= n的最小的j. - 转移方程:
dp[i][j] = dp[i-1][j-1] + dp[i][j-1] + 1
这里有一个性质, 如果k充分大, 我们可以通过二分的方式, 至多扔log2(n)+1就能确定, 因此k如果大于该值, 直接返回log2(n)+1即可.
另外要注意的是,我们发现, dp[i][j]只和上面元素和左上面元素有关系, 故可以压缩一维,并且需要逆向计算。
这个逻辑可以用图描述如下
用粉色表示dp[i][j], 黄色表示dp[i-1][j], 在计算dp[i][j]的时候,先从右侧开始计算,算到i的时候,一开始dp[i]处为黄色, 存储的是上一次迭代的结果dp[i][j-1], i-1处也为黄色,因此dp[i] = dp[i] + dp[i-1] + 1就等价于dp[i][j] = dp[i-1][j-1] + dp[i][j-1] + 1. 如果反了, 那么算到i的时候, i-1处就变成了粉色,转移方程就相当于dp[i][j] = dp[i-1][j] + dp[i][j-1] + 1, 导致wa。
类似可以参考01背包和完全背包中代码逻辑的区别, 在此不再赘述。
实现代码:
class Solution {
public:
// dp[i][j] = dp[i-1][j-1] + dp[i][j-1] + 1
static const int N = 1e6+5;
int dp[N];
int solve(int n, int k) {
if (n == 0) return 0;
if (k == 1) return n;
// 优化性质, 如果k充分大, 二分的扔即可
int b = log2(n) + 1;
if (k >= b) return b;
// 初始值, 不管有几个棋子, 扔1次只能测1层
for (int i = 0; i < N; i++) dp[i] = 1;
// 最少也得扔1次
int res = 1;
while(1) {
res++; // 每一轮迭代, j增加1次
// 转移方程:
// dp[i][j] = dp[i-1][j-1] + dp[i][j-1] + 1
// 压缩后:
// dp[i] = dp[i] + dp[i-1] + 1
// 因为dp[i][j] 依赖的是左边和左上, 为了防止覆盖, 需要倒着计算
// 参考01背包问题的解题思路
for (int i = k; i >= 2; i--) {
dp[i] = dp[i] + dp[i-1] + 1;
if (dp[i] >= n) return res;
}
dp[1] = res; // dp[1][j] = j
}
}
};- 时间复杂度: O(klogn). 代码中的外层while最多迭代logn轮, 否则可以使用二分扔棋子的方式解决, 内层循环是k次, 故总的时间复杂度最坏为O(klogn).
- 空间复杂度: O(n), 开了一个长度为n 的dp数组.

京公网安备 11010502036488号