说实话,我觉得二分查找很难

Posted Rhythm_2019

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了说实话,我觉得二分查找很难相关的知识,希望对你有一定的参考价值。

导语:二分查找是对一个有序数组的搜索算法,由于每次都是折半搜索,所以时间复杂度未O(LogN),但是在编写二分查找时里面蕴含了一些细节,下面将给大家讲述一下如何利用二分查找找到目标值、第一个比目标值大的值和最后一个比目标值小的值。

一、判断是否包含目标值(简单二分查找)

比如现在给你一个有序的数组nums,问你数组内是否包含目标值target,这个可以套用最基础的模板

public boolean binary_search(int[] arr) {
    int left = 0, right = arr.length - 1;   // 细节1
    while (left <= right) {                    // 细节2
        int mid = (right - left) >> 2 + left;
        if (arr[mid] == target) {
            return true
        } else if (arr[i] > target) {
            right = mid - 1;                // 细节3
        } else if (arr[i] < target) {
            left = mid + 1;                    // 细节3
        }
    }
    return false;                            // 细节4
}

挡你刷的二分查找题目比较多,看的题解比较多的时候可能会有下面的疑问

  1. 到底是right的初始值是arr.length还是arr.length - 1
  2. while的条件到底是<还是<=
  3. rightleft什么时候需要加1减1?
  4. 如果要返回坐标,应该返回啥

后面两个问题其实是第一个问题引出的,我们先解决第一个问题

  1. rightarr.length - 1还是arr.length,上面的例子是arr.length - 1,取arr.length - 1可以认为是在对区间[left, right]进行搜索(两边闭合),如果让right等于arr.length,那么right已经越界了,所以可以认为他是在对[(left, right)进行搜索(左闭右开)

    注意right的取值会影响框架的其他位置
  2. while<<=主要体现在终止条件的不同,如果是<,他的终止条件是left == right,如果是<=,则是left + 1 == right,不难发现<不会处理left == right的情况,如果你使用了<,需要多考虑left==right的情况

    如数组{1, 2, 3, 4, 5}, target = 5, 如果while修的是 <,返回的是false

    如果硬是要用<=,其实我们已经知道他漏了判断left==right的情况了,我们只要在最后补上就行

    return arr[left] == target;
  3. 最后就是leftright加1减1的时了,需不需要加一减一取决于你的right是怎么定义的

    1. 如果是两边闭合的,当你检测完mid不是你想要的,下一步应该检查[left, mid - 1][mid + 1, righjt]呀,
    2. 如果是做闭右开,当你检查完mid不是你想要的值,下一步应该检查[left, mid)[mid + 1, right)呀,所以right不用加1
所以二分查找怎么写是由right的取值决定的

二、找到目标值坐标否则返回-1

有了上面等到基础,我们可以很快的写出代码

  • right = arr.length - 1

    代码如下:

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

    代码如下

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

这里还是比较容易理解的

三、找到第一次出现目标元素的下标

例如给定一个数组[11, 22, 33, 44, 44, 44, 55],若target等于44,二分查找的结果应该为3,这个时候我们可以先用上面第二点的代码找到44,然后往左边一直找,我们也可以再二分查找模板上进行少量修改实现该功能

同样分成两种情况:

  • right = arr.length - 1

    这时是对两边闭合的区间进行搜索,我们只需要改一下原来的模板就行了

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

    为什么最后返回的是left呢,大家可以分析一下:如果target存在于数组中,最后一次循环时leftright都指向target,循环结束后right经过mid(left) - 1,所以left才是目标值,如果目标值不在数组里,最后一次循环leftright都会指向第一个比目标值大的数,right被减去1后不再循环,最终返回的left可以理解为数组中比目标值的数的个数,也可以认为是寻找大于等于目标值的数的索引,则个语义很重要

    如果题目要求找不到返回-1,那么就需要加上一些判断。根据上面的分析,left的取值范围应该是[0, len],因此我们需要判断left等于len(当target大于这个数组所有的数,最后left = mid + 1所导致),或者left = 0但是target != nums[0](当target小于所有数,最后left = 0所导致),又或者目标值在数组范围里却不在数组中,那就是target != nums[left],所以我们徐娅加上两个特殊判断

    完整代码如下:

    public int left_bound(int[] arr, int target) {
        int left = 0, right = arr.length - 1;
        while (left <= right) {
            int mid = ((right - left) >> 1) + left;
            if (arr[mid] == target) {
                right = mid - 1;
            } else if (arr[mid] > target) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }
        
        if (left >= arr.length || arr[left] != target) {
            return -1;
        }
        return left;
    }
  • right = arr.length

    如果对返回值没有作要求,代码如下:

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

    最后为什么返回left呢?如果目标值在数组中,最终left = right且会指向第一个target,如果目标值不在数组中,最后一次循环时leftright会指向第一个比目标值大的数,这其实和上面的情况相同,最终返回的left可以理解为数组中比目标值的数的个数

    如果要返回-1呢?我们也要考虑keft的取值范围,应该是[0, len],所以当目标值比数组元素大时,left = right = len,当目标值比所有制小的时候left = right = 0,所以这次的判断和上面的一样

    public int left_bound(int[] arr, int target) {
        int left = 0, right = arr.length;
        while (left < right) {
            int mid = ((right - left) >> 1) + left;
            if (arr[mid] == target) {
                right = mid;
            } else if (arr[mid] > target) {
                right = mid;
            } else {
                left = mid + 1;
            }
        }
        
        if (left == arr.length) {
            return -1;
        }
        return arr[left] == target ? left : -1;
    }

大家要思考当目标值比数组所有值都大的情况leftright指向哪里,最后应该是走mid = left,走arr[mid] < target的分支,letf = mid + 1left等于right等于arr.length

四、找到最后一次出现目标元素的下标

同样的,我们来找一下最后一个目标元素

  • right = arr.length - 1

    代码如下

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

    大家分析一下,最后我们应该返回的是什么,应该是left - 1,如果目标值在数组里,最后一次遍历left = right = 最后一个数字,然后left被+1,所以是left - 1 or right。如果目标值不在这个数组中,最后一次迭代left = right=第一个比目标值大的数的索引,最后饭返回的是left - 1,可以理解成比目标值小的最后一个元素的索引

    如果要返回 - 1,我们可以看看right的取值范围应该是[-1, len - 1],如果目标值太小,right应该是-1,如果太大,right应该是len,所以完整代码为

    private int right_bound(int[] arr, int target) {
        int left = 0, right = arr.length - 1;
        while (left <= right) {
            int mid = ((right - left) >> 1) + left;
            if (arr[mid] == target) {
                left = mid + 1;
            } else if (arr[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
    
        // left = right + 1, right指向的如果越界了
        if (right < 0 || arr[right] != target) {
            return -1;
        }
        return right;
    }
  • right = arr.length

    如果是这种情况,代码应该是

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

    为什么是left - 1呢,想象一下现在mid指向目标值,现在left = mid, right = mid + 1,mid趋势是目标值,最后left = mid + 1,所以目标值在left - 1。如果目标值不在数组中,left = right = 第一个比目标值大的数,所以返回的是最后一个比目标值小的数的索引

    如果你希望返回-1,left的取值范围是[0, len],如果目标值太大,这时候left应该是len·,如果太小,left = right = 0,综上,代码如下

private int right_bound(int[] arr, int target) {
    int left = 0, right = arr.length;
    while (left < right) {
        int mid = ((right - left) >> 1) + left;
        if (arr[mid] == target) {
            left = mid + 1;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }

    if (left == 0) {
        return  -1;
    }

    return arr[left - 1] == target ? left - 1 : - 1;
}

五、实际应用

形如:

for i in range(num1): 
    for j in range(num2)

这种时间复杂度为O (N2)的找最目标值问题,都可以用二分查找解决,其中寻找左边界的二分查找用的最多,下面是LeetCode中常见的题目:

  • LeetCode 875 爱吃香蕉的CoCo,
  • LeetCode 1011 在D天送达包裹的能力
  • LeetCode 410 分割数组最大值

当然啦,二分查找可以和其他算法结合,例如高楼丢鸡蛋问题也是可以使用二分查找优化的,写完本文,我对自己的要求是掌握分析边界的方法以及学会寻找大于等于目标值的二分查找

参考

本文大量参考博主labuladong的文章,平时仓看他的文章,写得很好,大家有兴趣可以去看看

  • 我作了首诗,保你闭着眼睛也能写对二分查找,labuladong https://mp.weixin.qq.com/s/M1...

以上是关于说实话,我觉得二分查找很难的主要内容,如果未能解决你的问题,请参考以下文章

九章算法第二天,二分搜索

「算法笔记」一文摸秃二分查找

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

老弟在吗,我怀疑 Go 标准库中的二分查找有 bug!

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

算法初体验 —二分查找