- 掌握动态规划算法设计思想。
- 掌握鸡蛋坠落问题的动态规划解法。
-
实验内容与结果
题目描述:
动态规划将问题划分为更小的子问题,通过子问题的最优解来重构原问题的最优解。动态规划中的子问题的最优解存储在一些数据结构中,这样我们就不必在再次需要时重新处理它们。任何重复调用相同输入的递归解决方案,我们都可以使用动态规划对其进行优化。
鸡蛋掉落问题是理解动态规划如何实现最佳解决方案的一个很好的例子。问题描述如下:
我们需要用鸡蛋确认在多高的楼层鸡蛋落下来会破碎,这个刚刚使鸡蛋破碎的楼层叫门槛层,门槛楼层是鸡蛋开始破碎的楼层,上面所有楼层的鸡蛋也都破了。另外,如果鸡蛋从门槛楼层以下的任何楼层掉落,它都不会破碎。如上图所示,如果有 5 层,我们只有1个鸡蛋,要找到门槛层,则必须尝试从每一层一层一层地放下鸡蛋,从第一层到最后一层,如果门槛层是第 k 层,那么鸡蛋就会在第 k 层抛下时破裂,应该做了k次试验。
注意:我们不能随机选择任何楼层,例如,如果我们选择 4 楼并放下鸡蛋并且它打破了,那么它不确定它是否也从 3 楼打破。 因此,我们无法找到门槛层,因为鸡蛋一旦破碎,就无法再次使用。
给定建筑物的一定数量的楼层(比如 f 层)和一定数量的鸡蛋(比如 e 鸡蛋),找出阈值地板必须执行的最少的鸡蛋掉落试验的次数,注意,这里需要求的是试验的测试,不是鸡蛋的个数。还要记住的一件事是,我们寻找的是找到门槛层所需的最少鸡蛋掉落试验次数,而不是门槛层下限本身。
问题约束条件:
- 从跌落中幸存下来的鸡蛋可以再次使用。
- 破蛋必须丢弃。
- 摔碎对所有鸡蛋的影响都是一样的。
- 如果一个鸡蛋掉在地上摔碎了,那么它从高处掉下来也会摔碎。
- 如果一个鸡蛋在跌落中幸存下来,那么它在较短的跌落中也能完整保留下来。
解题思路:
这道题目的题意不是很容易理解,我们先把题目简化一下,忽略一些限制条件,理解简单情况下的题意。然后再一步步增加限制条件,从而弄明白这道题目的意思,以及思考清楚这道题的解题思路。
假设条件:
我们先忽略K个鸡蛋这一条件,假设有无限个鸡蛋。现在有1~N一共N层楼。已知存在楼层F,在低于等于F层的楼层丢下去的鸡蛋都不会碎,在高于F层的楼层丢下去的鸡蛋都会碎。
这个临界楼层具体的F值在题目中没有给出,因此需要我们一次次去测试鸡蛋最高在哪一层掉落下来的时候不会摔碎。
在每一次操作中,我们可以选定一个楼层将鸡蛋扔下去,此时会出现两种情况:鸡蛋碎了和鸡蛋没碎。
- 如果鸡蛋没碎,则可以用这个鸡蛋对其他楼层进行测试。
- 如果鸡蛋碎了,则这个鸡蛋不能再进行测试,需要使用另一个鸡蛋进行测试。
如果现在的题目要求是:已知有N层楼,有无限个鸡蛋,请问至少需要扔几次鸡蛋,才能保证无论临界层F是多少层,都可以将F找出来?
那么最简单直接的想法就是:从第一层楼开始扔鸡蛋,如果鸡蛋没碎,就到第二层楼扔,如果还没碎,就到第三层楼扔……直到鸡蛋碎了就可以找到临界层F。
用这种遍历的方法,最坏的情况就是鸡蛋在第N层也没摔碎。这种情况我们总共需要尝试N次才能确定临界层F。即时间复杂度为O(N)。
有没有更为简便的方法呢?我们可以把这个问题抽象为在一个大小为N的数组中找到一个特定的值F。除了遍历以外,很容易想到可以使用二分查找的方法。
那么我们可以从1~N层中的中间层开始扔鸡蛋。
- 如果鸡蛋碎了,就到(1~中间层)这个区间内去扔鸡蛋。
- 如果鸡蛋没碎,就到(中间层~N)这个区间去扔鸡蛋。
每次扔鸡蛋都在选定区间的中间层去扔,这样每次都能排除当前区间一半的答案。从而最终确定鸡蛋不会摔碎的临界层F。
通过这种二分查找的方法,可以把搜索次数优化到logN次,此时的时间复杂度为O(logN),比线性查找的次数要少。
回归题目:
对题目有初步了解之后,我们来限制一下鸡蛋的个数为K。
现在题目要求:已知有K个鸡蛋,N层楼,请问至少需要扔几次鸡蛋,才能保证无论F是多少层,都能将F找出来?
如果鸡蛋足够多(大于等于logN个),则可以通过二分查找的方法来测试。但是不保证有这么多个鸡蛋,可能在二分查找的过程中,鸡蛋就不够用了,则不能通过二分查找的方法来测试。
那么这时候为了找出F,我们应该如何求出最少的扔鸡蛋数呢?
蛮力法:
分析:
如果我们尝试在1~N中的任意一层x扔鸡蛋:
- 如果鸡蛋没碎,则说明1~x层就不用考虑了,因为比当前x层更低的层扔鸡蛋肯定不会碎,我们可以用k个鸡蛋去考虑剩下的高N-x层。此时问题就从f(K,N)变成了f(K,N-x)。
- 如果鸡蛋碎了,则说明在高N-x层扔鸡蛋也一定会让鸡蛋破碎,此时可以用剩下的K-1个鸡蛋去测试低x-1层,问题从f(K,x)变成了f(K-1,x-1)。
状态转移方程:
由于门槛层F可能是任意值,因此最小移动次数应该取最坏情况下的最优方案,在对于1 <= x <= N的所有解中,应该取其中最小的一个。因此可以确定状态转移方程为:
该方程表示有i个鸡蛋,j层楼的条件下,为了找出临界层F,最坏情况下的最少扔鸡蛋次数。在1 <= i <= K,1 <= j <= N这个区间内每一对(i,j)对应的f(i,j)的值都是由上一个f(i,j)为基础决定的,因此可以自底向上求得f(K,N)的值。
初始条件:
- 当鸡蛋数为1时,如果唯一的蛋碎了,就无法进行测试了。因此只能从低到高一步步进行测试。
- 当楼层为1时,无论有多少个鸡蛋,都只需要测试一次就可以测出来临界层F。
代码实现:
int brutalEggDrop(int K,int N) { //brutal force
if (K == 1 || N == 1 || N == 0) {
return N;
}
int res = INT_MAX;
for (int i = 1; i <= N; ++i) {
int maxStep = max(brutalEggDrop(K - 1, i - 1), brutalEggDrop(K, N - i)) + 1;
res = min(res, maxStep);
}
return res;
}
复杂度分析:
- 由于对每层楼丢鸡蛋的情况都进行了递归测试,因此该算法的复杂度很高,是指数级的。
- 空间复杂度为O(N×K)
动态规划:
相同的思路,可以用二维数组对每次递归的结果进行保存,这样做的好处是省去了对已知结果的重复计算,提高了算法的时间效率。
状态方程:
代码实现:
int memoryEggDrop(int K, int N) {
if (K == 1 || N == 1 || N == 0) {
return N;
}
vector<vector<int>> cache(K+1, vector<int>(N+1, 0));
for (int i = 1; i <= K; ++i) {
for (int j = 1; j <= N; ++j) {
cache[i][j] = j; //初始化每层为最大尝试次数
}
}
for (int i = 2; i <= K; ++i) {
for (int j = 1; j <= N; ++j) {
int minStep = cache[i][j];
for (int k = 1; k < j; ++k) {
int maxStep = max(cache[i - 1][k - 1], cache[i][j - k]) + 1;
minStep = min(minStep, maxStep);
}
cache[i][j] = minStep;
}
}
return cache[K][N];
}
复杂度分析:
- 时间复杂度由函数中的三重循环决定,因此时间复杂度为O(N²×K)
- 空间复杂度为O(N×K)
动态规划 + 二分:
对上述记忆法的状态转移方程进行分析:
此时我们把和分开单独来看,可以发现:
- 对于:当x增加时,j-x的值减少,的值同样减少。因此可以把当成以x为自变量的单调非递增函数。
- 对于:当x增加时,x-1的值增加,的值同样增加。因此可以把当成以x为自变量的单调非递减函数。
此时可以画图如下所示:
两条函数交点处就是两个函数较大值的最小位置,即所取位置。而这个位置可以通过二分查找找到满足最大的那个x。
代码实现:
int betterEggDrop(int K, int N) {
vector<vector<int>> dp(K+1, vector<int>(N+1, 0));
for (int i = 1; i <= K; ++i) {
for (int j = 1; j <= N; ++j) {
dp[i][j] = j;
}
}
for (int i = 1; i <= K; ++i) {
dp[i][1] = 1;
}
for (int i = 2; i <= K; ++i) {
for (int j = 2; j <= N; ++j) {
int left = 1;
int right = j;
while (left < right) {
int mid = left + (right - left) / 2;
if (dp[i - 1][mid - 1] < dp[i][j - mid]) {
left = mid + 1;
} else {
right = mid;
}
}
dp[i][j] = max(dp[i - 1][left - 1], dp[i][j - left]) + 1;
}
}
return dp[K][N];
}
复杂度分析:
- 时间复杂度为O(NlogN×K)
- 空间复杂度为O(N×K)
动态规划+逆向思维:
再看一下我们的题目:已知有K个鸡蛋,N层楼,请问至少需要扔几次鸡蛋,才能保证无论临界层F是多少层,都可以将F找出来?
我们可以逆向转变一下思维,把题目转化为:已知有K个鸡蛋,最多扔x次鸡蛋,请问最多可以检测出多少层?
这样我们就把扔鸡蛋的次数变为了已知条件,把检测的楼层数变为了未知条件。
如果我们求出来的检测的楼层数大于等于N,则说明1~N层都考虑全了,F的值也就明确了。我们只需要从符合条件的情况中,找出扔鸡蛋次数最少的情况即可。
此时可以定义状态为:此时有i个鸡蛋,扔j次鸡蛋的条件下,最多可以检测出的楼层个数。
状态方程:
- 如果鸡蛋没碎,此时剩下i个鸡蛋,还有j-1次扔鸡蛋的机会,最多可以检测dp[i][j - 1]层楼层。
- 如果鸡蛋碎了,剩下i - 1个鸡蛋,还有j - 1次扔鸡蛋的机会,最多可以检测dp[i - 1][j - 1]层楼层。
- 再加上我们扔鸡蛋的第x层,i个鸡蛋,j次扔鸡蛋的机会最多可以检测dp[i][j - 1] + dp[i - 1][j - 1] + 1层。
则状态转移方程为:
初始条件 :
当鸡蛋数为1时,这时只有1次扔鸡蛋的机会,最多可以检测1层楼,即dp[1][1] = 1。
最终结果 :
根据我们之前定义的状态,dp[i][j] 表示为:一共有 i 个鸡蛋,扔 j 次鸡蛋的条件下,最多可以检测的楼层个数。则我们需要从满足 i ==K并且 dp[i][j] >= N(即 k 个鸡蛋,j 次扔鸡蛋,一共检测出 N层楼)的情况中,找出最小的 j,将其返回即可。
代码实现:
int superEggDrop(int K, int N) {
vector<vector<int>> dp(K+1, vector<int>(N+1, 0));
dp[1][1] = 1;
for (int i = 1; i <= K; i++) {
for (int j = 1; j <= N; j++) {
dp[i][j] = dp[i][j - 1] + dp[i - 1][j - 1] + 1;
if (i == K && dp[i][j] >= N) {
return j;
}
}
}
return N;
}
复杂度分析:
- 时间复杂度由两重循环决定,则时间复杂度为O(N×K)
- 空间复杂度为O(N×K)
小数据量测试:
随机产生f,e的值,对小数据模型利用蛮力法测试算法的正确性
在区间(0,20]内生成5组随机数据进行测试:
K | 1 | 2 | 3 | 4 | 5 |
N | 2 | 6 | 14 | 6 | 7 |
最小移动次数 | 2 | 3 | 4 | 3 | 3 |
算法正确性得证。
算法效率测试:
(在以下的算法效率测试中,都对K值进行固定以N的数量级为变量进行测试)
蛮力法:
设想对不同数量级的数据进行测试,但是当对数量级为10²进行测试时,在程序运行了7个多小时后都无法得出结果,因此对较小的数据进行测试:
K | 5 | 6 | 7 | 5 | 9 |
N | 19 | 18 | 18 | 22 | 19 |
运行时间/s | 0.378 | 0.28 | 0.47 | 4.203 | 1.989 |
K | 11 | 9 | 10 | 10 | 13 |
N | 19 | 23 | 23 | 24 | 25 |
运行时间/s | 2.567 | 118.447 | 157.69 | 429.373 | 1646.42 |
以N为X轴,运行时间为Y轴作图如下:
运行时间大致与指数函数的趋势线重合。
动态规划:
对不同数量级的数据进行测试:
数量级 | 10 | 100 | 1000 | 10000 | 100000 |
运行时间/s | 0.001 | 0.001 | 0.007 | 1.052 | 117.874 |
数量级 | 1000000 | 10000000 | |||
运行时间/s | 9031.67 | ? |
当数量级为10^7时,运行了超过7小时后都没有得到结果,因此对较小数量级的数据进行分析。
以N为X轴,运行时间为Y轴作图如下:
根据时间复杂度O(K*N²),除去数量级较小时运行时间差距不大以外,数量级较大时每相差一个数量级时,程序运行时间大致相差一个数量级的平方倍。
根据时间复杂度O(K*N²),实际运行时间与理论运行时间的差距作图如下:(除去数量级较小的数据)
可以看到实际运行效率和理论运行效率大致相同。
根据时间复杂度可以推测当数量级为107时,需要运行903167s(约等于10.45天)后才能得出结果。
动态规划 + 二分
对不同数量级的数据进行测试:
数量级 | 10 | 100 | 1000 | 10000 | 100000 |
运行时间/s | 0.001 | 0.001 | 0.001 | 0.003 | 0.032 |
数量级 | 1000000 | 10000000 | 100000000 | 1000000000 | |
运行时间/s | 0.396 | 4.181 | 46.17 | 544.166 |
以N为X轴,运行时间为Y轴作图如下:
根据时间复杂度O(NlogN*K),实际运行时间与理论运行时间的差距作图如下(除去数量级较小的数据):
可以发现实际运行效率反而比理论运行效率要低,猜测是因为引入了二维矩阵对每种情况进行记忆化,从而大大减少了程序运行时间。
动态规划+逆向思维
对不同数量级的数据进行测试:
数量级 | 10 | 100 | 1000 | 10000 | 100000 |
运行时间/s | 0.001 | 0.001 | 0.001 | 0.002 | 0.007 |
数量级 | 1000000 | 10000000 | 100000000 | 1000000000 | |
运行时间/s | 0.033 | 0.298 | 2.842 | 40.334 |
以N为X轴,运行时间为Y轴作图如下:
根据时间复杂度O(N*K),实际运行时间与理论运行时间的差距作图如下(除去数量级较小的数据):
可以发现理论运行时间与实际运行时间大致相同。
当对数量级为10^10的数据进行测试时,程序直接结束并无数据返回,因此猜测最大能在有限时间内处理完的运行数据的数量级在109。
-
实验总结
通过本次对鸡蛋掉落问题的分析,首先用最简单的蛮力法进行求解,但是该算法时间复杂度过高,数量级仅为10²时就需要很长时间才能计算出结果,因此采用动态规划、二分法、逆向思维等思路对该算法进行了三次优化,最终得到了时间复杂度为线性级的求解算法,大大加深了我对动态规划问题的理解。