分治法(divide and conquer)是算法分析里比较直观和朴素的思想,应用也很广泛。
分治法的思想
分治法的思想是,把一个复杂的问题 P 划分称 k 个子问题,这些子问题相互独立且与原问题相同。递归调用子问题,直到问题规模足够小,可以很容易地求解为止;接着,把小规模的问题的解合并成一个更大规模的问题的解。
可以用下面的伪代码来描述,
divide-and-conquer(P) {
if (|P| <= n0) adhoc(P); // 小规模问题求解
divide P into smaller subinstances P1, P2,...,Pk; // 分解问题成小规模问题
for (i = 1; i <= k; ++ i)
yi = divide-and-conquer(Pi); // 递归解决子问题
return merge(y1, y2,...,yk); // 合并为原问题的解
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
分治法和递归
从分治法的思想可以看出,在求解子问题的时候往往需要用到递归,因此分治法和递归经常一起出现。递归算法的优点是结构清晰,可读性强,一般代码也比较容易写,但是缺点是运行效率很低,无论是计算时间还是占用的存储空间都很大。
一般有下面几种解决的办法,
- 用自定义栈,做了编译器做的事,优化效果不明显。
- 用递推的方法来实现递归函数
- 通过变换,将一些递归转化成尾递归(尾递归是所有子递归出现在递归函数的尾部),迭代求结果
后两种方法虽然范围有限,但是如果可以在问题中这样优化的话,效果会比较好。
分治法的适用条件
看一个问题是否可以用分治法求解,可以考虑这个问题是否符合下面几种条件,
- 小规模时,问题容易求解,这个一般都很容易满足。
- 问题具有 最优子结构 的性质,即可以分解成若干个规模较小的相同问题。
- 子问题的解可以 合并为该问题的解
- 子问题相互独立,即 不包含公共的子问题
满足前三条特征,可以用分治法;如果不具备第三条特征,可以考虑贪心算法和动态规划。如果问题满足第 4 个特征的话,分治法会重复计算导致效率,用动态规划较好。
分治法的应用
二分查找算法
二分查找要解决的问题是,从有序数组 a 中找出特定元素 x 出来。因为是有序,所以可以用考虑分治法,从线性扫描的复杂度 O(n) 优化到 O(logn) 。
有递归和非递归两种实现方法。
大整数的乘法
大整数的乘法,分治成较小规模的子问题后,从四次乘法的优化到三次乘法,可以把复杂度从 O(n2) 优化到 O(nlog3=1.59) ,有较大的改进。
Strassen 矩阵乘法
与大数乘法的例子类似,做分治后想办法减少乘法的次数,从 O(n3) 优化到了 O(nlog7=2.81) 的复杂度。
归并排序
基本思想是,把待排序的数组分成两个子集,分别排序后再合并。递归代码如下,
void merge_sort(int a[], int start, int end) {
if (start < end) {
int mid = (start + end) / 2;
merge_sort(a, start, mid); // 对左边的序列递归排序
merge_sort(a, mid + 1, end); // 对右边的序列递归排序
merge(a, start, end); // 合并子序列
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
快速排序
快排的想法很简单,是想找一个数作为排序的基准(pivot),小的放左边,大的放右边。然后对左边和右边的两个子问题递归排序。递归实现如下,
void quick_sort(int a[], int start, int end) {
if (start < end) { //
int pivot_index = partition(a, start, end); // 挖坑划分
quick_sort(a, start, pivot_index - 1); // 左边的子序列递归排序
quick_sort(a, pivot_index + 1, end); // 右边的子序列递归排序
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
注意快排的划分函数 partition
,用的是挖坑填坑的方法。以第一个元素为基准(pivot),最后返回 pivot 的位置 pivot_index。此时左边的数组元素都比 pivot 小,右边的数组元素都比 pivot 大。