美文网首页动态规划
算法题:鸡蛋掉落

算法题:鸡蛋掉落

作者: fghong | 来源:发表于2019-03-13 16:19 被阅读464次

    算法:鸡蛋掉落

    昨天刷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,我们可以用同样的思想去降低它的空间复杂度。

    相关文章

      网友评论

        本文标题:算法题:鸡蛋掉落

        本文链接:https://www.haomeiwen.com/subject/scdtmqtx.html