二分查找

  二分查找通过不断缩小搜索区间的范围,直到找到目标元素或者没有找到目标元素。不断缩小搜索区间是一种减而治之的思想,也称为减治思想。二分查找算法的两种思路:在循环体中查找元素;在循环体中排除目标元素一定不存在的区间。

1、中间数

  在二分查找中,最初的取中间数的代码 int mid = (left + right) / 2;,严格意义上,这段代码存在 bug,因为 left + right 可能存在溢出。可以写成 int mid = left + (right - left) / 2;。你可能还见过 int mid = (left + right) >> 1; 其实它和 int mid = (left + right) / 2; 的结果是相同的,也存在溢出问题。

  在 Java 中可以使用 int mid = (left + right) >>> 1; 这种写法,>>> 是无符号右移,在 left + right 发生整型溢出的时候,右移一位由于高位补 0。

中间数的向上取整和向下取整

  在上面计算 mid 的公式,都是向下取整,因为 / 2 这个除号表示的含义是下取整。如果想向上取整使用 int mid = left + (right - left + 1) / 2;,两种取整方式需要区别对待,具体下文介绍。

2、二分查找的最基本问题

  最基本的二分查找是力扣 704 题,在循环体中查找元素。在一个有序数组里查找目标元素的下标,目标元素不存在,返回 -1。如果中间的那个元素正好等于目标元素,我们就可以直接返回这个元素的下标;否则我们就需要在中间这个元素的左边或者右边继续查找。

  迭代写法:

class Solution {
    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1, mid;
        // 目标元素可能存在在区间 [left, right]
        while (left <= right) {
            mid = (left + right) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 目标元素可能存在在区间 [mid + 1, right]
                left = mid + 1;
            } else {
                // 目标元素可能存在在区间 [left, mid - 1]
                right = mid - 1;
            }
        }
        return -1;
    }
}

  递归写法:

class Solution {
    public int search(int[] nums, int target) {
        return search(nums, 0, nums.length - 1, target);
    }

    public int search(int[] nums, int left,int right, int target) {
        if (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                return search(nums, mid + 1, right, target);
            } else {
                return search(nums, left,  mid - 1, target);
            }
        }
        return -1;
    }
}

3、二分排除法

  二分排除法的思路:排除了所有错误的答案,当 left == right 时,表示区间里只剩下一个元素的时候,有可能这个元素就是我们要找的那个元素。在退出循环以后,进行单独做一次判断。

  • 如果这个二分查找的问题比较简单,在输入数组里不同元素的个数只有 1 个,使用上面方法 ,在循环体内查找这个元素;
  • 如果这个二分查找的问题比较复杂,要你找一个可能在数组里不存在,或者是找边界这样的问题,使用该方法 ,在循环体内排除一定不存在目标元素的区间会更简单一些。

  在力扣 704 题中,我们要找等于 target 的元素,当前 mid 不等于 target 时,则缩小区间。在循环体里排除一定不存在目标元素的区间,向下取整。

class Solution {

    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        // 目标元素可能存在在区间 [left, right]
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                // 下一轮搜索区间是 [mid + 1, right]
                left = mid + 1;
            } else {
                // 下一轮搜索区间是 [left, mid]
                right = mid;
            }
        }
        if (nums[left] == target) {
            return left;
        }
        return -1;
    }
}

  向上取整。

class Solution {

    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left + 1) / 2;
            if (nums[mid] > target) {
                // 下一轮搜索区间是 [left, mid - 1]
                right = mid - 1;
            } else {
                // 下一轮搜索区间是 [mid, right]
                left = mid;
            }
        }
        if (nums[left] == target) {
            return left;
        }
        return -1;
    }
}

  向下取整,预留两个数。这种思路循环可以继续的条件是 while (left + 1 < right) ,在待搜索区间剩下两个元素的时候,退出循环,所以它不需要考虑死循环的问题;

public class Solution {
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        // 目标元素可能存在在区间 [left, right]
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                left = mid;
            } else {
                right = mid;
            }
        }
        if (nums[left] == target) {
            return left;
        }
        if (nums[right] == target) {
            return right;
        }
        return -1;
    }
}

4、中间数取整方式

  在排除法中,有时候使用 int mid = left + (right - left) / 2;,有时候使用 int mid = left + (right - left + 1) / 2;。那何时使用向下取整,何时使用向上取整呢?这个二分法的思路根据中间数的值把区间分为两个部分:

  • 一定不存在目标元素的部分;
  • 可能存在目标元素的部分。

  中间数的位置有两种情况。

FHGAOA5O3ZRITGVAVULB.png

情况一:mid 被分到左边区间

  分区被分为两部分:[left, mid] 与 [mid + 1, right],对应设置边界的代码为 right = midleft = mid + 1;

情况二:mid 被分到右边区间

  分区被分为两部分: [left, mid - 1] 与 [mid, right],对应设置边界的代码为 right = mid - 1left = mid注意:这种情况下,当搜索区间里只剩下两个元素的时候,一定要将取中间数改成上取整,也就是在括号里加 1。

  因为 [left, right] 区间里只剩下两个元素的时候,如果是取中间数 mid 是下取整,一旦进入 left = mid 这个分支,区间就不会再缩小。

边界设置的两种写法:

  • right = midleft = mid + 1int mid = left + (right - left) / 2; 一定是配对出现的;
  • right = mid - 1left = midint mid = left + (right - left + 1) / 2; 一定是配对出现的。

5、练习

1)在排序数组中查找数字 I

自己的想法

方法一:二分查找

  使用二分查找找到该数,然后向左右扩散寻找边界。

  • 执行用时:0 ms, 在所有 Java 提交中击败了100.00%的用户
  • 内存消耗:41.3 MB, 在所有 Java 提交中击败了63.16%的用户
class Solution {
    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1, mid;
        // 目标元素可能存在在区间 [left, right]
        while (left <= right) {
            mid = (left + right) / 2;
            if (nums[mid] == target) {
                int count = 1;
                for (int i = mid - 1; i >= 0 && nums[i] == target; i--) {
                    count++;
                }
                for (int i = mid + 1; i < nums.length && nums[i] == target; i++) {
                    count++;
                }
                // 向左和右扩散查找
                return count;
            } else if (nums[mid] < target) {
                // 目标元素可能存在在区间 [mid + 1, right]
                left = mid + 1;
            } else {
                // 目标元素可能存在在区间 [left, mid - 1]
                right = mid - 1;
            }
        }
        return 0;
    }
}

看题解后

方法二:左右边界下标

  如果 target 存在于数组中,则寻找 target 在数组中的左右边界,相减后加一,就是该数在数组的个数。

public class Solution {

    public int search(int[] nums, int target) {
        if (nums.length == 0) {
            return 0;
        }
        // 寻找该数在数组的左边界
        int left = getLeft(nums, target);
        // 不存在则返回 0
        if (left == -1) {
            return 0;
        }
        // 寻找该数在数组的右边界
        int right = getRight(nums, target);
        return right - left + 1;
    }

    private int getLeft(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            // 注意这样写,可以从左边收缩待搜索区间的范围,进而找到第一次出现的位置
            if (nums[mid] < target) {
                // mid 以及 mid 左边都不是,下一轮搜索区间在 [mid + 1, right]
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        if (nums[left] == target) {
            return left;
        }
        return -1;
    }

    private int getRight(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = left + (right - left + 1) / 2;
            // 注意这样写,可以从右边收缩待搜索区间的范围,进而找到最后一次出现的位置
            if (nums[mid] > target) {
                // mid 以及 mid 右边都不是,下一轮搜索区间在 [left, mid - 1]
                right = mid - 1;
            } else {
                left = mid;
            }
        }
        return left;
    }
}

方法三:左右边界

  另一种求左右边界的方法。该方法和上面方法求的边界不同。

  • 方法二:适用于查找存在于数组中的数的边界,返回的是该数在数组中的边界下标(包含边界)。数的个数为:right - left + 1
  • 方法三:返回的是该数在数组中的位置边界(不包含边界)。数的个数为:right - left - 1

  例如:int[] num = {1, 2, 2}; 找 2;int[] num = {1, 3}; 找 2.

  • 方法二:left = 1, right = 2left = 1, right = 0(无意义,不适应查找不存在的数,应该进行判断返回 -1)。
  • 方法三:left = 0, right = 3left = 0,right = 1
class Solution {
    public int search(int[] nums, int target) {
        // 搜索右边界 right
        int right = getRight(nums, target);
        // 若数组中无 target ,则提前返回
        if (right == -1) {
            return 0;
        }
        // 搜索左边界 left
        int left = getLeft(nums, target);
        return right - left - 1;
    }

    public int getRight(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left <= right) {
            int mid = (left + right) / 2;
            // 左右边界的区别,相等时,排除哪个分区
            if (nums[mid] <= target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        // 提高效率,若数组中无 target ,则提前返回。求边界时,不需要该判断
        if (right >= 0 && nums[right] != target) {
            return -1;
        }
        return left;
    }

    public int getLeft(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left <= right) {
            int mid = (left + right) / 2;
            if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return right;
    }
}

方法四:右边界

  分别二分查找 target 和 target - 1 的右边界,将两结果相减并返回即可。

class Solution {
    public int search(int[] nums, int target) {
       return getRight(nums, target) - getRight(nums, target - 1);
    }

    public int getRight(int[] nums, int target) {
        int left = 0, right = nums.length - 1, mid = 0;
        while (left <= right) {
            mid = (left + right) / 2;
            if (target >= nums[mid]) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return left;
    }
}

2)搜索插入位置

自己的想法

方法一:二分查找

  • 执行用时:0 ms, 在所有 Java 提交中击败了100.00%的用户
  • 内存消耗:38.3 MB, 在所有 Java 提交中击败了12.85%的用户
class Solution {
    public int searchInsert(int[] nums, int target) {
        int left = 0, right = nums.length - 1, mid;
        // 目标元素可能存在在区间 [left, right]
        while (left <= right) {
            mid = (left + right) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 目标元素可能存在在区间 [mid + 1, right]
                left = mid + 1;
            } else {
                // 目标元素可能存在在区间 [left, mid - 1]
                right = mid - 1;
            }
        }
        // right + 1
        return left;
    }
}

方法二:使用 API

class Solution {
    public int searchInsert(int[] nums, int target) {
        int tmp = Arrays.binarySearch(nums, target);
        if (tmp < 0) {
            return -(tmp + 1);
        }
        return tmp;
    }
}
3)在排序数组中查找元素的第一个和最后一个位置

自己的想法

方法一:二分查找

  • 执行用时:0 ms, 在所有 Java 提交中击败了100.00%的用户
  • 内存消耗:41.8 MB, 在所有 Java 提交中击败了29.26%的用户
class Solution {
    public int[] searchRange(int[] nums, int target) {
        int right = getRight(nums, target);
        if (nums.length == 0 || nums[right] != target) {
            return new int[]{-1, -1};
        }
        return new int[]{getLeft(nums, target), right};
    }

    public int getRight(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = (left + right + 1) / 2;
            if (nums[mid] > target) {
                right = mid - 1;
            } else {
                left = mid;
            }
        }
        return left;
    }

    public int getLeft(int[] nums, int target) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return left;
    }
}
4)寻找旋转排序数组中的最小值

自己的想法

方法一:排序性质

  因为原数组是升序的,正常情况下 nums[i - 1] <= nums[i],所以如果 nums[i - 1] > nums[i],则 nums[i] 肯定是原数组的开头,即最小值。

  • 执行用时:0 ms, 在所有 Java 提交中击败了100.00%的用户
  • 内存消耗:38.1 MB, 在所有 Java 提交中击败了13.10%的用户
class Solution {
    public int findMin(int[] nums) {
        for (int i = 1; i < nums.length; i++) {
            if (nums[i - 1] > nums[i]) {
                return nums[i]; 
            }
        }
        return nums[0];
    }
}

看题解后

方法二:二分查找

  使用二分查找,根据中间数进行判断,不断收缩左边界或右边界。左、中、右三个位置的值相比较,有以下几种情况:

         右 
     中	   
 左	   
-----------
 左   
         右
     中
-----------
     中  
 左 
         右
-----------

  我们可以找到一个变化点,这个点有如下特点:

  • 所有变化点左侧元素 > 数组第一个元素;
  • 所有变化点右侧元素 < 数组第一个元素。

  判断变化点的位置:

  • 找到数组的中间元素 mid;
  • 如果中间元素 > 数组第一个元素,则变化点在 mid 右边;
  • 如果中间元素 < 数组第一个元素,则变化点在 mid 左边。

  验证是否找到变化点:

  • 当我们找到变化点时停止搜索,当以下条件满足任意一个即可:
  • nums[mid] > nums[mid + 1],因此 mid+1 是最小值;
  • nums[mid - 1] > nums[mid],因此 mid 是最小值。
class Solution {
    public int findMin(int[] nums) {
        int left = 0;
        int right = nums.length - 1;
        if (nums[0] <= nums[right]) {
            return nums[0];
        }
        while (true) {
            int mid = (left + right) / 2;
            // 当中间的数大于右边的数,则 mid + 1 就是旋转点
            if (nums[mid] > nums[mid + 1]) {
                return nums[mid + 1];
            }
            // 当中间的数小于左边的数,则 mid 就是旋转点
            if (nums[mid] < nums[mid - 1]) {
                return nums[mid];
            }
            // 根据第一个数,进行移动
            if (nums[mid] > nums[0]) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
    }
}

  判断右边的数。我们把数组从中间分成两个数组(两个数组都包含中间数),不难发现最小数永远在无序数组中。

  • 如果中间的数小于等于右边的数,表示右边的数是有序的,则说明最小数的下标为 <= mid
  • 如果中间的数大于右边的数,表示左边的数是有序的,则说明最小数的下标为 > mid
class Solution {
    public int findMin(int[] nums) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] > nums[right]) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return nums[left];
    }
}

  判断左边的数。

class Solution {
    public int findMin(int[] nums) {
        int left = 0, right = nums.length - 1;
        while (left + 1 < right) {
            int mid = (left + right) / 2;
            if (nums[mid] < nums[left]) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return Math.min(nums[0], Math.min(nums[left], nums[right]));
    }
}

  判断头部的数。

class Solution {
    public int findMin(int[] nums) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] >= nums[0]) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return Math.min(nums[0], nums[left]);
    }
}

  判断尾部的数。

class Solution {
    public int findMin(int[] nums) {
        int left = 0, right = nums.length - 1;
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] >= nums[nums.length - 1]) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return nums[left];
    }
}

标题:二分查找汇总
作者:Yi-Xing
地址:http://zyxwmj.top/articles/2021/02/15/1613399455333.html
博客中若有不恰当的地方,请您一定要告诉我。前路崎岖,望我们可以互相帮助,并肩前行!