二分查找最详细的总结

283 阅读8分钟

二分查找详解

二分查找主要有三种变形,找出任意一个相等的元素,找出相等元素的左边界,找出相等元素的右边界(这里还有一种取巧办法,只要找比要找的元素target大一的最左边界的位置减一即可)。

而二分查找又需要注意搜索区间是左闭右开还是全闭区间,因此延伸出循环的结束条件以及在不同条件下如何更新区间,还有最后的返回值的问题,这些都是依赖于搜索区间的。

接下来为了说清楚上面需要考虑的问题,我会首先使用大家最常用的左闭右开区间为例,之后再给出闭区间版本(更简单,好用)。

一、基础版:找出任意一个相等元素的位置

这个应该比较简单,大家闭着眼睛都能写出来,话不多说,上代码。

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) {
        	left = mid + 1;
      } else if (nums[mid] > target) {
        	right = mid; // 注意细节
      } else { // nums[mid] == target
        	return mid; 
      }
  }
  return -1;
}

看完代码,我还是需要解释两句。第一点,注意循环结束条件是left == right,为什么是==不是>或者>=呢?

因为你的搜索区间限定了是左闭右开区间,因此当搜索区间为空的时候可不就是left == right嘛,只有当搜索区间没有值或者已经找到了才结束对吧~

第二点,为什么当nums[mid] > target时 更新right = mid呢?同样因为搜索区间是左闭右开,因此你的右端点必定是取不到的,即可取的最大值的下一个值,在这里因为target必定在[left, mid-1]区间内,因此你的右端点就应该取right = mid

最后,为什么你能断定我的搜索区间是左闭右开呢?因为你的right = nums.length,Are you OK?

基础版就解释到这里,如果还有问题可以继续提问~

二、进阶版:找出最小的相等元素的下标

这个就有一点难了,只需要注意一点就可以了,当找到相等元素的时候,不要立马返回继续往左压缩,直到找到比当前要找的target还要小的元素为止,此时我们才能确定这是最小的下标了!

废话不多说,直接看代码,还是左闭右开区间哦~

int binarySearch_left(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) {
          	left = mid + 1;
        } else if (nums[mid] > target) {
          	right = mid;
        } else if (nums[mid] == target) { // 注意这里的细节
          	right = mid;
        }
    }
  	return left; // 和这里的细节
}
  1. 循环结束条件和大于、小于的更新条件都还是一样的

    原因同理

  2. 等于的更新条件变成了right = mid是为什么呢?

    因为,我们找到一个相等的元素并不想直接返回,而是希望继续搜索直到找到最小的元素下标为止,那么应该继续往哪搜呢,如果元素值相等的话。当然是往左边搜了,因为你想找的是左边界,我们当然是继续往左搜看还有没有元素和target相等,如果有的话我们继续往左找,如果没有那就说明上次我们找到的已经是最后一个了。这种想法就是把区间往左压缩,即把原来的[left, right)区间压缩为[left, mid)

  3. 返回值为什么是返回left,不是返回right或者其他值呢?

    这是一个好问题,首先因为推出循环的时候必定是left == right,不论是找到了还是没找到。因此返回leftright是一样的,这是只是因为要找左边界版本,我们使用了left,看找右边界版本的时候可以做个对比,你就明白。

    其次,为什么一定是left呢,如果找不到呢?那么你需要明确,这里的返回值代表的含义。它指的是数组nums中第一个应当放置target元素的位置,这里涵盖了两层:如果数组中存在,那么返回的left必定就是最左边界;如果不存在,那么这个位置就是如果要插入target应当插入的下标。

    接下来,我来解释一下为什么~

    不好意思,忘记了规矩,我先举个栗子:

    nums = [1,2,2,3,4,5], target = 2

    第一次 left = 0, right = 6,别忘了right表示左闭右开区间的右端点,mid = 3, 进入nums[mid] > target分支,right = mid = 3,搜索区间变为[0, 3)

    大家自行脑补继续哈~,我加速一下(开玩笑,其实下一次就是倒数第二次迭代了);

    此时left = 0, right = 3, mid = 1,有nums[mid] == target,继续压缩左边界,得到right = mid = 1,此时搜索边界变成[0,1)此时区间只剩最后一个元素1了;

    此时left = 0, right = 1, mid = 0,有nums[mid] < targetleft = mid + 1 = 1,相等了对吧~

    退出循环,返回left = 1,哎,还真的是元素2的左边界诶~

    其实,没什么神奇的,因为循环开始前,搜索区间[0, nums.length)区间包含target,每一次循环我们都是让搜索区间[left, right)包含可能的target,结束迭代之前的区间记为[k, k+1)(这里把倒数第二次迭代的left的值记做k)必定仍然可能包含target。(有同学就会想到了,bingo,没错就是循环不变式,或者也可以用数学上的称呼,递推)

    结束的时候必定left == right,要么是left = mid + 1left自增一而来,要么是right减一而来。(没想明白的同学可以停下来思考一下~)

    我继续哦,第一种情况必定是mid = k小于target,所以最后left增一之后的位置必定是最有可能是target的位置对吧。想一想,这个时候区间必定是往左推到底,发现left(或者说mid,因为此时它们相等)小于target,然后往右推一次得到的。而之前的位置(即区间右边[k+2,...))我就是一路推过来的,可以保证这些位置都大于等于target(不然我不会往左推,而是往右推)。所以左边k位置小于,右边k+2及更右边都大于等于,只能是k+1位置要么是左边界,要么就是应当插入的位置。(如果还没理解,可以拿上面做例子哦)

    第二种情况则是k大于等于target,所以right减一,区间继续往左移,然后区间为空,所以上一个left必定是符合返回值要求的。因为,k之前的区间不可能,在之前的判断我们就排除了,否则此时必定包括那些位置,而倒数第二次的搜索区间为[k,k+1),最有可能的就是k也就是left对吧。

    因此,不管是那种情况,都必定是结束迭代时left的位置可能是target在数组中的左边界(如果存在的话)或者是插入时的第一个位置(如果不存在)。

    终于结束了,稍微有点烧脑哈~

  4. 换一个角度,怎么更好记住应该写更新条件和返回值呢?我从代码的角度给出另一个解释

    因为搜索区间是[left, right),所以循环继续的条件就是left < right,终止条件必定是区间没有值了,即left == right

    同样因为搜索区间,所以更新left时,即使得新的区间包括[mid + 1, right),所以left的更新条件为left = mid +。同理,如果当前中点的值大于等于target,区间就应该往左压缩变成[left, mid-1]也就是[left, mid),所以right = mid

    因为我们一直是利用midtarget进行比较,而最后一次left = mid = right,所以可以这样来记,最后返回的就是mid也就是我们要找的值;

    或者这样子理解,因为包含target的搜索区间是[left, right),要找的是最左边界,因此最后一次迭代得到的left必定是我们要的。

  5. 如果我们希望如果找不到返回-1怎么办呢?

    很简单,只需要将最后的返回值改写如下即可:

    if (left == nums.length || nums[left] != target)
      	return -1;
    return left;
    

    同样,可以这样来看,因为left = mid + 1,而mid必定是在区间[0, nums.length)内的,所以如果target超过数组中最大元素时,left会走到nums.length位置,需要小心越界。

基本上,将我能想到的问题都回答了,那我们继续看查找右边界版本

进阶:查找元素的右边界

这里思路是一样的,如果找到了相同元素不要立即返回,而是继续往右压缩,知道找到比它的大的元素为止。其实和人的认知也是一样的对吧,你怎么判断它是右边界,必定是下一个元素比它大,这个元素才是右边界(在有序数组中)。

直接上代码

int binarySearch_right(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) {
          	left = mid + 1;
        } else if (nums[mid] == target) {
          	left = mid + 1;
        } else if (nums[mid] > target) {
          	right = mid;
        }
    }
  	return right - 1;
}

这里和之前一样,为了说明清楚问题,我把三个分支都列出来了,无需惊讶,大家可以自行合并

重复的问题就不回答了~

这里重点回答一个,返回值为什么是right,其他类似的更新条件我相信聪明的同学们都可以很快看出来。

首先,可以类似的这样来理解,因为包含target的搜索区间是[left, right),我们要找的是最右边界,所以推到最后必定不是right而是right - 1

然后,我们正式来分析一下:

和上面类似,假设最后一次满足left < right的区间记为[k, k+1),即最后满足迭代条件的left = k,那么此时必定搜索区间是满足循环不变式的,即target如果要是在nums区间中,必定在该区间的点上有target,如果target不在nums区间,那么target也必定应当在我们的搜索区间内。

我们来考虑,此时搜索区间如何转换到不满足迭代条件,即此时的left == right,同样两种可能:

  1. 最后一次满足迭代条件的nums[mid] 小于等于target,即left小于等于target,我们把区间往右移,此时left = k+1 = right。我们知道[..., k]都小于等于target,而[k+1, ...]必定都大于target(反证很容易说明,如果不大于的话,最后一次满足迭代条件的区间就应当包括他们了),所以k也就是right - 1必定就是最有可能的target的右边界
  2. 最后一次满足迭代条件的nums[mid]大于target,也就是我们撞墙了,把区间往左移,此时left = k = right,我们知道[..., k-1]都是小于等于target的,而而[k, ...]必定都大于target,所以只有k-1可能是target的右边界(如果targetnums中的话,否则就是第一个应当插入的位置)

最后,这里的返回值的含义就是nums中最右一个可能为target的位置。

如果需要在找不到target时,返回-1,同样可以改写一下:

if (right == 0 || nums[right] != target)
  	return -1;
return right - 1;

因为right会更新为mid,所以right只会最多到位置0,而我们要返回的是right - 1所以right = 0此时left = 0说明target不存在

**当然有更简单的办法返回target的最右边界: binarySearch_left(nums, target+1)-1,**大家可以思考一下~

附赠一个完整的闭区间版本

int binarySearch(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) {
          	return mid;
        } else if (nums[mid] > target) {
          	right = mid - 1;
        }
    }
  	return -1;
}

int binarySearch_left(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;
        }
    }
  
  	if (left >= nums.length || nums[left] != target)
      	return -1;
  	return left;
}

int binarySearch_right(int[] nums, int target) {
  	int left = 0, right = nums.length - 1;
  	
  	while (left <= right) {
      	int mid = left + (right - mid) / 2;
      	if (nums[mid] < target) {
          	left = mid + 1;
        } else if (nums[mid] == target) {
          	left = mid + 1;
        } else if (nums[mid] > target) {
          	right = mid - 1;
        }
    }
  	
  	if (right < 0 || nums[right] != target) {
      	return -1;
    }
  	return right;
}

注意,如果是搜索区间为闭区间,则right在区间内,即right的值也可能包含target

在这里总结一下:

  • 闭区间,迭代终止条件统一是left = right + 1,即循环条件为left <= right
  • left的更新条件必定是left = mid + 1right的更新条件为right = mid - 1,在三个版本中唯一的区别在于相等的时候是去缩小左边还是右边的边界
  • 返回值此时可以统一而又对称,找左边界时返回left,找右边界时返回right
  • 判断返回值是否合法时,找左边界的情况,因为left的更新条件,所以left的取值为[0, nums.length],所以需要防止下标越界(即找左边界时需要注意target比整个nums都大的情况),找右边界时,因为right的更新条件,所以right的取值为[-1, nums.length - 1],所以需要防止下标越界(即找右边界时需要注意target比整个nums都小的情况)

完结撒花✿✿ヽ(°▽°)ノ✿