假设你面前有一栋n层的大楼和m个鸡蛋,假设将鸡蛋从f层或更高的地方放扔下去,鸡蛋才会碎,不然就不会。你须要设计一种策略来肯定f的值,求最坏状况下扔鸡蛋次数的最小值。git
leetcode原题连接github
乍一看这道题很抽象,可能有的人一看到这个题目历来没作过,就懵逼了。其实不用慌张,再花里胡哨的题目,最后均可以抽象成咱们熟悉的数据结构和算法去解决。算法
首先,咱们从一个简单的版本开始理解,假如不限制鸡蛋个数,即把题目改为n层大楼和无限个鸡蛋。那么这题要怎么解呢?
第一步就是要充分理解题意,排除题目中的干扰,创建模型:数组
很显然,这就是一个二分查找能解决的问题。
扔鸡蛋的次数就是二分查找的比较次数,即log2(n+1)。数据结构
那咱们如今再来看限制鸡蛋个数状况下,确定无法用二分查找,可是因为求解的是一个最优值,咱们天然而然地想到了动态规划。数据结构和算法
动态规划的题目,这边提供一个思路,就是四步走:函数
这一步很是很是重要,它创建在良好地理解题意的基础上。其实不少动态规划的题目都有这样的特色:优化
而这道题:设计
f(n)
:表明在1~n的楼层中找到f层的尝试次数,咱们的目标就是求出f(n)
的最优值。咱们知道动态规划就是多阶段决策的过程,最后求解组合最优值。
咱们先举一个简单例子,来理解划分子问题的思路,看下面这张图:
问题:求起点集 S1~S5到终点集 T1~T5的最短路径。code
分析这道题:定义子问题dis[i]
表明节点i到终点的最短距离,没有约束条件。
而后问题划分为4个阶段:
C1~C4
节点到终点的最短路径dis[C1]~dis[C4]
。B2~B5
节点到终点的最短路径dis[B1]~dis[B5]
,须要创建在阶段1的结果上计算。例如B2节点到终点有两条路,B2~C1,B2~C2,dis[C1]=2,B2到C1的长度=3;而dis[C2]=3,B2到C2的长度=6,所以dis[B2]=3+dis[B1]=5
。dis[S1]~dis[S5]
,得出最小路径为图中红色的两条。在这道题中,dis[i]
就是划分出来的子问题,每一步决策都是一个子问题,并且每个子问题都依赖于之前子问题的计算结果。
所以,在动态规划中,定义一个合理的子问题很是重要。
而扔鸡蛋这道题比上面这道题多了个约束条件,咱们把子问题定义为:用i个鸡蛋,在j层楼上扔,在最坏状况下肯定目标楼层E的最少次数,记为状态f[i,j]
。
假如决策是在第k层扔下鸡蛋,有两种结果:
e<k
,咱们只能用i-1个蛋在下面的k-1层继续寻找e。而且要求最坏状况下的次数最少,这是一个子问题,答案为f[i-1,k-1]
,总次数即是f[i-1,k-1]+1
。e>=k
,咱们继续用这i个蛋在上面的j-k层寻找E。注意:在k~j层寻找和在1~(j-k)层寻找没有区别,由于步骤都是同样的,只不过这(j-k)层在上面罢了,因此就把它当作是对1~(j-k)层的操做。所以答案为f[i,j-k]
,次数为f[i,j-k]+1
。初值:
当层数为0时,f[i,0]=0
,当鸡蛋个数为1时,只能从下往上一层层扔,f[1,j]=j
。
由于是要最坏状况,因此这两种状况要取大值:max{f[i-1,j-1],f[i,j-k]}
,又要在全部决策中取最小值,因此动态转移方程是:
f(i,j)=min{max{f(i-1,k-1),f(i,j-k)}+1|1<=k<=j}
获得了状态转移方程后,还须要判断咱们的思路是否是正确。能用动态规划解决的问题必需要知足一个特性,叫作最优子结构特性。
一个最优决策序列的任何子序列自己必定是相对于子序列的初始和结束状态的最优决策序列。
这句话是什么意思呢?举个例子:f[4,5]
表示4个鸡蛋、5层楼时的最优解
,那它的子问题f[3,4]
,获得的解在3个鸡蛋、4层楼时
也是最优解,它全部的子问题都知足这个特性。那这就知足了最优子结构特性。
求 路径长度模10 结果最小的路径
仍是像上面那道题同样,分红四个阶段。
按照动态规划的解法,阶段一CT
,上面的路2 % 10 = 2
,下面的路5 % 10 = 5
,选择上面那条,阶段二BC
也选择上面那条,以此类推,最后得出的结果路径是蓝色的这条。
但实际上,真正最优的是红色的这条路径20 % 10 = 0
。这就是由于不符合最优子结构,对于红色路径的子结构CT
阶段,最优解并非下面这条边。
假设m=3,n=4,咱们来看一下f[3,4]的递归树。
图中颜色相同的就是同样的状态,能够看出,重复的递归计算不少,所以咱们开设一个数组result[i,j]
用于存放f[i,j]
的计算结构,避免重复计算,用空间换时间。
class Solution { private int[][] result; public int superEggDrop(int K, int N) { result = new int[K + 1][N + 1]; for (int i = 1; i < K + 1; i++) { for (int j = 1; j < N + 1; j++) { result[i][j] = -1; } } return dp(K, N); } /** * @param i 剩余鸡蛋个数 * @param j 楼层高度 * @return */ private int dp(int i, int j) { if (result[i][j] != -1) { return result[i][j]; } if (i == 1) { return j; } if (j <= 1) { return j; } int min = Integer.MAX_VALUE; for (int k = 1; k <= j; k++) { int left = dp(i - 1, k - 1); result[i - 1][k - 1] = left; int right = dp(i, j - k); result[i][j - k] = right; int res = Math.max(left, right) + 1; if (res < min) { min = res; } } return min; } private static int log(int x) { double r = (Math.log(x) / Math.log(2)); if ((r == Math.floor(r)) && !Double.isInfinite(r)) { return (int) r; } else { return (int) r + 1; } } }
动态规划求时间复杂度的方法是:
时间复杂度 = 状态总数 * 状态转移方程的时间复杂度
在这道题中,状态总个数很明显是m*n
,而每一个状态f[i,j]
的时间复杂度为O(j),1<=j<=n,总时间复杂度为O(mn^2)。
O(mn^2)的时间复杂度仍是过高了。能不能想办法优化一下?
首先咱们知道,在一个1~n的数组中,查找目标数字,最少须要比较log2n次,也就是二分查找。这个理论能够经过决策树来证实:
咱们使用二叉树来表示全部的决策,内部节点表示一次扔鸡蛋的决策,左子树表示碎了,右子树表示没碎,叶子节点表明E的全部结果。每一条从根节点到叶子节点的路径对应算法求出E以前的全部决策。
内部节点(i,j),i表示鸡蛋个数,j表示在j层楼扔下。
当楼层高度n=5时,E总共有6种状况(n=0表明没找到),因此叶子节点的个数是n+1个。
而咱们关心的是树的高度,即决策的次数。根据二叉树理论:当树有n个叶子节点,数的高度至少为log2n,即比较次数在最坏状况下至少须要log2n次,也就是当这颗树尽可能平衡的时候。
换句话说,在给定楼层n的状况下,决策次数的下限是log2(n+1),这个下限能够经过二分查找达到,只要鸡蛋的数量足够(就是咱们刚才讨论的不限鸡蛋的状况)。
所以,一旦状态f[i,j]的鸡蛋个数i>log2(j+1),就不用计算了,直接输出二分查找的比较次数log2(j+1)便可。
这样咱们的状态总数就降为n*log2(k+1),时间复杂度降为O(n^2 log2n)。
/** * @param i 剩余鸡蛋个数 * @param j 楼层高度 * @return */ private int dp(int i, int j) { if (result[i][j] != -1) { return result[i][j]; } if (i == 1) { return j; } if (j <= 1) { return j; } //此处剪枝优化 int lowest = log(j + 1); if (i > lowest) { return lowest; } int min = Integer.MAX_VALUE; for (int k = 1; k <= j; k++) { int left = dp(i - 1, k - 1); result[i - 1][k - 1] = left; int right = dp(i, j - k); result[i][j - k] = right; int res = Math.max(left, right) + 1; if (res < min) { min = res; } } return min; }
优化还未结束,咱们尝试从动态转移方程的函数性质入手,观察函数f(i,j),以下图:
咱们能够发现一个规律,f(i,j)是根据j递增的单调函数,即f(i,j)>=f(i,j-1)
,这个性质是能够用数学概括法证实的,在这里不作证实,有兴趣的查看文末参考文献。
再来看动态转移方程:
f(i,j)=min{max{f(i-1,k-1),f(i,j-k)}+1|1<=k<=j}
因为f(i,j)具备单调性,所以f(i-1,k-1)是根据k递增的函数,f(i,j-k)是根据k递减的函数。
分别画出这两个函数的图像:
图像1:f(i-1,k-1)
图像2:f(i,j-k)
图像3:max{f(i-1,k-1),f(i,j-k)}+1,当k=kbest时,f达到最小值,咱们的目标就是找到kbest的值。
对于这个函数,可使用二分查找来找到kbest:
若是f(i-1,k-1)<f(i,j-k)
,则k<kbest,即k在图中kbest的左边;
若是f(i-1,k-1)>f(i,j-k)
,则k>kbest,即k在图中kbest的右边。
class EggDrop { private int[][] result; public int superEggDrop(int K, int N) { result = new int[K + 1][N + 1]; for (int i = 1; i < K + 1; i++) { for (int j = 1; j < N + 1; j++) { result[i][j] = -1; } } return dp(K, N); } /** * @param i 剩余鸡蛋个数 * @param j 楼层高度 * @return */ private int dp(int i, int j) { if (result[i][j] != -1) { return result[i][j]; } if (i == 1) { return j; } if (j <= 1) { return j; } int lowest = log(j + 1); if (i >= lowest) { result[i][j] = lowest; return lowest; } int left = 1, right = j; while (left <= right) { int k = (left + right) / 2; int broken = dp(i - 1, k - 1); result[i - 1][k - 1] = broken; int notBroken = dp(i, j - k); result[i][j - k] = notBroken; if (broken < notBroken) { left = k + 1; } else if (broken > notBroken) { right = k - 1; } else { return notBroken + 1; } } //没找到,最小值就在left或者right中 return Math.min(Math.max(dp(i - 1, left - 1), dp(i, j - left)), Math.max(dp(i - 1, right - 1), dp(i, j - right))) + 1; } private static int log(int x) { double r = (Math.log(x) / Math.log(2)); if ((r == Math.floor(r)) && !Double.isInfinite(r)) { return (int) r; } else { return (int) r + 1; } } }
如今状态转移方程的时间复杂度降为了O(log2N),算法的时间复杂度降为O(Nlog2^2 N)。
如今不管是状态总数仍是状态转移方程都很难优化了,但还有一种算法有更低的时间复杂度。
咱们定义一个新的状态g(i,j),它表示用j个蛋尝试i次在最坏状况下能肯定E的最高楼层数。
假设在k层扔下一只鸡蛋:
若是碎了,则在后面的(i-1)次里,咱们要用(j-1)个蛋在下面的楼层中肯定E。为了使 g(i,j)达到最大,咱们固然但愿下面的楼层数达到最多,这是一个子问题,答案为 g(i-1,j-1)。
若是没碎,则在后面(i-1)次里,咱们要用j个蛋在上面的楼层中肯定E,这一样须要楼层数达到最多,便为g(i-1,j) 。
所以动态转移方程为:
g(i,j)=g(i-1,j-1)+g(i-1,j)+1
当i=1时,表示只尝试一次,那最多只能肯定一层楼,即g(1,j)=1 (j>=1)
当j=1是,表示只有一个蛋,那只能第一层一层层往上扔,最坏状况下一直扔到顶层,即g(i,1)=i (i>=1)
。
而后咱们的目标就是找到一个尝试次数x,使x知足g(x-1,m)<n
且g(x,m)>=n
。
public class EggDrop { private int dp(int iTime, int j) { if (iTime == 1) { return 1; } if (j == 1) { return iTime; } return dp(iTime - 1, j - 1) + dp(iTime - 1, j) + 1; } public int superEggDrop(int i, int j) { int ans = 1; while (dp(ans, i) < j) { ans++; } return ans; } }
这个算法的时间复杂度是O(根号N),证实比较复杂,这里就不展开了,能够参考文末文献。
最后咱们总结一下动态规划算法的解题方法:
动态规划在算法中属于较难的题型,难点就在定义子问题和写出动态转移方程。因此须要勤加练习,训练本身的思惟。
这里给出几道动态规划的经典题目,这几道题都须要吃透,能够用本文中提到的四步走的方式来思考和解题。
Maximum Length of Repeated Subarray
Coin Change
Partition Equal Subset Sum