一道老生常谈有意思的面试题思考

题目

有一栋楼共N层,一个鸡蛋从第M层及以上的楼层落下来会摔破, 在第M层以下的楼层落下不会摔破。给你Q个鸡蛋,设计方案找出M,并且保证在最坏情况下, 最小化鸡蛋下落的次数。

这道题目经常在面试中问到,很多博客也给出了答案,但总感觉不全面,没有讲透彻,依据前人经验和自己的理解,从思路和实现两个方面进行思考,看一看采取哪一种算法合适。

为了简化问题,先假定有2个鸡蛋,100层楼。

假设最坏情况下,至多扔k次,那第一次需要在第k层扔下,会有两种情况:

  1. 碎了。这时只剩下一个鸡蛋,只能从1层,一层层往下扔,最坏情况下从第k-1层扔下,如果在k-1层碎了,那N=k-1,总共扔了k次,如果没碎,那N=k,总共也扔了k次。
  2. 没碎。这时手上还有2个鸡蛋,从k+1层开始往下扔,还可以扔k-1次,1到k层,最多扔k次,k-1次最多扔k-1层,所以第二次在k+k-1层往下扔,如果第二次扔没碎,第三次在k+k-1+k-2=3k-3层上扔,依此类推。
    所以得出,2个鸡蛋的时候,k次机会,最多可以从\(k+k-1+k-2+k-3+….+1 = \frac{k(k+1)} {2}\)层扔下,只要找到最小的k,使$\frac{k(k+1)} {2} >= 100 $,就找到了第一次扔的k层,容易得到k=14。
    这样就能保证在找到M时,扔的次数最多不超过14次。

第一种思路:

假设\(f[n][m]\)表示n个鸡蛋,m层时,最坏情况下,至多扔的次数(f是一个二维数组)。
\(f[2][100]=1+max(f[1][k-1],f[2][100-k];(k为第一次扔的楼层)\)

  • 常数1表示第一次在k层扔下了一个鸡蛋。
  • f[1][k-1]表示当第一次在k层扔下第一个鸡蛋时,碎了,还剩一个鸡蛋,只能在k-1层楼范围扔了。
  • f[2][100-k]表示第一次在k层扔下第一个鸡蛋时没有碎,那么还剩下2个鸡蛋,100-k层楼。

如果有3个鸡蛋,100层楼时,\(f[3][100]=1+max(f[2][k-1],f[3][100-k]);\)
可以类推得到\(f[n][m]=1+max(f[n-1][k-1],f[n][m-k])\)

第二种思路:

上面已经得到2个鸡蛋,k次机会,最多可以测试\(\frac{k(k+1)} {2}\)层楼。
假如有3个鸡蛋,k次机会,第一次测试碎了后,只剩下k-1次机会,必须要把剩下的楼层测试完。2个鸡蛋,k-1机会,最多测试\(\frac{(k-1)k} {2}\)层楼,所以第一次测试的楼层为\(\frac{k(k-1)} {2}+1\),如果第一次测试没有碎,第二次增加\(\frac{(k-1)(k-2)} {2}+1\)层,所以三个鸡蛋,k次机会,总共能够测试的楼层为
\[\frac{k(k-1)} {2}+1+ \frac{(k-1)(k-2)} {2}+1+\cdots+\frac{1*0} {2}+1 = \frac{1} {2} \left[ \left(1^2+2^2+3^2+\cdots+k^2\right) + \left(1+2+3+\cdots+k\right)-k(k+1)\right]+k = \frac{k^3+5k} {6}\]

总结
\(f(n,k)\)表示n个鸡蛋,第一次在k层楼时,最多扔的楼层数(f是一个函数)。
\(f(1,k)=k;\)
\(f(2,k)=f(1,k-1)+f(1,k-2)+….+f(1,0)+k;\)
\(f(3,k)=f(2,k-1)+f(2,k-2)+f(2,k-3)+….+f(2,0)+k\)
\(……\)
\(……\)
\(f(n,k)=f(n-1,k-1)+f(n-1,k-2)+….f(n-1,0)+k;\)

两种思路总结

第一种思路是一种直接的方式,直接求解。   
第二种思路是一种迂回的方式,求n个鸡蛋,k次最多能测试多少层。   

编码实现

自己对于java最熟悉,就使用java进行编码

先给出两种思路的实现代码,最后再解释。代码中省略对楼层和鸡蛋数量有效性的检查。

第一种思路

这一种思路是大多数博客常用的思路,解法也都是动态规划,这里仍然使用动态规划。

  • 动态规划
    int getFloor(int floorNum,int eggNum){
        if(eggNum < 1 || floorNum < 1) return 0;
        //f二维数据存储着eggNum个鸡蛋,从floorNum楼层扔下来最怀情况下,所需最多的次数
        int[][] f = new int[eggNum+1][floorNum+1];

        for(int i=1;i<=eggNum; i++){
            for(int j=1; j<=floorNum; j++)
                f[i][j] = j;//初始化,最坏的次数
        }

        for(int n=2; n<=eggNum; n++){
            for(int m=1; m<=floorNum; m++){
                for(int k=1; k<m; k++){
                    f[n][m] = Math.min(f[n][m],1+Math.max(f[n-1][k-1],f[n][m-k]));
                }
            }
        }
        return f[eggNum][floorNum];
    }

第二种思路

这一种思路,考虑使用递归和动态规划,动态规划用了两种方式实现。

  • 递归(1)
     /**
   * 递归
   * @param floorNum 楼层数
   * @param eggNum  鸡蛋数
   * @return 在最怀情况下,鸡蛋最多下落的次数
   */
    int getFloor(int floorNum,int eggNum){
     //从1层依次往上计算最大测试楼层
      for(int i=1;i<=floorNum;i++){
          if(maxFloor(eggNum,i)>=floorNum){
              return i;
          }
      }
      return 0;
  }

  /**
   * eggNum鸡蛋,k次尝试最大能测试的楼层数
   * @param eggNum 鸡蛋数量
   * @param k      尝试次数
   * @return       最大测试的楼层数
   */
  int maxFloor(int eggNum,int k){      
      //f(1,k)=k
      if (eggNum==1) return k ;
      int result=0;
     
      //计算f(eggNum,k)=f(eggNum-1,k-1)+f(eggNum-1,k-2)+....f(eggNum-1,0)+k
      for(int i=0;i<k;i++){
          result += maxFloor(eggNum-1,i);
      }

      result += k;

      return result;
  }
  • 动态规划(1)
       /**
     * 动态规划
     * @param floorNum 楼层数
     * @param eggNum  鸡蛋数
     * @return 在最怀情况下,鸡蛋最多下落的次数
     */
    int getFloor(int floorNum,int eggNum){
        int[][] f=new int[eggNum+1][floorNum+1];

        for(int j=0;j<=floorNum;j++){
            f[1][j]=j;
            f[0][j]=0;
        }

        if (eggNum==1){
            return floorNum;
        }

        for(int i=2;i<=eggNum;i++){
            f[i][0]=0;
            //从低层依次住上下落
            for(int j=1;j<=floorNum;j++){
                f[i][j]=0;
                //计算f(eggNum,k)=f(eggNum-1,k-1)+f(eggNum-1,k-2)+....+f(eggNum-1,0)+k
                for(int q=1;q<=j;q++){
                    f[i][j] += f[i-1][q-1];
                }
                f[i][j] +=j;//此处使用j,开始写成了k
                //比较第一次在j层落下时,最大测试的楼层数与总楼层数
                if(f[i][j]>=floorNum){
                    //如果超过总楼层数且等于鸡蛋数量,则返回,否则不必再计算
                    if(i==eggNum) {
                        return j;
                    }else{
                        break;
                    }
                }
            }
        }

        return 0;
    }
  • 动态规划(2)


    /**
     *
     * @param floorNum 楼层数
     * @param eggNum   鸡蛋数
     * @return         最坏情况下,至多测试的次数
     */
         int getFloor(int floorNum,int eggNum){
            for(int i=1;i<=floorNum;i++){
            if(f(eggNum,i)>=floorNum){
                return i;
            }
        }
        return 0;
    }

    /**
     * 
     * @param eggNum 鸡蛋数量
     * @param k      K次尝试
     * @return       最大测试的楼层数
     */
     int f(int eggNum,int k){
        int[][] f=new int[eggNum+1][k+1];

        for(int j=0;j<=k;j++){
            f[1][j]=j;
            f[0][j]=0;
        }

        if (eggNum==1){
            return f[1][k];
        }

        for(int i=2;i<=eggNum;i++){
            f[i][0]=0;
            for(int j=1;j<=k;j++){
                f[i][j]=0;
                //计算f(eggNum,k)=f(eggNum-1,k-1)+f(eggNum-1,k-2)+....+f(eggNum-1,0)+k
                for(int q=1;q<=j;q++){
                    f[i][j] += f[i-1][q-1];
                }
                f[i][j] +=j;//此处使用j,开始写成了k
            }
        }

        return f[eggNum][k];
    }

测试

  • 3个鸡蛋,100层楼
第二种思路-递归:第9层,耗时0ms
第二种思路-动态规划1:第9层,耗时0ms
第二种思路-动态规划2:第9层,耗时0ms
第一种思路-动态规划:第9层,耗时1ms
  • 10个鸡蛋,10000层楼
第二种思路-递归:第14层,耗时0ms
第二种思路-动态规划1:第14层,耗时1ms
第二种思路-动态规划2:第14层,耗时0ms
第一种思路-动态规划:第14层,耗时478ms
  • 2个鸡蛋,100000层楼
第二种思路-递归:第447层,耗时2ms
第二种思路-动态规划1:第447层,耗时2ms
第二种思路-动态规划2:第447层,耗时36ms
第一种思路-动态规划:第447层,耗时5281ms
  • 60鸡蛋,10000000层楼
第二种思路-递归:第24层,耗时102ms
第二种思路-动态规划1:第24层,耗时641ms
第二种思路-动态规划2:第24层,耗时16ms
第一种思路运行中.....

可以看出,第一种思路实现方式运行是最慢的,因为需要从小到大(eggNum从2开始,floorNum从1开始)循环嵌套计算二维数组每一项的值。而第二种思路动态规划2,当得出的层数较矮时,优势明显,层数比较多时,就慢于第二种思路动态规划1,因为动态规划2,得到的结果楼层越矮时计算的越快,而动态规划1也是嵌套循环计算,但只要计算到可测试最大楼层大于或等于总楼层就停止计算,比第一种思路的动态规划要快。所以没有哪一种算法是最优的,需要根据数据量的多少和算法具体的实现方式来决定采取哪一种实现方法。