算法题:鸡蛋掉落

算法:鸡蛋掉落

昨天刷leetcode发现一道很有意思的题目,乍一看好像很简单,但通过率只有17%。记录一下自己的解题思路。

题目描述

你将获得 K 个鸡蛋,并可以使用一栋从 1N 共有 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次;

鸡蛋掉落_1.png

所以如果我们第一次来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

鸡蛋掉落_2.png

假如鸡蛋碎了,我们将来到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.

鸡蛋掉落_3.png

图中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.png

上面的表格表示最多有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,我们可以用同样的思想去降低它的空间复杂度。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容