それ以外の場合はないでしょう、あなたはフロント階建ての建物であり、mのn個の卵、高台にまたはf層がダウン投げ置くために、卵が破壊されることを前提から卵を持っていると仮定します。あなたは、fの値を決定するための戦略、卵を投げ最悪の場合の最小数を設計する必要があります。
この質問は一見非常に抽象的で、一部の人がこれをやったことがない話題、無知の力を見ることができます。実際には、慌てないでください、その後、対象の添えもの、そして最終的に解決するためになじみのデータ構造とアルゴリズムに抽象化することができます。
いずれかの卵
まず、我々は場合は、簡易版から理解し始める卵の数を制限していない、つまり、対象は、n階建ての建物と卵の数は無制限に変更しました。だから、この問題を解決するには、どのように?
最初のステップは、問題を完全に理解し、モデルの干渉のタイトルを排除することです。
- n個の層を構築するF階を見てみると - >実際には、目標は、配列内の1からnまでの数字を見つけることです。
- または床の代わりに代わって、高層階が低すぎる壊れた卵 - >それぞれの試みは、現在の数が大きいか小さい知ることができます。
明らかに、これはバイナリ検索の問題が解決することができます。
卵を投げること数、すなわち$ log_2(N + 1)$を見つけるためにバイナリ比較の数です 。
卵の数を制限
私たちは今、卵ケースの数を制限することを見ていることを、確かにバイナリ検索を使用することはできませんが、解決が最適値であるため、私たちは自然に動的計画法を考えます。
4つのステップ
トピックの動的プログラミングは、ここにアイデアを提供し、それは4つのステップ:
- モデルの問題、どのような最適化の目的関数?どのような制約がありますか?
- サブ分割問題(状態)
- 状態遷移方程式と初期リスト
- 最適なサブ構造特性を満たしています
モデリング
このステップは、それが良い地理的な問題解決の意味に基づいており、非常に重要です。実際には、多くの動的プログラミングについては、このような機能があります。
- 目標は、最適値を見つけることです
- 意思決定のすべてのステップは、全体の検討のための制約値があり、価格で来ます。
そして、この質問:
- 目的関数
f(n)
:fはレイヤ1〜n個の床を見つけるための試行回数を表し、私たちの目標は見つけることですf(n)
最適値を。 - 意思決定の各ステップのコスト:卵を破壊することができ、合計対価の制約値:卵の総数。
サブ分割問題
私たちは、計画は、多段階の意思決定の動的なプロセスであることを知って、そして最終的に組合せ最適値を解決します。
簡単な例を見てみましょう、サブ分割、問題の考え方を理解し、以下の画像を参照する:
問題:宛先セットS1〜S5に開始点を探すには、最短経路T1〜T5を設定します。
この質問の分析:副問題定義されdis[i]
た制約のない宛先ノードIまでの最短距離の代表、。
質問はそれから4つの段階に分かれています。
- ステージ1は、端部に最も近いから得られる
C1~C4
最短経路の終点までノードdis[C1]~dis[C4]
。 - フェーズ2は、最も近い端から得られる
B2~B5
最短経路の終点までノードdis[B1]~dis[B5]
フェーズ1の計算結果に確立する必要があります。例端にノードB2のための二つの経路、B2〜C1、B2〜C2あり 、DIS [C1] = 2、B2 及びDIS [C2] = 3、C2 = 6の長さB2ので、; = 3の長さC1はdis[B2]=3+dis[B1]=5
。 - ステージ3とステージ4ので、最終的に得られ得ることである
dis[S1]~dis[S5]
最小の経路をもたらす、赤の2に示されています。
この質問では、dis[i]
質問のうち、サブ分割され、意思決定のすべてのステップは、問題のある子であり、かつ各サブ問題は、前のサブ問題の結果に依存しています。
したがって、動的プログラミングは、合理的なサブ問題の定義は非常に重要です。
この質問上記の2つの制約よりも、卵を投げながら、この質問で、我々は問題を処理は次のように定義されています卵と私は、覚えて、最悪の場合にはEの目的階の最小数を決定するために、建物のJの床に投げます状態のためf[i,j]
。
状態遷移方程式と初期リスト
決定はk番目の階に卵を投げるのであれば、2つの結果があります。
- 壊れた卵は、この時
e<k
、私たちは、以下の層で卵I-1、J-1を使用することができる電子を探し続けています。そして、サブ質問ですワーストケースの最小数を、必要と、答えはf[i-1,j-1]
、合計数ですf[i-1,j-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 \leq j \leq n$,总时间复杂度为$O(mn^2)$。
优化
$O(mn^2)$的时间复杂度还是太高了。能不能想办法优化一下?
优化1
决策树
首先我们知道,在一个1~n的数组中,查找目标数字,最少需要比较$log_2n$次,也就是二分查找。这个理论可以通过决策树来证明:
我们使用二叉树来表示所有的决策,内部节点表示一次扔鸡蛋的决策,左子树表示碎了,右子树表示没碎,叶子节点代表E的所有结果。每一条从根节点到叶子节点的路径对应算法求出E之前的所有决策。
内部节点(i,j),i表示鸡蛋个数,j表示在j层楼扔下。
当楼层高度n=5时,E总共有6种情况(n=0代表没找到),所以叶子节点的个数是n+1个。
而我们关心的是树的高度,即决策的次数。根据二叉树理论:当树有n个叶子节点,数的高度至少为$log_2n$,即比较次数在最坏情况下至少需要$log_2n$次,也就是当这颗树尽量平衡的时候。
换句话说,在给定楼层n的情况下,决策次数的下限是$log_2(n+1)$,这个下限可以通过二分查找达到,只要鸡蛋的数量足够(就是我们刚才讨论的不限鸡蛋的情况)。
因此,一旦状态f[i,j]的鸡蛋个数$i>log_2(j+1)$,就不用计算了,直接输出二分查找的比较次数$log_2(j+1)$即可。
这样我们的状态总数就降为$n*log_2(k+1)$,时间复杂度降为$O(n^2 log_2n)$。
代码
/**
* @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(log_2N)$,算法的时间复杂度降为$O(Nlog_2^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)<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(\sqrt{N})$,证明比较复杂,这里就不展开了,可以参考文末文献。
小结
最后我们总结一下动态规划算法的解题方法:
- 四步走:问题建模、定义子问题、动态转移方程、最优子结构。
- 时间复杂度 = 状态总数 * 状态转移方程的时间复杂度。
- 考虑是否需要设置标记,例如有的题目还要求打印出最小路径。
- 写代码,递归和循环选择你熟悉的来写。
- 如果时间复杂度不能接受,考虑能不能优化算法。
优化思路
- 是否能够剪枝优化(优化1)
- 从函数本身的数学性质入手(优化2)
- 转换思路,尝试一下别的状态转移方程(优化3)
- ……
動的計画法は、アルゴリズムでより困難な質問、サブ問題の定義の難しさに所属し、動的に転送する式を書きます。自分の思考を訓練、アクティブ実際に従事することが必要です。
ここではいくつかの古典的な動的プログラミングの問題である、これらの質問は、あなたがこの記事を使用することができ、徹底的に理解する必要がありますについて説明し、4段階の思考と問題解決の方法を。
繰り返し部分配列の最大長
コインの変更
パーティション均等サブセット合計