vlambda博客
学习文章列表

记一个美团的二分查找面试题

今天分享一道有趣的二分查找的题目,这也是美团面试遇到的比较难的一题,让我们一起看看怎么去解决它。

连续段最大的中位数

先看看问题:

牛牛得到了一个长度为n的正整数序列,现在牛牛想要从里面取出一段连续的长度大于等于k的序列。
定义一个序列的“中数”为最大的整数x,使得序列中至少一半的数字大于等于x
牛牛想知道这个取出来的序列的中数最大可以是多少?

其实问题本身就有点难理解,当时我读这道题读了好几遍都不太明白题目想表达的含义。我们配合提供的示例来说明下这个题目是什么意思。

输入:5,3,[30,1,2,31,9]  
输出:30  
说明:选前四个数字组成[30,1,2,31],中数为30

我们以示例来说明,n=5为数组的长度,k=3是要取出序列的最小长度。那么第一件事情,我们可以枚举出所有的序列,分别是

  • [30,1,2]

  • [1,2,31]

  • [2,31,9]

  • [30,1,2,31]

  • [1,2,31,9]

  • [30,1,2,31,9]  

我们共计可以枚举出六个序列。然后再来看什么是中位数。对于每一个序列来说,如果有一个数字能够让序列中一半的数字都大于等于它,那么这个数字就是这个序列的中位数。换句话说我们对数组排序,取下标是n>>1的数字,他就是中位数。

再回到上面的六个序列

  • [30,1,2] 中位数是2

  • [1,2,31] 中位数是2

  • [2,31,9] 中位数是9

  • [30,1,2,31] 中位数是30

  • [1,2,31,9] 中位数是9

  • [30,1,2,31,9] 中位数是9  

因此这六个序列中最大的中位数就是30,也就是本体的答案说明。

暴力法

最直观的解法就是暴力法,我们枚举所有可能的序列,然后算出中位数,取最大的一个即可。但是这种必然面临着超时的问题,由于暴力法比较简单我们就不写代码了。

二分查找

回顾一下二分查找的运用场景,最简单的莫过于猜数字。例如主持人在0-100中想一个数让参赛者来猜,第一次参赛者猜50,根据主持人的反馈猜的这个数字偏大还是偏小,继而猜下个数字。如果主持人想的数字比50大,那么这个数字必然在50-100之间,那么参赛者会猜75;如果比50小,那么参赛者会猜25。这就是二分查找的应用场景之一。

再比如之前提到的自己实现一个简单的开平方功能。我们在0到目标值中间取平均值,判断这个平均值平方以后和目标值的关系,继而来调整范围。

所以根据以上两个场景我们可以总结出二分查找的步骤:

  1. 找出答案的上下限,假如答案是ans,a是下限,b是上限,满足a<=ans<=b。

  2. 算出ab的平均值,判断平均值和目标值的关系。例如猜数字中会判断平均值和目标值是大还是小;在开方题目中,则是判断平均值的平方和目标值的关系。

  3. 根据2中的关系,来更改a或者b的取值,从而缩小范围。循环1,2步。

  4. 如果a和b相等或者差值在很小的误差范围内,那么可认为已经找到所需的答案了。

回到题目,我们可以类比的列出解这道题的思路步骤:  

  1. 因为答案必然在数组中,我们可以将下限设定为数组中最小的数,上限设定为数组中最大的数。

  2. 这一步是比较难的,如果关系想清楚了,那么这道题就迎刃而解了。假如我们有一个平均值,这个平均值在数组的某个序列中是中位数,那么我们可以认为下限还可以继续增大,如果这个数字不是任何序列的中位数,那么可以认为上限应该降低。所以这道题的关键在于怎么比较高效的遍历所有序列去确定平均值是不是中位数。

  3. 如果调整下限,可令新的下限是mid+1;如果调整上限,可令新的上限是mid-1。

  4. 如果上下限重叠或者下限超过了上限,那么可认为找到答案。

1.找到上下限,很简单:

int max = Integer.MIN_VALUE;
    int min = Integer.MAX_VALUE;
    for (int i = 0; i < n; i++) {
      max = Math.max(a[i], max);
      min = Math.min(a[i], min);
    }

2.如何去匹配关系

while (l <= r) {
      // 中间的数
      int mid = (l + r) >> 1;
      //比中位数大的数
      int greaterThanMidCount = 0;
      // 先看mid在前面0到k-1个数字中是否是中位数
      for (int i = 0; i < k; i++) {
        if (a[i] >= mid) {
          //大于等于中位数,则数量+1
          greaterThanMidCount++;
        } else {
          greaterThanMidCount--;
        }
      }
      // 说明mid可以增加
      if (greaterThanMidCount >= 0) {
        ans = mid;
        l = mid + 1;
        continue;
      }
      // 如果在前面k个数字中,mid无法成为它们的中位数才会走到这里

      boolean flag = false;
      int cur = 0;
      int ps = 0;
      // 继续从第k+1个数字看
      for (int i = k; i < n; i++) {
        if (a[i] >= mid) {
          greaterThanMidCount++;
        } else {
          greaterThanMidCount--;
        }
        /*
         * 滑动窗口,靠这个去高效遍历其他序列
         * 这里从头去除元素,模拟滑动窗口
         */

        if (a[i - k] >= mid) {
          /*
           * 如果大于等于mid,可以认为去除这个数字后,
           * 新的序列中将会减少一个大于等于mid的数字,
           * 从而减小让mid成为中位数的概率
           */

          cur++;
        } else {
          /*
           * 如果小于mid,可以认为去除这个数字后,
           * 新的序列中将会减少一个小于mid的数字,
           * 从而增加让mid成为中位数的概率
           */

          cur--;
        }
        /* ps为记录上次 从头开始看 比mid数小的数字所记录的一个浮标
         * 这个值越小,mid成为中位数的概率就越大。换句话说要尽可能让
         * mid成为中位数,不断的提高下限
         */

        ps = Math.min(ps, cur);

        /*
          判断去除头部元素后 还能否是中位数
         */

        if (greaterThanMidCount - ps >= 0) {
          flag = true;
          break;
        }
      }
      // 还是可以找到以mid为中点的大于等于k的一段序列
      if (flag) {
        ans = mid;
        //提高下限
        l = mid + 1;
      } else {
        //减少上限
        r = mid - 1;
      }
    }

这一段代码有点晦涩难以理解,需要多看几遍。但是总体思想就是让mid尽可能的成为中位数,有点类似贪心算法局部最优推导出全局最优,但是这个推导过程没有说明,需要再斟酌下。

The    end

Cool Coding

喜欢就关注我,和我一起玩吧~