算法思想(一) 递归

如果一个算法调用自己来完成它的部分工作,就称这个算法是递归的。这种方法要想取得成功,必须在原始问题规模更小的问题上调用自己。

递归简介

一个递归算法必须有两个部分:初始情况和递归部分。初始情况只处理可以直接解决而不需要再次递归调用的简单输入。递归部分包含对算法的一次或者多次递归调用。

递归算法的实际总能使用下面的方法实现:首先写出初始情况,然后考虑通过一个或多个较小但是类似子问题的结果来解决原问题。

递归成功的秘诀在于:不要担心递归方法是如何解决子问题的。只要简单地接收它能正确地解决子问题,而且使用子问题的求解结果就能正确地解决原问题。只要在不超出递归调用的范围内分析递归过程,子问题就会迎刃而解。

递归调用子程序会生成一个活动记录,虽然递归方法实现起来比较容易,而且清晰易懂,但是有些时候我们希望避免因递归函数调用而产生的庞大的时空代价。

在某些情况下,递归可以轻易用迭代来代替(如计算阶乘),但是当实现有多个分支的算法时,就很难用迭代来代替递归,所以必须使用递归或者与递归等价的算法。值得庆幸的是,我们可以使用栈来方便的模拟递归,改写递归程序。

下面我们引入河内塔这一经典的问题,来完整的讲述递归思想以及使用栈将递归算法改成非递归形式的全过程。

河内塔(TOH)

首先我们给出河内塔问题的完整描述:
给出3根柱子和n个圆盘,所有圆盘均在最左边的柱子上,各个圆盘之间大小不同,按照大的圆盘在下面的顺序依次往上堆放。问题是要通过一系列步骤把这些圆盘从最左边的柱子上移到最右边的柱子上。每一步只能把某个柱子最上面的一个圆盘移到另一个柱子的上面,圆盘移到哪根柱子上不受限制,但是任何一个圆盘都不能放到比它小的圆盘的上面。
在这里插入图片描述

递归分析

如果不努力去考虑细节,这个问题是非常容易的。只要考虑所有圆盘都必须从柱1移动到柱3上,因此必须首先把最下面的圆盘移到柱3上。要达到这个目的,柱3必须是空的,而且柱1上只能有最下面的一个圆盘,因此其余的n-1个圆盘只能在柱2上。

具体如何实现呢?假设X是一个函数,可以把柱1上面的n-1个圆盘移动到柱2的上面,然后把柱1最下面的一个圆盘移动到柱3上;最后,再用函数X把其余的n-1个圆盘从柱2移动到柱3上即可。在这两种情况中,“函数X”只不过是一个调用更小问题的河内塔函数而已。

下面给出河内塔递归算法的一种实现:

void TOH(int n, Pole start, Pole goal, Pole temp)
{
	if(n == 0) return;
	TOH(n-1, start,temp, goal);
	move(start, goal);
	TOH(n-1, temp, goal, start);
}

注:move(start, goal)把柱start最上面的圆盘移动到柱goal上。如果函数move的功能是打印它的参数值,那么递归调用TOH的结果将给出解决此问题的圆盘移动序列。

用栈实现河内塔问题

河内塔问题的递归程序的TOH函数有两个递归调用:第一个把n-1个圆盘与最底层的圆盘分离,另一个是把这n-1个圆盘放回到目标柱。为了消除递归,使用栈来存储TOH所必行的三个操作(两个递归调用和一个移动操作)的表示。为此,要用一个类来表示这三个不同的操作,该类的对象就是存储在栈中的元素。

enum TOHobj {DOMOVE, DOTOH};
class TOHobj
{
public:
	TOHop op;
	int num;
	Pole start, goal, tmp;
	
	TOHobj(int n, Pole s, Pole g, Pole t)
	{
		op = DOTOH; num = n;
		start = s; goal = g; tmp = t;
	}

	TOHobj(Pole s, Pole g)
	{ op = DOMOVE; start = s; goal = g; }
};

这段代码首先定义了一个名为TOHop的枚举类型,该枚举类型有两个常量MOVE和TOH,分别指示对move函数的调用和对TOH的递归调用。类TOHobj存储了5个域:一个操作域(说明是移动还是新的TOH操作)、圆盘的数目和三根柱子。注意,移动操作实际只需要存储两根柱子的信息。存在两个构造函数:一个存储模拟递归调用时的状态,另一个存储移动操作的状态。
注:TOHobj的五个域实际上是原TOH递归函数的4个传参和程序跳转过程中保存上层函数地址的模拟,其他递归改非递归可以以此触类旁通。

最后,我们使用TOHobj来实现TOH的非递归版本

//本例中的栈采用顺序栈形式,因为知道栈中恰好要存放2n+1个元素
void TOH(int n, Pole start, Pole goal, Pole tmp, Stack<TOHobj*>& S)
{
	S.push(new TOHobj(n, start, goal, tmp));
	TOHobj = t;
	while(S.length() > 0)
	{
		t = S.pop();
		if(t->op == DOMOVE)
			move(t->start, t->goal);
		else if(t->num > 0)
		{
			int num = t->num;
			Pole tmp = t->tmp; Pole goal = t->goal; Pole start = t->start;
			//Store (in reverse) 3 recursive statements
			S.push(new TOHobj(num-1, tmp, goal, start));
			S.push(new TOHobj(start, goal));
			S.push(new TOHobj(num-1, start, tmp, goal));
		}
		delete t;
	}
}

注:因为栈是后进先出的所以我们需要逆序压入语句。这一过程我们可以形象的理解为打开包裹的过程:每次从栈中取出一样物品,如果它是一个包裹我们就拆掉包裹,将包裹里面的物品解压入栈。由于入栈是逆序的,所以我们出栈的顺序与递归程序中语句执行的顺序是一致的。

用栈模拟递归时,定义一个类保存函数闭包中的环境变量,这一思想十分重要!博主联想到了之前遇到过的一道倒水问题:倒水问题中也定义了一个类,以对象的形式表示三个水桶的状态,由此定义了问题的解状态空间的具体形式。对问题的求解就转化成了按照一定次序对解状态空间进行搜索的问题(该问题还定义了状态之间的跳转动作表,作为保证这种转换的机制)。
ps:倒水问题博主以后会在另一篇博文中详细论述,更新ing~

递归解答树重复遍历问题

看了河内塔问题4行代码的递归实现,是不是感觉递归很强大。事实上,很多数据额结构是自然递归的,比如树;很多搜索和排序算法是基于“分治法”策略的:即把问题分解成较小的(类似)子问题,再解决这些子问题,然后组合子问题的解以形成对原问题的解,而这个过程通常用递归来实现。通常情况下,递归算法可以简洁而相对高效的解决可定义相同子结构的问题,但并不总是:

long fibr(int n)
{
	Assert((n > 0) && (n < 47), "Input out of range");
	if((n == 1) || (n == 2)) return 1;
	return fibr(n-1) + fibr(n-2);
}
long fib(int n)
{
	Assert((n > 0) && (n < 47), "Input out of range");
	long past, prev, curr;
	past = prev = curr = 1;
	for(int i = 3; i <= n; i++)
	{
		past = prev;
		prev = curr;
		curr = past + prev;
	}
	return curr;
}

上面两个代码分别是求斐波那契数列的递归实现和迭代实现。分别运行代码,可以发现,递归版本的n到了12时程序已经慢成龟速,再也跑不下去了,而非递归版本则能轻松计算精度范围以内(n<47)的所有斐波那契数列。
为什么会这样呢?下面我们来传一个参数对递归版本的程序做一个简单的执行。我们传递n=6,则fibr(6)会分别递归调用fibr(4)和fibr(5),而我们继续执行程序,fibr(5)则又会分别递归调用fibr(3)和fibr(4)。等等,fibr(4)被调用了两次!事实上,在fibr的整个执行过程中,会有大量的计算过程会被重复执行,而且重复次数远不止两次,从解答树的层面来看,当fibr函数找到两条从根结点到子结点的后,以这一子结点为根结点的子树会被fibr函数以同样的方式重复遍历,这就解释了为什么fibr的效率如此低下。
那么,遇到类似于本例的情况有没有解决办法呢?非要使用迭代吗?事实上,我们引入动态规划的思想,递归算法有一种通用的优化办法——查表法(lookup table)。即我们可以将函数计算的结果存储下来,每次递归调用计算前,先看看表中有没有计算好的结果,如果有就不必往下计算了。这个办法同时也是一个经典的时空权衡的例子。


最后附上fib数列递归优化以及应用动态规划思想的算法实现的两篇相关博文:

斐波那契数列—递归和递归优化
斐波那契数列的实现(简单递归和动态规划)

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值