Loading

基础算法 —— 2. 分治

方法论

步骤
  • Divide :Divide the problem into a number of subproblems that are smaller instances of the
    same problem.
  • Conquer :Conquer the subproblems by solving them recursively. If the subproblem sizes are
    small enough, however, just solve the subproblems in a straightforward manner.
  • Combine :Combine the solutions to the subproblems into the solution for the original problem.
特点

从分治法的设计模式可以看出,它设计的程序一般是递归算法,对算法效率的分析一般都可通过递归方程来分析。当然,也有非递归版的分治算法,如问题集中的循环赛日程表。

递归实现都可以通过栈转化为非递归实现,所以下文习题中我们讨论的非递归实现默认都不考虑栈的实现方法。一般来说,相较于递的过程解决问题,在归的过程中解决问题的算法更容易转化为迭代实现。

后面我们会学到和分治很像的算法策略 - 动态规划,它和分治很像,不过它与分治最大的不同是子问题重叠。另外动态规划的解决问题总是在归的阶段解决问题,所以动态规划算法都有递归、非递归两种实现。

分治的优化

依据 \(W(n) = aW(n/b) + f(n)\)

  1. 减小子问题个数a

    后面的大整数乘法与 Strassen 矩阵乘法 都是利用代数变化,减少一个子问题个数

  2. 增加预处理,减少 f(n)

问题集

在问题中,大都用伪码描述算法思想。

二分搜索

在非降序的数组中我们查找一个数,顺序查找的复杂度是 \(O(n)\) ,当我们注意到数组本身有序的情况下,完全可以使用二分搜索将时间复杂度降至 \(O(logn)\)

它的步骤是:先找到数组中间的数,待查找的数和它只有三种情况 < = > ,如果相等,直接返回下标;如果小于它,这个数就可能在数组的左半部分;如果大于它,就可能在数组右半部分。

仔细观察剩下的问题,它与原问题唯一的不同就是规模不同。如此,我们就将一个问题分治为一个子问题。

if(left > right){
	return -1;
}

if(arr[middle] < x){
	递归进入左半部分
}
else if(arr[middle]>x){
	递归进入右半部分
}
else{
	return middle;
}

在递的过程中解决问题,在进入下一层前只做了比较工作,所以比较容易转化为迭代实现。

大整数乘法

两个n位二进制整数相乘,按照我们一般方法需要 \(n*n\) 次乘法。

考虑下面的算法:

  1. 我们将每个整数分为两段,每段 \(n/2\),A,B 和 C,D 总共四段。则两个整数相乘就变为\((A*2^{n/2}+B)(C*2^{n/2}+D) = AC*2^n+(AD+BC)*2^{n/2}+BD\)

    递推方程为 \(T(n) =4T(n/2)+O(n)\) 算下来也是 \(O(n^2)\)

  2. 不过,我们能通过代数变换来减少一项乘法。将原式变为\(AC*2^n+((A-B)(D-C)+AC+BD)*2^{n/2}+BD\)

    递推方程就变为 \(T(n) = 3T(n/2)+O(n)\) 算下来是 \(O(n^{log3})\)

Strassen 矩阵乘法与此类似,也是通过代数变换(常数级)减少一个子问题。

在归的过程解决问题,可以转化为非递归,不过由于子问题与原问题间关系较复杂,所以非递归条件控制可能有点麻烦。

棋盘覆盖问题

将棋盘均分为4个小棋盘,为了使用分治,子问题的性质必须和原问题相同,所以我们判断特殊格的坐标在哪个部分从而在当前棋盘中心点适当放置L型方块,使得四个小棋盘都是特殊棋盘,从而我们正确的将原问题转为4个子问题。

递推公式 \(T(n) = 4T(n/4)+O(1)\) 根据主定理该算法耗时 \(O(n)\)

if(当前棋盘大小为 1){
	return;
}

if(特殊点位置在左上部分){
	在中心放置缺口朝左上的L方块
}
else if(特殊点在右上部分){
	在中心放置缺口朝右上的L方块
}
else if(特殊点在左下部分){
	在中心放置缺口朝左下的L方块
}
else{
	在中心放置缺口朝右下的L方块
}

递归覆盖左上;
递归覆盖右上;
递归覆盖左下;
递归覆盖右下;

在递的过程解决问题,所作的工作比较复杂,不太好转化为非递归。

归并排序

如果两个数组非递减有序,那么我们可以通过将这两个数组合并成一个非递减有序数组。利用这样性质,归并排序将原数组分为两个数组,一直递到数组规模为1时,此时什么都不干,因为一个元素本身就是有序的。

在归的过程中建立一个辅助数组,利用这个辅助数组将两两有序数组合并成一个数组。

递推公式 \(T(n) = 2T(n/2)+O(n)\) 时间复杂度 \(O(nlogn)\) 空间复杂度为 \(O(n)\)

if(当前数组大小为1){
	return;
}
递归归并左半部分;
递归归并右半部分;
将左右两个半个数组合并为一个数组;

在归的过程解决问题,转化为非递归比较方便

快速排序

快速排序的递归过程和二分查找的递归过程比较像,都是在递的过程中解决问题。不过与二分查找不同的是,在递给下一层前,快速排序需要先对数组划分,将数组元素划分为两部分,生成两个子问题;而二分查找只是计算一下中间元素下标,根据不同情况最多只有一个子问题。

快排每次在递到下一层前,通过划分,将一个元素放到数组中合适位置,并且以这个元素的大小将数组分为两个部分。这样使得每个部分和原问题性质相同,只是规模更小。

快排最好的情况下,每次均分数组,这种情况下递归方程为 \(T(n) = 2T(n/2)+O(n)\) 时间复杂度 \(O(nlogn)\) 空间复杂度 \(O(logn)\) ; 最坏情况下,每次只产生一个子问题 \(T(n) = T(n-1)+O(n)\) 时间复杂度 \(O(n^2)\) 空间复杂度 \(O(n)\)

可见快排的最坏情况下并不比插入排序等基础排序好多少,所以有一系列为了使快排均匀划分数组,避免最坏情况的选择合适划分元素的算法。

//划分:
设置左右双指针分别指向数组左端和右端;
while true:
	右向左找第一个<=划分元素的元素;
	左向右找第一个>=划分元素的元素;
	if(左指针>=右指针) break;
	交换它们;
把划分元素放到左指针或右指针的位置


if(元素个数为1){
	return;
}
选取划分元素;
对数组进行划分;
通过上一步划分返回的位置将数组分为左右两部分;
递归快排左部分;
递归快排右部分;

另一个问题我们通过分析发现快排好像没有比归并排序好多少,在某些情况下比归并排序还差。那么为什么快排反而比较经常使用?

实际上 stack overflow 上这个问题说的很清楚。https://stackoverflow.com/questions/70402/why-is-quicksort-better-than-mergesort

简单来说,就是实际上由于常数项的存在,或者某些使用场景,使得快排平均来看要优于归并排序。

一般来说,小数据使用这些排序算法(这里的小是指内存能同时加载全部数据)快排的常数因子比归并更小,并且不需要额外的辅助空间。而且在实际中,通过合适的算法选择划分元素,可以使快排保持 \(O(nlogn)\)

但假设一下这样的场景,如果数据量非常多,以至于不能同时加载到内存上,要访问磁盘的数据就必须经过操作系统将磁盘数据加载到内存上,这样来快排的性能就受到严重影响。而此时可以使用归并排序。这种排序场景使用的是外部排序。

另外有些场景下要求稳定排序,而快排是不稳定排序。

C++标准库STL 的sort 实际上是 introsort 综合了三种排序算法,在数据量较小时是插入,一般时是快排,大时是堆排。

在递的过程中解决问题,无法转化为非递归。

线性时间的选择问题

我们可以将快排与选择问题结合起来。假设我们要找数组中第K小元素,我们可以通过快排的划分将数组分为两部分,然后检测划分元素的位置看是不是我们想找的第K小元素,如果不是,则在递归进入左半部分或右半部分。

最坏 \(T(n) = T(n-1)+O(n)\) 也就是 \(O(n^2)\)

最好 \(T(n) = T(n/2)+O(n)\) 也就是 \(O(n)\)

//第k小
if(当前元素大小为1){
	当前是第1小?返回当前元素:抛异常
}
对数组元素划分,返回划分元素的位置。
if(划分位置小于k)
    递归进入右部分,更新k为右半部分的第几小
else if(划分位置大于k)
	递归进入左部分。
else
	返回当前元素

在递的过程中解决问题

最坏情况下为 \(O(n)\) 的查找算法步骤:

  1. 将整个大数组每五个视为小数组进行插入排序。
  2. 取每个小数组的中位数,构成中位数集合。
  3. 递归调用该选择算法,获得中位数集合的中位数。
  4. 用这个中位数集合的中位数对整个数组进行划分,返回数组中划分元素在数组中的下标。
  5. 若是题中所求,则直接返回,否则递归调用该算法,进入子问题。
循环赛日程表

观察所给的表,按照分治策略,将所有选手对半分,n个选手的日程表可以由n/2个选手日程表得到。

if(n==1){
	return;
}
进入下一层n/2
以n 大小为一组将n/2 大小的左上角表格拷贝到右下角;
以n 大小为一组将n/2 大小的左下角表格拷贝到右上角;

在归的过程中解决问题,可以转化为非递归。

最大子段和

最直观的方案暴力法直接三重循环 \(O(n^3)\) ,不过我们可以对其做一个小小的改进可以降至 \(O(n^2)\),我们并不用每次挪动尾端就求一次和。

int Max = INT_MIN;
for(前端所有可能的位置){
	for(尾端所有可能的位置){
		int sum = 对这个范围求和;
		Max = max(Max, sum);
	}
}
return Max;

//改进
int Max = INT_MIN;
for(前端所有可能的位置){
	int sum = 0;
	for(尾端所有可能的位置){
		sum += 当前尾端位置的元素;
		Max = max(Max, sum);
	}
}

接下来思考分治的办法。

观察原问题,若将其数组一分为二,那么最大子段和只有三种情况:1.在左半部分产生 2.在右半部分产生 3.在跨越中线的区域产生。前两种情况与原问题只是规模不同,所以我们递归计算前两种情况,单独计算第三种情况。

//求跨越中线的最大子段和 	和上面改进后的暴力算法相似
{
    对左半部分控制尾端不动
    int lsum = 0,lmax = INT_MIN;
    for(左半部分前端的所有可能位置){
        lsum += 当前前端位置元素
        lmax = std::max(lmax,lsum);
    }
    
    对右半部分控制前端不动
    int rsum = 0,rmax = INT_MIN;
    for(右半部分尾端的所有可能位置){
        rsum += 当前尾端位置元素
        rmax = std::max(rmax,rsum);
    }
    return lmax+rmax;
}

if(当前规模为1){
	return 当前元素
}
else{
	int middle = 求出中间元素的下标
	int lmax = 进入下一层递归(左半部分)
	int rmax = 进入下一层递归(右半部分)
	int mmax = 求跨越中线的最大子段和
	
	return std::max(lmax,rmax,mmax);
}

注意到在求跨越中线的最大子段和时,有一些重复计算,这就为我们后面再次优化算法提供了可能。我们会在动态规划再次看见这个问题

posted @ 2020-06-04 17:35  沉云  阅读(383)  评论(0)    收藏  举报