算法-分治法(Divide-and-Conquer)
分治法简介
分治法是把一个复杂的问题分成两个或多个相同或相似的子问题,再把子问题分成更小的子问题直到最后子问题可以简单地直接求解,原问题的解即子问题的解的合并,这个思想是很多高效算法的基础。
分治法的基本思想:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
分治策略
分治策略:对于一个规模为n的问题,
- 若该问题可以容易的解决(比如规模n较小)则直接解决,
- 否则将其分解为k个规模较小的子问题
这些子问题互相独立且与原问题形式相同,递归地解决这些子问题,然后将各个子问题的解合并得到原问题的解。
如果原问题可以分割成k个子问题,1<k<=n,且这些子问题均可解,并且利用这些子问题的解求出原问题的解,那么分治方法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归经常同时应用在算法设计之中。
分治法使用场景
-
该问题的规模缩小到一定的程度就可以很容易得到解决。
-
该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
-
利用该问题分解出的子问题的解可以合并为该问题的解。
-
该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
-
第一条特征是绝大多数问题可以满足的,问题的复杂性一般是随着问题规模的增加而增加
-
第二条特征是应用分治法的前提。它是大多数问题可以满足的,此特征反映了递归思想的应用
-
第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条,而不具备第三条特征,则可以考虑使用贪心法或者动态规划法
-
第四条关系到分治法的效率,如果各个子问题是不独立的则分治法要重复的解决公共的子问题,此时虽然可用分治法,但一般使用动态规划法较好。
分治法的基本步骤
- 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
- 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
- 合并:将各个子问题的解合并为原问题的解
分治法的复杂度分析
分治法的时间复杂度
分治法的空间复杂度
可使用分治法求解的一些经典问题
- 二分搜索
- 大整数乘法
- Strassen矩阵乘法
- 棋盘覆盖
- 归并排序
- 快速排序
- 线性时间选择
- 最接近点对问题
- 循环赛日程表
- 汉诺塔
归并排序(merge sort)
归并排序简介
归并排序是将两个或两个以上的有序表组合成一个新的有序表(也称为二路归并 )。其基本思想是:先将N个数据看成N个长度为1的表(分治法中的“分”),将相邻两个表合并,得到长度为2的N/2个有序表,进一步将相邻的表合并,得到长度为4的N/4个有序表,以此类推,直到所有数据合并成一个长度为N的有序表位置。(分治法中的“治”)每一次归并称为一趟。
- 要解决归并问题,首先要解决两两归并问题(两个有序表合并成一个有序表)
public static void mergeTwo(int[] data,int first,int mid,int last,int[] tmp){
//把data[first]-data[mid]当做第一个有序序列 ,这里设为A
//把data[mid+1]-data[last]当做第二个有序序列,这里设为B
//将两个有序序列合并,形成的新序列为data[first]-data[last]
int i = first, j = mid + 1;
int m = mid, n = last;
int k = 0;
while(i<=m&&j<=n){
//A序列和B序列依次从起始值开始比较
//如果A序列值小,就将其移值tmp中
//并且A下标i+1;tmp下标k+1
if(data[i]<data[j]){
tmp[k++] =data[i++];
}else{
//如果B序列值小,就将其移值tmp中
//并且B下标i+1;tmp下标k+1
tmp[k++] = data[j++];
}
}
//如果A序列或者B序列已经全部移到tmp中
//则剩余的另一个序列依次移到tmp中
while(i<=m){
tmp[k++] =data[i++];
}
while(j<=n){
tmp[k++] = data[j++];
}
//遍历tmp,将tmp中元素移会data,此时data[first]-data[last]为有序序列
for (i = 0; i < k; i++) {
data[first + i] = tmp[i];
}
}
- 将两个有序表合成一个有序表之后,要开始实现“分”的部分,这里分为两种方法
- 自底向上
- 自顶向下
自底向上的基本思想是:第一趟归并排序时,将待排序的文件R[1…n]看做是n个长度为1的有序文件,将这些子文件两两归并,
- 若n为偶数,则得到n/2个长度为2的有序文件;
- 若n为奇数,则最后一个子文件轮空(不参与归并,直接进入下一趟归并),估本趟归并完成后,前n/2-1个有序子文件长度为2,单最后一个子文件长度仍为1;
第二趟归并则是将第一趟归并所得到的n/2个有序文件再进行两两归并,以此类推,直到得到最后长度为n的有序文件。
package sortDemo;
public class MargeSort {
public static void main(String[] args) {
int[] sort ={3,2,1,4,6,5,8,9,10,7} ;
System.out.println("排序前:");
print(sort);
int[] tmp = new int[sort.length];
mergeSort(sort,0,sort.length-1,tmp);
System.out.println("\n排序后:");
print(sort);
}
public static void mergeSort(int[] data,int first,int last,int[] tmp){
if(first<last){
int mid = (last-first)/2+first;
//使左侧有序
mergeSort(data,first,mid,tmp);
//使右侧有序
mergeSort(data,mid+1,last,tmp);
//合并两个有序的子序列
mergeTwo(data, first, mid, last, tmp);
}
}
public static void mergeTwo(int[] data,int first,int mid,int last,int[] tmp){
//把data[first]-data[mid]当做第一个有序序列 ,这里设为A
//把data[mid+1]-data[last]当做第二个有序序列,这里设为B
//将两个有序序列合并,形成的新序列为data[first]-data[last]
int i = first, j = mid + 1;
int m = mid, n = last;
int k = 0;
while(i<=m&&j<=n){
//A序列和B序列依次从起始值开始比较
//如果A序列值小,就将其移值tmp中
//并且A下标i+1;tmp下标k+1
if(data[i]<data[j]){
tmp[k++] =data[i++];
}else{
//如果B序列值小,就将其移值tmp中
//并且B下标i+1;tmp下标k+1
tmp[k++] = data[j++];
}
}
//如果A序列或者B序列已经全部移到tmp中
//则剩余的另一个序列依次移到tmp中
while(i<=m){
tmp[k++] =data[i++];
}
while(j<=n){
tmp[k++] = data[j++];
}
//遍历tmp,将tmp中元素移会data,此时data[first]-data[last]为有序序列
for (i = 0; i < k; i++) {
data[first + i] = tmp[i];
}
}
public static void print(int[] a){
for (int i = 0; i < a.length; i++) {
System.out.print(a[i]+" ");
}
System.out.println();
}
}
算法分析:
- 稳定性
归并排序不会改变元素的相对位置,所以是稳定的 - 是否为原地算法
归并排序可用顺序存储结构,也易于在链表上实现,因此占用了额外的存储空间,是非原地算法 - 时间复杂度
对长度为n的文件,需要进行[log2n]趟二路归并,每一趟归并的时间为O(n),故其时间复杂度无论是在最好情况下还是最坏情况下均是O(nlgn).