分而治之思想
问题
在面对一个问题时,如何解决问题的思路如下。
分治思想
分治思想:
分而治之:分成子问题、解决子问题、合并子问题的解
- 一个复杂可解问题是可分的,即可以被化简为多个简单的子问题
- 子问题的解可以合并为复杂问题的解
如果满足以上两个条件,则称此问题是可分的,可以使用分治思想解决此问题。
正难则反,大难则小
判断是否可分治的条件
最大的前提条件是问题是可解的。
不是所有可解的问题都可分,我们通过观察问题的输入,如:
- 一个字符串的部分仍为字符串
- 一个集合的部分依然是集合
- 一棵树去除根节点分成若干子树(树的问题很多用到分治策略,只对根节点进行操作)
- 一个图的部分仍是一个图
同理,我们也可以观察问题的输出来判断子问题的解能否合并成复杂问题的解。
看input中的关键数据结构:
- 决定是否可分、怎么分(影响复杂度)
- 数据结构:数组、矩阵、Set、链表、树、DAG、一般的图
看output:
- 决定是否能把解和起来,如何合并
例1 : 分治思想在排序问题中的应用
给定一个不为空的乱序数组A[0,1,2,…,n−1],如何应用分治思想对其进行排序?
1、最简单的情况
当数组只有1个元素时,直接返回。
当数组只有2个元素时,比较两个元素大小,进行交换。
2、多个元素
当数组中有n个元素的时候,我们总希望能将复杂问题转化为我们已知的,因此,可以将数组一分为2。
A[0,1,…,n−2],A[n−1],A[n−1]是一个元素,假设前面n-1个元素已经有序,那么只需将A[n−1]插入到前面的有序数组中即可。
3、递归调用
我们可以重复上面的过程,将A[0,1,…,n−2]进一步分为A[0,1,…,n−3],A[n−2],最终,我们会将数组划分成n个单一元素。
4、合并子问题的解
现在已经划分完毕,只需要依次将解合并即可。
上述过程可以用下图表示:
但上面的方式效率很低下,时间复杂度是 O ( n 2 ) O(n^2) O(n2),原因在于每一次划分都是划出一个元素,可以将这个过程用公式表达:
T ( n ) = T ( n − 1 ) + O ( n ) T(n)=T(n−1)+O(n) T(n)=T(n−1)+O(n)
因为后面要将k个数进行合并(k<=n),所以总的使用时间可以计算如下:
T ( n ) ≤ T ( n − 1 ) + c n ≤ T ( n − 2 ) + c ( n − 1 ) + c n . . . ≤ T ( 1 ) + . . . + c n = O ( n 2 ) T(n)≤T(n−1)+cn≤T(n−2)+c(n−1)+cn...≤T(1)+...+cn=O(n2) T(n)≤T(n−1)+cn≤T(n−2)+c(n−1)+cn...≤T(1)+...+cn=O(n2)
这样做归并排序就退化成插入排序
更改划分数量
前面每次只划分一个元素,这次每次将元素对半划分,如下图:
T ( n ) = 2 T ( n 2 ) + n = 2 ( 2 ( T ( n 4 ) + n 2 ) + n = . . . = 2 k T ( n 2 k ) + k n , ( 当 n 2 k = 1 时 , k = l o g 2 n ) = n T ( 1 ) + n l o g 2 n , T ( 1 ) 为 常 数 时 间 复 杂 度 = c n + n l o g 2 n = O ( n l o g n ) T(n) = 2T(\frac{n}{2}) + n\\ = 2(2(T(\frac{n}{4})+\frac{n}{2}) + n \\ = ...\\ = 2^k T(\frac{n}{2^k})+kn,(当\frac{n}{2^k}=1时,k=log_2n)\\ = nT(1)+nlog_2n,T(1) 为常数时间复杂度\\ = cn + nlog_2n\\ =O(nlogn) T(n)=2T(2n)+n=2(2(T(4n)+2n)+n=...=2kT(2kn)+kn,(当2kn=1时,k=log2n)=nT(1)+nlog2n,T(1)为常数时间复杂度=cn+nlog2n=O(nlogn)
此时发现,时间复杂度简化到了nlogn,使得运算时间大幅减小。
归并排序java实现:
public class MergeSort {
private void merge(int[] A,int l,int m,int r){
int[] L = new int[m-l+1];
int[] R = new int[r-m];
for(int i = 0; i < m-l+1; i++){
L[i] = A[l+i];
}
for(int i = 0; i < (r-m); i++){
R[i] = A[m+i+1];
}
int i = 0;
int j = 0;
for(int k=l;k<=r;k++){
if(i>=L.length){//左边数组空了
A[k] = R[j];
j++;
}else if(j>=R.length){//右边数组空了
A[k] = L[i];
i++;
}else if(L[i]<R[j]){
A[k] = L[i];
i++;
}else {
A[k] = R[j];
j++;
}
}
}
private void mergesort(int[] A,int l,int r){
if(l<r){
int m = (l+r)/2;
mergesort(A,l,m);//分
mergesort(A,m+1,r);
merge(A,l,m,r);//合
}
return;
}
public static void main(String[] args) {
int[] data = new int[]{4,5,7,6,2,3,1,8};
MergeSort test = new MergeSort();
test.mergesort(data,0,data.length-1);
for(int i=0;i<data.length;i++){
System.out.println(data[i]);
}
}
}
非均匀的划分
上面讨论的都是均匀的划分(每个子例的规模相同),试想一下不均匀的划分,例如3/4,1/4 ?如果我们将规模n的问题按照这样的划分,可以预见结果是一颗向左倾斜的树,右边要比左边先一步到达不可分点。我用橙色背景标注出了。
这种情况会在快速排序的时候遇到,因为pivot是不确定的,我们无法做到每次都能进行均匀划分,但实际上上图这种划分方式可以看作介于红色和蓝色区域之间,所以这种划分也是O(nlogn)的复杂度(在额外合并结果开销为O(n)的情况下)。
例2:求逆序对的个数
问题
给定一个数组A,求数组中逆序对的个数?
所谓逆序对,当i<j时,a[i]>a[j],称(a[i],a[j])为一个逆序对
如果直接双重循环遍历,则需要O(n^2)的时间,利用分治策略,可以将复杂度减小到O(nlogn)
应用分治策略
将n各元素对半划分,这时候考虑三种情况:
1、两个元素均在左数组:继续划分(递归)
2、两个元素均在右数组:继续划分(递归)
3、一个元素在左,一个元素在右:这种情况较为复杂,单独考虑若两个数组无序,则不可避免每两个之间要进行一次比较,算法复杂度依然是O(n^2),但如果两个数组是有序数组呢?看下面的图例:
由此我们可以得到伪代码
sort_and_count(A):
divide A into A_L,A_R // 将A二等分
C_L,L = sort_and_count(A_L) // C_L是计算的L的逆序对个数,L与A_L有点不同,L是经过merge后得到,A_L是A的左半部分
C_R,R = sort_and_count(A_R) // 同上
(C,A) = merge_and_count(L,R) // 做归并操作并计算两个数组间逆序对个数
return (C_L+C_R+C,A) // 返回左+右+一左一右得到总逆序对个数还有merge得到的数组A,此时的A已经是排序好的
merge_and_count(L,R):
C=0;i=0;j=0; // C:逆序对个数,i:左数组的下标,j:右数组的下标
for k=0 to |L|+|R|-1:
if L[i] > R[j]:
A[k] = R[j]
j++
C += |L|-i // 如果L[i]>R[j],必然有L[i+1]>R[j],L[i+2]>R[j]...
else:
A[k] = L[i]
i++
return (C,A)