章节目录

递归

本节阅读量:

如果一个函数调用了自身,那么我们就说这里发生了递归。下面是一个写得不好的递归函数示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <iostream>

void countDown(int count)
{
    std::cout << "push " << count << '\n';
    countDown(count-1); // countDown() 递归地调用自身
}

int main()
{
    countDown(5);

    return 0;
}

当调用countDown(5)时,它会打印“push 5”,并调用countDown(4)。countDown(4)打印“push 4”并调用countDown(3)。countDown(3)打印“push 3”并调用countDown(2)。countDown(n)调用countDown(n-1)的序列会无限重复,形成无限递归调用。

在上一节课中,我们了解到每个函数调用都会导致函数相关数据被放到调用栈上。由于countDown()函数从不返回(它只是再次调用countDown()),因此这些信息永远不会从栈中弹出!因此,在某个时刻,计算机会耗尽栈内存,导致栈溢出,程序将崩溃或终止。在作者的机器上,该程序在终止之前倒数到了-11732!


递归终止条件

递归函数调用通常像普通函数调用一样工作。然而,上面的程序说明了递归函数最重要的区别:必须包含递归终止条件,否则它们将“永远”运行(实际上,是直到调用栈耗尽内存)。递归终止条件是一种判断条件,当满足该条件时,会让递归函数停止调用自身。

递归终止通常涉及使用if语句。下面是使用终止条件(和一些额外的输出)重新设计的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>

void countDown(int count)
{
    std::cout << "push " << count << '\n';

    if (count > 1) // 终止判断
        countDown(count-1);

    std::cout << "pop " << count << '\n';
}

int main()
{
    countDown(5);
    return 0;
}

现在,当我们运行程序时,countDown()将首先输出以下内容:

1
2
3
4
5
push 5
push 4
push 3
push 2
push 1

如果此时查看调用栈,您将看到以下内容:

1
2
3
4
5
6
countDown(1)
countDown(2)
countDown(3)
countDown(4)
countDown(5)
main()

由于终止条件,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 5
push 4
push 3
push 2
push 1
pop 1
pop 2
pop 3
pop 4
pop 5

值得注意的是,“push”输出按正向顺序发生,因为它们出现在递归函数调用之前。“pop”输出则以相反顺序出现,因为它们发生在递归函数调用之后,此时函数正在从栈中弹出(这与它们被放入栈中的顺序相反)。


一个更有用的例子

我们已经讨论了递归函数调用的基本机制,现在来看另一个稍微更典型的递归函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 计算 1 到 sumto 之和
// 如果是负数,返回 0
int sumTo(int sumto)
{
    if (sumto <= 0)
        return 0; // 判断非预期的输入 (终止条件)
    if (sumto == 1)
        return 1; // 正常输入时的基础情形 (终止条件)

    return sumTo(sumto - 1) + sumto; // 递归调用
}

让我们看看当用参数sumto=5调用这个函数时会发生什么。

1
2
3
4
5
sumTo(5) 被调用, 5 <= 1  false, return sumTo(4) + 5.
sumTo(4) 被调用, 4 <= 1  false, return sumTo(3) + 4.
sumTo(3) 被调用, 3 <= 1  false, return sumTo(2) + 3.
sumTo(2) 被调用, 2 <= 1  false, return sumTo(1) + 2.
sumTo(1) 被调用, 1 <= 1  true,  return 1.  遇到终止条件

现在,回退调用栈(在每个函数返回时将其从调用栈中弹出):

1
2
3
4
5
sumTo(1) returns 1.
sumTo(2) returns sumTo(1) + 2, 结果 1 + 2 = 3.
sumTo(3) returns sumTo(2) + 3, 结果 3 + 3 = 6.
sumTo(4) returns sumTo(3) + 4, 结果 6 + 4 = 10.
sumTo(5) returns sumTo(4) + 5, 结果 10 + 5 = 15.

这样,就比较容易看出我们正在把1到传入值之间的数字相加。

因为递归函数比较难通过阅读代码来理解,所以好的注释特别重要。

注意,在上面的代码中,我们使用sumto-1而不是–sumto进行递归。这样做是因为操作符–有副作用,并且在同一表达式中多次使用带有副作用的变量可能导致未定义的行为。使用sumto-1可以避免副作用,使sumto在表达式中多次使用时仍然安全。


递归算法

递归函数通常先递归地找到问题的子集,然后通过解决子集问题来聚合得到最终结果。在上面的算法中,sumTo(value)首先求解sumTo(value-1),然后将结果与value的值相加,以得到sumTo(value)的解。

在许多递归算法中,一些输入会直接产生普通输出。例如,sumTo(1)直接输出1,并且不需要进一步递归。这种可以直接得到输出的场景称为基本情况。基本情况充当算法的终止条件。通常可以通过考虑输入0、1、““或null时的输出来识别基本情况。


斐波那契数

最著名的数学递归算法之一是斐波那契数列。斐波那契数列出现在自然界的许多地方,例如树木的分枝、贝壳的螺旋、菠萝的小果、未卷曲的蕨类叶子以及松果的排列。

斐波那契数在数学上定义为:

1
2
3
4

f(n) =	0 if n = 0
        1 if n = 1
        f(n-1) + f(n-2) if n > 1

因此,编写一个(不是非常有效的)递归函数来计算第n个斐波那契数是相当简单的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

int fibonacci(int count)
{
    if (count == 0)
        return 0; // 基本情况
    if (count == 1)
        return 1; // 基本情况
    return fibonacci(count-1) + fibonacci(count-2);
}

// 计算和展示前13个斐波那契数列
int main()
{
    for (int count { 0 }; count < 13; ++count)
        std::cout << fibonacci(count) << ' ';

    return 0;
}

运行程序会产生以下结果:

1
0 1 1 2 3 5 8 13 21 34 55 89 144

记忆化算法

上面的递归Fibonacci算法并不高效,部分原因是每次调用Fibonacci的非基本情况都会导致两次以上的Fibonacci调用。这会产生指数数量的函数调用(事实上,上面的示例调用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
#include <iostream>
#include <vector>

// count 这里是 std::size_t,以便更容易访问 std::vector
int fibonacci(std::size_t count)
{
	// 使用 std::vector 缓存计算结果
	static std::vector results{ 0, 1 };

	// 如果算过,那么直接返回结果
	if (count < std::size(results))
		return results[count];

	// 否则进行计算
	results.push_back(fibonacci(count - 1) + fibonacci(count - 2));
	return results[count];   
}

// 计算和展示前13个斐波那契数列
int main()
{
	for (int count { 0 }; count < 13; ++count)
		std::cout << fibonacci(static_cast<std::size_t>(count)) << ' ';

	return 0;
}

这个记忆化版本进行了35次函数调用,比原始算法的1205次要好得多。


递归与迭代

关于递归函数,经常被问到的一个问题是:“如果可以用迭代方式执行许多相同任务(使用for循环或while循环),为什么还要使用递归函数?”事实证明,递归问题总是可以用迭代方式解决——然而,对于稍微复杂的问题,递归版本通常更易于编写(和阅读)。例如,虽然可以用迭代方式编写斐波那契函数,但这要稍微困难一些!(试试看!)

迭代函数(那些使用for循环或while循环的函数)几乎总是比对应的递归函数执行效率更高。这是因为每次调用函数时,推送和弹出栈帧都会产生一定开销。迭代函数避免了这种开销。

这并不是说迭代函数总是更好的选择。有时,函数的递归实现非常清晰,也很容易编写,因此为了可维护性,承担一点额外开销是值得的,特别是在算法不需要递归太多次就能找到解决方案时。

通常,当满足以下大多数条件时,递归是一个不错的选择:

  1. 递归代码的实现要简单得多。
  2. 递归深度可以是有限的。
  3. 该算法的迭代版本需要管理数据栈。
  4. 这不是代码的性能关键部分。

如果递归算法更易于实现,可以先以递归方式实现,之后有需要时再优化为迭代算法。


20.1 堆和栈

上一节

20.3 命令行参数

下一节