前言
分治策略作为算法中的一个基础策略之一(例如还有动态规划,贪心算法,摊还分析等等),是我们进入算法世界的第一扇门,所以学的好与坏,也关系到将来。这里为了让我更好的学习分治策略,特意写了这几篇,以此提高自己对分治策略的理解。
现实中,许多有用的算法在结构上是递归的:为了解决一个给定的问题,算法一次或多次递归地调用自身以解决紧密相关的若干子问题。这些算法典型地遵循分治法的思想:将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
分治策略的基本步骤
在分治策略中,我们递归求解一个问题,在每层递归调用中我们都遵循如下三个步骤
- 分解步骤:将问题划分为一些子问题,子问题的形式与原问题一样,只是规模更小。
- 解决步骤:递归求解出子问题。如果子问题的规模足够小,则停止递归,直接求解。
- 合并步骤:将子问题的解组合成原问题的解。
简述递归
递归与分治方法是紧密相关的。所以要想明白分治方法,首先要明白什么是递归。在维基百科中是这样解释的
递归(英语:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。递归一词还较常用于描述以自相似方法重复事物的过程。例如,当两面镜子相互之间近似平行时,镜中嵌套的图像是以无限递归的形式出现的。也可以理解为自我复制的过程。
C++允许函数的递归调用,例如,经典的阶乘问题:
int factorial(int n){
if(n == 1)
return 1;
else
return n * factorial(n - 1); // 调用函数自身
}
有关C++对递归的应用这里不再赘述,如有不懂,请自行查阅资料。
思路
分治策略很容易理解,无非就是分解问题,解决问题,合并问题。但实际操作起来,并不想象中那么简单。其中我认为最难的地方就在于分解问题(如何找到一个子问题,而该子问题又是可求解的),一般来说,我们对问题的分析,无非就是从问题出发,寻找其中的规律。其中一个经典的例子:归并排序算法,就是完全遵循分治模式的一种排序算法。
在归并排序中,我们的三个步骤为:
1. 分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列
2. 解决:使用归并排序递归地排序两个子序列
3. 合并:合并两个已排序的子序列以产生已排序的答案
这说的还是有些模糊,我们可以这样想问题,我们将一个数组A分成两部分B和C,如果这B和C都是已经排列好的数组,我们再合并这两个以排列的B和C,得出最终整个排列好的数组A。那么问题就来了!一般来说我们要排列的数组都是乱序的,那么如何的得到这两个已排序好的数组呢?此时我们已经将原来大的数组分为两部分了,进一步思考,我们拿出其中一个已排列好的数组(例如拿出B)进行思考,如果B也是由两个一排列好的数组C和D组成,那么我们就可以得出B是一个已排序好的数组
……
继续往下划分,最后,我们就能得出两个(或者一个)元素进行排序。分解完毕后,这时候我们只要一步一步从下往上合并子数组,那么最终我们将得出排序好的数组A。
在上述的讲解中,我们很容易找到对应的三个步骤:
- 分解:将大数组分为两个小数组
- 解决:分别排序两个小数组
- 合并:将两个小数组组合成一个大数组
此时问题就好理解多了。
注:如果对归并排序算法不懂的可以看看我的这篇博客:九大排序算法,里面包含有实现的代码。
最大子数组之和问题
问题
一个有N个整数元素的一维数组(A[0],A[1],…,A[n-2],A[n-1]),这个数组当然还有很多子数组,呢么子数组之和的最大值是什么呢?
这个问题很常见,常见的还有求解最少费用问题。
解法一
暴力破解法,只需要两个循环,就能找到子数组的最大值。
代码如下:
int find_max(const vector<int>& vec)
{
int size = vec.size();
int sum_max = -INT_MAX;
int sum_tmp = 0;
for(int i = 0; i < size; ++i) {
sum_tmp = 0;
for(int j = i; j < size; ++j) {
sum_tmp += vec[j];
if(sum_tmp > sum_max) {
sum_max = sum_tmp;
}
}
}
return sum_max;
}
解法二
使用分治算法往往意味着我们要将子数组划分为两个规模尽量相等的子数组。也就是说我们要找到数组的中间位置mid。然后考虑两个子数组A[low,mid]和A[mid,high],那么最大子数组的可能位置就是下面三种情况中的一种:
- 完全位于数组A[low,mid]中,因此
- 完全位于数组A[mid,high]中,因此
- 跨越数组A中点,因此
此时我们就很容易的在线性时间内计算出跨越终点的最大子数组。然后再通过不断的递归求解子数组,我们就能找到每个子数组中的最大子数组和,合并之后,就能得出我们最后的结果。
代码如下
int find_max_crossing_subarray(int* array, int begin, int mid, int end)
{
int left_max = -INT_MAX;
int left_temp = 0;
for(int i = mid; i >= begin; --i) { // 注意这里是从中间往前叠加
left_temp += array[i];
if(left_temp > left_max) {
left_max = left_temp;
}
}
int right_max = -INT_MAX;
int right_temp = 0;
for(int i = mid + 1; i <= end; ++i) {
right_temp += array[i];
if(right_temp > right_max) {
right_max = right_temp;
}
}
return left_max + right_max;
}
int find_maximum_subarray(int* array, int begin, int end)
{
if(begin == end) {
return array[begin];
}
int mid = (begin + end) / 2;
int left_max, crossing_max, right_max;
left_max = find_maximum_subarray(array, begin, mid);
right_max = find_maximum_subarray(array, mid + 1, end);
crossing_max = find_max_crossing_subarray(array, begin, mid, end);
if(left_max >= right_max && left_max >= crossing_max) {
return left_max;
}
else if(right_max >= left_max && right_max >= crossing_max) {
return right_max;
}
else {
return crossing_max;
}
}
现在总结下分治策略的三个步骤:
- 分解:将数组(子数组)分为两个等大小的子数组
- 解决:求解两个子数组中的最大子数组的情况
- 合并:将两个子数组的跨越中点的最大子数组进行合并
利用分治策略实现的最大子数组之和的算法时间复杂度为:
当然这并不是该问题的最好算法,该问题还存在一个线性时间的算法,使用的是动态规划的思想,这不是本节讨论的问题。该代码在本人的github上:最大字数组之和,有兴趣的可以去看看。
参考
1.《算法导论原书第3版》第四章 分治策略