【算法设计与分析】递归与分治 | 复习笔记

在这里插入图片描述

算法总体思想

  1. 将要求解的较大规模的问题分割成k个较小规模的子问题
  2. 对这k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进
  3. 将求出的小规模的问题的解合并为一个更大规模的问题的解,自底向上逐步求出原来问题的解
  • 分治法的设计思想是,将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之

递归的概念

定义
  • 直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数
  • 由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生
  • 分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法
n的阶乘

在这里插入图片描述
边界条件与递归方程是递归函数的二个要素,递归函数只有具备了这两个要素,才能在有限次计算后得出结果。

Fibonacci数列

在这里插入图片描述

Ackerman函数

当一个函数及它的一个变量是由函数自身定义时,称这个函数是双递归函数
在这里插入图片描述

排列问题

设计一个递归算法生成n个元素{r1,r2,…,rn}的全排列。

设R={r1,r2,…,rn}是要进行排列的n个元素,Ri=R-{ri}。
集合X中元素的全排列记为perm(X)。(ri)perm(X)表示在全排列perm(X)的每一个排列前加上前缀得到的排列
在这里插入图片描述

template<class Type>
void Perm(Type list[], int k, int m )
{ //产生[list[k:m]的所有排列
	if(k==m)
	{ //只剩下一个元素
		for (int i=0;i<=m;i++)
			cout<<list[i];
		cout<<endl;
	}
	else //还有多个元素待排列,递归产生排列
	{	
		for (int i=k; i<=m; i++)
		{
			swap(list[k],list[i]);
			Perm(list,k+1,m);
			swap(list[k],list[i]);
		}
	}
}
整数划分问题

将正整数n表示成一系列正整数之和:n=n1+n2+…+nk,其中n1≥n2≥…≥nk≥1,k≥1。
正整数n的这种表示称为正整数n的划分。求正整数n的不同划分个数。

如果{m1,m2,…,mi}中的最大值不超过m,即max(m1,m2,…,mi)<=m,则称它属于n的一个m划分。这里我们记n的m划分的个数为f(n,m);
根据n和m的关系,考虑以下几种情况:

  1. n=1时,不论m的值为多少(m>0),只有一种划分即{1};
  2. m=1时,不论n的值为多少,只有一种划分即n个1,{1,1,1,…,1};
  3. n=m时,根据划分中是否包含n,可以分为两种情况:
    A. 划分中包含n的情况,只有一个即{n};
    B. 划分中不包含n的情况,这时划分中最大的数字也一定比n小,即n的所有(n-1)划分。
    因此 f(n,n) =1 + f(n,n-1);
  4. n<m时,由于划分中不可能出现负数,因此就相当于f(n,n);
  5. n>m时,根据划分中是否包含最大值m,可以分为两种情况:
    A. 划分中包含m的情况,即{m, {x1,x2,…xi}}, 其中{x1,x2,… xi} 的和为n-m,可能再次出现m,因此是(n-m)的m划分,因此这种划分个数为f(n-m, m);
    B. 划分中不包含m的情况,则划分中所有值都比m小,即n的(m-1)划分,个数为f(n,m-1);
    因此 f(n, m) = f(n-m, m)+f(n,m-1);

在这里插入图片描述

int equationCount(int n,int m)
{
	if(n<1||m<1)  return 0;
	if(n==1||m==1)  return 1;
	else if(n<m)  return equationCount(n,n);
	else if(n==m)  return 1+equationCount(n,n-1);
	else  return equationCount(n,m-1)+equationCount(n-m,m);
}

部分内容参考自7215:简单的整数划分问题

Hanoi塔问题

设a,b,c是3个塔座。开始时,在塔座a上有一叠共n个圆盘,这些圆盘自下而上,由大到小地叠在一起。各圆盘从小到大编号为1,2,…,n,现要求将塔座a上的这一叠圆盘移到塔座b上,并仍 按同样顺序叠置。在移动圆盘时应遵守以下移动规则:
规则1:每次只能移动1个圆盘;
规则2:任何时刻都不允许将较大的圆盘压在较小的圆盘之上;
规则3:在满足移动规则1和2的前提下,可将圆盘移至a,b,c中 任一塔座上。

void hanoi(int n, int a, int b, int c)//把n快从a移到b借助c
{
	if(n>0)
	{
		hanoi(n-1, a, c, b);
		move(a, b);
		hanoi(n-1, c, b, a);
	}
}
递归小结
  • 优点:
    结构清晰,可读性强,而且容易用数学归纳法来证明算法的正确性,因此它为设计算法、调试程序带来很大方便。

  • 缺点:
    递归算法的运行效率较低,无论是耗费的计算时间还是占用的存储空间都比非递归算法要多。

  • 解决方法:在递归算法中消除递归调用,使其 转化为非递归算法

    1. 采用一个用户定义的栈来模拟系统的递归调用工作栈。该方法通用性强,但本质上还是递归,只不过人工做了本来由编译器做的事情,优化效果不明显。
    2. 用递推来实现递归函数
    3. 通过变换能将一些递归转化为尾递归,从而 迭代求出结果

    后两种方法在时空复杂度上均有较大改善, 但其适用范围有限

分治法的适用条件
  1. 该问题的规模缩小到一定的程度就可以容易地解决;
  2. 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质
  3. 利用该问题分解出的子问题的解可以合并为该问题的解
  4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
    因为问题的计算复杂性一般是随着问题规模的增加而增加,因此大部分问题满足这个特征。
    这条特征是应用分治法的前提,它也是大多数问题可以满足的,此特征反映了递归思想的应用
    能否利用分治法完全取决于问题是否具有这条特征,如果具备了前两条特征,而不具备第三条特征,则可以考虑贪心算法或动态规划。这条特征涉及到分治法的效率,如果各子问题是不独立的,则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然也可用分治法,但一般用动态规划较好。
分治法的基本步骤
divide-and-conquer(P)
{
	if ( | P | <= n0) adhoc(P); //解决小规模的问题
		divide P into smaller subinstances P1,P2,...,Pk;//分解问题
	for (i=1,i<=k,i++)
		yi=divide-and-conquer(Pi); //递归的解各子问题
	return merge(y1,...,yk); //将各子问题的解合并为原问题的解
}

人们从大量实践中发现,在用分治法设计算法时,最好使子问题的规模大致相同。即将一个问题分成大小相等的k个子问题的处理方法是行之有效的。这种使子问题规模大致相等的做法是出自一种平衡(balancing)子问题的思想,它几乎总是比子问题规模不等的做法要好。

分治法的复杂性分析

一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阈值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有:
在这里插入图片描述
通过迭代法求得方程的解:
在这里插入图片描述
注意:递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值。可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而
当mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。

实例

二分搜索技术

给定已按升序排好序的n个元素a[0:n-1],现要在这n个元素中找出一特定元素x。

分析:很显然此问题分解出的子问题相互独立,即在a[i]的前面或后面查找x是独立的子问题,因此满足分治法的第四个适用条件

template<class Type>
int BinarySearch(Type a[], const Type& x, int l, int r)
{
	while (r >= l){
		int m = (l+r)/2;
		if (x == a[m]) return m;
		if (x < a[m]) r = m-1; 
		else l = m+1;
	}
	return -1;
} 

算法复杂度分析:
每执行一次算法的while循环, 待搜索数组的大小减少一半。因此,在最坏情况下,while循环被执行了O(logn) 次。循环体内运算需要O(1)时间,因此整个算法在最坏情况下的计算时间复杂性为O(logn) 。

大整数乘法

请设计一个有效的算法,可以进行两个n位大整数的乘法运算
传统方法O(n2)

在这里插入图片描述

Strassen矩阵乘法

传统方法:O(n3)

在这里插入图片描述
在这里插入图片描述

棋盘覆盖

在一个2k×2k 个方格组成的棋盘中,恰有一个方格与其它方格不同,称该方格为一特殊方格,且称该棋盘为一特殊棋盘。在棋盘覆盖问题中,要用图示的4种不同形态的L型骨牌覆盖给定的特殊棋盘上除特殊方格以外的所有方格,且任何2个L型骨牌不得重叠覆盖。
在这里插入图片描述

当k>0时,将2k×2k棋盘分割为4个2k-1×2k-1 子棋盘(a)所示。特殊方格必位于4个较小子棋盘之一中,其余3个子棋盘中无特殊方格。为了将这3个无特殊方格的子棋盘转化为特殊棋盘,可以用一个L型骨牌覆盖这3个较小棋盘的会合处,如 (b)所示,从而将原问题转化为4个较小规模的棋盘覆盖问题。递归地使用这种分割,直至棋盘简化为棋盘1×1。
在这里插入图片描述

理解部分可以看这个ppt,棋盘覆盖问题,里面讲得非常清楚明白!

void chessBoard(int tr, int tc, int dr, int dc, int size){//tr,tc棋盘左上角方格的行列号;dr,dc是特殊方块行列号

	if (size == 1) return;
	int t = tile++, // L型骨牌号
	s = size/2; // 分割棋盘
	
	// 覆盖左上角子棋盘
	if (dr < tr + s && dc < tc + s)// 特殊方格在此棋盘中
		chessBoard(tr, tc, dr, dc, s);
	else {// 此棋盘中无特殊方格
		board[tr + s - 1][tc + s - 1] = t;// 用 t 号L型骨牌覆盖右下角
		chessBoard(tr, tc, tr+s-1, tc+s-1, s);// 覆盖其余方格
	}
	
	// 覆盖右上角子棋盘
	if (dr < tr + s && dc >= tc + s)
		chessBoard(tr, tc+s, dr, dc, s);
	else {
		board[tr + s - 1][tc + s] = t;
		chessBoard(tr, tc+s, tr+s-1, tc+s, s);
	}
	
	// 覆盖左下角子棋盘
	if (dr >= tr + s && dc < tc + s)
		chessBoard(tr+s, tc, dr, dc, s);
	else {
		board[tr + s][tc + s - 1] = t;
		chessBoard(tr+s, tc, tr+s, tc+s-1, s);
		}
		
	// 覆盖右下角子棋盘
	if (dr >= tr + s && dc >= tc + s)
		chessBoard(tr+s, tc+s, dr, dc, s);
	else {
		board[tr + s][tc + s] = t;
		chessBoard(tr+s, tc+s, tr+s, tc+s, s);
		}
}

在这里插入图片描述
这是怎么想出来的神仙算法

合并排序

将待排序元素分成大小大致相同的2个子集合,分别对2个子集合进行排序,最终将排好序的子集合合并成为所要求的排好序的集合。

void MergeSort(Type a[], int left, int right)
{
	if (left<right) {//至少有2个元素
		int i=(left+right)/2; //取中点
		mergeSort(a, left, i);
		mergeSort(a, i+1, right);
		merge(a, b, left, i, right); //合并到数组b
		copy(a, b, left, right); //复制回数组a
	}
}

在这里插入图片描述

在这里插入图片描述
归并排序可以使用自上而下的递归也可以使用自下而上的迭代,递归占的空间有些过于多了。

1.5归并排序 | 菜鸟教程这个网站上有一个描述归并排序过程的很好看的动图

快速排序

在快速排序中,记录的比较和交换是从两端向中间进行的,关键字较大的记录一次就能交换到后面单元,关键字较小的记录一次就能交换到前面单元,记录每次移动的距离较大,因而总的比较和移动次数较少。

template<class Type>
void QuickSort (Type a[], int p, int r){
	if (p < r) {
		int q = Partition(a,p,r);
		QuickSort ( a, p, q - 1); //对左半段排序
		QuickSort ( a, q + 1, r); //对右半段排序
	}
}
int Partition (Type a[], int p, int r){
	int i = p, j = r + 1;
	Type x=a[p];
	// 将< x的元素交换到左边区域
	// 将> x的元素交换到右边区域
	while (true) {
		while (a[++i] <x);
		while (a[- -j] >x);
		if (i >= j) break;
		Swap(a[i], a[j]);
	}
	a[p] = a[j];
	a[j] = x;
	return j;
}

在这里插入图片描述

template<class Type>
int RandomizedPartition (Type a[], int p, int r){
	int i = Random(p,r);
	Swap(a[i], a[p]);
	return Partition (a, p, r);
}

在这里插入图片描述

线性时间选择

给定线性序集中n个元素和一个整数k,1≤k≤n,要求找出这n个元素中第k小的元素

通过快排实现

template<class Type>
Type RandomizedSelect(Type a[],int p,int r,int k){
	if (p == r) return a[p];
	int i = RandomizedPartition(a,p,r),
	j = i - p + 1;
	if (k <= j) return RandomizedSelect( a, p, i, k);
	else return RandomizedSelect( a, i + 1, r, k - j);
}
  • 在某些特殊情况下,很容易设计出解选择问题的线性时间算法。如:当要选择最大元素或最小元素时,显然可以在O(n)时间完成。(一趟比较即可)

  • 一般的选择问题,特别是中位数的选择问题似乎比最小(大)元素要难。但实际上,从渐近阶的意义上,它们是一样的。也可以在O(n)时间完成。

  • 如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的两个子数组长度都至少为原数组长度的 ε 倍(0< ε <1是某个正常数),那么在最坏情况下用O(n)时间就可以完成选择任务。例如,若 ε =9/10,算法递归调用所产生的子数组的长度至少缩短 1/10。所以,在最坏情况下,算法所需的计算时间T(n)满足递归式 T(n) <= T(9n/10)+O(n)。由此可得T(n)=O(n)

  • 步骤:

    1. 将n个输入元素划分成n/5(向上取整)个组,每组5个元素,最多只可能有一个组不是5个元素。用任意一种排序算法,将每组中的元素排好序,并取出每组的中位数,共n/5(向上取整)个
    2. 递归调用select来找出这n/5(向上取整)个元素的中位数。如果n/5(向上取整)是偶数,就找它的2个中位数中较大的一个。以这个元素作为划分基准。

划分策略示意图:
在这里插入图片描述

  1. 设中位数的中位数是x,比x小和比x大的元素至少3*(n-5)/10个,原因:
    3—中位数比x小的每一组中有3个元素比x小
    n/5-1—有5个数的组数
    1/2—大概有1/2组的中位数比x小
  2. 而当n≥75时,3(n-5)/10≥n/4所以按此基准划分所得的2个子数组的长度都至少缩短1/4,也就是说,长度最长为原长度的3/4。
  3. 划分的部分左上是肯定比x小的(大概占1/4)右下是肯定比x大的(大概占1/4)左下和右上不确定,就算这两部分同时不比x小或比x大,划分成的子区间也能至少缩短1/4!
Type Select(Type a[], int p, int r, int k)
{
	if (r - p < 75) {
		用某个简单排序算法对数组a[p:r]排序;
		return a[p + k - 1];
	}
	for ( int i = 0; i <= (r - p - 4) / 5; i++ ){
		将 a[p + 5 * i] 至 a[p + 5 * i + 4] 的第3小元素(中位数)与 a[p + i] 交换位置;
		找中位数的中位数,(r - p - 4)即上面所说的 (n - 5)放在开头 
	}
	Type x = Select(a, p, p + (r - p - 4) / 5, (r - p - 4) / 10);
	int i = Partition( a, p, r, x),
	j = i - p + 1;
	if (k <= j)   return Select( a, p, i, k);
	else   return Select( a, i + 1, r, k - j);
}

上述算法将每一组的大小定为5,并选取75作为是否作递归调用的分界点
在这里插入图片描述
部分内容参考自算法:线性时间选择
部分内容参考自线性时间选择

最接近点对问题

定平面上n个点的集合S,找其中的一对点,使得在n个点组成的所有点对中,该点对间的距离最小。

为了使问题易于理解和分析,先来考虑一维的情形。此时,S中的n个点退化为x轴上的n个实数 x1,x2,…,xn。最接近点对即为这n个实数中相差最小的2个实数
在这里插入图片描述
最接近点对是S1中的最接近点对或S2中的最接近点对,或p3和q3

下面考虑二维的情形:

  1. 选取一垂直线l:x=m来作为分割直线。其中m为S中各点x坐标的中位数。由此将S分割为S1和S2。(m是S中各点x坐标值的中位数,因此S1和S2中的点数大致相等)
  2. 递归地在S1和S2上找出其最小距离d1和d2,并设d=min{d1,d2},S中的最接近点对或者是d,或者是某个{p,q},其中p∈P1且q∈P2。
    在这里插入图片描述

考虑P1中任意一点p,它若与P2中的点q构成最接近点对的候选者,则必有distance(p,q)<d。满足这个条件的P2中的点一定落在一个d×2d的矩形R中
由d的意义可知,P2中任何2个S中的点的距离都不小于d。由此可以推出矩形R中最多只有6个S中的点。
因此,在分治法的合并步骤中最多只需要检查6×n/2=3n个候选者

为了确切地知道要检查哪6个点,可以将p和P2中所有S2的点投影到垂直线l上。由于能与p点一起构成最接近点对候选者的S2中点一定在矩形R中,所以它们在直线l上的投影点距p在l上投影点的距离小于d。由上面的分析可知,这种投影点最多只有6个。
因此,若将P1和P2中所有S中点按其y坐标排好序,则对P1中所有点,对排好序的点列作一次扫描,就可以找出所有最接近点对的候选者。对P1中每一点最多只要检查P2中排好序的相继6个点

double cpair2(S){
	n = |S|;
	if (n < 2) return  ;
	1、m = S中各点x间坐标的中位数;
		构造S1和S2;//S1={p∈S|x(p)<=m}, S2={p∈S|x(p)>m}
	2、d1 = cpair2(S1); d2 = cpair2(S2);
	3、dm = min(d1,d2);
	4、设P1是S1中距垂直分割线l的距离在dm之内的所有点组成的集合;
		P2是S2中距分割线l的距离在dm之内所有点组成的集合;
		将P1和P2中点依其y坐标值排序;
		并设X和Y是相应的已排好序的点列;
	5、通过扫描X以及对于X中每个点检查Y中与其距离在dm之内的所有点(最多6个)可以完成合并;
		当X中的扫描指针逐次向上移动时,Y中的扫描指针可在宽为2dm的区间内移动;
		设dl是按这种扫描方式找到的点对间的最小距离;
	6、d = min(dm,dl);
		return d;
}

在这里插入图片描述
截图源自最接近点对问题
在这里插入图片描述

循环赛日程表

设计一个满足以下要求的比赛日程表:
(1)每个选手必须与其他n-1个选手各赛一次;
(2)每个选手一天只能赛一次;
(3)循环赛一共进行n-1天。

按分治策略,将所有的选手分为两半,n个选手的比赛日程表就可以通过为n/2个选手设计的比赛日程表来决定。递归地用对选手进行分割,直到只剩下2个选手时,比赛日程表的制定就变得很简单。这时只要让这2个选手进行比赛就可以了。
在这里插入图片描述
请按此要求将比赛日程表设计成有n行和n-1列的一个表。在表中的第i行,第j列处填入第i个选手在第j天所遇到的选手。其中1≤i≤n,1≤j≤n-1。8个选手的比赛日程表如下图:
在这里插入图片描述
正方形表是8个选手的比赛日程表。其中左上角与左下角的两小块分别为选手1至选手4和选手5至选手8前3天的比赛日程。据此,将左上角小块中的所有数字按其相对位置抄到右下角,又将左下角小块中的所有数字按其相对位置抄到右上角,这样我们就分别安排好了选手1至选手4和选手5至选手8在后4天的比赛日程。依此思想容易将这个比赛日程表推广到具有任意多个选手的情形。

算法步骤:

  1. 用一个for循环输出日程表的第一行 for(int i=1;i<=N;i++) a[1][i] =i
    在这里插入图片描述

  2. 定义一个m值,m初始化为1,m用来控制每一次填充表格时i(表示行)和j(表示列)的起始填充位置。

  3. 用一个for循环将问题分成几部分,对于k=3,n=8,将问题分成3大部分
    第一部分:根据已经填充的第一行,填写第二行
    第二部分:根据已经填充好的第一部分,填写第三、四行;
    第三部分:根据已经填充好的前四行,填写最后四行。
    for (int s=1;s<=k;s++) N/=2;

  4. 用一个for循环对③中提到的每一部分进行划分
    for (int t=1;t<=N;t++)
    对于第一部分,将其划分为四个小的单元,即对第二行进行如下划分
    在这里插入图片描述
    同理,对第二部分(即三四行),划分为两部分,第三部分同理。

  5. 根据以上for循环对整体的划分和分治法的思想,进行每一个单元格的填充。填充原则是:对角线填充

for(int i=m+1;i<=2*m;i++) //i控制行
	for(int j=m+1;j<=2*m;j++) //j控制列
	{	a[i][j+(t-1)*m*2]= a[i-m][j+(t-1)*m*2-m];/*右下角的值等于左上角的值 */
		a[i][j+(t-1)*m*2-m] =a[i-m][j+(t-1)*m*2];/*左下角的值等于右上角的值 */
	}
  1. 由初始化的第一行填充第二行
    在这里插入图片描述

  2. 由s控制的第一部分填完。然后是s++,进行第二部分的填充
    在这里插入图片描述

  3. 最后是第三部分的填充
    在这里插入图片描述

void Table(int k,int n,int **a)
{
	for(int i=1; i<=n; i++)
		a[1][i]=i;//设置日程表第一行
	int m = 1;//每次填充时,起始填充位置
	for(int s = 1; s <= k; s++){//例题中k=3
		n /= 2;
		for(int t = 1; t <= n; t++){
			for(int i = m+1; i <= 2*m; i++){//控制行
				for(int j = m+1; j <= 2*m; j++{//控制列
					a[i][j+(t-1)*m*2] = a[i-m][j+(t-1)*m*2-m];//右下角等于左上角
					a[i][j+(t-1)*m*2-m] = a[i-m][j+(t-1)*m*2];//左下角等于右上角
				}
			}
		}
		m *= 2;
	}
}

  • 以上内容仅复习所用,侵删
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值