假设你面前有一栋n层的大楼和m个鸡蛋,假设将鸡蛋从f层或更高的地方放扔下去,鸡蛋才会碎,否则就不会。你需要设计一种策略来确定f的值,求最坏情况下扔鸡蛋次数的最小值。

leetcode原题链接

乍一看这道题很抽象,可能有的人一看到这个题目从来没做过,就懵逼了。其实不用慌张,再花里胡哨的题目,最后都可以抽象成我们熟悉的数据结构和算法去解决。

不限鸡蛋

首先,我们从一个简单的版本开始理解,假如不限制鸡蛋个数,即把题目改成n层大楼和无限个鸡蛋。那么这题要怎么解呢?

第一步就是要充分理解题意,排除题目中的干扰,建立模型:

  • 在一栋n层的大楼中寻找目标楼层f --> 其实就是在一个1~n的数组中查找一个目标数字。
  • 鸡蛋碎了就代表楼层过高,否则就代表楼层过低 --> 每次尝试都能知道当前数字是大了还是小了。

很显然,这就是一个二分查找能解决的问题。

扔鸡蛋的次数就是二分查找的比较次数,即log2(n+1)。

限制鸡蛋个数

那我们现在再来看限制鸡蛋个数情况下,肯定没法用二分查找,但是由于求解的是一个最优值,我们自然而然地想到了动态规划。

四步走

动态规划的题目,这边提供一个思路,就是四步走

  1. 问题建模,优化的目标函数是什么?约束条件是什么?
  2. 划分子问题(状态)
  3. 列出状态转移方程及初值
  4. 是否满足最优子结构性质

建模

这一步非常非常重要,它建立在良好地理解题意的基础上。其实很多动态规划的题目都有这样的特点:

  1. 目标是求一个最优值
  2. 每一步决策有代价,总代价有一个约束值。

而这道题:

  1. 目标函数f(n):代表在1~n的楼层中找到f层的尝试次数,我们的目标就是求出f(n)的最优值。
  2. 每一步决策的代价:鸡蛋可能会碎;总代价的约束值:鸡蛋总个数。

划分子问题

我们知道动态规划就是多阶段决策的过程,最后求解组合最优值。

我们先举一个简单例子,来理解划分子问题的思路,看下面这张图:



问题:求起点集 S1~S5到终点集 T1~T5的最短路径。

分析这道题:定义子问题dis[i]代表节点i到终点的最短距离,没有约束条件。

然后问题划分为4个阶段:

  1. 阶段1求出离终点最近的C1~C4节点到终点的最短路径dis[C1]~dis[C4]
  2. 阶段2求出离终点最近的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
  3. 阶段3和阶段4也是以此类推,最终就求出得到dis[S1]~dis[S5],得出最小路径为图中红色的两条。

在这道题中,dis[i]就是划分出来的子问题,每一步决策都是一个子问题,而且每一个子问题都依赖于以前子问题的计算结果。

因此,在动态规划中,定义一个合理的子问题非常重要

而扔鸡蛋这道题比上面这道题多了个约束条件,我们把子问题定义为:用i个鸡蛋,在j层楼上扔,在最坏情况下确定目标楼层E的最少次数,记为状态f[i,j]

列出状态转移方程和初值

假如决策是在第k层扔下鸡蛋,有两种结果:

  1. 鸡蛋碎了,此时e<k,我们只能用i-1个蛋在下面的k-1层继续寻找e。并且要求最坏情况下的次数最少,这是一个子问题,答案为f[i-1,k-1],总次数便是f[i-1,k-1]+1
  2. 鸡蛋没碎,此时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

决策树

首先我们知道,在一个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;
}

优化2

优化还未结束,我们尝试从动态转移方程的函数性质入手,观察函数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)。

优化3

现在无论是状态总数还是状态转移方程都很难优化了,但还有一种算法有更低的时间复杂度。

我们定义一个新的状态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)<ng(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),证明比较复杂,这里就不展开了,可以参考文末文献。

小结

最后我们总结一下动态规划算法的解题方法:

  • 四步走:问题建模、定义子问题、动态转移方程、最优子结构。
  • 时间复杂度 = 状态总数 * 状态转移方程的时间复杂度。
  • 考虑是否需要设置标记,例如有的题目还要求打印出最小路径。
  • 写代码,递归和循环选择你熟悉的来写。
  • 如果时间复杂度不能接受,考虑能不能优化算法。

优化思路

  • 是否能够剪枝优化(优化1)
  • 从函数本身的数学性质入手(优化2)
  • 转换思路,尝试一下别的状态转移方程(优化3)
  • ……

动态规划在算法中属于较难的题型,难点就在定义子问题和写出动态转移方程。所以需要勤加练习,训练自己的思维。

这里给出几道动态规划的经典题目,这几道题都需要吃透,可以用本文中提到的四步走的方式来思考和解题。

Maximum Length of Repeated Subarray

Coin Change

Partition Equal Subset Sum

最后:

参考文献

国家集训队朱晨光论文

知乎-如何用最少的次数测出鸡蛋会在哪一层摔碎?

面试官:你有m个鸡蛋,如何用最少的次数测出鸡蛋会在哪一层碎?的更多相关文章

  1. 每日一问:面试结束时面试官问"你有什么问题需要问我呢",该如何回答?

    面试结束时面试官问"你有什么问题需要问我呢",该如何回答?

  2. 面试官的七种武器:Java篇

    起源 自己经历过的面试也不少了,互联网的.外企的,都有.总结一下这些面试的经验,发现面试官问的问题其实不外乎几个大类,玩不出太多新鲜玩意的.细细想来,面试官拥有以下七种武器.恰似古龙先生笔下的武侠世界 ...

  3. 关键词:ACM & 大小端 & 面试官

    关于“ACM” fender0107401 :面试了一个在ACM拿过奖的人 我问了他几个问题: 读取数组中的一个元素,计算复杂度是多少,回答不清楚. 往链表里面存一个数,不排序的情况下,计算复杂度是多 ...

  4. Android开发面试经——6.常见面试官提问Android题②(更新中...)

    版权声明:本文为寻梦-finddreams原创文章,请关注:http://blog.csdn.net/finddreams 关注finddreams博客:http://blog.csdn.net/fi ...

  5. Android开发面试经——5.常见面试官提问Android题①

    版权声明:本文为寻梦-finddreams原创文章,请关注:http://blog.csdn.net/finddreams 关注finddreams博客: http://blog.csdn.net/f ...

  6. 书评<<剑指offer 名企面试官精讲典型编程题>>

      前前后后阅读了一周, 感慨很多, 面试考察的是一个人的综合能力, 这一点从面试官的角度去解读, 确实对面试的理解更立体. *) 具体考察的点1) 扎实的基础2) 高质量的代码3) 清晰的思路4) ...

  7. 我是面试官--"自我介绍"

    工作10余年,经历过很多次面试,也面试了N多人.这些年来,已经有好些位朋友(或同事)与我聊起相关话题,涉及面试,更关乎职业生涯规划.感触颇多,就借助自媒体的浪潮,与更多的程序员一起共谈面试经历,希望可 ...

  8. 一个资深java面试官的“面试心得”

    在公司当技术面试官几年间,从应届生到工作十几年的应聘者都遇到过.先表达一下我自己对面试的观点: 1.笔试.面试去评价一个人肯定是不够准确的,了解一个人最准确的方式就是“路遥知马力,日久见人心”.通过一 ...

  9. 漂亮回答面试官struts2的原理

    众所周知,Struts2是个非常优秀的开源框架,我们能用Struts2框架进行开发,同时能快速搭建好一个Struts2框架,但我们是否能把Struts2框架的工作原理用语言表达清楚,你表达的原理不需要 ...

随机推荐

  1. POJ - 3436 ACM Computer Factory 网络流

    POJ-3436:http://poj.org/problem?id=3436 题意 组配计算机,每个机器的能力为x,只能处理一定条件的计算机,能输出特定的计算机配置.进去的要求有1,进来的计算机这个 ...

  2. .NET Core CSharp 中级篇2-8 特性标签

    .NET Core CSharp 中级篇2-8 本节内容为特性标签 简介 标签Attribute是一个非常重要的技术,你可以使用Attribute技术优化精简你的代码.特性标签可以运用在程序集,模块, ...

  3. 【5】SVM算法原理

    大纲 简介 支持向量机(support vector machines)是一个二分类的分类模型(或者叫做分类器).如图: 它分类的思想是,给定给一个包含正例和反例的样本集合,svm的目的是寻找一个超平 ...

  4. GA,RC,Alpha,Beta,Final等软件版本名词释义

    对应上图的表格如下: 名词 说明 Alpha α是希腊字母的第一个,表示最早的版本,内部测试版,一般不向外部发布,bug会比较多,功能也不全,一般只有测试人员使用. Beta β是希腊字母的第二个,公 ...

  5. Intro to Machine Learning

    本节主要用于机器学习入门,介绍两个简单的分类模型: 决策树和随机森林 不涉及内部原理,仅仅介绍基础的调用方法 1. How Models Work 以简单的决策树为例 This step of cap ...

  6. Go语言标准库之context

    在 Go http包的Server中,每一个请求在都有一个对应的 goroutine 去处理.请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务.用来处理一个请 ...

  7. idea中applicationContext-dao.xml文件中Cannot resolve file***** :spring xml model validation问题

    访问不了classpath下的文件夹中的文件 解决办法如下:(问题出在我创建的resources文件夹是一个普通的文件夹) 1.本来是普通的文件夹 2.ctrl+shift+alt+s打开如下界面: ...

  8. Vue 利用指令实现禁止反复发送请求

    前端做后台管控系统,在某些接口请求时间过长的场景下,需要防止用户反复发起请求. 假设某场景下用户点击查询按钮后,后端响应需要长时间才能返回数据.那么要规避用户返回点击查询按钮无外乎是让用户无法在合理时 ...

  9. .net core Cookie的使用

    缘起: 公司领导让我做一个测试的demo,功能大概是这样的:用户通过微信扫一扫登陆网站,如果用户登录过则直接进入主界面,否则就保留在登录界面. 实现方法: 首先先把网站地址生成个二维码,在扫描二维码后 ...

  10. Dart语言概览

    ## Dart特性 Dart同时支持JIT(Just In Time,即时编译)和AOT(Ahead of Time,运行前编译)两种编译模式. **JIT** 在运行时即时编译,在开发周期中使用,可 ...