漫话算法[二分查找](上):一首诗解决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 {
right = 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更容易让大家理解!
基本步骤
-
[定左右]:确定左右边界
-
[定区间]:确定查询区间即条件
while(查询区间)
,左右闭区间[left,right]双指针终止位置(目标值不在样例中的情况):双指针不重合坚持的原则[left,right=left-1]!因为终止条件是
(left > right)
隐藏技巧:适当修改后如果目标值在数组中则返回对应的位置而如果不在时则
left
总会落在它应该插入的位置! -
查询 7
(超出右边界):[left=n,right=n-1] 且mid=n-1(n为数组长度) -
查询 -1
(超出左边界):[left=0,right=-1] 且mid=0 -
[取中值]:获取中值(后面不再赘述)
注意点:/是向下取整如
0 1 2 ->3<- 4 5 6 7
重复数字取不到右边界,还有避免整型溢出的写法后面不再赘述! -
写法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
源码 -
[进退三则]:进退就是确定下一轮查询的[有效区间]的边界
-
大中则进:左边界以m为分界点进一步 -
小中则退:右边界以m为分界点退一步 -
等中则返:查找到了目标值刚好就是mid位置 -
[无功而返]:没有查询到元素返回-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)从而导致查询不到结果
基本步骤
-
[定左右]:确定左右边界
-
[定区间]:确定查询区间即条件
while(查询区间)
,左闭右开区间[left,right)双指针终止位置1(目标值在样例中的情况):双指针不重合并返回mid
双指针终止位置2(目标值不在样例中的情况):双指针重合坚持的原则[left,right=left)
隐藏技巧:left和right指针可以游走于[0,n]n为数组长度的范围,且只要将等于的情况按需修改就能保证终止时指针重合
-
查询 7
(超出右边界):[left =n,right=n) 且mid=n-1(n为数组长度),双指针重合在最右侧后再执行一步[大中则进]后产生无效区间[7,7)终止 -
查询 -1
(超出左边界):[left =0,right=0]且mid=0, 双指针重合在数组最左侧后产生无效区间[0,0)后终止 -
查询2:[left =2,right=3),mid = 2 -
[取中值]:获取中值
-
[进退三则]:进退就是确定下一轮查询的左右[区间]的边界
-
大中则进:左边界进一步 -
小中则退:有边界退一步 -
等中则返:查找到了目标值刚好就是mid位置 -
[无功而返]:没有查询到元素返回-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应该插入的第一个位置
-
基本步骤
-
[定左右]:确定左右边界
-
[定区间]:确定查询区间即条件
while(查询区间)
,左右闭区间[left,right]双指针终止位置1(目标值在样例中的情况):坚持的原则[left,right=left-1]
双指针终止位置2(目标值不在样例中的情况):坚持的原则[left,right=left-1]
总结!!!:这种写法可以让left指针的范围在[0,数组长度]区间内而right指针在[-1,数组长度-1],且终止时right总是在left左侧
-
查询 8
(超出右边界):[left=n,right=n-1]且mid=n-1(n为数组长度),双指针重合在最右侧后再执行一步[大中则进]后终止 -
查询 -1
(超出左边界):[left=0,right=-1]且mid=0,双指针重合在数组最左侧后再执行一步[小中则退]后终止 -
右指针向左缩进至左边界 -
右指针向右双指针重合致使l左指针变为我们想要寻找的左边界 -
最后[等中则退]结束循环 -
查询2:[left=2,right=1],mid = 1 -
[取中值]:获取中值
-
[进退三则]:大中则进、小中则退、等中则退,当然也可以将后两步合并在一起正是本文后半部分要带你学会的核心思想
-
[检越]:检查left指针是否越界,若越界则“矫正”
-
[返边界]:查询左边界则“返左”,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所在的最左索引位置或插入位置
基本步骤
-
[定左右]:确定左右边界
①处修改为数组长度,扩大范围为寻找超出右边界的插入位置创造条件
-
[定区间]:确定查询区间即条件
while(查询区间)
,左右闭区间[left,right)修改说明
双指针终止位置1(目标值在样例中的情况):坚持的原则[left,right=left]
双指针终止位置2(目标值不在样例中的情况):坚持的原则[left,right=left]
总结!!!:这种写法可以让left指针和right指针的范围都在[0,数组长度]区间内移动且终止时最终位置重合
-
查询 8
(超出右边界):[left=n,right=n]且mid=n-1(n为数组长度),[大中则进]后双指针重合[8,8)退出 -
查询 -1
(超出左边界):[left=0,right=0]且mid=0,右指针向左缩进[小则取中]后双指针重合在数组最左侧[0,0) -
查询2:[left=2,right=2],mid = 1 -
left=mid+1致使右双指针重合[2,2)不符合查询区间则退出了 -
左指针向执行[大中则进]left=mid+1致使右双指针重合[2,2)不符合查询区间则退出了 -
右区间修改为开区间是为了防止索引越界 -
[取中值]:获取中值
-
[进退三则]:大中则进、小中则中、等中则中,当然也可以将后两步合并在一起,因为后两步都是为了将right向左[缩进]
-
[检越]:最后一个元素还不是那说明没有相等的了
-
[返边界]:查询左边界则“返左”便于你记忆但实际上双指针重合随意返回
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 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 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;// 修改④
}
小结思考
到这里不知道你是否已经发现!教叮当写的这种框架其实就是利用了[统一东部]的思想来解决的,也就是从左向右裁剪,从右向左压缩,那么你思考一下能不能利用[统一西部]的思想来解决呢!
思考题:下面的写法会产生什么问题呢?
下一篇例题讲解将会为你揭晓答案!