目录
1、概述
对于一个有序数组A[n],一般满足A1<=A2<=A3<= ... <= An,从n个元素的数组中找到目标target值。定义一个i=0左指针,j=n-1右指针,如果i > j,则返回-1表示查找失败。如果i <= j,则计算中间索引m=floor[(i+j)/2], (floor表示向下取整)。如果target < A[m],则j=m-1。如果target > A[m],则i =m+1。如果target=A[m],则查找成功。
2、分析详解
左闭右闭指针实现二分查找的核心代码
/**
* 有序数组的二分查找
* @param a 有序数组
* @param target 目标值
* @return 待查找的索引
*/
public static int binarySearch(int[] a, int target) {
int i = 0; //左指针
int j = a.length - 1; //右指针
while (i <= j) {
int m = (i + j) / 2; //中间值的索引
if (target < a[m]) {
//左分区
j = m - 1;
} else if (a[m] < target) {
//右分区
i = m + 1;
} else {
return m;
}
}
return -1;
}
测试控制台输出
看着别人写好的算法思路,觉得自己好像懂了,但是真正自己在写的时候不是一脸蒙,就是丢三落四。原因是自己没有将核心的要点记到心里去。学专业知识不能像看长篇小说一样,要边看边写边记到心里去。
提问1:为什么i <= j,不能用i < j?
当i < j 每次取中间值和目标值比较,一旦i和j重合时下标索引对应的值刚好是待查找的值,而循环退出了,让比较的次数会减少一次。
提问2:求m = (i + j) / 2 和 m = (i + j) >>> 1 有什么区别?
当 i= 0, j= 整数的最大值,求中间值m在jvm以有符号位的二进制数相加,当在右分区时求出的中间值m的最高位符号位是1,所以得出的一定是个负数。
采用 m = (i + j) >>> 1计算中间值会将最高位右移一位求出的是正数。
二进制相加除2得到最高位1的有符号数:
int i = 0; //左指针
int j = Integer.MAX_VALUE - 1; //右指针
int m = (i + j) / 2;
System.out.println("m=" + m); // 1073741823
System.out.println("-------------------------");
i = m + 1;
m = (i + j) / 2;
System.out.println("m=" + m); //-536870913
System.out.println("i = " + i); //1073741824D 0100 0000 0000 0000 0000 0000 0000 0000
System.out.println("j=" + j); //2147483646 +0111 1111 1111 1111 1111 1111 1111 1110
System.out.println("i + j =" + (i + j)); //-1073741826D =1011 1111 1111 1111 1111 1111 1111 1110
System.out.println("-----------------------");
j = m - 1;
m = (i + j) / 2;
System.out.println("m=" + m); //268435455
System.out.println("i = " + i); //1073741824 0000 0000 0000 0000 0000 0000 0000 0000 0100 0000 0000 0000 0000 0000 0000 0000
System.out.println("j=" + j); //-536870914 +1111 1111 1111 1111 1111 1111 1111 1111 1101 1111 1111 1111 1111 1111 1111 1110
System.out.println("i+j=" + (i + j)); //536870910 =0000 0000 0000 0000 0000 0000 0000 0000 0001 1111 1111 1111 1111 1111 1111 1110
最高位右移一位得到无符号位的整数解决方案:右移让代码变得可扩展性
int m = (i + j) >>> 1;
描述过程(面试中回答):
定义有序数组的起始索引作为i指针,最大索引作为j指针,当i和j相交时最后一次和目标值比较不相等i到j的右边表示没有查找到。如果i在j的左边时,计算中间值,让中间值和目标值比较,如果目标值小于中间值则移动右边的索引,下一轮在左分区查找;如果中间值小于目标值则移动左边索引,下一轮在右分区查找;如果中间值等于目标值,表示查找成功。
左闭右开指针实现二分查找的核心代码
/**
* 有序数组的二分查找改进版
* @param a 有序数组
* @param target 目标值
* @return 待查找的索引
*/
public static int binarySearchImproved(int[] a, int target) {
int i = 0; //左指针
int j = a.length; //TODO 1:右指针
while (i <= j) { //TODO 2: 循环条件 注意:i = j可能越界或死循环
int m = (i + j) >>> 1; //中间值的索引
if (target < a[m]) {
//左分区
j = m; //TODO 3: 右边界值 注意:j只作为右边界不是待查找的目标
} else if (a[m] < target) {
//右分区
i = m + 1;
} else {
return m;
}
}
return -1;
}
测试当i = j时出现的异常情况:
测试 j = m - 1时出现待查找值无法查找
3、评估算法的优劣性
我们在评估一个算法的好坏,可能会想到我们在idea或其他编辑器上都能看到执行时间,但是这 些会有很多不确定的因素干扰。比如,电脑的硬件环境、电脑运行的任务数量、测试的数据量也需要选择合适 ... ... 所以我们通过事前分析法对二分查找和线性两者做个每步代码的分析。
线性查找的步骤分析:
/**
* 分析在最坏的情况下执行的次数:
* int i = 0; 1
* i < a.length; n+1
* a[i] == target n
* i++ n
* return -1; 1
*
* 1+n+1+n+n+1=3*n+3
* 执行的总次数:3*n+3
* 每行代码花费t时间,当n=4时 则花费的时间15t
* 每行代码花费t时间,当n=1024时 则花费的时间3075t
*/
二分查找的分析步骤:
/**
* 分析在最坏的情况下执行的次数:
* 循环次数的规律:
* 元素的个数 次数
* 2 2 floor(log2(2))+1
* 4-7 3 floor(log2(4))+1
* 8-15 4 floor(log2(8))+1
* 16-31 5 floor(log2(16))+1
* 32-64 6 floor(log2(32))+1
* n N=floor(log2(n))+1
* int i = 0; 1
* int j = a.length - 1; 1
* i <= j N+1
* int m = (i + j) >>> 1; N
* target < a[m] N
* a[m] < target N
* i = m + 1; N
* return -1; 1
*
* 1+1+N+1+N+N+N+N+1=5*N+5=5*(floor(log2(n))+1)+4
* 执行的总次数:5*floor(log2(n))+9
* 每行代码花费t时间,当n=4时 则花费的时间 19t
* 每行代码花费t时间,当n=1024时 则花费的时间 59t
*/
假设每一行代码执行的时间是t,数据规模n个,线性查找的执行函数,二分查找的执行函数。为了能够更直观感受两个函数的变化趋势,如图:
在计算机中用时间复杂度表示代码执行的效率,时间复杂度步依赖于环境因素,在坐标轴上x轴是时间t,y轴是执行的代码数。
渐近上界函数
当f(n)的渐近上界函数确定c*g(n)后,则执行的时间复杂度
当f(n)的渐近上界函数确定c*g(n),则执行的时间复杂度
渐近下界函数:
渐近紧界函数:
通过两个执行函数分析时间复杂度评估代码的性能问题,在代码的每一个变量的数据类型会在内存中占用一定空间,int类型4Byte,在二分查找用到i、j、m三个整型变量占用的空间12Byte。定义了用空间复杂度O(1)表示,这三个变量在内存中只需要定义一次。在评估代码性能的问题从时间和空间复杂度上考虑,二分查找的时间复杂度在最好的情况下O(1),最坏情况下,只有上界没有下界。空间复杂度O(1)。
原始的二分查找代码会出现不平衡的问题
// 左右分区不平衡
if (target < a[m]) {
//左分区执行的次数:N=5*floor(log2(n))+9
//左分区
j = m - 1;
} else if (a[m] < target) {
//右分区执行的次数:2N
//右分区
i = m + 1;
} else {
return m;
}
改进平衡的二分查找实现的核心代码
/**
* 有序数组的二分查找平衡版
* @param a 有序数组
* @param target 目标值
* @return 待查找的索引
*/
public static int binarySearchBalanced(int[] a, int target) {
int i = 0; //左指针
int j = a.length; //1:右指针
while (1 < j - i) { //
int m = (i + j) >>> 1; //中间值的索引
if (target < a[m]) {
//左分区
j = m;
} else {
//右分区
i = m;
}
}
if (a[i] == target) {
return i;
} else {
return -1;
}
}
在Java集合util包下Arrays的binarySearch在集合工具包源码
// Like public version, but without range checks.
private static int binarySearch0(long[] a, int fromIndex, int toIndex,
long key) {
int low = fromIndex; //左索引
int high = toIndex - 1; //右索引
while (low <= high) {
int mid = (low + high) >>> 1; //计算中间索引
long midVal = a[mid];
if (midVal < key) //右分区
low = mid + 1;
else if (midVal > key) //左分区
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
在Java中实现数据的查找,借助集合工具类Arrays的核心代码
@Test
@DisplayName("Arrays binarySearch 找到")
public void testArraysBinarySearch(){
int[] a = {2, 5, 8};
int target = 4;
// [2, 5, 8] a
// [2, 0, 0, 0] b
// [2, 4, 0, 0] b
// [2, 4, 5, 8] b
int i = Arrays.binarySearch(a, target);
System.out.println("i = " + i);
if (i < 0) {
//插入点索引
int insertIndex = Math.abs(i + 1);
//目标数组
int[] b = new int[a.length + 1];
System.arraycopy(a, 0, b, 0, insertIndex);
b[insertIndex] = target;
System.arraycopy(a, insertIndex, b, insertIndex + 1, a.length - insertIndex);
System.out.println(Arrays.toString(b));
}
}
当有序数组有重复元素,则需要返回重复元素的最左边的元素索引值。
/**
* 二分查找重复元素最左的索引
* @param a
* @param target
* @return
*/
public static int binarySearchLeftmost1(int[] a, int target) {
int i = 0; //左指针
int j = a.length - 1; //右指针
//定义候选变量
int candidate = -1;
while (i <= j) { // 执行的总次数:N=5*floor(log2(n))+9
int m = (i + j) >>> 1; //中间值的索引
// 左右分区不平衡
if (target < a[m]) {
//左分区执行的次数:N
//左分区
j = m - 1;
} else if (a[m] < target) {
//右分区执行的次数:2N
//右分区
i = m + 1;
} else {
//目标值和中间值相等时,将中间值索引赋值给候选变量
candidate = m;
j = m - 1;
}
}
return candidate;
}
当查找最右侧重复元素,与上面同理只需修改一处代码即可。
else {
//目标值和中间值相等时,将中间值索引赋值给候选变量
candidate = m;
i = m + 1;
}
以上代码在对含有重复元素的数组用二分查找返回用-1在实际过程没有任何意义。改进返回值的核心代码:
/**
* 二分查找重复元素最左的索引
*
* @param a
* @param target
* @return
*/
public static int binarySearchLeftmost1(int[] a, int target) {
int i = 0; //左指针
int j = a.length - 1; //右指针
while (i <= j) { // 执行的总次数:N=5*floor(log2(n))+9
int m = (i + j) >>> 1; //中间值的索引
// 左右分区不平衡
if (target <= a[m]) {
//左分区执行的次数:N
//左分区
j = m - 1;
} else {
//右分区执行的次数:2N
//右分区
i = m + 1;
}
}
return i; //返回找重复元素最左元素的索引
}
/**
* 二分查找重复元素最右的索引
*
* @param a
* @param target
* @return
*/
public static int binarySearchRightmost1(int[] a, int target) {
int i = 0; //左指针
int j = a.length - 1; //右指针
while (i <= j) { // 执行的总次数:N=5*floor(log2(n))+9
int m = (i + j) >>> 1; //中间值的索引
// 左右分区不平衡
if (target < a[m]) {
//左分区执行的次数:N
//左分区
j = m - 1;
} else {
//右分区执行的次数:2N
//右分区
i = m + 1;
}
}
return i - 1 ; //当目标值不在有序数组中,小于等于目标值索引且重复元素最靠右的元素
}
使用binarySearchLeftmost和binarySearchRightmost认识应用的概念。
对于有序数组:A[n]={1, 2, 4, 4, 4, 7, 7}; 目标值target=5。求target的排名、求target的前任、求target的后任、求target的最近邻居。
二分查找binarySearchLeftmost1
/**
* 二分查找重复元素最左的索引
*
* @param a [1,2,4,4,4,7,7]
* @param target
* @return i //返回找重复元素最左元素的索引
*/
二分查找binarySearchRightmost1
/**
* 二分查找重复元素最右的索引
*
* @param a [1,2,4,4,4,7,7]
* @param target
* @return i - 1 //当目标值不在有序数组中,小于等于目标值索引且重复元素最靠右的元素
*/
求target的排名:target=4、target=5
Leftmost(4)=2+1
Leftmost(5)=5+1
求target的前任:target=4、target=5
Leftmost(4)=2-1
Leftmost(5)=5-1
求target的后任:target=4、target=5
Rightmost(4)=4+1
Rightmost(5)=4+1
求target的最近邻居:target=4、target=5
Leftmost(4)=2-1=1
Rightmost(4)=4+1=5
目标值4在1~5:target=4的最近邻居:5
Leftmost(5)=5-1=4
Rightmost(5)=4+1=5
目标值在4~5:target=5的最近邻居:5
对于有序数组:A[n]={1, 2, 4, 4, 4, 7, 7}; 按照指定的条件对目标值进行范围查找。
x < 4, 0 ~ Leftmost(4)=2-1
x <= 4, 0 ~ Rightmost(4)=4
x > 4, Rightmost(4)=4 ~ 无穷大
x >= 4,Leftmost(4)=2 ~ 无穷大
4<=x<=7,Leftmost(4)=2 ~ Rightmost(7)
4<x<7, Rightmost(4)=4 ~ Leftmost(7)=5
4、力扣算法
提供二分查找力扣算法题链接
https://leetcode.cn/problems/binary-search/description/
704:给定一个
n
个元素有序的(升序)整型数组nums
和一个目标值target
,写一个函数搜索nums
中的target
,如果目标值存在返回下标,否则返回-1
。
- 你可以假设
nums
中的所有元素是不重复的。(隐含意义:有序数组不需要考虑重复的下标索引是最左或最右)n
将在[1, 10000]
之间。(隐含意义:有序数组的值不会达到Interger的最大值,即中间值不会出现有符号的负数情况)nums
的每个元素都将在[-9999, 9999]
之间。
这道题在前面分析中已经讲解了代码,这里省略代码,分别基础版、改进版、平衡版的二分查找方法。
//平衡版实现二分查找
public int search(int[] a, int target) {
int i = 0;
int j = a.length;
while(1 < j - i) {
int m = (i + j) >>> 1;
if(target < a[m]){
j = m;
}else {
i = m;
}
}
return (a[i]==target) ? i : -1;
}
35. 给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。(时间复杂度为
O(log n)
)
34. 给你一个按照非递减顺序排列的整数数组
nums
,和一个目标值target
。请你找出给定目标值在数组中的开始位置和结束位置。(时间复杂度为O(log n)
)如果数组中不存在目标值
target
,返回[-1, -1]
。