分治算法由两部分组成:
分:将问题分解为较小的问题,并递归解决小问题(基本情况除外)。
治:从所有子问题的解中构建原问题的解。
一般认为,在程序中至少含有两个递归的算法就是分治算法,如果只有一个递归函数(例如,快速幂等),那么通常是将原问题转化为更简单的问题来解决,例如求阶乘的递归写法:只是将n的阶乘转化成了n乘(n-1)的阶乘,并没有将原问题进行分解。
在分治算法中,子问题通常是不相交的,如果子问题相交,例如斐波那契数列的递归写法,实际上并没有将问题真正分解(因为,,可以看到在递归中出现了重复的计算,也就是子问题是相交的),这样就会使算法的效率变低,事实上使用递归算法的斐波那契数列求解在的情况下效率就已经非常低了。所以在使用分治算法时,子问题之间尽量不要有交集。
分治算法一般由两个递归函数(递归的解决子问题)以及一些附加的工作(用来处理两个子问题的解并得到最终解)组成。如果处理基本情况的语句时间复杂度为,那么分治算法花费的时间就是:
由主定理可知,这个表达式的最终结果为(也就是时间复杂度)。所以在一般情况下 ,分治算法解决问题的时间复杂度就为。
下面以两个例子来说明分治算法的思想:
有一个平面点集(如下图),我们假设点集是按照横坐标排好序的(就算对点集进行排序,最坏的时间界也为,这并不会影响分治算法的最终时间量级)。如果,,它们之间的欧几里得距离为。我们需要找到一对点,它们之间的距离是所有点对距离的最小值,如果,那么这个距离就为0.
如果点集中有N个点,那么总共就有个距离,如果使用穷举法来检查所有距离的话,算法的时间复杂度就为,当N较小时,这个量级是可以接受的。但如果使用分治算法,那么时间复杂度就为。
据分治算法的思想,我们需要将问题化为两部分。由于点集已经根据横坐标排序,所以我们在横坐标的中点作一条垂线,这样就将点集划分为与。最短距离的出现实际上就只有三种情况:出现在;出现在;一个点在,一个点在。它们的最短距离分别为:。出现在左右两部分的情况可以递归的解决,而出现在中间的距离就是本问题的基本情况,需要我们使用的时间去解决,最后找到三者中的最小值,就是整个问题的解。
我们首先递归的求出,并找出它们之间的小者。然后从点集的分界线,向左向右分别找出距离为的区域,那么距离最小的两个点一定落在这个带状区域间:
如果点集是均匀分布的,那么在数学上可以证明,落在带状区域间的点的个数平均就有个,这个时候对带状区域内的点使用穷举的方法来求得最小距离,所花费的时间也就是。但如果全部的点都落在这个带状区域内,花费的时间就变成了。所以需要对这个算法进行改进。
一个可行的方法是,在每次排序时对带状区域内的点按照纵坐标进行排序,然后依次求得每个点与其下方距离为以内的点的最短距离(因为上方的点在之前已经考察过了),如果发现与()之间的差距大于,那么就可以继续处理第个点,而不需要再考察第个点。
对于任意节点P,实际上要计算的最多距离只有7个。由于在的正方形区域内,每两个点的距离至少为(因为两个正方形区域要么位于左半部分,要么位于右半部分,而是这两个区域中的最短距离),所以一个正方形内最多会有四个点,分别位于四个顶点,两个正方形就有8个点(如下图,重合的点并不一定就是同一个点),其中一个点是P本身,这样即使在最坏情况下,求得花费的时间也为。
但在每一次求时,如果都对纵坐标进行排序,那么最后算法的时间复杂度就为,因为排序总共进行了次,但这个时间复杂度比起来说也是优的。我们可以使用两个表,一个存放横坐标排序的点集,一个存放纵坐标排序的点集,只需要在算法进行前进行两次时间的处理即可,这样算法最后的时间也就是,到此就完成了分治算法求解最近距离的问题。
分治算法的另一个例子是选择问题,即找到N个元素中第k大(小)的元素。
选择问题的思路与快速排序类似,首先选择一个枢纽元(pivot),将小于枢纽元的元素放在它的左边,大于枢纽元的放在它的右边,如果,那么就返回枢纽元的值,如果,就对递归的调用查找函数,找到中第k大(小)元素,否则就对递归的调用查找函数,找到中第大(小)元素。这个方法的问题在于枢纽元的选择,如果枢纽元选择的不够好,很容易造成的最坏时间界。对于枢纽元,有一个保证子问题最多是原问题大小的百分之七十的方法,称为五分化中项的中项(median-of-median-of-five partition),这个算法是在中项中寻找中项。
五分化中项的中项:
1.把N个元素分为组,5个元素一组,并略去剩下的元素(最多为4个)
2.找出每一组的中项,得到有个中项的表
3.找到表的中项,并作为枢纽元返回
下面证明这个算法找到的枢纽元可以保证子问题最多为原问题大小的百分之七十。
我们假设N可以被5整除,并且商为奇数。假设N为10k+5的形式,如果N=45,那么使用五分化中项的中项得到的结果如下图:
每一组的中项在图中已经标出,为中项的中项,由于N/5为奇数,所以大于的中项和小于的中项的数量是一样的,并且由于分别是该组的中项,所以在所在组中会有两个元素大于它,也就大于枢纽元,记为;在所在组中,有两个元素小于它,所以也就小于枢纽元,记为,那么就会有10个以及10个,已知的大于和小于枢纽元的元素就各为14个,那么在最坏情况下,子问题最大就占
现在推广到一般情况:,那么进行上述的分析后会发现,有大于和小于枢纽元的元素都为个,那么最坏的情况就为。
如果为偶数,那么依然可以进行同样的分析,但是最终的结果不会改变。
所以在使用五分化中项的中项得到的枢纽元来实现算法时,其两个子问题的大小基本上相当,所以算法的运行时间就为。
目前的问题是,如何找到中项的中项?如果使用排序算法,那么对于规模的数据进行排序算法,时间复杂度显然为,那么分治算法的最终时间界就不再是,而是.所以要花费线性时间找到中项的中项,方法就是对表递归的使用选择算法来选择中项,这个方法得到枢纽元的时间为(应该与主定理有关)。五分化中项的中项并不是一种很实用的方法,但它仍是理论上的一种突破。
快排思想的选择问题代码实现:
- void swap(int* a, int* b) {
- int tmp = *a;
- *a = *b;
- *b = tmp;
- }
-
- int select(int* arr, int left, int right, int k) {
- if (left < right) {//如果左坐标小于右坐标,说明子数组中的值多于两个,还需要进行选择
- int pivot = arr[left];//第一个元素为枢纽元
- int i = left, j = right + 1;
- for (;;) {
- while (arr[++i] < pivot);//左侧的元素要小于枢纽元,所以需要找到左边大于枢纽元的元素与右边小于枢纽元的元素交换
- while (arr[--j] > pivot);
- if (i < j) {//如果左<右,说明这两个值需要交换位置
- swap(&arr[i], &arr[j]);
- }
- else {//否则,右指针指向的地方就是枢纽元正确的位置
- swap(&arr[j], &arr[left]);
- break;
- }
- }
- if (j == k - 1) {//如果枢纽元的下标==k-1,数组下标从0开始,则枢纽元就是要找的元素
- return pivot;
- }
- else if (j < k - 1) {//如果下标<k-1,说明需要向右找
- return select(arr, j + 1, right, k);
- }
- else {//否则向左找
- return select(arr, left, j - 1, k);
- }
- }
- else {//如果左==右,说明这个值就是要找的值,如果左>右,说明不存在第k大(小)值
- if (left == right) {
- return arr[left];
- }
- return -1;
- }
- }
测试代码:
- #define MAX 10//数组元素大小
-
- void test() {
- int arr[MAX] = { 0 };
- srand((unsigned)time(NULL));
- for (int i = 0; i < MAX; i++) {
- arr[i] = rand() % 100;
- }
-
- printf("%d\n", select(arr, 0, MAX - 1, 1));
-
- return;
- }
选择问题是分治算法的原因在于:在选择问题的实际代码中,好像只使用了一个递归,但实际上是因为我们已经人为的进行了处理,就不用再使用计算机去进行递归。对于第k大(小)的元素,实际上 它可能存在于枢纽元的左侧、右侧,或是枢纽元本身,这实际上与之前的一些分治算法的思想一致。但是利用枢纽元在数组中的正确位置,我们就可以人为的判断出这个元素到底在这三个位置中的哪一个,但实际上选择问题还是一个分治算法。