二分查找理论及部分试题剖析

     

目录

本期试题:

leetcode35 搜索插入位置

leetcode582 山脉数组的峰顶索引

二分查找

双指针

leetcode162 寻找峰值

leetcode153 寻找旋转排序数组中的最小值

以D为基准点

以A点为基准点

leetcode LCR173 点名

有没有别的修改方法呢?(不对特殊情况作出判断)

1。哈希表(set)

2。哈希表——映射理论——计数排序

3。直接遍历找结果

4。位运算

5。数学方法(高斯求和公式)


       面对比较细,使用的时候比较容易越界访问的算法,我个人感觉相对其他的算法是比较简单的,但是在不了解二分查找算法本质的情况下就大胆操练的话也没有想象中的那么容易。面对试题的多样性和二分算法本身自带的局限性,对下面的一些经典的二分题目进行分析。

本次采用的试题全部来自leetcode。为了减少篇幅所有的试题一般不会考虑暴力枚举的解法,虽然所有的比较好的算法根本上都是从暴力枚举中优化来的建议先自己做一下再往下看解析,看完解析也先自己编写代码,实在不会了再结合着leetcode官解和我给的答案代码进行分析和编写以达到练习和学习的目的。

本期试题:

35. 搜索插入位置

852. 山脉数组的峰顶索引

162. 寻找峰值

153. 寻找旋转排序数组中的最小值

LCR 173. 点名

leetcode35 搜索插入位置

根据题目可知,需要在一个升序数组中插入一个数,返回这个插入位置的下标。由于此时数组有序,所以我们很容易就可以想到可以使用二分查找算法进行解题。二分查找算法无论是使用朴素二分还是查找左端点或者右端点都是利用了原数组的数据具有二段性。升序只是能使得二分查找容易被看出并且使用起来更容易更方便(毕竟已经排好序了嘛),所以能利用二分查找的根源就是数组具有二段性。

分析:知道了要使用二分查找来解题,知道了要寻找数组的二段性。由于目标值target已经给出,所以数组的二段性的区分点就是这个target,利用这个target一定可以将数组分成左边是小于target,右边是大于等于target,这样当二分循环结束后结束的位置就是我们插入的位置。

所以核心部分就是对于leftright的移动的判断了。

if (nums[mid] < target)
{
    left = mid + 1;
//如果发现下标为mid的值小于target了就表示mid和mid之前的数都不用看了,此时将left更新到mid + 1
}
else
{
    right = mid;
//如果发现下标为mid的值大于等于了target就表示这个mid超过或者就是要插入的位置,此时只能将right更新到mid
}

但是如果这个target大于了数组中的最后一个元素,此时数组中的元素就全部都是小于target的,此时数组不具有二段性了,所以就需要单独考虑了。如果这个target小于了数组中的第一个元素,此时数组中的元素就全部都是大于target的,此时数组也不具有二段性了,但是这种情况不需要特殊处理的。因为这种情况插入的位置为0,在上面的区间移动推理中可以推理得到,right会一直向前移动,等于0时就 right == left就退出循环了,所以是可以取到最终结果的。

mid如何更新呢?mid只能每次更新到偏向left的这边,因为只有left在向前+1移动。就是int mid = left + (right - left) / 2

循环退出的条件显然就是:当left == right

我们可以感悟到所有的可以利用二分查找算法的题在使用二分查找时考虑的细节无非就4个:

1。如何得见数组的二段性,如何得到target再利用target将数组分成两段

2。mid如何更新,要不要多+1

3。循环结束条件是什么,left == right还是别的什么

4。最开始时leftright一定就是数组的最左边和最右边吗?

只要理清楚这4个细节,那恭喜你这个二分题你已经解出大半了!!!

经过上面的全部的细节分析(其实已经提示得差不多了),下面是代码实现:

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

这个题其实就是套用二分查找算法的模板之一:查找区间的左端点!!!,一模一样的。

leetcode582 山脉数组的峰顶索引

如果上一个题简单是因为,target已经给出再加上数组有序。那这个题就没那么直接了。

由题目可以得出山脉数组只有一个峰,并且提供的一定是山峰数组,然后要找这个数组的山峰的位置,也就是说要去找一个无序山脉状的数组中的最大的数。

二分查找

分析:题目表达的意思非常简单,如何使用二分查找呢?还是先分析一下数组可能具有的二段性。我们以arr等于0 1 2 3 2 1 0为例可以发现所有的山脉数组的数据分布都是先上升后下降的,那处于上升的部分每一个数都会比上一个数大(包括峰值),处于下降的部分,每一个数都会比上一个数小(不包括峰值),经过这么分析数据就有了二段性了。

现在可以使用二分查找了,接着控制细节:

arr[mid] > arr[mid - 1]时,说明mid处于上升阶段,此时mid有可能就是峰值且mid的后面的数都不用考虑了,所以缩小寻找范围为:left = mid

arr[mid] < arr[mid - 1]时,说明mid处于下降阶段,此时midmid的后面的数都不用考虑了,所以缩小寻找范围为:right = mid - 1

由于只有right在向前移动一步或着多步(left相当于是在向前平移,如果mid不动left就不动了),所以mid每次更新应该偏向于right,所以为int mid = left + (right - left + 1) / 2

循环结束条件自然就是 当left == right时。

本题的target就是一个不固定的值了,为arr[mid - 1]

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

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

这个题其实就是套用二分查找算法的模板之一:查找区间的右端点!!!,一模一样的。

双指针

分析:如果抛开本题的严格的时间复杂度限制不谈,由于二分查找有时候不好看出数组的二段性,既然知道了要找数组的最大值,且最大值只有一个加上这个数组是呈左右两边数字向中间递增的势态。依据这个特点,我们可以使用以前学的双指针做法,一个指针指向最开始的元素,一个指针指向最后一个元素,然后开始通过左右指针不断平移向内靠来寻找每次比较大的数,如果arr[left] > arr[right],就将right++,反之left++;当left == right时循环结束,此时left或者right的位置就是峰值了。

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

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

此情况说明本题可能并没有严格的时间复杂度限制!!!

leetcode162 寻找峰值

题目理解起来还是非常简单的,就是现在的山脉数组有多个峰值了,我们只要找到其中的一个就可以返回对应的下标了。

分析:那我们也是先寻找一下数组具有的二段性。由于前面那题的二段性是通过midmid - 1找到的,所以我们这题也通过mid的位置寻找一下二段性。寻找二段性的过程就是在寻找有没有峰值。

nums[mid] < nums[mid + 1]时,mid处在一个下坡的位置,这时会发现mid后面不一定还会有峰值,可能一直处于下坡的趋势,所以如果此时在midmid之后未必可以找到峰值,所以更新寻找区间为:left = mid + 1

nums[mid] > nums[mid + 1]时,mid处在一个上坡的位置,这时会发现mid后面也不一定还会有峰值,可能也一直处于下坡的趋势,所以如果此时在mid之后未必可以找到峰值,但是此时mid有可能就是峰值所以更新寻找区间为:right = mid

经过上述分析就可以完美的将数组分为一定没有峰值的一边和有峰值的一边,这个二段性就产生了!!!

由于只有left在向前移动一步或着多步(right相当于是在向前平移,如果mid不动left就不动了),所以mid每次更新应该偏向于right,所以为int mid = left + (right - left) / 2

循环结束条件自然就是 当left == right时。

本题的target是一个不固定的值了,为arr[mid - 1]

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

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

这个题其实也是套用二分查找算法的模板之一:查找区间的右端点!!!,一模一样的。

leetcode153 寻找旋转排序数组中的最小值

首先,由题目可知,对于一个升序的数组进行旋转,旋转一次就将处在尾部的数移到头。然后通过给出的旋转n次的数组来从中找到里面的最小值。

分析:如图乍一看,和之前山脉数组的峰顶索引这个题的图好像有点像,我当时的第一个想法就是可不可以先用二分查找的方法找到峰值,然后这个最小数就是峰值的下一个数

如果按照这个思路这个题只能去找midmid - 1之间的关系,但是你会发现无论哪一段都是递增的,所以无法通过这个方法找到数组具有的二段性。

由于旋转到前面的元素肯定会比旋转之后的最后一个元素大,所以我们会发现如果以上图的最后一个元素D作为参照的话,处在AB段的已完成旋转的元素就会大于D,处在CD段的元素就会小于等于D,咦,这时数组的二段性就产生了——前一部分大于D,后一部分小于等于D

以D为基准点

经过上面的分析可得二段性,当mid是落在AB段的,则mid的值一定是大于最小值的,因为本身D点的值就是大于或者等于最小值的,此时midmid之前的数就不需要考虑了,更新查找区间为:left = mid + 1

mid是落在CD段的,则mid的值一定是小于或者等于D的,此时由于CD段是递增的且mid有可能就是最小值,所以mid之后的数就不需要考虑了,更新查找区间为:right = mid

由于只有left在向前移动一步或着多步(right相当于是在向前平移,如果mid不动right就不动了),所以mid每次更新应该偏向于left,所以为int mid = left + (right - left) / 2

循环结束条件自然就是 当left == right时。

本题的target为D下标的值即arr[nums.size() - 1]

这时会有一个特殊情况,我们看一下示例三:当旋转原数组长度次时,发现旋转后的数组和原数组就一样了,这时旋转数组是单调递增的就没有二段性了,需不需要单独判断呢?答案是不需要的,因为这时每次的mid的值都是小于等于D的,在循环内部只会进行right = mid,想象一下right一直在向左平移而left不动,当left == right时跳出循环,此时right为0,right下标下的元素就是原数组的最小值。

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

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

这个题其实也是套用二分查找算法的模板之一:查找区间的右端点!!!,一模一样的。

有的同学通过详细的观察发现如果选点D作为基准点,那可不可以选A点呢,A点好像也可以达到D点相同的效果呀!!!,这个答案是肯定的。

以A点为基准点

如图如果将A点作为基准点的话,AB段都是大于等于A的,CD段都是小于等于A的,此时照样能找到数组具有的二段性。

mid是落在AB段的,则mid的值一定是大于等于A点的值,又最小值一定是小于A点的值,此时midmid之前的数就不需要考虑了,更新查找区间为:left = mid + 1

mid是落在CD段的,则mid的值一定是小于A点的值的,此时由于CD段是递增的且mid有可能就是最小值,所以mid之后的数就不需要考虑了,更新查找区间为:right = mid

由于只有left在向前移动一步或着多步(right相当于是在向前平移,如果mid不动right就不动了),所以mid每次更新应该偏向于left,所以为int mid = left + (right - left) / 2

循环结束条件自然就是 当left == right时。

本题的target为A下标的值即arr[0]

这时也会有一种和以D为基准点时一样的特殊情况,就如示例三,当旋转原数组长度次时,发现旋转后的数组和原数组就一样了,这时旋转数组是单调递增的就没有二段性了,需不需要单独判断呢?这时是需要另外判断的,因为这时数组的元素以及每次的mid全部大于等于A点的值了,这样进入循环后就只会进行left = mid + 1都移动,这样最后跳出循环后left指向最后一个元素的下标,但是我们需要得到的是最小值呀!!!

那就是需要判断这个数组旋转完后是不是升序的就可以了,但是不需要这么麻烦,因为你这一判断有可能时间复杂度就大了!结合不同的例子比较不难看出,如果nums的移动步数不等于长度值时,D点的值是一定小于A点的值的,只有在特殊情况之下,D点的值才会大于A点的值。所以当D点的值大于A点的值时,直接返回A点(第一个值)的值就可以了:

int ret = nums[0];
if (nums[nums.size() - 1] > ret)
{
    return ret;
}

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

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

这个题其实也是套用二分查找算法的模板之一:查找区间的右端点!!!,一模一样的。

leetcode LCR173 点名

本题要求求给定数组中缺失的那个元素。我们先进行试题分析。

分析:既然要运用二分算法进行解答那就必须找到数组中数字具有的二段性。经过示例观察可以发现,数据到缺失数字之前是从第一个数字为0开始以1个单位为步长满满递增的,依据C语言学的数组储存原理不难发现,数组在缺失数字之前,数字和对应下标都是相等的,缺失数字之后数字才开始和对应的下标不等了。咦,经过这么分析,二段性是不是就轻而易举的找到了!!!

所以records具有的二段性就是,一部分元素值等于下标值(前半部分),一部分元素值不等于下标值(后半部分)。那么这个缺少值在哪呢?我们依下图可以看出,这个缺少值就是第一个元素值不等于下标值的下标,也可以理解为最后一个下标值等于元素值的下一个数的下标。

老规矩确认可以使用二分查找后接着控制leftright的移动细节,如上图,当records[mid] == mid时,说明此时mid还在前半部分,那midmid之前的值都不需要考虑了,因为这个缺少值一定在后半部分,则更新查找区间为:left = mid + 1

records[mid] != mid时,说明此时mid在后半部分,此时mid下标有可能就是缺省值,所以mid之后的值都不需要考虑了,因为这个缺少值只会在后半部分的第一个,则更新查找区间为:right = mid

由于只有left在向前移动一步或着多步(right相当于是在向前平移,如果mid不动right就不动了),所以mid每次更新应该偏向于left,所以为int mid = left + (right - left) / 2

循环结束条件自然就是 当left == right时。

而这题的target的值则变成了一个下标mid

但是,这个题有一个坑,如果这个缺少值是最后一个数或者第一个数的话,此时的records就要嘛

下标值全部等于元素值,或者下标值全部不等于元素值。此时数组的二段性消失,带入上面的普遍情况的分析都永远无法得到缺少值。那怎么办呢?

当缺少值是第一个时:records没有前半部分,由于我们上述分析可得,缺少值一定会被找到在后半部分的第一个元素的下标,此时当跳出循环时,理论上返回的下标就是0,这种情况的缺少值也的确是0,所以这种情况不用特殊处理了。

当缺少值是最后一个数时:records没有后半部分,由于我们上述分析可得,缺少值一定会被找到在后半部分的第一个元素的下标,没有了后半部分,自然这种情况的缺少值是不会被找到的。这种情况循环会一直走left = mid + 1,循环的结束值就是数组的最后一个元素的下标(不包括缺少值),就需要特殊处理了。

所以只需要最后判断一下返回值是不是等于元素值就可以了:

if (right == records[right])
{
    return right++;
}

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

class Solution {
public:
    int takeAttendance(vector<int>& records)
    {
        int left = 0;
        int right = records.size() - 1;
        while (left < right)
        {
            int mid = left + (right - left) / 2;
            if (records[mid] == mid)
            {
                left = mid + 1;
            }
            else
            {
                right = mid;
            }
        }
        if (left == records[left])
        {
            return ++left;//注意:前置++才可以的
        }
        return left;
    }
};

有没有别的修改方法呢?(不对特殊情况作出判断)

由于上述的当缺少值是最后一个数的情况其实是由于right == left就跳出循环导致的,所以可不可以更换循环结束的条件来完成调整,像之前朴素二分那样当left <= right时才跳出循环。这个时候逻辑就完全变了。

此时就相当于把这个缺少值归为前半部分,而后半部分就少了一个值了。接着重新控制leftright的移动细节,如上图,当records[mid] == mid时,说明此时mid还在前半部分,那midmid之前的值都不需要考虑了,因为mid还未等于缺少值,则还是更新查找区间为:left = mid + 1。当records[mid] != mid时,说明此时mid在后半部分,此时mid下标不可能是缺省值,所以mid及其之后的值都不需要考虑了,因为这个缺少值只会在前半部分的最后一个,则更新查找区间为:right = mid + 1

由于只有left在向前移动一步或着多步(right相当于是在向前平移,如果mid不动right就不动了),所以mid每次更新应该偏向于left,所以为int mid = left + (right - left) / 2

对于返回条件的判断,当left == right时,下一次mid == left,如果mid进行的是records[mid] == mid的判断那left = mid + 1因为这个缺少值只会在前半部分的最后一个,所以返回left,如果records[mid] != mid,此时left本身就是缺少值了,直接返回left,所以循环结束后就是只能返回left。由于是从特殊情况反推的普遍情况,所以就不需要考虑之前的两种特殊情况了。

经过上面的全部的细节分析(其实已经提示得差不多了),代码实现如下:

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

所以有的时候二分查找的循环结束条件也不一定都是left == right的,这体现算法的灵活性!!!

博主,博主上面的二分查找的方法实在是有点难想的,我并没有注意到下标和数之间的关系或者我就算注意到了但是在笔试时紧张而没有想到用二分查找算法,可不可以稍微牺牲时间复杂度另找其他没有二分查找那么优秀的方法呢?

肯定是有的,这里提供另外的5种我个人感觉也还可以的方法来解答这个题?

1。哈希表(set)

我们可以使用哈希表这个容器来解决这个问题,要找n个数字的数组中缺失的数字,我们可以先定义一个n + 1个空间大小的哈希表,然后将数组的元素往哈希表里填,全部数据都填完了,看一下哈希表内哪个空间没有数据,那个没有数据的空间的下标就是缺失的数据。

2。哈希表——映射理论——计数排序

博主,博主,我是C语言刚学完呀,还没有接触过哈希表呀,这怎么办!

没关系,由于本题的数据全部都是整型的,那就可以用映射,哈希表的底层理论也是映射,先建一个数组,这个数组的大小为原数组的最大值减去最小值再加1,然后将原数组的元素的最小值映射到数组的对应下标的位置,然后加1,等全部数字都映射完,然后遍历一变映射数组,看一下哪一个下标下没有数字,那个没有数字的下标就是缺少的数据。注意这里不可以将最小值映射到映射数组的第一个元素

映射理论详见我在排序章节讲过的计数排序

3。直接遍历找结果

这个就属于暴力枚举的算法,就是从左向右遍历,当遍历到下标不等于元素值的第一个下标时就找到缺失值了,这里有一个特殊情况就是当缺失值是最后一个时(不在原数组中)需要特殊判断一下。

4。位运算

这里可以使用的位运算符只有异或^,根据异或运算的特点,相同的值异或等于0,0和其他值异或等于其他值,所以我们可以将没有缺少的数组的全部元素和其中有缺少的值的同一个数组的全部元素异或在一起,相同的都抵消了,最后就变成0 ^ 缺少值 == 缺少值。可见也可以得到答案的。位运算不了解的详见我之前关于位运算的讲解

5。数学方法(高斯求和公式)

由于如果数组是没有缺少值的,那数据分布会呈一个等差数列排布,公差为1,那如果有缺少的话此规律就会被打破,所有可以先利用等差数列求和的公式将没有缺少时的数组的全部元素先加起来,然后再依次减去含有缺少值的同一个数组的全部元素,那最后剩下的就是缺少值了。

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值