vlambda博客
学习文章列表

二分查找,边界思维的惊险挑战

读完本文,你可以写对简单的二分查找题。

二分查找也称为折半查找、对数查找,思路很简单,对于要查找的目标值,不是通过从前往后迭代查找,而是每次查找中间位置,每次操作排除掉一半的数据。

二分查找比较容易出错,主要原因在于边界难以确定,而且二分查找的写法多样,多种方式容易弄混。

我们来看最简单的一道题,leetcode 704,题目如下:

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

源代码:

class Solution {
public:
    int search(vector<int>& nums, int t) {
        int l=0, r=nums.size()-1;
        int ans = l;
        while(l<=r){
            int m = (l+r)/2;
            if(nums[m]>=t){
                ans = m;
                // left
                r = m-1;
            }else{
                l = m+1;
            }
        }
        return nums[ans] == t?ans:-1;
    }
};

这个解法是一个模板,代码看起来不多,让我们一行行的看。

函数输入的是一个有序数组和一个目标值t。

1 初始值

第一行我们定义二分查找的左右区间为l=0和r=数组最后一个下标。从l和r的定义可以看出,l和r都是有效位置。

第二行我们将ans设为l,这里设置成r也可以。ans只是一个临时位置,但是最好是有效的,因为最后一行会用这个取数组值。如果在整个二分查找过程中没有找到新的ans,那么ans就是初始值。

2 退出条件

接着是while循环,这里使用l>r作为退出条件,比较容易记忆,因为l大于r肯定是二分结束了,有些写法是l>=r结束二分,这种写法也可以,但是光从这一行的理解来说,不怎么直观,因为需要结合后面的代码才能满足这个条件,虽然只有几行代码,显然前者耦合性更小(不需要关心任何其他代码)。

3 二分

接下来进入循环, ,也就是二分,一般使用下界,也可以使用上界,影响不大。

4 check判断

下一行是if语句,if(nums[m] >= t)如果成功,则说明当前中间位置的值比较大,所以调整上边界r为m-1。因为m这个位置已经判断过了,所以从m-1位置开始继续下一轮。这里ans=m,为什么放在这里而不是放在下面调整l的边界时候呢?

因为这里是大于等于,当等于的时候,ans就会更新成新的值。

如果判断失败,则说明当前中间位置的值比较小,调整下边界l为m+1。

为什么易出错

这上面4步,每一步都可能走错,这也是为什么写对二分不是那么容易的事情。

特殊情况1:ans初始化超出数组边界的情况

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。你可以假设数组中无重复元素。

输入:[1,3,5,6], 2

输出:1

源码:

class Solution {
public:
    int searchInsert(vector<int>& nums, int t) {
        int l=0, r=nums.size()-1;
        int ans = r+1;
        while(l<=r){
            int m = (l+r)/2;
            if(nums[m] >= t){
                ans = m;
                r = m-1;
            }else{
                l = m+1;
            }
        }
        return ans;
    }
};

这道题因为需要找到插入的位置,而这个位置可能刚好是最后一个元素的下一位,因此初始化ans为r+1,如果t大于所有元素,ans不能更新,就会返回r+1。

特殊情况2:二分收缩方向

在做了几个二分题目后,你会发现二分的套路都可以用上面一样的模板,但是要注意ans的初始值和收缩方向,什么是收缩方向,比如数组是[1, 2, 3, 5, 5, 7, 9, 10],如果我们从左边开始收缩,也就是<=t的话,最终会收缩到第二个5那里,如果从右边(高位)收缩,也就是>=t为check判断,最终会收缩到第一个5那里,所以这是有区别的。如果没有重复元素,一般来说我们可以任意一边收缩。来看leetcode 34这道题,题目如下:

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target,返回 [-1, -1]。

输入:nums = [5,7,7,8,8,10], target = 8

输出:[3,4]

这道题就需要上面提到的收缩方向:

class Solution {
public:
    int lower(vector<int>& nums, int t){
        int l = 0, r = nums.size()-1;
        int ans = r;

        while(l<=r){
            int m = (l+r)/2;
            if(nums[m] >= t){
                ans = m;
                r = m-1;
            }else{
                // < t
                l = m+1;
            }
        }
        return nums[ans]==t?ans:-1;
    }
    int upper(vector<int>& nums, int t){
        int l = 0, r = nums.size()-1;
        int ans = 0;

        while(l<=r){
            int m = (l+r)/2;
            if(nums[m] > t){
                r = m-1;
            }else{
                // <= t
                ans = m;
                l = m+1;
            }
        }
        return nums[ans]==t?ans:-1;
    }
    vector<intsearchRange(vector<int>& nums, int t) {
        if(nums.empty()){
            return {-1-1};
        }
        auto l = lower(nums, t), r = upper(nums, t);
        return {l, r};
    }
};

计算上和下2个方向,然后拼起来就可以了。

上面简单讲解了3个二分题目,最重要的还是要理解第一题里的四步,第二题是ans初始化的变化题,第三题是二分的收缩特性题。

总结

二分需要多做题多总结,本人做的二分题不多,后面还会继续写一些二分的深入分析。看起来很简单的思路,却非常容易让人掉坑,二分在软件开发过程中也会有很多应用,关键是性能好,值得熟练掌握。

ACM算法日常
专注于基础算法的研究工作,5分钟阅读,动画解说每一行源代码。
338篇原创内容
Official Account