好好学Java:从零基础到项目实战
上QQ阅读APP看书,第一时间看更新

3.4.3 利用二分查找法定位数组元素

古代两大经典问题“鸡兔同笼”和“物不知数”各有巧妙的解法,可见通过代数手段求解颇具技巧,程序代码使用穷举法反而更容易。虽然穷举法能够有效解决问题,但它的缺陷很明显,就是效率太低了。在某些场合,完全可以用其他算法来替代笨拙的穷举法。假设有一串顺序排列的数字,它们从小到大依次排列,然后想要找到特定数字在该队伍中的位置。倘若使用穷举法,就得从队列的第一个数字开始,一个一个比较过去,要是目标数字正好在队伍末尾,穷举法走完一整圈才能找到该数字。

考虑到待查找的数列本身是有序的,将目标数字与数列中的某个数字比较时,如果发现目标数字较大,就无须再跟之前的数字比较,因为序号靠前的数字还不如已比较的那个数字大,还用得着去跟铁定较小的数字比较吗?反之,如果发现目标数字较小,那么无须再拿它跟序号靠后的数字比较,因为那些数字更大。

为了更直观地理解前述的算法思想,来看图3-3所示的数列队伍。

图3-3 待查找的有序数列

由图3-3可见,该数列共有10个数字,且从左到右依次增大。接着准备在该数列中找到65所在的位置,光凭肉眼看很容易发现数字65在第6个,意味着穷举法需要历经6次查找才能找到65,效率显然不高。

现在打算换一种方式,首次查找时,不去比较数列的第一个数字6,而去比较数列的中间数字54,结果发现目标数字65比54要大,表示65不可能在54之前,只可能在54之后。于是第二次查找改为比较后半段数列的中间位置,也就是83,结果发现目标数字65比83小,表示65不可能在83后面,只可能在83前面。那么第3次查找又改为比较后半段数列的前半段,这个范围的数字大于54且小于83,落在该区间的只有两个数字(65和69),继续比较目标数字65与该区间的第一个数字65,发现二者相等,表示成功找到了目标数字的所处位置是第6个。

总而言之,新的查找算法总共只花了3次比较就找到了目标数字65,说明其效率优于花了6次比较的穷举法,详细的查找步骤如图3-4所示。

图3-4 对有序数列折半查找的过程

由于这种算法每次都比较指定范围的中间位置(二分之一处),因此该查找算法名叫“二分查找法”,也叫“折半查找法”。不过为什么首次查找从中间位置开始,而不是从三分之一位置开始,或者从三分之二位置开始呢?这是因为折半查找符合概率学的最佳选择,以中间位置为分割线,目标数字落在前半段的概率是二分之一,落在后半段的概率也是二分之一,说明在这两段找到目标数字的机会是均等的。在机会均等的情况下,二分法的查找开销是各种组合中最小的,好比人民币有10元钞票和5元钞票,却没有3元钞票,也没有7元钞票。

接下来,通过具体代码来演示二分查找法的实现过程。首先构建一个有顺序的整数数组,可以利用循环语句依次生成若干随机数填入数组,再调用数组工具Arrays的sort方法对数组排序。构建随机数数组的代码示例如下(完整代码见本章源码的src\com\control\BinaryChop.java):

        int item=0;  // 随机数变量
        int[] numbers=new int[20];  // 随机数构成的数组
        // 以下生成一个包含随机整数的数组
        loop: for (int i=0; i < numbers.length; i++) {
            item=new Random().nextInt(100);  // 生成一个小于100的随机整数
            for (int j=0; j < i; j++) {  // 遍历数组进行检查,避免塞入重复数字
                if (numbers[j] == item) {  // 若已经存在该随机数,则继续第一层循环,重新生成随机数
                    i--;  // 本次循环做了无用功,取消当前的计数
                    continue loop;  // 直接继续上一级循环
                }
            }
            numbers[i]=item;  // 往数组填入新生成的随机数
        }
        Arrays.sort(numbers);  // 对整数数组排序(默认升序排列)
        for (int seq=0; seq<numbers.length; seq++){  // 遍历并打印数组中的所有数字
            System.out.println("序号="+seq+", 数字="+numbers[seq]);
        }
        System.out.println("最后生成的随机数="+item);

运行以上的随机数组构建代码,观察到如下的输出日志:

序号=0,数字=1

序号=1,数字=5

序号=2,数字=12

序号=3,数字=15

序号=4,数字=17

序号=5,数字=20

序号=6,数字=26

序号=7,数字=38

序号=8,数字=42

序号=9,数字=45

序号=10,数字=48

序号=11,数字=50

序号=12,数字=60

序号=13,数字=70

序号=14,数字=72

序号=15,数字=79

序号=16,数字=84

序号=17,数字=88

序号=18,数字=89

序号=19,数字=95

最后生成的随机数=60

然后希望在随机数组中找到目标数字60,采取二分查找的话,需要声明3个位置变量,分别是本次查找范围的开始位置、本次查找的结束位置、本次查找的中间位置,这3个变量依据含义分别命名为start、end和middle。在每次循环的查找过程中,先计算本次循环的中间位置,接着比较中间数字与目标数字的大小,再根据比较结果调整下次循环的开始位置或结束位置。一旦在第二步的比较操作中发现找到目标数字,就打印查找日志并退出循环。据此可编写如下的二分查找代码(完整代码见本章源码的src\com\control\BinaryChop.java):

        // 下面通过二分查找法确定目标数字排在第几位
        int aim_item=item;  // 最后生成的整数
        System.out.println("准备查找的目标数字="+aim_item);
        int start=0;  // 二分查找的开始位置
        int end=numbers.length - 1;  // 二分查找的结束位置
        int middle=0;  // 开始位置与结束位置之间的中间位置
        for (int count=1, position=-1; start <= end; count++) {
            middle=(start + end) / 2;  // 折半获得中间的位置
            System.out.println("折半查找的中间数字="+numbers[middle]);
            if (numbers[middle] > aim_item) {
                // 该位置的数字比目标数字大,表示目标数字在该位置左边
                end=middle - 1;
            } else if (numbers[middle] < aim_item) {
                // 该位置的数字比目标数字小,表示目标数字在该位置右边
                start=middle + 1;
            } else {  // 找到目标数字,跳出循环
                position=middle;
                System.out.println("查找次数="+count+", 序号位置="+position);
                break;
            }
        }

把上述的查找代码添加到前面的数组构建代码末尾,重新运行修改之后的测试程序,即可观察到程序日志多出了以下的查找信息:

准备查找的目标数字=60

折半查找的中间数字=45

折半查找的中间数字=72

折半查找的中间数字=50

折半查找的中间数字=60

查找次数=4,序号位置=12

由查找日志可知,通过二分查找法找到目标数字只花了4次比较,倘若使用穷举法来查找,同一个目标数字得比较13次才能找到,无疑二分法的执行效率大大高于穷举法。