算法设计与分析复习笔记第二章递归与分治
目录
递归的概念
递归就是用自己来定义自己
其中Q也不包含P,B为递归终止条件。
递归算法的特点
将较大规模对象的操作归结为对较小规模对象实施同样的操作。
递归元
这种规模的变化就体现在递归算法的变元的一类(一个或几个)变元上,这类变元称之为递归元。
递归元的特点
1.递归元的变化是在递归定义中确定的。
2.递归元的变化应能导致递归算法的终止。
直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数。
何时使用递归:
1.定义是递归的。例如,求n!和Fibonacci数列等。
2.数据结构是递归的。例如,单链表就是一种递归数据结构。
3.问题的求解方法是递归的。典型的有Hanoi问题求解
阶乘函数
边界条件与递归方程是递归函数的二个要素。递归函数只有具备了这两个要素,才能在有限次计算后得出结果。
Fibonacci数列
无穷数列1,1,2,3,5,8,13,21,34,55,……,称为Fibonacci数列。
整数划分问题
将正整数n表示成一系列正整数之和:n=n1+n2+…+nk,其中n1≥n2≥…≥nk≥1,k≥1。
正整数n的这种表示称为正整数n的划分。求正整数n的不同划分个数。
在本例中,如果设p(n)为正整数n的划分数,则难以找到递归关系,因此考虑增加一个自变量:将最大加数n1不大于m的划分个数记作q(n,m)。可以建立q(n,m)的如下递归关系。
- m=1时,q(n,1)=1,n>=1;当最大加数n1不大于1时,任何正整数n只有一种划分形式,即
- n=1时,q(1,m)=1;对于正整数1,很明显只有一种划分形式。
- n<m时,由于排列中没有负数,m多出n的数,没有了实际意义,其实本质上还是等于q(n,n)。
- n=m时,q(n,m)=1+q(n,m-1);
正整数n的最大加数n1不大于m的划分由n1=m=n的划分(仅1个)和n1≤m-1 的划分组成。 - 当n>m时,应分两种情况考虑:?
a.划分中包含m,即n=m+n2+n3+…+nk,则n2+n3+…+nk=n-m,且ni<=m,所以对于后面这些之和为n-m的元素来说,它们的排列数目应该为q(n-m,m)
b.划分中不包含m,则问题转化为q(n,m-1),故此情况下的q(n,m)=q(n-m,m)+q(n,m-1);
重点
Hanoi塔问题
(1)先将A上面n–1个盘借助 B移至C。
(2)再将A上剩下的1个盘移至B
(3)最后将C上的n–1个盘借助A移至B。
递归函数的调用过程
一个正确的递归程序虽然每次调用的是相同的子程序,但它的参量、输入数据等均有变化。
在正常的情况下,随着调用的不断深入,必定会出现调用到某一层的函数时,不再执行递归调用而终止函数的执行,遇到递归出口便是这种情况。
递归调用是函数嵌套调用的一种特殊情况,即它是调用自身代码。也可以把每一次递归调用理解成调用自身代码的一个复制件。
由于每次调用时,它的参量和局部变量均不相同,因而也就保证了各个复制件执行时的独立性。
系统为每一次调用开辟一组存储单元,用来存放本次调用的返回地址以及被中断的函数的参量值。
这些单元以系统栈的形式存放,每调用一次进栈一次,当返回时执行出栈操作,把当前栈顶保留的值送回相应的参量中进行恢复,并按栈顶中的返回地址,从断点继续执行。
递归函数的运行轨迹
在递归函数中,调用函数和被调用函数是同一个函数,需要注意的是递归函数的调用层次。
如果把调用递归函数的主函数称为第0层,进入函数后,首次递归调用自身称为第1层调用;从第i层递归调用自身称为第i+1层。反之,退出第i+1层调用应该返回第i层。
采用图示方法描述递归函数的运行轨迹,从中可较直观地了解到各调用层次及其执行情况。
阶乘函数的执行过程
阶乘函数的运行轨迹
每递归调用一次,就需进栈一次,最多的进栈元素个数称为递归深度,当n越大,递归深度越深,开辟的栈空间也越大。
每当遇到递归出口或完成本次执行时,需退栈一次,并恢复参量值,当全部执行完毕时,栈应为空。
归纳起来,递归调用的实现是分两步进行的,第一步是分解过程,即用递归体将“大问题”分解成“小问题”,直到递归出口为止,然后进行第二步的求值过程,即已知“小问题”,计算“大问题”。前面的fun(5)求解过程如下所示。
Hanoi塔问题的时间复杂性为多少,如何计算?
O(n)=2n
递归方法小结
优点:结构清晰,可读性强,而且容易用数学归纳法来证明算法的正确性,因此它为设计算法、调试程序带来很大方便。
缺点:递归算法的运行效率较低,无论是耗费的计算时间还是占用的存储空间都比非递归算法要多。
分治算法的基本思想
总体思想
将要求解的较大规模的问题分割成k个更小规模的子问题。
对这k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进行下去,直到问题规模足够小,很容易求出其解为止。
将求出的小规模的问题的解合并为一个更大规模的问题的解,自底向上逐步求出原来问题的解。
分治法的使用条件
该问题的规模缩小到一定的程度就可以容易地解决;
该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质;
利用该问题分解出的子问题的解可以合并为该问题的解;
该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
分治法的基本步骤
- Divide: 整个问题划分为多个子问题
- Conquer:求解各子问题(递归调用正设计的算法)
- Merge:合并子问题的解, 形成原始问题的解
平衡子问题:在用分治法设计算法时,最好使子问题的规模大致相同。即将一个问题分成大小相等的k个子问题的处理方法是行之有效的。
分治法的时间复杂性分析
一般的,分治法的时间复杂性可归结为递归方程:
其中,a是子问题的个数,b是递减的步长, ~表示递减方式, D(n)是合成子问题的开销。
通常,递归元的递减方式~有两种:
- 减法,即n – b,的形式
- 除法,即n / b,的形式
递减方式为减法
若~为减法,即n – b,则有:
这里k = n / b。不失一般性令b = 1,则k = n。若设D(n)为常数,则有T(n) = O(an)(a>1)。即这种情况下递归算法的时间复杂性为指数函数。
递减方式为除法
若~为除法,即n / b,则有:
这里bk = n,所以k = log b n。于是:
由此可知,T(n)的首项是n的常数幂
若递减方式为n / b,则递归算法的时间复杂性
这个递归方程的首项np称为ji齐次解,第二项称为特解。特解一般很难估计。但是,对于一些特殊的D(n),可以给出其显式解。
例题
求解递归方程
分治法的应用
折半查找(二分查找)
【问题描述】
设a[0],…,a[n-1]是一个按升序排列的整型数组,且没有重复元素,即
?????????? a[0]<a[1]<…<a[n-1]
另有一个整数T,要查找T是否存在于这个数组中。若存在,找出其所在位置,即给出其下标值;若不存在,则给出-1作为查找结果。
【基本思路】
对于有序数组:(05,13,19,21,37,56,64,75,80,88,92)
T=21的查找过程:
T=85的查找过程:
二分搜索有序表的递归式算法
int recursive_binary_search ( int a[] , int left , int right , int T )
{ int k , mid;
if ( left > right ) k = -1; //*数组中不存在T,返回-1
else
{mid = ( left + right ) / 2;//*取中点下标
if ( a[mid] == T ) k = mid ;
else if (a[mid] < T )
k = recursive_binary_search ( a , mid+1 , right , T ) ;
else
k = recursive_binary_search ( a , left , mid-1 ,T ) ;
}
return ( k ) ; //*返回T在数组a中位置的下标值
}
二分搜索有序表的非递归式算法
int binary_search ( int a[] , int n , int T )
{ int left , right , mid , k ;
left = 0 ;
right = n - 1 ;
k = -1 ;
while ( left <= right && k == -1 )
{ mid = ( left + right ) / 2;
if ( a[mid] == T ) k = mid ;
else
if ( a[mid] > T ) right = mid-1;
else left = mid+1 ;
}
return ( k ) ;
}
二分搜索的每次循环查找区间减半,查找区间构成一棵二叉树,最坏的情况是一直走到二叉树的叶子。因此算法的复杂度为 log n + 1。
递归方程:
时间复杂度为O(log n)。
关于二分查找的应用
虽然二分查找的效率高,但是要将表按关键字排序。而排序本身是一种很费时的运算。既使采用高效率的排序方法也要花费O(nlgn)的时间。
二分查找只适用顺序存储结构。为保持表的有序性,在顺序结构里插入和删除都必须移动大量的结点。因此,二分查找特别适用于那种一经建立就很少改动、而又经常需要查找的线性表。
对那些查找少而又经常需要改动的线性表,可采用链表作存储结构,进行顺序查找。链表上无法实现二分查找。
大整数乘法
在实际应用中,经常要使用两个很大的整数相乘(如RSA算法),而计算机硬件能够直接处理计算的位数受其硬件对整数的表示范围的限制,所以需要软件来运算。
模拟手工算法很简单,主要是用三个数组来存储乘数、被乘数和积,数组的每一位存储一个十进制的位,步骤如下:
- 积数组的每一位置0
- i=最低位
- 将乘数的第i位分别与被乘数的每一位相乘,结果放入积数组对应的位置,并加上此位置原先的值
- 如果此位置的值大于9,则将进位加入到高位
- 将i指向高一位
- 如果i还未超出乘数的最高位,则转3;否则停止,输出结果
模仿人工计算方式的大整数乘法
设X和Y都是n位的二进制整数。如果用常规的乘法计算乘积XY,其时间复杂性为O(n2)。
伪代码
前面的算法要求被乘数X和乘数Y的位数都必须是2的幂。
思考:如果X,Y的位数不是2的幂,且位数不相等怎么办呢?
矩阵乘法
传统算法
时间复杂度:O(M*N*L)主要操作是乘法,很耗时
Strassen矩阵乘法
设A、B为n阶方阵,且n为偶数(=2m)
如果将矩阵分为4个大小相等的子矩阵,则
划分成了八个子问题是因为A11B11是一个子问题,同理共有八个子问题。
需要计算7个m*m的矩阵相乘,18个m*m的矩阵相加减。所以要做7/8n3次乘法和18/4n2次加法。
任一Mi仍然是由两个矩阵相乘得到,只要M i的阶是一个偶数,也可以仿照这种方法来求…一直到Mi的阶为1(通常只分解到2,为什么?),故可以递归下去--分治法。
Strassen算法局限性
必须保证A、B都是n阶方阵
只有当n是偶数时才能分块,所以必须保证n是2的幂。
棋盘覆盖问题
一个2k×2k的特殊棋盘是其中含有一个特殊方格的棋盘,如左下图为k=2的一个特殊棋盘。
现在任意给定一个2k×2k的特殊棋盘,要用右下图所示的L型骨牌来无重叠的覆盖它。
棋盘覆盖问题的分析
当k>0时,将2k×2k的棋盘分割成4个2k–1×2k–1的子棋盘,如右下图所示:
特殊方格必定位于4个子棋盘之一中。
然而,这样一来四个子棋盘的情形就不一致了。因为递归求解是将问题归结到较小的规模的同一问题,所以就需要将三个正常子棋盘也转化成特殊棋盘。
为此,可以用一个L型骨牌来覆盖其余三个子棋盘的会合处,如左图所示
这样原问题转化成了四个较小规模的子问题。递归地分割下去直至单格棋盘。
棋盘覆盖算法
棋盘覆盖(参数表) {
如果是单个格子,则返回;
将棋盘划分成尺寸为一半的子棋盘;
判断特殊方格在哪个子棋盘中,再用相应的L型骨牌覆盖相应结合部,即不含特殊方格的部分在结合部的三个方格;并记下它们的位置,作为各部分的特殊方格;
依次对左上角、右上角、左下角和右下角这四个子棋盘进行棋盘覆盖;}
棋盘覆盖算法中的参数
递归元:棋盘尺寸size=2k。递归时棋盘尺寸减半,即size=size/2;当size为1时递归终止。
表示棋盘位置参数,即棋盘左上角方格的行号tr和列号tc,每次用size/2调整子棋盘位置。
表示特殊方格位置的参数,即特殊方格的行号dr和列号dc。
棋盘用一个二维整型数组Board表示,Board[0][0]表示棋盘中左上角的方格 。
L型骨牌覆盖的顺序用全局变量tile表示,初始值为0。
编写成一个递归的C函数TileBoard,调用格式为TileBoard(0,0,dr,dc,size)。
棋盘覆盖算法的复杂性
设T(k)是棋盘覆盖算法覆盖2k×2k的棋盘所需要的时间,当k= 0时,size等于1,覆盖它将花费常数时间d。当k>0时,将进行4次递归的函数调用,这些调用需花费的时间为? 4*T(k-1)。除了这些时间外,if条件测试和覆盖3个非特殊方格也需要时间,假设用常数O(1)表示这些额外时间,则 T(k)满足如下递归方程:
递归元递减方式是减法k – 1,a = 4,因此
由于覆盖2k×2k的棋盘要用 (4k–1)/3个L型骨牌,故此算法是一个在渐进意义下最优的算法。
快速排序
【问题描述】有n个数据(在此假设都是整数)存放于数组a中,即a[0]到a[n-1]中存放了n个整数。要求经过处理后,使得
a[0] ≤a[1] ≤, …, ≤a[n-1]
通过一趟排序将待排整数分割成独立的两部分,其中前部分数均比后部分数小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
要做到这种“分块有序”,只要在a[low..high]中任选一个整数作为基准(T),以此基准将当前无序区划分为左、右两个较小的子区间a[low..T-1)和a[T+1..high],并使左边子区间中所有元素均小于等于基准(不妨记为T),右边的子区间中所有元素均大于等于它,而基准T则位于正确的位置上,它无须参加后续的排序。
基准T该如何选择呢?
一种简单的方法就取待排序的区间[0..n-1]中的第一个元素a[0]作为基准。
然后保持原数组不动,另取一个等长的数组b,从b[0]开始往后存放小于基准的元素,从b[n-1]开始往前存放大于基准的元素,分组完成后把所有元素从b数组复制回a中,并记下两组的中间分隔位置,基准就在这个位置。
但这样的处理会造成存储空间上额外的开销,下面描述的方法可以在原数组a上进行分组,并找到基准的位置,只需要另外增加一个整型变量。
快速排序的核心就是基准的定位,只要确定了基准的位置,它前后两部分就已经分块有序了——我们也把这个过程称为“划分”。再按同样的方法进行下去就可以了。
下面是划分的过程:
快速排序的算法分析
快速排序的时间主要耗费在划分操作上,对长度为k的区间进行划分,共需k-1次关键字的比较。
1.最坏情形的时间复杂度
最坏情况是每次划分选取的基准都是当前无序区中关键字最小(或最大)的记录,划分的结果是基准左边的子区间为空(或右边的子区间为空),而划分所得的另一个非空的子区间中记录数目仅仅比划分前的无序区中记录个数减少一个。
因此,快速排序必须做n-1次划分,第i次划分开始时区间长度为n-i+1,所需的比较次数为n-i(1≤i≤n-1),故总的比较次数达到最大值:
Cmax = n(n-1)/2=O(n2)
如果按前面给出的划分算法,每次取当前无序区的第1个记录为基准,那么当文件的记录已按递增序(或递减序)排列时,每次划分所取的基准就是当前无序区中关键字最小(或最大)的记录,则快速排序所需的比较次数反而最多。
2.在最好情况下,每次划分所取的基准都是当前无序区的“中值”记录,划分的结果是基准的左、右两个无序子区间的长度大致相等。它的递归式为:
其中D(n)=n-1,是n的线性函数,套用前面的公式,得到总的关键字比较次数:O(nlogn)
尽管快速排序的最坏时间为O(n2),但就平均性能而言,它是基于关键字比较的内部排序算法中速度最快的,快速排序亦因此而得名。它的平均时间复杂度为O(nlogn),但它的常数因子比别的排序法都小。
基准T的选取
由复杂度分析可知,在当前无序区中选取划分的基准是决定算法性能的关键。
“三者取中”规则:即在当前区间里,将该区间首、尾和中间位置上的关键字比较,取三者之中值作为基准,在划分开始前将该基准记录和该区间的第1个记录进行交换,此后的划分过程与前面所给的qkpass算法完全相同。
循环赛日程表
设有n=2k个运动员要进行网球循环赛。现要设计一个满足以下要求的比赛日程表:
1、每个选手必须与其他n-1个选手各赛一次;
2、每个选手一天只能参赛一次;
3、循环赛在n-1天内结束。
按此要求将比赛日程表设计成有n行和n-1列的一个表。在表中的第i行,第j列处填入第i个选手在第j天所遇到的选手。其中1≤i≤n,1≤j≤n-1。
将所有的选手分为两半,则n个选手的比赛日程表可通过n/2个选手的比赛日程表来决定。递归地用这种一分为二的策略对选手进行划分,直到只剩下两个选手时,比赛日程表的制定就变得很简单。
循环赛日程表算法
算法复杂度分析:解决问题的整个过程就是在一个n*n的矩阵中填入数据,且每个元素只访问一次。故时间复杂度为O(n2)
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!