概念

二分查找是一种在每次比较之后将查找空间一分为二的算法。
每次需要查找集合中的索引或元素时,都应该考虑二分查找。
如果集合是无序的,我们可以总是在应用二分查找之前先对其进行排序。

三部分
二分查找一般由三个主要部分组成:

  • 预处理 —— 如果集合未排序,则进行排序。
  • 二分查找 —— 使用循环或递归在每次比较后将查找空间划分为两半。
  • 后处理 —— 在剩余空间中确定可行的候选者。

模板一:

标准的二分查找模板
模板 #1 用于查找可以通过访问数组中的单个索引来确定的元素。

  1. int binarySearch(int[] nums, int target){
  2. if(nums == null || nums.length == 0)
  3. return -1;
  4. int left = 0, right = nums.length - 1;
  5. while(left <= right){
  6. // Prevent (left + right) overflow
  7. int mid = left + (right - left) / 2;
  8. if(nums[mid] == target)
  9. {
  10. return mid;
  11. }else if (nums[mid] < target)
  12. {
  13. left = mid + 1;
  14. } else
  15. {
  16. right = mid - 1;
  17. }
  18. }
  19. // End Condition: left > right
  20. return -1;
  21. }

关键属性

  • 二分查找的最基础和最基本的形式。
  • 查找条件可以在不与元素的两侧进行比较的情况下确定(或使用它周围的特定元素)。
    • 保证查找空间在每一步中至少有 1 个元素。
  • 不需要后处理,因为每一步中,你都在检查是否找到了元素。如果到达末尾,则知道未找到该元素。

区分语法

  • 初始条件:left = 0, right = length-1
  • 终止:left > right
  • 向左查找:right = mid-1
  • 向右查找:left = mid+1

例题

Acwing 789. 数的范围

704. 二分查找(简单) 数组 二分法(标准的模板1)

给定一个 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]之间。

  1. //标准的模板1类型
  2. class Solution {
  3. public int search(int[] nums, int target) {
  4. int left = 0;
  5. int right = nums.length - 1;
  6. while (left <= right) {
  7. int mid = left + (right - left >> 1);
  8. if (nums[mid] == target) {
  9. return mid;
  10. }
  11. else if (nums[mid] < target) {
  12. left = mid + 1;
  13. }
  14. else {
  15. right = mid - 1;
  16. }
  17. }
  18. return -1;
  19. }
  20. }

二分查找的思路是:根据待搜索区间里的中间元素 nums[mid] 与 target 的值的大小关系,判断下一轮搜索需要在哪个区间里查找,进而设置 left 和 right 的值。分为如下三种情况:

如果 nums[mid] == target,运气很好,找到了目标元素,返回 mid ;
如果 nums[mid] > target,说明 mid 以及 mid 的 右边 的所有元素一定都比 target 大,下一轮搜索需要在区间 [left, mid - 1] 里查找,此时设置 right = mid - 1;
如果 nums[mid] < target,说明 mid 以及 mid 的 左边 的所有元素一定都比 target 小,下一轮搜索需要在区间 [mid + 1, right] 里查找,此时设置 left = mid + 1。
退出循环的时候,说明区间里不存在目标元素,返回 −1。

模板二:

当看到了 **nums[mid]** 恰好等于 **target** 的时候,还得继续查找,继续看看左边的元素的值,或者继续看看右边元素的值。这时候用模板二就比模板二更合适。

例如:

  • 大于等于 target 的下标最小的元素
  • 小于等于 target 的下标最大的元素

    1. int binarySearch(int[] nums, int target){
    2. if(nums == null || nums.length == 0)
    3. return -1;
    4. int left = 0, right = nums.length - 1;
    5. while(left < right) {
    6. // Prevent (left + right) overflow
    7. int mid = left + (right - left) / 2;
    8. if(nums[mid] == target) {
    9. return mid;
    10. }
    11. else if (nums[mid] < target) {
    12. left = mid + 1;
    13. }
    14. else {
    15. right = mid;
    16. }
    17. }
    18. // Post-processing:
    19. // End Condition: left == right
    20. //这一步不是必须的,可能有也可能没有
    21. if(nums[left] == target)
    22. return left;
    23. return -1;
    24. }

    关键属性

  • 一种实现二分查找的高级方法。

  • 查找条件需要访问元素的直接右邻居。
  • 保证查找空间在每一步中至少有 2 个元素。
    • 如果数组长度为0或1,不会进入while循环直接返回左值,结果也满足条件!
  • 需要进行后处理。 当你剩下 1 个元素时,循环 / 递归结束。 (可能)需要评估剩余元素是否符合条件。


    区分语法

  • 初始条件:left = 0, right = length - 1

  • 终止:left == right
  • 向左查找:right = mid
  • 向右查找:left = mid+1

💡
二分查找(整数) - 图1
一三为一组,二四为一组。
一三考虑一:大于等于 target 的下标最小的元素
mid = left + (right - left >> 1);
如果nums[mid] < target,left = mid + 1;否则 right = mid;
二四考虑四:小于等于 target 的下标最大的元素
mid = left + (right - left + 1 >> 1);
如果nums[mid] > target, right = mid - 1; 否则left = mid;
循环可以继续的条件 写成 while (left < right)

在 写 if 语句的时候,通常把容易想到的,不容易出错的逻辑写在 if 的里面,这样就把复杂的、容易出错的情况放在了 else 的部分,这样编写代码不容易出错。

例题

34. 在排序数组中查找元素的第一个和最后一个位置(中等) 数组 二分法(标准的模板2)
852. 山脉数组的峰顶索引(简单) 数组 二分法(标准的模板2)

34 给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]。

进阶:你可以设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?

示例 1:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
示例 2:
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
示例 3:
输入:nums = [], target = 0
输出:[-1,-1]

提示:
0 <= nums.length <= 105
-109 <= nums[i] <= 109
nums 是一个非递减数组
-109 <= target <= 109

  1. class Solution {
  2. public int[] searchRange(int[] nums, int target) {
  3. //返回值
  4. int[] result = new int[]{-1, -1};
  5. //特殊情况判断
  6. if (nums == null || nums.length == 0) {
  7. return result;
  8. }
  9. int left = 0;
  10. int right = nums.length - 1;
  11. //前边界二分查找
  12. while (left < right) {
  13. int mid = left + right >> 1;
  14. if (nums[mid] < target) {
  15. left = mid + 1;
  16. }
  17. else {
  18. right = mid;
  19. }
  20. }
  21. //后处理
  22. if (nums[left] != target) {
  23. return result;
  24. }
  25. else {
  26. result[0] = left;
  27. right = nums.length - 1;
  28. //后边界二分查找
  29. while (left < right) {
  30. int mid = left + right + 1 >> 1;
  31. if (nums[mid] > target) {
  32. right = mid - 1;
  33. }
  34. else {
  35. left = mid;
  36. }
  37. }
  38. result[1] = right;
  39. return result;
  40. }
  41. }
  42. }

总结:

  1. 从数组中找第一次或最后一次出现这种表示范围的题,如果能用二分法做,应该用模板2

852 符合下列属性的数组 arr 称为 山脉数组 :

  • arr.length >= 3
  • 存在 i(0 < i < arr.length - 1)使得:
    • arr[0] < arr[1] < … arr[i-1] < arr[i]
    • arr[i] > arr[i+1] > … > arr[arr.length - 1]

给你由整数组成的山脉数组 arr ,返回任何满足 arr[0] < arr[1] < … arr[i - 1] < arr[i] > arr[i + 1] > … > arr[arr.length - 1] 的下标 i 。

示例 1:
输入:arr = [0,1,0]
输出:1
示例 2:
输入:arr = [0,2,1,0]
输出:1
示例 3:
输入:arr = [0,10,5,2]
输出:1
示例 4:
输入:arr = [3,4,5,1]
输出:2
示例 5:
输入:arr = [24,69,100,99,79,78,67,36,26,19]
输出:2

提示:
3 <= arr.length <= 104
0 <= arr[i] <= 106
题目数据保证 arr 是一个山脉数组

进阶:很容易想到时间复杂度 O(n) 的解决方案,你可以设计一个 O(log(n)) 的解决方案吗?

  1. class Solution {
  2. public int peakIndexInMountainArray(int[] arr) {
  3. int left = 0;
  4. int right = arr.length - 1;
  5. while (left < right) {
  6. int mid = left + (right - left >> 1);
  7. if (arr[mid] < arr[mid+1]) {
  8. left = mid + 1;
  9. }
  10. else {
  11. right = mid;
  12. }
  13. }
  14. return left;
  15. }
  16. }

总结:这种每次在二分的时候需要依赖mid+1位置元素的题用模板2更容易

细节 为什么有些时候取 mid 的时候需要上取整?

回答:是否需要上取整,只和区间划分的逻辑有关。如果不调整,会出现死循环。
题34完美涵盖了这两种情况,最左值和最右值.
二分查找(整数) - 图2

题型

1. 二分下标(在数组中查找符合条件的元素的下标)

2. 二分答案(在一个有范围的区间里搜索一个整数)

定位一个有范围的整数,这件事情也叫「二分答案」或者叫「二分结果」。如果题目要求的是一个整数,这个整数有明确的范围,可以考虑使用二分查找。

模板1:

#374 猜数字大小 经典!
#367 有效的完全平方数 用long类型保存平方

模板2:

#69 x 的平方根 用long类型保存平方
#29 两数相除 用long类型保存乘的结果
除了二分,还用到了倍增乘法模板
小于等于 target 的下标最大的元素
#441 排列硬币 小于等于 target 的下标最大的元素

3. 二分答案的升级版:判别条件需要遍历数组

#287 寻找重复数 💡有点难

4. 其它

#50 Pow(x, n) 递归类型的二分
#167 两数之和 II - 输入有序数组 多次二分
#349 两个数组的交集 多次二分
#392 判断子序列 虽然能用二分,但是时间复杂度有点高

总结

  1. 首先要关注能不能用二分?用哪种模板合适?
    • 有序或者半有序数组中找下标 或者 确定一个有范围的整数。
  2. 能用二分后,要先确定搜索范围,例如:33. 搜索旋转排序数组(中等)81. 搜索旋转排序数组 II(中等)
  3. 可以从「看到的中间元素什么时候不是解」开始思考 if 的语句怎么写,分成可能存在和一定不存在两个区间
  4. 如果搜索区间里一定存在目标元素,退出 while (left < right) 以后,返回 left 或者 left 代表的值就可以,否则还需要单独做一次判断;例如:35. 搜索插入位置, #744 寻找比目标字母大的最小字母
  5. 有单调性一定能用二分,能用二分不一定满足单调性,二分的本质是边界具有某种性质使得一半满足,另一半不满足

参考

[1]. leetcode liweiwei1419评论总结
[2]. leetcode 官方学习文档