递归算法求解遍历(或穷举)问题

递归问题可以理解为遍历问题,必须遍历出所有的数据来,才能进行相应的运算,比如Fibonacci问题、阶乘问题,必须把每一步的值都遍历出来,然后才能做加法或乘法。

递归算法解决问题的特点:

(1)必须有一个明确的递归结束条件,称为递归出口。

(2)根据当前状态的值推断下一个状态值的个数n与条件,本次递归调用将根据条件调用n个自身(根据条件,遍历不同分支,如二叉树中前序、中序和后序遍历)。

递归算法优缺点:借助递归方法,我们可以把一个相对复杂的问题转化为一个与原问题相似的规模较小的问题来求解,递归方法只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。但在带来便捷的同时,也会有一些缺点,也即:通常用递归方法的运行效率不高。

使用递归算法处理的几类问题

1.Fibonacci问题

讲到递归,我们最先接触到的一个实例便是斐波那契数列。

斐波那契数列指的是这样一个数列 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,…

特别指出:第0项是0,第1项是第一个1。

这个数列从第二项开始,每一项都等于前两项之和。

斐波那契数列递归法实现:

int Fib(int n)
{
if(n<1)         /*结束条件*/
{
return -1;
}
if(n == 1|| n == 2)  /*结束条件*/
{
return 1;
}
return Fib(n-1)+Fib(n-2);  //很庆幸,Fibonacci有明显的递归方程
}

2.二叉树遍历问题


二叉树的遍历分为三种:前(先)序、中序、后序遍历。

设L、D、R分别表示二叉树的左子树、根结点和遍历右子树,则先(根)序遍历二叉树的顺序是DLR,中(根)序遍历二叉树的顺序是LDR,后(根)序遍历二叉树的顺

序是LRD。还有按层遍历二叉树。这些方法的时间复杂度都是O(n),n为结点个数。

假设我们有一个包含值的value和指向两个子结点的left和right的树结点结构。我们可以写出这样的过程:

先序遍历(递归实现):

visit(node)
{
print node.value    /*当前状态有两个可能分支*/
if node.left  != null then visit(node.left)
if node.right != null then visit(node.right)
return
}

中序遍历(递归实现):

visit(node)
{
if node.left  != null then visit(node.left)
print node.value
if node.right != null then visit(node.right)
return
}

后序遍历(递归实现):

visit(node)
{
if node.left  != null then visit(node.left)
if node.right != null then visit(node.right)
print node.value
return
}

3.字符串全排序或条件全遍历问题

问题:写一个函数返回一个串的所有排列。

解析:对于一个长度为n的串,它的全排列共有A(n, n)=n!种。这个问题也是一个递归的问题, 不过我们可以用不同的思路去理解它。为了方便讲解,假设我们要考察的串是”abc”, 递归函数名叫permu。

思路一:我们可以把串“abc”中的第0个字符a取出来,然后递归调用permu计算剩余的串“bc” 的排列,得到{bc, cb}。然后再将字符a插入这两个串中的任何一个空位(插空法), 得到最终所有的排列。比如,a插入串bc的所有(3个)空位,得到{abc,bac,bca}。 递归的终止条件是什么呢?当一个串为空,就无法再取出其中的第0个字符了, 所以此时返回一个空的排列。代码如下:

typedef vector vs;
vs permu(string s){              /*返回当前次排序的结果VS*/
vs result;
if(s == ""){                    /*退出条件*/
result.push_back("");
return result;
}
string c = s.substr(0, 1);
vs res = permu(s.substr(1));     /*与上一次排序结果的关系*/
for(int i=0; i
string t = res[i];
for(int j=0; j<=t.length(); ++j){
string u = t;
u.insert(j, c);
result.push_back(u);
}
}
return result; //调用result的拷贝构造函数,返回它的一份copy,然后这个局部变量销毁(与基本类型一样)
}

思路二:我们还可以用另一种思路来递归解这个问题。还是针对串“abc”, 我依次取出这个串中的每个字符,然后调用permu去计算剩余串的排列。 然后只需要把取出的字符加到剩余串排列的每个字符前即可。对于这个例子, 程序先取出a,然后计算剩余串的排列得到{bc,cb},然后把a加到它们的前面,得到{abc,acb};接着取出b,计算剩余串的排列得到{ac,ca},然后把b加到它们前面, 得到{bac,bca};后面的同理。最后就可以得到“abc”的全序列。

vs permu1(string s){
vs result;
if(s == ""){
result.push_back("");
return result;
}
for(int i=0; i
string c = s.substr(i, 1);
string t = s;
vs res = permu1(t.erase(i, 1));
for(int j=0; j
result.push_back(c + res[j]);
}
}
return result;
}

4.汉罗塔问题

汉诺塔是根据一个传说形成的数学问题:有三根杆子A,B,C。A杆上有N个(N>1)穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至C杆:每次只能移动一个圆盘;大盘不能叠在小盘上面。提示:可将圆盘临时置于B杆,也可将从A杆移出的圆盘重新移回A杆,但都必须遵循上述两条规则。

问:如何移?最少要移动多少次?

#include 
#include 
using namespace std;
void hannoi (int n, char from, char buffer, char to)
{    if (n == 1)
{        cout <
}
else
{
hannoi (n-1, from, to, buffer);
cout <
hannoi (n-1, buffer, from, to);    }}
int main(){
int n;    cin >> n;
hannoi (n, 'A', 'B', 'C');
return 0;
}

5.八皇后问题

问题:

经典的八皇后问题,即在一个8*8的棋盘上放8个皇后,使得这8个皇后无法互相***(任意2个皇后不能处于同一行,同一列或是对角线上),输出所有可能的摆放情况。

解析:

8皇后是个经典的问题,如果使用暴力法,每个格子都去考虑放皇后与否,一共有264种可能。所以暴力法并不是个好办法。由于皇后们是不能放在同一行的, 所以我们可以去掉“行”这个因素,即我第1次考虑把皇后放在第1行的某个位置, 第2次放的时候就不用去放在第一行了,因为这样放皇后间是可以互相***的。 第2次我就考虑把皇后放在第2行的某个位置,第3次我考虑把皇后放在第3行的某个位置, 这样依次去递归。每计算1行,递归一次,每次递归里面考虑8列, 即对每一行皇后有8个可能的位置可以放。找到一个与前面行的皇后都不会互相***的位置, 然后再递归进入下一行。找到一组可行解即可输出,然后程序回溯去找下一组可靠解。

我们用一个一维数组来表示相应行对应的列,比如c[i]=j表示, 第i行的皇后放在第j列。如果当前行是r,皇后放在哪一列呢?c[r]列。 一共有8列,所以我们要让c[r]依次取第0列,第1列,第2列……一直到第7列, 每取一次我们就去考虑,皇后放的位置会不会和前面已经放了的皇后有冲突。 怎样是有冲突呢?同行,同列,对角线。由于已经不会同行了,所以不用考虑这一点。 同列:c[r]==c[j];同对角线有两种可能,即主对角线方向和副对角线方向。 主对角线方向满足,行之差等于列之差:r-j==c[r]-c[j];副对角线方向满足, 行之差等于列之差的相反数:r-j==c[j]-c[r]。 只有满足了当前皇后和前面所有的皇后都不会互相***的时候,才能进入下一级递归。

#include 
using namespace std;
int c[20], n=8, cnt=0;
void print()
{
for(int i=0; i
{
for(int j=0; j
{
if(j == c[i])
cout<
else cout<
}
cout<
}
cout<
}
void search(int r)
{
if(r == n)
{
print();
++cnt;
return;
}
for(int i=0; i
{
c[r] = i;
int ok = 1;
for(int j=0; j
if(c[r]==c[j] || r-j==c[r]-c[j] || r-j==c[j]-c[r])
{
ok = 0;
break;
}
if(ok) search(r+1);
}
}
int main()
{
search(0);
cout<
return 0;
}

在实际应用中,碰到最多的是第1,2,3类遍历问题,如目录下所有文件的遍历类似于二叉树问题,阶乘问题类似于Fibonacci问题。

下面是一个条件全遍历的例子:

给定一个n,写出n对‘()’的全部正确组合。

例如,给定n=3,输出结果为:

"((()))", "(()())", "(())()", "()(())", "()()()"

采用递归树的思想,遍历条件:

当左括号数大于右括号数时可以加左或者右括号;否则只能加左括号;当左括号数达到n时,剩下全部加右括号

在该方法中采用的正向遍历。

class Solution
{
public:
vector generateParenthesis(int n)
{
vector res;
generate(res, "", 0, 0, n);
return res;
}
void generate(vector res, string tmp, int lhs, int rhs, int n)  /*当前状态*/
{
if(lhs == n)
{
for(int i = 0; i 
{
tmp += ")";
}
res.add(tmp);
return ;
}
if(lhs > rhs)                    /*遍历分支:经典之处*/
{
/*先遍历当前状态后,下一个状态是’(’的所有状态*/
generate(res, tmp + "(", lhs + 1, rhs, n); /*下一个可能状态1*/
/*然后遍历当前状态后,下一个状态是’)’的所有状态*/
generate(res, tmp + ")", lhs, rhs + 1, n); /*下一个可能状态2*/
}
else
{
generate(res, tmp + "(", lhs + 1, rhs, n); /*下一个可能状态3*/
}
}
}

当前状态能推断下一个状态,或由下一个状态能推断当前状态的情况,都适合递归。与状态有关的值需要当做入参或返回值传递。