传说中的二分法查找

采用二分法查找数据元素并不是一个陌生的行为,而可能只是一个陌生的词汇而已。

二分法说的是从一个有序序列中查找某个元素时,先和这个序列的中间值比较,如果中间值小,则再从中间位置到末尾的一半序列中查找,仍然先和中间值比较,以此类推。如果中间值大,则再从开始位置到中间位置的一半序列中查找,仍然先和中间值比较,以此类推。直到最后找到目标值所在的位置,或者最后确定该序列并不包含该目标值。

假如有20个写着不同数字的号码牌,按从小到大或者从大到小的顺序排列好,然后背面朝上盖好,找一个人来,告诉他这20个号码牌已经按顺序排列好了,要求以最快的方法从里面找一个数(可以不是号码牌上的数)。他将很轻松地搞定这件事,而他采用的方法,自然而然就是二分法。很显然,我们的大脑很擅长处理这种情况。

但是,把我们的大脑处理这件事的思维过程用代码的形式表示出来并不是一件简单的事。传说中的二分法查找

 

我昨天只是在笔记本上画画草图,想了一个晚上没想明白(中间包括以为想明白了立刻发现其实是有问题的)。今天又想了一上午,勉强写出来了一个很麻烦的版本。其实昨天晚上没想出来以及今天上午写出来的麻烦版本,基本上都是纠结于中间值小了向右查找以后发现中间值又大了变成向左查找,以及更复杂的左右来回查找的时候,具体这些变量该怎么存储的问题。即使在中间值一直小于目标值的情况下,也需要一个变量temp来存储当前中间值的下标跳跃到下一个中间值下标之前,也就是当前下标的位置,以便发现跳到下一个中间值以后发现中间值大于目标值的时候,可以回到左边的一半序列来查找。而此时,左边的一半的开始位置的下标已经不能以0计算了,而必须是目标值变换之间的下标,即上一次的下标。也正是这个让我头脑混乱,因为我会想,下一次中间值和目标值比较的结果又是两种可能,那么我是否还要保存上上次的下标位置,以及上次的下标位置和现在的中间值的下标其实也有两种位置关系,或者在左边或者在右边。。。

上午的代码保存在另外一台电脑上,所以就不展示了。晚上回来后又仔细想了想,理顺了思路,改进了代码。贴在下面,和  Arrays 类提供的源码做个比较,看一下普通人和大神的思维区别。

直接在main方法里面写了,没有封装成方法。前半部分主要是随机创建一个类型为int长度为10的数组,数组元素的取值范围是从0到20。主要看变量定义和循环的部分。

int target = 10;
int temp = 0;
int index = (int)(arrays.length/2);

这里定义了3个变量,target指的是要查找的目标值,temp用来保存中间值下标变化之前的位置,初始值为0,index表示当前中间值的下标,初始值是数组的中间位置,这里考虑到奇数偶数,加了强制数据类型转型,最后看了源码才发现其实没有必要,因为计算的结果赋值给了一个int类型的变量,会自动省略小数部分。另外由于我只想判断目标值在数组中是否存在,所以虽然变量名起的是index(索引,下标),但其实我简单地使用了数组长度除以2代表中间值。当然这并不影响查找到目标值的结果,但是从返回下标的角度来说,还是应该尽量直接用下标值计算index。

while(index != temp) {

解决这样的问题离不开循环,这个是从一开始就可以预见到的,重要的是循环结束的条件以及循环体。其实昨天晚上以及今天上午的时候把问题想复杂了。去想中间值和目标值比较以后向左还是向右甚至向左或向右以后下一次又向左还是向右这种思维本身就是有问题的。考虑问题要在一次循环的范围内,在当前这次循环中,有三种可能,第一,中间值和目标值一致,退出整个循环。第二,中间值小于目标值,调整一下相关变量的值。第三,中间值大于目标值,调整一下相关变量的值。至于中间值小于或大于目标值之后,下一次向左还是向右,这是下一次循环要做的事。以人脑代替while循环去想接下来的事儿,自然会陷入混乱的旋涡。

关于怎么查找目标值,最后的想法是,用变量temp保存中间值当前的位置,当前中间值的位置index可以根据判断结果随便向左或者向右跳。正是由于二分法的特殊性——不停地除以对象序列(每次折半后的序列)的一半,导致出现一种类似于镜子的效果,而且这种效果出现在任何一次循环中,出现在任何一个坐标位置上。也就是说,每次index跳转后判断中间值和目标值的大小,接下来不管是向左跳到下一个中间值位置还是向右跳到下一个中间值位置,跳转的幅度都是当前index的值和当前temp的值相减求绝对值。比如说,到了某次循环,index从5跳到10,temp从上一次的下标,跳到index跳转前的5,可想而知,index之所以从5调到10,是因为上一次的循环之后,查找的范围序列变成了5到15。下标为10的那个中间值如果比目标值小,去10到15里查找。如果比目标值大,则去5到10里查找。不管是向左还是向右,移动幅度都是index和temp的值相减求绝对值。由于对称性,如果向右,temp就好像位于15那个位置一样,或者说,temp到index的距离等于index到15的距离,所以index要调到的index到15的一半和当前temp到index的一半是一样一样的。

具体如下图:

import java.util.Arrays;
import java.util.Random;

public class TestBinarySearch {
    public static void main(String[] args) {
        int[] arrays = new int[10];
        Random random = new Random();
        for(int i=0; i<arrays.length; i++) {
            arrays[i] = random.nextInt(20);
        }
        //先把随机生成的10个数字显示出来
        System.out.println(Arrays.toString(arrays));
        //对数组进行排序
        Arrays.sort(arrays);
        int target = 10;
        int temp = 0;
        int index = (int)(arrays.length/2);
        while(index != temp) {
            if(arrays[index]==target) {
                System.out.println("the target " + target + " is in the arrays");
                break;
            } else if(arrays[index]<target) {
                int j = index;
                index = index + (int)(Math.abs(temp-index)/2);
                temp = j;
            } else {
                int j = index;
                index = index - (int)(Math.abs(index-temp)/2);
                temp = j;
            }
        }
        if(index==temp) {
            System.out.println("the target " + target + " is not found!");
        }
    }
}

所以结论是,我不用再考虑中间值向左和向右的差异了,循环体内的代码将变得很简洁。

if(arrays[index]==target) {
    System.out.println("the target " +
    target + " is in the arrays");
    break;

这是中间值正好等于目标值的情况,找到的话就跳出 while 循环。

} else if(arrays[index]
    int j = index;
    index = index + (int)(Math.abs(temp-index)/2);
    temp = j;

这是在当前这次循环中(即上次中间值跳到当前的中间值位置以后),中间值如果比目标值小,那么要向右跳转,把当前index的值加上当前index和当前temp的值相减除以2(temp减index也是一样的),就是index要跳转到的地方。因为是向右,所以是加法。temp要始终存储index变化前的位置,而index寻找下一个中间值位置需要借助当前的temp的值,所以先把当前index的值存到临时变量里了。结果是index到了下一个中间值位置,而temp成功地存储了index上一次的位置。
 

} else {
    int j = index;
    index = index - (int)(Math.abs(index-temp)/2);
    temp = j;
}

向左的情况正好反过来,中间值位置向左移动,所以是减法。

这样,如果下一次应该向右,就会去执行向右的代码。再下一次如果向左,就会去执行向左的代码。直到最后temp和index的位置重合了,就证明目标值不在数组内。否则,它就会在某一次循环里被找到,然后通过break跳出循环。

 

Arrays 里面关于二分法查找有很多重载的静态方法,以 int 类型为例看一下。

private static int binarySearch0(int[] a, int fromIndex, int toIndex int key) {
    int low = fromIndex;
    int high = toIndex - 1;
    while (low <= high) {
        int mid = (low + high) >>> 1;
        int 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.
}

源码用到了3个变量,即目标数组(每次折半后的数组)的开始位置,终了位置和中间位置,很巧妙很聪明。
 

int low = fromIndex;
int high = toIndex - 1;
binarySearch0

这个静态方法是用private修饰的,因为它主要是用来被其它含有int类型参数的方法调用的。看了一下,具体传递过来的值是fromIndex = 0 和 toIndex = a.length,所以是把 low 和 high 初始化为原始数组的开始位置和终了位置。

while (low <= high) {
    int mid = (low + high) >>> 1;
    int midVal = a[mid];

它的思路是,每次中间值和目标值比较以后,调整开始位置或者终了位置。如果中间值小于目标值,需要向右继续确认,则开始位置向右调整为当前中间值+1,反之如果中间值大于目标值,需要向左继续确认,则终了位置向左调整为当前中间值-1,终了位置使得 a[low] 到 a[high] 始终代表折半以后的目标数组。就像一个口袋,不断地缩小口儿,直到开始位置等于终了位置。然后循环体内会继续开始位置变大或者终了位置变小,从而导致开始位置大于终了位置,循环就结束了。所以while循环的条件是开始位置小于终了位置。而且,中间值一直等于本次循环中对象数组的开始位置和终了位置的中间值,相当于求和除以2,用到了位运算。。。

if (midVal < key)
    low = mid + 1;
else if (midVal > key)
    high = mid - 1;
else
    return mid; // key found

这里就是比较判断。如果中间值小于目标值,把开始位置向右移动,变成新的对象数组的开始位置。如果中间值大于目标值,则把终了位置向左移动,变成新的对象数组的终了位置。如果相当,则返回下标。

return -(low + 1);  // key not found.

如果返回负数,表示没找到。

源码就是源码,简约而不简单。

普通人思考问题,一般就抓住一点,最多能抓住两点,就像我写的那样,通过中间值的下标和上一次中间值的下标来推演预测以解决问题。而大神则能同时考虑3个元素,通过中间值和目标值的比较结果不断改变开始位置和终了位置来定位目标值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值