"细节魔鬼" - 二分查找

Posted 蚂蚁的宝藏

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了"细节魔鬼" - 二分查找相关的知识,希望对你有一定的参考价值。

二分查找,是一个高效,实用,且易理解的一个查找算法, 通常时间复杂度为O(lgn)。局限性在于,待查找对象必须为有序的数组;数据量过小,优势不明显,数据量过大,数组大小受限于内存。

除此之外,二分查找是面试中比较高频考察的算法。

高频题目清单:

二分查找应用(简单)

  • 374 猜数字大小
  • 375 猜数字大小II
  • 35 搜索插入位置
  • 278 第一个错误的版本
  • 367 有效的完全平方数
  • 69 x的平方根
  • 441 排列硬币

二分查找应用(中等)

  • 50 Pow(x,n)
  • 29 两数相除
  • 34 在排序数组中查找元素的第一个和最后一个位置
  • 540 有序数组中的单一元素
  • 275 H指数II
  • 436 寻找右区间
  • 300 最长上升子序列
  • 718 最长重复子数组
  • 354 俄罗斯套娃信封问题
  • 658 找到K个最接近的元素
  • 162 寻找峰值
  • 4 寻找两个正序数组的中位数
  • 209 长度最小的子数组
  • 222 完全二叉树的节点个数
  • 287 寻找重复数

二分查找与旋转数组

  • 153 寻找旋转排序数组中的最小值
  • 154 寻找旋转排序数组中的最小值II
  • 33 搜索旋转排序数组
  • 81 搜素旋转排序数组II
  • 74 搜索二维矩阵

二分答案法

  • 378 有序矩阵中第K小的元素
  • 668 乘法表中第K小第数
  • 410 分割数组的最大值
  • 483 最小好进制


刷题技巧


关于二分查找,有个说法 “思路简单,细节是魔鬼”,相关的刷题技巧和注意事项,网上大神们总结了很多,自己加工总结后的笔记:

1. 题目出现排序数组,优先思考是否可以用二分查找。

2. 找mid值,记住无脑写 int mid = (left + right) >>> 1

当然 int mid = left + (right - left) / 2  或者 int mid = left + ((right - left) >> 1) 也是对的.

但是万万不能写int mid = (left + right) / 2 ,原因是,如果left + right 有可能造成整型溢出,而left + (right - left),因为right和left一般是索引值,所以基本不会溢出。
至于推荐写int mid = (left + right) >>> 1 ,除了装逼外,还是jdk官方的标准写法,也是因为位运算更快的原因。

3. int mid = (left + right) >>> 1,对于偶数数组长度来说,找的是左中点,int mid = (left + right + 1) >>> 1,找的是右中点。

找几个case测试理解后,牢牢背住就行,不加一是左中点,加一是右中点。

4. 循环中的终止条件,写while(left < right) 还是while (left <= right) ,大有乾坤。

left < rightleft <= right 区别在于,如果左右边界一样时,前者会漏掉一个数的查找。不过这也取决于最开始,right的值是否为nums.length -1。使用left < right的好处在,退出循环时,left一定等于right,不用额外考虑返回值。不过需要额外处理最后一个未查找的值。

这两部篇文章分析了两种条件的影响和处理调整:

https://leetcode-cn.com/problems/binary-search/solution/er-fen-cha-zhao-xiang-jie-by-labuladong/

https://leetcode-cn.com/problems/search-insert-position/solution/te-bie-hao-yong-de-er-fen-cha-fa-fa-mo-ban-python-/

这篇文章极力安利了第一种写法:https://zhuanlan.zhihu.com/p/123863325

所有的文章,包括你我自己写的,都只是个人的见解,你认为哪个有道理就学习它。
我认为必须了解两种的写法具体意义和影响,不同场景不同题目下才能,具体情况具体对待。



高频题目


704.二分查找

题目描述:

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

示例:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

解题思路:

最最基础的二分查找题目,考虑以上技巧,分别使用left < rightleft <= right 两种循环条件写出代码,感受边界处理。

  1. left <= right 左闭右闭写法
public int search(int[] nums, int target) {
    if (nums == null || nums.length <= 0) {
        return -1;
    }
    int left = 0, right = nums.length - 1, mid = 0;
    // 左闭右闭区间,搜索不会漏掉值
    while (left <= right) {
        mid = (right + left) >>> 1;
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] > target) {
            // 因为循环条件中是右闭区间,
            //所以要跳过mid值,mid得-1
            right = mid - 1;
        }else {
            left = mid + 1;
        }
    }
    return -1;
}

2.left < right 左闭右开写法

public int search2(int[] nums, int target) {
    if (nums == null || nums.length <= 0) {
        return -1;
    }
    int left = 0, right = nums.length - 1, mid = 0;
    while (left < right) {
        mid = (right + left) >>> 1;
        if (nums[mid] == target) {
            return mid;
        }else if (nums[mid] > target) {
        //因为循环条件为右开区间
        //所以跳过mid值,右区间应为mid
            right = mid;
        }else {
            left = left + 1;
        }
    }
    // 处理最后没查找的元素,
    //最后一次left==right一定成立,
    //所以这块left或者right都可以
    return nums[left] == target ? left : -1;
}

最后,附上,jdk中的写法:


34.在排序数组中查找元素的第一个和最后一个位置

题目描述:

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-1, -1]。

示例:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]

解题思路:

先简单二分查找到target值后,然后向前向后遍历,找到第一个不重复的和最后一个不重复的值,这样做的话,如果数组元素全是同一个target值,那么是否复杂度就是O(n)了,不符合题目要求了。

所以先解决 如何在O(log n)时间内找到元素第一次出现的位置 :

和基础二分查找不同的是,在nums[mid] == target后,不能直接返回mid,而是应该让缩小右边界,继续查找。在退出循环后,对整个数组没有target值的情况做处理。

public int searchFirst(int[] nums, int target) {
    if (nums == null || nums.length <= 0) {
        return -1;
    }
    int left = 0, right = nums.length - 1, mid = 0;
    while (left <= right) {
        mid = left + (right - left) / 2;
        // 当中点值和target相等时,更新右边界
        // 不和下面分支判断合一块,是为了逻辑清晰
        if (nums[mid] == target) {
            right = mid - 1;
        }else if (nums[mid] > target) {
            right = mid - 1;
        }else {
            left = mid + 1;
        }
    }
    // 不用nums[right] 和target比较,
    //是因为在中点相等时,跳过了中点,
    //且right=mid-1。
    // 而left总和mid + 1。 
    // 所以用nums[left]和target比较。
    // 当最后一次查找,left = nums.length - 1时,
    // left有可能+1,等于nums.length
    // nums[left]就有可能下标越界。
    return left >= nums.length || nums[left] != target ? -1 : left;
}

查找目标元素的最后一次出现的位置:
同样的,在中点值等于target时,不要返回mid,而是更新左边界。记得最后在退出循环后,对整个数组没有target值的情况做处理。

public int searchLast(int[] nums, int target) {
    if (nums == null || nums.length <= 0) {
        return -1;
    }
    int left = 0, right = nums.length - 1, mid = 0;
    while (left <= right) {
        mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            left = mid + 1;
        }else if (nums[mid] > target) {
            right = mid - 1;
        }else {
            left = mid + 1;
        }
    }
    // 和找第一个元素位置一样
    // 选nums[right]和target比较是因为:
    // 当中点和target相等时,跳过mid,left = mid + 1了。
    // 当数组只有一个元素时,right有可能-1,变成-1,造成数组下标越界。
    // 当然更好一点,直接 return left - 1;
    // 因为left初始值是0,如果数组不存在target元素,即返回-1.
    return right < 0 || nums[right] != target ? -1 : right;
}

最后,解决这道题,只需:

public int[] searchRange(int[] nums, int target) {
    // 这块处理边界条件后,剩下两个函数可去掉。
    if (nums == null || nums.length <= 0) {
        return new int[]{-1,-1};
    }
    return new int[] {searchFirst(nums, target), searchLast(nums, target)};
}

367.有效的完全平方数

题目描述:

给定一个正整数 num,编写一个函数,如果 num 是一个完全平方数,则返回 True,否则返回 False。

示例一:
输入:16
输出:true

示例二:
输入:14
输出: false

解题思路:

这是一道简单题,我们每次取中点值,然后判断中点值自乘是否等于num,如果等于,证明该num是完全平方数,否则更新左右指针。

注意学习点:

一个数的平方根最大不会超过它本身,再细点,基本不会超过它的一半。
,  解不等式,得到 或者 。所以,当a =0,1,2,3时,它们的平方根是超过它们自身一半的,对应的平方根解分别是,0,1,1,1。所以,为了处理这4个特殊情况,初始值left = 0, right = x/2 + 1

public boolean isPerfectSquare(int num) {
    int left = 0, right = num / 2 + 1, mid = 0;
    while (left <= right) {
        mid = (left + mid) >>> 1;
        if (mid * mid == num) {
            return true;
        }else if (mid * mid > num) {
            right = mid - 1;
        }else {
            left = mid + 1;
        }
    }
    return false;
}

69.x的平方根-sqrt(x)

题目描述:

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

示例:输入: 8
输出: 2
说明: 8 的平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。

解题思路:

该题最快的解法并不是二分查找,有O(1)的袖珍计算器算法和同是O(lgn)却更快的牛顿迭代法。
这两种数学算法,具体思路参考官方题解:
https://leetcode-cn.com/problems/sqrtx/solution/x-de-ping-fang-gen-by-leetcode-solution/

二分查找的具体做法,和上题差不多,初始右边界,选择right = x/2 + 1。注意在退出循环后,如果使用while(left <= right),得考虑返回值如何选择。
如case 8 的平方根是 2.82842...,应该返回2。left在非return正常退出循环时,可能会mid + 1,不合适,right退出循环时,是mid-1,符合题目取整数的要求。

public int mySqrt(int x) {
    // 用long 防止int*int后溢出
    long left = 0, right = x / 2 + 1, mid = 0;
    while (left <= right) {
        mid = (left + right) >>> 1;
        long square = mid * mid;
        if (square == x) {
            return (int)mid;
        }else if (square > x) {
            right = mid - 1;
        }else  {
            left = mid + 1;
        }
    }
    // 按要求,比如8,平方根2.82842,所求解为2.
    // 对于left <= right,在正常退出时,left = right + 1
    // right每次更新都是mid - 1,所以直接返回right即可,或者left - 1。
    return (int)right;
}

50 Pow(x,n)

题目说明:

实现 pow(x, n) ,即计算 x 的 n 次幂函数。

示例1:
输入: 2.00000, 10
输出: 1024.00000

示例2:
输入: 2.00000, -2
输出: 0.25000
解释: 2-2 = 1/22 = 1/4 = 0.25

说明:-100.0 < x < 100.0
n 是 32 位有符号整数,其数值范围是 [−231, 231 − 1]

解题思路:

最坏的解法,就是遍历一遍,不断相乘。二分法的思想在这题上,比如要算2的10次方,只要求得2的5次方即可;2的5次方只要求2的2次方,然后结果自乘即可.  自然想到也要使用递归。

可以看到,需要注意的是,当n为奇数时,中间结果还需要再乘以当前数。除此之外,还应该考虑x为负数的情况,x为负数,所求结果要被1除,即倒数。

public double myPow(double x, int n) {
    long N = n;
    return N >= 0 ? quickMul(x, N) : 1.0 / quickMul(x, -N);
}

public double quickMul(double x, long n) {
    if (n == 0) {
        return x;
    }
    double y = quickMul(x, n / 2);
    return n % 2 == 0 ? y * y : y * y * x;
}

153.寻找旋转排序数组中的最小值

题目描述:

假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。你可以假设数组中不存在重复元素。

示例:
输入: [4,5,6,7,0,1,2]
输出: 0

解题思路

主要思路就是通过判断中点值与边值的大小来缩小查找的左右区间。
本题有3个关键点:

  1. while条件使用 left < right
  2. 中点选左中点
  3. 边值比较使用右边值

1好理解,使用left<right后,在非return正常退出循环后,left是等于right的,随便返回一个即可,除此之外,因为是求最小值,所以当mid值等于边值时,更新左右指针时,要考虑mid也可能是最小值,如果处理不当,使用left <= right,可能会死循环。

2.3的考虑,二分查找题目分析一定要考虑数组只有两个元素的case,就能确定到底使用左中点合适还是右中点合适。

此题,如果case为[1,3],如果选左中点,用左边界比较,就会很麻烦:如果nums[mid] = num[left],left肯定不能mid+1跳过,但是如果left=mid,就死循环了。

如果选右中点,用左边界比较,也会有问题,正常情况nums[mid] > num[left],肯定右半边是较小的区间,left跳过mid,但是case[1,3]来说,跳过1就有问题。

所以,求旋转数组最小值,因为旋转点的右区间是小区间,所以用左中值和右边值比较,当左中值和右值相等时,右边值由可能是最小值,注意右边值不能跳过mid。

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

154.寻找旋转排序数组中的最小值II

题目描述:

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请找出其中最小的元素。

注意数组中可能存在重复的元素。

示例 1:
输入: [3,3,1,3]
输出: 1

示例 2:
输入: [2,2,2,0,1]
输出: 0

解题思路:

基于上题,如果没有重复元素,如果左中点值和右值相等时nums[mid] = nums[right],右值有可能是小值,不能跳过,就更新右边值right = mid

如果有重复元素后,处理也简单,当左中值和右值相等,让右指针往左滑动,right = right - 1, 跳过迷惑值。

public int findMin(int[] nums) {
    int left = 0, right = nums.length - 1, mid = 0;
    while (left < right) {
        mid = (left + right) >>> 1;
        // 相等时,跳过右边这个迷惑值
        if (nums[mid] == nums[right]) {
            right = right - 1;
        }
        if (nums[mid] > nums[right]) {
            left = mid + 1;
        }
        if (nums[mid] < nums[right]) {
            right = mid;
        }
    }
    return nums[left];
}

33 搜索旋转排序数组

题目描述:

给你一个升序排列的整数数组 nums ,和一个整数 target 。

假设按照升序排序的数组在预先未知的某个点上进行了旋转。(例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

请你在数组中搜索 target ,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。

示例 1:
输入:nums=[4,5,6,7,0,1,2], target = 0
输出:4

解题思路:

和上面找最小值一样,通过中值和边值的比较,先确定有序的数组,然后通过判断target是否在区间内,来更新左右边值。

使用左中点的话,

  • 如果 nums[mid] >= nums[left], 可以证明 [left,mid]左半段是有序的。当target在[left,mid)区间内,更新右边值 right = mid - 1,否则更新左边值 left = mid + 1
     使用 >=是因为是用的是左中点,得考虑只有两个元素的case。
  • nums[mid] < nums[left], 右半段 [mid,right]是有序的
    当target在(mid,right],更新左边值 left = mid + 1, 否则更新右边值 right = mid - 1
public int search(int[] nums, int target) {
    int left = 0, right = nums.length - 1, mid = 0;
    while (left <= right) {
        mid = (left + right) >>> 1;
        if (nums[mid] == target) {
            return mid;
        }
        // 如果中点值大于左值,说明左半边是有序的
        if (nums[mid] >= nums[left]) {
            // 如果目标值在左区间,更新右边界
            if (target >= nums[left] && target < nums[mid]) {
                right = mid - 1;
            }else {
                left = mid + 1;
            }
        }else {
            // 右半边是有序的,如果目标值在右半边区间,更新左边界                                            
            if (target > nums[mid] && target <= nums[right]) {
                left = mid + 1;
            }else {
                right = mid - 1;
            }
        }
    }
    return -1;
}

81.搜索旋转排序数组II

题目描述:

假设按照升序排序的数组在预先未知的某个点上进行了旋转。
编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false。
进阶:
这是 搜索旋转排序数组 的延伸题目,本题中的 nums 可能包含重复元素。这会影响到程序的时间复杂度吗?会有怎样的影响,为什么?

示例:
输入:nums=[2,5,6,0,0,1,2], target = 0 输出: true

解题思路:

154.寻找旋转排序数组中的最小值II题一样,当nums[mid] == nums[left]时,需要滑动left边值指针,跳过迷惑项。
如果这么处理,当待排序数组全是一样的重复元素,时间复杂度会退化为O(n)。

public boolean search(int[] nums, int target) {
    int left = 0, right = nums.length - 1, mid = 0;
    while (left <= right) {
        mid = (left + right) >>> 1;
        if (nums[mid] == target) {
            return true;
        }
        // 跳过迷惑值
        if (nums[mid] == nums[left]) {
            left ++;
        }else if (nums[mid] > nums[left]) {
            if (target >= nums[left] && target < nums[mid]) {
                right = mid - 1;
            }else {
                left = mid + 1;
            }
        }else {
            if (target > nums[mid] && target <= nums[right]) {
                left = mid + 1;
            }else {
                right = mid - 1;
            }
        }
    }
    return false;
}

74.搜索二维矩阵

题目描述:

编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:

每行中的整数从左到右按升序排列。
每行的第一个整数大于前一行的最后一个整数。

示例:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,50]], target = 3
输出:true

解题思路:

从示例上就能看到,讲该二维矩阵按行依次展开,就是一个升序的一维数组。
然后就能按标准的二分查找来做题了。

此题关键技巧,如果展开后的一维数组的下班为i。
和m(row length) * n(col length)二维数组存在以下关系:

  • row = i / n
  • col = i % n
public boolean searchMatrix(int[][] matrix, int target) {
    if (matrix == null || matrix.length <= 0) {
        return false;
    }
    int m = matrix.length;
    int n = matrix[0].length;
    if (n <= 0) {
        return false;
    }
    int left = 0, right = m * n - 1, mid = 0;
    while (left <= right) {
        mid = (left + right) >>> 1;
        int val = matrix[mid / n][mid % n];
        if (val == target) {
            return true;
        }else if (val > target) {
            right = mid - 1;
        }else {
            left = mid + 1;
        }
    }
    return false;
}


总结
  • 链表题目注意在使用不同while条件时,不同的细节处理,包括左右指针的更新,退出循环后的取值考虑。

  • 记得不要忘记考虑只有两个元素的case。

  • 旋转数组类的查找,首先要通过中值与边值的比较,来确定哪半边是有序的。升序数组旋转后右半边是小区间,求旋转数组最小值时,使用左中值和右边值比较。

  • 查找数组中,如果有重复元素,需要滑动指针,跳过疑惑值,但是有可能时间复杂度退化为O(n)。


以上是关于"细节魔鬼" - 二分查找的主要内容,如果未能解决你的问题,请参考以下文章

六十七二分查找算法及其四个变形问题

详解二分查找算法

二分查找深度分析

聊聊算法——二分查找算法深度分析

二分查找为什么让面试者挂的这么惨?

C语言二分查找