算法:鸡蛋掉落
昨天刷leetcode发现一道很有意思的题目,乍一看好像很简单,但通过率只有17%。记录一下自己的解题思路。
题目描述
你将获得 K
个鸡蛋,并可以使用一栋从 1
到 N
共有 N
层楼的建筑。
每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。
你知道存在楼层 F
,满足 0 <= F <= N
任何从高于 F
的楼层落下的鸡蛋都会碎,从 F
楼层或比它低的楼层落下的鸡蛋都不会破。
每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X
扔下(满足 1 <= X <= N
)。
你的目标是确切地知道 F
的值是多少。
无论 F
的初始值如何,你确定 F
的值的最小移动次数是多少?
示例 1:
输入:K = 1, N = 2
输出:2
解释:
鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。
否则,鸡蛋从 2 楼掉落。如果它碎了,我们肯定知道 F = 1 。
如果它没碎,那么我们肯定知道 F = 2 。
因此,在最坏的情况下我们需要移动 2 次以确定 F 是多少。
示例 2:
输入:K = 2, N = 6
输出:3
示例 3:
输入:K = 3, N = 14
输出:4
二分法?
题目还没看完,脑中就闪过:这不就是个二分法吗。logN得咧!等等,要是这么简单这题通过率会只有17%吗?还有K
个鸡蛋的限制条件没有用呢,这也正是本题的难点所在。最初我也没搞清楚K个鸡蛋的限制是什么意思,他的示例给得不太完善,如果我们加一个示例,k个鸡蛋的限制就很好理解了
输入:K=1, N=3
输出:3
3层楼,按照我们的想法3层楼只需要两次就够了呀。 第一次去2楼,如果碎了,再去1楼,如果没碎,就去3楼。注意:我们只有一鸡蛋,如果第一次在2楼扔,鸡蛋碎了,那你将没有剩余的鸡蛋去1楼尝试,导致无法确定F。所以我们只能第一次去一楼,然后上二楼,依次直到顶楼。所以当K=1时,有多少层楼就得移动多少次。二分法显然是不正确的。
先二分,直到剩余一个鸡蛋?
既然鸡蛋有数量限制,那我们可以先进行二分查找,直到鸡蛋只剩一个鸡蛋了再逐层查找。先来到中间层X
,扔下鸡蛋,如果鸡蛋没碎,那我们应该往上走;很显然这不是我们应该考虑的情况,如果鸡蛋没碎那我们将能够一直往上进行二分。我们应该考虑的是最坏情况,每一次扔鸡蛋都碎了,能够进行二分的机会越来越少,直到把鸡蛋用到只剩一个,此时剩余的楼层就是我们要移动的次数;或者一直二分直到走到1楼。那这种思路对不对呢?找一个简单点的示例试一下:
输入K=2, N=9
1 2 3 4 |5| 6 7 8 9
按照面上的思路我们第一次来到X=5
楼,扔下鸡蛋,碎了,下面还剩4楼没有尝试,所以总共需要移动4+1=5次。
那如果我们第一次不去5楼,X选择4,试一下:
第一次来到四楼,假如鸡蛋碎了,剩余一个鸡蛋,还有3层楼没有尝试,所以中共需要3+1=4次;
假如鸡蛋没碎,我们来到7楼,手里还有两个鸡蛋,扔下一个鸡蛋,此时不管鸡蛋碎没碎,很明显都还需要两次尝试。总共需要:1+1+2=4次;
所以如果我们第一次来4楼而不是5楼,不管是哪种情况,最坏也只需要4次移动就行了,而不是5次。显然,这种尝试又失败了,但这种尝试为进一步思考提供了重要思路。
解法分析
通过上一步的尝试,我发现了一个很重要的规律。假设,总共有K
个鸡蛋,楼有N
层,我们第一次来到X
层。首先我们来到X
层,扔下鸡蛋,此时大楼可以被看做分成了三部分:
- 第一部分:X层,进行了一次尝试
- 第二部分:X之下的X-1层,手里还有k-1个鸡蛋
- 第三部分:X之上的N-X层,手里还有k个鸡蛋
第二部分和第三部分我们可以把他们看做全新的两栋楼,分别称为T1和T2,因为我们只关心有多少层,而不关心是第几层。
以上面的K=2, N=9
为例。我们取X=4
:
假如鸡蛋碎了,我们将来到T1
,手里还有1
个鸡蛋
假如鸡蛋没碎,我们将来到T2
,手里还有2
个鸡蛋
总共需要尝试的次数是T1尝试次数
和T2尝试次数
中较大的值加上X层尝试的一次
。
对于T2,我们可以以同样的方法把他再次分成3部分。脑中再次闪过一丝光,递归警告!
现在用数学语言来描述一下发现的规律,设f(K,N)
为手里有k个鸡蛋,楼有N层时需要尝试的次数,假设第一次来到X
层,那么:
f(K,N) = max(f(K-1,X-1), f(k,N-X)) + 1
我们需要做的是找到X
使得max(f(K-1,X-1), f(k,N-X))最小
找到合适的X
最简单暴力的方法,把X从1到N全部取一遍,找到使得max(f(K-1,X-1), f(k,N-X))
最小的X取值。但是这种做法的时间复杂度会比较高,我没有采取这一种做法,进一步分析,找到X的规律。
当X
取1时,T0只有0层,所以f(K-1,X-1)= 0
,而T2有N层,f(k,N-X)
此时是它能取到的最大值。 随着X
不断增大,f(K-1,X-1)
也随之增大,f(k,N-X)
不断减小,直到X
取N,f(K-1,X-1)
达到最大,f(k,N-X)
为0.
图中T1即是f(K-1,X-1)
的取值变化,T2即是f(k,N-X))
的取值变化。红色标记即为max(f(K-1,X-1), f(k,N-X))
的取值变化,很显然当T1和T2交汇时它就取到了最小值。
进一步分析,当K
鸡蛋数量一定时,X
一定是随着N
楼层数增大而增大的。例如K=2,N=9时,我们取了X=4,当N=10 , 11 ...,X不可能再取3。所以我们一步一步增大N
时,不必从头去找X
,而是在上一步的X
之上,看是不是需要增大X
。
解法1
根据上面的分析,我们已经得到了解题的思路,核心:
f(K,N) = max(f(K-1,X-1), f(k,N-X)) + 1
X在f(K-1,X-1), f(k,N-X)
交汇处得到最小值。
然后是核心方程的边际条件:很容易分析,当鸡蛋只有一个时,f(K,N) = N; 当N为1层楼时,只需要一次;当N为2层时,也一定需要两次;
K=1 or N<=2 : f(K,N) =N
else: f(K,N) = max(f(K-1,X-1), f(k,N-X)) + 1
得到上面的状态转移方程之后,递归代码已经呼之欲出了。但是当你脑中闪过递归的时候,应该立马意识到,这个递归中有非常多的重复子问题,应该用动态规划来解
function superEggDrop(K, N){
// K=1 or N<=2 : f(K,N) =N
if( N <= 2 || K === 1 ) return N
const aux = new Array(K)
// 初始化一个K行, N+1列的二维数组(多一个0层方便计算)
for( let i = 0; i < K; i++ ){
aux[ i ] = new Array(N + 1)
// N<=2 : f(K,N) =N
aux[ i ][ 0 ] = 0
aux[ i ][ 1 ] = 1
aux[ i ][ 2 ] = 2
}
// K=1 : f(K,N) =N
for( let i = 3; i <= N; i++ ){
aux[ 0 ][ i ] = i
}
// aux[e][f] 代表有e个鸡蛋,f层楼时,需要移动的次数
for( let e = 1; e < K; e++ ){
let x = 1
for( let f = 3; f <= N; f++ ){
// x取交汇处
if( aux[ e - 1 ][ x - 1 ] < aux[ e ][ f - x ] ){
x++
}
// f(K,N) = max(f(K-1,X-1), f(k,N-X)) + 1
aux[ e ][ f ] = aux[ e - 1 ][ x - 1 ] + 1
}
}
return aux[ K - 1 ][ N ]
}
代码的注释解释了每一段代码的用处。x取交汇处,把if
换成 while
可能会方便理解一些:我们一直往上找直到找到交汇处。 但是我们注意到f
每一次只加1,那么X只会加0或者加1。while
只会运行0次或一次,和 if
作用是一样的。
当X
满足if
条件后,max(f(K-1,X-1), f(k,N-X))
一定等于f(K-1,X-1)
,所以我直接取aux[ e ][ f ] = aux[ e - 1 ][ x - 1 ] + 1
而不是aux[ e ][ f ] = Math.max(aux[ e - 1 ][ x - 1 ],aux[ e ][ f - x ]) + 1
多此一举。
该算法的时间复杂度O(KN),如果有用递归解法去解的同学,可以分析一下递归的时间复杂度。
空间复杂度O(KN),对于很多动态规划来说,空间复杂度是可以进行优化的,文末会讲到。
另一种思路
做出上面的解法1之后,我看到评论区有代码使用了不同的思路;同样是动态规划,我的思路是求K个鸡蛋在N层楼的情况下求解需要多少步。 另一种思路是 求K个鸡蛋在m步内可以测出多少层
分析一下这种思路:
设 f(k,m)
表示K个鸡蛋在m步内可以测出的楼层数量。显然我们只要找到m
使得f(k,m) >= N
就得到了解。
然后分析一下状态转移方程:
我们一共有K个鸡蛋,可以行动m次。来到X层,扔下鸡蛋,此时有两种情况:
鸡蛋碎了,鸡蛋少了一个,行动次数减少一次;往下可以测
f(k-1,m-1)
层鸡蛋没碎,鸡蛋不减,行动次数减少一次;往上可以测
f(k, m-1)
层
但是我们的状态转移方程并不是 f(k,m)= max(f(k-1, m-1), f(k, m-1)) +1
而是f(k,m) = f(k-1,m-1) + f(k, m-1) + 1
+1即测试的X层本身。
为什么是+ 而不是取max,因为之前的思路是 K个鸡蛋测N层楼最坏情况下需要移动多少次, 与之相对的应该用 k个鸡蛋移动m次数最好情况下能测多少层。
假设你拥有k个鸡蛋m移动次数,直接来到f(k-1, m-1)+1
层(注意这里的f
不再表示移动次数,f
表示的是楼层数量),事实上这一层就是我们选取的X。扔下鸡蛋最好的情况是什么?下楼是已知的不必再测,最好就是鸡蛋没碎,往上还能测f(k, m-1))
层。
这有点绕,转换一下,我们有k个鸡蛋,要测的楼有 f(k,m)= max(f(k-1, m-1), f(k, m-1)) +1
层。你来到X层(即f(k-1, m-1)+1
层),扔下鸡蛋。不管鸡蛋碎没碎,你测出F最多还需要多少步呢?鸡蛋碎了往下还需要 m-1 步,鸡蛋没碎往上也是需要m-1步。
所以在有k个鸡蛋的情况下测f(k,m)= max(f(k-1, m-1), f(k, m-1)) +1
层楼最多需要m
步。
边际条件:
只有一个鸡蛋K=1
时,能移动多少次就能测多少楼。
只能移动一次m=1
时,不管多少鸡蛋都只能测一层楼。
总结一下这种思路的状态转移方程:
k=1 : f(k,m) = m
m=1 : f(k,m) = 1
else : f(k,m)= max(f(k-1, m-1), f(k, m-1)) +1
根据上面的方程可以写出下面的动态规划算法
function superEggDrop2(K, N){
if( N <= 2 || K === 1 ) return N
// 很显然, m不可能超过N
const aux = new Array(N)
// m=1 : f(k,m) = 1
aux[ 0 ] = new Array(K).fill(1)
// aux[m][e] 表示有e+1个鸡蛋,能移动m+1是最多能测多少层楼
for( let m = 1; m < N; m++ ){
aux[ m ] = new Array(K)
// k=1 : f(k,m) = m
aux[ m ][ 0 ] = m + 1
for( let e = 1; e < K; e++ ){
// f(k,m) = f(k-1,m-1) + f(k, m-1) + 1
let f = aux[ m - 1 ][ e - 1 ] + aux[ m - 1 ][ e ] + 1
if( f >= N ){
return m + 1
}
aux[ m ][ e ] = f
}
}
}
由于没有用0填充,所以aux的角标+1才是它代表的真实值。
该算法的空间复杂度O(KN)。
当k足够小时,时间复杂度趋近O(N) , 当k足够大时,时间复杂度为O(KlongN)。
这种思路时间复杂度上会比上一种低,代码也更简单,但是没那么容易想到。
优化动态规划的空间复杂度
很多动态规划用到的二维表格,我们都可以把它优化成一个线性表来降低空间复杂度。他们有一个特点:计算只会用到临近计算值很小范围内的数据。我们以上面superEggDrop2用到的表格为例(这里填充了0方便分析,代码里面是没有填充0的):
上面的表格表示最多有4个鸡蛋时,随着move的增加最多能测多少层楼。我们从右往左计算,至于为什么不从左往右了,待会就知道了。假设正在计算move=3的行,首先计算有4个鸡蛋的情况:(3 ,4 ) = (2, 4) + (2, 3) +1 = 7。 一直往左直到egg=1 , (3,1) = (2,1) + (2,0) + 1。可以看到计算整个move=3行,只用到了move=2行的数据,再上面的数据就一直没有用了,所以我们可以考虑把他们覆盖掉,重复利用空间。
只用红色圈起来的一行,计算(3,4)的结果就放在(2,4)中,计算(3,3)结果就放在(2,3)中,这样计算完move=3后,红色的一行中方的就是刚才计算的move=3的结果,然后用这一行来计算move=4,以此类推。
如果我们从左往右,计算(3,1)覆盖(2,1),那计算(3,2)的时候需要取的(2,1)的数据就被覆盖掉了,所以我们从右往左计算,这样不会覆盖后面计算需要的数据。
function superEggDrop3(K, N){
if( N <= 2 || K === 1 ) return N
const aux = new Array(K + 1).fill(1)
aux[ 0 ] = 0
let m = 1
while( aux[ K ] < N ){
m++
for( let e = K; e > 0; e-- ){
aux[ e ] = aux[ e ] + aux[ e - 1 ] + 1
}
}
return m
}
这样改动过后算法的空间复杂度从O(KN)降低到了O(K),时间复杂度不变。
对于解法1,我们可以用同样的思想去降低它的空间复杂度。