递归
首先通过一个直观的例子来展示递归的用法:
int factorial(int n)
{
int result = n;
if(n > 0)
return result * factorial(n-1);
if(n == 0)
return 1;
else
{
cout << "error" << endl;
return 0;
}
}
以上是常用求 n ! n! n!的代码。注意递归只是被调用的函数名刚好同调用者相同,因此递归调用并不是表面上的调用自身,而是调用同一个函数的另一个实例。而C预言或者C++等语言均可以用过程来形容这一过程。
栈帧
不论是不是同一个函数,设调用者为P,被调用者为Q,简单的说就是:
P()
{
...
Q();
...
}
可以看到进程在调用P时,会在运行P的过程中调用Q,而每一个函数运行的时候都会有自己的栈区用以保存关键变量以及寄存器,返回值等内容。如下图所示:
对于每一个子函数,栈区顶端都会保存调用者的返回地址,例如Q退出后会回到进入Q之前的状态,并加入Q函数返回带来的变化。P和Q都有自己的栈帧,而回归我们的递归话题,只不过Q就变成了
P
′
P'
P′。栈帧的本质没有变化。
尾递归
尾递归就是在函数实现的末尾才有一次递归调用,且在此之前没有其他直接或者间接的递归。
void tail(int i)
{
if(i > 0)
{
tail(n-1);
...
tail(n-1);
}
}
该例就不是尾递归,因为最后一次递归之前又出现了其他语句或者递归。所以尾递归特别容易使用一个循环来代替。
尾递归两个条件:
- 递归语句在函数末尾。
- 函数体中有且仅有一个递归。
非尾递归
下面举一个例子,实现字符串反向输出:
void reverse()
{
char ch;
cin.get(ch)
if(ch != '\n')
{
reverse();
cout.put(ch);
}
}
根据运行时栈的原理,当执行输出的时候会遵照后进先出的原则进行反向输出。
a | b | c | d |
---|---|---|---|
‘\n’ | |||
(204) | |||
‘C’ | ‘C’ | ||
(204) | (204) | ||
‘B’ | ‘B’ | ‘B’ | |
(204) | (204) | (204) | |
‘A’ | ‘A’ | ‘A’ | ‘A’ |
(tomain) | (tomain) | (tomain) | (tomain) |
以上是输入"ABC\n"的栈情况。一共调用了4次,其中(204)代表函数在内存中的地址。
如果不使用递归,也可以使用标准栈结构实现,代码如下:
Stack<char> st;
void reverse()
{
char ch;
cin.get(ch);
while(ch != '\n')
{
st.push(ch);
cin.get(ch);
}
while(!st.empty())
cout.put(st.pop());
}
根据数据结构加上循环的模式,完全可以替代非尾递归。因此得出以下结论:
- 尾递归,可以用循环迭代的方式替代。
- 非尾递归,可以用显示数据结构与循环迭代的方式替代。
当然一个函数体中可以多次递归,这是一种非常重要的思想:分治。
初始的三角形,每一条边都可以运用这种思想。伪代码如下:
drawFourlines(side, level) // side为单条线段的总长度,level为递归次数。
{
if(level == 0)
画一条直线;
else
{
drawFourlines(side/3, level-1);
向左旋转60°;
drawFourlines(side/3, level-1);
向右旋转120°;
drawFourlines(side/3, level-1);
向左旋转60°;
drawFourlines(side/3, level-1);
}
}
间接递归
前面只讨论了函数体f()内直接调用f()的另一个实例。其实f()还可以通过其他一系列调用来调用自身。调用中间函数,再调用自身实例:
f() -> g1() -> g2() … -> f()
嵌套递归
更复杂的情况是函数还可以作为参数进行递归:
{
0
n
=
0
n
n
>
0
h
(
2
+
h
(
2
n
)
)
n
≤
4
\begin{cases} 0\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ n = 0\\ n\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ n > 0\\ h(2+h(2n))\ \ \ \ n≤4 \end{cases}
⎩⎪⎨⎪⎧0 n=0n n>0h(2+h(2n)) n≤4
代码如下,可以自行测试准确性。
#include <iostream>
using namespace std;
int factorial(uint32_t);
int main()
{
uint32_t state = 3;
cout << "Type the n!" << endl;
cin >> state;
state = factorial(state);
cout << "The end is " << state << endl;
return 0;
}
int factorial(uint32_t n)
{
if(n == 0)
return 0;
if(n > 4)
return n;
if(n <= 4)
return factorial(2+factorial(2*n));
}
不合理递归
递归的好处是逻辑的简单与可读,代价是降低了运行速度。与非递归相比,栈里存储的内容更多,次数太多容易导致栈空间耗尽进而引起崩溃。如果某个递归函数重复计算某些参数,即便问题有时可能非常简单,也会占用很长的时间。
下面引入著名的斐波那契数列(Fibonacci):
{ n n < 2 F i b ( n − 2 ) + F i b ( n − 1 ) e l s e \begin{cases} n\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ n < 2\\ Fib(n-2)+Fib(n-1)\ \ \ \ \ \ \ else\\ \end{cases} {n n<2Fib(n−2)+Fib(n−1) else
根据代码,最终都可以拆分为
a
∗
f
(
0
)
+
b
∗
f
(
1
)
a*f(0)+b*f(1)
a∗f(0)+b∗f(1)的形式,由于斐波那契数列的性质:
f
(
n
+
1
)
=
f
(
n
)
+
f
(
n
−
1
)
,
n
≥
1
f(n+1)=f(n)+f(n-1),n≥1
f(n+1)=f(n)+f(n−1),n≥1
设N为一个大于2的正数,每一项的数量为
f
k
(
n
)
f_k(n)
fk(n),容易得到:
f
k
(
N
)
=
1
,
f
k
(
N
+
1
)
=
0
;
∵
f
(
N
)
=
f
(
N
−
1
)
+
f
(
N
−
2
)
;
∴
f
k
(
N
−
1
)
=
1
,
f
k
(
N
−
2
)
=
f
k
(
N
)
+
f
k
(
N
−
1
)
=
2
;
∵
f
k
(
0
)
=
f
k
(
2
)
;
∴
可
以
将
以
上
过
程
等
效
当
N
的
值
合
法
时
,
f
(
N
−
1
)
=
f
k
(
1
)
;
f_k(N)=1,f_k(N+1)=0;\\ \because f(N)=f(N-1)+f(N-2);\\ \therefore f_k(N-1) = 1,f_k(N-2)=f_k(N)+f_k(N-1)=2;\\ \because f_k(0)=f_k(2);\\ \therefore 可以将以上过程等效\\ 当N的值合法时,f(N-1)=f_k(1);
fk(N)=1,fk(N+1)=0;∵f(N)=f(N−1)+f(N−2);∴fk(N−1)=1,fk(N−2)=fk(N)+fk(N−1)=2;∵fk(0)=fk(2);∴可以将以上过程等效当N的值合法时,f(N−1)=fk(1);
根据上面的推导,当N的值确定且合法时, f k ( ) f_k() fk()也是一个逆向的斐波那契数列,由于 f k ( 0 ) = f k ( 2 ) f_k(0)=f_k(2) fk(0)=fk(2),所以累加次数较 f ( ) f() f()少一次。根据斐波那契数列的性质:
f ( N − 1 ) = f k ( 1 ) ∗ 1 + f k ( 0 ) ∗ 0 f ( N ) = f k ( 1 ) + f k ( 0 ) f(N-1)=f_k(1)*1+f_k(0)*0\\ f(N)=f_k(1)+f_k(0) f(N−1)=fk(1)∗1+fk(0)∗0f(N)=fk(1)+fk(0)
以上过程相当于斐波那契又累加了一次, f ( N ) f(N) f(N)可以表示0和1的项数总和,因此可以由此判断该递归过程执行了多少次加法,可以表示为 s u m t i m e s ( N ) = f ( N + 1 ) − 1 sumtimes(N)=f(N+1)-1 sumtimes(N)=f(N+1)−1
斐波那契数列的代码(递归版本)如下:
unsigned int Fib(unsigned int n)
{
if(n<2)
return n;
else
return Fib(n-1)+Fib(n-2);
}
一次加法包含两次递归调用,加上最初的一次调用,所以调用次数为
c a l l t i m e s ( N ) = 2 f ( N + 1 ) − 1 calltimes(N)=2f(N+1)-1 calltimes(N)=2f(N+1)−1
由于斐波那契数列的快速增长,可以预见当n大于一个数时,调用次数会非常大,而递归会消耗栈空间,所以这种递归开销过大。另一方面,次数过多导致程序运行时间太长,所以我们需要寻求一种更简洁的实现方式。下面采用简单的迭代算法:
unsigned int Fib(unsigned int n)
{
if(n<2)
return n;
else
{
register int i = 2, tmp, current = 1, last = 0;
for(;i<=n;++i)
{
tmp = current;
current += last;
last = tmp;
}
return current;
}
}
此外关于斐波那契数列还有一种新的计算方式:
F
i
b
(
n
)
=
ϕ
n
+
ϕ
^
n
5
Fib(n)=\dfrac{\phi^n+\hat{\phi}^n}{\sqrt{5}}
Fib(n)=5ϕn+ϕ^n
其中 ϕ = 1 + 5 2 \phi = \dfrac{1+\sqrt{5}}{2} ϕ=21+5, ϕ ^ = 1 − ϕ = 1 − 5 2 ≈ − 0.618034 \hat{\phi}=1-\phi=\dfrac{1-\sqrt{5}}{2}\approx-0.618034 ϕ^=1−ϕ=21−5≈−0.618034,随着n的增长, ϕ ^ n \hat{\phi}^n ϕ^n会变得非常小,因此可以在公式中忽略它:
F i b ( n ) = ϕ n 5 Fib(n)=\dfrac{\phi^n}{\sqrt{5}} Fib(n)=5ϕn
近似计算:
其中ceil()为取整函数。
unsigned int deMoivreFib(unsigned int n)
{
return ceil(n*log(1.6180339897) - log(2.2360679775) - 0.5);
}
注:0.618是黄金分割线的位置,感兴趣可以查阅相关资料。
回溯
解决某些问题时,会出现一种情况,从起点到终点有一条路径,而路径上的各节点都有一些分支路径。当尝试一些路径不成功,可以倒退回上一个节点选择其他路径,如果仍不成功,继续回退,最终提供解决问题的可能性。这种方法常用于人工智能。经典的八皇后问题就是一个案例。
在8×8格的国际象棋上依次摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上。假如放到第5个皇后发现没有位置可以容纳下一个皇后,那么就倒回去重新放第四个皇后。下面运用回溯的思路以递归实现:
putQueen(row)
{
for(同一行的每一个col)
{
if(如果col可以放皇后)在该位置放皇后;
if(row<8)putQueen(row+1);
else return 成功;
取走col位置上的皇后;//因为在后面的操作中发现没有皇后可以放。
}
}
这是一个典型的回溯法。
设棋盘坐标为board[r][c],根据皇后棋盘的规则,斜线上分为左斜线和右斜线,各7条。分别满足r+c = k, r-c = m。k和m都是常数。所以当一个皇后的行和列确定以后,可以通过两个数组leftdiagnoal[r+c]和rightdiagnoal[r-c+N]确定左右斜线是否可用。注意常数N是确保r-c为大于0的数。
代码如下:
#include <iostream>
#include <cstring>
using namespace std;
class ChessBoard
{
public:
ChessBoard();
ChessBoard(int); //这种形式可以设置棋盘大小。
void findSolutions();
private:
const bool avaliable; //列,左斜线,右斜线数组始否可用的标志。
const int squares, norm; //方格规格,r-c+n的n值。
bool * column, * leftDiagnoal, * rightDiagnoal; //列数组,左斜线数组,右斜线。数组
int * positionInrow, howMany; //皇后的列位置数组, 解法数。
void putQueen(int); //放置皇后,递归函数。
void printboard(ostream&); //打印棋盘。
void initializeBoard(); //初始化棋盘。
};
ChessBoard::ChessBoard() : avaliable(true), squares(8), norm(squares - 1)
{
initializeBoard();
}
ChessBoard::ChessBoard(int n) : avaliable(true), squares(n), norm(squares - 1)
{
initializeBoard();
}
void ChessBoard::initializeBoard()
{
howMany = 0;
register int i;
column = new bool[squares];
leftDiagnoal = new bool[squares*2 - 1];
rightDiagnoal = new bool[squares*2 - 1];
positionInrow = new int[squares];
for(i = 0; i < squares; i++)
{
positionInrow[i] = -1;
column[i] = avaliable;
}
for(i = 0; i < squares*2 - 1; i++)
leftDiagnoal[i] = rightDiagnoal[i] = avaliable;
}
void ChessBoard::putQueen(int row)
{
for(int col = 0; col < squares; col++)
{
if(column[col] == avaliable && leftDiagnoal[col+row] == avaliable
&& rightDiagnoal[row-col+norm] == avaliable)
{
positionInrow[row] = col;
column[col] = leftDiagnoal[col+row] = rightDiagnoal[row-col+norm] = !avaliable;
if(row < squares - 1)
putQueen(row+1);
else printboard(cout);
column[col] = leftDiagnoal[col+row] = rightDiagnoal[row-col+norm] = avaliable;
}
}
}
void ChessBoard::findSolutions()
{
putQueen(0);
cout << howMany << "solutions found.\n";
}
void ChessBoard::printboard(ostream & p)
{
char *board = new char [squares*squares]; //棋盘本是二维数组,这里简化为一维数组。
memset(board, '0', squares*squares);
for(int i = 0; i < squares; i++)
{
board[i*squares+positionInrow[i]] = '1';
}
for(int row = 0; row < squares; row++)
{
for(int col = 0; col < squares; col++)
p << board[row*squares + col];
p << endl;
}
p << endl;
howMany++;
}
int main(void)
{
ChessBoard().findSolutions();
return 0;
}
运行结果如下:
以上符合八皇后问题的解92。核心思路就是通过尝试逐步按行放置皇后,如果不行就退回上一个皇后,通过回溯法+遍历的思路找出所有符合规定的摆放。【可以在程序补上析构函数释放分配的空间。博主用的QT自带析构,所以偷懒啦。】
关于递归和迭代的取舍
之前有提到,非尾递归要用迭代替换,会不可避免地使用栈,而尾递归被替代时不需要。值得注意的是,尾递归需要用栈,运行时间通常比迭代(不需要用栈)长,因此最适合用迭代而不是递归(例如之前讲到的斐波那契数列)。但对于非尾递归,两者的差距不会太大;相反因为硬件的缘故,递归有时在这种情况下的效率比迭代强,更多时候两者速度差异并不明显,此时更多考虑递归。
另外,如果函数体需要重复的次数非常多,尽可能避免递归,因为这将带来极大的内存浪费;相反调用次数少,单次调用栈效率较高时,则可以考虑递归。