递归的数学定义和编程应用
递归的定义
在定义一个过程或函数时,出现直接或者间接调用自己的成分,称之为递归。
- 若直接调用自己,称之为直接递归。
- 若间接调用自己,称之为间接递归。
/*直接递归*/
int
fact( int n )
{
if( n == 1 ){
return 1;
}else{
return ( n * fact( n-1 ) );
}
}
/*间接递归*/
void
f1( void )
{
f2();
}
void
f2( void )
{
f1();
}
如果一个递归函数中递归调用语句是最后一条执行语句,如上文的fact
函数,则称这种递归调用为尾递归 ( tail recursion )。
递归算法和非递归算法的转换
- 尾递归算法可以用循环语句转换为等价的非递归算法。
- 其他递归算法可以通过栈转换为等价的非递归算法。
有些编译器会把尾递归转换为循环指令,这样不会占用栈空间。
$ gcc a.c -O3 #O3为优化选项
追踪递归函数
C通过运行时栈支持递归函数的实现。追踪一个递归函数执行过程的关键是理解函数中所声明的变量是如何存储的。
程序用例:用递归方法将整数逐位输出
void
print_num( int n )
{
if( n == 0 ){
return;
}
print_num( n / 10 );
printf("%d ", n % 10);
}
当函数被调用时,函数的局部变量、参数值以及返回地址都被压入栈中。调用其他函数时(调用自己和调用别的函数没有区别),在栈上开辟新的空间压入新函数的信息,新函数的变量掩盖了之前调用的函数的变量。
当函数返回时,位于栈顶的局部变量、参数值和返回地址被弹出,返回调用层次中执行代码的其余部分。
程序用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。
x86-64的栈向低地址方向增长,而栈指针%rsp指向栈顶元素。
什么时候使用递归
- 定义是递归的
例如阶乘的递推形式
f a c t o r i a l ( 1 ) = 1 f a c t o r i a l ( n ) = n ∗ f a c t o r i a l ( n − 1 ) ( n > 1 ) \begin{aligned} factorial(1) &= 1\\ factorial(n) &= n*factorial(n-1)(n>1) \end{aligned} factorial(1)factorial(n)=1=n∗factorial(n−1)(n>1)
斐波那契数列
F i b o n a c c i ( 1 ) = 1 F i b o n a c c i ( 2 ) = 1 F i b o n a c c i ( n ) = F i b o n a c c i ( n − 1 ) + F i b o n a c c i ( n − 2 ) ( n > 2 ) \begin{aligned} Fibonacci(1) &= 1\\ Fibonacci(2) &= 1\\ Fibonacci(n) &= Fibonacci( n-1) + Fibonacci( n-2)(n>2) \end{aligned} Fibonacci(1)Fibonacci(2)Fibonacci(n)=1=1=Fibonacci(n−1)+Fibonacci(n−2)(n>2) - 数据结构是递归的
有些数据结构是递归的。例如单链表的结点定义
typedef struct _node{
Elemtype data;
struct _node *next;
}node;
如果我们换一个角度来看单链表,说不定能看到下面这幅图的样子。
可以看到next
指针域指向了相同类型的结点,自己指向自己。
- 问题的求解方法是递归的
例如汉诺塔问题
当 n = 1 : H a n o i ( n ) = 1 当 n > 1 : H a n o i ( n ) = 2 H a n o i ( n − 1 ) + 1 \begin{aligned} 当n=1:Hanoi(n) &= 1\\ 当n>1:Hanoi(n) &= 2Hanoi(n-1)+1 \end{aligned} 当n=1:Hanoi(n)当n>1:Hanoi(n)=1=2Hanoi(n−1)+1
递归模型
递归模型是递归算法的抽象,它反映一个递归问题的递归结构。一般地,一个递归模型由递归出口和递归体两部分组成
递归出口
递归出口确定递归到何时结束,递归出口的一般格式如下:
f
(
s
1
)
=
m
1
f(s_1)=m_1
f(s1)=m1
这里的s1和m1均为常量。递归出口不一定只有一个,有些递归问题可能有几个递归出口。
递归体
递归确定递归求解时的递推关系,递归体的一般格式如下:
f
(
s
n
)
=
g
(
f
(
s
i
)
,
f
(
s
i
+
1
)
,
.
.
.
,
f
(
s
n
−
1
)
,
c
j
,
c
j
+
1
,
.
.
.
,
c
m
)
f(s_n)=g( f(s_i),f(s_{i+1}),...,f(s_{n-1}),c_j,c_{j+1},...,c_m)
f(sn)=g(f(si),f(si+1),...,f(sn−1),cj,cj+1,...,cm)
g是一个非递归函数,一串c是常量。
递归思路
把一个不能或不好直接求解的大问题转化成一个或几个小问题来解决,再把这些小问题进一步分解成更小的小问题来解决。直到每个小问题都可以直接解决(此时分解到递归出口)。
递归分解不是随意的分解,递归分解要保证大问题和小问题相似,即求解过程和环境都相似。
一般的递归模型
f u n ( s 1 ) = m 1 f u n ( s n ) = g ( f ( s n − 1 ) , c n − 1 ) \begin{aligned} fun(s_1) &= m_1\\ fun(s_n) &= g( f(s_{n-1}),c_{n-1})\end{aligned} fun(s1)fun(sn)=m1=g(f(sn−1),cn−1)
分解过程
f ( s n ) → f ( s n − 1 ) → . . . → f ( s 2 ) → f ( s 1 ) f(s_n)→f( s_{n-1})→...→f(s_2)→f(s_1) f(sn)→f(sn−1)→...→f(s2)→f(s1)
求值过程
f u n ( s 1 ) = m 1 f u n ( s 2 ) = g ( f ( s 1 ) , c 1 ) f u n ( s 3 ) = g ( f ( s 2 ) , c 2 ) . . . f u n ( s n ) = g ( f ( s n − 1 ) , c n − 1 ) \begin{aligned} fun(s_1) &= m_1\\ fun(s_2) &= g( f(s_1),c_1)\\ fun(s_3) &= g( f(s_2),c_2)\\ &...\\ fun(s_n) &= g( f(s_{n-1}),c_{n-1})\\ \end{aligned} fun(s1)fun(s2)fun(s3)fun(sn)=m1=g(f(s1),c1)=g(f(s2),c2)...=g(f(sn−1),cn−1)
递归算法设计的步骤
- 设计求解问题的递归模型
- 转换成对应的递归算法
求解问题的递归模型
- 对原问题f(s)进行分析,称为大问题,假设出合理的小问题f(s’)。
- 假设f(s’)是可解的,在此基础上确定f(s)的解,即给出f(s)和f(s’)之间的关系,从而得出递归体。
- 确定一种特殊情况(如f(1)或f(0))的解,即递归出口。
这个过程和数学归纳法类似。
例一 采用递归算法求整型数组A[0…n-1]中的最小值
假设f(A,i)
求数组元素A[0]~A[i](i+1个元素)中的最小值。
假设f( A, i-1 )
已求出,则f( A, i )
= MIN( f( A, i-1 )
, A[i] ),其中MIN()为求两个值较小值函数。
当i = 0时,只有一个元素,有f( A, i )
= A[0]
因此得到如下递归模型:
当
i
=
0
时
:
f
u
n
(
A
,
i
)
=
A
[
0
]
其
他
情
况
:
f
u
n
(
A
,
i
)
=
min
(
f
(
A
,
i
−
1
)
,
A
[
i
]
)
\begin{aligned} 当i=0时:fun(A,i) &= A[0] \\ 其他情况:fun(A,i) &= \min( f( A,i-1),A[i])\end{aligned}
当i=0时:fun(A,i)其他情况:fun(A,i)=A[0]=min(f(A,i−1),A[i])
int
f( int a[], int i )
{
int min;
if( i == 0 ){
return a[0];
}else{
min = f( a, i-1 );
if( min > a[i] ){
return a[i];
}else{
return min;
}
}
}
基于递归数据结构的递归算法设计
下面以单链表的递归算法设计为例
数据结点个数
- 链表尾条件
l->next == NULL
为true
在链表尾,链表只有一个结点,结点个数为1
,由此确定递归出口。整个链表的结点个数是大问题,将大问题一直分解到递归出口,最后求值即可得到结果。
当 l = N U L L : f ( l ) = 0 其 他 情 况 : f ( l ) = f ( l → n e x t ) + 1 \begin{aligned} 当l=NULL:f(l) &= 0 \\ 其他情况:f(l) &= f(l→next)+1 \end{aligned} 当l=NULL:f(l)其他情况:f(l)=0=f(l→next)+1
int
count( node *l )
{
if( l == NULL ){
return 0;
}else{
return ( count( l→next) + 1 );
}
}
正向输出所有结点值
前面提到尾递归可以用循环语句代替,反过来用循环可以正向输出单链表,尾递归也可以。
当
l
=
N
U
L
L
:
f
(
l
)
=
不
做
任
何
事
情
其
他
情
况
:
f
(
l
)
=
输
出
l
→
d
a
t
a
;
f
(
l
→
n
e
x
t
)
\begin{aligned} 当l=NULL:f(l) &= 不做任何事情\\ 其他情况:f(l) &= 输出l→data;f(l→next) \end{aligned}
当l=NULL:f(l)其他情况:f(l)=不做任何事情=输出l→data;f(l→next)
void
printList( node *l )
{
if( l == NULL ){
return;
}
printf("%d", l→data);
printList( l→next );
}
正如上文提到的,尾递归可以用循环来代替
反向输出所有结点值
反向输出链表,让后进的先出,很快能联想到栈的数据结构,使用递归的方式,把所有结点的地址信息存放在运行时栈中,到达递归出口后再将所有内容弹出并一一打印。
当
l
=
N
U
L
L
:
f
(
l
)
=
不
做
任
何
事
情
其
他
情
况
:
f
(
l
)
=
f
(
l
→
n
e
x
t
)
;
输
出
l
→
d
a
t
a
\begin{aligned} 当l=NULL:f(l) &= 不做任何事情\\ 其他情况:f(l) &= f(l→next);输出l→data \end{aligned}
当l=NULL:f(l)其他情况:f(l)=不做任何事情=f(l→next);输出l→data
void
reverse( node *l )
{
if( l == NULL ){
return;
}
reverse( l→next );
printf("%d", l→data);
}
递归算法在效率上有优势,一般反向输出的算法都是O(n2),而递归算法的时间复杂度为O(n)
可以看到,正向输出和反向输出的代码除了执行顺序没有什么不同,在设计递归算法时尤其要注意递归调用的时机。
销毁表
当 l 为 空 : f ( l ) = 什 么 都 不 做 当 l 非 空 : f ( l ) = f ( l → n e x t ) ; f r e e ( l ) \begin{aligned} 当l为空: f(l) &= 什么都不做\\ 当l非空:f(l) &= f(l→next);free(l) \end{aligned} 当l为空:f(l)当l非空:f(l)=什么都不做=f(l→next);free(l)
void
destroyList( node *l )
{
if( l != NULL ){
destroyList( l→next );
free( l );
}
}
递归应用
汉诺塔(Tower of Hanoi)
有三根柱子a
、b
和c
,a
柱上有n
个(n
>1)穿孔圆盘,盘的尺寸由下到上依次变小。要求将a
柱上的n
个圆盘移到c
柱上。
- 每次只能移动一个圆盘;
- 大盘不能叠在小盘上面。
移动过程中可将圆盘临时置于b
柱,也可以将从a
柱移出的圆盘重新移回a
柱,但都必须遵循上述两条规则。
x
塔有n
块圆盘,大问题是将这些盘全部移到z
塔,先把x
塔顶部的n-1
块盘移动到y
塔,再把剩下的1
块大盘移动到z
塔,最后再把y
塔的n-1
块盘移到z
。
void
hanoi( int n, char from, char buffer, char to )
{
if( n == 0 ){
return;
}
hanoi( n-1, from, to, buffer );
printf("Move disk from %c to %c\n", from, to );
hanoi( n-1, buffer, from, to );
}
递推形式描述如下
当
n
=
1
:
H
a
n
o
i
(
n
)
=
1
当
n
>
1
:
H
a
n
o
i
(
n
)
=
2
H
a
n
o
i
(
n
−
1
)
+
1
\begin{aligned} 当n=1:Hanoi(n) &= 1\\ 当n>1:Hanoi(n) &= 2Hanoi(n-1)+1 \end{aligned}
当n=1:Hanoi(n)当n>1:Hanoi(n)=1=2Hanoi(n−1)+1
全排列
假设a数组含有1,2,…,n,求其全排列。
设f( a, n, k )
为a[0…k](k+1个元素)的所有元素的全排列,为大问题。
则f( a, n, k-1 )
为a[0…k](k个元素)的所有元素的全排列,为小问题。
假设f( a, n, k-1 )
可求,对于a[k]
位置,可以取a[0…k]任何元素值,再组合f( a, n, k-1 )
,则得到f( a, n, k )
下标为k的位置可以取a[0]
~a[k]
中任何一个值,但不重复。采用循环:i
从0开始,循环到k,a[i]
和a[k]
交换。
当
k
=
0
:
f
(
a
,
n
,
k
)
=
输
出
a
其
他
情
况
:
f
(
a
,
n
,
k
)
=
a
[
k
]
位
置
取
a
[
0...
k
]
任
何
值
,
并
组
合
f
(
a
,
n
,
k
−
1
)
的
结
果
\begin{aligned} 当k=0: f(a,n,k) &= 输出a\\ 其他情况:f(a,n,k) &= a[k]位置取a[0...k]任何值,并组合f( a, n, k-1 )的结果 \end{aligned}
当k=0:f(a,n,k)其他情况:f(a,n,k)=输出a=a[k]位置取a[0...k]任何值,并组合f(a,n,k−1)的结果
#include <stdio.h>
void
perm( int a[], int n, int k )
{
int i, j;
if( k == 0 ){
for( j = 0; j < n; j++ ){
printf("%d", a[j] );
}
printf("\n");
}else{
for( i = 0; i <= k; i++ ){
int temp;
temp = a[k];
a[k] = a[i];
a[i] = temp;
perm( a, n, k-1 );
temp = a[k];
a[k] = a[i];
a[i] = temp;
}
}
}
int
main( void )
{
int a[] = {1,2,3};
perm( a, 3, 2 );
return 0;
}