二分查找算法
- 本文只是暂时性总结,有新的理解以及更好的总结以后将会替换。
- 本文是总结类文章,做题的时候找本文对应的类型或者做完阅读效果更好。
题目:二分 · SharingSource/LogicStack-LeetCode Wiki · GitHub
二分查找的整体思路很简单,但是细节上会有很多的小问题。
⼏个最常见的⼆分查找场景:
- 寻找⼀个数
- 寻找左侧边界
- 寻找右侧边界
常见的纠结问题
- 二分查找判断的时候大于小于是否应该带等号
- mid 是否应该加⼀
- ...
二分与二段性
「二分」的本质是二段性,并非单调性,即数组并不局限于单调增减。只要一段区间满足某个性质,另外一段区间不满足某个性质,就可以用「二分」。
复杂的题目并不是能够一眼能够看出是该类型,需要进行一定的转化(LeetCode中Mid题目大多都是需要转化的类型)比如LeetCode 162.寻找峰值,本题利用mid的一边一定没有峰值,另一边一定有峰值来进行二段的划分(即一边有解,一边无解,这样也可以使用二分)。因此只需要找到一段的一个边界即可。
因此本文只给出三种基本框架,具体的应用还需要结合题目来解决。
注意点
- 题目中元素的类别由于不一定是数字类数组,二分的判断条件需要灵活变通。同时是否需要排序也要根据二段性划分的性质来定。
- 数字类:比如大于某个值的区间最小值(这种类型先记得排序)
- 非数字类,比如字符串类型的查找。
Java
中需要创建一个实现Comparable
接口的对象放入数组之后,再排序,下文不再重复赘述。
- 文中对于一端是开区间的模板的解释是由于网上有部分题解是这种形式,而两端都闭的情况更加简单。
查找单个数的框架
最简单的二分类题目,下面的讲解以数字类为例。
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意点1
while(left <= right) {
int mid = left + (right - left) / 2; //防止left + right 直接溢出
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意点2
else if (nums[mid] > target)
right = mid - 1; // 注意点2
}
return -1;
}
这里的注意点可以和下面的左边界,右边界的查找结合起来对比阅读
1、为什么 while 循环的条件中是 <=,⽽不是 <?
因为初始化 right 的赋值是 nums.length - 1
,即最后⼀个元素的索引,⽽不是 nums.length
。 这⼆者可能出现在不同功能的⼆分查找中,区别是:
- 前者相当于两端都闭区间 [left, right]
- 后者相当于左闭右开区间 [left, right)
索引大小为 nums.length
是越界的。 我们这个算法中使用的是前者 [left, right] 两端都闭的区间。这个区间其实就是每次进⾏搜索的区间。搜索区间为空的时候,while循环终止。
两种情况下循环终止情况不同:
- <=的时候, 终止条件left >= right + 1 此时终止。[right + 1,right]搜索区间一定是非空的
- while中是<时,此时终止条件是 left >= right, 那么搜索区间为[right,right]的时候,就会终止,但是搜索区间非空,此时最终还要检查索引为right这个位置上的值是否为要搜索的值。
2.为什么 left = mid + 1 , right = mid - 1 ?有的代码是 right = mid 或者 left = mid ?
还是从搜索区间的角度来分析,当前算法的搜索区间是闭区间,当前的mid已经与target进行比较了,需要从搜索区间中去除。而如果是开区间的写法的话,开区间去除mid的方法是将开的那一端=mid。(这个注意点可以先等后面两种类型看了再来理解)
3.缺陷
比如说给你有序数组 nums = [1,2,2,2,3]
, target 为 2,此算法返回的索引是 2。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是⽆法处理的。
当然,我们可以直接暴力解决,即排序后遍历,但是数量级增长之后,速度就慢了(O(N)
),此时用二分查找边界索引(O(logN)
)能够解决这个问题(见后文)。
总结:
因为我们初始化 right = nums.length - 1
,所以决定了我们的「搜索区间」是 [left, right],决定了 while (left <= right)
,同时也决定了 left = mid+1
和 right = mid-1
。因为我们只需找到⼀个 target 的索引即可,所以当 nums[mid] == target
时可以立即返回。
左右两边界的二分查找
可用于有序数组的中重复元素左\右边界元素索引位置查找类题目。同时if
以及return
处判定条件的改变需要根据不同题目要求来修改。
查找左边界的二分查找
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意点1
while (left < right) {
int mid = left + (right - left) / 2; //防止left + right 直接溢出
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意点1
}
}
// 注意点3,4
/* 下面有两种处理方式 */
/* 一、
target ⽐所有数都⼤,则此时没有所谓左边界
if (left == nums.length) return -1;
类似之前算法的处理⽅式,找不到target的时候返回-1,找得到的时候left就是左边界
return nums[left] == target ? left : -1;
*/
/*二、
if (left == nums.length) return -1;
else return left;
*/
}
// 变成两边都闭的写法
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 检查越界情况,这里也有两种情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
- 为什么 left = mid + 1 , right = mid ?和之前的算法不一样?
答:因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉 mid 分割成两个区间,即 [left, mid) 或 [mid + 1, right) 。意思就是mid是right的时候,由于右边是开区间,所以已经可以排除原来的mid了,则此时不需要再进行right - 1 的操作了。
- 为什么该算法能够搜索左侧边界
关键在于对于 nums[mid] == target
这种情况的处理:
if (nums[mid] == target)
right = mid;
可⻅,找到 target 时不要⽴即返回,而是缩小「搜索区间」的上界 right ,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
- 为什么不返回right?
因为终止条件是left == right,所以是一样的。
- 为什么有两种处理方式?
因为有一类题型是target并不在nums数组内部,比如[2,3,6,7]
,找出大于5的左侧边界,此时target = 5,
此时target不存在于nums数组,此时就不需要判断是否等于target(同时nums[mid] == target
的if判断也可以省去)。
- 总结
因为我们初始化 right = nums.length
,所以决定了我们的「搜索区间」是 [left, right),决定了 while (left < right)
,同时也决定了 left = mid + 1
和 right = mid
因为我们需找到 target 的最左侧索引,所以当 nums[mid] == target
时不要⽴即返回,⽽要收紧右侧边界以锁定左侧边界。
寻找右侧边界的二分查找
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
// 搜索区间是 [left,right),停止搜索的条件是left == right
while (left < right) {
int mid = left + (right - left) / 2; //防止left + right 直接溢出
if (nums[mid] == target) {
left = mid + 1; // 注意1
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
// 同样有两种形式,这里不再赘述
if (left == 0) return -1;
return nums[left-1] == target ? (left-1) : -1; // 注意
}
/* 两端闭的形式 */
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这⾥改成收缩左侧边界即可
left = mid + 1;
}
}
// 这⾥改为检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
基本和找左侧边界一致,只需要修改少部分即可。
- 为什么这里是left = mid + 1,而找左边界的时候是right = mid?
因为必须要移动左边界,否则如果mid 为 target,左边界不收缩,没有办法得到范围的右边界,而right = mid
,仅仅因为我们的搜索区间右边是闭区间。
- 为什么最后返回left索引位置时需要-1(左闭右开的情况)?
因为对 left 的更新必须是 left = mid + 1
,就是说 while 循环结束 时, nums[left]
⼀定不等于 target 了,⽽ nums[left-1]
可能是 target。比如nums= [1,2,2,4],target = 2
时,由于需要不断收缩左边界,最后left=right=3,此时target=2的右边界索引为2,此时需要减1。
- 总结(开区间版)
因为我们初始化 right = nums.length
,所以决定了我们的「搜索区间」是 [left, right)
,所以决定了 while (left < right)
,同时也决定了 left = mid + 1
和 right = mid
,因为我们需找到 target 的最右侧索引,所以当 nums[mid] == target
时不要⽴即返回,⽽要收紧左侧边界以锁定右侧边界。
基本模板(两端都闭)
// 旋转数组类问题记得要去掉数组右边与数组首元素相同的元素。
int binary_search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if(nums[mid] == target) {
// 直接返回
return mid;
}
}
// 直接返回
return -1;
}
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定左侧边界
right = mid - 1;
}
}
// 最后要检查 left 越界的情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
// 需要注意的是这里并不一定都是比较大小,可能是满足某种性质
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定右侧边界
left = mid + 1;
}
}
// 最后要检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
总结
本文本质上比较简单,实际上就是三种类型的二分题目的归纳(找单个值,找满足条件的左边界,找满足条件右边界),但由于网上题解有上面讲的左开右闭的形式,加之自己遇到的一些疑惑,所以写的比较冗长。
但一些稍微灵活的题目并不会直接给出这种形式,比如1011. 在 D 天内送达包裹的能力,这题原来问题是找到在D天内送达条件下,运输能力的最小值,转化过来的问题就变成了在载重可能的最⼩值和载重可能的最⼤值之间找到能够满足D天内送达的左边界值,因此模板中的if判断条件就可以改成是否能够完成在D天内送达。从刚才这一题可以看出,难点在于如何转换成二分的3种情形,只要转化好,就可以利用上面的模板进行解题。