12.4 - 递归
在 C++ 中,自己调用自己的函数称为递归函数。下面这个例子是一个递归函数的例子(不佳的写法):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
当 countDown(5)
被调用时,打印了 “push 5”。然后 countDown(4)
被调用,它打印 “push 4” 并调用 countDown(3)
。countDown(3)
会打印 “push 3” 并调用 countDown(2)
。countDown(n)
调用的一系列 countDown(n-1)
,形成的递归调用等价于一个死循环。
在 12.2 - 栈和堆 中我们学过,所有的函数调用都需要将相应的数据放在调用栈上。因为 countDown()
函数从不返回(只是不断地调用 countDown()
),因此相关的函数信息从来没有被从栈上弹出过。因此,在到达某个极限时,计算机就会耗尽栈上内存,进而发生堆栈溢出,程序崩溃。在笔者的机器上,程序会在count到-11732时崩溃!
递归的终止条件
递归函数调用的工作方式通常与普通函数调用一样。然而,上面的程序说明了递归函数最重要的区别:递归函数必须包含递归终止条件,否则它们将“永远”运行(实际上,直到调用堆栈耗尽内存)。递归终止条件——当满足该条件时,将导致递归函数停止调用自身。
递归终止条件通常涉及使用if语句。下面是我们的函数重新设计了一个终止条件(和一些额外的输出):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
运行程序,countDown()
会开始打印:
1 2 3 4 5 |
|
如果你现在查看调用栈,你会看到以下内容:
1 2 3 4 5 6 |
|
由于终止条件存在,countDown(1)
不调用countDown(0)
——相反," if语句"不执行,因此它打印" pop 1 ",然后终止。此时,从栈中弹出countDown(1)
,控制返回countDown(2)
。countDown(2)
在countDown(1)
被调用后继续执行,因此它打印" pop 2 "然后终止。递归函数调用随后从堆栈中弹出,直到所有的countDown
实例都被删除。
因此,程序打印的完整结果如下:
1 2 3 4 5 6 7 8 9 10 |
|
值得注意的是,“push”输出是按前向顺序发生的,因为它们发生在递归函数调用之前。“pop”输出以相反的顺序出现,因为它们出现在递归函数调用之后,因为函数从栈中弹出(其发生的顺序与放入它们的顺序相反)。
更有用的例子
现在我们已经讨论了递归函数调用的基本机制,让我们看看另一个更典型的递归函数:
1 2 3 4 5 6 7 8 9 10 11 |
|
仅通过观察递归程序通常很难理解递归调用。当我们调用具有特定值的递归函数时,看看会发生什么通常是有指导意义的。让我们看看当我们调用这个参数为sumto = 5
的函数时会发生什么。
1 2 3 4 5 |
|
现在我们展开调用栈(在返回时将每个函数从调用栈中弹出):
1 2 3 4 5 |
|
这里可以很明显的看出,每次相加值会递增1,然后再加上传入的值。
因为单看递归函数可能很难理解,所以好的注释特别重要。
注意,在上面的例子中,我们使用了 sumto - 1
而不是 --sumto
。这是因为 operator--
是有副作用的,而在一个表达式中多次使用一个有副作用的变量会导致未定义行为。使用 sumto - 1
可以避免副作用,使得 sumto
可以安全地在一个表达式中多次使用。
递归算法
使用递归函数解决问题的思路如下:首先找到问题子集的解(递归地),然后修改子问题的解以得到问题的解。在上述算法中,sumTo(value)
首先求解sumTo(value-1)
,然后将变量value
的值相加,求出sumTo(value)
的解。
在许多递归算法中,一些输入会得到很简单的输出。例如,sumTo(1)
的输出为1(你可以在头脑中计算),并且没有必要再进一步递归了。这种情况称为——基本条件。基本条件通常作为算法的终止条件。通过考虑输入0、1、" "、"或null的输出,通常可以识别为基本条件。
斐波那契数列
最著名的数学递归算法之一是斐波那契数列。斐波那契数列出现在自然界的许多地方,如树木的分枝、贝壳的螺旋形、菠萝的果实、不卷曲的蕨类叶子和松果的排列。
这是一张斐波那契螺旋图:
每一个斐波那契数列都是该数列所在正方形的边长。
斐波那契数列在数学上定义为:
1 2 3 4 5 |
|
因此,编写一个(不是很高效的)递归函数来计算第n个斐波那契数是相当简单的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
运行该程序会产生以下结果:
1 |
|
你会注意到,这正是斐波那契螺旋图中出现的数字。
记忆化算法
上面的递归斐波那契算法不是很高效,部分原因是每次调用一个斐波那契都会导致另外两个斐波那契调用。这将产生指数级的函数调用次数(实际上,上面的示例调用fibonacci()
1205次!)有一些方法可以用来减少必要的调用次数。一种是叫做记忆化的技术,将开销很大的函数调用的结果缓存起来,以便在再次出现相同的输入时返回结果。
下面是一个记忆版的递归斐波那契算法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
这个记忆版本进行了35次函数调用,比原始算法的1205次好得多。
递归和迭代
关于递归函数经常被问到的一个问题是,“如果可以迭代地执行许多相同的任务(使用for 循环或while循环),为什么还要使用递归函数呢?”事实证明,你总是可以迭代地解决递归问题——然而,对于复杂的问题,递归版本的编写(和读取)通常要简单得多。例如,虽然可以迭代地编写Fibonacci函数,但这有点困难!(试一试!)
迭代函数(使用for循环或while循环的函数)几乎总是比递归函数更高效。这是因为每次调用函数时,栈帧的推入和弹出都会产生一定的开销。迭代函数避免了这种开销。
这并不是说迭代函数总是更好的选择。有时候,函数的递归实现是如此的简洁和容易遵循,以至于为了可维护性的好处,产生一点额外的开销是值得的,特别是在算法不需要多次递归来找到一个解决方案的情况下。
在满足下面条件的时候,使用递归通常是更好的选择:
- 当递归代码更容易编写时;
- 递归的深度有限(例如,实际上没办法编写十万层递归的算法);
- 当迭代版本的算法需要手工管理栈时;
- 性能要求不高
但是,如果递归算法更容易实现,那么可以先递归地开始,然后再优化为迭代算法。
最佳实践
一般来说,迭代比递归更受欢迎,除非递归确实有意义。