实验内容:
动态规划将问题划分为更小的子问题,通过子问题的最优解来重构原问题的最优解。动态规划中的子问题的最优解存储在一些数据结构中,这样我们就不必在再次需要时重新处理它们。任何重复调用相同输入的递归解决方案,我们都可以使用动态规划对其进行优化。鸡蛋掉落问题是理解动态规划如何实现最佳解决方案的一个很好的例子。问题描述如下:
我们需要用鸡蛋确认在多高的楼层鸡蛋落下来会破碎,这个刚刚使鸡蛋破碎的楼层叫门槛层,门槛楼层是鸡蛋开始破碎的楼层,上面所有楼层的鸡蛋也都破了。另外,如果鸡蛋从门槛楼层以下的任何楼层掉落,它都不会破碎。如上图所示,如果有 5 层,我们只有1个鸡蛋,要找到门槛层,则必须尝试从每一层一层一层地放下鸡蛋,从第一层到最后一层,如果门槛层是第 k 层,那么鸡蛋就会在第 k 层抛下时破裂,应该做了k次试验。
注意:我们不能随机选择任何楼层,例如,如果我们选择 4 楼并放下鸡蛋并且它打破了,那么它不确定它是否也从 3 楼打破。 因此,我们无法找到门槛层,因为鸡蛋一旦破碎,就无法再次使用。
给定建筑物的一定数量的楼层(比如 f 层)和一定数量的鸡蛋(比如 e 鸡蛋),找出阈值地板必须执行的最少的鸡蛋掉落试验的次数,注意,这里需要求的是试验的测试,不是鸡蛋的个数。还要记住的一件事是,我们寻找的是找到门槛层所需的最少鸡蛋掉落试验次数,而不是门槛层下限本身。
问题约束条件:
- 从跌落中幸存下来的鸡蛋可以再次使用。
- 破蛋必须丢弃。
- 摔碎对所有鸡蛋的影响都是一样的。
- 如果一个鸡蛋掉在地上摔碎了,那么它从高处掉下来也会摔碎。
- 如果一个鸡蛋在跌落中幸存下来,那么它在较短的跌落中也能完整保留下来。
暴力枚举:
- 思路:从最高层开始尝试,一层一层地扔鸡蛋,直到找到鸡蛋摔碎的楼层为止。这种方法的最坏情况下需要尝试所有可能的楼层,例如当f=7,试到第一层最低层鸡蛋才碎(k=1),此时res=f即楼层数量。
- 伪代码:
暴力枚举(线性扫描) |
Int linearSearch(int f) { num=0 // 尝试次数 For i from f to 1 dec : num++ If i==k: Return num // 试到鸡蛋碎的楼层了,直接可以返回 Else : Continue // 鸡蛋没碎,继续尝试 Return num // 如果到达最后,直接返回尝试次数 } |
- 思考分析:由于从跌落中幸存下来的鸡蛋可以再次使用,考虑从最高层往下扔鸡蛋,这样可以不受鸡蛋个数的限制。但这种线性扫描暴力枚举的方法当楼层数很大时,效率会非常低下。
暴力二分:
- 思路:将搜索范围二分,每次选择中间楼层扔鸡蛋,如果鸡蛋碎了则往高楼层找鸡蛋恰好没碎的楼层h;如果鸡蛋没碎则往低楼层找鸡蛋恰好碎了的楼层k,即根据鸡蛋是否摔碎来确实下一步搜索的范围。例如当f=7,首先去第(1+7)/2=4层扔一下,如果碎了说明k>=4,则去第(5+7)/2=6层尝试;如果没碎说明k<4,则去第(1+3)/2=2层尝试。这种方法的最坏情况下需要尝试次数为log7向上取整等于3次。
- 伪代码:
暴力二分 |
|
- 思考分析:如果不限制鸡蛋个数的话,二分显然可以得到最少尝试次数,但限制鸡蛋个数为e,直接使用暴力二分并不可行。例如,如果f=100,e=2,先在第50层扔鸡蛋,如果碎了最后一个鸡蛋只能线性扫描51-100层找到没碎的临界层,如果没碎最后一个鸡蛋也要线性扫描1-49层找到碎的临界层,无论如何最坏情况都要扔50次。在限制了鸡蛋数量的情况下,暴力二分搜索也不是一个高效的解决方法。
动态规划
- 设F(e,f)表示目前有e个鸡蛋,需要测试的楼层数量为f的最坏情况下最少操作次数。目前在x层,可以分成两个子问题F(e-1,x-1)和F(e,f-x),转移到当前F(e,f)状态。
- 由于鸡蛋在x层,如果碎了则往上面高楼层也是碎了的,而下面低楼层中存在没碎的临界层;如果没碎则往下面低楼层也是没碎,而上面高楼层中存在碎的临界层。
- 因此,子问题F(e-1,x-1)表示目前在x层碎了,还剩e-1个鸡蛋,还有x-1层楼需要测试的最坏情况下最少操作次数(即要去下面x-1层楼测试找到临界没碎的那一层);子问题F(e,f-x)表示目前在x层没碎,还剩e个鸡蛋,还有f-x层楼需要测试最坏情况下最少操作次数(即要去上面f-x楼测试找到临界碎的那一层)。
- 由于F(e,f)求的是鸡蛋个数为e,楼层数量为f的最坏情况下最少操作次数。最坏情况下,即当从两个子问题中选一个子问题转移到主问题F(e,f)中时,我们要选择需要操作次数多的那个子问题,确保是最糟糕情况(要操作多次)下的转移。
- 在第x层操作后,我们考虑是去x的上面楼层继续试还是下面楼层继续试,就去所需操作次数多的那一方。如果在上面楼层试的操作次数多,就让鸡蛋不碎;如果下面楼层试的操作次数多,就让鸡蛋碎。
思路:动态规划问题主要分成三部分:状态表示,转移方程,初始和边界条件。
状态表示:根据题目,鸡蛋个数为e,楼层数量为f,转换为一个函数F(e,f),F(e,f)表示目前有e个鸡蛋,需要测试的楼层数量为f的最坏情况下最少操作次数。
转移方程:假设我们第一个鸡蛋扔出的位置在第x层(1<=x<=f),有两种情况:
- 第一个鸡蛋没碎,剩下f-x层楼,剩下e个鸡蛋,函数转变为:F(e,f-x)+1。
- 第一个鸡蛋碎了,剩下x-1层楼,剩下e-1个鸡蛋,函数转标为:F(e-1,x-1)+1。
- “至少”的理解:满足要求的最大尝试次数的最小解。F(e-1,x-1)和F(e,f-x)这个两个函数,固定e和f,当x从1到f单调递增,前者随着x的增加也单调递增,后者随着x的增加而单调递减。
- 整体而言,要求出的是在e个鸡蛋,f层楼的条件下的最大尝试次数的最小解,转移方程为:F(e,f) = min( F(e,f) , max( F(e,f-x) , F(e-1,x-1) )+1 )
初始和边界条件
- 当e=0且f=0时,无需操作。
- 当e=1即只有一个鸡蛋时,res=f,即线性扫描所有楼层。
- 当f=1即只有一层楼时,res=1,扔一次即可。
- 在每次选择楼层进行扔鸡蛋之前,操作次数初始为当前楼层的数量+1(最坏结果),之后再进行取min操作即可。
伪代码:
动态规划(递归处理) |
|
使用动态规划数组:
- 状态表示:dp[i][j]表示剩余i个鸡蛋,共有j层楼的最坏情况下最小操作次数。
- 转移方程:dp[i][j]=min(dp[i][j], max(dp[i-1][x-1], dp[i][j-x])+1),1<=x<=j。
- 最后结果:res=dp[e][f]即有e个鸡蛋,f层楼找到门槛层k的最坏情况下最小操作次数。
- 伪代码:
动态规划(动规数组) |
|
复杂度分析:
- 时间复杂度:
- 对于递归处理:由于每个递归调用需要O(f)的时间来计算,且有e层递归调用,因此总体时间复杂度为O(e*f*f)=O(e*f^2),是指数级的(O(K*N^2))。
- 对于数组处理:由于三层循环嵌套,时间复杂度也是指数级的O(K*N^2)。
- 空间复杂度:
- 对于递归处理:递归调用需要O(e)的栈空间,因此空间复杂度为O(e)即O(N)。
- 对于数组处理:由于需要一个二维数组来存储动态规划的结果,因此空间复杂度为O(N^2)。
一维数组优化:(空间优化)
- 思路:在普通的动态规划方法中,我们使用二维数组 dp[i][j] 来表示剩余 i 个鸡蛋,共有 j 层楼的最小操作次数。在每一层的计算中,我们需要使用上一层的状态 dp[i-1][m-1] 和当前层的状态 dp[i][j-m]。
通过观察状态转移方程可以发现,当前层的状态只依赖于上一层的状态和当前层内的一部分状态,而不依赖于整个上一层的状态。因此,我们只需要保留上一层的状态和当前层的部分状态即可,不需要保留整个二维数组。
这样,我们就可以将二维数组 dp 优化为一维数组 P,其中P[j]表示剩余i个鸡蛋,共有j层楼时的最小操作次数。在递推过程中只需要用两个数组来交替保存当前层和上一层的状态,从而实现了空间的优化。一维空间优化的原理就是通过递推过程中只保存当前层和前一层的状态,而不是整个二维数组的状态,从而节省了空间。
- 转移方程表示:
设置两个数组P 和 preP 分别表示当前状态和上一次状态。在计算当前状态 P[j] 时,我们需要用到上一次状态 preP[m-1],因为当前状态的计算依赖于上一次状态的结果。因此,需要在计算当前状态之前将上一次状态保存下来,以便在计算下一次状态时使用。
设当前遍历到的楼层的高度为x,剩余i个鸡蛋,共有j层楼,由
dp[i][j]=min(dp[i][j], max(dp[i-1][x-1], dp[i][j-x])+1)可以很容易得到:
P[j]=min(P[j],max(P[j-x],preP[x-1])+1)
- 复杂度分析:
时间复杂度仍然是O(K*N^2),空间复杂度则降低到O(N)。
- 伪代码:
动态规划(动规数组|一维数组优化) |
|
二分搜索优化:(时间优化)
- 思路:由于求“山谷值”,可以使用二分搜索来进行优化。
将第三层循环去掉,对每个楼层进行二分搜索,通过二分搜索来尝试找到一个楼层,使得鸡蛋在该楼层扔下后得到最少的尝试次数。
- 算法思维导图:
- 伪代码:
动态规划(动规数组|二分搜索优化) |
|
复杂度分析:
时间复杂度:
取决于两个因素:鸡蛋的个数 e 和楼层的高度 f。对于每个鸡蛋个数 e,我们需要进行 O(f) 次循环,因为我们需要在每一层楼进行二分搜索。在每次二分搜索中,时间复杂度为 O(log f),因为我们将搜索范围减半,直到找到最优的楼层。因此,总的时间复杂度为 O(e * f * log f)即O(K*NlogN)。
空间复杂度:
我们只使用了一个二维数组 dp 来存储动态规划的结果,因此空间复杂度为O(N^2)。
状态转移优化:(时间优化)
- 最优性解释:
- 设dp(m,e)表示当前可以操作m次,有e个鸡蛋时能测出的最大楼层数量。目前在x层,可以分成两个子问题dp(m-1,e-1)和dp(m-1,e),转移到当前dp(m,e)状态。
- 由于鸡蛋在x层,如果碎了则往上面高楼层也是碎了的,上面高楼层可测出;如果没碎则往下面低楼层也是没碎,下面低楼层可测出。
- 因此,子问题dp(m-1,e-1)表示目前在x层碎了,可以操作m-1次,还剩e-1个鸡蛋时能测出的最大楼层数量(即在x层上的高楼层都能测出);dp(m-1,e)表示目前在x层没碎,可以操作m-1次,还剩e个鸡蛋时能测出的最大楼层数量(即在x层下的低楼层都能测出)。
- 由于dp(m,e)表示当前可以操作m次,有e个鸡蛋时能测出的最大楼层数量。要转移到dp(m,e)这个状态,则要将两个子问题求得的答案进行相加,再把当前正在测试的楼层加上,即最后为dp(m,e)=dp(m-1,e-1)+dp(m-1,e)+1。
- 状态表示:dp[i][j]表示当前可以操作i次,有j个鸡蛋时能测出的最高的楼层数。
例如:dp[7][1]=7表示:现在允许扔7次,只有1个鸡蛋,这个状态下最多给7层楼可以确定门槛层k使得鸡蛋恰好摔。
- 转移方程:设当前操作次数为i,我们用一个鸡蛋尝试从某一层扔下,有两种可能的结果:一种是鸡蛋碎了即dp[i-1][j-1],我们可以继续使用剩余的鸡蛋和更少的操作次数来测试更低的楼层;另一种是鸡蛋没碎即dp[i-1][j],我们可以继续使用相同数量的鸡蛋和更少的操作次数来测试更高的楼层。将两种可能结果进行相加并加上当前这次操作,即可得到最后答案。转移方程为:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j] +1
- 主要思路及返回结果:
由于结果是最少操作次数,即可考虑直接枚举操作次数。操作次数设为m,初始为0。在循环中,不断迭代增加操作次数 m,直到满足条件 dp[m][e] >= f 为止。这意味着当前操作次数下,使用 e个鸡蛋可以测出的最高楼层数已经不小于给定的最大楼层数 f。退出循环后的m就是所求的最小操作次数,能够确保在给定鸡蛋个数下找到目标楼层的最少尝试次数。
- 伪代码:
动态规划(动规数组|状态转移优化) |
|
复杂度分析:
时间复杂度:
总体时间复杂度取决于dp数组的大小,因此时间复杂度为O(m*e)其中m是满足dp[m][e]>=n的最小值,即O(K*N)。
空间复杂度:
需要一个二维数组 dp 来存储动态规划的结果,因此空间复杂度为O(N^2)。
基于状态转移优化的一维空间优化:(时间+空间优化)
- 思路及转移方程:
注意到dp[m][e]转移只和上一层m-1的两个状态有关,考虑优化成一维dp数组。dp[i]表示鸡蛋个数为i时,能够测出的最高楼层数。此时转移方程为:
dp[j]=dp[j]+dp[j-1]+1
需要注意的是在枚举鸡蛋个数时采用逆向枚举,因为我们在计算当前状态时可能会用到之前计算过的状态,而这些之前计算过的状态是不会再被改变的。因此,我们可以从高到低的顺序计算状态,确保每次计算时都能够利用到之前已经计算好的状态值。
- 复杂度分析:
此时空间复杂度由O(N^2)降低到O(N),时间复杂度仍然是O(K*N)。
- 伪代码:
动态规划(动规数组|状态转移优化+一维空间优化) |
|
基于状态转移优化和一维空间优化的二分优化:(时间+空间优化)
- 思路:
随着测试次数的增加,我们能够覆盖的楼层数也会增加,这意味着,如果我们知道某个测试次数足够覆盖所有楼层,那么比这个测试次数更多的次数也一定足够覆盖所有楼层。存在单调递增的单调性,可以考虑二分搜索来优化减少搜索的时间复杂度。
具体来说,如果我们知道 dp[e] 小于楼层数 mid,那么少于当前测试次数的次数一定不够,我们应该将搜索范围的下界调整为 mid + 1,以便在更多的测试次数中寻找最小的满足条件的值。当满足dp[e]>=f,即当前已经能够覆盖所有楼层,则可以停止搜索得到我们的答案。
- 复杂度分析:
时间复杂度:
在每次循环中,对于给定的e的值(鸡蛋数量),会进行一次二分搜索以查找满足条件的最小值。二分搜索的时间复杂度为O(log f)其中f为楼层高度,时间复杂度为O(logN)。总的时间复杂度为O(e*log f)即O(K*logN)。
空间复杂度:
只需要一个一维数组 dp 来存储动态规划的结果,因此空间复杂度为O(N)。
- 伪代码:
动态规划(动规数组|状态转移优化+一维空间优化+二分搜索优化) |
|