说到二分查找,很多人第一反应是挺简单的,然后分分钟写出来了以下代码:
public static int BinarySearch01(int [] nums,int target ) {
int left=0;
int right=nums.length-1;
while(left<=right) {
int mid =left+(right-left)/2;
if(nums[mid]==target) {//相等时返回
return mid;
}else if(nums[mid]>target) {//大于目标值的时候right=mid-1
right=mid-1;
}else {//小于目标值的时候left=mid+1
left=mid+1;
}
}
return -1;//若找不到返回-1
}
亦或是另一个版本:
public static int BinarySearch02(int [] nums,int target ) {
int left=0;
int right=nums.length;
while(left<right) {
int mid =left+(right-left)/2;
if(nums[mid]==target) {//相等时返回
return mid;
}else if(nums[mid]>target) {//大于目标值的时候right=mid
right=mid;
}else {//小于目标值的时候left=mid+1
left=mid+1;
}
}
return -1;//若找不到返回-1
}
或许还可能是递归版本:
public static int BinarySearch03(int [] nums,int target,int left,int right ) {
if(nums.length==0) return -1;
int mid =left+(right-left)/2;
if(left>right) return -1;//若找不到返回-1
if(nums[mid]==target) {//相等时返回
return mid;
}else if(nums[mid]>target) {//大于目标值的时候right=mid-1
return BinarySearch03(nums,target,left,mid-1);
}else {//小于目标值的时候left=mid+1
return BinarySearch03(nums,target,mid+1,right);
}
}
抛开递归不谈,因为递归只是一种实现方式,可以看出前两种写法的区别:
- 一个right为数组最后一个下标,而另一个为数组长度;
- 循环判断条件一个是left<=right,一个是left<right;
- 在大于目标值的时候一个是right=mid-1,另一个是right=mid。
两种写法本质上没有区别,第一种写法是在闭区间[left,right]中查找,结束条件为left > right;第二种写法是在左闭右开的区间[left,right)内查找,结束条件为left == right 。闭区间的写法中,结束是left是大于right的,并且一般情况下left指向最终结果(特殊情况后面会有例题)。开区间的右边界是访问不到的,所以在一次遍历过程中left不会到达right,一旦left==right则说明遍历过程结束,left和right同时指向最终结果。因为right不会被访问,所以在nums[mid]>target时只需要让right=mid就可以。
以上就是两种写法的区别所在,就最基本的二分查找而言,最好固定一种格式,以免因为使用错乱而导致各种问题。另外合理使用不同的循环结束条件也能帮助我们解决更多的类二分查找问题。
有了以上基础,那么接下来就可以拔高一下了,小二,上菜!
题目1:查找数字在有序数组中出现的第一个位置,若不存在返回-1(LeetCode 34)
查找第一个出现的位置和之前介绍的基本写法看似相同,实则大有区别:
- 首先基本查找找到该数字就返回了,而题目1要求返回第一个出现的位置
- 若当前mid所指向的数字为要找的数字,还要确定是否为第一个
- 在何时返回结果,怎样的方式返回结果?
- 查找可能会越界
由以上可知,查找数组第一个出现位置的关键是在于nums[mid]==target时候的处理,我的处理方法是令right=mid-1,
直接考虑最坏的情况,假设第一次找到的位置就是第一个出现的位置,此时将right=mid-1,right指向了一个比target小的数,left会不断的增加,直至到了right+1的位置循环结束,也就是right原来的,即正确答案的位置。
当然了这是在数字存在于数组中的情况,若数组并不存在于数组中,考虑第一种情况target介于数组中间的某两个数之间,这时要判断nums[left]==target,若相等则返回。另一种情况是target大于数组中全部元素,left到达了nums的边界,即nums.length,此时也说明数组中不存在该数组,返回-1即可。
然后总结一下二分查找的做法:
- 首先确定二分的依据,看题目能不能二分,或者可以通过某种方式转化为二分,也就是确立left,mid和right三者之间的关系
- 然后确定判断条件,即满足条件1时怎样做,满足条件2是怎样做,满足条件3时怎样做,一般情况下是3个条件,有时可以合并为两个
- 确定循环的终止条件 ,标志着最终答案由哪个指针指向
- 处理边界问题
根据以上总结可以整理出以下模板:
public int BinarySearch(int[] nums) {
int left=0;
int right=nums.length-1;
while(终止条件){
int mid = left+(right-left)/2;
if(条件1){
left/right xxxx;
}else if(条件2){
right xxxx;
}else{
left xxxx;
}
}
if(xxx) return xxx;
else return xxx;
}
有了以上分析,我们可以很快写出代码:
public static int BinarySearchFirst(int [] nums,int target ) {
int left=0;
int right=nums.length-1;
while(left<=right) {
int mid =left+(right-left)/2;
if(nums[mid]==target) {//相等时right=mid-1
right=mid-1;
}else if(nums[mid]>target) {//大于目标值的时候right=mid-1
right=mid-1;
}else {//小于目标值的时候left=mid+1
left=mid+1;
}
}
if(left>nums.length-1) return -1;//若left>nums.length-1则说明找不到并返回-1
else if(nums[left]==target) return left;//若nums[left]==target则说明数组中存在target并返回该位置
else return -1;//若nums[left]!=target则说明找不到并返回-1
//以上返回内容可以简写为:
//return left>nums.length-1?-1:(nums[left]==target?left:-1);
//或者
//return left<=nums.length-1&&nums[left]==target?left:-1;
}
有了上一的题目经验,我们可以想一下能不能找到数组中某个数字最后出现的位置,那么题目二就来了:
题目二:查找数字在有序数组中出现的最后一个位置,若不存在返回-1(LeetCode 34)
对比题目一,发现两者有共同的问题那就是:
- 如何确定当前mid指向的位置是最后一个
- 在何时返回结果,怎样的方式返回结果?
- 越界问题
稍作思考,可以得出答案:
- 在nums[mid]=target时处理,将 left=mid+1
- 在遍历完之后返回right的位置即可
- 欲知数组中存在该数字与否,判断nums[right]==target即可;若该数字比数组中所有元素都小,right也会减到-1
在题目一的代码上稍作修改即可得到题目二的代码:
public static int BinarySearchLast(int [] nums,int target ) {
int left=0;
int right=nums.length-1;
while(left<=right) {
int mid =left+(right-left)/2;
if(nums[mid]==target) {//相等时right=mid-1
left=mid+1;
}else if(nums[mid]>target) {//大于目标值的时候right=mid-1
right=mid-1;
}else {//小于目标值的时候left=mid+1
left=mid+1;
}
}
if(right<0) return -1;//若right>nums.length-1则说明找不到并返回-1
else if(nums[right]==target) return right;//若nums[right]==target则说明数组中存在target并返回该位置
else return -1;//若nums[right]!=target则说明找不到并返回-1
//以上返回内容可以简写为:
//return right<0?-1:(nums[right]==target?right:-1);
//或者
//return right>=0&&nums[right]==target?right:-1;
}
有了以上两个题目练习的基础,接下来可以小试牛刀了:
题目三:有序字符数组中第一个大于给定值的元素(LeetCode 744)
题目描述:给定一个有序的字符数组 letters 和一个字符 target,要求找出 letters 中大于 target 的最小字符,如果
找不到就返回第 1 个字符。
和第一个题差不多,只不过本题是要找到比给定值大的,当letters[mid]==target时将left=mid+1,注意返回时判断条件即可。
同理要找最后一个比给定元素大的只要在相等时将right=mid-1,并修改返回时判断条件
public char nextGreatestLetter(char[] letters, char target) {
int left=0;
int right=letters.length-1;
while(left<=right){
int mid=left+(right-left)/2;
if(letters[mid]==target){
left=mid+1;
}else if(letters[mid]>target){
right=mid-1;
}else{
left=mid+1;
}
}
return left>letters.length-1?letters[0]:letters[left];
}
题目四:旋转数组最小值 (LeetCode 153)
题目描述:旋转数组类似于{4,5,1,2,3}这种,是由{1,2,3,4,5}其中的前部分元素和后一部分元素互换位置得来,要求给定某一旋转数组,返回其中的最小值,要求时间复杂度为log(n)
首先要对旋转数组进行分析:旋转数组由两个递增的子数组组成,并且前部分任意一个元素都大于后部分的任意元素。而我们要找的位置是整个数组中最小的值,也就是第一部分之后的第一个数或者是第二部分第一个数。明确了这两点,我们可以分析判断条件了,毕竟这题不像前两个题一样有target作为参照,只能自己创造条件,决定left和right移动的条件是什么呢?答案是mid所在的位置,mid若在前半部分则操作left;mid若在后半部分则操作right;那么问题进一步细化为如何确定mid的位置。
nums[mid]在本题中只能跟nums[left]和nums[right]这两个来对比,首先来看跟left对比,以题目中的{4,5,1,2,3},初始left=0,right=4,mid=2,nums[mid]<nums[left],令left=mid+1=3,此时right=4, mid=3, nums[mid] == nums[left] , 可以将right=mid;然后结束条件为left<right,即相等时结束,于是草草写了个代码去提交,发现有一种情况没有考虑nums={5,1,2,3,4}的时候,left=0,mid=2,nums[left]<nums[right],若此时将left=mid+1,则会出现正确答案在left左边的情况,很明显是错误的,于是和将nums[mid]和nums[left]比较的方案被排除了。
然后只剩下将nums[mid]和nums[right]比较的方案了,重复上述推演过程,发现right可行,正确答案的位置处于后半部分,而right也一直处在mid的右侧,无论怎么比较,正确答案的位置一直会在right左侧。
明确了这一点我们可以分析得出几个条件了
- 首先看nums[mid]>nums[right]时,说明mid在前半部分,此时正确答案位置在mid右侧,故令left=mid+1
- 然后是nums[mid]<nums[right]时,说明mid在后半部分,并且mid的位置更靠近答案,此时令right=mid
- 当nums[mid]=nums[right],必有right=mid时,此时答案已经出现了,但是循环却还在继续,此时可以令left=mid,并且将循环结束条件指定为left==right(即while(left<right){…})来结束循环
有了以上讨论可以快速写出代码了。
public int findMin(int[] nums) {
int left=0;
int right=nums.length-1;
while(left<right){
int mid = left+(right-left)/2;
if(nums[mid]==nums[right]){
left=mid;
}else if(nums[mid]>nums[right]){
left=mid+1;
}else{
right=mid;
}
}
return nums[left];
}
题目五:有序数组中只出现一次的数(LeetCode 540)
题目描述:在有序数组中,只有一个数出现一次,而其他数都出现了两次,请找出这个数
输入: [3,3,7,7,10,11,11,12,12]
输出: 10
根据题意分析,因为是有序数组,可以使用二分查找。
然后分析条件,根据样例,10是目标数字,分析10前边的数字和10后边的数字的区别,分析可得:10之前的奇数位的数字都和该位置数字后边的数字相当,而10以后的数字就不符合这个条件了,而是奇数位后边的数字大于该位置的数字。于是们可以通过此条件判断mid的位置是在目标数字之前还是之后,为了实现这一条件可以将mid控制在奇数范围内。结束条件为left=right。
public int singleNonDuplicate(int[] nums) {
int left =0;
int right=nums.length-1;
while(left<right){
int mid=left+(right-left)/2;
if(mid%2==1){//将mid控制在奇数范围内
mid--;
}
if(nums[mid]==nums[mid+1]){//在目标数字之前left=left+2;
left=left+2;
}else{//在目标数字之后right=mid;
right=mid;
}
}
return nums[left];
}