数据结构与算法之二分法
本文章将以图文的方式详细的讲解算法基础之二分法查找数据
二分法在数组中查找数据
查找前提:
要进行查找内容的数组必须是有序的,所以在查找数据的之前可以对你的数组进行排序,排序也会涉及到很多的算法,我们后面再讲。
算法描述:
需求分析:
假设我们现在有一个升序的数组定义如下
int []array = {7,9,16,17,19,45,67};
现在我们将要在这个数组中查找19这个数,
找到返回数组下标
找不到返回-1
查找机制主要有以下几步:
- 定义两个指针分别指向数组的首尾,记为i和j,在此数组中i和j的值定义为数组的下标分别为0和6。
- 当首指针大于尾指针的时候,意味着首指针与尾指针相遇,整个数组查找结束。
- 对于首指针和尾指针进行二分并向下取整操作,得到中间定义的指针m,这时m的值为3。
- 当m指向的值大于查找的值时说明查找值在m的左边,则j =m-1继续此过程,相应的,如果m指向的值小于查找的值时,说明查找值在m的右边,则i = m+1,重复此过程,直到找到想要的值坐标。
- 在本数组中m指向的值为17,小于我们要查找的值,则值在m的右面,这个时候让i 的指针值等于 m+1 = 4,继续二分,得到n的指针值为4,这时可以判断n的指针值就是要查找数值的数组下标值。
算法图解:
JAVA代码实现:
/**
* 利用二分法在数组中查询数据
* @param array 要查询数据的数组,前提是有序的升序的数组。
* @param value 要查找的数据值
*/
public static int findValue(int[] array,int value){
int i = 0;
int j = array.length - 1;
while (i <= j){
int m = (i+j)/2;//int数据类型会自动舍弃小数部分,满足我们的向下取整。
if (array[m] > value){ //查找数据在m的左边。
j = m-1;
}else if (array[m] < value){//查找数据在m的右边。
i = m+1;
}else {
return m;
}
}return -1;
}
}
这段代码包含以下问题:
1.对于搜索范围的确定:
i
,
j
i,j
i,j 对应着搜索区间
[
0
,
a
.
l
e
n
g
t
h
−
1
]
[0,a.length-1]
[0,a.length−1](注意是闭合的区间),
i
<
=
j
i<=j
i<=j 意味着搜索区间内还有未比较的元素,
i
,
j
i,j
i,j 指向的元素也可能是比较的目标
● 思考:如果不加
i
=
=
j
i==j
i==j 行不行?
● 回答:不行,因为这意味着
i
,
j
i,j
i,j 指向的元素会漏过比较
●
m
m
m 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
● 如果某次未找到,那么缩小后的区间内不包含
m
m
m
2.对于二分的方法(i+j)/2的思考:
当我们定义数组的右边界为整数的最大值-1的时候:
public static void main(String[] args) {
int []arrary = {1,2,6,7,8,10};
int i = 0;
int j = Integer.MAX_VALUE - 1;
int m = (i+j)/2;
i = m + 1;
m = (i+j)/2;
System.out.println(i);
System.out.println(j);
System.out.println(m);
}
输出结果:
1073741824
2147483646
-536870913
为什么两个正数相加除2会出现负数呢
因为在JAVA语言中使用二进制表示数字的时候默认将最高位看成是符号位也就造成了正数相加出现负数的原因。
那么,如何改进代码呢。
代码改进
public static int findValue(int[] array,int value){
int i = 0;
int j = array.length; //j在这里只作为一个边界,指向一个不存在的元素,所以二分查找时j=m。
while (i < j){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] > value){
j = m;
}else if (array[m] < value){
i = m+1;
}else {
return m;
}
}return -1;
}
解析:
i
,
j
i,j
i,j 对应着搜索区间
[
0
,
a
.
l
e
n
g
t
h
)
[0,a.length)
[0,a.length)(注意是左闭右开的区间),
i
<
j
i<j
i<j 意味着搜索区间内还有未比较的元素,
j
j
j 指向的一定不是查找目标
● 思考:为啥这次不加
i
=
=
j
i==j
i==j 的条件了?
● 回答:这回
j
j
j 指向的不是查找目标,如果还加
i
=
=
j
i==j
i==j 条件,就意味着
j
j
j 指向的还会再次比较,找不到时,会死循环。
● 如果某次要缩小右边界,那么
j
=
m
j=m
j=m,因为此时的
m
m
m 已经不是查找目标了。
二分查找简化循环流程版本
public static int findValue3(int[] array,int value){
int i = 0;
int j = array.length;
while (j - i > 1 ){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] > value){//i和j从两端向内逼近,只要数组中存在想要的数据那么i总会找到这个值并与它同化
j = m;
}else {
i=m;
}
}return (array[i] == value) ? i : -1 ;//判断最后的i值是否就是自己想要找的数据的索引。
}
解析:在这个版本的二分法中其实仍然做了三次不一样的判断,但是循环流程变的简洁。
二分查找JAVA内置方法
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.
}
解析:
最后没查找到数据时返回的值为要插入的索引位置+1的负数。加一的目的是为了防止在插入位置是0的时候无法区分。
对于没有查找到的数据进行插入操作:
public static void main(String[] args) {
int[] a = {2,4,6,8};
int value = Arrays.binarySearch(a,1);
if(value < 0){//判断数据是否存在,不存在则插入
int insertValue = Math.abs(value+1);//取绝对值
int[] b = new int[5]; //定义新数组用来插入数据
System.arraycopy(a,0,b,0,insertValue);//拷贝插入点之前的数据
b[insertValue] = 1;//将数据插入到新数组
System.arraycopy(a,insertValue,b,insertValue +1,a.length - insertValue);//将插入点之后的数据进行拷贝
System.out.println(Arrays.toString(b));//打印新数组
}}
分析:
二分查找的时间复杂度
● int i = 0, j = a.length - 1 各执行 1 次
● i <= j 比较
f
l
o
o
r
(
log
2
(
n
)
+
1
)
floor(\log_{2}(n)+1)
floor(log2(n)+1) 再加 1 次
● (i + j) >>> 1 计算
f
l
o
o
r
(
log
2
(
n
)
+
1
)
floor(\log_{2}(n)+1)
floor(log2(n)+1) 次
● 接下来 if() else if() else 会执行
3
∗
f
l
o
o
r
(
log
2
(
n
)
+
1
)
3* floor(\log_{2}(n)+1)
3∗floor(log2(n)+1) 次,分别为
● if 比较
● else if 比较
● else if 比较成立后的赋值语句
● return -1 ,执行一次
结果:
● 总执行时间为
(
2
+
(
1
+
3
)
+
3
+
3
∗
3
+
1
)
∗
t
=
19
t
(2 + (1+3) + 3 + 3 * 3 +1)*t = 19t
(2+(1+3)+3+3∗3+1)∗t=19t
● 更一般地公式为
(
4
+
5
∗
f
l
o
o
r
(
log
2
(
n
)
+
1
)
)
∗
t
(4 + 5 * floor(\log_{2}(n)+1))*t
(4+5∗floor(log2(n)+1))∗t
所以二分算法的时间复杂度为:O(log(n)).
二分查找的空间复杂度
因为随着数据规模的增大,二分查找法并没有额外的空间产生,那么我们可以判断二分查找法的空间复杂度为O(1)。
二分法的扩展
当要查找的数组中包含我们多个想要查找的元素的时候,普通的二分法查找通常会返回他所扫描到的第一个数据的索引,那么如何改进我们的二分法,使得我们所查找的数组为数组的第一数据,或者是数组的最后一个数据。
返回最左边查找到数据的索引
代码:
public static int findValue(int[] array,int value){
int i = 0;
int j = array.length;
int find = -1;//定义一个变量用来标记我们所找到数据的索引
while (i < j){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] > value){
j = m;
}else if (array[m] < value){
i = m+1;
}else {
find = m;
j = m;//当索引值相同时,使用find来标记我们找到的第一个索引值,然后让我们的扫描范围向左边收缩,也就是m,
//在左边的范围内如果再找到数据,说明当前数据索引并不是最左边的索引,应该将再次找到的索引值与当前的索引值进行替换
}
}return find;//当没有找到数据时,直接返回初始值-1,找到数据时则返回最左边的索引。
}
返回最右边查找到数据的索引
代码:
public static int findValue2(int[] array,int value){
int i = 0;
int j = array.length;
int find = -1;//定义一个变量用来标记我们所找到数据的索引
while (i < j){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] > value){
j = m;
}else if (array[m] < value){
i = m+1;
}else {
find = m;
i = m+1;//当索引值相同时,使用find来标记我们找到的第一个索引值,然后让我们的扫描范围向右边收缩,也就是i+1,
//在右边的范围内如果再找到数据,说明当前数据索引并不是最右边的索引,应该将再次找到的索引值与当前的索引值进行替换
}
}return find;//当没有找到数据时,直接返回初始值-1,找到数据时则返回最右边的索引。
}
在上述的式子中返回值-1对于我们来说并没有什么用,它存在的意义就是告诉我们要查找的数据不存在,针对返回值我们再次对二分法对于重复查询的代码进行优化。
查询存在重复数据的最左边数据的索引
public static int findValue4(int[] array,int value){
int i = 0;
int j = array.length;
while (i < j){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] >= value){
j = m ;
}else {
i = m+1;
}
}return (array[i] == value) ? i : -i;
}
查询存在重复数据的最左边数据的索引
public static int findValue4(int[] array,int value){
int i = 0;
int j = array.length;
while (i < j){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] <= value){
i = m ;
}else {
j = m-1;
}
}return (array[j] == value) ? j : -j;
}
对于最后的i值代表着两种含义:第一种是数据找到的时候返回他的索引值,如果没有找到的时候则返回他应该按顺序插入数组的索引值得负数。
查询存在重复数据的最右边数据的索引
public static int findValue5(int[] array,int value){
int i = 0;
int j = array.length;
while (i < j){
int m = (i + j) >>>1;//>>>右移运算符,将得到的二进制数进行右移,可以达到除2取整的效果。
if (array[m] > value){
j = m ;
}else {
i = m+1;
}
}return (array[i] == value) ? i-1 : -(i);
}
同样,对于最后的i值代表着两种含义:第一种是数据找到的时候返回他的索引值,只不过查询最右边数据索引需要-1.如果没有找到的时候则返回他应该按顺序插入数组的索引值得负数。
递归思想使用二分法
代码示例
public int find(int[] are, int target){
return findTarget(are,target,0,are.length - 1);
}
//递归思想使用二分查找
//定义内部方法,不让外界指定边界
private static int findTarget(int[]are,int target,int i,int j){
if (i > j){//说明整个数组不存在目标值
return -1;
}
int m = (i + j) >>> 1;//取i和j的中间值
if (are[m] < target) {
return findTarget(are,target,m + 1,j);
}else if (are[m] > target){
return findTarget(are,target,i,j - 1);
}else {
return m;
}
}
总结:
二分查找法相比于传统的线性查找法,效率提高了很多,尤其是在体现在大量的数据上,
对于二分法有很多的衍生方法,但是只要掌握了最基础的二分算法,其他的算法都是没有问题的。