Data-Works
Never settle
浅谈动态规划
动态规划问题的⼀般形式就是求最值。动态规划其实是运筹学的⼀种最优化 ⽅法,只不过在计算机问题上应⽤⽐较多,⽐如说让你求最⻓递增⼦序列 呀,最⼩编辑距离呀等等。
对于动态规划问题,思路就是:1.这个问题有什么【状态】2.有什么【选择】并择优 3.穷举法求解最后优化(剪枝)。
在此举一个动态规划的典型例题:高楼扔鸡蛋问题。(用有限个(k个)鸡蛋在不同的楼层进行测试,找出在最坏情况下至少需要用掉多少个鸡蛋才能确定使鸡蛋恰好不摔碎的楼层)。ps:F可以为0,如果鸡蛋在1层都能摔碎,那么F=0.
此题中【状态】很明显,就是当前的鸡蛋数k和需要测试的楼层数N。随着测试的进行,鸡蛋个数会减少,楼层的搜索范围会减少,这就是状态的变化。
【选择】就是去选择哪层楼扔鸡蛋。这里拿二分法和线性搜索法来说明不同的选择会造成不同的状态转移。
现在明确了【状态】和【选择】,动态规划的基本思路就形成了:肯定是个二维的dp数组或者带有两个状态参数的dp函数来表示状态转移;外加一个
for循环来遍历所有的选择,选出最优的解来更新结果。
当前状态为(k个鸡蛋,N层楼)
返回这个状态下的最优结果
def dp(k,N)
int res
for 1<=i<=N
res=min(res,这次在第i层楼扔鸡蛋)
return res
这段伪代码还没有展示递归和状态转移,不过大致的算法已经完成了。
我们在第i层扔完鸡蛋后,可能出现两种情况:鸡蛋碎了,鸡蛋没碎。注意,这时候状态转移就来了:
如果鸡蛋碎了,那么鸡蛋的个数k应该减一,搜索的楼层区间应从[1…N]变为[1…i-1]共i-1层楼
如果鸡蛋没碎,那么鸡蛋的个数k不变,搜索的楼层区间应该从[1…N]变为[i+1…N]共N-i层楼
def dp(k,N)
for 1<=i<=N
//最坏情况下的最少扔鸡蛋次数
res=min(res,max(dp(k-1,i-1)碎了
,dp(k,N-i)//没碎)
+1//在第i层扔了一次)
return res
递归的base case很容易理解:当楼层数N=0时,显然不需要仍鸡蛋;当鸡蛋数k=1时,显然只能线性扫描所有楼层(从第一层开始依次往上面楼层测试扔鸡蛋):
def dp(k,N)
if k=1: return N
if N=0: return 0
至此,这道题其实就解决了!只要添加一个备忘录消除重叠子问题即可:
def superEggDrop(k:int,N:int):
memo=dict()
def dp(k,N)->int:
//base case
if(k==1): return N
if(N==0): return 0
//避免重复计算
if(k,N) in memo:
return memo[(k,N)]
res=float('INF')
//穷举所有可能的选择
for i in range(1,N+1):
res=min(res,max(dp(k,N-i),dp(k-1,i-1))+1)
//记入备忘录
memo[(k,N)]=res
return res
return dp(k,N)
这个算法的时间复杂度是多少呢?动态规划算法的时间复杂度就是子问题个数*函数本身的复杂度
函数复杂度就是忽略递归部分的复杂度,这里dp函数中有一个for循环,所以函数本身的复杂度是O(N)
子问题个数就是不同状态组合的总数,显然是两个状态的乘积,也就是O(KN)
所以算法的总时间复杂度是O(K*N^2),空间复杂度为子问题个数,即O(KN)
Conclusion
这个问题很复杂,但是代码却十分简洁,这就是动态规划的特性,穷举+备忘录/DP table优化,真的没啥新鲜玩意
有的人可能不理解代码中为什么用一个for循环遍历楼层[1…N],也许会把这个逻辑和之前探讨的线性扫描混为一谈。
其实并非如此,这只是在做一次【选择】
比方说你拿2个鸡蛋,面对10层楼,你得拿一个鸡蛋去某一层楼扔对吧?那选择去哪一层楼扔呢?不知道,那就把这10层楼全试一遍。
至于说鸡蛋碎没碎,下次怎么选不用你操心,有正确的状态转移,递归会算出每个选择的代价,我们取那个最优的就是最优解。