1.递归定义
直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数。
由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。
分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。
在定义自身的同时,出现对自身的直接或间接调用(自己调用自己)。一个直接或间接调用自己的算法称为递归算法。
递归算法的特点:
(1)描述简捷;
(2)结构清晰;
(3)算法的正确性比较容易证明。
递归转为非递归的原因
(1)递归的执行效率低;
(2)空间消耗多;
(3)软、硬件环境条件限制;
写递归注意问题
(1)算法中的过程或函数对自身进行调用;
(2)递归控制条件;有一个明确的递归结束条件,即递归出口;
(3)非递归的初始值的定义。
递归设计方法
(1)寻找分解方法;
(2)设计递归出口(寻找所分解的问题的出口)。
递归算法的执行过程中包括两个执行阶段
(1)自上而下的递归进层阶段(递推阶段)
(2)自下而上的出层阶段(回归阶段)
递归与迭代的区别
递归算法优缺点:
优点:结构清晰,可读性强,而且容易用数学归纳法来证明算法的正确性,因此它为设计算法、调试程序带来很大方便。
缺点:递归算法的运行效率较低,无论是耗费的计算时间还是占用的存储空间都比非递归算法要多。
解决方法:在递归算法中消除递归调用,使其转化为非递归算法。
1、采用一个用户定义的栈来模拟系统的递归调用工作栈。该方法通用性强,但本质上还是递归,只不过人工做了本来由编译器做的事情,优化效果不明显。
2、用递推来实现递归函数。
3、通过变换能将一些递归转化为尾递归,从而迭代求出结果。
后两种方法在时空复杂度上均有较大改善,但其适用范围有限。
2.具有递归特征的问题--汉诺塔问题
分析:当n很大时这个问题不好解决,我们可以使用递归技术来解决该问题
当n=1时,将编号为1的圆盘从X柱子直接移到柱子Z上。
当n>1时,需要利用柱子Y作为辅助柱子,设法将n-1个较小的盘子按规则移到柱子Y中,然后将编号为n的盘子从X柱子移到Z柱子,最后将n-1个较小的盘子移到Z柱子中。
用递归解决规模为n的Hanoi塔问题Void HANOI(n,X,Y,Z){if n=1 MOVE(X,1, Z)else {HANOI(n-1,X,Z,Y)MOVE(X,n,Z)HANOI(n-1,Y,X,Z) }}下面给出三个盘子搬动时HANOI(3, A, B , C) 的递归调用过程
HANOI(3, A, B , C)HANOI(2, A, C, B):HANOI(1, A, B, C)MOVE(A->C) 1号搬到CMOVE(A->B) 2号搬到BHANOI(1, C, A, B)MOVE(C->B) 1号搬回到BMOVE(A->C) 3号搬到CHANOI(2,B,A,C):HANOI(1, B, C, A)MOVE(B->A) 1号搬到AMOVE(B->C) 2号搬到CHANOI(1, A, B, C)MOVE(A->C) 1号搬到C
3.递归过程的设计与实现
递归算法的主要表现形式为过程或者函数在定义自身的同时对自身进行调用
在采用递归策略时,算法必须有一个明确的递归边界,即递归出口
一般从两个主要方面进行递归算法的设计:
1.寻找对问题进行分解的方法;
2.寻找所分解问题的出口,即递归出口
递归求解问题的基本思想
递归是程序设计中一种常用的问题求解策略, 它的基本思想就是把规模较大的、较难解决的问题转化为规模较小的、易于解决的同类子问题. 而规模较小的子问题又可转化为规模更小的子问题, 当问题规模小到一定程度时, 我们可以直接得出这个问题的解, 从而得到原始问题的解.
一个递归算法有两个基本要素——递归的一般条件和递归的基本条件. 递归的基本条件也称为递归的终止条件, 它是保证递归可以终止的条件, 因此递归的基本条件也称为递归的出口. 递归的一般条件实际上代表了一种递归关系, 它是使得问题能向递归出口转化的一种规则.
递归算法的执行过程可以分为两个阶段——递归阶段和回归阶段. 在递归阶段中, 原本规模较大、较为复杂的问题被逐步分解为规模较小、与原始问题类似的子问题, 换句话说就是不断降低问题的规模, 直到可以转化为一个最简单的、可以直接求解的问题为止.
在算法的递归调用过程中,进行递归进层(i→i +1层)系统需要做三件事:
⑴ 保留本层参数与返回地址;
⑵ 为被调用函数的局部变量分配存储区,给下层参数赋值;
⑶ 将程序转移到被调函数的入口。
而从被调用函数返回调用函数之前,递归退层(i←i +1层)系统也应完成三件工作:
⑴ 保存被调函数的计算结果;
⑵ 释放被调函数的数据区,恢复上层参数;
⑶ 依照被调函数保存的返回地址,将控制转移回调用函数。
当递归函数调用时,应按照“后调用先返回”的原则处理调用过程,因此上述函数之间的信息传递和控制转移必须通过栈来实现。系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就为它在栈顶分配一个存储区,而每当从一个函数退出时,就释放它的存储区。显然,当前正在运行的函数的数据区必在栈顶。
递归实现的原理:
一个递归函数的调用过程类似于多个函数的嵌套的调用,只不过调用函数和被调用函数是同一个函数。为了保证递归函数的正确执行,系统需设立一个工作栈。具体地说,递归调用的内部执行过程如下:
开始执行时,首先为递归调用建立一个工作栈,其结构包括值参、局部变量和返回地址;
每次执行递归调用之前,把递归函数的值参、局部变量的当前值以及调用后的返回地址压栈;
每次递归调用结束后,将栈顶元素出栈,使相应的值参和局部变量恢复为调用前的值,然后转向返回地址指定的位置继续执行。
计算sum(n=3)的出入栈
4.递归算法分析
当一个算法包含对自身的递归调用过程时,该算法的运行时间复杂度可用递归方程进行描述,求解该递归方程,可得到对该算法时间复杂度的函数度量。
递归方程的求解一般可采用如下三种方法:
(1)替换法(Substitution Method) (递推法)
替换方法的最简单方式为:
根据递归规律,将递归公式通过方程展开、反复代换子问题的规模变量,通过多项式整理,如此类推,从而得到递归方程的解。
2路归并排序的递归分析
假设初始序列含有n个记录,首先将这n个记录看成n个有序的子序列,每个子序列的长度为1,然后两两归并,得到个长度为2(n为奇数时,最后一个序列的长度为1)的有序子序列;在此基础上,再对长度为2的有序子序列进行两两归并,得到若干个长度为4的有序子序列;如此重复,直至得到一个长度为n的有序序列为止。
2.二路归并排序过程描述
设有数列{16,23,100,3,38,128,23}
初始状态:16,23,100,3,38,128,23
第一次归并后:{6,23},{3,100},{38,128},{23};
第二次归并后:{3,6,23,100},{23,38,128};
第三次归并后:{3,6,23,23,38,100,128}。 完成排序。
归并排序属于比较类非线性时间排序,号称比较类排序中性能最佳者,在数据中应用中较广。归并排序是分治法(Divide and Conquer)的一个典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
二路归并算法的步骤
步骤1:分割 将待排序的序列划分为两个相等大小的子序列,直到每个子序列只包含一个元素。
步骤2:排序 对每个子序列进行排序。这可以使用递归调用归并排序来实现。
步骤3:合并 将两个排好序的子序列合并成一个更大的有序序列。这一过程需要比较两个子序列的元素,并按顺序合并它们。
步骤4:重复 重复步骤1到3,直到所有子序列都合并成一个有序序列。
在合并过程中,两个有序的子表被遍历了一遍,表中的每一项均被复制了一次。因此,合并的代价与两个有序子表的长度之和成正比,该算法的时间复杂度为O(n)。
2-路归并排序的递归方法实现算法思想: 将r1[ ]中的记录用归并法排序后放到r3[ ]中,可以分为下面三个步骤:
①将r1[ ]前半段的记录用归并法排序后放到r2[ ]的前半段中;
②将r1[ ]后半段的记录用归并法排序后放到r2[ ]的后半段中;
③将r2[ ]的前半段和后半段合并到r3[ ]中。
替换方法求解递归方程还可以通过如下步骤进行:
(1)猜测界限函数
(2)对猜测进行证明,并寻找到猜测中常量C的范围。
(2)递归树法(Recursion Method)
用递归树来求解归并排序的时间复杂度
①:每次分解是一分为二,所以代价很低,将时间上的消耗记作常量1。
②:归并算法中比较耗时的归并操作,也就是把两个子数组合并为大数组
③:每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关,将每层归并操作消耗的时间记作n。
④:我们只需要知道这颗树的高度h,用高度乘以每层的时间消耗n,就可以得到总的时间复杂度O(n*h)。
⑤:从归并排序的原理和递归树,可知归并排序递归树是一颗满二叉树,满二叉树的高度大约为log2n,所以归并排序递归实现的时间复杂度就是O(nlogn)。
(3)主方法(Master Method)
主方法为我们提供了解如下形式递归方程的一般方法
其中a≥1,b>1为常数,f(n)是渐近正函数。
算法将规模为n的问题划分成a个子问题, 每个子问题的大小为n/b。求解这a个子问题,每个所需时间为T(n/b)。
函数f(n)表示划分子问题与组合子问题解的开销。
5.分治法的基本思想
任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。当问题规模n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。
分治法的设计思想是,将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
6.分治法的适用条件
分治法的特点:
(1)子问题相互独立且与原问题形式相同;
(2)反复分治,使划分得到的小问题小到不可再分时,直接对其求解。
分治法所能解决的问题一般具有以下几个特征:
该问题的规模缩小到一定的程度就可以容易地解决;
该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质
利用该问题分解出的子问题的解可以合并为该问题的解;
该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
这条特征涉及到分治法的效率,如果各子问题是不独立的,则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然也可用分治法,但一般用动态规划较好。
7.分治法的基本步骤
分治思想设计算法时一般包含的步骤
第一步,分解(割)。将待解决的问题划分成足够小的问题,相互独立,与原问题形式相同的子问题;
第二步,求解。若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
第三步,合并。自下而上,将子问题的解逐层合并,得到原问题的解。
人们从大量实践中发现,在用分治法设计算法时,最好使子问题的规模大致相同。即将一个问题分成大小相等的k个子问题的处理方法是行之有效的。这种使子问题规模大致相等的做法是出自一种平衡(balancing)子问题的思想,它几乎总是比子问题规模不等的做法要好。
注意:递归方程及其解只给出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)。
8.分治法的典型示例
1.n个数中求出最大/最小值
问题描述:
从给定的n个数中,设计算法在最坏情况下最多进行用次比较,可找出给定n个数的最大和最小值。
问题分析:
方法一:从n个数中找最大最小数可以通过如下过程进行:第一步,通过n-1次比较找出最大值;第二步,从其余的n-1个数中通过n-2次比较找出最小值。这种常规的方法一共要进行2n-3次比较((n-1)+(n-2))。
方法二:题目设计要求,可通过分治思想进行算法设计。记n个数的集合为S,将其平分为元素个数为n/2的两个集合S1,S2,。 首先,分别在S1、S2中找出最大、最小值,分别记作MaxS1,MinS1,MaxS2,MaxS1;之后,通过2次比较,从所找到的该四个数中找出S中的最大、最小值,。从S1,S2中分别找出最大、最小值的方法可以按以上思想递归进行。
根据上述分析,采用分治递归思想设计的算法如下:
Void SearchMaxMin(S)
{if (|S|==2) return (max(a,b),min(a,b))else{ S分成S1,S2(max1,min1)= SearchMaxMin (S1) //找出S1中最大、最小值(max2,min2)= SearchMaxMin (s2) //找出S2中最大、最小值return(max(max1,max2), min(min1,min2)) //找出S中最大、最小值}}
2.折半查找(二分搜索)
给定已排序好的N个元素a[0:n-1],现要在这N个元素中找出一个特定元素x
分析:
该问题的规模缩小到一定的程度就可以容易地解决;
该问题可以分解为若干个规模较小的相同问题;
分解出的子问题的解可以合并为原问题的解;
分解出的各个子问题是相互独立的。
很显然此问题分解出的子问题相互独立,即在a[i]的前面或后面查找x是独立的子问题,因此满足分治法的第四个适用条件。
(1)划分:将数组进行划分
(2)求解:逐个查找
(3)合并:由于数组中其他元素已经被排除,因此无须进行合并操作
int Binary_search(int *array,int length,int key){int low = 0,high = length-1,mid;while(low <= high){ //退出循环条件为low>highmid = (low+high)/2; if(array[mid] == key) return mid; //找到else if(array[mid] > key) high = mid-1; //key在mid左边else low = mid+1; //key在mid右边} return -1; //未找到
}
算法复杂度分析:
每执行一次算法的while循环, 待搜索数组的大小减少一半。因此,在最坏情况下,while循环被执行了O(logn) 次。循环体内运算需要O(1) 时间,因此整个算法在最坏情况下的计算时间复杂性为O(logn) 。
3.快速排序
问题描述:
对给定的n个记录A[p..r]进行排序。
问题分析:
基于分治设计的思想,从待排序记录序列中选取一个记录(通常选取第一个记录)为枢轴,其关键字设为K1,然后将其余关键字小于K1的记录移到前面,而将关键字大于K1的记录移到后面,结果将待排序记录序列分成两个子表,最后将关键字为K1的记录插到其分界线的位置处。我们将这个过程称作一趟快速排序。
通过一次划分后,就以关键字为K1的记录为界,将待排序序列分成了两个子表,且前面子表中所有记录的关键字均不大于K1,而后面子表中的所有记录的关键字均不小于K1。对分割后的子表继续按上述原则进行分割,直到所有子表的表长不超过1为止,此时待排序记录序列就变成了一个有序表。
具体的排序过程由以下三步组成:
(1)划分:将数组A[p..r]划分成两个子数组A[p..q-1]和A[q+1..r](其中之一可能为空),满足数组A[p..q-1]中的每个元素值不超过数组A[q+1..r]中的每个元素。计算下标q作为划分过程的一部分。
(2)解决:递归调用快速排序算法,对两个子数组A[p..q-1]和A[q+1..r]进行排序。
(3)合并:由于子数组中元素已被排序,无需合并操作,整个数组A[p..r]有序。
在该排序过程中,记录的比较和交换是从两端向中间进行的,关键字较大的记录一次就能交换到后面单元,关键字较小的记录一次就能交换到前面单元,记录每次移动的距离较大,因而总的比较和移动次数较少。
template<class Type>
void QuickSort (Type A[], int p, int r)
{if p<r thenq← PARTITION(A,p,r);QuickSort (A,p,q-1); //对左半段排序QuickSort (A,q+1,r); //对右半段排序endif
} PARTITION(A, p, r)x ← A[p] //最最端元素作为枢轴元素i ←p-1 for j ←p to r-1do if A[j] ≤ xthen i ←i+1exchange A[i]A[j]exchangeA[i+1] A[r]return i+1 template<class Type>
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;
}
快速排序算法的性能取决于划分的对称性。通过修改算法partition,可以设计出采用随机选择策略的快速排序算法。在快速排序算法的每一步中,当数组还没有被划分时,可以在a[p:r]中随机选出一个元素作为划分基准,这样可以使划分基准的选择是随机的,从而可以期望划分是较对称的。
快速排序最坏情况分析:
当划分过程产生的两个子问题规模分别为n-1和1时,快速排序出现最坏的情况。划分的时间复杂度为O(n)。假设每次递归调用时产生这种不平衡的情况。间复杂度
快速排序最好情况分析:
在大多数均匀划分的情况下,PARTITION产生两个规模为n/2的子问题,以下对该情况下的算法进行分析。也就是刚好每次都在中间的时候
4.大整数乘法
问题描述:
设有两个n位二进制数x,y,现要计算他们的乘积xy;
问题分析:
两个n位二进制整数相乘,根据计算规则,两个数中每位数都需要对应做乘法运算,因此,按照一般方法计算这两个数乘法所需要的算法的复杂度是O(n^2)
现采用分治思想进行处理降低算法: 设有两个n位二进制数x,y
5.矩阵乘法
问题描述:
设计算法,完成矩阵运算C=A×B,其中A、B为n×n矩阵。
矩阵乘法是线性代数中最基本的运算之一,它在数值计算中有广泛的应用。若A和B是2个n×n矩阵。A和B的乘积矩阵C中的元素
根据该规则,两个n阶矩阵相乘时,算法需要完成3重次数为n的循环,算法的时间复杂度为 o(n^3) ,可以利用分治法对算法进行改进:
9.小结
直接或间接地对自身进行调用的算法称为递归算法,分治法所产生的子问题往往是原问题的较小模式,因此,分治法就为使用递归技术提供了方便。反复使用分治法,可将原问题分解为若干个规模缩小而与原问题类型一致的子问题,当子问题的规模缩小到一定程度时,就可以很容易的直接求出其解,这个过程也就自然形成了一个问题求解的递归过程。分治与递归经常同时应用于算法设计中,并由此产生了许多高效的算法。