目录
问题描述
解决问题
①蛮力法
②备忘录
③递归改递推
④二分法
⑤逆向思维
问题描述
给定一定楼层数的建筑物和一定数量的鸡蛋,求出可以找出门槛楼层的最少鸡蛋掉落实验的次数,约束条件是:幸存的鸡蛋可以重复使用,破碎的鸡蛋不能再次使用,如果鸡蛋从此层掉落会碎,那么从更高的楼层掉落也会碎,如果鸡蛋从此层掉落不会碎,那么从更低的楼层掉落也不会碎。
解决问题
我们先来看只有一个鸡蛋的情况,如图1所示,由于鸡蛋会破碎不可再次使用,所以我们只能从最低的楼层开始扔鸡蛋,如果在第一层楼扔鸡蛋没有碎,那么继续往上也就是第二层扔鸡蛋,如果鸡蛋在某一层扔碎了,那么说明门槛楼层就是这一层。这样要找出门槛楼层的最少扔鸡蛋的次数就是楼层的层数。
图1 只有一个鸡蛋的情况
那如果我们有足够多的鸡蛋,即鸡蛋数大于等于楼层数,如图2所示,我们可以先随便在一个楼层扔一个鸡蛋看看会发生什么。
图2 有足够多的鸡蛋
比如说我们选择在3楼扔出一个鸡蛋,如图3所示。
图3 在3楼扔出一个鸡蛋
那么事实是扔出的鸡蛋会有两种结果,一种情况是鸡蛋在3楼扔下破碎了,那么说明我们要找的门槛楼层在3楼以下的楼层,如图4所示,同时我们可以用的鸡蛋减少了一个,即问题的规模减少。
图4 鸡蛋破碎的情况
还有一种情况是鸡蛋从3楼扔下去没碎,那么我们刚刚扔下去的鸡蛋还可以再重复使用,也就是可用的鸡蛋数没有减少,同时说明我们要找的门槛楼层在3楼以上,如图5所示,这样问题的规模也减小了。
图5 鸡蛋没碎的情况
我们记Time[egg][height]为egg个鸡蛋和height高的楼层要找出门槛楼层所需要的扔鸡蛋的次数,通过刚刚的分析,我们知道要求解Time[egg][height]可以先在high层扔一次鸡蛋,那么Time[egg][height]的值就应该为Time[egg-1] [high-1](鸡蛋碎了,鸡蛋数减少一个,楼层数减少一层)和Time[egg][height-high](鸡蛋没碎,鸡蛋数没变,楼层往上)的较大值加一(因为我们扔了一次鸡蛋),所以可以得出下面的状态转移方程:
Time[egg][height]=1+max (Time[egg-1] [high-1], Time[egg][height-high])
通过上面的分析,我们可以知道,要求解Time[egg][height]可以通过求解其子问题Time[egg-1] [high-1]和Time[egg][height-high]来实现。
因为我们要找出最少的扔鸡蛋的次数,所以要在所有楼层扔鸡蛋的情况中寻找最小值,更准确的状态转移方程应该是下面这个:
①蛮力法
蛮力法即直接枚举所有楼层扔鸡蛋的情况,如图6所示,把在每一层楼扔鸡蛋的情况都计算一遍,找出其中的最小值。
图6 蛮力法枚举所有情况
采用纯递归的方法,可以对所有可能的楼层进行遍历,计算在这一层扔鸡蛋所需的最小尝试次数。具体地,对于第i层楼,需要递归调用函数,在第i层扔鸡蛋和在第i层楼上方继续搜索两种情况下,所需的尝试次数取二者中的最大者,再加上一次实际的尝试机会,即可得到在第i层楼扔鸡蛋的最小尝试次数。
纯递归的时间复杂度非常高,随楼层数的增加指数增长,随鸡蛋数的增加线性增长。
C++代码
//
// Created by YEZI on 2023/5/15.
//
#ifndef EGGDROP_BRUTE_H
#define EGGDROP_BRUTE_H
#include<iostream>
namespace brute{
int superEggDrop(int egg,int height){
if(egg==1||height<=2)
return height;
int minTimes=height;
for(int i=1;i<=height;i++){
minTimes=std::min(minTimes,std::max(superEggDrop(egg,i-1), superEggDrop(egg-1,height-i))+1);
}
return minTimes;
}
}
#endif //EGGDROP_BRUTE_H
测试
我们先在LeetCode上提交代码进行测试,测试结果如图7所示,可见蛮力法通过了32个测试样例,验证了算法的正确性,但是在鸡蛋数为3、楼层数为26的时候超出了时间的限制。
图7 LeetCode蛮力法测试
我们固定楼层数为20层,测试不同鸡蛋数的结果如图8所示,符合线性增长的预测。
图8 蛮力法 固定楼层数
具体数据如表1所示。
表1 蛮力法 固定楼层数
固定鸡蛋的个数为10个,测试不同楼层的结果如图9所示,符合指数增长。
图9 蛮力法 固定鸡蛋数
具体数据如表2所示。
表2 蛮力法 固定鸡蛋数
结果分析
由结果可知,纯递归的暴力枚举的执行效率比较差,速度非常慢,由于没有将每个子问题的解记录下来,每次都需要重新计算子问题的解,重复计算大大增加,加上递归调用函数的开销也很大,其计算的时间效率随楼层数的增加呈指数增长,随鸡蛋数的增加线性增长,
因此我们需要对算法进行优化。
②备忘录
纯递归的暴力枚举重复计算了子问题的解,因此我们可以开辟二维数组将计算过程的子问题的解记录下来,这样将不再需要重新计算子问题的解,时间复杂度为O(egg*height^2),空间复杂度为O(egg*height)。
C++代码
//
// Created by YEZI on 2023/5/15.
//
#ifndef EGGDROP_DYNAMICPROGRAM_H
#define EGGDROP_DYNAMICPROGRAM_H
#include<iostream>
namespace dynamicProgram{
int **dp;
int Drop(int egg,int height){
if(dp[egg][height]!=-1){
return dp[egg][height];
}
if(egg==1||height<=2){
dp[egg][height]=height;
return height;
}
int minTimes=height;
for(int i=1;i<=height;i++){
minTimes=std::min(minTimes,std::max(Drop(egg,i-1), Drop(egg-1,height-i))+1);
}
dp[egg][height]=minTimes;
return minTimes;
}
int superEggDrop(int egg,int height){
dp=new int*[egg+1];
for(int i=1;i<=egg;i++){
dp[i]=new int[height+1];
}
for(int i=1;i<=egg;i++){
for(int j=0;j<=height;j++){
dp[i][j]=-1;
}
}
return Drop(egg,height);
}
}
#endif //EGGDROP_DYNAMICPROGRAM_H
测试
我们先在LeetCode上提交代码进行测试,测试结果如图10所示,可见备忘录法通过了67个测试样例,比先前的通过个数更多了,验证了算法的正确性,但是在鸡蛋数为5、楼层数为2000的时候超出了时间的限制。
图10 LeetCode备忘录测试
固定楼层数为500层,测试不同鸡蛋个数的结果如图11所示,符合线性增长。
图11 备忘录 固定楼层数
具体数据如表3所示。
表3 备忘录 固定楼层数
固定鸡蛋的个数为10个,测试不同楼层的结果如图12所示,符合平方增长。
图12 备忘录 固定鸡蛋数
具体数据如表4所示。
表4 备忘录 固定鸡蛋数
结果分析
由结果可以看出,与之前的暴力枚举相比,我们将每个子问题的解记录下来的优化效果十分明显,可以测试的数据规模明显增长,但是我们仍然没有在规定时间内通过LeetCode上的所有测试用例,我们还需要继续优化算法。
③递归改递推
我们先前两个策略都是采用递归调用函数实现的,反复递归调用函数的开销很大,因此我们在备忘录的基础上将递归调用函数改为循环内递推,时间复杂度和空间复杂度和备忘录相比没有变化,但理论上递推执行起来会更快。
C++代码
//
// Created by YEZI on 2023/5/21.
//
#ifndef MAIN_CPP_ITERATIVEDP_H
#define MAIN_CPP_ITERATIVEDP_H
#include<iostream>
namespace iterativeDP{
int superEggDrop(int egg,int height){
int **dp=new int*[egg+1];
for(int i=1;i<=egg;i++){
dp[i]=new int[height+1];
dp[i][0]=0;
dp[i][1]=1;
}
for(int i=1;i<=height;i++){
dp[1][i]=i;
}
for(int i=2;i<=egg;i++){
for(int j=2;j<=height;j++){
dp[i][j]=height;
for(int k=1;k<=j;k++){
dp[i][j]=std::min(dp[i][j],std::max(dp[i-1][k-1],dp[i][j-k])+1);
}
}
}
return dp[egg][height];
}
}
#endif //MAIN_CPP_ITERATIVEDP_H
测试
我们先在LeetCode上提交代码进行测试,测试结果如图13所示,可见递推法通过了74个测试样例,与之前的相比通过的个数更多了,验证了算法的正确性,但是在鸡蛋数为6、楼层数为10000的时候还是超出了时间的限制。
图13 LeetCode 递推测试
固定楼层数为500层,测试不同鸡蛋个数的结果如图14所示,符合线性增长的预测。
图14 递推固定楼层数
具体数据如表5所示。
表5 递推固定楼层数
固定鸡蛋个数为10个,测试不同楼层数的结果如图15所示。
图15 递推固定鸡蛋数
具体数据如表6所示。
表6 递推固定鸡蛋数
结果分析
由结果可以看出来,递推法相比备忘录的速度更快了,但是还是没有在规定时间内通过LeetCode上的所有测试用例,我们还得继续努力。
④二分法
先前我们是暴力枚举了所有楼层扔鸡蛋的情况去找最小的测试次数,但事实上我们可能并不需要把每一个情况都计算一次。我们通过求解子问题Time[egg-1][high-1](鸡蛋破碎的情况,可用的鸡蛋个数减一,楼层数减一)和子问题Time[egg][height-high](鸡蛋没碎的情况,楼层数减少),我们要求二者的较小值,而从数学上,Time[egg-1][high-1]是high单调递增的函数,Time[egg][height-high]是high单调递减的函数,如图16所示,我们可以通过二分法找到满足要求的high值,从而将把时间复杂度从原本的O(egg*height^2)缩小为O(egg*height*log(height))。
图16 二分法
C++代码
//
// Created by YEZI on 2023/5/21.
//
#ifndef MAIN_CPP_DICHOTOMY_H
#define MAIN_CPP_DICHOTOMY_H
#include<iostream>
namespace dichotomy {
int superEggDrop(int egg, int height) {
int **dp = new int *[egg + 1];
for (int i = 1; i <= egg; i++) {
dp[i] = new int[height + 1];
dp[i][0] = 0;
dp[i][1] = 1;
}
for (int i = 1; i <= height; i++) {
dp[1][i] = i;
}
for (int i = 2; i <= egg; i++) {
for (int j = 2; j <= height; j++) {
int low = 1;
int high = j;
while (low < high) {
int mid = (low + high + 1) / 2;
if (dp[i - 1][mid - 1] <= dp[i][j - mid]) {
low = mid;
} else {
high = mid - 1;
}
}
dp[i][j] = std::max(dp[i - 1][low - 1], dp[i][j - low]) + 1;
}
}
return dp[egg][height];
}
}
#endif //MAIN_CPP_DICHOTOMY_H
测试
我们先在LeetCode上提交代码进行测试,测试结果如图17所示,可见二分法优化的效果非常明显,终于通过了所有的测试用例,耗时324ms,内存消耗25.4MB。
图17 LeetCode 二分法测试
固定楼层数为10000层,测试不同鸡蛋个数的结果如图18所示,符合线性增长的预测。
图18 二分法 固定楼层数
具体数据如表7所示。
表7 二分法 固定楼层数
固定鸡蛋个数为10个,测试不同楼层数的结果如图19所示,符合对数增长的预测。
图19 二分法 固定鸡蛋数
具体数据如表8所示。
表8 二分法 固定鸡蛋数
结果分析
由结果可知,二分优化的效果非常明显,无论是执行速率还是可以处理的数据规模和之前的相比都有非常大的提升。
⑤逆向思维
我们可以将问题反过来想,对于给定time次尝试,egg个鸡蛋,我们可以测出多少层。我们定义high[egg]为egg个鸡蛋可以测出的层数,那么每一次尝试中,egg个鸡蛋可以测试出的层数就应该等于上一次尝试中egg个鸡蛋可以测出的层数加上本次egg个鸡蛋可能测出的层数,即:
high[egg]=1+high[egg]+high[egg-1]
而对于要解决鸡蛋掉落问题,我们只需要一次一次的尝试,直到high[egg]大于等于所给定的楼层数即可。
这个算法的空间复杂度为O(egg),时间复杂度为O(egg*height),事实上,更确切的时间复杂度为。
C++代码
//
// Created by YEZI on 2023/5/21.
//
#ifndef MAIN_CPP_BACKWARD_H
#define MAIN_CPP_BACKWARD_H
namespace backward{
int superEggDrop(int egg,int height){
int*high=new int[egg+1];
for(int i=0;i<=egg;i++){
high[i]=0;
}
int times=0;
while(high[egg]<height){
times++;
for(int i=egg;i>0;i--){
high[i]=high[i]+high[i-1]+1;
}
}
return times;
}
}
#endif //MAIN_CPP_BACKWARD_H
测试
我们先在LeetCode上提交代码进行测试,测试结果如图20所示,逆向思维方法通过了所有的测试用例,而且更快,耗时0ms,内存消耗5.9MB。
图20 LeetCode逆向思维测试
固定楼层数为一百万层,测试不同鸡蛋个数的结果如图21所示,符合线性增长的预测。
图21 逆向思维法 固定楼层数
具体数据如表9所示。
表9 逆向思维法 固定楼层数
固定鸡蛋数为1000000个,测试不同楼层数的结果如图22所示,符合预测。
图22 逆向思维法 固定鸡蛋个数
具体数据如表10所示。
表10 逆向思维法 固定鸡蛋个数
结果分析
由结果可以看出,该优化策略效果非常明显,运行速度非常之快,能够处理的规模非常大,百万级别的数据也可以在毫秒级别解决。