算法设计(二):分治

本节课是小密圈《进击的Java新人》第十一周的第四课。

其实贪心还有很多内容,我们就先讲到这里,后面还会遇到其他的更多的问题,到时候我们会再更详细地讲解。昨天的题目能自己做出来,这种程度的掌握目前就已经足够了。

我们今天看第二种重要的算法设计思想:分治。

算法的第一节课,我就举了个图书馆管理员大妈的例子来说明分治算法。这里我们来看一下分治的具体定义是什么。

分治算法,通过把问题分解为更小的子问题,先解决小问题,再把小问题的解合并起来的一种方法。通常,如果子问题的规模仍然很大,可以继续拆解成更小的任务,更进一步,实践中,大任务往往与子任务有相同的结构,以便于递归地进行任务拆解。

以具体的例子来说明。现在一共9枚硬币,已知其中有一枚是假的,它比其他8枚都要轻。现在有一个天平,只称两次,能找到这枚硬币吗?

我们倒着想,假如现在有两枚,称一次,这很简单,天平高的那一边的是假币,如果有三枚呢?那就有两种情况,如果天平平衡,那么未称的那枚是假币,如果天平不平衡,高的那边是假币。

再往上一层,如果有9枚硬币,我们可以把它分为三堆,一堆3个,然后任选两堆放在天平上称,如果天平平衡,那么假币一定在没称的那一堆里,如果天平不平衡,那么假币就在天平高起的那一堆里。

这个例子就是一个很好的分治的例子,我们先把规模为9的大问题,分拆为规模为3的小问题,而且这个小问题与大问题保持了相同的性质。然后再去解决规模为3的小问题,就得到最终的答案了。

解题步骤

分治法有三个解题步骤,无论是简单的分治还是复杂的,都逃不开这个标准步骤。它们是:

  1. 拆解子任务
  2. 解决子任务
  3. 合并子任务的解

分治算法应用在方方面面,一般来说,解决子任务这一步骤相对容易,因为当子任务拆到规模一定小的情况下,解就会显得非常简单。分治算法的应用难点一般都会出现在拆解子任务与合并解的步骤上。

给定一堆平面上的坐标,找出平面上的距离最近的点。暴力地一对对地去查找,这个办法的时间复杂度是O(n^2),我们能不能使用分治来加速呢?实际上是可以的。但是这个问题的子问题分拆就没有那么简单了。

我们尝试着拆分一下,把这个平面一分为二,那么距离最小的点可能出现在左边,也可能出现在右边。假如,我们已经把左边最近的两个点找出来了,又把右边最近的两个点找出来了。那么合并这两个子问题的时候,只需要再比较这个子问题的解,看是哪一边的点更近。

但是等等,我们漏了一点东西。

看这幅图,假如,左边那个平面里,找到的最近的一对点是1和2,右边找到的最近的一对是3和4。解合并的时候,如果是检查1,2和3,4之间的距离谁更小是不行的,因为明显这个平面上,距离最近的一对点是2和3。也就是说在进行平面点的拆分的时候时候,就已经把2和3这一对最近的点拆开了,那么我们在合并的时候就要考虑这个问题。这个子问题的解合并是一个非常难的事情,这节课就不展开讲了。(以后还有没有机会再讲,看情况吧)

总之,使用分治算法解题的时候,如何正确地拆解问题,合并问题是设计算法的核心所在。

二分法

所有的分治算法中,最常用的,还是二分法。二分通常可以使得问题的规模减半。

我们最常见的一个游戏,猜数字。我选定一个数字,在0到100之间,你来猜,如果猜得比选定数字大,我会提示你猜高了,如果比选定数字小,我会提示你猜低了。那么最多需要几次就一定能猜对呢?

我们常用的策略就是二分,先报50,如果主持人说低了,那我就知道,这个数字一定位于50,100这个区间,那我再报75。这样每次都能把待选区减少一半。所以最多需要7次就可以找到这个数字。如果是1到1000,只需要10次(从这里也可以看到O(log n)是一种时间复杂度很好的算法了,毕竟一百万地规模也只要20次。)

我们上面举的例子就是二分法,在一个已经有序的数组里进行查找,我们通常称之为二分查找(Binary Search)。

JDK中有二分查找的完整实现,我们来看一下:

    //java.utils.Arrays
    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.
    }

很简单,就是比较目标值与中间值的大小,如果目标值大,就找从mid到high的那一半,如果比目标值小,就到从low到mid的那一半。

二分法只能应用于数组有序的情况,如果数组无序,二分查找就不能起作用了。这也体现出来为什么排序会如此重要——排好序的数据总是更容易处理。

作业:

陪你的妹子多玩几次猜数字的游戏,脑子里把二分解决问题的过程多想几次。

上一节课:堆排序和PriorityQueue源码解析

下一节课:强大的二分法

目录:课程目录

编辑于 2017-03-21

文章被以下专栏收录