目录
一、实验目的与要求
实验目的:
1. 掌握动态规划算法设计思想。
2. 掌握鸡蛋坠落问题的动态规划解法。
实验要求:
1. 给出解决问题的动态规划方程;
2. 随机产生f,e的值,对小数据模型利用蛮力法测试算法的正确性;
3. 随机产生f,e的值,对不同数据规模测试算法效率,并与理论效率进行比对,请提供能处理的数据最大规模,注意要在有限时间内处理完;
4. 该算法是否有效率提高的空间?包括空间效率和时间效率。
二、实验内容与方法
使用暴力递归算法、动态规划算法可以解决鸡蛋掉落问题,并对其进行优化。以下是鸡蛋掉落问题的描述:
我们需要用鸡蛋确认在多高的楼层鸡蛋落下来会破碎,这个刚刚使鸡蛋破碎的楼层叫门槛层,门槛楼层是鸡蛋开始破碎的楼层,上面所有楼层的鸡蛋也都破了。另外,如果鸡蛋从门槛楼层以下的任何楼层掉落,它都不会破碎。如上图所示,如果有 5 层,我们只有1个鸡蛋,要找到门槛层,则必须尝试从每一层一层一层地放下鸡蛋,从第一层到最后一层,如果门槛层是第 k 层,那么鸡蛋就会在第 k 层抛下时破裂,应该做了k次试验。也就是说,如果有k层楼,1一个鸡蛋,最少的实验次数是k次。反之,如果有e个鸡蛋,楼层数是0,则最少试验次数是0,如果有e个鸡蛋,楼层数是1,最少试验次数是1。
如果有6层楼,三个鸡蛋,需要的最少试验次数是3;如果有5层楼,三个鸡蛋,需要的最少试验次数也是3。
我们不能随机选择任何楼层,例如,如果我们选择 4 楼并放下鸡蛋并且它打破了,那么它不确定它是否也从 3 楼打破。 因此,我们无法找到门槛层,因为鸡蛋一旦破碎,就无法再次使用。
给定建筑物的一定数量的楼层(比如 f 层)和一定数量的鸡蛋(比如 e 鸡蛋),找出阈值地板必须执行的最少的鸡蛋掉落试验的次数,
注意:我们寻找的是“一定能找到门槛层”所需的最少掉落试验次数(对于某个鸡蛋与楼层的组合,这个次数是固定的),而不是门槛层本身。
问题约束条件:
1. 从跌落中幸存下来的鸡蛋可以再次使用。
2. 破蛋必须丢弃。
3. 摔碎对所有鸡蛋的影响都是一样的。
4. 如果一个鸡蛋掉在地上摔碎了,那么它从高处掉下来也会摔碎。
5 .如果一个鸡蛋在跌落中幸存下来,那么它在较短的跌落中也能完整保留下来。
三、实验步骤与过程
(一)问题分析:
首先需要了解动态规划解决的问题具有的三个性质:最优子结构性质、重叠子问题性质和无后效性。以下是这三个性质的简要内容:
1.最优子结构性质:指的是一个问题的最优解包含其子问题的最优解。
2.重叠子问题性质:指的是在求解问题的过程中,有大量的子问题是重复的,一个子问题在下一阶段的决策中可能会被多次用到。如果有大量重复的子问题,那么只需要对其求解一次,然后将结果存储下来,以后使用时可以直接查询,不需要再次求解。需要注意,此性质并不是动态规划求解的必要条件,但如果问题具有此性质,动态优化将会拥有远超递归算法的效率。
3.无后效性:指的是子问题的解(状态值)只与之前阶段有关,而与后面阶段无关。当前阶段的若干状态值一旦确定,就不再改变,不会再受到后续阶段决策的影响。
本问题兼具三个性质:
对于性质一,我们可以发现鸡蛋碎与不碎均可以包含前面状态的最优解(详见后续分析),只需要多加一步扔鸡蛋的操作即可。
对于性质二,我们发现鸡蛋类似于斐波那契数列,在计算过程中会不断地涉及到鸡蛋最少,层数最小的情况,与其使用递归算法,从小到大每次算都要从头递归;不如用动态规划算法直接存储已经解决的子问题的答案。
对于性质三,鸡蛋多的层数高的解并不会反过来影响鸡蛋少层数低的解。故本实验可以使用动态规划算法。
接下来分析本问题的解:
首先考虑特殊情况:如果只有一层楼,无论有多少个鸡蛋都需要一次实验次数;如果只有一个鸡蛋,无论有多少层楼都需要等同于楼层的实验次数。
接下来从子问题已解决的角度分析当前的问题:
当前剩余e个鸡蛋,楼层有f层(楼层的范围为1~f)。接下来进行“丢鸡蛋”的操作,我们可以将鸡蛋从任意一个楼层丢下(由于门槛层可出现在任何一层,我们需要遍历所有的楼层依次丢下鸡蛋),设当前从x楼丢下鸡蛋。
而每一次丢下鸡蛋都有两种可能:碎了,或者没碎。如果鸡蛋碎了,当前问题就将转化为:e-1个鸡蛋,门槛层范围可确定为1~x层,但是,此处由于已知x层会碎,因此只需要讨论1~x-1层的结果(也就是有x-1层楼);如果鸡蛋没碎,当前问题转化为:e个鸡蛋,楼层范围为x+1~f(也即有f-x层楼)。
由上,本问题已经拆解为两个子问题的解,这两种情况都是可能发生的,我们需要讨论最坏情况需要的实验次数,因此我们取其中的较大值+1(本次丢鸡蛋),作为当前找到的一个解。而尝试从每一层楼丢下鸡蛋,找到的最小解即为本问题的最优解!
(二)编写代码:
1. 递归算法(蛮力法):
根据上文的描述,即可直接写出对应的递归代码:
图:递归代码
程序过程中讨论了一个特殊情况:层数范围为0的情况,此时已经确定了门槛层为0,故可直接返回。
接下来使用循环,尝试从每一层丢下鸡蛋,并取其中的最优解(试验次数最少的丢法)即可。
2. 朴素动态规划:
在上述递归算法的基础上,尝试将过程中的每一个解存储下来,并使用循环的方式代替递归,构成动态规划算法。令dp(e,f)表示鸡蛋数为e,层数为f的解,可得动态规划方程如下:
而由于使用数组保存状态,特殊情况的处理稍有不同:
图:初始化部分
根据上图代码,将上述的两种特殊情况进行初始化。
接下来使用循环遍历数组,计算数组中的某个元素时,进行遍历层数的“丢鸡蛋”操作,并统计最优解即可。
图:核心代码
此算法由于使用了三层循环和二维数组,时间复杂度为\(O(ef^2)\),空间复杂度为O(ef)。
3. 进行二分查找优化的动态规划:
之前的方法中,我们并未讨论每个尝试是否有必要,只是“暴力”地尝试从每一层楼丢下鸡蛋,并找到最优解。但是,通过对问题的数学分析,可以发现大部分的讨论其实是不必要的。
我们讨论上述动态规划方程中的dp(e-1,x-1)和dp(e,f-x)的函数性质(关于x):显然易见,鸡蛋相同的情况下,层数越多,需要的实验次数越多。因此这两个函数都是单调的,dp(e-1,x-1)单调递增,而dp(e,f-x)单调递减。
此时我们需要找到的就是这两个函数中较大值最小时对应的x值x0,但需要注意,并不一定会出现两个函数相等的点,因为x的取值不是连续的,仅通过大小关系我们最终能找到两个可能的x0,需要通过对比才可确定最终结果。通过将这两个函数的关系绘图即可直观地找到此问题的解决方法——二分法。
进行循环,每次取当前范围的中间点mid,如果dp(e-1,mid-1)比dp(e,f-mid)更大,说明x0<=mid,如果大于则x0>=mid,如果二者相等则说明mid就是需要找的x0。代码如下:
此处需要特别注意边界问题——循环的终止条件:由于循环中将中点mid取值为(left + right) / 2,因此当left==right-1时mid将会取到left,而如果此时正好触发dp(e-1,mid-1)<dp(e,f-mid),接下来执行的left=mid;语句将会无效,进入死循环。而此时可以确定的是,x0要么是left,要么是right,因此可以直接退出循环了。综上,循环的进行条件为:left + 2 <= right。
由于使用了二分查找,并嵌套两层循环,本算法的时间复杂度为O(e*flogf),空间复杂度为O(e*f)。
4. 进行空间优化的动态规划:
在3的基础上,可以发现更新最新状态只需要上一层的数据,更早的数据已经失去作用,因此可以直接将其优化,只需保留两层的数组。
并将代码中所有的dp[i-1][]替换为dp[0][],dp[i][]替换为dp[1][],即可实现空间优化。与3时间复杂度相同的情况下将空间复杂度优化到了O(f)。
5. 逆向思维法动态规划:
在之前的方法中,我们讨论的是规定鸡蛋数和层数所需的实验次数,但如果规定鸡蛋数和实验次数,也可以得到一个能确定门槛层的最大层数,这就是逆向思维法。
此时动态规划数组和方程也均会有所改变,首先从易于理解的二维数组开始讨论:
用dp[t][e]表示t次实验,e个鸡蛋能确定的最大层数。
由于层数不确定,我们可以想象:在一个无数层的楼房上进行实验,最终通过测试可以确定其中的某一段会不会有门槛层,如果会,在哪层。接下来,直接令这一段是从1层开始的(因为只要总层数不变,问题都是等价的),这一段的长度也就是t次实验,e个鸡蛋能确定门槛层的最大层数。
接下来讨论计算过程:如果在某一层进行了一次实验,那么也会有破与不破两种可能:
①如果破了,说明往上的所有层都不可能是门槛层,往这一层之下,使用剩余的t-1次实验次数和e-1个鸡蛋继续进行实验;
②如果没破,说明此层与底下的所有层都不可能是门槛层,往这层之上,使用t-1次实验次数和e个鸡蛋进行实验。
由上述两种情况,其实可以发现,我们可以确定是否为门槛层的楼层为:
dp[t-1][e-1](往下能确定的层数)+1(本层)+dp[t-1][e](往上能确定的层数)
动态规划方程可表示为:
dp[t][e]= dp[t-1][e-1]+1+dp[t-1][e]
示意图:
如图所示,无论此次实验的结果如何,中间这一段长度中只要有门槛层,我们一定能将其找到。这一段的长度dp[t-1][e-1]+1+dp[t-1][e]就是t次实验,e个鸡蛋能确定门槛层的最大层数。
而与4同理,本算法过程中只需要用到上一层的状态,且此算法最终不需要用到两层数据,因此可以直接将dp数组直接简化为一维,这样一来,动态规划方程可变为:
dp[e]= dp[e-1]+1+dp[e]
由于后续的状态将会覆盖之前的状态,因此循环的方向需要反过来。核心代码如下:
本算法的最差时间复杂度为O(ef,但由于外层循环往往不需要进行到最后就会找到答案,因此实际时间复杂度会更低,为O(et),其中,t表示问题的解(这种时间复杂度的表示方法并不规范,如果需要规范的表达,我找到的结果是:\(O(e\sqrt[e]{f})\),但是并没有找到相关证明,可能是基于经验的结果)。
本算法空间复杂度为O(e)。
6. 过多鸡蛋数特殊优化:
如果鸡蛋相当充足,实验的方法显而易见:使用鸡蛋直接从楼层的中间进行实验,每次可以排除一半的楼层。这样一来,对于f层楼,只要鸡蛋数量超过\(log_2f+1\)(+1是为了避免边界问题,向上取整),得出的答案都是一样的。那么可以在函数开始时判断鸡蛋的个数是否超过了这个值,如果超过了,直接将鸡蛋的数量改为\(log_2f+1\),再进行创建数组、遍历与计算的操作。这样一来,处理大量鸡蛋的问题所需的时间将会极大减少!
(三)验证算法正确性
在较小规模数据集上对编写的所有算法进行测试,结果如下:
由图可见,编写的所有算法均可得到正确答案。
(四)测试算法效率,并于理论效率对比:
此处为了方便统计与计算,并使数据规模适用于对应算法,采用手动输入数据的方式进行实验。
1. 蛮力递归:
此算法由于复杂度与问题相关且难以估计,故此处不做理论效率与实际效率分析。
图:用时与楼层数的关系
由图可知,此算法所用时间与楼层数呈指数级关系。这是由于递归解法会尝试每一层楼来找到最优解,随着楼层数的增加,需要探索的次数呈指数级增长。
再将楼层数固定为25,探究鸡蛋数与计算时间的关系:
图:用时与鸡蛋数的关系
由图可知,当鸡蛋数较少时,随着鸡蛋数的增加,时间逐渐呈指数级增长,但是随着鸡蛋数变多,时间的增长逐渐放缓,最后当鸡蛋数达到某个值时,时间将不再增长,甚至有所回落。
推测原因:当鸡蛋数较少时,增加鸡蛋数出现了与上个实验中增加楼层数相同的效果,都是由于递归调用次数的指数增长导致了时间的指数增长。而当鸡蛋数量较多,调用过程中将会出现:楼层范围已经确定为某一层,但是鸡蛋仍有多个的情况,这种情况将会直接返回1,而无需继续调用函数,因此后续鸡蛋的增长不会带来时间的增长。
2. 朴素动态规划:
首先将鸡蛋数固定为两个进行实验,实验的理论时间与实际时间如图:
图:楼层数与所用时间的关系
由于算法的复杂度为\(O(ef^2)\),时间与楼层数的平方成正比,实验结果也符合预期。
接下来将楼层数固定为10000,探究鸡蛋数与所用时间的关系:
图:鸡蛋数与所用时间的关系
时间与所用鸡蛋数成正比,实验结果符合预期。
3. 二分查找动态规划:
经过实验测试,经过了空间优化的二分查找动态规划相较于普通的二分查找动态规划效率更高,二者的效率差距大约为30%,但由于二者本质相同,故此处仅进行经过了空间优化的二分查找动态规划算法的效率展示:
固定鸡蛋数为2,所用时间与楼层数的关系如图:
图:楼层数与所用时间的关系
算法的时间复杂度为O(eflogf),所用时间与楼层数的关系接近线性,也符合预期。可以注意到此时算法已经可以在短时间内解决上亿层数的问题了。
将楼层数固定为一千万,探究鸡蛋数与所用时间的关系:
图:鸡蛋数与所用时间的关系
可见,随着鸡蛋数的增长,相较于直接与鸡蛋数成正比,算法所用时间增长更为缓慢,接近线性关系。
4. 逆向思维+空间优化+鸡蛋过多处理动态规划:
本算法可解决的数据数量级极大,超出了int的限制,因此将鸡蛋数量eggs和楼层数floor的类型均改为了long long int。
接下来将鸡蛋数固定为2进行实验:
图:楼层数与所用时间的关系
可见,随着楼层数的增长,所用时间与楼层的关系并不会线性增长,而是更加缓慢,说明了本算法的复杂度小于O(ef)。且此算法能在数个毫秒的时间内直接解决亿级别数据的问题。
将楼层数固定为1e8,测试所用时间与鸡蛋数的关系:
图:鸡蛋数与所用时间的关系
可以发现,随着鸡蛋数的增加,所用的时间不增反减,这是因为随着鸡蛋数的增加,问题的解快速变小,使得需要进行的循环的次数快速减小,因此节省了时间。
将此代码提交到leetcode的原题处,结果如下:
图:提交结果
由图可见,答案正确,且用时极短。
附原题链接:. - 力扣(LeetCode)
实验代码:
#include <iostream>
#include <vector>
#include <chrono>
#include <math.h>
using namespace std;
using namespace std::chrono;
const int eggs_top = 100;//鸡蛋最大个数
const int floor_top = 10000;//楼层最大数
//蛮力递归
int force(int eggs, int floor) {
if (floor <= 1 || eggs == 1)return floor;
int ans = INT_MAX;
for (int i = 2; i <= floor; ++i) {//尝试丢鸡蛋
ans = min(ans, max(force(eggs - 1, i - 1), force(eggs, floor - i)) + 1);
}
return ans;
}
//朴素动态规划,时间复杂度O(egg*floor^2),空间复杂度O(egg*floor)
int simple_dp(int eggs, int floor) {
vector<vector<int>> dp(eggs + 1, vector<int>(floor + 1));
//首先进行初始化:
for (int i = 0; i <= eggs; ++i) {//层数为一的情况,无论多少鸡蛋都是一次
dp[i][1] = 1;
}
for (int i = 0; i <= floor; ++i) {//鸡蛋为一的情况,多少楼就需要多少次
dp[1][i] = i;
}
for (int i = 2; i <= eggs; ++i) {
for (int j = 2; j <= floor; ++j) {
int minnum = INT_MAX;
for (int x = 2; x <= j; ++x) {//进行“扔鸡蛋”的操作:尝试将鸡蛋从x楼丢下
//鸡蛋如果碎了,说明门槛层在1 ~ x层,但只需要检查1 ~ x-1层,且接下来能用的鸡蛋数减少一个,转化为子问题dp[i - 1][x - 1]
//鸡蛋如果没碎,说明门槛层在x+1 ~ j层,可以等价转化为1 ~ j-x层,且能用的鸡蛋数不变,转为dp[i][j - x]
int temp = max(dp[i - 1][x - 1], dp[i][j - x]);//两种情况都有可能,我们要选最坏的情况
minnum = min(minnum, temp);
}
//由上,本循环的目的是找到一个层数,使得子问题的最大可能解最小
dp[i][j] = 1 + minnum;//进行了一次尝试,需要+1
}
}
return dp[eggs][floor];
}
//二分查找优化,时间复杂度O(egg*floor*log(floor)),空间复杂度O(egg*floor)
int binary_search_dp(int eggs, int floor) {
vector<vector<int>> dp(eggs + 1, vector<int>(floor + 1));
for (int i = 0; i <= eggs; i++) dp[i][1] = 1;
for (int i = 0; i <= floor; i++) dp[1][i] = i;
for (int i = 2; i <= eggs; ++i) {
for (int j = 2; j <= floor; ++j) {
int left = 1, right = j;
while (left + 2 <= right) {
int mid = (left + right) / 2;//二分寻找x0
//三种可能的情况
if (dp[i - 1][mid - 1] < dp[i][j - mid]) left = mid;
else if (dp[i - 1][mid - 1] > dp[i][j - mid]) right = mid;
else left = right = mid;
}
//此时,x0将会是left或者right,需要比较两个解的情况,取较好的
int leftans = max(dp[i - 1][left - 1], dp[i][j - left]);
int rightans = max(dp[i - 1][right - 1], dp[i][j - right]);
dp[i][j] = 1 + min(leftans, rightans);
}
}
return dp[eggs][floor];
}
//二分查找优化+空间优化,时间复杂度O(egg*floor*log(floor)),空间复杂度O(floor)
int less_space_dp(int eggs, int floor) {
vector<vector<int>> dp(2, vector<int>(floor + 1));
for (int i = 0; i <= 1; i++) dp[i][1] = 1;
for (int i = 0; i <= floor; i++) dp[1][i] = i;
for (int i = 2; i <= eggs; ++i) {
for (int j = 2; j <= floor; ++j) {
int left = 1, right = j;
while (left + 2 <= right) {
int mid = (left + right) / 2;//二分寻找x0
//三种可能的情况
if (dp[0][mid - 1] < dp[1][j - mid]) left = mid;
else if (dp[0][mid - 1] > dp[1][j - mid]) right = mid;
else left = right = mid;
}
//此时,x0将会是left或者right,需要比较两个解的情况,取较好的
int leftans = max(dp[0][left - 1], dp[1][j - left]);
int rightans = max(dp[0][right - 1], dp[1][j - right]);
dp[0][j] = dp[1][j];//将已经用不到的空间替换掉,从而优化了空间!
dp[1][j] = 1 + min(leftans, rightans);
}
}
return dp[1][floor];
}
long long int backward_dp(long long int eggs, long long int floor) {
if (pow(2, eggs) > floor)eggs = 1.0 * log(floor) / log(2) + 1;
eggs = (int)eggs;
if (eggs == 1)return floor;
vector<long long int> dp(eggs + 1);
for (int i = 0; i <= eggs; ++i) dp[i] = 0;//初始化:进行0次实验确定0层
long long int times;//实验次数
for (times = 0; dp[eggs] < floor; times++)
for (int i = eggs; i > 0; i--) //需要逆序遍历,因为新的会覆盖旧的
dp[i] = dp[i] + dp[i - 1] + 1;
return times;
}
int main() {
int ran_or_hand = 2;//数据来源,1表示随机,0表示手动输入,2表示使用最终优化算法,并自动生成一系列数
int experiment_times = 100;// 实验重复次数
for (int i = 1; i <= experiment_times; ++i) {
long long int eggs;
long long int floor;
if (ran_or_hand == 1) {
eggs = rand() * rand() % eggs_top;
floor = rand() * rand() % floor_top;
cout << "鸡蛋数:" << eggs << ",楼层数:" << floor << endl;
}
else if (ran_or_hand == 0) {
cout << "请输入鸡蛋数和层数:" << endl;
cin >> eggs >> floor;
}
long long int ans;
//auto begintime1 = system_clock::now();
//ans = force(eggs, floor);
//duration<double> dura1 = system_clock::now() - begintime1;
//cout << "蛮力递归算法:答案:" << ans << ",用时:" << dura1.count() << 's' << endl;
//auto begintime2 = system_clock::now();
//ans = simple_dp(eggs, floor);
//duration<double> dura2 = system_clock::now() - begintime2;
//cout << "朴素动态规划算法:答案:" << ans << ",用时:" << dura2.count() << 's' << endl;
//auto begintime3 = system_clock::now();
//ans = binary_search_dp(eggs, floor);
//duration<double> dura3 = system_clock::now() - begintime3;
//cout << "二分查找动态规划:答案:" << ans << ",用时:" << dura3.count() << 's' << endl;
//auto begintime4 = system_clock::now();
//ans = less_space_dp(eggs, floor);
//duration<double> dura4 = system_clock::now() - begintime4;
//cout << "空间优化动态规划:答案:" << ans << ",用时:" << dura4.count() << 's' << endl;
//由于本算法可计算的数据过大,直接生成对应数据
long long int mul = 1e8;
eggs = 2; floor = i * mul;
cout << "鸡蛋数" << eggs << ",楼层数" << i << "*" << mul << "\n";
auto begintime5 = system_clock::now();
ans = backward_dp(eggs, floor);
duration<double> dura5 = system_clock::now() - begintime5;
cout << "逆向思维动态规划:答案:" << ans << ",用时:" << dura5.count() << 's' << endl;
cout << endl;
}
}
四、实验结论与体会
实验结论:
本实验通过编写代码并对代码进行优化,使用多种方式解决了鸡蛋掉落问题。其中,我们首先编写了蛮力递归代码探究问题的解;接着编写了朴素动态规划算法,再根据问题中带有的数学原理对动态规划算法进行了二分搜索优化、根据所用的子问题的特点进行了空间优化;最后,我们从逆向思维的角度出发,编写了一个新的算法,并对其进行了空间优化、特殊情况优化,使得程序可以在极短的时间内解决相当大规模的问题。
实验体会:
1. 计算理论效率时需要选择一个适合的数据作为基准,此数据不应该过小,因为过小容易受到与算法本身无关的操作的影响,比如调用函数的消耗、访问数组空间的消耗。本实验中我往往选择一个较小的数(时间)作为基准,计算其他情况的理论时间,这个数与实验尝试的最大数据尽量不超过两个数量级。
2. 动态规划本身代码并不难,难点在于如何理解状态是什么,为什么父问题可以继承子问题的最优解,以及日后自己做动态规划的相关研究时是否能够正确的用语言描述出动态规划的状态。
尾注:
本实验是此课程的第四次实验,我认为此实验的题目质量相当高,兼具较高的难度与相当大的优化空间,值得学习与钻研。
本报告的优点在于对解题的思路做了相当详细的解释与分析,并配以图示,应该能有助于加深对本实验内容的理解!
本次实验是我在本学期中得分最高的实验,也是自认为做的最好的实验,最后一步的优化使用了查阅的资料中未曾出现过的方法,并补全了本算法最后一块短板——多鸡蛋多层数问题。
如有疑问欢迎讨论,如有好的建议与意见欢迎提出,如有发现错误则恳请指正!