1. 二分查找的概念
二分查找,也叫折半查找。其思想是:对一个有序的数据集合, 每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0。
2. 二分查找的时间复杂度
假设数据大小是n,每次查找后数据都会缩小为原来的一半,也就是会除以2。被查找区间的大小是n,n/2,n/4,....n/2^k。当n/2^k为1时,k的值就是总共缩小的次数。而每次缩小操作只涉及两个数据的大小比较,所以,经过了k次区间缩小操作,时间复杂度就是O(k)。通过计算n/2^k = 1,求得 k= log2n(以2为底,n的对数),所以时间复杂度就是O(logn)。即二分查找的时间复杂度是对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级O(1)的算法还要高效。因为即便n非常大,对应的logn也很小。比如n等于2的32次方。大约是42亿。也就是说,如果我们在42亿个数据中用二分查找一个数据,最多需要比较32次。但是用大O标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1)有可能表示的是一个非常大的常量值,比如O(1000),O(10000)。所以,常量级时间复杂度的算法有时候可能还没有O(logn)的算法执行效率高。
3. 二分查找的递归与非递归实现
题目描述:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target,
写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
非递归实现
C语言版本
int search(int* nums, int numsSize, int target){
if (nums == NULL || numsSize == 0) {
return -1;
}
int low = 0;
int high = numsSize - 1;
int mid;
while (low <= high) { // 结束的条件是 low > high
mid = low + (high - low) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
这里需要注意几点:
-
为什么while循环的条件中是 <=,而不是 < ? 因为初始化 high = numsSize - 1,即最后一个元素的索引,而不是numsSize。这两者的区别是前者的搜索范围相当于两端都是闭区间[low, high],而后者相当于是左闭右开区间[low, high),因为索引numsSize是越界的。
-
为什么 low = mid + 1和high = mid - 1? 因为我们上面的代码搜索区间是两端闭区间[low, high],所以当发现mid不是我们要找的target时,由于mid已经找过了,所以需要从这个搜索范围中扣除。因此需要去[mid + 1, high]或者[left, mid - 1]中去查找。
-
上面我们mid的取值是 mid = low + (high - low) / 2。为什么不用 mid = (low + high) / 2呢? 因为如果low和high比较大的话,两者之和就有可能会溢出。所以将mid的计算方式改成 mid = low + (high - low) / 2。更进一步,我们还可以优化,将这里除以2的操作转化成位运算 mid = low + ((high-low)>>1)。因为相比除法运算,计算机处理位运算要快得多。
C语言优化版本
int search(int* nums, int numsSize, int target){
if (nums == NULL || numsSize == 0) {
return -1;
}
int low = 0;
int high = numsSize - 1;
int mid;
while (low <= high) { // 结束的条件是 low > high
mid = low + ((high - low) >>1);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
最后再给出一个Go语言版本:
Go语言版本
func search(nums []int, target int) int {
low := 0
high := len(nums) - 1
var mid int
for low <= high {
mid = low + ((high-low) >> 1)
if nums[mid] == target {
return mid
} else if nums[mid] < target {
low = mid + 1
} else {
high = mid - 1
}
}
return -1
}
递归实现
C语言实版本
int searchInner(int *nums, int low, int high, int target) {
if (low > high) {
return -1;
}
int mid = low + ((high - low) >> 1);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
return searchInner(nums, mid + 1, high, target);
} else {
return searchInner(nums, low, mid - 1, target);
}
}
int search(int* nums, int numsSize, int target) {
return searchInner(nums, 0, numsSize - 1, target);
}
4. 二分查找的局限性
- 二分查找依赖的是顺序表结构,即数组
- 二分查找针对的是有序数据,因此只能用在插入、删除操作不频繁,一次排序多次查找的场景中。
- 数据量太小不适合二分查找,与直接遍历相比效率提升不明显。但是有一个例外,就是数据之间的比较操作非常费时时,比如数组中存储的都是长度超过300的字符串,那还是使用二分查找来尽量减少比较操作吧。
- 数据量太大也不适合二分查找,因为数组需要连续的空间,若数据量太大,往往找不到存储如此规模数据的连续内存空间。
5. 二分查找变形问题
查找第一个值等于给定值的元素 && 查找最后一个值等于给定值的元素
LeetCode-34-在排序数组中查找元素的第一个和最后一个位置
题目描述:
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
题解,这里找右边界和左边界是类似的,这里只讲一个左边界:
- 如果当前看到的元素恰好等于 target,那么当前元素有可能是target出现的第1个位置,由于我们要找第1个位置,此时我们应该向左边继续查找。当然, 我们此时可以记下这个元素的位置;
- 如果当前看到的元素严格大于target,那么当前元素一定不是要找的 target出现的第1个位置,第1个位置肯定出现在mid的左边,因此就需要在 [left, mid - 1]区间里继续查找; 如果当前看到的元素严格小于target,那么当前元素一定不是要找的 target出现的第1个位置,第1个位置肯定出现在mid的右边,因此就需要在 [mid + 1, right] 区间里继续查找。
总结一句话就是:如果查找左边界值,就压缩待查找的区间右边界;如果查找右边界值,就压缩待查找的区间左边界。 C语言版本
/**
* Note: The returned array must be malloced, assume caller calls free().
*/
int searchLeftRange(int* nums, int numsSize, int target) {
int left = 0;
int right = numsSize - 1;
int mid;
int result = -1;
while (left <= right) {
mid = left + ((right - left) >>1);
if (nums[mid] == target) {
result = mid;
right = mid - 1;
} else if (nums[mid] < target){
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
int searchRightRange(int* nums, int numsSize, int target) {
int left = 0;
int right = numsSize - 1;
int mid;
int result = -1;
while (left <= right) {
mid = left + ((right - left) >>1);
if (nums[mid] == target) {
result = mid;
left = mid + 1;
} else if (nums[mid] < target){
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
int *searchRange(int* nums, int numsSize, int target, int* returnSize){
if (returnSize == NULL) {
return NULL;
}
int *result = (int *)malloc(sizeof(int) * 2);
if (result == NULL) {
return NULL;
}
result[0] = -1;
result[1] = -1;
(*returnSize) = 2;
if (nums == NULL || numsSize <= 0) {
return result;
}
int leftPos = searchLeftRange(nums, numsSize, target);
int rightPos = searchRightRange(nums, numsSize, target);
result[0] = leftPos;
result[1] = rightPos;
return result;
}
查找第一个大于等于给定值的元素
题目描述:
给定一个有序的数组,查找第一个大于等于给定值的元素的下标。例如对于数组[3,4,6,7,10].如果查找第一个大于等于5的元素,则是6,所以返回下标2。
其实仔细想想,这个题目就是查找左边界。所以和【查找第一个值等于给定值的元素】那题的解题思路是一样的,只是那题是在等于目标值时,更新结果。但是本题则是在大于等于目标值时更新。具体请见代码:
// 查找第一个大于等于target的值的下标(相当于查找左边界那题)
int searchFirstGETargetIndex(int *nums, int numsSize, int target) {
int left = 0;
int right = numsSize - 1;
int mid;
int result = -1;
while (left <= right) {
mid = left + ((right - left) >>1);
if (nums[mid] >= target) {
result = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return result;
}
查找最后一个小于等于给定值的元素
与上题类似,这个题目其实是【查找最后一个值等于给定值的元素】的变形。
// 查找最后一个小于等于target的值的下标(相当于查找右边界那题)
int searchLastLETargetIndex(int *nums, int numsSize, int target) {
int left = 0;
int right = numsSize - 1;
int mid;
int result = -1;
while (left <= right) {
mid = left + ((right - left) >>1);
if (nums[mid] <= target) {
result = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return result;
}
参考文献
time.geekbang.org/column/intr… github.com/labuladong/… leetcode-cn.com/tag/binary-…