算法之二分查找算法

二分查找算法简介

1. 首先说明二分查找算法是比较恶心, 细节很多, 很容易写出死循环的算法, 但熟悉了之后是最简单的算法.

2. 其次我们可能听说过二分查找的前提是数组有序的前提下进行, 但其实不一定.

3. 二分查找算法有一套模板:

  • 朴素的二分模板: 比较简单, 但是有局限性
  • 查找左边界的二分模板, 查找右边界的二分模板: 万能模板, 但是细节很多.

二分查找算法的关键是"二段性" , 当我们发现一个规律, 根据这个规律能把这个数组分为两部分, 根据规律能有选择性的舍去一部分, 进而能在另一个部分继续查找, 可以看到这其中并没有提到数组是否有序, 关键在于数组是否有"二段性". 

此外, 我们对于选择区间划分点mid的位置也并没有具体的描述必须选择在中间点, 三分之一点, 四分之一点....都可以, 因为只要我们找到的这个位置能把区间分为两部分即可, 即数组有二段性即可.

但是选择中间点作为划分点时间复杂度是最好的.


题目1: 二分查找

朴素二分查找的步骤:

a. 定义 leftright 指针, 分别指向数组的左右区间.
b. 找到待查找区间的中间点 mid , 找到之后分三种情况讨论:

朴素二分查找核心:

1. arr[mid] == target 说明正好找到, 返回 mid 的值;

2. arr[mid] > target 说明 [mid, right] 这段区间都是大于 target 的,因此舍去右边区间, 在左边 [left, mid -1] 的区间继续查找,即让 right = mid -1 ,然后重复 2 过程;
3. arr[mid] < target 说明 [left, mid] 这段区间的值都是小于 target 的, 因此舍去左边区间, 在右边 [mid + 1, right] 区间继续查找, 即让 left = mid +1 ,  然后重复 2 过程;

c. 当 left 与 right 错开时, 说明整个区间都没有这个数, 返回 -1 .

相关细节问题: 

1. 循环结束的条件: left>right, 也就是这个区间不存在的时候, 说明没找到; 注意: 当left==right的时候循环没结束, 此时代表区间内只有一个数, 这个数也是要判断的.

2. 为什么时间复杂度低? 

程序执行次数 1次 ->  区间长度n/2, 2次->n/2^2, 3次->n/2^3, ..., x次->n/2^x, 假设在第x次区间长度为1, 也就最坏的情况下找到了这个数, 那么n/2^x = 1 -> x = logn, 所以时间复杂度为O(logn).

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;//加法有溢出的风险
            int mid = left + (right-left)/2;
            if(nums[mid] < target) left = mid + 1;
            else if(nums[mid] > target) right = mid -1;
            else return mid;
        }
        return -1;//没找到
    }
};

 关于朴素二分查找的模板:

    while(left <= right)
    {
        int mid = left + (right-left)/2;
        if(...)
            left = mid + 1;
        else if(...)
            right = mid -1;
        else
            return ...;
    }

其中...根据题目所分析出的二段性进行填写. 其中两个细节:

1. 条件判断为left<=right.

2. 求中间点的方式为left +(right-left)/2防溢出.

3. 朴素二分查找求中间点用 left+(right-left)/2 和 left+(right-left+1)/2 没有差别, 这两种求中间点的方法在区间长度为奇数的时候, 求出的mid是一样的, 唯一的区别在于区间长度为偶数的时候, 第一种求出的mid偏左, 第二种求出的mid偏右, 对于朴素二分查找这两种没有区别.


题目2: 在排序数组中查找元素的第一个和最后一个位置

 这道题用朴素二分法无法求解, 因为要求的是一个区间, 朴素二分求到的那个值无法确定位于区间的哪一个位置, 但是还是可以用二分的, 关键是找到数组的二段性:

1. 查找区间的左端点:

可以发现, 我们可以把区间按照大于等于target小于target分为两部分:

当nums[mid]的值落在小于target的区间内时, 如何去更新值呢? 能去更新right吗?

不能, 因为区间左端点的位置在大于等于target的区间内, 更新right就错过了这个点, 所以要更新left, 而left左边区间的值包括left都小于target, 所以left = mid+1, 最好的情况就是left恰好在区间的右端, 此时mid对应的值就一定等于要找的那个位置.

 当nums[mid]的值落在大于等于target的区间内时, 如何去更新right呢?

right不能等于mid-1, 因为如果mid恰好落在区间的左端点, 那么mid-1就错过了这个位置, 而right要尽可能的往左移取找到区间的左端点, 所以right=mid.

细节处理:

1. 循环条件必须是left<right, 而不能是left<=right, 因为当left==right的时候, 一定是要找的区间左端点, 如果条件还取判断是否相等, 就会陷入死循环.

2.  求中点的操作:

上面我们说了 left+(right-left)/2 和 left+(right-left+1)/2 两种取中点的方式, 这里必须要选择左边那种, 否则又会进入死循环, 因为当区间不断缩减并只剩两个元素的时候,

1. 假如存在区间左端点: 第二种取中点取的一定是right的值, 就会陷入死循环

2. 假如不存在区间: 假如区间内的值都大于target, right一直向左移动, 最终也会达到区间长度为2的, 和上面一样陷入死循环; 假如区间内的值都小于target, left一直向左移动, 最终不会死循环, 但是前面两种情况都会死循环.

 

2. 查找区间的右端点:

操作和查找区间左端点类似, 也是寻找二段性, 把区间分为小于等于target和大于target两部分, 和上面相反当mid落在小于等于target区间, left=mid, 落在大于3的区间, right = mid-1.

循环也是left < right, 但是取中点的方式必须是第二种, 道理和之前类似, 只剩两个元素时会进入死循环.

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target)
    {
        //处理边界情况
        if(nums.empty())
            return {-1,-1};
        
        int begin = -1, end = -1;
        int left = 0, right = nums.size()-1;

        //寻找区间左端点
        while(left < right)
        {
            int mid = left + (right-left)/2;
            if(nums[mid] < target)
                left = mid+1;
            else
                right = mid;
        }
        if(nums[left] == target)
            begin = left;
        
        left = 0, right = nums.size()-1;//这里left可以不用重置, 因为left已经在begin位置
        //寻找区间右端点
        while(left < right)
        {
            int mid = left + (right-left+1)/2;
            if(nums[mid] > target)
                right = mid - 1;
            else
                left = mid;
        }
        if(nums[right] == target)
            end = right;

        return {begin,end};
    }
};

 寻找区间左端点右端点模板:

//寻找区间左端点
        while(left < right)
        {
            int mid = left + (right-left)/2;
            if(...)
                left = mid+1;
            else
                right = mid;
        }
 //寻找区间右端点
        while(left < right)
        {
            int mid = left + (right-left+1)/2;
            if(...)
                right = mid - 1;
            else
                left = mid;
        }

...处根据二段性填入

 这两种划分方式选择哪种呢?

根据题目去判断要找的值最终是 >=target or <=target的, 因为left和right相遇的位置一定是包含等于的那个区间, 所以要找的结果一定是要落在包含等于的区间内的.


题目3: 搜索插入位置

解法一: 朴素二分查找, 先用朴素二分查找看看是否能找到target, 能找到直接返回; 找不到的话, 如果循环结束前区间为1的值大于target, right向左移动, 此时的left就是要插入的位置, 如果小于target, left向右移动, left也是要插入的位置 , 所以返回left即可.

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

解法二: 二段性划分: 对于能找到target插入位置在数组中插入位置在数组最左侧的情况, 要返回的位置要么是=target的位置, 要么是第一个大于target的位置, 所以把区间划分为 小于target和大于等于target两部分:

注意, 需要单独处理插入位置在最右边的, 此时整个数组都在小于target的区间, 最终一定是落在数组最后一个位置, 要将返回值+1.

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

题目4: x的平方根

 二分查找方法依然是去寻找数组的二段性, 要去[1,x]的数组中去寻找x的平方根, 可以把数组划分为平方<x和平方>=x 平方<=x和平方>x ,哪一种划分是正确的? 因为此题返回的是平方根的整数部分, 比如8的算术平方根是2.82842, 应该返回2, 返回的数值是<=实际的平方根的, 所以应该划分为平方<=x和平方>x.

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) 
    {
        int left = 0, right = nums.size()-1;
        while(left < right)
        {
            int mid = left + (right-left)/2;
            if(nums[mid] < target)
                left = mid + 1;
            else 
                right = mid;
        }
        
        if(target > nums[left])//单独处理插入在右边界
            return left+1;
        else 
            return left;
    }
};

题目5: 山峰数组的峰顶索引.

暴力解法:

对于数组每一个元素, 比较arr[i] > arr[i-1]是否成立, 出现的第一个不成立的位置记为k, 则k-1就是山峰位置. 

二分查找:

观察山脉数组的定义, 可以发现对于山峰左侧包括山峰的位置, arr[i] > arr[i-1], 对于山脉的右侧, arr[i] < arr[i-1], 所以由此可以把数组分为两段, 求区间的右端点.

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

题目6:寻找峰值

暴力解法: 

 1. 第一个位置后如果下降, 则第一个位置就是山峰

2. 第一个位置后上升, 遇到第一个arr[i] < arr[i-1]的位置, i-1即为山峰

3. 第一个位置到最后一直上升, 最后一个位置就是山峰.

二分查找: 

 此题和上一题类似, 但是可能有多个山峰, 也可能是一路上升或者一路下降, 但只需要找到一种情况即可:

arr[i] > arr[i-1]时, 修正left = mid; arr[i] < arr[i-1]时, 修正right = mid-1, 其实就算有多个山峰, 经过区间不断地缩小, 最终一定会变成上一题的只有一个山峰的情况, 无非多了两种一路上升和一路下降,  所以代码和上一题一模一样.

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

题目7: 搜索旋转排序数组中的最小值

以这段数组为例, 以最小值为分界点, 左侧区间内的值一定大于D点的值, 右侧区间内的值一定小于等于D点的值, 根据这个性质就可以把数组分为两段, 题目变成求右区间的左端点:

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

如果我们用A点作为判断点, 左侧区间内的值都大于等于A点, 右侧区间内的值都小于A点, 可以吗?

不完全可以, 这种情况下需要单独去判断如果数组是否是完全递增的情况, 这时left会一直++直到最右侧, 此时是区间的最大值. 而用D点作为判断点则不用考虑这个情况.

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

题目8: 点名

此题以缺失的那个值作为分界点, 可以把区间划分为两块, 左区间内的值 下标数组内对应的值 是相等的, 右区间 下标数组内对应的值 是不相等的, 以此来将数组划分为两块. 需要注意, 如果最后求出来的返回值 下标 和 数组内对应的值 还是相等的, 说明缺失的是学号为n-1的那个人, 要返回left+1:

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

 此题还有若干时间复杂度为O(n)的方法:

1. 遍历数组哈希表存储出现次数, 没出现的就是缺失的

2. 直接遍历找结果

3. 位运算, 根据相同的数字异或等于0的性质, 将前n-1个数异或求和, 然后与数组中的数再异或, 最终的结果就是缺失的值

4. 等差数列求和, 求前n-1项的和再减去数组元素的和, 得到的就是缺失的值.


  • 13
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值