Algorithm-Binary Search算法进阶理解

写在前面

本来想着在原来的文章的基础上进行改进,但是这样原来的文章就会内容太复杂了,原文可参考:Algorithm-Binary Search算法。所以单独写一篇文章来说明我都二分搜索的进一步理解。

下面的例子中的nums都是不下降的数列,不上升的数列其实就是不下降的数列反过来嘛,我就不罗嗦了。

下面我将从两个简单的例子来说明二分搜索不同类型问题中基本的共性之处。

后续更新

2018-5-12在LeetCode上做到二分搜索的变形题目1,2,3;
2018-5-16更新题目4。

  1. LeetCode-33. Search in Rotated Sorted Array
  2. LeetCode-81. Search in Rotated Sorted Array II
  3. LeetCode-153. Find Minimum in Rotated Sorted Array
  4. LeetCode-34. Search for a Range

两个基本问题

首先,可以来尝试解决两个最基本的问题:

  1. 对于不下降序列a,求最小的i,使得a[i] = target
  2. 对于不下降序列a,求最大的i,使得a[i] = target

在我目前的框架下,二分搜索算法的模板第一句是考虑闭区间[left, right]的,所以有:

int mid, left = 0, right = nums.size()-1;

然后,循环条件设定为:

while(left < right){}

这里也可以设定为 <= 或者 >=,但这是进一步的思考了,所以我希望可以先用最简单的形式来思考最简单的问题。

首先来思考一下第一个问题,要找最小的i使得:nums[i] = target,那么举个例子:

[1,2,3,3,3,3,3,5,5,5,6,8,8,8,8,9,9,9,9]

target=3,那么有很多个3,我们就要找到第一个出现的3.如果是要解决问题2,那么就应该找到最后一个出现的3。

那么,现在有很多个3,我们要么要找第一个出现的,要么要找最后一个出现的,根据二分查找的思想,如果mid刚好是第一个或者最后一个3,那么就万事大吉。但是很多时候可能刚刚好:nums[mid] = 3,但是,这个3不是第一个出现也不是最后一个,而是中间的3。这个时候,就需要一些方法来解决这个问题。

回顾一下二分查找的方法,主体算法是:

  1. 获取mid,mid是left与right的中间值。这个中间值可向上取整,也可向下取整;
  2. 判断nums[mid] 与 target 的大小关系;
  3. 如果nums[mid] > target, 那么就应该更新 right;
  4. 如果nums[mid] < target, 那么就应该更新 left;
  5. 如果nums[mid] = target, 这种情况再说
  6. 不断重复以上过程,直到循环条件 left < right不满足,退出循环
  7. 退出循环之后,二分搜索的结果是哪个呢? left 还是 right呢?

根据以上主体算法流程,再结合我们之前思考的问题,我们应该解决步骤5的情况。那就是,当nums[mid] = target,应该怎么做。

聚焦于第一个问题

要找到最小的i,nums[i] = target,那么我们应该尽可能往小的找,而当nums[mid]等于target的时候,我们还是希望下一个mid能出现在当前mid与left之间的位置的某一处。

这样,我们就能明白:要找到最小的i,那么当nums[mid] = target时,我们应该更新right,这样,下一个mid就会出现在left与当前mid之间了。所以,我们就可以得到二分搜索中,while中循环判断部分代码:

if(nums[mid] < target ) left = mid + 1;
else right = mid;

这里的else 就是当nums[mid] >= target的时候,很明显,包含nums[mid] = target 的情况。

这里我们还要思考,为啥left更新为mid+1,right更新为mid呢?

  1. 因为nums[mid] < target的话,新的left肯定不能为mid,如果能让left = mid,那么一方面会浪费一次比较;另一方面,无法收缩区间(因为right只能更新为mid)。
  2. 而right更新为mid 的条件的时候,是在else的条件下,else 是对应 >=,所以有可能nums[right] = target 所以,如果让right=mid - 1,那就会跳过正确的搜索结果。所以这里,right = mid。那么反过来,上面的left只能为 mid + 1.

那么,我们进一步可以解决主体算法流程中的第8步,循环结束后,搜索结果在哪里?只可能在right取到!因为所有的nums[left] < target, 而 nums[right] >= target啊。所以 nums[right] 有可能等于target。也有可能不等,不等就意味着没有找到要搜索的结果。

所以有返回代码:

return nums[right] == target ? right : -1;

那么现在还只剩下一个问题,left与right的更新方式都解决了,那就只剩下mid的了。mid是left与right 的中间值,但是这是数列,mid是向上取整呢还是向下取整呢?我理解的方式是看下面这个例子:

四个元素:left,mid1,mid2,right

那么很明显,当mid向下取整时,对应left与right的“中间值”是mid1;反过来,mid向上取整时,“中间值”是mid2。如果nums[mid1] = nums[mid2],那么根据现在考虑的问题:要找到最小的i,使得nums[i] = target, 我想自然就应该是mid向下取整更加合适。那么对应,如果是要找到最大的i,使得nums[i] = target,就应该是向上取整 了。

所以对于这个问题,可以有:
注意这里为了防止int型溢出,采用这样计算,此外运算符+优先级高于 > > ,所以要有两层括号,可参考:C++——运算符优先级。

//向下取整
mid = left + ((right-left)>>1);
//向上取整
mid = left + ((right-left + 1)>>1);

那么,至此,我们就可以整理一下之前一大段分析,从而得到解决问题:找到最小的i,使得nums[i] = target的二分搜索的代码了:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int mid, left=0, right=nums.size()-1;
        while(left < right){
            mid = left + ((right-left)>>1);
            if(nums[mid] < target) left = mid + 1;
            else right = mid;
        }
        return nums[right] == target ? right : -1;
    }
};

如法炮制解决问题二

现在我们可以更快的按照问题一的思路来解决问题二:找到最大的i,使得nums[i]=target。

  1. mid、left、right的初始设定不用动;
  2. while循环条件不用动;
  3. 找最大的i,所以mid向上取整;
  4. 当nums[mid] = target,希望下一个mid能在当前mid与right之间出现,此时应该更新left。
  5. 由于相等条件时更新left,所以返回值应该检查left。

根据以上分析,就不难得出代码:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int mid, left=0, right=nums.size()-1;
        while(left < right){
            mid = left + ((right-left+1)>>1);
            if(nums[mid] > target) right = mid - 1;
            else left = mid;
        }
        return nums[left] == target ? left : -1;
    }
};

所以结合这两个基本问题,不难发现注意好以上6个步骤就可以解决基本的二分搜索的问题了。

  1. mid、left、right的初始设定不用动;
  2. while循环条件不用动;
  3. 如果要找最大的i,mid就应该向上取整;如果要找最小的i,mid就应该向下取整;
  4. 第四步是判断条件与边界(left与right的更新)。这一部分灵活性很大(if else条件句可以改写成不同形式),但基本模式是让if条件单纯比大小的话,else就是if条件的补集,所以等于的情况在else中。那么要找最大的i,else 处就应该更新left;要找最小的i,else处就应该更新right。
  5. 基于第4步,要找最大的i,if处应该更新right;要找最小的i,if处应该更新left;
  6. 基于第4步,根绝要找的结果,比如说 = target,那么由于我这里if条件只是比大小,所以等于条件只可能在else中,所以就去找else更新的那个变量。比如这里要找最大的i,else更新left,所以结果就去找left;要找最小的i,else更新right,所以结果就去找right。

以上就是两种最简单的问题的分析思路了。我是分析由繁琐到简洁,但是,必须多做几种不同类型的题目,才能好好体会不同二分搜索之中的相同点与不同点。

最小的i,使得nums[i] > target

一样的思路,我们来逐一思考:

  1. mid、left、right的初始设定不用动;
  2. while循环条件不用动;
  3. 找最小的i,所以mid向下取整;
  4. 当nums[mid] > target,希望下一个mid能在当前mid与left之间出现,此时应该更新right。
  5. if_else结构设计,考虑到4,if处更新right,由于是right,判断条件应该是大于;else处更新left,比较简洁。
  6. 由于要求条件满足时更新right,所以返回值应该检查right。

所以,代码自然而然就可以改写如下:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int mid, left=0, right=nums.size()-1;
        while(left < right){
            mid = left + ((right-left)>>1);
            if(nums[mid] > target) right = mid;
            else left = mid + 1;
        }
        return nums[right] > target ? right : -1;
    }
};

这里需要说明的时,这个问题与前面两个问题不同的就是,“要求条件”从相等变成小于,所以,nums[right] > target 的时候,有可能这里的right就是我们要找的值,也有可能不是,所以right可以更新,但是不能更新为mid-1,只能更新为mid。那么对应的left就应该更新为 mid + 1。

同样,结果返回值处,也应该思考要求条件满足就是nums[right] > target的时候,满足那么right就是要找到值。

最大的i,使得nums[i] < target

一样的思路,我们来逐一思考:

  1. mid、left、right的初始设定不用动;
  2. while循环条件不用动;
  3. 找最大的i,所以mid向上取整;
  4. 当nums[mid] < target,希望下一个mid能在当前mid与right之间出现,此时应该更新left。
  5. if_else结构设计,考虑到4,if处更新left,由于是left,判断条件应该是小于;else处更新right,比较简洁。
  6. 由于要求条件满足时更新left,所以返回值应该检查left。

代码如下:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int mid, left=0, right=nums.size()-1;
        while(left < right){
            mid = left + ((right-left+1)>>1);
            if(nums[mid] < target) left = mid;
            else right = mid - 1;
        }
        return nums[left] < target ? left : -1;
    }
};

要注意,由要更新的是left或right反推if条件的时候要注意,由于我们是反推,所以一定要注意这里的逻辑关系。

要更新left --> nums[mid] < target --> left = mid
要更新right --> nums[mid] > target --> right = mid

其实,最后可以和“要求条件”对照一下,因为返回值 return 的时候比较的都是 = mid的时候,因为 mid + 1或者 mid - 1都被跳过了。

最大的i,使得nums[i] > target

要求条件还是大于,只是要找最大的i,那么同样的,我们有:

  1. mid、left、right的初始设定不用动;
  2. while循环条件不用动;
  3. 找最大的i,所以mid向上取整;
  4. 当nums[mid] > target,希望下一个mid能在当前mid与right之间出现,此时应该更新left。
  5. if_else结构设计,考虑到4,if处更新left,由于是left,判断条件应该是小于;else处更新right,比较简洁。
  6. 由于要求条件满足时更新left,所以返回值应该检查left。

同样的,代码如下所示:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int mid, left=0, right=nums.size()-1;
        while(left < right){
            mid = left + ((right-left+1)>>1);
            if(nums[mid] < target) left = mid;
            else right = mid - 1;
        }
        return nums[left] > target ? left : -1;
    }
};

测试代码,发现???有问题?仔细看看问题,对于不下降数列,求最大的i,使得nums[i] > target?,这需要二分搜索求么?不直接返回nums.size()-1不就好了么?而且仔细看一看,就会发现之前的思路中有一个地方有Bug,那就是4与5.

4要求nums[mid] > target的时候,更新left,但是5又说,由于更新left,所以应该是nums[mid] < target的时候,这不就矛盾了么,所以这个问题是有问题的!

最小的i,使得nums[i] < target

同样的,这个问题我也是在一本正经的胡说八道。

要求条件此时变成小于,要找最小的i,同样的有:

  1. mid、left、right的初始设定不用动;
  2. while循环条件不用动;
  3. 找最小的i,所以mid向下取整;
  4. 当nums[mid] < target,希望下一个mid能在当前mid与left之间出现,此时应该更新right。
  5. if_else结构设计,考虑到4,if处更新right,由于是right,判断条件应该是小于;else处更新left,比较简洁。
  6. 由于要求条件满足时更新right,所以返回值应该检查right。

所以代码如下:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int mid, left=0, right=nums.size()-1;
        while(left < right){
            mid = left + ((right-left)>>1);
            if(nums[mid] < target) right = mid;
            else left = mid + 1;
        }
        return nums[right] < target ? right : -1;
    }
};

写在后面

这一篇博客我费了九牛二虎之力,用了三寸不烂之舌,把我自己对二分搜索的理解应用在了四个最基本的问题上。书从薄到厚,再从厚到薄。最后总结一下,二分搜索主要就是要注意:

  1. mid、left、right的初始值;
  2. while循环条件;
  3. mid的更新,根据要找最大i还是最小i,来对应向上取整或向下取整;
  4. 根据要找最大值还是最小值,确定满足要求条件时更新left或者right;
  5. 根据4,设计if_else条件,注意left对应小于,right对于大于;
  6. 根据4,return来判断left或者right中哪个才符合要求条件。

这个东西基础特别重要,所以这篇博客我一定要经常搞不清楚就来多看看。此外,这篇博客主要是我自己的理解,那么之前的博客可以多看看其他代码,毕竟万变不离其宗。

多版本二分搜索代码解答参考:Algorithm-Binary Search算法。

你可能感兴趣的:(Algorithm)