分治法是一种基于递归解决问题的方法,其大致思路如下:将原问题分为k个同类子问题,对这k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进行下去,直到问题规模足够小,很容易求出其解为止。
例如在下图所示的问题中,可以将原问题分为三个子问题,分别标记为1、2、3 。子问题1又可以分为问题1-1、问题1-2;子问题2又可分为3个子问题;子问题3可以被解决因此不需要再分割。
一、分治法的算法复杂性分析
分治法的时间复杂性分析只需要列出递推表达式及最底层解的时间复杂度并进行迭代即可。加入一个分治法将规模为
n
n
n的原问题每次都分成
k
k
k个规模为
n
m
\frac{n}{m}
mn的子问题,并且需要
f
(
n
)
f(n)
f(n)个单位时间将这些子问题合并,用
T
(
n
)
T(n)
T(n)表示该分治法求解规模为
n
n
n的问题需要的时间可以写出如下方程:
T
(
n
)
=
{
O
(
1
)
n
=
1
k
T
(
n
m
)
+
f
(
n
)
n
>
1
T(n)=\begin{cases} O(1) & n=1 \\ kT(\frac{n}{m}) + f(n) & n>1 \end{cases}
T(n)={O(1)kT(mn)+f(n)n=1n>1
通过迭代法得到方程的解为
T
(
n
)
=
n
log
m
k
+
∑
j
=
0
log
m
n
−
1
k
j
f
(
n
m
j
)
T(n)=n^{\log_mk}+\sum^{\log_m{n-1}}_{j=0}{k^jf(\frac{n}{m^j})}
T(n)=nlogmk+j=0∑logmn−1kjf(mjn)
二、分治法的具体例子
该部分围绕以下三个复习范例展开:
- 快速排序
- 合并排序
- 线性时间选择
1、快速排序
1) 算法思路即伪代码
首先按照分治法的思路来梳理一下快速排序,以升序排列的快速排序为例。
- 分解: 确定某个基准元素a的最终位置,即排序后的数组中A[q]=a,使得A[p…q-1]的每个元素都小于等于A[q];A[q+1…r]的每个元素都大于等于A[q]。
- 解决: 递归地调用快排,分别对上一步划分出来的子数组进行排序。
- 合并: 因为两个子数组已经就地排序,所以不需要再进行刻意合并。
// 快速排序:其中A是待排序数组,p和r是起始和结束的位置下标
void Quicksort (int A[],int p,int r) {
if (p < r) {
int q = Partition (A, p, r);
Quicksort (A, p, q);
Quicksort (A, q + 1, r);
}
}
// 最外层调用
int main() {
Quicksort (A, 0, n);
return 0;
}
上述代码中设计确定基准元素位置并将其他元素进行划分的函数Partition,其伪代码如下:
// 确定第一个元素为基准元素进行划分
int Partition(int A[], int p, int q) {
int x = A[p];
int i = p;
// 指针j从第二个位置开始向后探测,
// 每次遇到一个小于基准数的元素时就将基准数的位置i后移一位
// 并将那个较小的与当前指针i指向元素进行交换
for (int j = p + 1; j < q; j++) {
while (A[j] <= x) {
i = i + 1;
swap (A, i, j);
}
}
// 遍历结束后指针i的位置即时基准元素的对应位置,进行交换即可
swap (A, p, i);
return i;
}
有时会采用RandomizedPartition来选择基准元素,及从A[p…r]中随机抽取一个元素作为基准元素开始排序。由此改写算法伪代码如下。
// 随机选取元素为基准元素进行划分
RandomizedPartition(int A[], int p, int q) {
// 从p...q之间随机选取一个数
ri = random(p, q);
swap(A, p, ri);
return partition(A, p, q);
}
2)时间复杂度分析
-
最坏情况:当数组是逆序排列时。
这种情况最糟糕的原因是,当我们每次取到的基准元素都是最小值或最大值,而使用Partition最数组进行划分后,都会导致被划分区间的一个方向上总是没有元素的,这样算法就会退化成冒泡排序,时间复杂度为 Θ ( n 2 ) \Theta(n^2) Θ(n2)。Partition的时间复杂度为 Θ ( n ) \Theta(n) Θ(n),具体推导过程如下:
T ( n ) = T ( 0 ) + T ( n − 1 ) + Θ ( n ) = Θ ( 1 ) + T ( n − 1 ) + Θ ( n ) = T ( n − 1 ) + Θ ( n ) \begin{aligned} T(n) &= T(0) + T(n-1) +\Theta(n)\\ &=\Theta(1) +T(n-1) + \Theta(n)\\ &=T(n-1)+\Theta(n) \end{aligned} T(n)=T(0)+T(n−1)+Θ(n)=Θ(1)+T(n−1)+Θ(n)=T(n−1)+Θ(n)
迭代后得到 T ( n ) = n 2 T(n)=n^2 T(n)=n2。 -
最好情况:每次选择的基准数都是数组的中间元素。此时可以将数组划分为等大的两个子数组并进行递归,时间复杂度推导如下:
T ( n ) = T ( n / 2 ) + T ( n / 2 ) + Θ ( n ) = 2 Θ ( n / 2 ) + Θ ( n ) = 2 ( 2 Θ ( n / 4 ) + Θ ( n 2 ) ) + Θ ( n ) = 4 Θ ( n / 4 ) + 2 Θ ( n ) = Θ ( n lg n ) \begin{aligned} T(n) &= T(n/2) + T(n/2) +\Theta(n)\\ &=2\Theta(n/2) + \Theta(n)\\ &=2(2\Theta(n/4)+\Theta(\frac{n}{2}))+\Theta(n)\\ &=4\Theta(n/4)+2\Theta(n)\\ &=\Theta(n\lg n) \end{aligned} T(n)=T(n/2)+T(n/2)+Θ(n)=2Θ(n/2)+Θ(n)=2(2Θ(n/4)+Θ(2n))+Θ(n)=4Θ(n/4)+2Θ(n)=Θ(nlgn) -
平均情况时间复杂度也是 O ( n lg n ) O(n\lg n) O(nlgn)。
2、线性选择
(1)算法概述
线性原则给定一个序列中的n个元素和一个正数 k ( 1 ≤ k ≤ n ) k(1\leq k \leq n) k(1≤k≤n)找出这n个元素中第k小的元素。
算法的大致思路如下:
- 首先尽可能找到数组的中间数,并以这个数为基准将序列划分为两部分(采用快速排序的Partition)
- 确定中间数的位次,如果不是k则根据k的值在对应的区间内递归查找。也就是说,如果中间数的实际位次 m < k m<k m<k,则在序列后半段查找;如果 m > k m>k m>k则在前半段进行查找。
选取划分元素的大致思路如下:
- 将n个输入元素划分为 ⌈ n 5 ⌉ \lceil \frac{n}{5}\rceil ⌈5n⌉组,即只有至多一个组的长度不是5。
- 用任意一种算法将这五个数进行排序,并取出每一组的中位数。长度小于5的数取第三小的数,长度小于3的取最大。
- 递归调用select来找出这些中位数组成的新序列的中位数,如果是新序列长度为偶数则取较大的中位数。
伪代码如下:
// InsertSort(A, p, r) 排列数组下标p...r-1的元素
int Select (int A[], int p, int r, int k) {
// 如果元素个数小于75则线性增速不多,直接求解。
if (A.length <= 75){
InsertSort (A, p, r);
return A[k - 1];
}
int j = 0;
for (int i = p; i + 4 < r; i+=5) {
InsertSort (A, i, i + 5);
// 将中位数换到队头方便比较
if (st + 2 < r)
swap(A, j++, st + 2);
else // 如果最后一组数量不超过3个则选择最后一个数
swap(A, j++, r - 1);
}
// 找中位数的基准数
int pivot = Select (A, p, j, (j + 1) / 2);
int pId = int((j + 1)/2); // 基准数下标
// 划分区间, 这里的只需把只需把中位数移到最前面在用上面快排的Partition即可。
swap(A, 0, pId);
int x = Partition(A, p, r);
if (k - 1 == x) return A[x];
else if (k - 1 < x) return Select(A, p, x, k);
else return Select(A, x + 1, r, k);
}
(2)复杂度分析
当我们取 m m m个元素为一组时,并取出所有组的中位数,再求得这些中位数的中位数。这样一来,小于中位数的中位数的至少有这些数:
- 中位数的中位数所在的组中所有小于它的数
- 将中位数排序后排在中位数之前的那些中位数;
- 上述中位数所对应的组中所有在中位数之前的数。
大于中位数的中位数的数同理,两者都至少为(考虑到最后一组可能不满5个因此直接少算一组)
⌈ m 2 ⌉ ( ⌈ 1 2 ⌈ n m ⌉ ⌉ − 2 ) ≥ n 4 − m \lceil \frac{m}{2}\rceil(\lceil{\frac{1}{2}\lceil \frac{n}{m}\rceil}\rceil-2) \geq \frac{n}{4} - m ⌈2m⌉(⌈21⌈mn⌉⌉−2)≥4n−m
在这样的分割下,最坏情况Select将处理的元素个数不超过 ( 3 4 n + m ) (\frac{3}{4}n+m) (43n+m)个。同时,组内元素数量有限,排序的复杂度为 O ( 1 ) O(1) O(1),因此排序的总复杂度为 O ( ⌈ n m ⌉ ) = O ( n ) O(\lceil \frac{n}{m}\rceil)=O(n) O(⌈mn⌉)=O(n),递归调用Select查找中位数的中位数复杂度表示为 T ( ⌈ n m ⌉ ) T(\lceil \frac{n}{m}\rceil) T(⌈mn⌉),由此得到递推不等式
T ( n ) ≤ T ( ⌈ n m ⌉ ) + T ( 3 4 n + m ) + O ( n ) T(n) \leq T(\lceil \frac{n}{m}\rceil) + T(\frac{3}{4}n+m) + O(n) T(n)≤T(⌈mn⌉)+T(43n+m)+O(n)
猜想有 T ( n ) = O ( n ) T(n)=O(n) T(n)=O(n),即 ∃ c ∈ N + , T ( n ) ≤ c n \exists c \in \N+, T(n) \leq cn ∃c∈N+,T(n)≤cn,代回不等式得到
T ( n ) ≤ c ( n m + 1 ) + 3 c n 4 m + c m + O ( n ) = c n ( 1 m + 3 4 ) + c ( m + 1 ) + O ( n ) \begin{aligned}T(n) &\leq c(\frac{n}{m} + 1) + \frac{3cn}{4m} + cm + O(n) \\ & = cn(\frac{1}{m}+\frac{3}{4}) + c(m+1) + O(n) \end{aligned} T(n)≤c(mn+1)+4m3cn+cm+O(n)=cn(m1+43)+c(m+1)+O(n)
观察式子可以得到,在 m ≤ 4 m\leq 4 m≤4的时候复杂度才是线性的,同时,因为不等式右边还有一些其他的后缀,因此一组元素最少的个数为5比较合理。同时为了防止组内排序复杂度增大, m m m取值过大也不太合适。
3、归并排序
归并排序(又叫合并排序)是将待排序元素分为长度大致相同的两组并进行递归排序的排序。例如使用归并排序将29,38,12,46,78,24,53七个数升序排列的过程如下。
(1)算法概述
用分治法的思想来梳理归并排序的过程如下:
- 分解: 将n个元素分为大致为 n / 2 n/2 n/2个元素的两组。
- 解决: 递归地对每个分组进行归并排序知道元素个数为1。
- 合并: 将两个子序列按照升序合并。
伪代码如下:
void Mergesort (int A[], int p, int r){
if(r - p > 1) {
int q = (r + p) / 2;
Mergesort (A, p, q);
Mergesort (A, q, r);
Merge (A, p, q, r);
}
}
// Merge函数用于合并两组数
void Merge (int A[], int p, int q, int r) {
copy(A, B,p, r); // 将A数组copy到B数组中
int i = p, j = q, k = p;
// 比较两组的队头元素,将较小者出队放入原数组
while (i < q && j < r) {
if (B[i] < B[j]) A[k++] = B[i++];
else A[k++] = B[j++];
}
// 将两数组可能剩余的元素全部升序放入数组
while (i < q) A[k++] = B[i++];
while (j < r) A[k++] = B[j++];
}
(2)复杂度分析
直接分析算法的最坏情况,直接写出地递推式如下。
{
T
(
n
)
=
2
T
(
n
/
2
)
T
(
1
)
=
0
\begin{cases} T(n)=2T(n/2)\\ T(1)=0 \end{cases}
{T(n)=2T(n/2)T(1)=0
易得算法时间复杂度为
T
(
n
)
=
O
(
n
log
n
)
T(n) = O(n\log n)
T(n)=O(nlogn)(最坏情况下
T
(
n
)
=
Θ
(
n
log
n
)
T(n) = \Theta(n\log n)
T(n)=Θ(nlogn));另外,算法还需要申请
Θ
(
n
)
\Theta(n)
Θ(n)的辅助空间。