最近阅读《算法的乐趣》这本书,书中的例子和作者的思考解题思路都让我很受益,给了我很多启发,于是想针对递归的使用方式,结合一些简单的例子,将自己的一些理解写出来供自己和大家在今后学习和工作中参考。
递归是每本算法书中必讲解的内容,也是算法设计中的一类重要的设计思想。在搜索算法设计中,递归方式属于一种暴力搜索方法,即通过计算机的高速运算性能对所有的搜索分支都进行判断,取出符合要求的结果,尽管效率较低但简单方便,在很多算法设计中也是有应用。
提到递归,很多初学者或者对其理解不深的程序猿会觉得它很复杂,不容易使用(咱都是一类人),因此希望这篇文章能对学习递归有所帮助。
首先我们可以从斐波拉契数列说起,也是大家比较熟悉或者最先接触递归时的例子,下面是它的代码:
/*
Fibonacci: F(n)=F(n-1)+F(n-2)
F(0)=F(1)=1
*/
#include
#include
int Fibonacci(int n);
int main()
{
int num,i=0;
do
{
printf("Please input the num(>=1) you want to print of Fibonacci sequences:");
scanf("%d",&num);
}while(num<1);
for(i=0;i<=num;i++)
printf("%d ",Fibonacci(i));
printf("\n");
system("pause");
return 0;
}
int Fibonacci(int n)
{
if(n<2)
return 1;
else
return Fibonacci(n-1)+Fibonacci(n-2);
}
考虑当我们要计算第N个斐波那契数或者距离某个已知数最近的斐波那契数时,于是就出现递归第一个要素:结束条件。即递归需要有可以终止的结果(找到答案或者没有答案),或者手动设定循环结束的次数,否则会陷入死循环。
递归第二个要素:推进力或者称状态转移驱动。即每次递归过程需要前进而不是保持原状态,前进的方式称为推进力或者驱动力,如斐波那契驱动力是新函数值是前两个函数值之和。
第三个是递归函数,即函数内调用参数不同的自己。
下面再看青蛙跳台阶问题:一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级。求该青蛙跳上一个N 级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
该问题比较简洁的解法是跳N级的台阶可以变成跳N-1次台阶的跳法加上跳N-2次台阶的跳法(最后一步可以跳1级或者跳2级,最后一步跳1级和前面跳N-1次台阶的跳法,再加上最后一步跳2级和前面跳N-2次台阶的跳法就是跳N级的跳法),即斐波那契数列,这里我们用比较复杂的递归来解,代码如下:
#include
static int nCount = 0;
int fnForgStep(int n)
{
// 结束条件 n < 0表示不成功, n == 0 表示成功
if(n < 0)
{
return 0;
}
if(n == 0)
{
nCount++;
std::cout << "success!" << std::endl;
}
// 驱动力 跳一次或者跳两次
fnForgStep(n-1);
fnForgStep(n-2);
return 0;
}
int main()
{
fnForgStep(10);
std::cout << "total " << nCount << " solutions." << std::endl;
return 0;
}
同上面的斐波那契数列解法,我们发现本题也有这几个要素,结束条件(n<0,n==0),驱动力(青蛙每次跳1级或者2级台阶)。这也是递归不可缺少的步骤。至于在搜素中的应用方式我们下面来看看走迷宫的例子。
下面再讨论搜索问题,迷宫求解:在给定的一个迷宫,求解该迷宫是否能走出并给出所有的解法。
求解迷宫的方式是从起始点开始,四个方向顺序尝试,如果是墙壁则选择另一个方式,能走通则在下一个点再进行四个方向尝试,直至找到出口。这一过程就是搜索路径的过程。
下面是代码:
/*
迷宫的解法:当前位置有四个方向,选取任一方向前进,若是终点
则返回成功打印路径,若是墙壁或者已走过的路径,则返回上一位置
选取另外的方向前进,采用递归的方式遍历全部位置。
*/
#include
#include
using namespace std;
//设计迷宫,‘#’代表墙壁,‘-’代表通道,‘+’代表已走过路径
char maze[8][8]={{'#','#','#','#','#','#','#','#'},
{'#','-','-','-','#','#','-','#'},
{'#','-','#','-','-','-','-','#'},
{'#','-','#','#','-','#','-','#'},
{'#','-','-','#','-','#','-','#'},
{'#','#','-','-','-','-','#','#'},
{'#','-','-','-','#','-','-','#'},
{'#','#','#','#','#','#','-','#'}
};
int startX=1,startY=2; //入口
int exitX=7,exitY=6; //出口
void visit(int x,int y);
int main()
{
//绘制迷宫
for(int i=0;i<8;i++)
{
for(int j=0;j<8;j++)
cout<<maze[i][j];
cout<<endl;
}
//寻找路径
visit(startX,startY);
//cout<<"Press any key to end!"<
system("pause");
return 0;
}
void visit(int x,int y)
{
//状态设置
maze[x][y]='+';
//结束条件
if(x==exitX && y==exitY)
{
cout<<"迷宫的解答为:"<<endl;
for(int i=0;i<8;i++)
{
for(int j=0;j<8;j++)
cout<<maze[i][j];
cout<<endl;
}
}
//访问各个方向 推进力
// 剪枝判断`
if(maze[x][y+1]=='-')
visit(x,y+1);
if(maze[x+1][y]=='-')
visit(x+1,y);
if(maze[x][y-1]=='-')
visit(x,y-1);
if(maze[x-1][y]=='-')
visit(x-1,y);
//注意:失败访问过的路径要清除
//状态设置清除
maze[x][y]='-';
}
比对之前的递归方式,我们可以发现递归的要素同样的需要,结束条件:找到出口或者没有出口;驱动力:每次向四个方向中的某个方向前进。
除此之外,我们可以发现出现了其他的要素,
1、判断前进的下一步是否是墙壁或者是否已经访问过(等价判断是否是为走过的道路),这样可以大大减少不必要的分支,在搜索算法中去除没用的分支操作称为剪枝;
2、在上述程序中,每次访问一个点时,都会把该点设置成访问过的点(即设置状态),而在递归结束后会将该状态重置,这是因为当一个分支结束后(得到正确答案或者失败),我们需要返回上一次状态重新进行下一个分支,这一过程在搜索算法中去称为回溯。
至此,我们可以总结整个递归搜索算法中所需要的几个步骤:结束条件、推进力、剪枝判断、设置状态、回溯、递归。为了方便设计程序的流程,将回溯并为清除状态,
所以递归主程序设计流程如下:
其中除了递归必须在状态设置和状态清除步骤的中间外,其他流程可以根据不同情况调整顺序。
下面我们再看一下蓝桥杯的李白打酒这个例子。
标题:李白打酒
话说大诗人李白,一生好饮。
一天,他提着酒壶,从家里出来,酒壶中有酒2斗。他边走边唱:
无事街上走,提壶去打酒。
逢店加一倍,遇花喝一斗。
这一路上,他一共遇到店5次,遇到花10次,已知最后一次遇到的是花,他正好把酒喝光了。
请你计算李白遇到店和花的次序,可以把遇店记为a,遇花记为b。则:babaabbabbabbbb 就是合理的次序。
像这样的答案一共有多少呢?请你计算出所有可能方案的个数(包含题目给出的)
这个题目的解法和上述青蛙跳台阶问题类似,只不过“跳1级”和“跳2级”次数有限制,而且可以确定最后一步必然是剩下1斗酒的时候遇到花。
下面是代码:
#include
using namespace std;
int sum=0;
int f(int a,int b,int c) // a:店的总数 b:花的总数减1 c:酒的初值
{
// 任何初始状况,都有两个可能:先遇到店,或者先遇到花
if(b>0)
f(a,b-1,c-1); // 遇花喝一斗
if(a>0)
f(a-1,b,c*2); // 逢店加一倍
if(a==0&&b==1&&c==1) //这个是满足要求的终止条件。没有店剩下,还剩一朵花和一斗酒
sum=sum+1;
return sum;
}
int main()
{
f(5,10,2);
cout<<sum<<endl;
}
分析上面代码可以发现,同样包含结束条件、推动力、状态设置、递归几个步骤。但这种方式将所有状态全部遍历了一遍,实际上通过剪枝判断可以将很多状态(例如酒的数量为负数)可以剔除掉。
可以发现右边很大一部分分支都是可以剪掉的。
因此,下面的程序用了一种更加直观的方式解决这个问题(虽然代码比较多~~):
#include
#include
#define MAX_STEP 16 // 最大步骤,包括了初始的一步所以是16
using namespace std;
// 描述推动方式
enum GOWHERE
{
STORE = 0, //遇店
FLOWER, //遇花
HOME //初始状态
};
// 描述状态的结构体
typedef struct LIBAI_STATE
{
int num; //酒的数量
int s_count; //剩余店的数量
int f_count; //剩余花的数量
GOWHERE go; //上次的动作
}lState;
// 初始化状态
void InitState(deque<lState>& states)
{
lState tmpState;
tmpState.num= 2;
tmpState.s_count = 5;
tmpState.f_count = 10;
tmpState.go = HOME;
states.push_back(tmpState);
}
// 结束条件判断函数
bool IsLastState(lState& state)
{
if(state.num == 0 && state.s_count == 0 && state.f_count == 0)
{
return true;
}
return false;
}
// 剪枝判断函数
bool CanGoToNext(lState& state,GOWHERE go)
{
lState tmpState;
tmpState.num = state.num;
tmpState.s_count = state.s_count;
tmpState.f_count = state.f_count;
if(go == STORE)
{
tmpState.num *= 2;
tmpState.s_count -= 1;
}
else if(go == FLOWER)
{
tmpState.num -= 1;
tmpState.f_count -= 1;
}
if(tmpState.num > 0 && tmpState.s_count >= 0 && tmpState.f_count >= 0)
{
return true;
}
else if(tmpState.num == 0 && tmpState.s_count == 0 && tmpState.f_count == 0)
{
return true;
}
else
{
return false;
}
}
// 状态转移函数
void ActionState(lState& current,GOWHERE go,lState& next)
{
next.num = current.num;
next.s_count = current.s_count;
next.f_count = current.f_count;
if(go == STORE)
{
next.num *= 2;
next.s_count -= 1;
next.go = STORE;
}
else if(go == FLOWER)
{
next.num -= 1;
next.f_count -= 1;
next.go = FLOWER;
}
}
// 输出打印函数
void PrintState(deque<lState>& states,int nCount)
{
for(int i = 0; i < nCount; i++)
{
printf("%d ",states[i].go);
}
printf("\n");
}
void SearchState(deque<lState>& states)
{
//结束条件
if(MAX_STEP == states.size() && IsLastState(states[MAX_STEP-1]))
{
PrintState(states,MAX_STEP);
return;
}
if(states.size() > MAX_STEP)
{
return;
}
// 推进力
GOWHERE go[2] = {
STORE,FLOWER
};
for(int i = 0; i < 2; i++)
{
// 剪枝判断
if(CanGoToNext(states.back(),go[i]))
{
lState next;
//状态设置
ActionState(states.back(),go[i],next);
states.push_back(next);
SearchState(states);
//状态设置清除
states.pop_back();
}
}
}
int main()
{
//用作存储状态的队列
deque<lState> states;
states.clear();
//初始化状态
InitState(states);
//递归搜索
SearchState(states);
return 0;
}
上面程序用lState结构体来表示当前状态,包含当前酒的数量、剩余遇店的次数、剩余遇花的次数以级记录上次动作的变量。
搜索遍历开始前函数InitState(deque& states)初始化状态,用队列来作为记录每一步状态的数据结构。
最后运行后PrintState输出每个步骤的行动,可以看到整个搜索过程完整地按照上述的6个设计流程进行。
最后引用《算法的乐趣》书中最开始提供的google方程式的例子。详情可以看书中72-74页的内容,题目:
有一个由字符组成的等式:WWWDOT - GOOGLE = DOTCOM,每个字符代表一个0~9之间的数字,WWWDOT、GOOGLE和DOTCOM都是合法的数字,不能以0开头。请找出一组字符和数字的对应关系,使它们互相替换,并且替换后的数字能够满足等式。
这里给出完整的代码:
#include
typedef struct tagCharItem
{
char c;
int value;
bool leading;
}CHAR_ITEM;
typedef struct tagCharValue
{
bool used;
int value;
}CHAR_VALUE;
CHAR_ITEM charItem[] = {
{'W',-1,true},{'D',-1,true},{'O',-1,false},
{'T',-1,false},{'G',-1,true},{'L',-1,false},
{'E',-1,false},{'C',-1,false},{'M',-1,false}
};
CHAR_VALUE charValue[] = {
{false,0},{false,1},{false,2},{false,3},{false,4},
{false,5},{false,6},{false,7},{false,8},{false,9},
};
int max_char_count = 9;
int max_number_count = 10;
// 判断当前赋值是否合法
bool IsValueValid(CHAR_ITEM ci,CHAR_VALUE cv)
{
if(ci.leading == true && cv.value == 0)
{
return false;
}
if(cv.used == true)
{
return false;
}
return true;
}
int GetLength(char* ch)
{
int len = 0;
while(ch[len] != '\0')
{
len++;
}
return len;
}
int pow10(int n)
{
int rst = 1;
while(n)
{
rst = rst * 10;
--n;
}
return rst;
}
// 转换成整数
int MakeIntegerValue(CHAR_ITEM ci[],char* ch)
{
int length = GetLength(ch);
int pos = length-1;
int rst = 0,j = 0,tmpValue = 0;
while(pos+1)
{
for(int i = 0; i < max_char_count; ++i)
{
if(ch[j] == ci[i].c)
{
tmpValue = ci[i].value;
break;
}
}
int tmp = pow10(pos);
rst += tmp * tmpValue;
j++;
pos--;
}
return rst;
}
// 赋值完后判断是否符合方程式
void Callback(CHAR_ITEM ci[])
{
char* minuend = "WWWDOT";
char* subtrahend = "GOOGLE";
char* diff = "DOTCOM";
int m = MakeIntegerValue(ci,minuend);
int s = MakeIntegerValue(ci,subtrahend);
int d = MakeIntegerValue(ci,diff);
if(m > 700000 && d > 589000)
{
int i = 0;
}
if((m - s) == d)
{
std::cout << m << " - " << s << " = " << d << std::endl;
}
}
void SearchingResult(CHAR_ITEM ci[],CHAR_VALUE cv[],int index)
{
// 结束条件
if (index == max_char_count)
{
Callback(ci);
return;
}
// for循环是推进力
for(int i=0; i < max_number_count; ++i)
{
if(IsValueValid(ci[index],cv[i])) // 剪枝判断`
{
//状态设置
cv[i].used = true;
ci[index].value = cv[i].value;
SearchingResult(ci,cv,index+1);
//状态设置清除
cv[i].used = false;
}
}
}
int main()
{
SearchingResult(charItem,charValue,0);
return 0;
}
代码中的步骤已经注释,其中递归搜索过程中也可以按照上述设计流程来实现。
《算法的乐趣》中书还有许多类似的例子,想更加深入理解的读者可以学习一下,也再次感谢该书作者为后来学习者提供的著作。
最后说一下,搜索算法是分为深度优先搜索和广度优先搜索,上述所有程序均是利用递归进行深度搜索,即从某一个分支搜索完毕后再考虑另一个分支,而广度优先搜索是搜索每一个可行方案的第一步,然后接着搜索所有的第二步,依此类推。广度优先搜索需要额外的空间来存储每一步的所有状态,更适合用于求解最短路径问题,而深度优先搜索时间复杂度更高,但代码简单,更适合求解所有路径问题。