🍑 一、概念
二分查找是一种查找算法,指的是在一个有序且没有重复的数组中,查找某个指定的元素,并返回指定元素的位置,如果没有找到,则返回-1。
🍑 二、分治思想
二分查找的原理也是分治思想,即定位在指定区间n-m的中间元素k,判断中间元素k跟要查找的值是否相等,如果相等就返回k,如果大于就m=k-1,如果小于就n=k+1,继续递归处理,直到查找区间被缩小为0。
注意:如果猜测范围的数字有偶数个,中间数有两个,就选择较小的那个。
如下图:0-100之间找到数字23,只需要7次就能找到目标数值
🍑 二分查找时间复杂度—— O(logn)
二分查找是一种非常高效的查找算法,它的时间复杂度分析如下,我们假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。被查找区间的变化如下图:
如图是一个等比数列。其中 n/2k=1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k=1,我们可以求得 k=log2n,所以时间复杂度就是 O(logn)。
O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。因为用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。
🍑 三、算法实现
循环实现
假定有序数组中不存在重复元素,写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。区间的定义就是不变量,要在二分查找的过程中保持不变量,就是while循环中每一次边界的处理要根据区间的定义来操作。
1、左闭右闭写法————[left, right]
首先,我们定义target实在一个左闭右闭的区间,也就是[left, right] (非常重要) 。 看以下实现代码
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
// right是数组最后一个数的下标,num[right]在查找范围内,是左闭右闭区间
let mid, left = 0, right = nums.length - 1;
// 当left=right时,由于nums[right]在查找范围内,所以要包括此情况
while (left <= right) {
// 位运算 + 防止大数溢出
mid = left + ((right - left) >> 1);
// 如果中间数大于目标值,要把中间数排除查找范围,所以右边界更新为mid-1;如果右边界更新为mid,那中间数还在下次查找范围内
if (nums[mid] > target) {
right = mid - 1; // 去左面闭区间寻找
} else if (nums[mid] < target) {
left = mid + 1; // 去右面闭区间寻找
} else {
return mid;
}
}
return -1;
};
注意:
- 1、循环退出的条件
while (left <= right) 要使用 <= ,因为left == right是有意义的,所以使用 <=
- 2、mid 的取值
最简单的办法是mid=(left+right)/2,如果 left 和 right 比较大的话,两者之和就有可能会溢出。
改进:mid=left+(right-left)/2
优化:mid=left+((right-left)>>1)
相比除法运算来说,计算机处理位运算要快很多
- 3、left和right的更新
if (nums[middle] > target) right 要赋值为 middle - 1,因为当前这个nums[middle]一定不是target,那么接下来要查找的左区间结束下标位置就是 middle - 1,如果直接等于mid可能导致死循环,如:当 right=3,left=3 时,如果 arr[3]不等于 value,就会导致一直循环不退出。
2、左闭右开写法————[left, right)
递归实现
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var search = function(nums, target) {
return bsearchInternally(nums, target, 0, nums.length-1);
};
function bsearchInternally(nums, target, left, right) {
if (left > right) return -1;
let mid = low + Math.floor((high - low) / 2); // 或者使用位运算提升效率,mid=left+((right-left)>>1)
if (nums[mid] === value) {
return mid;
} else if (nums[mid] < value) {
return bsearchInternally(nums, mid + 1, right, value);
} else {
return bsearchInternally(nums, left, mid - 1, value);
}
}
🍑 四、二分查找应用条件
1、二分查找依赖的是顺序表结构,简单点说就是数组。
二分查找依赖的是顺序表结构,因为二分查找算法需要按照下标随机访问元素。数组按照下标随机访问数据的时间复杂度是 O(1)。
2、二分查找针对的是有序数据
二分查找的数据必须是有序的,如果无序,需要先对数据进行排序。二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。
3、数据量太小不适合二分查找
如果要处理的数据量很小,直接顺序遍历就可以了,但是如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找
4、数据量太大也不适合二分查找
二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。
🍑 五、二分查找———“近似”查找问题
1、查找数组中第一个值等于给定值的元素
如题:比如在[1,2,3,6,7,8,8,8,11,18]这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 8,是重复的数据。我们希望查找第一个等于 8 的数据。代码如下:
function bsearch(arr, target) {
let left = 0; // 数组最左边下标
let right = arr.length - 1; // 数组最右边下标
while (left <= right) {
let mid = left+((right-left)>>1); // 位运算防止内存溢出,得到数组中间位置下标
if (arr[mid] > target) {
right = mid - 1; // 如果中间元素大于目标匀速,去左区间找,给right赋值为mid-1
} else if (arr[mid] < target) {
left = mid + 1; // 如果中间元素小于目标匀速,去右边区间找,给left赋值为mid+1
} else {
if ((mid === 0) || (arr[mid - 1] !== target)) return mid; // 如果是最后一位或者前面一位不等于target,说明mid是第一个等于给值的元素,否则再往前找,给right赋值为mid-1
else right = mid - 1;
}
}
return -1; // 如果都找到,返回-1
}
2、查找数组中最后一个值等于给定值的元素
如题:比如在[1,2,3,6,7,8,8,8,11,18]这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 8,是重复的数据。我们希望查找最后一个等于 8 的数据。代码如下:
function bsearch(arr, target) {
let n = arr.length; // 数组长度
let left = 0; // 数组最左边下标
let right = n-1; // 数组最右边下标
while (left <= right) {
let mid = left+((right-left)>>1); // 位运算防止内存溢出,得到数组中间位置下标
if (arr[mid] > target) {
right = mid - 1; // 如果中间元素大于目标匀速,去左区间找,给right赋值为mid-1
} else if (arr[mid] < target) {
left = mid + 1; // 如果中间元素小于目标匀速,去右边区间找,给left赋值为mid+1
} else {
if ((mid === n-1) || (arr[mid + 1] !== target)) return mid; // 如果是最后一位或者前面一位不等于target,说明mid是第一个等于给值的元素,否则再往后找,给left赋值为mid+1
else right = mid + 1;
}
}
return -1; // 如果都找到,返回-1
}
3、查找数组中第一个大于等于给定值的元素
js实现代码如下:
function bsearch(arr, target) {
let left = 0; // 数组最左边下标
let right = arr.length-1; // 数组最右边下标
while (left <= right) {
let mid = left+((right-left)>>1); // 位运算防止内存溢出,得到数组中间位置下标
if (arr[mid] >= target) {
if ((mid === 0) || (arr[mid - 1] < target)) return mid; // 如果是第一位或者前面一位小于target,说明mid是第一个大于等于给定值的元素,否则再往前找,给right赋值为mid-1
else right = mid - 1;
} else {
left = mid + 1; //中间值小于目标值,去右边区间找,给left赋值
}
}
return -1; // 如果都找到,返回-1
}
4、查找数组中最后一个小于等于给定制的元素
js实现代码如下:
function bsearch(arr, target) {
let n = arr.length; // 数组长度
let left = 0; // 数组最左边下标
let right = n-1; // 数组最右边下标
while (left <= right) {
let mid = left+((right-left)>>1); // 位运算防止内存溢出,得到数组中间位置下标
if (arr[mid] <= target) {
if ((mid === n-1) || (arr[mid + 1] > target)) return mid; // 如果是第一位或者前面一位小于target,说明mid是第一个大于等于给定值的元素,否则再往前找,给right赋值为mid-1
else left = mid + 1;
} else {
right = mid - 1; //中间值小于目标值,去右边区间找,给left赋值
}
}
return -1; // 如果都找到,返回-1
}
🍑 六、leetcode上相关算法题
- 34.在排序数组中查找元素的第一个和最后一个位置
- 35.搜索插入位置
- 69.x 的平方根
- 367.有效的完全平方数
- 704.二分查找