本文参考UCAS卜东波老师的计算机算法设计与分析课程完成
分治思想
分治思想可以归纳为两点:
- 将一个复杂问题化简为多个简单的子问题
- 将子问题的解组合成复杂问题的解
如果满足以上两个条件,则称“大实例可归约为小实例”。
判断分治的条件
并非所有问题都能进行分治,通常我们通过观察问题的输入来获取划分的线索,如:
- 一个字符串的部分仍为字符串
- 一个集合的部分依然是集合
- 一棵树去除根节点分成若干子树(树的问题很多用到分治策略,只对根节点进行操作)
- 一个图的部分仍是一个图
相对地,我们也可以根据观察问题的输出来判断子问题的解能否组和成复杂问题的解。
最经典的参考排序,排序得到的子序列可以进一步组成原序列的解
分治思想在排序问题的应用
给定一个乱序数组
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(n2),原因在于每一次划分都是划出一个元素,可以将这个过程用公式表达:
T(n)=T(n−1)+O(n)
因为后面要将k个数进行合并(k<=n),所以总的使用时间可以计算如下:
T(n)≤T(n−1)+cn≤T(n−2)+c(n−1)+cn...≤T(1)+...+cn=O(n2)
实际上容易发现此时的归并排序已经退化成插入排序
-
更改划分数量
前面每次只划分一个元素,这次每次将元素对半划分,如下图:
可以得到新的时间复杂度计算:
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,使得运算时间大幅减小。
-
更一般的划分
上述情况用了对半划分使得效率大幅提高,猜想若是将问题一次分成更多子问题,情况如何?假设将规模为n的问题,划分成a个子问题,每个子问题规模设为
bn,可以得到公式如下:
这里之所以是
bn不是
an,是因为并非所有问题中n都是被刚好分给所有子实例,各实例之间可能存在交叉数据,例如n分成3份,每份规模
2n,则分别是
0−2n,4n−43n,2n−n
T(n)={1aT(bn)+cndif n=1else (a>1,b>1)
其中,
cnd是将a个子问题的解合并成原问题解所需的额外开销,在上述归并排序中,d=1
将上述表达式进行迭代,得到公式如下:
T(n)=aT(bn)+cnd=a(aT(b2n)+c(bn)d)+cnd=a2T(b2n)+a1c(bn)d+a0cnd,上一步化简=a3T(b3n)+a2c(b2n)d+a1c(bn)d+a0cnd=...=akT(bkn)+ak−1c(bk−1n)d+...+a1c(bn)d+a0cnd=alogbnT(1)+alogbn−1c(blogbn−1n)d+...+a1c(bn)d+a0cnd,(当bkn=1时,k=logbn,T(1)=1)=alogbn+cnd((bda)logbn−1++(bda)logbn−2+...+bda+1),上一步化简{nlogba+cnlogbaO(logn),调和级数用等比求和公式化简如下if d=logbaelse =alogbn+cnd((bda)−1(bda)logbn−1),等比公式=alogbn+(bda)−1cnd(bda)logbn−(bda)−1cnd=alogbn+Cndndalogbn−Cnd,(C=(bda)−1c,(bd)logbn=nd,注意这里d=logba,分母不能为0)=alogbn+Calogbn−Cnd=(C+1)nlogba−Cnd,换底公式(alogbn=nlogba)=⎩⎪⎨⎪⎧O(nlogba)O(nlogbalogn)O(nd)if d<logbaelse if d=logbaelse d>logba
整个推导过程较为复杂,用到了许多数学基础知识,如调和级数,等比求和公式,换底公式等。特别是在
d=logba的地方,很容易忘记做判断,导致最后结果没有
O(nlogbalogn),因此每个地方我都加了标注,建议自己手动推导一遍,才能真正理解。
其实上述结果就是Master定理,用于快速求得均等划分递归表达式的复杂度。
如
T(n)=3T(2n)+O(n)的复杂度是
O(nlog23)
-
非均匀的划分
上面讨论的都是均匀的划分(每个子例的规模相同),试想一下不均匀的划分,例如
43,41?如果我们将规模n的问题按照这样的划分,得到图例如下:
可以预见结果是一颗向左倾斜的树,右边要比左边先一步到达不可分点。我用橙色背景标注出了。
为什么要介绍这种划分?我们每次直接做均匀划分不就好了吗?
这种情况会在快速排序的时候遇到,因为pivot是不确定的,我们无法做到每次都能进行均匀划分,但实际上上图这种划分方式可以看作介于红色和蓝色区域之间,所以这种划分也是
O(nlogn)的复杂度(在额外合并结果开销为O(n)的情况下)。关于这种划分的更详细讲解可以看我这篇文章深入理解快速排序
求逆序对的个数
-
问题
给定一个数组A,求数组中逆序对的个数?
所谓逆序对,当i<j时,a[i]>a[j],称(a[i],a[j])为一个逆序对
如果直接双重循环遍历,则需要
O(n2)的时间,利用分治策略,可以将复杂度减小到
O(nlogn)
-
应用分治策略
将n各元素对半划分,这时候考虑三种情况:
- 1、两个元素均在左数组:继续划分(递归)
- 2、两个元素均在右数组:继续划分(递归)
- 3、一个元素在左,一个元素在右:这种情况较为复杂,单独考虑
若两个数组无序,则不可避免每两个之间要进行一次比较,算法复杂度依然是
O(n2),但如果两个数组是有序数组呢?看下面的图例:
其中,蓝色的线代表两个元素之间进行一次比较,可以看到有序数组的比较次数明显少于无序。其中缘由是两个有序数组有一个隐含条件,在从左向右做比较时,一旦左数组中某个元素a小于右数组某个元素b时,其不必再与b后面的元素继续比较(因为a一定小于b之后的元素),这大大减少了比较的次数。
由此我们可以得到伪代码
sort_and_count(A):
divide A into A_L,A_R
C_L,L = sort_and_count(A_L)
C_R,R = sort_and_count(A_R)
(C,A) = merge_and_count(L,R)
return (C_L+C_R+C,A)
merge_and_count(L,R):
C=0;i=0;j=0;
for k=0 to |L|+|R|-1:
if L[i] > R[j]:
A[k] = R[j]
j++
C += |L|-i
else:
A[k] = L[i]
i++
return (C,A)
将整个过程画成图例如下:
可以看到统计得到逆序对11个,与预期相符。而这个方法的时间复杂度如下:
T(n)=2T(2n)+O(n)=O(nlogn),基于master定理
分治总结
- 1、看输入决定如何划分,看输出决定如何合并
- 2、分要保证数据规模指数下降(保证降低复杂度)
- 3、无需最优,只需足够好(快排中pivot选择不一定要中间)
文章内容只覆盖了主要内容,毕竟想要一两篇文章将老师3节课的内容全部叙述清楚,还是有难度,重要的是理解分治思想的核心,分而治之(能否分,怎么分,怎么合)。文中公式推导的地方建议自己手动推一下,会有更深的理解!
如果你觉得文章对你有用,不妨顺手点个赞哦~