一,二分法(Binary Search)
前提:线性表采取顺序存储结构(即适用于数组,不适用于链表),表中元素有序排列
优点:因为是有序排列,故每次查询后可减少一半的搜索范围,时间复杂度是 O(log N)
场景:寻找一个数,寻找左侧边界,寻找右侧边界。
二,细节提醒
不同的搜索场景下,有些变量的初始值以及终止条件是不同的。
1, while (left < right) 还是 while (left <= right)
2, mid 是 +1 还是 -1
3, ......
三,整体框架
代码中 ... 代表细节处的不同,即不同的场景中此处代码可能会有所不同。
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = ...; // 位置 1
while (...) { // 位置 2
int mid = left + (right - left) >> 1;
if (nums[mid] == target) {
... // 位置 3
}
else if (nums[mid] < target) {
left = ... // 位置 4
}
else if (nums[mid] > target) {
right = ... // 位置 5
}
}
return ... // 位置 6
}
1, 此处决定搜索区间 [left, right] 或者是 [left, right)
left = 0, right = nums.size() - 1; ==> [left, right]
left = 0, right = nums.size(); ==> [left, right) 此时右边界的下标是无法访问的
2, 此处决定终止条件 , left == right 或者 left == right + 1
while (left < right) ==> left == right 为终止条件,终止时 {right, right}
while (left <= right) ==> left == right + 1 为终止条件,终止时 {right+1, right}
通常,位置2 的写法由 位置1 决定。(当我们决定了是 [ ] 或 [ ) 后,我们便能确定终止的条件了)
比如,你的搜索区间是 [left, right] , 那么终止条件是选 [right, right] 还是 [right+1, right] 呢? 肯定是 [right+1, right] 呀,因为 [right, right] 终止时,下标是 right 的元素还没有被访问。
再比如,你的搜索区间是 [left, right), 则终止时你的搜索区间一定是 [right, right), 那么终止条件可以是 while (left < right) 或 while (left <= right)。不过后者显然没必要。
当你明白后,你会发现:
当搜索区间是 [left, right] 时, 终止条件只能是 [right+1, right] , 即 while (left <= right)。 你若写 while (left < right) 则是错的,因为会漏元素。
当搜索区间是 [left, right) 时, 终止条件可以是 [right, right) 或 [right+1, right)。即 while (left < right) 和 while (left <= right) 都对,不过后者没必要。
3, 这取决于应用场景
若是找一个数,则找到后应该直接返回索引。
若是找左边界,则找到目标值后,目标值的左边可能还存在目标值。故改变搜索的右边界,继续往左边寻找。
若是找右边界,则找到目标值后,目标值的右边可能还存在目标值。故改变搜索的左边界,继续往右边寻找。
4, 此处改变搜索区间的左边界,注意,左边界始终是搜索区间内的。
所以这里 left = mid + 1;
5, 此处改变搜索区间的右边界,注意,右边界可能是改变的,与 位置1 有关。 [left,right] [left,right)
所以这里 right = mid - 1; 或者 right = mid;
四,寻找一个数
搜索一个数,若找到,则返回其索引,否则返回 -1。
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1; // 搜索区间 [left, right]
while (left <= right) { // 终止条件 [right+1, right]
int mid = left + (right - left) >> 1;
if (nums[mid] == target) {
return mid;
}
else if (nums[mid] < target) {
left = mid + 1; // [mid+1, right]
}
else {
right = mid - 1; // [left, mid-1]
}
}
return -1;
}
解释:
位置 1 : 我们搜索的区间是 [left,right],注意选择的是 左闭右闭 []
位置 2 : 由于搜索区间是 [], 故终止时搜索区间是 [right+1, right],此区间为空,故不会遗漏元素没有遍历。
位置 3 : 当找到目标值时,我们直接返回。
位置 4 : [left, mid, right] ==> 当目标值大于 nums[mid] 后,搜索区间变成了 [mid+1, right], 即 left = mid + 1;
位置 5 : 同 4, [left, mid-1] ==> right = mid - 1;
五,寻找左侧边界
现有一个数组 [1, 2, 2, 2, 4] ,利用上面算法寻找 2, 则得出的索引是 2。那么,如果我们想要得到第一次出现 target 值的索引,该如何修改 ?
有种容易想到的方法,就是在上述的算法中,当找到 target 后,向左线性搜索便可。
但是这样难以保证二分搜索对数级的复杂度。
int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1; // 搜索区间 [left, right]
while (left <= right) {
int mid = left + (right - left) >> 1;
if (nums[mid] == target) { // 因为是找左边界,若找到了目标值,则应该缩小右边界,使之在左边寻找 [left, mid-1]
right = mid - 1; // 很好奇为何不是 [left, mid] ? 因为 nums[mid] == target ,若 [left,mid-1] 区间没有 target,则最后 left == mid
}
else if (nums[mid] < target) {
left = mid + 1; // 区间缩小到 [mid+1, right]
}
else {
right = mid - 1; // 区间缩小到 [left, mid-1]
}
}
// 异常情况处理:
// left >= num.size() : 当 target 大于所有数时,left 会等于 right + 1, 即会越界,没找到,返回 -1
// nums[left] != target : 当 target 小于所有数时, left 仍然等于 0, 但需要判断 num[0] 是否等于 target
// 这两个判断条件的顺序不可以调换,因为后者可能会导致非法访问
if (left >= nums.size() || nums[left] != target) {
return -1;
}
return left;
}
// 另外一种写法,供思考
int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // [left, right)
while (left < right) { // [right, right)
int mid = left + (right - left) >> 1;
if (nums[mid] == target) {
right = mid; // [left, mid)
}
else if (nums[mid] < target) {
left = mid + 1; // [mid+1, right)
}
else {
right = mid; // [left, mid)
}
}
if (left >= nums.size() || nums[left] != target) {
return -1;
}
return left;
}
六,寻找右边界
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1; // [left, right]
while (left <= right) { // [right+1, right]
int mid = left + (right - left) >> 1;
if (nums[mid] == target) {
left = mid + 1; // [mid+1, right], 当该区间没有 target 时,最后 right 会变成 mid
}
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;
}
// 另一种写法,供思考
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size(); // [left, right)
while (left < right) { // [right, right)
int mid = left + (right - left) >> 1;
if (nums[mid] == target) {
left = mid + 1; // [mid+1, right)
}
else if (nums[mid] < target) {
left = mid + 1; // [mid+1, right)
}
else {
right = mid; // [left, mid)
}
}
// 因为终止条件是 left == right
// 当 target 小于所有数时, right 会不断缩小,最后 right == left 终止,即 right == 0 时,表明没有找到,返回 -1
// 当 target 大于所有数时, left 会不断增大,最后 left == right 终止,而 right = nums.size() 的,取不到,故 nums[right-1] != target 表明没找到
if (right == 0 || nums[right-1] != target) {
return -1;
}
return right - 1; // 当找到后,需要 right - 1。因为 nums[mid] == target 时,将 left = left + 1。而最后 right == left,故 right 需要 -1。
}