声明:
模板的内容来源于力扣的探索章节,加上了自己的理解后的修改以及注释
0、介绍
二分查找是一种在每次比较之后将查找空间一分为二
的算法。每次需要查找集合中的索引或元素时,都应该考虑二分查找。如果集合是无序的,我们可以总是在应用二分查找之前先对其进行排序
。
二分查找一般由三个主要部分组成:
- 预处理 —— 如果集合未排序,则进行排序。
- 二分查找 —— 使用循环或递归在每次比较后将查找空间划分为两半。
- 后处理 —— 在剩余空间中确定可行的候选者。
先来看一个最基本的二分查找题目:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的
target,如果目标值存在返回下标,否则返回 -1。示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4 解释: 9 出现在 nums 中并且下标为 4
示例 2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1 解释: 2 不存在 nums 中因此返回 -1
提示:
你可以假设 nums 中的所有元素是不重复的。
n 将在 [1, 10000]之间。
nums 的每个元素都将在 [-9999, 9999]之间。
我们很容易能写出这样的代码:
class Solution {
public int search(int[] nums, int target) {
int i = 0;
int j = nums.length-1;
while( i<=j ){
int mid = i + (j-i)/2;
if( nums[mid]==target ){
return mid;
}
if( nums[mid]<target ){
i = mid + 1;
}else{
j = mid - 1;
}
}
return -1;
}
}
为了方便,一般都写成 i 和 j 了,更好一些还是写成 left 和 right ,语义比较明确。
不过,遇到不同的题目,最终的二分查找的代码实现似乎都略有不同。尽管每个实现在每个步骤中都会将问题空间划分为原来的 1/2,但其中有许多问题,关于边界,关于是否移动
等等,往往需要变通。
因此有了三个模板,下面提供了一些对应的练习题,能够巩固练习。
一、基础二分查找模板
int binarySearch(int[] nums, int target){
if(nums == null || nums.length == 0)
return -1;
int left = 0, right = nums.length - 1;
while(left <= right){
// 防止left+right溢出
int mid = left + (right - left) / 2;
if(nums[mid] == target){ return mid; }
else if(nums[mid] < target) { left = mid + 1; }
else { right = mid - 1; }
}
// 结束条件: left > right
return -1;
}
这个模板就是,简单的二分查找。
可以看到核心部分就是:
- 相等的时候return;
- 如果 mid 不是结果,那么 left 和 right 每次都会移动一个位置(显然,mid既然不是结果了,没必要呆在原地,事实上这是由问题的性质决定的);
- 跳出循环的条件是 left <= right ,因为 left 和 right 每次总会有一个移动,那么会有相等的情况是答案。
上面模板中还有一个 tip :left 和 right 求 mid 的时候,往往采用 left + (right - left) / 2
的写法,是为了避免相加之后溢出。
遇到类似于,有序数组查找元素的最基本问题,这个模板就是很顺手的。
相应的几个题目链接在此,都是 leetcode 上的题目,我也给出了相应的题解:
二、模板2(适用于考虑当前位置和右边位置的情景)
一个很经典的题目是力扣的 153题 寻找旋转排序数组的最小值。
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
你可以假设数组中不存在重复元素。
示例 1:
输入: [3,4,5,1,2]
输出: 1
示例 2:
输入: [4,5,6,7,0,1,2]
输出: 0
如果采用上一个模板,也可以做这个题目,但是对于答案的位置,可能处于哪个点,要有很多冗余的判断,而且很容易疏忽,你可以先做完这个题目后,再来看看下面的模板。
int binarySearch(int[] nums, int target){
if(nums == null || nums.length == 0)
return -1;
int left = 0, right = nums.length;
while(left < right){
int mid = left + (right - left) / 2;
if(nums[mid] == target){ return mid; }
else if(nums[mid] < target) { left = mid + 1; }
else { right = mid; }
}
//结束条件left == right
if(left != nums.length && nums[left] == target) return left;
return -1;
}
和上一个模板的区别是:
- 当 mid 不是要找的答案时,对于 right 的操作是不移动,对于 left 移动。
- 结束条件变成 left 和 right 相等,(显然如果还是 left <= right 的话有可能进入死循环)
这个模板用于查找需要访问数组中当前索引及其直接右邻居索引的元素或条件。
可以想一想上面的题目,答案的那个元素应该满足: 它的右邻居正好比它小,要访问当前索引,及直接右邻居,这种情况就能够用到这个模板
这道题目的题解:
其他题目(力扣):
在上面四个练习题里面,对于 j (right)的初始值,都是直接取到了 nums.length-1,而不是取到了 nums.length ,对于这四个题目,和模板取nums.length也是一样的,但是那样就一定要加后处理,避免left=right的时候是left=nums.length越界。
总之需要灵活根据题目的边界适当修改
三、模板3(适用于考虑当前位置和左边、右边位置的情景)
int binarySearch(int[] nums, int target) {
if (nums == null || nums.length == 0)
return -1;
int left = 0, right = nums.length - 1;
while (left + 1 < right){
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid;
} else {
right = mid;
}
}
// 终止条件: left + 1 == right
if(nums[left] == target) return left;
if(nums[right] == target) return right;
return -1;
}
区别很明显,为了访问左右,并且不错过这些值,我们每次都让 left 和 right 相对于 mid 不移动。
最后加上后处理去判断。
它用于搜索需要访问当前索引及其在数组中的直接左右邻居索引的元素或条件。
四、更多练习
下面还有更多力扣的二分查找练习题目,有些难度已经是 hard ,属于比较难想到思路的题目,当分析出来可以用二分查找做,难度就降了一半。
更复杂的二分查找练习:
- 4 寻找两个正序数组的中位数
- 50 Pow(x,n)
- 367 有效的完全平方数
- 153 寻找旋转排序数组中的最小值
- 154 寻找旋转排序数组中的最小值II
- 287 寻找重复数
- 719 找出第K小的距离对
- 410 分割数组的最大值