二分查找思想的迁移应用

二分查找算法可以说是很基本的一个算法思想,如果写成题目一般是这样的形式:

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
来源:力扣704

别看这样一道小题,我在面试的那么多次中还碰到过三次,这样的题目当然是信手拈来,直接默写下来的,最多两分钟搞定。

还记得高三物理老师给我说过的一句话:“自信过头了,就会阴沟里翻船。”那时候我对物理的学习兴趣浓厚,当然有兴趣的前提是,物理最后一道大题总能作对,并且对力学的各种分析乐此不疲。物理老师对我说这句话的时候,拿着我的考试卷子做分析,答题除了计算错误扣了几分外,几乎全对,而选择题却红刷刷,左一刀,右一刀,错了一片。

求根号x

几年前,去找实习工作,我又翻了船,一个人不会踏入同一条河流,但可以在同一条河流中翻两次船。当时,面试官写了一道二分查找的题目出来,我一看题目心里就一股不屑,这题还拿出来考,直接拿过来,三笔两笔默写出来,默写出来的代码,长这个样子:

class Solution {
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        while (left <= right) {
            int mid = (right - left) / 2 + left;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return -1;
    }
}
复制代码

然后,面试官又写下了一道题目:

实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
来源:力扣-69

并且要求不能用库函数,这题该怎么做,还不能用库函数,难道用加减乘除吗?这道题目并不难,但当时我一下就懵了,就好像是我正踩着高跷走在冰面上,突然冰面碎了,我一下掉进了软绵绵的深水中,脑子一片空白,没办法思考了。

我后来回想,面试官还是挺不错的,让我去树上摘苹果,先给了我一把梯子,我却告诉面试官:“我用不着梯子,您没看见我踩着高跷呢,够得着。”

结果高跷不如梯子管用,我摔得够呛,好在面试官还是把梯子帮我竖了起来,拉我起来,在面试官的提示下,我勉强做出来了。而当时使用的解法正是二分查找的套路。

思路是这样滴!

假设根号x的整数部分为a,那么a满足

aaxa*a \le x

class Solution {
    public int mySqrt(int x) {
        if (x == 0)
            return 0;
        int left = 1, right = x;
        while(true) {
            int mid = (right - left) / 2 + left;
            if (mid > x / mid) {
                right = mid - 1;
            } else {
                if ((mid + 1) > x / (mid + 1)) {
                    return mid;
                }
                left = mid + 1;
            }
        }
    }
}
复制代码

需要注意的是,这里的代码的第8行和第11行不能用乘法代替,因为比较大的数可能导致数据溢出变成负数,从而造成死循环。

从这次面试之后,我再也不敢轻视二分查找了,这个小小的算法,有大大的用处。有很多题目都可以用到二分查找的思想。我也明白了一个道理,刷题不在多,关键是要融会贯通,学会题目的思想,能举一反三,就拿二分查找的例子来说,如果碰到类似的题目,能否想到用二分查找来解决,如何解决,如果刷算法题目,有了这种能力,就不怕新题目了,因为题目再新,原理还是那些原理。下面举几个使用二分查找思想的例子。

二叉搜索树中第K小的元素

给定一个二叉搜索树,编写一个函数 kthSmallest 来查找其中第 k 个最小的元素。
说明:你可以假设 k 总是有效的,1 ≤ k ≤ 二叉搜索树元素个数。
来源:力扣-230

二叉搜索树的中序遍历是有序的,这个题目可以使用中序遍历的方式来做。不过,这里主要看一下如何使用二分查找的思想来做这道题目。

树的结构很容易让人想到从中间劈开,一分为二。而二叉搜索树的根元素又是树的中间值,左小,右大。

在这里插入图片描述

对于图中的二叉树(a)来说,根节点15的左子树的个数为4,如果要找第5小的,那就刚好是根节点,如果要找第3小的,就要到左子树(b)中去继续寻找,如果要找第6小的,就要去右子树(c)中寻找,去右子树(c)中寻找第几小的呢,这个数要减去左子树的个数+1,因为右子树的节点都是大于根节点和左子树的。代码清单如下:

class Solution {
    public int kthSmallest(TreeNode root, int k) {
        int n = count(root.left);
        if (k <= n) {
            return kthSmallest(root.left, k);
        } else if (k > n + 1) {
            return kthSmallest(root.right, k - n - 1);
        }
        return root.val;
    }

    private int count(TreeNode root) {
        if (root == null) {
            return 0;
        }
        return 1 + count(root.left) + count(root.right);
    }
}
复制代码

寻找重复数

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2] 输出: 2
示例 2:
输入: [3,1,3,4,2] 输出: 3
说明:
不能更改原数组(假设数组是只读的)。
只能使用额外的 O(1) 的空间。
时间复杂度小于 O(n2) 。
数组中只有一个重复的数字,但它可能不止重复出现一次。
来源:力扣-287

这个题目有几种解法,这里专注一点,只讨论二分查找的方法。要说清楚这个方法,我们先来看个例子。
对于数组[1,2,6,4,4,5,3],重复的元素是4,如果以4为分隔把数组分成两部分,一部分是(1,2,3),一部分是(6,4,4,5)
定义函数f(x)表示在数组中小于等于x的元素的个数,那么有

f(1) = 1;
f(2) = 2;
f(3) = 3;
f(4) = 5;
f(5) = 6;
f(6) = 7;
复制代码

也就是说,如果数组中的重复元素是d,数组中只有一个重复的数字,但它可能不止重复出现一次,有以下关系:

{f(x)x对于 x<df(x)>x对于 xd\begin{cases} f(x)\le x,&\text{对于 }x<d \\ f(x)> x,&\text{对于 }x\ge d \end{cases}

数组的范围已经给定了,使用二分法和公式(1)和(2),可以逐步缩小重复元素可能出现的范围。对于数组[1,2,6,4,4,5,3],下面使用二分法推演找到重复元素的过程。

  • 第一步:left = 1, right = 6,mid = 3,数组中小于等于mid的个数为3,符合公式(1),说明重复元素在[mid+1, rihgt]这个区间;
  • 第二步:left = 4,right = 6,mid = 5,数组中小于等于mid的个数为6,符合公式(2),说明重复元素在[left, mid]这个区间;
  • 第三步:left = 4,right = 5,mid = 4,数组中小于等于mid的个数为5,符合公式(2),说明重复元素在[left, mid]这个区间;
  • 第四步:由于left = right,结束,答案为4。

如果把上述过程翻译成代码,为:

class Solution {
  public int findDuplicate(int[] nums) {
      int left = 1, right = nums.length - 1;
      while (left < right) {
          int mid = (right - left) / 2 + left;
          int cnt = 0;
          for (int v : nums) {
              if (v <= mid) {
                  cnt++;
              }
          }
          if (cnt <= mid) {
              left = mid + 1;
          } else {
              right = mid;
          }
      }
      return left;
  }
}
复制代码

使用二分查找思想的题目还有一些,比如: