递归在搜索算法中使用方法

最近阅读《算法的乐趣》这本书,书中的例子和作者的思考解题思路都让我很受益,给了我很多启发,于是想针对递归的使用方式,结合一些简单的例子,将自己的一些理解写出来供自己和大家在今后学习和工作中参考。

从斐波那契数列说起

递归是每本算法书中必讲解的内容,也是算法设计中的一类重要的设计思想。在搜索算法设计中,递归方式属于一种暴力搜索方法,即通过计算机的高速运算性能对所有的搜索分支都进行判断,取出符合要求的结果,尽管效率较低但简单方便,在很多算法设计中也是有应用。

提到递归,很多初学者或者对其理解不深的程序猿会觉得它很复杂,不容易使用(咱都是一类人),因此希望这篇文章能对学习递归有所帮助。

首先我们可以从斐波拉契数列说起,也是大家比较熟悉或者最先接触递归时的例子,下面是它的代码:

/*
	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、在上述程序中,每次访问一个点时,都会把该点设置成访问过的点(即设置状态),而在递归结束后会将该状态重置,这是因为当一个分支结束后(得到正确答案或者失败),我们需要返回上一次状态重新进行下一个分支,这一过程在搜索算法中去称为回溯。

至此,我们可以总结整个递归搜索算法中所需要的几个步骤:结束条件、推进力、剪枝判断、设置状态、回溯、递归。为了方便设计程序的流程,将回溯并为清除状态,
所以递归主程序设计流程如下:

  1. 判断是否结束
  2. 状态转移动力
  3. 剪枝判断
  4. 状态设置
  5. 递归
  6. 状态清除

其中除了递归必须在状态设置和状态清除步骤的中间外,其他流程可以根据不同情况调整顺序。

李白喝酒千杯不醉

下面我们再看一下蓝桥杯的李白打酒这个例子。

标题:李白打酒
话说大诗人李白,一生好饮。
一天,他提着酒壶,从家里出来,酒壶中有酒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;

}

分析上面代码可以发现,同样包含结束条件、推动力、状态设置、递归几个步骤。但这种方式将所有状态全部遍历了一遍,实际上通过剪枝判断可以将很多状态(例如酒的数量为负数)可以剔除掉。

如下面状态转移树状图所示:
递归在搜索算法中使用方法_第1张图片

可以发现右边很大一部分分支都是可以剪掉的。
因此,下面的程序用了一种更加直观的方式解决这个问题(虽然代码比较多~~):

#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)初始化状态,用队列来作为记录每一步状态的数据结构。

  1. 递归开始时首先判断是否是结束条件,一是是否已经走完所有步数,二是结束时用IsLastState(lState& state)函数判断状态是否是最终状态;
  2. 然后进行状态转移,转移动力包含遇店和遇花两种情况;
  3. 进行某个方向转移时,再用函数CanGoToNext(lState& state,GOWHERE go)做剪枝判断;
  4. 之后进行状态设置,函数ActionState(lState& current,GOWHERE go,lState& next)输出下一个状态next,然后将状态入队列;
  5. 再进行递归重复SearchState函数;
  6. 最后出队列来清除状态,进行回溯操作。

最后运行后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;
}

代码中的步骤已经注释,其中递归搜索过程中也可以按照上述设计流程来实现。

结束语

《算法的乐趣》中书还有许多类似的例子,想更加深入理解的读者可以学习一下,也再次感谢该书作者为后来学习者提供的著作。

最后说一下,搜索算法是分为深度优先搜索和广度优先搜索,上述所有程序均是利用递归进行深度搜索,即从某一个分支搜索完毕后再考虑另一个分支,而广度优先搜索是搜索每一个可行方案的第一步,然后接着搜索所有的第二步,依此类推。广度优先搜索需要额外的空间来存储每一步的所有状态,更适合用于求解最短路径问题,而深度优先搜索时间复杂度更高,但代码简单,更适合求解所有路径问题。

只有输入没有输出的系统不是一个好系统!希望与大家共同学习,共同进步,以此共勉。

你可能感兴趣的:(算法(C++))