漫话算法[二分查找]:不用背你也能写出漂亮的二分查找框架并秒杀至少5道题!

快来和叮当学习算法吧!

B站同步更新!

同步到开源项目Github传送门: Easy-Programming及微信公众号:CVBear

项目内含Leetcode五杀刷题指南-致力于通过5个问题带你入门掌握算法套路

漫话算法[二分查找|算法模板]一首诗解决5道Leetcode题!

From all CV to No CV !我的梦想是编程不再CV!

相信你和我一样在解决二分查找的时候,被“边界”以及二分查找诸多细节折磨过痛苦过,什么时候该前什么时候该退,半个小时还是没有解决一个问题最后"进退两难"!没关系,通过本文我相信你将学会做出正确的"进退选择"并且我将手把手带你解决5道算法题。
在这里插入图片描述

叮当的困惑

你是否有疑问别人的写法中为什么会出现这样或者那样的写法,而别人仅仅只告诉你,这是一种技巧!其实并没有这么神秘,只是别人花时间去细细的调试了里面的细节而已,看完这篇文章相信你心中的疑惑都会解开!
在这里插入图片描述

二分查找诗歌

在这里插入图片描述

使用这首诗歌你至少能完成本文5道题目而且不仅仅于此!

LeetCode题目
34. 在排序数组中查找元素的第一个和最后一个位置
35. 搜索插入位置
69. x 的平方根
278. 第一个错误的版本
852. 山脉数组的峰顶索引
还有很多问题你也可以解决!

如何快速阅读本文

在这里插入图片描述

算法思想

使用[减治]思想裁剪原问题中[无效区间],将原问题变为规模更小的子问题配合边界的[收缩]从减小[有效子区间],从而获取答案!这也是通过本文希望你掌握的而不是去死记所谓的算法模板
在这里插入图片描述

二分查找不是分治算法吗?

如果你了解归并排序的话一定知道他的步骤:分解、求解、合并,而二分查找实际上每次分解都会淘汰掉另一个子问题,并不需要子问题合并再得出原问题的解,这种就属于单子问题也就是下图中提到的,Anany V. Levitin提出的减治

在这里插入图片描述

关键词说明

本文将反复提到的关键词

  • [无效区间]:不在的区间

  • [有效子区间]:解在的区间

  • [收缩]:收缩边界减小[有效子区间]范围

  • [裁剪]:排除掉解一定不存在的位置

  • [左中位]:0 1 2 3(left mid) 4 5 6 7即7/2=3,向下取整的中间位置

  • [右中位]:0 1 2 3 4(rigtht mid) 5 6 7即(7+1)/2=4,向上取整的中间位置

适用场景

  • 有序数列
左边(left为左边界)小于mid中值小于右边(right为右边界)
left~(mid-1)<mid<(mid+1)~right

复杂度分析

  • 时间复杂度:O(logn):n为数组长度
  • 空间复杂度:O(1)

区域划分(三国视野)

从三国的角度看二分查找中区域的划分

在这里插入图片描述

天下三分

[裁剪]掉左右找到孩子[mid]

待搜索区间划分为三块:各部分独立,谁都不属于谁

  • [mid+1, r]:[裁剪]
  • [l,mid-1]:[裁剪]
  • [mid]:解

在这里插入图片描述

// [进退三则]
if (target > a[mid]) l = mid + 1;  	   // [大中则进] [mid+1,r] [裁剪]
else if (target < a[mid]) r = mid - 1; // [小中则退] [l,mid-1] [裁剪]
else return mid;					   // [等中则返] [mid]解区间

统一西部

舍不得孩子(mid)套不出狼

待搜索区间划分为两块:

  • [mid,r]:解一定不存在的[无效区间]
  • [l,mid-1]:解存在的[有效区间]

在这里插入图片描述

// 伪代码只是一种思想,具体编码要考虑不同的细节,通常这种写法需要考虑[中位]向下取整导致取不到[右边界]的解导致死循环的问题
// 隐藏技巧: 取右中位 int mid = l + (r - l + 1) /2;
if (m及右侧属于[无效区间]?) {
    right = mid - 1;// [裁剪]掉[无效区间]
} else {
    left = mid;    // 边界[收缩]
}

统一东部

舍不得孩子(mid)套不出狼

将待搜索区间划分为两块:假设mid属于left均为[无效区间],让出mid给东部然后right进行边界[收缩]得出解!

  • [left,mid]:解一定不存在的[无效区间]
  • [mid+1, r]:解存在的[有效区间]

在这里插入图片描述

// 伪代码只是一种思想,具体编码要考虑不同的细节
if (m及左侧属于[无效区间]?) {
    left = mid + 1; // [裁剪]掉[无效区间]
} else {
    right = mid;    // 边界[收缩]
}

基本框架:天下三分

记住双指针终止位置这些细节,他们将是你以后解决问题的关键!熟悉基本写法而不要被“模板”学会思想是关键,写法可以千变万化

温馨提示:基本写法如果会的话可以跳过,这是为了帮助你了解

在这里插入图片描述

目的

查询target所在的索引位置,并不能解决含有重复数字时取左右边界位置的情况

样例

在这里插入图片描述

双闭区间写法[left,right]

这种写法出自《算法4》,将其中变量lo和hi替换为left和right而来,书中的变量定义其实更规范,但left和right更容易让大家理解!

基本步骤

  1. [定左右]:确定左右边界

  2. [定区间]:确定查询区间即条件while(查询区间),左右闭区间[left,right]

    双指针终止位置(目标值不在样例中的情况):双指针不重合坚持的原则[left,right=left-1]!因为终止条件是(left > right)

    • 查询7(超出右边界):[left=n,right=n-1] 且mid=n-1(n为数组长度)
    • 查询-1(超出左边界):[left=0,right=-1] 且mid=0

    隐藏技巧适当修改后如果目标值在数组中则返回对应的位置而如果不在时则left总会落在它应该插入的位置

  3. [取中值]:获取中值(后面不再赘述)

    • 写法1:int mid = (left + right)/2; 容易整型溢出,/2并不用改为位运算>>1因此也不必装x!,编译器已经帮你优化了
    • 写法2:int mid = left + (right - left)/2; 避免整型溢出,出自《算法4》,当然还是无法避免就把int修改为long即可
    • 写法3:int mid = (low + high) >>> 1; 出自JDK Arrays源码

    注意点:/是向下取整如 0 1 2 ->3<- 4 5 6 7重复数字取不到右边界,还有避免整型溢出的写法后面不再赘述!

  4. [进退三则]:进退就是确定下一轮查询的[有效区间]的边界

    • 大中则进:左边界以m为分界点进一步
    • 小中则退:右边界以m为分界点退一步
    • 等中则返:查找到了目标值刚好就是mid位置
  5. [无功而返]:没有查询到元素返回-1

public int indexOf(int[] a, int target) {
    // 1.[定左右]
    int left = 0;
    int right = a.length -1;

    // 2.[定区间] 
    while (left <= right) {// [left,right]
        // 3.[取中值]
        int mid = left + (right-left)/2;
        // 4.[进退三则]
        if (target > a[mid]) left = mid + 1;  	   // [大中则进] [mid+1,right]
        else if (target < a[mid]) right = mid - 1; // [小中则退] [left,mid-1]
        else return mid;					       // [等中则返] [mid]且l=r=mid 解区间
    }

    // 5.[无功而返]
    return -1;
}

左区右开区间写法[left,right)

只是为了让你了解这种写法中引发的修改细节!理解即可

修改说明

  • 修改①:这种写法较上面的写法的区别
    • 达到左边界,right指针不会小于0,很好理解[0,0)产生了[失效区间]不符合修改②就退出了
    • 达到右边界,left指针落在n(数组长度)
  • 修改②:你可以理解为让双指针都能落在数组长度位置的一种写法
    • 就是为了不让双支指针在循环查询时指针重合,指针重合时跳出while循环
    • 如果不修改显然就引发了索引越界初始值[0,数组长度]必须改为[0,数组长度)
  • 修改③:为什么是right = mid而不是right = mid - 1;
    • 这样会导致如查询2出现[失效区间]即[2)从而导致查询不到结果

基本步骤

  1. [定左右]:确定左右边界

  2. [定区间]:确定查询区间即条件while(查询区间),左闭右开区间[left,right)

    双指针终止位置1(目标值在样例中的情况):双指针不重合并返回mid

    • 查询2:[left =2,right=3),mid = 2

    双指针终止位置2(目标值不在样例中的情况):双指针重合坚持的原则[left,right=left)

    • 查询7(超出右边界):[left =n,right=n) 且mid=n-1(n为数组长度),双指针重合在最右侧后再执行一步[大中则进]后产生无效区间[7,7)终止
    • 查询-1(超出左边界):[left =0,right=0]且mid=0,双指针重合在数组最左侧后产生无效区间[0,0)后终止

    隐藏技巧:left和right指针可以游走于[0,n]n为数组长度的范围,且只要将等于的情况按需修改就能保证终止时指针重合

  3. [取中值]:获取中值

  4. [进退三则]:进退就是确定下一轮查询的左右[区间]的边界

    • 大中则进:左边界进一步
    • 小中则退:有边界退一步
    • 等中则返:查找到了目标值刚好就是mid位置
  5. [无功而返]:没有查询到元素返回-1

public int indexOf(int[] a, int target) {
    // 1.[定左右]
    int left = 0;
    int right = a.length;// 修改①

    // 2.[定区间] 
    while (left < right) {// [l,r)
        // 3.[取中值]
        int mid = left + (right-left)/2;
        // 4.[进退三则]
        if (target > a[mid]) left = mid + 1;  // [大中则进] [mid+1,r)
        else if (target < a[mid]) right = mid; // [小中取中] [l,mid) 修改②
        else return mid;					   // [等中则返] [mid]且l+1=r & mid=l
    }

    // 5.[无功而返]
    return -1;
}

新的需求

C总:赛赛!你能给我查询出第一个出现的2吗

在这里插入图片描述

查询左边界框架

后面会教你写这种框架的思路,而并不需要你去背所谓的“算法框架”,现在只要有印象就好啦!

在这里插入图片描述

※写法1:双闭区间[left,right]

  • 目的

查询target所在的最左索引位置或target应该插入的第一个位置

  • 基本步骤
  1. [定左右]:确定左右边界

  2. [定区间]:确定查询区间即条件while(查询区间),左右闭区间[left,right]

    双指针终止位置1(目标值在样例中的情况):坚持的原则**[left,right=left-1]**

    • 查询2:[left=2,right=1],mid = 1
      • 右指针向左缩进至左边界
      • 右指针向右双指针重合致使l左指针变为我们想要寻找的左边界
      • 最后[等中则退]结束循环

    双指针终止位置2(目标值不在样例中的情况):坚持的原则**[left,right=left-1]**

    • 查询8(超出右边界):[left=n,right=n-1]且mid=n-1(n为数组长度),双指针重合在最右侧后再执行一步[大中则进]后终止
    • 查询-1(超出左边界):[left=0,right=-1]且mid=0,双指针重合在数组最左侧后再执行一步[小中则退]后终止

    总结!!!:这种写法可以让left指针的范围在[0,数组长度]区间内而right指针在[-1,数组长度-1],且终止时right总是在left左侧

  3. [取中值]:获取中值

  4. [进退三则]:大中则进、小中则退、等中则退,当然也可以将后两步合并在一起正是本文后半部分要带你学会的核心思想

  5. [检越]:检查left指针是否越界,若越界则“矫正”

  6. [返边界]:查询左边界则“返左”,left总会在终止时落在你想要的位置!

public int indexOfLeft(int[] nums, int target) {
    // 1.[定左右]
    int left = 0;
    int right = nums.length-1;

    // 2.[定区间]
    while (left <= right) {//[l,r]
        // 3.[取中值]
        int mid = left + (right - left)/2;
        // 4.[进退三则]
        if (target > nums[mid]) left = mid + 1;        // [mid+1,r] 大中则进
        else if (target < nums[mid]) right = mid - 1;  // [l,mid-1] 小中则退
        else right = mid -1;                           // [r=mid-1] 等中则退
    }

    // 5.[检越]
    if (left >= nums.length || nums[left] != target) return -1;

    // 6.[返边界]
    return left;
}

※写法2:左闭又开区间[left,right)

基于基本写法的修改,查询区间修改为[left,right)

目的

查询target所在的最左索引位置或插入位置

基本步骤

  1. [定左右]:确定左右边界

    ①处修改为数组长度,扩大范围为寻找超出右边界的插入位置创造条件

  2. [定区间]:确定查询区间即条件while(查询区间),左右闭区间[left,right)

    修改说明

    • 右区间修改为开区间是为了防止索引越界

    双指针终止位置1(目标值在样例中的情况):坚持的原则**[left,right=left]**

    • 查询2:[left=2,right=2],mid = 1
    • left=mid+1致使右双指针重合[2,2)不符合查询区间则退出了
    • 左指针向执行[大中则进]left=mid+1致使右双指针重合[2,2)不符合查询区间则退出了

    双指针终止位置2(目标值不在样例中的情况):坚持的原则**[left,right=left]**

    • 查询8(超出右边界):[left=n,right=n]且mid=n-1(n为数组长度),[大中则进]后双指针重合[8,8)退出
    • 查询-1(超出左边界):[left=0,right=0]且mid=0,右指针向左缩进[小则取中]后双指针重合在数组最左侧[0,0)

    总结!!!:这种写法可以让left指针和right指针的范围都在**[0,数组长度]区间内移动且终止时最终位置重合**

  3. [取中值]:获取中值

  4. [进退三则]:大中则进、小中则中、等中则中,当然也可以将后两步合并在一起,因为后两步都是为了将right向左[缩进]\

  5. [检越]:最后一个元素还不是那说明没有相等的了

  6. [返边界]:查询左边界则“返左”便于你记忆但实际上双指针重合随意返回

public int indexOfLeft(int[] nums, int target) {

    // 1.[定左右]
    int left = 0;
    int right = nums.length;// ①修改自: nums.length-1

    // 2.[查区间]
    while (left < right) {// [l,r) ②修改自: l <= r
        // 3.[取中值]
        int mid = left + (right - left)/2;
        // 4.[进退三则]
        if (target > nums[mid]) left = mid + 1;    // [mid+1,r) 大中则进
        else if (target < nums[mid]) right = mid;  // [l,mid)   小中则中
        else right = mid; 						   // [r=mid]   等中则中 注意[r=mid-1]错误
    }

    // 5.[检越]
    if (left >= nums.length || nums[left] != target) return -1;

    // 6.[返边界]
    return left;// right也可因为重合了
}

转折点[下面是重点]

想吐槽了吧!毫无重点!写法太多晕了吧!或许你和我一样看见有这么多种写发放已经晕了!没关系你已经有了这些写法的基本印象接下来我来告诉你怎样快速写出这些写法!

在这里插入图片描述

教叮当写一个[查询右边界]的框架

下图是以左开右闭[)版本说明,其实双闭区间[]写法只是最后指针位置不重合以及有一些细节,因此不用纠结一定要使用哪一种!理解思想才是关键

1:小于2的部分是[无效区间]

在这里插入图片描述

2:想想如何[裁剪]掉无效区间?

在这里插入图片描述

3:[裁剪]掉无效区间,大胆假设mid就在1位置

在这里插入图片描述

4:mid+1后mid在最左侧,通过边界[收缩]减小问题方可找到答案!

在这里插入图片描述

5:找到了答案且双指针重合

在这里插入图片描述

在这里插入图片描述

叮当通过修改写出了代码你呢?

写法1:[裁剪]掉解不就得到解右边界+1的位置[左边界]了吗?那最后-1不就得到解了吗?

  • 修改① < 修改为了 <=这样就把我们要的解也[裁剪]掉了从而得到了[右边界]的下一个位置
  • right-1改②下一个位置-1就可以了
/**
  * 修改自[查询左边界]版本
  * 左闭右开版[l,r)
  */
private static int indexOfRight(int[] nums, int target) {
    // 1.[定左右]
    int left = 0;
    int right = nums.length;

    // 2.[检越] 根据实际情况检越即可不是死记
    if (target > nums[right-1] || target < nums[left]) return -1;

    // 3.[定区间]
    while (left < right) {// [0,r)双指针可以在[0,n]移动
        // 4.[取中值]
        int mid = left + (right - left) /2;
        // 5.[裁剪与收缩]
        if (nums[mid] <= target) {// 修改① < 修改为了 <=
            left = mid + 1;// [裁剪]后 [mid+1,hi)
        } else {
            right = mid;    // 边界[收缩]至[l,mid)实际移动范围[l,mid]
        }
    }

    // 6.[返边界] 方便记忆查询哪边返回哪边
    return right-1;// 修改② left-1也行因为[双指针重合]
}

写法2:是不是发现写法1[返边界]的写法return right-1;很难受呢!

通过循环条件 while (left <= right)的终止条件的特点right会终止在左侧来改变它!

  • 修改①:右指针数组长度-1避免越界为闭区间写法做准备
  • 修改②:修改为双闭区间
  • 修改③:加上相等情况也就是将解也看作[裁剪]的[无效区间]
  • 修改④:返回right,这是和上面的写法不同的技巧,因为这种写法终止时left>right且right正好就落在这里我们要寻找的解的位置
  • 修改⑤:将写法1的[检越]修改为了指针的[检越]相信你更能看出来指针终止的特点细节在※写法1已经说明了!

揭秘:不理解你再回头看看※写法1的图例更好的理解[裁剪]与边界[收缩]两种思想

  • 这种写法类似于上文中※写法1并将[进退三则]按需修改合并从而简化"三分天下"考虑的细节
  • 且利用这种写法在终止时left总大于right
/**
  * 修改自写法1
  * 闭区间版[l,r]
  *
  */
private static int indexOfRight(int[] nums, int target) {
    // 1.[定左右]
    int left = 0;
    int right = nums.length - 1;// 修改①

    // 3.[定区间]
    while (left <= right) {// [0,r] 修改② 双指针技巧 left→逃跑 逃跑<-right
        // 4.[取中值]
        int mid = left + (right - left) / 2;
        // 5.[裁剪与收缩]
        if (nums[mid] <= target) {  // 修改③ < 修改为了 <=
            left = mid + 1;         // [裁剪]后 [mid+1,right]
        } else {
            right = mid - 1;        // 边界[收缩]至[left,mid-1]
        }
    }
    
    // 2.[检越] right左逃 left右逃
    if (right < 0 || left > nums.length-1) return -1; // 修改⑤

    // 6.[返边界] 方便记忆查询哪边返回哪边
    return right;// 修改④
}

小结思考

到这里不知道你是否已经发现!教叮当写的这种框架其实就是利用了[统一东部]的思想来解决的,也就是从左向右裁剪,从右向左压缩,那么你思考一下能不能利用[统一西部]的思想来解决呢!

在这里插入图片描述

下面写法会产生什么问题呢?

我将通过例题为你讲解!

在这里插入图片描述

LeetCode五杀实战!一个思想解决多个问题

通过解决题目能更好的理解算法思想和享受支配算法的乐趣!

说明:在题解中我将left写为l,right写为r

34. 在排序数组中查找元素的第一个和最后一个位置

在这里插入图片描述

掌握点:取(右)中位的技巧

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

你的算法时间复杂度必须是 O(log n) 级别。

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

示例 1:

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

示例 2:

输入: nums = [5,7,7,8,8,10], target = 6
输出: [-1,-1]

在这里插入图片描述

查询右边界:错误版本

private int indexOfRight(int[] nums, int target) {
    int l = 0;
    int r = nums.length - 1;

    while (l < r) {
        // 3.[取中值]
        int mid = l + (r - l) /2;// 错误的获取中值
        if (nums[mid] > target) {
            r = mid - 1;
        } else {
            l = mid;
        }
    }

    return l;
}

在这里插入图片描述

查询右边界:[统一西部]

  • [右边界]:注意这个是为了避免越界
  • [取中值]:这是本题重点技巧点,取右中位因为这种写法取左中位会导致[死循环]永远取不到[右边界]
  • [检越]:如果你要在实际工作中使用不建议,会引发越界
  • 也可以写成闭区间版本而且不会引发越界,但是要判断边界情况元素是否相等
private int indexOfRight(int[] nums, int target) {
    // 1.[定左右]
    int l = 0;
    int r = nums.length - 1;// [右边界] 照顾只有一个数的情况
    
    // 本题不用检查最后一个元素是否为target因为先查询了左边界保证数字存在了,单独使用一定检查
    
    // 2.[定区间]
    while (l < r) {// [0,r)双指针可以在[0,n]移动
        
        // 3.[取中值]
        int mid = l + (r - l + 1) /2;
        // 4.[裁剪与收缩]
        if (nums[mid] > target) {
            r = mid - 1;// [裁剪]后 [mid+1,hi)
        } else {
            l = mid;    // 收缩[l,mid)实际移动范围[l,mid]
        }
    }

    return l;// r 终止时[双指针重合]
}

查询左边界:[统一东部]

private int indexOfLeft(int[] nums, int target) {
    // 1.[定左右]
    int l = 0;
    int r = nums.length;

    // 2.[定区间]
    while (l < r) {// [0,r)双指针可以在[0,n]移动
        // 3.[取中值]
        int mid = l + (r - l) /2;
        // 4.[裁剪与收缩]
        if (nums[mid] < target) {
            l = mid + 1;// [裁剪]后 [mid+1,r)
        } else {
            r = mid;    // 收缩[l,mid)实际移动范围[l,mid]
        }
    }

    return l;// r 终止时[双指针重合]
}

主函数:衔接上面两个方法

// searchRange函数与Solution1相同
public int[] searchRange(int[] nums, int target) {
    // 1.定义结果集
    int [] ans = {-1, -1};

    // 2.空判
    if (nums == null || nums.length == 0) return ans;

    // 3.[查询左边界]
    int leftIndex = indexOfLeft(nums, target);

    // 3.[检越]避免不在的情况和越界情况,因为left总是往mid+1的方向->
    if (leftIndex == nums.length || target != nums[leftIndex]) {
        return ans;
    }

    // 4.查询target的下一个元素的[左边界]索引-1即可
    int rightIndex = indexOfRight(nums, target);
    // int rightIndex = indexOfLeft(nums, target+1)-1; //写法②
    
    // 5.设置
    ans[0] = leftIndex;
    ans[1] = rightIndex;

    return ans;
}

35. 搜索插入位置

在这里插入图片描述

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

你可以假设数组中无重复元素。

示例 1:

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

示例 2:

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

写法一:三分天下

  • [大中则进]:[裁剪]不是解的部分

  • [小中则退]:mid-1会导致比如查询2[2,2)产生无效区间就查不到元素

  • [等中则中]:为什么不是mid-1同①

public int searchInsert(int[] nums, int target) {
    // 0.特判
    if(nums.length == 0) return 0;

    // 1.[定左右]
    int l = 0;
    int r = nums.length;

    // 2.[定区间]
    while (l < r) {//[lo,hi)
        int mid = l + (r - l)/2;
        if (target > nums[mid]) l = mid + 1;  // [mid+1,hi) [大中则进]
        else if (target < nums[mid]) r = mid; // [lo,mid)   [小中则中]
        else r = mid;                         // [mid]      [等中则中]
    }

    // 3.[返回边界]
    return l;
}

写法二:统一东部版

// 统一东部
public int searchInsert(int[] nums, int target) {
    // 1.[定左右]
    int l = 0;
    int r = nums.length;

    // 2.[定区间]
    while (l < r) {//[l,r)
        int mid = l + (r - l)/2;
        if (nums[mid] < target) l = mid + 1;  // [裁剪] 更新区间[mid+1,r]
        else  r = mid;                        // 边界[收缩] 更新区间[l,mid]不是[l,mid)
    }

    // 3.[返回边界]
    return l;
}

69. x 的平方根

简介:此题要找的是[严格平方]根向的下取整

  • 变化为查询[严格平方根]应该插入的位置
  • 从右裁剪[查询右边界]
  • 还可以利用(x/2)2=a
  • 二分查找并不是最优解以后在讨论!

实现 int sqrt(int x) 函数。

计算并返回 x 的平方根,其中 x 是非负整数。

由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。

示例 1:

输入: 4
输出: 2

示例 2:

输入: 8
输出: 2
说明: 8 的平方根是 2.82842..., 
     由于返回类型是整数,小数部分将被舍去。

写法1:三分天下

在这里插入图片描述
在这里插入图片描述

目的:基本写法查询的是相同的数字或者应该插入的第一个位置也就是查询[左边界]

说明:此写法中(left)获取的是不重复的有序数组中某个数字特殊位置有[插入位置]和[相等位置]

  • [相等位置]:如2是4的平方根则返回
  • [插入位置]:如1.732…>1是3的平方根应该插入在数列0 1 2 3中1的后一个位置,我们要向下取整因此left-1就得到了答案1,终止时候right总是在left左侧所以也可以返回right

突破点:寻找[严格平方根]的位置

  • 当x < mid2时[小中则退]:一定不是解需要排除5<32
  • 当x = mid2时:是解如4的平方根4=22,当 2.236…2(严格平方根)=5
  • 当x > mid2时:5 > 22,[大中则进]找到[严格平方根]应该插入的位置left=3 0 1 2 3 4 5,而left-1才是本题的答案

具体分析

  • [等中则返]:mid2=x,如查询9的平方根时32=9直接返回mid=3即可
  • [大中则进]:mid2<x,如查询5的平方根时22=5已经是答案了却还是进了一步
  • [!检越]:题目 x 是非负整数所以不用担心越界的问题也就是查询-1[返边界]l-1=r=-1
  • [指针终止位置]:查询不到元素时right总是在left的左边
/**
  * l: left r: right
  * @param x 目标值
  * @return 返回x的平方根
  */
public static int mySqrt(int x) {
    // 1.[定左右],范围: [0,x]
    long l = 0;
    long r = x;

    // 2.[定区间]
    while (l <= r) {// [l,r]
        // 3.[取中值]
        long mid = l + (r - l)/2;
        long midVal = mid * mid;
        // 4.[进退三则]
        if (x > midVal) l = mid + 1; 	   //[大中则进] 下一轮查询区间 [mid+1,r]①
        else if (x < midVal)  r = mid - 1; //[小中则退] 下一轮查询区间 [l,mid-1]②
        else return (int)mid;			   //[等中则返] 获取值的区间 [mid]③
    }

    // 5.[返边界]
    return (int)(l-1);// r
}

写法2:[统一西部]开区间

在这里插入图片描述

说明:这种写法可以用于含有重复数字的数组中[查询右边界]的问题!如0 2 2 3返回2出现的第最后一个位置2

突破点:mid2>x一定不是解需要[裁剪]而另一面则是[有效区间]需要[收缩]

技巧:①和②属于减治策略,③则是while的终止条件的技巧

  • [裁剪]①:排除调解一定不在的区间
  • [收缩]②:缩小解的范围
  • [指针终止位置]③
    • left:包含了写法1中[等中则返]mid2=x的情况,left+1使得left指针总是落在解的右侧
    • right:循环的条件就是left>right因此right一定会落在left左侧,则right就是解的位置

※注意点1说明:这样

例子:查询在0 2 2中2的第一个位置

  • 第一步:mid=1,右边界[收缩]
  • 第二步:mid所在位置值小于2从左向右[收缩]left = mid + 1使得left始终在解的位置“mid”!
/**
  * l: left r: right
  * @param x 目标值
  * @return 返回x的平方根
  */
public static int mySqrt(int x) {
    // 1.[定左右],范围: [0,x]
    long l = 0;
    long r = x;

    // 2.[定区间]
    while (l <= r) {// [l,r]
        // 3.[取中值]
        long mid = l + (r - l)/2;
        long midVal = mid * mid;
        // 4.[裁剪与收缩]
        if (midVal > x) r = mid - 1;// 从右向左[裁剪]掉一定不是解的部分,更新有效区间为[l,mid-1]
        else l = mid + 1;// 从左向右[收缩]解存在的[有效区间]更新有效区间为[mid+1,r] 
        // ※注意点1 发现了吗[收缩]少了[mid]
    }

    // 5.[返边界]
    return (int)(r);// l-1 
}

写法3:[统一西部]闭区间版本

技巧

  • [定区间]1:技巧① l < r这样做能保证查询过程中双指针不重合
  • [定区间]2:虽然左闭右开[0,x)但隐藏细节是left和right会落在[0,x]双闭区间范围内!
  • 双指针终止位置:技巧① 导致终止时双指针重合
  • [裁剪]:剪掉一定不是解的[无效区间]
  • [收缩]:收缩不能l = mid-1导致漏掉了[mid]并且此题中会导致死循环
/**
  * l: left r: right
  * @param x 目标值
  * @return 返回x的平方根
  */
public int mySqrt(int x) {
    // 1.[定左右],范围: [0,x]
    long l = 0;
    long r = x;

    // 2.[定区间]
    while (l < r) {//技巧① [l,r) 虽然是左闭右开但双指针仍然可以在[l,r]内移动
        // 3.[取中值] 使用整型避免溢出
        long mid = l + (r - l + 1)/2;
        long midVal = mid * mid;
        // 4.[裁剪与收缩]
        // 如 5: 0 1 2 3 4 5
        if (midVal > x) {
            r = mid - 1;// [裁剪]一定不是解的部分即[无效区间],更新有效区间为[l,mid-1]
        } else {
            // 错误写法mid-1显然[mid-1,r]就漏掉了[mid]
            l = mid;// [收缩]解存在的[有效区间]更新有效区间为[mid,r]注意是左右双闭区间
        }

    }

    // 5.[返边界]: 返回有效区间的边界l
    return (int)(l);// 实际上双指针重合了因此也可以返回l或则r
}

隐藏技巧:选取右中值

public int mySqrt(int x) {
    // 1.[定左右],范围: [0,x)
    long l = 0, r = x;

    // 2.确定[查询区间]
    while (l < r) {// [l,r)
        // 3.[取(右)中值]
        long mid = l + (r - l + 1)/2;
        long midVal = mid * mid;
        // 4.[进退三则]
        if(x > midVal) l = mid; 		  // 大中则中
        else if (x < midVal) r = mid - 1; // 小中则退
        else l = mid; 					  // 等中则中
    }

    return (int)l;
}

278. 第一个错误的版本

在这里插入图片描述

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 [1, 2, …, n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

示例

给定 n = 5,并且 version = 4 是第一个错误的版本。

调用 isBadVersion(3) -> false
调用 isBadVersion(5) -> true
调用 isBadVersion(4) -> true

分析

很明显这是个查询[左边界]的问题

false false false ->true<- true true

题解1:闭区间写法

相信你要是认真看到这里的话你已经会写了

  • [定区间]:l <= r允许双指针重合,
  • [排除法]
    • 减掉[无效区间]即不是解的部分
    • [收缩]解所在[有效区间]让解浮出水面
  • [返边界]:左指针l总是落在要查询的位置,且右指针r总是在l的左侧

统一东部思想:这个题目和之前的题目的不同点并没有nums[mid]==target的情况!

  • [l,mid]
  • [mid+1, r]
public int firstBadVersion(int n) {
    // 1.[定左右],范围: [0,n]
    int l = 0;
    int r = n;

    // 2.[定区间]查询左边界
    while (l <= r) {

        // 3.[取中值]
        int mid = l + (r-l)/2;

        // 4.[裁剪与收缩]
        if (!isBadVersion(mid)) {
            l = mid + 1;// [裁剪]掉无效区间[l,mid]后更新有效区间区间为[mid+1,r]
        } else {
            r = mid - 1;// [收缩]将右边界,下一轮区间[l,mid-1]不用担心错过[mid]因为最后l会+1刚好落在解的位置
        }
    }
	
    // 5.[返边界]
    return l;// 不能返回r
}

题解2:开区间写法

  • [定区间]:l < r[l,r)
  • [裁剪与收缩]
    • [裁剪]减掉[无效区间]即不是解的部分
    • [收缩]解所在[有效区间]让解浮出水面
  • [返边界]:双指针重合

统一东部思想

public int firstBadVersion(int n) {
    // 1.[定左右],范围: [0,n]
    int l = 0;
    int r = n;

    // 2.[定区间]查询左边界
    while (l < r) { // [l,n)

        // 3.[取中值]
        int mid = l + (r-l)/2;

        // 4.[裁剪与收缩]
        if (!isBadVersion(mid)) {
            l = mid + 1;// [裁剪]掉无效区间[l,mid]后更新有效区间区间为[mid+1,r]
        } else {
            r = mid;// [收缩]将右边界,下一轮区间[l,mid)实际包含了mid[l,mid]
        }
    }
	
    // 5.[返边界]
    return l;// r 或者 l都可双指针重合
}

852. 山脉数组的峰顶索引

我们把符合下列属性的数组 A 称作山脉:

A.length >= 3
存在 0 < i < A.length - 1 使得A[0] < A[1] < ... A[i-1] < A[i] > A[i+1] > ... > A[A.length - 1]
给定一个确定为山脉的数组,返回任何满足 A[0] < A[1] < ... A[i-1] < A[i] > A[i+1] > ... > A[A.length - 1] 的 i 的值。

示例 1:

输入:[0,1,0]
输出:1

示例 2:

输入:[0,2,1,0]
输出:1

在这里插入图片描述

题解:三分天下

掌握点:灵活运用基本的写法

public int peakIndexInMountainArray(int[] A) {
    // 1.[定左右]
    int l = 0;
    int r = A.length-1;
	
    // 2.[定区间]
    while (l <= r) {// [l,r]
        // 3.[取中值]
        int mid = l + (r-l)/2;
       
        // 4.[进退三则]
        if (A[mid+1] > A[mid]) {// 上坡
            l = mid + 1; // [爬坡]
        } else if (A[mid-1] > A[mid]){// 下坡
            r = mid - 1; // [返回坡顶]
        } else {
            return mid;
        }

    }
	
    // 5.[无功而返]
    return -1;

}

代码获取及支持我!

代码已经上到我的开源项目将持续更新各种算法和基础知识
Github传送们

五杀刷题指南致力于通过每类算法的至少5个算法问题让你掌握算法套路及思想!

在这里插入图片描述
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值