查找是指根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
对于乱序的一组数,要找到给定数的位置是比较困难的,查找的时间复杂度通常为O(n)。但是对于一组有序的数查找到给定数的位置相对较为简单,通常可以使用二分查找法(Binary Search),时间复杂度为O(logn)。当然,要想有更快的查找速度可以改变存储方式,用空间换时间,使用散列表(哈希表)存储,查找的时间复杂度为O(1)。下面主要对二分查找算法进行讲解,本文讲解的都是以升序排列的数据作为待查找数据。
对于已经排好序的数据(也称为顺序表存储的数据)查找指定值的位置通常可以使用二分查找算法,时间复杂度比顺序查找快得多。本文就来探究几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。二分查找算法虽然简单,但是细节很重要,不注意细节会产生难以预料的bug。
二分查找算法的基本思想:在有序表中,每次都取中间记录作为比较对象,若给定值与中间记录的关键字相等则查找成功,返回该关键字的索引;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上诉过程,直到查找成功,或者所有查找区域无记录,查找失败为止,返回一个值代表没有找到。
基于二分查找算法的基本思想,可以得到二分查找算法的基本框架。
int binarySearch(vector<int>& nums, int target) {
int left = 0;
int right = ...;//注意
while(...) {//注意
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...//注意
} else if (nums[mid] < target) {
left = ...//注意
} else if (nums[mid] > target) {
right = ...//注意
}
}
return ...;//注意
}
框架中需要注意的细节都用…标注出来了,只有将这些细节都理解清楚才能写出正确的二分查找算法。注意这句代码:int mid = left + (right - left) / 2;,不用int mid=(left+right)/2;这种写法是为了防止数据直接相加产生溢出。
经典二分查找算法是最简单的,即搜索一个数,如果存在,返回其索引,否则返回 -1。
我们先写出代码再分析具体的细节。
int binarySearch(vector<int>& nums, int target) {
int left = 0;
int right = nums.size()- 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;
}
需要注意的细节都已经在代码中标注出来了,我们针对这个代码提出两个细节问题:
这两个问题应该放在一起考虑,首先说结论:如果right减一那么循环里面就应该包含等于;如果right不减一那么循环里面就不应该包含等于。
原因如下:
如果初始值right是减一的,那么初始的搜索区间就是[left,right],即左闭右闭,那么如果没有找到应该是left>right的时候,或者说left=right+1,此时搜索区间为[right+1,right],搜索区间为空。如果循环里面不包含等号,那么left==right的情况会被忽略,可能会漏解。
如果初始值right是不减一的,那么初始的搜索区间就是[left,right),即左闭右开,那么如果没有找到应该是left等于right的情况,此时搜索区间是[right,right),该区间为空,因此不会漏解。
当然如果你非要在循环里面用小于号也没有问题,因为我们已经知道了可能会漏掉的一个解是target刚好等于退出循环时候的索引值,我们只需要在最后返回值里面打一个补丁就好:
//...
while(left < right) {
// ...
}
return nums[left] == target ? left : -1;
这也是二分查找中特别需要注意的一个点,如果你能理解前面的内容,就能够很容易判断。
上面明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]。那么当我们发现索引 mid 不是要找的 target 时,下一步当然是去搜索 [left, mid-1] 或者 [mid+1, right] ,因为 mid 已经搜索过,应该从搜索区间中去除。
解决完这两个问题后,我们就可以写出很多经典二分查找算法的变种,这个可以自己去尝试,这里不再赘述。
问题引出:如果待查找数在有序数组中是重复数字,那么上述算法虽然可以查找出一个正确的解,但是不能查找到处于边界的数字索引。比如有序数组nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,该解没错。但是如果我想得到 target的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。这样的需求很常见,你也许会说,找到一个target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。
int leftBound(vector<int>& nums, int target) {
int left = 0;
int right = nums.size()-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;
}
}
// 检查出界情况
if (left >= nums.size() || nums[left] != target)
return -1;
return left;
}
这里的right取值以及while循环条件需要注意的细节和经典的二分查找算法一样,不清楚的可以往上面再翻一下。
可以看到寻找左侧边界的算法与经典二分查找算法只有两处不同,一个是当找到相同值时的操作,一个是返回值判断。下面我们一一进行讲解。
这正是该算法能找到边界值的关键点,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid] 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
如果该值存在于数组中,那么在找到相等值后right边界会不断向左收缩,直到right索引位于target目标值的左边一个索引,而退出循环的条件刚好是left=right+1,刚好是target的左侧边界索引值!
没有找到target返回-1时有两种情况:
一种是target大于数组中的所有值,如果target大于数组中的所有值,那么right永远不会改变,即right = nums.size()-1,left边界会不断收缩,直到退出循环时left=right+1,即left=nums.size();此时left超出边界,不能用nums[left] != target进行判断。
另一种情况是target没有超出边界,但是不存在,这时只需要判断left索引对应的值是否等于target即可。这里还能判断target小于数组中所有值的情况,因为这时left的值始终不会改变,没有超出边界,可以通过nums[left] != target进行判断。
寻找右侧边界的应用场景和寻找左侧边界相似,同样我们也先来看代码:
int rightBound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 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;
}
}
// 这里改为检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
可以看到寻找右侧边界的代码和寻找左侧边界的代码相似,因此我们也像寻找左侧边界一样来回答3个问题:
这正是该算法能找到边界值的关键点,找到 target 时不要立即返回,而是缩小「搜索区间」的下界 left,在区间 [mid,right] 中继续搜索,即不断向右收缩,达到锁定右侧边界的目的。
如果该值存在于数组中,那么在找到相等值后left边界会不断向右收缩,直到left索引位于target目标值的右边一个索引,而退出循环的条件刚好是left=right+1,即right=left-1,right刚好是target的右侧边界索引值!
没有找到target返回-1时有两种情况:
一种是target小于数组中的所有值,如果target小于数组中的所有值,那么left永远不会改变,即left = 0,right边界会不断收缩,直到退出循环时right=left-1,即right=-1;此时right超出边界,不能用nums[right] != target进行判断。
另一种情况是target没有超出边界,但是不存在,这时只需要判断right索引对应的值是否等于target即可。这里还能判断target大于数组中所有值的情况,因为这时right的值始终不会改变,没有超出边界,可以通过nums[right] != target进行判断。
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。 如果数组中不存在目标值
target,返回 [-1, -1]。示例 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]
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array
这道题就是明显的边界搜索题目,我们可以通过上面讲述的左右边界搜索算法很快将这道题写出来。具体代码如下:
class Solution {
public:
//寻找第一个的匹配值
int leftBound(vector<int>& nums, int target) {
int left = 0;
int right = nums.size()-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;
}
}
// 检查出界情况
if (left >= nums.size() || nums[left] != target)
return -1;
return left;
}
//寻找最后一个匹配值
int rightBound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 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;
}
}
// 这里改为检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
vector<int> searchRange(vector<int>& nums, int target) {
if(nums.size()==0)
{
return {-1,-1};
}
int left=leftBound(nums,target);
int right=rightBound(nums,target);
return {left,right};
}
};