二分查找
前言
跟着网课重新开始从头学习二分查找,发现原来二分查找也有这么多的变种与应用,写下了自己的学习笔记,如果有错误的地方,还请见谅。
二分查找基础版
二分查找(Binary Search)是一种在有序数组中查找特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是目标值,则搜索过程结束;如果目标值大于或小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且同样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
二分查找的基本步骤如下:
- 初始化左指针
left
指向数组的第一个元素,右指针right
指向数组的最后一个元素。 - 计算中间索引
mid
,通常使用(left + right) / 2
(整数除法)或(left + right) >>> 1
(无符号右移一位,等价于整数除法)。 - 如果
mid
对应的元素等于目标值target
,则搜索结束,返回mid
。 - 如果
mid
对应的元素大于目标值target
,则在数组的左半部分(left
到mid - 1
)继续搜索,更新right = mid - 1
。 - 如果
mid
对应的元素小于目标值target
,则在数组的右半部分(mid + 1
到right
)继续搜索,更新left = mid + 1
。 - 重复步骤 2-5,直到找到目标值或搜索范围为空(
left > right
)。 - 如果搜索范围为空,则目标值不存在于数组中,返回
-1
或其他表示未找到的值。
public int search(int[] nums, int target) {
int i = 0;
int j = nums.length-1;
while(i<=j){
int m = (i+j)/2;
if(target<nums[m]){
j=m-1;
}else if(nums[m]<target){
i=m+1;
}else{
return m;
}
}
return -1;
}
无符号右移运算
在Java语言中,会把二进制数字的首位看作是符号位,即当首位是0时,为正数,当首位为1时,为负数,这也是为什么当一个运算超出了Integer的最大值时,打印结果会是一个负数的原因,因此会采用无符号右移运算来避免这种情况,无符号右移运算一位简单来讲就是对这个数除以二,因为当一个二进制的偶数右移一个单位,所得结果就是这个数除以2,奇数就是对除以二以后结果进行取整。
举个例子,如果我们有一个8位的二进制数 11001100
(十进制中为204),将其无符号右移两位,操作如下:
原始数值:11001100
右移两位:00110011
左边空出的两位用零填充,得到新的二进制数 00110011
(十进制中为51)。
无符号右移运算通常用于处理无符号整数(即只能为正数的整数),或者在需要保持位运算结果始终为正数的情况下使用。在一些编程语言中,比如Java,无符号右移是特别为处理32位整数设计的,因为在Java中,整数都是有符号的,但通过无符号右移,可以在某种程度上模拟无符号整数的行为。
需要注意的是,并不是所有编程语言都支持无符号右移运算符。在一些不支持无符号右移的语言中,可能需要通过其他方式(如逻辑右移和算术右移的组合)来模拟无符号右移的效果。此外,在使用无符号右移时,也要考虑到不同数据类型和平台可能存在的差异,以确保代码的正确性和可移植性。
无符号右移运算的实际应用:
public class test1 {
public static void main(String[] args) {
int target = 5;
int[] arr = {1,2,3,4,5,6};
//设置指针和初值
int i =0;
int j = arr.length-1;
while(i<=j){
int mid = (i+j)>>>1;
if (arr[mid]==target){
System.out.println(mid);
return;
}
else if (arr[mid]>target){
j=mid-1;
}
else if (arr[mid]<target){
i=mid+1;
}
}
System.out.println("-1");
}
}
在上述代码中,表示的是一个二分查找的模型,对于mid的值,如果这个数组特别大,就有可能出现数据溢出的情况,采取无符号右移一位,也能表示除以二的效果。
二分查找改动版
在上面的二分查找代码中,循环条件必须为 i<=j,但是可以对代码进行一些改动,使得循环条件可以变为 i<j,代码如下。
public class test1 {
public static void main(String[] args) {
int target = 3;
int[] arr = {1,2,3,4,5,6};
int i =0;
//第一处改动
int j = arr.length;
//第二处改动
while(i<j){
int mid = (i+j)>>>1;
if (arr[mid]==target){
System.out.println(mid);
return;
}
else if (arr[mid]>target){
//第三处改动
j=mid;
}
else if (arr[mid]<target){
i=mid+1;
}
}
System.out.println("-1");
}
}
与上面代码相比较,明显可以看出,代码进行了三处改动,在基础版中,i和j不仅仅代表数组的边界,还有可能作为比较的值,但是在改动之后,j就不可能是比较的值了,所以当mid的值大于target的值时,j就应该取mid,而不是mid-1。
那么为什么循环条件也要改成 i<j呢,因为根据上面我们可以知道,j只作为数组的边界,不再进入循环的比较中,如果不改为 i<j,那么在之后的代码中,会把j的值也带入到比较中,看起来似乎没有什么关系,好像无伤大雅,但是在某些情况下,会出现一些比较严重的错误。
当使用二分查找时,查找一个数组中不存在的数,而循环条件写成了i<=j,那么就会使得循环一直进行下去,进入一个死循环。因为当我们查找一个不存在的值时,最终i和j的值会相等,但如果循环条件为i<=j,如果在mid的右边,那j仍然等于mid,没有改变,但又一直查找不到这个数,就会陷入死循环。
二分查找平衡板
在上述的二分查找代码中,我们可以了解到,查找元素所在位置不同,那么所对应的比较次数也不同,那么在这里设计一个比较平衡的查找方法,代码如下
public static int serch(int [] a,int target){
int i = 0;
int j = a.length;
// j-i表示j和i之间的数据个数,当数据个数小于1时,由于j不可能是参
// 与比较的数据,那么就只剩一个i元素,此时跳出循环,把a[i]的值与target目标值进行比较。
while(1<j-i){
int m = (i+j)>>>1;
if (target < a[m]){
j=m;
}
else{
//i的边界也要进行修改,因为中间值也要参与比较
i=m;
}
}
if (a[i] == target){
return i;
}
else{
return -1;
}
}
可以看到,上述代码进行了一些改进,在while循环中只比较一次,知道i与j之间的元素仅剩一个,此时跳出循环,把a[i]的值与target的目标值进行比较,查找成功返回数组下标,查找失败则返回-1。循环内的平均比较次数减少了。
缺点:时间复杂度无论何种情况都是O(log(n)),不存在O(1)的情况了。
二分查找Java版
学完了二分查找的几种形式,下面可以了解一下在Java中,二分查找的实现。查看Arrays下的binarySearch方法。
可以看到这个binarySearch方法还调用了一个binarySearch0方法,并且参数也变多了。查看这个binarySearch0方法。
不难看出,四个参数所代表的意义,fromIndex代表起始指针,toIndex代表截至指针,key代表要查找的元素。可以看出,这个方法底层是用的基础版的二分查找。
查看这个binarySearch的文档,翻译一下return值的含义
搜索键的索引(如果它包含在数组中);否则为 (-(插入点) - 1)。插入点定义为将键插入到数组中的点:第一个元素的索引大于键,如果数组中的所有元素都小于指定的键,则为 a.length。请注意,这保证了当且仅当找到键时,返回值将 >= 0。
那么插入点是什么意思呢,举个例子,有一个数组a={2,5,8},我此时想要查找4这个数字,那么我调用这个方法之后,所得的结果是-2,为什么是-2呢,根据帮助文档可知,查找失败后,结果为-2=-(插入点) - 1,可以得到插入点为1,也就是如果把查找元素插入到这个数组里,那么插入的数组下标为1.
对于基础版的二分查找,谁可以代表插入点呢,经过分析可知,在基础班的二分查找中,i可以代表插入点。因此,在Java底层代码中,low就代表了插入点。
那为什么要加上-1呢,因为如果插入点是0,返回一个-0,0和-0是区分不出来的,那是查找到了0索引的数,还是插入点是0呢,
这时候区分不出来,所以加上一个-1,就可进行区分了。
如果要打印插入之后的数组,该如何进行操作,这时候可以用到System.arraycopy。
System.arraycopy
System.arraycopy
是 Java 中的一个原生方法,用于将一个源数组的部分或全部元素复制到另一个目标数组中。这个方法通常比手动复制数组元素更快,因为它是一个底层操作,可以直接在内存中进行数据传输。
方法签名如下:
java
public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length)
参数说明:
src
:源数组,即从中复制元素的数组。
srcPos
:源数组中的起始位置,从这个位置开始复制元素。
dest
:目标数组,即将元素复制到的数组。
destPos
:目标数组中的起始位置,从这个位置开始放置复制的元素。
length
:要复制的元素数量。
public class test {
public static void main(String[] args) {
int[] a = {2,5,8};
int insert = 1;
int[] b = new int[4];
System.arraycopy(a,0,b,0,insert);
b[insert]=4;
System.arraycopy(a,insert,b,insert+1,a.length-insert);
System.out.println(Arrays.toString(b));
}
}
示例代码如上所示。
LeftRightmost
在使用二分查找进行查找运算时,需要满足一个前提条件,也就是查找的数组必须是有序的,那如果在数组中有重复的元素,在使用二分查找时,只会返回一个数据元素,但是这个位置是不固定的,取决于那个元素被最先访问到,如果想要查找最左边的元素,那就需要对代码进行一些改动。
改动也很简单,不需要什么大改动,只需要对返回数据那一处的代码进行修改
public static int Leftmost(int [] a,int target){
int i = 0,j=a.length-1;
//设定一个候选元素,初值为-1,这样当没有查到时,直接返回-1
int candidates = -1;
while(i<=j){
int m = (i+j)>>>1;
if(target<a[m]){
j=m-1;
}else if (a[m]<target){
i=m+1;
}else{
//当找到元素时,把下标赋值给候选元素,因为要查找最左边元素,就缩小j的范围,看在这个值的左边还有没有符合条件的元素值
candidates = m;
j=m-1;
}
}
return candidates;
}
由上述代码可以看到,改动并不大,接下来写一个测试用例来测试代码是否可行。
@Test
public void testLeftmost(){
int[] a = {1,2,4,4,4,5,6,7};
assertEquals(0,Leftmost(a,1));
assertEquals(1,Leftmost(a,2));
assertEquals(2,Leftmost(a,4));
assertEquals(5,Leftmost(a,5));
assertEquals(6,Leftmost(a,6));
assertEquals(7,Leftmost(a,7));
assertEquals(-1,Leftmost(a,0));
assertEquals(-1,Leftmost(a,3));
assertEquals(-1,Leftmost(a,8));
}
运行代码后可以看到代码时正确的,那有找最左边的元素,与之对应就有找最右边的元素,那此时只需要把找到符合条件元素时代码从j=m-1改为i=m-1,这样就可以找到最右边的元素。
上述代码虽然能找到最左或最右边的数据元素位置,但如果没找到的话,直接返回-1,-1这个值没有什么实际意义,那对代码进行修改,使得代码在查找失败时,返回一个有意义的数据。
public static int Leftmost(int [] a,int target){
int i = 0,j=a.length-1;
while(i<=j){
int m = (i+j)>>>1;
if(target<=a[m]){
j=m-1;
}else if(a[m]<target){
i=m+1;
}
}
return i;
}
进行上述修改后,在查找失败时,返回的i值就是大于target的最靠左的索引,同理,对最右查找也进行类似修改。
public static int Rightmost(int [] a,int target){
int i = 0,j=a.length-1;
while(i<=j){
int m = (i+j)>>>1;
if(target<a[m]){
j=m-1;
}else if(a[m]<=target){
i=m+1;
}
}
return i-1;
}
此时代码进行修改后,在查找失败时,返回的值就是小于target的最靠右索引。
力扣真题演练
学完上面的各种二分查找,接下来做三道力扣题来实战一下。
第一题
这道题就是直接用简单的二分查找,三种版本都可以用。
//二分查找基础版
class Solution {
public int search(int[] nums, int target) {
int i = 0;
int j = nums.length-1;
while(i<=j){
int m = (i+j)>>>1;
if(target<nums[m]){
j=m-1;
}else if(nums[m]<target){
i=m+1;
}else{
return m;
}
}
return -1;
}
}
//二分查找改动版
class Solution {
public int search(int[] nums, int target) {
int i = 0;
int j = nums.length;
while(i<j){
int m = (i+j)>>>1;
if(target<nums[m]){
j=m;
}else if(nums[m]<target){
i=m+1;
}else{
return m;
}
}
return -1;
}
}
//二分查找平衡板
class Solution {
public int search(int[] nums, int target) {
int i = 0;
int j = nums.length;
while(j-i>1){
int m = (i+j)>>>1;
if(target<nums[m]){
j=m;
}else{
i=m;
}
}
if(target==nums[i]){
return i;
}else{
return -1;
}
}
}
第二题
//直接使用基础版的二分查找,只不过把返回值切换为i,即插入点
class Solution {
public int searchInsert(int[] nums, int target) {
int i = 0;
int j = nums.length-1;
while(i<=j){
int m = (i+j)>>>1;
if(target<nums[m]){
j=m-1;
}else if(nums[m]<target){
i=m+1;
}else{
return m;
}
}
return i;
}
}
第三题
class Solution {
public int[] searchRange(int[] a, int target) {
if (left(a,target)==-1){
return new int[] {-1,-1};
}else{
return new int[]{left(a,target),right(a,target)};
}
}
public int left(int[] a,int target){
int i = 0;
int j = a.length-1;
int candidates = -1;
while(i<=j){
int m = (i+j)>>>1;
if(target<a[m]){
j=m-1;
}else if(a[m]<target){
i=m+1;
}
else{
candidates=m;
j=m-1;
}
}
return candidates;
}
public int right(int[] a,int target){
int i = 0;
int j = a.length-1;
int candidates = -1;
while(i<=j){
int m = (i+j)>>>1;
if(target<a[m]){
j=m-1;
}else if(a[m]<target){
i=m+1;
}else{
candidates=m;
i=m+1;
}
}
return candidates;
}
}