二分查找总结

最近刷leetcode,做了挺多道二分查找的题目,感觉二分查找并没有想象中的简单,一些变形题还是有一定难度的,所以这里做一个总结,方便自己以后复习。

首先,二分查找一般有两种写法

左闭右闭写法

int search(vector<int> &nums, int target){
    int left = 0, right = nums.size() - 1, mid; //注意,right的初始值是nums.size()-1,而不是nums.size()
    while (left <= right){ //注意,是≤,不是<
        mid = left + ((right - left) >> 1); //注意,写成 mid = (left+right)/2 有可能发生溢出,除法换成移位操作可以提升计算速度,移位运算记得加括号,因为优先级比加号低
        if (nums[mid] == target){ //找到了
            return mid;
        }
        else if (nums[mid] < target){ //更新左边界
            left = mid + 1;
        }
        else{ //更新右边界
            right = mid - 1;
        }
    }

    return -1; //注意,如果执行到这里来了,说明没有找到目标元素,返回-1,因为如果找得到目标元素,则一定会进入循环里面的 if(nums[mid] == target)
}

左闭右开写法

int search(vector<int> &nums, int target){
    int left = 0, right = nums.size(), mid; //注意,right的初始值是nums.size(),而不是nums.size()-1
    while (left < right){ //注意,是<,不是≤
        mid = left + ((right - left) >> 1); //同上
        if (nums[mid] == target){ //找到了
            return mid;
        }
        else if (nums[mid] < target){ //更新左边界
            left = mid + 1;
        }
        else{ //更新右边界,注意,这种情况下不是 mid-1
            right = mid;
        }
    }

    return -1;
}

以上两种写法在大部分情况下是等价的,不过有些题目却只能用其中一种写法来书写,这个还是得依据具体的题目去分析。

但是,从以上两种写法,我们发现,在二分查找题目中,有这几点是需要我们注意的:

  • right的初始值,是选择 nums.size(),还是选择 nums.size()-1
  • 循环条件,是 left < right,还是 left <= right
  • right如何更新,是 right = mid,还是 right = mid - 1
  • 可能还有一点,就是有些时候循环最后还要返回一个值,是返回 left,还是返回 mid

所以,其实你去仔细思考的话,二分查找还是不那么容易想明白的。

下面我就记录一下几道自己在leetcode上做到的二分查找题,个人感觉有一定的代表性。




34. Find First and Last Position of Element in Sorted Array

这道题我在 网上 看到这么一种写法:
先找第一个大于等于target的数,记这个位置为low;然后再找第一个大于等于target+1的数,记这个位置为high。那么target的范围就是{low, high-1}。
于是我们先得写一个子函数,就是找到第一个大于等于target的数的位置

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        int start = firstGreaterEqual(nums, target);
        if (start == nums.size() || nums[start] != target) return {-1, -1};
        return {start, firstGreaterEqual(nums, target + 1) - 1};
    }

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

个人觉得不是很好想出来,而且你会发现,firstGreaterEqual这个函数一些变量的更新和之前标准的二分查找有点不一样了。

于是我写了一个更好理解的代码

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        int start_idx = find(nums, target, 0);
        if(start_idx == -1) return {-1, -1};
        else return {start_idx, find(nums, target, 1)};
    }
    
    int find(vector<int>& nums, int target, int tag){
        int left = 0, right = nums.size()-1, mid;
        while(left <= right){
            mid = (left + right)/2;
            if(target == nums[mid]){
                if(tag == 0){//表示找最左边的
                    if(mid > 0 && nums[mid-1] == target) right = mid-1;
                    else return mid;
                }
                else{//表示找最右边的
                    if(mid < nums.size()-1 && nums[mid+1] == target) left = mid + 1;
                    else return mid;
                }
            }
            else if(target < nums[mid]) right = mid-1;
            else left = mid + 1;
        }

        return -1;
    }
};

其他部分跟标准的二分查找一样,就是当我们找到target的时候,不能停下来,我们需要判断是否需要接着往左边找或者往右边找,
当我们需要查找最左边的哪个target时,我们需要判断当前这个target是否已经是最左边的了,如果不是,那么接着往左边找,同理,找最右边的也是这个逻辑。

33. Search in Rotated Sorted Array

读过题目之后,我们发现,Rotated Sorted Array 其实是长下面这个样子的:

这个图的意思就是数组右半段跑到左边去了,需要注意的是,这个数组是不包含重复数字的,所以 旋转过后左半段的最左边数字 一定大于 右半段的最右边的数字

所以接下来我们的代码这么写

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

        return -1;
    }
};

注意,当我们发现 target != nums[mid]的时候,接下来该怎么办呢?这个地方估计是很多人卡到的地方,
主要问题就是我们不知道现在mid是处于左边半段还是右边半段,那么如何确定我们所在的位置呢?就是通过和nums[right]比较,如果小于或者等于nums[right],说明mid在右半段,否则在左半段。

现在问题又变成了,我们知道了mid在左边还是右边,接下来如何更新left或者right的值呢?

其实我们发现,无论是到了左半段还是右半段,我们都存在一个接下来往左还是往右继续搜索的问题。所以里面还有一个ifelse判断。

81. Search in Rotated Sorted Array II

这个题和上一个题唯一的区别就是 数组中允许出现重复数字,别小看这一个区别,这可能给我们接下来的搜索带来不小的问题。

现在数组变成了下面这个样子

就是一开始的时候,nums[left]的值是有可能等于nums[right]的,当然,中间也可能出现这种情况

那么这怎么解决呢,既然nums[left]可能等于nums[right],那么我们就从nums[left]下手。

先看一下完整代码:

class Solution {
public:
    bool search(vector<int>& nums, int target) {
        int left = 0, right = nums.size()-1, mid;
        while(left <= right){
            mid = (left+right)/2;
            if(target == nums[mid]) return true;
            if(nums[left] == nums[mid]){
                //无法判断[left...mid]和[mid...right]两个区间哪个是有序的
                left++;
            }
            else if(nums[mid] <= nums[right]){//[mid...right]区间是递增的
                if(target > nums[mid] && target <= nums[right]) left = mid+1;
                else right = mid-1;
            }
            else{//[left...mid]区间是递增的
                if(target >= nums[left] && target < nums[mid]) right = mid-1;
                else left = mid+1;
            }
        }
        
        return false;
    }
};

首先,如果target==nums[mid]的时候我们自然返回true就行,这跟最原始的二分查找是一样的。

然后,我们发现target != nums[mid]的时候,就去比较nums[mid]和nums[left]的值,注意,nums[left]是可能等于nums[right]的。

  • 如果nums[left] == nums[mid],那么我们并不知道mid是在左半段,还是在右半段,也就无法判断[left...mid]和[mid...right]两个区间哪个是有序的,这种情况下,我们就让left++,因为target此时是不等于nums[left]的,自然可以跳过它

  • 如果nums[mid] <= nums[right],那么分两种情况

    • 一种是刚好等于nums[right],说明这时候nums[left]是不等于nums[left]的,否则就会进上一个判断条件,这说明,mid此时就是在右半段
    • 还有一种就是nums[mid] < nums[right],这时候自然也说明mid是在右半段的,因为左半段的数字都大于等于nums[right]
      于是这时候就回到了上一题的判断逻辑,就是在右半段去看一下接下来往左查询还是往右查询
  • 如果nums[mid] > nums[right],那么自然是去左半段找了,接下来的过程还是分为往左还是往右继续搜索


注意下面这两道题,同样是基于 Rotated Sorted Array 结构上的查找,虽然可以用二分查找做,但是用分治法明显更简单

153. Find Minimum in Rotated Sorted Array

这道题规定了数组的元素不重复
代码如下:

class Solution {
public:
    int findMin(vector<int>& nums) {
        return helper(nums, 0, nums.size()-1);
    }
    
    int helper(vector<int>& nums, int start, int end){
        if(nums[start] <= nums[end]) return nums[start];
        int mid = start + ((end-start)>>1);
        return min(helper(nums, start, mid), helper(nums, mid+1, end));
    }
};

你会发现这个函数及其简单,试想,加入我们数组没被翻转过,那么你如何快速查找数组的最小值?

那很简单啊,就是第一个值呀,因为数组已经是按照升序排列的。
那怎么判断一个序列是不是没有翻转过呢?或者说,怎么知道数组的某一个部分是升序的?
很简单,假设这个子片段开始下标是start,结束下标是end,那么加入nums[start] < nums[end],说明这个子序列是有序的,其中最小值就是nums[start]。

但是,我们在递归过程中,可能出现 start等于end 的情况,只包含一个元素的序列也是有序的,
所以当nums[start] <= nums[end]时,说明这个子序列[start...end]是有序的,直接返回其中的最小值nums[start]
当然这有一个前提,就是数组中不包含重复的数字,如果包含,当nums[start]==nums[end]的时候,[start...end]是不一定有序的。例如[3,1,3]

如果这段子序列无序,则说明最小值可能存在于[left...mid],也可能存在于[mid+1...end],于是我们递归计算两边的最小值,去两者中更小的值作为本次调用的最小值。
注意,有些人递归调用可能写成下面这个样子:
min(helper(nums, start, mid-1), helper(nums, mid, end));
这样是会出现错误的,可以自己去试试。

154. Find Minimum in Rotated Sorted Array II

这道题允许数组的元素重复

这道题和上一道题的唯一区别就在于,当我们发现 nums[start]==nums[end] 的时候,不能直接判断[start...end]之间是否是有序的,也就无法判断其中的最小值是nums[start]。
例如出现[3,1,3]的时候,最小值并不是3,而是1

那么怎么办呢?很好办,只需要在这里改造一下上一道题的代码。

class Solution {
public:
    int findMin(vector<int>& nums) {
        return helper(nums, 0, nums.size() - 1);
    }

    int helper(vector<int>& nums, int start, int end) {
        if (start == end) return nums[start];
        else {
            if (nums[start] < nums[end]) return nums[start];
            else {
                int mid = start + ((end - start) >> 1);
                return min(helper(nums, start, mid), helper(nums, mid+1, end));
            }
        }
    }
};

当我们nums[start] < nums[end]的时候,这一段是肯定有序的,其他情况我们都拆成两段递归。

只有0和1的世界是简单的
原文地址:https://www.cnblogs.com/nullxjx/p/14529117.html