声明
本文内容来源于学堂在线邓俊辉老师的数据结构课程以及邓俊辉老师编写的《数据结构(C++语言版)》,本文为学习内容记录。
目录
递归
线性递归(linear recursion)
定义
每一递归实例对自身的调用至多一次,即每一层次上最多在只有一个实例,且它们构成一个线性的次序关系。线性递归是递归的最基本形式。
数组求和(线性递归版)
int sum(int A[], int n) //数组求和算法,线性递归版
{
if (n < 1) //平凡情况,递归基
return 0; //直接(非递归式)计算
else //一般情况
return sum(A, n - 1) + A[n - 1]; //递归:前 n - 1 项之和,再累计第 n - 1 项。
} //复杂度:T(n) = O(1) * 递归深度 = O(1) * (n+1) = O(n)
线性递归也是递归的最基本形式,这种形式中,应用问题总可分解为两个独立的子问题:其一对应于单独的某个元素,故可直接求解(比如A[n-1]);另一个对应于剩余的部分,且其结构与原问题相同(比如A[0, n - 1))。然后子问题的解经简单的合并(比如整数相加)之后,即可得到原问题的解。
减而治之(decrease-and-conquer)
线性递归的模式,往往对应于减而治之的算法策略:递归每深入一层,待求解问题的规模都缩减一个常数,直至最终蜕化为平凡的简单问题。
递归分析
递归跟踪(recursion trace)
递归跟踪可用于分析递归算法的总体运行时间和空间:检查每个递归实例,累计所需时间。
具体地,就是按照以下原则,将递归算法地执行过程整理为图地形式:
- 算法的每一递归实例都表示为一个方框,其中注明了该实例调用的参数。
- 若实例M调用实例N,则在M与N对应的方框之间添加一条有向连线。
按照上述约定,sum()
算法的递归跟踪图如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jvAz95UU-1629004205896)(C:\Users\whh\AppData\Roaming\Typora\typora-user-images\image-20210810160654747.png)]
整个算法所需的计算时间,应该等于所有递归实例的创建、执行和销毁所需的时间总和。其中,递归实例的创建、销毁均由操作系统负责完成,其对应的时间成本通常可以近似为常数,不会超过递归实例中实质计算步骤所需的时间成本,故往往给予忽略。
为便于估算,启动各实例的每一条递归调用语句所需的时间,也可以计入被创建的递归实例的账上,如此我们只需统计各实例中非递归调用所需的时间。
递推方程(recurrence equation)
通过对递归模式的数学归纳,导出复杂度定界函数的递推方程(组)及边界条件,从而将复杂度的分析,转化为递归方程(组)的求解。
为使复杂度定界函数的递推方程能够给出确定的解,需要给定某些边界条件,这类边界条件往往可以通过对递归基的分析而获得。
例子
以上面的线性递归版sum()算法为例,将该算法处理长度为n的数组所需的时间成本记作T(n)。
则为解决问题sum(A, n),需递归地解决问题sum(A, n - 1),然后累加上A[n - 1]。按照这一理解,则有:T(n) = T(n - 1) + O(1) = T(n - 1) + c1
,当抵达递归基时,有T(0) = O(1) = c2
,其中c1、c2为常数。
联立上面两个方程,可得T(n) = c1 * n + c2 = O(n)
多递归基
为保证有穷性,递归算法都必须设置递归基,且确保总能执行到。为此,针对每一类可能出现的平凡的情况,都需要设置对应的递归基,故同一算法的递归基可能(显示或隐式地)不止一个。
数组倒置(递归版)
我们以数组倒置的问题为例,也就是将数组中各元素的次序前后翻转。
借助线性递归不难解决这一问题:为得到整个数组的倒置,可以先对换其首、末元素,然后递归地倒置除了这两个元素以外的部分。
void reverse(int*, int, int); //重载得倒置算法原型
void reverse(int* A, int n) //数组倒置(算法得初始入口,调用的可能时reverse()的递归版或迭代版
{
reverse(A, 0, n - 1); //由重载的入口启动递归或迭代算法
}
void reverse(int* A, int lo, int hi) //数组倒置(多递归基版)
{
if (lo < hi) //问题规模地奇偶性不变,需要两个递归基
{
swap(A[lo], A[hi]); //交换A[lo], A[hi]
reverse(A, lo + 1, hi - 1); //递归倒置A(lo, hi)
}//else 隐含了两种递归基
}//O(hi - lo + 1)
快速幂
递归版本还不是很懂,以后复习的时候可以重点关注一下。
//迭代版:
ll fastPower1(ll base, ll power)
{
ll res = 1;
while (power > 0)
{
if (power % 2)
res = res * base % 1000;
power /= 2;
base = base * base % 1000;
}
return res % 1000;
}
//递归版:
inline ll sqr(ll a) { return a * a % 1000; }
ll fastPower2(ll base, ll power)
{
if (power == 0)return 1;
return power & 1 ? sqr(fastPower2(base, power >> 1)) * base % 1000 : sqr(fastPower2(base, power >> 1));
}//O(logn) = O(r),r为输入指数n的比特位数
递归消除
在对运行速度要求极高、存储空间需精打细算的场合,往往应将递归算法改写成等价的非递归版本。
尾递归及其消除
在线性递归算法中,若递归调用在递归实例中恰好以最后一步操作的形式出现,则称作尾递归(tail recursion)。实际上,属于尾递归形式的算法,均可以简洁的转换为迭代版本。
数组倒置(迭代版)
上面递归版的数组倒置算法中的最后一步操作,是对去除了首、末元素之后总长缩减两个单元的子数组进行递归倒置,属于典型的尾递归。以下为该算法的迭代版本:
void reverse(int* A, int lo, int hi) //迭代版本的数组倒置
{
while (lo < hi)
swap(A[lo++], A[hi--]);
}//O(hi - lo + 1)
二分递归
分而治之(Divide-and-conquer)
为求解一个大规模的问题,可以将其划分为若干(通常两)个子问题,规模大体相当,直到子问题的规模缩减至平凡情况,分别求解子问题,由子问题的解,得到原问题的解。
由于每一递归实例都可以做多次递归,故称作多路递归(multi-way recursion),而通常都是将原问题一分为二,故称作二分递归(binary recursion)。
无论是分解为两个还是更多个子问题,对算法总体的渐进复杂度并无实质影响。
数组求和(二分递归版)
以居中的元素为界将数组一分为二,递归地对子数组分别求和,最后,子数组之和相加即为原数组的总和。
int sum(int A[], int lo, int hi) //数组求和算法(二分递归版,入口为sum(A, 0, n-1))
{
if (lo == hi)return A[lo]; //遇到递归基(区间长度为1),直接返回该元素
int mid = (lo + hi) >> 1; //以居中元素为界,将数组一分为二
return sum(A, lo, mid) + sum(A, mid + 1, hi); //递归对各子数组求和,然后合计
} //O(hi - lo + 1)
算法启动后经连续m = log2n次递归调用,数组区间的长度从最初的n首次缩减至1,并到达第一个递归基。
递归深度(即任一时刻的活跃递归实例的总数)不会超过m + 1。鉴于每个递归实例仅需常数空间, 故除数组本身所占的空间,该算法只需要O(m + 1) = O(log2n)的附加空间 ,递归实例共计2n - 1个,故新算法的运行时间为O(2n - 1) = O(n),与线性递归版相同。
效率
并非所有问题都适宜采用分治策略,必须保证子问题之间相互独立(各子问题可独立求解,而无需借助其它子问题的原始数据或中间结果,否则会导致时间和空间复杂度的无谓增加(例:Fibonacci数列)