第二章 递归
2.1递归的概念
递归思想(降维)
将一个大型复杂的问题层层转化为一个(或几个)与原问题相似的规模较小的问题来求解(递归本体)。继续下去知道子问题简单到能够直接求解(递归出口)。
1.子问题须与原问题为同样的事,且更为简单(规模更小);
2.不能无限制地调用本身,须有个出口,化简为非递归状况处理。
递归算法
一个直接或间接调用自身的算法称为递归算法。
int jie(int n) {
if (n == 1) return 1;
return jie(n - 1) * n;
}
2.2递归算法的应用和实现
如果问题的数据结构是递归的,问题的定义是递归的,问题的解法是递归的,可以考虑用递归算法。
2.2.1递归算法的应用
2.2.1.1问题的数据结构是递归的
2.2.1.1.1链表
这是单链表,每个结点有两个域:数据域和指针域。它是一种递归的结构,可定义为:1)一个结点,其指针域为null,是一个单链表;2)一个结点,其指针域为有效指针指向一个单链表,仍是一个单链表。
建立链表有几个注意事项:
1.链表的定义:
typedef struct node{
int data;
struct node* next;
}*point;
因为定义里面要用到指针,所以结构体命名的时候不可以不写名字。
后面写一个重命名:*point,方便。
2.链表的输入:(尾插法)
void input(point& h,int n) {
h = new node;
if (!h) { cout << "创建失败!" << endl; return; }
int x;
point q = h;
while (n--) {
cin >> x;
point p = new node;
p->data = x;
q->next = p;
q = p;
}
q->next = NULL;//(这里用q不用p是因为如果用p的话,在外面要new node)
}
最重的是:循环里面每循环一次就要给p一次空间,引用。
头指针也要new node(给一个空间)
在最后,写p->next=NULL:
注意函数的形参,头指针要设置为引用,要修改。
3.打印链表(两种,正常的打印(迭代),递归的打印)
递归算法,有一点问题是,因为要一直往回溯,所以要回溯到h->data,但是h->data没有初始化,所以这里要处理一下。
//输出链表
//迭代(正着输出)
void output(point h) {
point p = h->next;
while (p != NULL) {
cout << p->data << " ";
p = p->next;
}
}
//递归(逆着输出)
void out(point p){
if (p == NULL) {
return;
}
if (p->next == NULL) {
cout << p->data << " ";
return;
}
out(p->next);
if(p!=h)
cout << p->data << " ";
}
//输出会有点问题,重新设置一下,不再直接输出h,而是新设了一个
//p,让p来递归,同时可以用p和h判断是否相等。
//把上面的递归修改一下
void out(point p){
if (p == NULL) {
return;
}
if (p->next == NULL) {
cout << p->data << " ";
return;
}
out(p->next);
cout << p->data << " ";
}
//这样的话,在主函数里面调用的时候,out(h->next);
补充一个递归算法,
比上面那个简单,
//这个是正着输出
void out(point h) {
if (h == NULL)return;
if (h->next == NULL)return;
cout << h->next->data<<" ";
out(h->next);
}
**任务1:打印最后一个结点:
因为链表本身也是一种递归的结构,就是大的是h作为头指针的链表,依次往下看是,h->next作为头指针的链表,以此类推,直到链表为空,输出。
//实现打印最后一个结点(递归)
point find_last(point h) {
if (h == NULL)return NULL;//这里包含了只有头指针的情况,
//因为本来头指针就没有数据域,如果指针域也为空,就直接为空了。
if (h->next == NULL)cout << h->data << endl;
find_last(h->next);
}
//消除尾递归
point find_last2(point h) {
if (h == NULL)return NULL;
point p = h;
while (p->next != NULL) {
p = p->next;
}
cout << p->data << endl;
}
**任务2:单链表逆序:
//链表逆置
point nizhi(point p,point& h) {
if (h == NULL)return NULL;
if (p->next == NULL) {
h->next = p;
return p;
}
point temp = nizhi(p->next, h);
p->next = NULL;
temp->next = p;
return p;
}
point nizhi01(point h) {
if (h == NULL || h->next == NULL) {
return h;
}
point temp = nizhi01(h->next);
h->next->next = h;
h->next = NULL;
return temp;
}
第二个方法,返回值直接作为h。但是调用的时候是h->next.
第一个直接调用函数即可,在函数内部修改了h。
第一个方法是返回值为新链表的头结点,但是要不断地将要插入新链表的结点的next域置空,而且指控之后,再回溯一次就不空了,除了第一个节点。
第二个方法是,返回值直接为新的头结点的下一个节点(首元结点),然后让h去递归,层层深入。
2.2.1.1.2二叉树
图上这个0是表示没有结点,方便区分左右结点。
先序遍历序列是1 2 3 4 5 6 10 11 7 8
代码实现:
输入:1 2 3 4 0 0 5 0 0 0 6 10 11 0 0 0 7 0 8 0 0
//定义二叉树
typedef struct bTree {
int data;
struct bTree* rchild, * lchild;
}*tpoint;
//创建二叉树
void set(tpoint& th) {
int x;
cin >> x;
if (x == 0) {
th = NULL; return;
}
th = new bTree;
th->data = x;
set(th->lchild);
set(th->rchild);
}
//二叉树的遍历
//先序(递归)
void bian_x(tpoint th) {
if (th == NULL) {
return;
}
cout << th->data << " ";
bian_x(th->lchild);
bian_x(th->rchild);
}
//用栈实现先序
stack <tpoint> s;
void bian_s(tpoint th) {
if (th == NULL)return;
s.push(th);
while (!s.empty()) {
tpoint tp = s.top();
s.pop();
//cout << "size: " << s.size() << endl;
while (tp != NULL) {
cout << tp->data << " ";
s.push(tp->rchild);
tp = tp->lchild;
}
}
}
//后序遍历(递归)
void bian_h(tpoint th) {
if (th == NULL) {
return;
}
bian_h(th->lchild);
bian_h(th->rchild);
cout << th->data << " ";
}
运行结果:
1 2 3 4 0 0 5 0 0 0 6 10 11 0 0 0 7 0 8 0 0
递归遍历:
1 2 3 4 5 6 10 11 7 8
栈遍历:
1 2 3 4 5 6 10 11 7 8
后序遍历:
4 5 3 2 11 10 8 7 6 1
任务1:求树的深度
树的深度=max{左子树的深度,右子树的深度}
//树的深度
int high_t(tpoint th,int h) {
if (th == NULL)return h;
h++;
int lh = high_t(th->lchild, h);
int rh = high_t(th->rchild, h);
return max(lh, rh);
}
任务2:求叶子结点的个数
叶子数目=左边的叶子结点的数目+右边的叶子结点的数目。
//求叶子结点的数目
int num = 0;
void yezi(tpoint th) {
if (th == NULL)return;
if (th->lchild == NULL&&th->rchild==NULL) { num++; return; }
yezi(th->lchild);
yezi(th->rchild);
}
2.2.1.2问题的定义是递归的
这种先if( ),递归出口
然后再写递归本体
2.2.1.2.1Fibonacci数列
F i b ( n ) = { 1 n = 0 1 n = 1 F i b ( n − 1 ) + F i b ( n − 2 ) n > 1 Fib(n)=\begin{cases} 1&n=0\\ 1&n=1\\ Fib(n-1)+Fib(n-2)&n>1 \end{cases} Fib(n)=⎩⎪⎨⎪⎧11Fib(n−1)+Fib(n−2)n=0n=1n>1
递归方法和非递归方法(数组存储):
//递归
int f(int n) {
if (n == 0)return 1;
if (n == 1)return 1;
return f(n - 1) + f(n - 2);
}
//非递归
const int N = 1e5;
int a[N];
//非递归(循环)
int f2(int n) {
if (n < 0)return -1;
if (n < 2)return 1;
int x = 1, y = 1,z=0;
for (int i = 2; i <= n; i++) {
z = x + y;
x = y;
y = z;
}
return z;
}
int main() {
int t, n;
cout << "请输入要进行的次数:" << endl;
cin >> t;
cout << "非递归:" << endl;
int m;
cout << "请输入要计算n的最大值:" << endl;
cin >> m;
a[0] = 1, a[1] = 1;
for (int i = 2; i <= m; i++) {
a[i] = a[i - 1] + a[i - 2];
}
int t1 = t;
while (t--) {
cin >> n;
cout << a[n] << " ";
}
cout << endl << "递归:" << endl;
while (t1--) {
cin >> n;
cout << f(n) << " ";
}
cout << "非递归(循环):" << endl;
while (t2--) {
cin >> n;
cout << f2(n) << " ";
}
个人觉得还是我写的数组比较好。
2.2.1.3问题的解法是递归的
2.2.1.3.1整数划分问题
将一个正整数n表示成一系列正整数之和,n=n1+n2+…+nk,其中n1≥n2≥…≥nk≥ 1,k ≥1正整数n的一个这种表示称为正整数n的一个划分。正整数n的不同划分个数称为正整数n的划分数,记做p(n)。
整数划分,我们先定义一个函数q(n,m)表示用不大于m的数表示n有几种可能。先考虑几种特殊情况,m>n时,q(n,m)=q(n,n);
q(n,n)又可以变换成q(n,n-1)+1(n=m);
当m=1时,q(n,m)=1;
最常见的情况是n>m>1,这时q(n,m)可以看作是用了m,不用m分开两类q(n,m)=q(n,m-1)+q(n-m,m){前面一项时没有用,后面一项是必须用}
q
(
n
,
m
)
=
{
0
n
<
1
或
m
<
1
1
m
=
1
q
(
n
,
n
)
n
<
m
1
+
q
(
n
,
n
−
1
)
n
=
m
q
(
n
,
m
−
1
)
+
q
(
n
−
m
,
m
)
n
>
m
>
1
q(n,m)=\begin{cases} 0&n<1或m<1\\ 1&m=1\\ q(n,n)&n<m\\ 1+q(n,n-1)&n=m\\ q(n,m-1)+q(n-m,m)&n>m>1 \end{cases}
q(n,m)=⎩⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎧01q(n,n)1+q(n,n−1)q(n,m−1)+q(n−m,m)n<1或m<1m=1n<mn=mn>m>1
代码实现:
int q(int n, int m) {
if (n < 1 || m < 1)return 0;
if (m == 1||n==1)return 1;
if (n <= m)return q(n, n-1)+1;
return q(n, m - 1) + q(n - m, m);
}
2.2.1.3.2设计模拟汉诺塔问题求解过程的算法。
汉诺塔问题的描述是:设有3根标号为A,B,C的柱子,在A柱上放着n个盘子,每一个都比下面的略小一点,要求把A柱上的盘子全部移到C柱上,移动的规则是:(1)一次只能移动一个盘子;(2)移动过程中大盘子不能放在小盘子上面;(3)在移动过程中盘子可以放在A,B,C的任意一个柱子上。
问题分析:可以用递归方法求解n个盘子的汉诺塔问题。
基本思想:1个盘子的汉诺塔问题可直接移动。n个盘子的汉诺塔问题可递归表示为,首先把上边的n-1个盘子从A柱移到B柱,然后把最下边的一个盘子从A柱移到C柱,最后把移到B柱的n-1个盘子再移到C柱。
const int N = 1e5;
int pl[N], n;
char A = 'A', B = 'B', C = 'C';
void han(char A, char B, char C,int m) {
if (m == 1) {
cout << m << "号盘: from " << A << " to " << C << endl;
return;
}
han(A, C, B, m - 1);
cout << m << "号盘: from " << A << " to " << C << endl;
han(B, A, C, m - 1);
}
2.2.2递归算法的设计方法
递归算法既是一种有效的算法设计方法,也是一种有效的分析问题的方法。递归算法求解问题的基本思想是:对于一个较为复杂的问题,把原问题分解成若干个相对简单且类同的子问题,这样,原问题就可递推得到解。
适宜于用递归算法求解的问题的充分必要条件是:(1)问题具有某种可借用的类同自身的子问题描述的性质;(2)某一有限步的子问题(也称作本原问题)有直接的解存在。
当一个问题存在上述两个基本要素时,该问题的递归算法的设计方法是:
(1)把对原问题的求解设计成包含有对子问题求解的形式。(写成一个递归函数)
(2)设计递归出口。
设计举例:
2.2.2.1猴吃桃问题
猴子吃桃。有一群猴子摘来了一批桃子,猴王规定每天只准吃一半加一只(即第二天吃剩下的一半加一只,以此类推),第九天正好吃完,问猴子们摘来了多少桃子?
我的分析:因为第九天刚好吃完了,所以原本第九天只有两个桃子。
设一个函数f(x)(表示第x天开始的时候桃子的数目)
f
(
x
)
=
{
2
x
=
n
0
x
=
n
+
1
(
f
(
x
+
1
)
+
1
)
∗
2
x
<
n
f(x)=\begin{cases} 2&x=n\\ 0&x=n+1\\ (f(x+1)+1)*2&x<n \end{cases}
f(x)=⎩⎪⎨⎪⎧20(f(x+1)+1)∗2x=nx=n+1x<n
利用递归写算法:
递归出口时x=n或者x=n+1时。
int tao(int n,int i) {
if (i == n)return 2;
return (tao(n, i + 1) + 1) * 2;
}
int main() {
int n;
cin >> n;
cout << tao(n, 1);
上面的方法有点浪费空间,可以写成尾递归形式:
//尾递归
int tao2(int n, int s) {
return (n == 1) ? s : tao2(n - 1, 2 * (s + 1));
}
2.2.2.2输出形式
例1 设计一个输出如下形式数值的递归算法。
n n n … n
…
3 3 3
2 2
1
由题可见,递归出口是n=1,递归本体是一直输出n个n.
void shuchu(int n) {
if (n == 1) {
cout << "1" << endl;
return;
}
for (int i = 1; i <= n; i++) {
cout << n << " ";
}
cout << endl;
shuchu(n - 1);
}
2.2.2.3委员会问题
设计求解委员会问题的算法。委员会问题是:从一个有n个人的团体中抽出k (k≤n)个人组成一个委员会,计算共有多少种构成方法。
问题分析:从n个人中抽出k(k≤n)个人的问题是一个组合问题。把n个人固定位置后,从n个人中抽出k个人的问题可分解为两部分之和:第一部分是第一个人包括在k个人中,第二部分是第一个人不包括在k个人中。对于第一部分,则问题简化为从n-1个人中抽出k-1个人的问题;对于第二部分,则问题简化为从n-1个人中抽出k个人的问题。
先分析问题将问题转化为递归形式的函数:
找到递归出口。
c(n,k)=c(n-1,k)+c(n-1,k-1);
c
(
n
,
k
)
=
{
1
n
=
k
n
k
=
0
c
(
n
−
1
,
k
)
+
c
(
n
−
1
,
k
−
1
)
n
>
k
c(n,k)=\begin{cases} 1&n=k\\ n&k=0\\ c(n-1,k)+c(n-1,k-1)&n>k \end{cases}
c(n,k)=⎩⎪⎨⎪⎧1nc(n−1,k)+c(n−1,k−1)n=kk=0n>k
设计程序:
int f(int n,int k) {
if (n == k)return 1;
if (k == 1)return n;
return f(n - 1, k - 1) + f(n - 1, k);
}
2.2.2.4求两个正整数n和m最大公约数
辗转相除法:(a>b)
a/b=p,a%b=q
求a,b的最大公约数就是求b,q的最大公约数,当q=0时,b就是最大公约数。求最大公约数的公式定义为f(a,b)
问题分析:公式1:
f
(
a
,
b
)
=
{
f
(
b
,
a
)
b
>
a
f
(
b
,
a
%
b
)
a
%
b
!
=
0
b
a
%
b
=
0
f(a,b)=\begin{cases} f(b,a)&b>a\\ f(b,a\%b)&a\%b!=0\\ b&a\%b=0 \end{cases}
f(a,b)=⎩⎪⎨⎪⎧f(b,a)f(b,a%b)bb>aa%b!=0a%b=0
公式2:
f
(
a
,
b
)
=
{
f
(
b
,
a
)
b
>
a
f
(
b
,
a
%
b
)
a
%
b
!
=
0
a
b
=
0
f(a,b)=\begin{cases} f(b,a)&b>a\\ f(b,a\%b)&a\%b!=0\\ a&b=0 \end{cases}
f(a,b)=⎩⎪⎨⎪⎧f(b,a)f(b,a%b)ab>aa%b!=0b=0
程序设计:
int f(int a, int b) {
if (a < b) {
f(b, a);
}
if (b == 0)return a;
f(b, a % b);
}
2.2.3递归过程的实现
方法调用离不开栈,递归不过是一种特殊的方法调用,即所谓的“自己调用自己”。
每递归一次,增加一层,函数一样,参数不同,本地变量的值不同。
每层间的关系满足先进后出的特性,这是栈。
所以递归要通过栈这个数据结构来维护方法间的调用关系,而且要保存每一层的本地变量的值。
调用函数在调用被调用函数前,系统要保存以下两类信息:
(1)调用函数的返回地址;
(2)调用函数的局部变量值。
当执行完被调用函数,返回调用函数前,系统首先要恢复调用函数的局部变量值,然后返回调用函数的返回地址。
递归函数被调用时,系统要作的工作和非递归函数被调用时系统要作的工作在形式上类同.
递归函数被调用时,系统的运行时栈也要保存上述两类信息。每一层递归调用所需保存的信息构成运行时栈的一个工作记录,在每进入下一层递归调用时,系统就建立一个新的工作记录,并把这个工作记录进栈成为运行时栈新的栈顶;每返回一层递归调用,就退栈一个工作记录。因为栈顶的工作记录必定是当前正在运行的递归函数的工作记录,所以栈顶的工作记录也称为活动记录。
2.3递归问题的非递归算法
一般说来,递归过程的实现效率是非常低的,每次递归调用都必须首先做诸如参数替换、环境保护等事情。造成效率低下的另一个重要的原因是大量的重复计算。
例如Fibonacci数列,
F
i
b
(
n
)
=
{
1
n
=
0
1
n
=
1
F
i
b
(
n
−
1
)
+
F
i
b
(
n
−
2
)
n
>
1
Fib(n)=\begin{cases} 1&n=0\\ 1&n=1\\ Fib(n-1)+Fib(n-2)&n>1 \end{cases}
Fib(n)=⎩⎪⎨⎪⎧11Fib(n−1)+Fib(n−2)n=0n=1n>1
如果要求f(5)
在计算的过程中,会重复多次计算f(3),f(2),f(1)等等;导致效率很低
2.3.1将递归算法转化为非递归算法的方法:
2.3.1.1设计迭代算法
如果一个函数既有递归形式的定义又有非递归的迭代形式的定义,则可以用循环结构设计出迭代算法。一般说来,如果在一个函数或过程中只递归调用它一次,那么它的计算或执行过程可以看成是线性变化的。
2.3.1.1.1例如:求阶乘算法。
f ( n ) = n ! 按 递 归 形 式 写 : f ( n ) = { n ∗ f ( n − 1 ) n > 0 1 n = 0 f(n)=n!\\ 按递归形式写: f(n)=\begin{cases} n*f(n-1)&n>0\\ 1&n=0 \end{cases} f(n)=n!按递归形式写:f(n)={n∗f(n−1)1n>0n=0
在阶乘算法中,每个f(n)只被调用一次,所以可写成循环来实现;
int n, t;
//递归
int f1(int n) {
if (n < 0)return -1;
if (n == 0)return 1;
return n * f1(n - 1);
}
//非递归
int f2(int n) {
if (n < 0)return -1;
int s = 1;
for (int i = 1; i <= n; i++) {
s *= i;
}
return s;
}
//尾递归
int f3(int n,int as) {
if (n <= 1) {
return as;
}
f3(n - 1, as * n);
}
顺序执行、循环和跳转是冯·诺依曼计算机体系中程序设计语言的三大基本控制结构,这三种控制结构构成了千姿百态的算法,程序,乃至整个软件世界。递归也算是一种程序控制结构,但是普遍被认为不是基本控制结构,因为递归结构在一般情况下都可以用精心设计的循环结构替换,因此可以说,递归就是一种特殊的循环结构。
补充知识:
令f(x)表示正整数x末尾所含有的“0”的个数,则有:
当0 < n < 5时,f(n!) = 0;
当n >= 5时,f(n!) = k + f(k!), 其中 k = n / 5(取整)。
也可以写成递归形式。
2.3.1.1.2Fibnaocci数列
见前(2.2.1.2)
2.3.1.2消除尾递归:递归调用是最后一步操作
可以用循环结构通过设置一些工作单元,把递归算法转化为非递归算法。开始令工作单元等于外层的实际参数,以后随着循环的执行,不断向里层变化,直到原递归调用的最里层的情况。循环结束后,执行原属于最里层的操作,而后整个算法结束。
###插入:尾递归
递归调用是最后一步操作;
如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。
例子:猴吃桃:见前(2.2.2.1)
阶乘(见前:2.3.1.1.1)
尾递归的效果就是去除了将下层的结果再次返回给上层,需要上层继续计算才得出结果的弊端。
其实,寻找单链表的最后一个结点并打印其数据域的值的过程search就是一个尾递归过程。
可以将这个代码用循环实现,消除尾递归。(见前:2.2.1.1.1)
2.3.1.3利用堆栈
递归的实现是基于堆栈的,当一个递归问题不容易找到它的迭代算法又不属于尾递归时,通常通过引入一个工作栈保存“返回位置”以实现过程调用一返回控制;这一思想同样适用于递归消除。下面以先根遍历为例,具体讨论如何用工作栈消除递归。
首先必须弄清工作栈的作用方式,即弄清怎样用工作栈控制先根遍历的“走向”。二叉链表上的任一X以及它的左XL和右子树XR。假设t是指向结点X的指针.
具体代码见前(2.2.1.1.2)
通过上面的例子可以看出,工作栈在消除递归中的基本作用是提供一种控制机构。在非递归算法执行过程中的某些“关键”时刻,用栈顶元素来“引导”下一步操作的“走向”。为了达到这一目的,必须提前将这些有用的信息进栈保存。在上面的例子中,工作栈保存的是各个结点的右指针,这些指针也就是二叉树上各结点的右子树的根指针。显然,这些根指针正是先根遍历递归算法 中包含的一些递 归调用的实参;这些实参在非递归算法中若不及时保存就会丢失。因此,递归算法中的调用一返回控制被工作栈的作用所取代,从而将递归算法转换成非递归算法。
阶乘也可以,不写了。
2.4递归方程解的渐进阶的求法
定理:设a,b,c是非负常数,n是c的整幂,则递归方程:
T
(
n
)
=
{
b
若
n
=
1
a
T
(
n
/
c
)
+
b
n
若
n
>
1
的
解
是
:
T
(
n
)
=
{
O
(
n
)
若
a
<
c
O
(
n
l
o
g
2
n
)
若
a
=
c
O
(
n
l
o
g
c
a
)
若
a
>
c
T(n)= \begin{cases} b&若n=1\\ aT(n/c)+bn&若n>1 \end{cases} \\的解是:\\ T(n)=\begin{cases} O(n)&若a<c\\ O(nlog_2n)&若a=c\\ O(n^{log_ca})&若a>c \end{cases}
T(n)={baT(n/c)+bn若n=1若n>1的解是:T(n)=⎩⎪⎨⎪⎧O(n)O(nlog2n)O(nlogca)若a<c若a=c若a>c