一 二分搜索框架
二分的本质是「二段性」而非「单调性」
int binarySearch(int[] nums, int target) { int left = 0, right = ...; while(...) { // 计算 mid 时需要防止溢出,使用以下方式 int mid = left + (right - left) / 2; if (nums[mid] == target) { ... } else if (nums[mid] < target) { left = ... } else if (nums[mid] > target) { right = ... } } return ...; }
框架简单,但是有很多细节需要注意。框架中…部分均为需要注意到细节点。
二 例题
一 寻找一个数
经典二分查找
int binarySearch(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) left = mid + 1; // 注意 else if (nums[mid] > target) right = mid - 1; // 注意 } return -1; }
1、为什么 while 循环的条件中是 <=,而不是 <?
答:因为初始化
right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length。这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间
[left, right],后者相当于左闭右开区间 [left, right)。后者相当于开区间因为索引大小为
nums.length 是越界的,所以我们把 right 这一边视为开区间。因为本题目查询的区间为两端都闭区间
[left, right],所以查询循环的条件为while(left <= right),因为可能left=reght时为目标值。如果在搜索中没有
return mid; 的部分,则考虑while循环中为了避免死循环,也为left <= right。二 寻找有重复的数字数组的左侧边界
eg:
num[]=’1,2,2,2,3’ target = 2
expect return: 1
int left_bound(int[] nums, int target) { int left = 0; int right = nums.length; // 注意 while (left < right) { // 注意 int mid = left + (right - left) / 2; if (nums[mid] == target) { right = mid; } else if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid; // 注意 } } return left; }
1、为什么 while 中是
< 而不是 <=?答:用相同的方法分析,因为
right = nums.length 而不是 nums.length - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开。while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。2、为什么没有返回 -1 的操作?如果
nums 中不存在 target 这个值,怎么办?答:其实很简单,在返回的时候额外判断一下
nums[left] 是否等于 target 就行了,如果不等于,就说明 target 不存在。需要注意的是,访问数组索引之前要保证索引不越界:while (left < right) { //... } // 如果索引越界,说明数组中无目标元素,返回 -1 if (left < 0 || left >= nums.length) { return -1; } // 判断一下 nums[left] 是不是 target return nums[left] == target ? left : -1;
3、为什么
left = mid + 1,right = mid ?和之前的算法不一样?答:这个很好解释,因为我们的「搜索区间」是
[left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步应该去 mid 的左侧或者右侧区间搜索,即 [left, mid) 或 [mid + 1, right)。4、为什么该算法能够搜索左侧边界?
答:关键在于对于
nums[mid] == target 这种情况的处理:if (nums[mid] == target) right = mid;
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界
right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。在明白了搜索的区间后,也可以改为左右都是闭区间的搜索方式。
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; } } // 判断 target 是否存在于 nums 中 // 如果越界,target 肯定不存在,返回 -1 if (left < 0 || left >= nums.length) { return -1; } // 判断一下 nums[left] 是不是 target return nums[left] == target ? left : -1; }
三 寻找有重复的数字数组的右侧边界
int right_bound(int[] nums, int target) { 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 left - 1; // 注意 }
1、为什么最后返回
left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对。答:首先,while 循环的终止条件是
left == right,所以 left 和 right 是一样的,你非要体现右侧的特点,返回 right - 1 好了。至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在锁定右边界时的这个条件判断:
// 增大 left,锁定右侧边界 if (nums[mid] == target) { left = mid + 1; // 这样想: mid = left - 1
因为我们对
left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target。在明白了搜索的区间后,也可以改为左右都是闭区间的搜索方式。
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; } } // 最后改成返回 left - 1 if (left - 1 < 0 || left - 1 >= nums.length) { return -1; } return nums[left - 1] == target ? (left - 1) : -1; }
四 逻辑统一
梳理一下这些细节差异的因果逻辑:
第一个,最基本的二分查找算法:
因为我们初始化 right = nums.length - 1 所以决定了我们的「搜索区间」是 [left, right] 所以决定了 while (left <= right) 同时也决定了 left = mid+1 和 right = mid-1 因为我们只需找到一个 target 的索引即可 所以当 nums[mid] == target 时可以立即返回
第二个,寻找左侧边界的二分查找:
因为我们初始化 right = nums.length 所以决定了我们的「搜索区间」是 [left, right) 所以决定了 while (left < right) 同时也决定了 left = mid + 1 和 right = mid 因为我们需找到 target 的最左侧索引 所以当 nums[mid] == target 时不要立即返回 而要收紧右侧边界以锁定左侧边界
第三个,寻找右侧边界的二分查找:
因为我们初始化 right = nums.length 所以决定了我们的「搜索区间」是 [left, right) 所以决定了 while (left < right) 同时也决定了 left = mid + 1 和 right = mid 因为我们需找到 target 的最右侧索引 所以当 nums[mid] == target 时不要立即返回 而要收紧左侧边界以锁定右侧边界 又因为收紧左侧边界时必须 left = mid + 1 所以最后无论返回 left 还是 right,必须减一
总结:
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; } } // 判断 target 是否存在于 nums 中 if (left < 0 || left >= nums.length) { return -1; } // 判断一下 nums[left] 是不是 target return nums[left] == target ? left : -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; } } // 判断 target 是否存在于 nums 中 // if (left - 1 < 0 || left - 1 >= nums.length) { // return -1; // } // 由于 while 的结束条件是 right == left - 1,且现在在求右边界 // 所以用 right 替代 left - 1 更好记 if (right < 0 || right >= nums.length) { return -1; } return nums[right] == target ? right : -1; }
