一、递归的基本概念
1.1 什么是递归?
递归(Recursion)是指一个方法在执行过程中直接或间接调用自身的编程方式。
在学习普通方法调用时,我们通常会这样编写代码:
java
public static void methodA() {
methodB();
}
public static void methodB() {
System.out.println("Hello Java");
}
这里,methodA() 调用了 methodB()。
而递归与普通方法调用的不同之处在于:方法调用的目标可以是自己。
例如:
java
public static void func() {
func();
}
这段代码虽然符合递归调用的形式,但存在一个严重问题:没有终止条件。
如果执行 func(),方法就会不断调用自身,导致方法调用栈持续增长,最终可能抛出 StackOverflowError。
因此,递归不是简单地让方法调用自己,而是需要有明确的执行规则。
1.2 递归的两个核心条件
一个能够正常终止的递归算法,通常需要满足两个条件。
1. 递归终止条件
也称为基本情况(Base Case)。
它用于规定在什么情况下,不需要继续调用自身,而是可以直接得到结果。
例如:
java
if (n == 0) {
return;
}
当 n == 0 时,方法直接结束,不再继续递归。
2. 递归关系
也称为递归步骤(Recursive Step)。
它用于说明如何将当前问题转化为规模更小、结构相同或相似的子问题。
例如:
java
func(n - 1);
每次调用时,参数 n 都减少 1,不断接近终止条件。
需要注意:仅仅写出终止条件还不够,还必须确保递归过程确实能够到达该条件。
1.3 一个简单的递归示例
先通过一个简单例子,观察递归方法的运行过程。
题目:使用递归依次输出 3、2、1。
java
public class RecursionDemo {
public static void print(int n) {
if (n == 0) {
return;
}
System.out.println(n);
print(n - 1);
}
public static void main(String[] args) {
print(3);
}
}
运行结果:
text
3
2
1
执行过程:
text
print(3)
|
| 输出 3
v
print(2)
|
| 输出 2
v
print(1)
|
| 输出 1
v
print(0)
|
| 满足终止条件
v
结束递归
可以发现:
print(3)调用print(2)。print(2)调用print(1)。print(1)调用print(0)。print(0)满足终止条件,不再调用自身。
因此,递归最终能够结束。
总结:递归的关键不是方法调用自身,而是通过方法调用,不断将问题缩小,直到能够直接解决。
二、递归的执行原理
理解递归时,最容易困惑的地方通常不是代码怎么写,而是:
为什么方法不断调用自身,最后还能正确返回结果?
要理解这个问题,需要结合之前学习过的方法调用栈。
2.1 方法调用栈
在 Java 中,每次方法调用都会创建对应的栈帧(Stack Frame),用于保存该次调用所需的执行状态,例如局部变量、操作数栈以及方法的执行位置等。
当方法调用另一个方法时,新的调用对应的栈帧会进入当前线程的 Java 虚拟机栈。
当方法执行结束后,对应的栈帧会退出,程序返回调用它的位置继续执行。
递归也是如此。
虽然每次调用的是同一个方法,但每次调用都有自己独立的执行状态。
例如:
java
public static void print(int n) {
if (n == 0) {
return;
}
System.out.println(n);
print(n - 1);
}
调用:
java
print(3);
递归深入时,可以将方法调用栈简化理解为:
text
栈顶
┌─────────────────┐
│ print(0) n = 0 │
├─────────────────┤
│ print(1) n = 1 │
├─────────────────┤
│ print(2) n = 2 │
├─────────────────┤
│ print(3) n = 3 │
├─────────────────┤
│ main() │
└─────────────────┘
栈底
注意,这只是帮助理解的示意图,并不是 JVM 栈帧实际内存结构的完整表示。
此时:
print(3)的n是3。print(2)的n是2。print(1)的n是1。print(0)的n是0。
它们并不是同一个局部变量。
当 print(0) 执行完毕后,对应调用结束,程序逐层返回。
2.2 什么是"递"和"归"?
理解递归时,可以将执行过程分为两个阶段。
第一阶段:递
不断调用自身,将问题逐步缩小。
第二阶段:归
到达终止条件后,按照方法调用关系逐层返回,继续完成尚未执行的操作。
需要注意,递归过程通常并不是严格分成两个互不交叉的阶段。这里的"递"和"归"主要用于帮助理解典型的递归执行过程。
下面通过一个例子说明。
2.3 观察递归调用前后的代码
java
public class RecursionDemo {
public static void print(int n) {
if (n == 0) {
return;
}
System.out.println("递:" + n);
print(n - 1);
System.out.println("归:" + n);
}
public static void main(String[] args) {
print(3);
}
}
运行结果:
text
递:3
递:2
递:1
归:1
归:2
归:3
为什么会出现这样的结果?
我们将代码分成三部分:
java
System.out.println("递:" + n);
print(n - 1);
System.out.println("归:" + n);
第一条输出语句位于递归调用之前。
因此,每次进入方法后,都会先执行它。
执行顺序:
text
print(3) → 输出:递:3
|
v
print(2) → 输出:递:2
|
v
print(1) → 输出:递:1
|
v
print(0) → 直接返回
当 print(0) 结束后,程序开始逐层返回。
由于:
java
System.out.println("归:" + n);
位于递归调用之后,因此只有当前层调用的 print(n - 1) 返回后,才会执行。
返回过程:
text
print(0) 返回
|
v
print(1) → 输出:归:1
|
v
print(2) → 输出:归:2
|
v
print(3) → 输出:归:3
所以,最终结果为:
text
递:3
递:2
递:1
归:1
归:2
归:3
由此可以得到一个很重要的结论:
递归调用之前的操作,通常会随着递归深入执行;递归调用之后的操作,则需要等子调用返回后才能继续执行。
理解这一点,对后面分析递归计算、数位输出以及二叉树递归遍历都非常重要。
三、递归经典练习
前面已经了解了递归的基本概念与执行原理。
接下来,通过几个经典练习进一步理解递归。
3.1 练习一:计算 n 的阶乘
3.1.1 问题描述
给定一个非负整数 n,计算 n!。
例如:
text
4! = 4 × 3 × 2 × 1 = 24
规定:
text
0! = 1
1! = 1
3.1.2 寻找递归关系
观察:
text
4! = 4 × 3!
3! = 3 × 2!
2! = 2 × 1!
1! = 1
可以发现:
计算 n 的阶乘,可以转化为计算 n - 1 的阶乘。
递归关系:
text
factorial(n) = n × factorial(n - 1)
终止条件:
text
当 n == 0 或 n == 1 时,返回 1。
3.1.3 代码实现
java
public class FactorialDemo {
public static long factorial(int n) {
if (n < 0 || n > 20) {
throw new IllegalArgumentException(
"n 必须在 0~20 之间"
);
}
if (n == 0 || n == 1) {
return 1;
}
return n * factorial(n - 1);
}
public static void main(String[] args) {
System.out.println(factorial(4));
}
}
运行结果:
text
24
这里使用 long 保存结果。由于 long 的数值范围有限,21! 已经超出它的表示范围,因此示例将输入限制在 0~20。
3.1.4 递归执行过程
以 factorial(4) 为例。
第一阶段:递归展开
text
factorial(4)
|
v
4 × factorial(3)
|
v
3 × factorial(2)
|
v
2 × factorial(1)
|
v
1
需要注意:
当程序执行:
java
return n * factorial(n - 1);
时,必须先计算 factorial(n - 1) 的结果,才能完成当前层的乘法运算。
因此,在子问题没有返回之前,当前层的计算尚未完成。
第二阶段:逐层返回
text
factorial(1) = 1
factorial(2) = 2 × 1
= 2
factorial(3) = 3 × 2
= 6
factorial(4) = 4 × 6
= 24
最终得到:
text
24
3.1.5 复杂度分析
每次递归都将 n 减少 1。
因此:
- 时间复杂度:
O(n)。 - 空间复杂度:
O(n),主要来自递归调用栈。
这个例子最重要的不是阶乘本身,而是理解每一层递归都需要等待下一层返回结果。
3.2 练习二:计算 1 到 n 的整数之和
3.2.1 问题描述
计算:
text
1 + 2 + 3 + ... + n
例如:
text
1 + 2 + 3 + 4 + 5 = 15
3.2.2 寻找递归关系
观察:
text
sum(5) = 5 + sum(4)
sum(4) = 4 + sum(3)
sum(3) = 3 + sum(2)
sum(2) = 2 + sum(1)
sum(1) = 1 + sum(0)
因此,递归关系为:
text
sum(n) = n + sum(n - 1)
终止条件:
text
sum(0) = 0
3.2.3 代码实现
java
public class SumDemo {
public static int sum(int n) {
if (n < 0) {
throw new IllegalArgumentException(
"n 不能为负数"
);
}
if (n == 0) {
return 0;
}
return n + sum(n - 1);
}
public static void main(String[] args) {
System.out.println(sum(10));
}
}
运行结果:
text
55
3.2.4 执行过程
以 sum(3) 为例:
text
sum(3)
= 3 + sum(2)
= 3 + 2 + sum(1)
= 3 + 2 + 1 + sum(0)
= 3 + 2 + 1 + 0
= 6
递归的本质仍然是:
将求 1 到 n 的和,转化为求 1 到 n - 1 的和。
这个方法的时间复杂度和空间复杂度均为 O(n)。
对于实际计算,如果只需要求整数之和,可以使用循环或数学公式。对于非常大的 n,递归还可能因为调用层数过深而导致栈溢出。
此外,int 也有表示范围限制,计算结果过大时会发生整数溢出。
3.3 练习三:按顺序打印整数的每一位
3.3.1 问题描述
输入一个非负整数,按照从左到右的顺序输出它的每一位数字。
例如:
text
输入:1234
输出:1 2 3 4
3.3.2 解题思路
对于整数:
text
1234
可以通过以下运算拆分:
java
1234 / 10 = 123
java
1234 % 10 = 4
其中:
/ 10:去掉最后一位数字。% 10:取出最后一位数字。
如果希望从左到右输出:
text
1 2 3 4
就应该先处理高位数字,再输出当前整数的最后一位。
因此:
text
先递归处理 n / 10
再输出 n % 10
终止条件:
text
n < 10
当 n 只有一位时,直接输出即可。
3.3.3 代码实现
java
public class DigitPrintDemo {
public static void printDigits(int n) {
if (n < 0) {
throw new IllegalArgumentException(
"n 不能为负数"
);
}
if (n < 10) {
System.out.print(n);
return;
}
printDigits(n / 10);
System.out.print(" " + n % 10);
}
public static void main(String[] args) {
printDigits(1234);
System.out.println();
}
}
运行结果:
text
1 2 3 4
3.3.4 递归执行过程
调用:
java
printDigits(1234);
递归深入:
text
printDigits(1234)
|
v
printDigits(123)
|
v
printDigits(12)
|
v
printDigits(1)
当 n == 1 时:
java
if (n < 10)
条件成立,输出:
text
1
随后开始返回。
逐层输出:
text
printDigits(1) → 输出 1
printDigits(12) → 输出 2
printDigits(123) → 输出 3
printDigits(1234) → 输出 4
最终:
text
1 2 3 4
3.3.5 为什么要先递归再输出?
观察:
java
printDigits(n / 10);
System.out.print(" " + n % 10);
递归调用位于输出语句之前,因此程序会先深入处理更高位的数字。
如果把顺序颠倒:
java
System.out.print(n % 10 + " ");
printDigits(n / 10);
就会先输出最低位,得到从右到左的顺序:
text
4 3 2 1
这说明:
递归调用和其他操作的先后顺序,会直接影响程序的执行结果。
这也是学习二叉树前序、中序、后序遍历时需要理解的基础思想。
3.4 练习四:计算整数各位数字之和
3.4.1 问题描述
输入一个非负整数,返回它的各位数字之和。
例如:
text
输入:1729
输出:19
因为:
text
1 + 7 + 2 + 9 = 19
3.4.2 寻找递归关系
同样利用:
java
n % 10
取出最后一位数字。
利用:
java
n / 10
去掉最后一位数字。
例如:
text
1729 % 10 = 9
1729 / 10 = 172
因此:
text
1729 各位数字之和
= 9 + 172 各位数字之和
递归关系:
text
digitSum(n) = n % 10 + digitSum(n / 10)
终止条件:
text
当 n < 10 时,直接返回 n。
3.4.3 代码实现
java
public class DigitSumDemo {
public static int digitSum(int n) {
if (n < 0) {
throw new IllegalArgumentException(
"n 不能为负数"
);
}
if (n < 10) {
return n;
}
return n % 10 + digitSum(n / 10);
}
public static void main(String[] args) {
System.out.println(digitSum(1729));
}
}
运行结果:
text
19
3.4.4 执行过程
text
digitSum(1729)
= 9 + digitSum(172)
= 9 + 2 + digitSum(17)
= 9 + 2 + 7 + digitSum(1)
= 9 + 2 + 7 + 1
= 19
与上一道题相比:
- 上一道题通过递归逐位输出数字。
- 这一道题通过递归将每一位数字的结果累加起来。
虽然题目要求不同,但使用的是相同的数位拆分思路。
总结:对于整数数位问题,可以优先考虑通过 / 10 缩小问题规模,通过 % 10 获取当前需要处理的数字。
四、递归与迭代:斐波那契数列
前面的递归练习,每次通常只调用一次自身。
但是,有些递归问题在每一层会产生多个子问题。
斐波那契数列就是一个典型例子。
4.1 斐波那契数列的概念
斐波那契数列(Fibonacci Sequence)是一组经典的整数序列。
本文规定第一项和第二项均为 1:
text
1、1、2、3、5、8、13、21、34、55 ...
从第三项开始,每一项等于前两项之和。
例如:
text
第 3 项:1 + 1 = 2
第 4 项:1 + 2 = 3
第 5 项:2 + 3 = 5
因此,可以得到递归关系:
text
fib(1) = 1
fib(2) = 1
fib(n) = fib(n - 1) + fib(n - 2)
其中:
text
n >= 3
4.2 递归实现
java
public class FibonacciDemo {
public static long fib(int n) {
if (n <= 0) {
throw new IllegalArgumentException(
"n 必须大于 0"
);
}
if (n == 1 || n == 2) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
public static void main(String[] args) {
System.out.println(fib(6));
}
}
运行结果:
text
8
从代码结构来看,这种实现非常简洁。
但是,它存在一个明显的问题。
4.3 递归调用过程分析
以 fib(5) 为例:
text
fib(5)
├── fib(4)
│ ├── fib(3)
│ │ ├── fib(2) = 1
│ │ └── fib(1) = 1
│ └── fib(2) = 1
└── fib(3)
├── fib(2) = 1
└── fib(1) = 1
仔细观察,可以发现:
text
fib(3)
被计算了不止一次。
同样:
text
fib(2)
也被重复计算。
当 n 不断增加时,这种重复计算会迅速增加。
因此,虽然直接递归的代码简洁,但执行效率并不理想。
4.4 朴素递归的复杂度
对于:
java
return fib(n - 1) + fib(n - 2);
每次递归会产生两个更小的子问题。
它的时间复杂度呈指数级增长,常用 O(2^n) 作为渐进上界。
递归调用的最大深度与 n 成正比,因此调用栈的空间复杂度为:
text
O(n)
当 n 较大时,朴素递归会进行大量重复计算。
因此,不能仅仅因为问题能够写成递归形式,就认为递归一定是最合适的实现方式。
4.5 迭代实现
仔细观察斐波那契数列:
text
1、1、2、3、5、8、13 ...
计算下一项时,实际上只需要前两项。
例如:
text
第 3 项 = 第 1 项 + 第 2 项
第 4 项 = 第 2 项 + 第 3 项
第 5 项 = 第 3 项 + 第 4 项
因此,可以使用三个变量记录计算过程:
a:前面的第一项。b:前面的第二项。next:当前计算出的下一项。
代码如下:
java
public class FibonacciDemo {
public static long fib(int n) {
if (n <= 0 || n > 92) {
throw new IllegalArgumentException(
"n 必须在 1~92 之间"
);
}
if (n == 1 || n == 2) {
return 1;
}
long a = 1;
long b = 1;
for (int i = 3; i <= n; i++) {
long next = a + b;
a = b;
b = next;
}
return b;
}
public static void main(String[] args) {
System.out.println(fib(6));
}
}
运行结果:
text
8
这里将 n 限制在 1~92,是为了避免计算结果超出 long 的表示范围。
执行过程:
| 计算位置 | a | b |
|---|---|---|
| 初始 | 1 | 1 |
| 第 3 项 | 1 | 2 |
| 第 4 项 | 2 | 3 |
| 第 5 项 | 3 | 5 |
| 第 6 项 | 5 | 8 |
最终返回:
text
8
4.6 递归与迭代的效率比较
| 比较项 | 朴素递归 | 迭代 |
|---|---|---|
| 实现方式 | 方法调用自身 | 使用循环 |
| 时间复杂度 | O(2^n)(上界) | O(n) |
| 额外空间复杂度 | O(n) | O(1) |
| 重复计算 | 大量重复计算 | 无需重复计算已处理的项 |
| 特点 | 直接体现数学递归关系 | 通常执行效率更高 |
因此,在计算斐波那契数列的第 n 项时,迭代通常比朴素递归更合适。
当然,递归也可以通过记忆化等方式避免重复计算,将时间复杂度优化到 O(n)。
这里暂时不展开,后续学习动态规划时会进一步介绍。
五、递归进阶练习:汉诺塔
前面介绍的递归问题,大多可以通过简单的数学关系直接建立递归式。
接下来,介绍一个更加典型的递归问题:汉诺塔(Tower of Hanoi)。
它的重点不再只是计算某个数值,而是通过递归解决一个操作过程较复杂的问题。
5.1 汉诺塔的基本概念
汉诺塔是一个经典的数学与算法问题。
假设有三根柱子:
text
A、B、C
在 A 柱上放置若干个大小不同的圆盘。
圆盘按照从大到小的顺序排列:
text
上面:较小的圆盘
下面:较大的圆盘
现在要求:
借助 B 柱,将 A 柱上的全部圆盘移动到 C 柱。
移动过程中必须遵守以下规则:
- 每次只能移动一个圆盘。
- 只能移动某根柱子最上面的圆盘。
- 较大的圆盘不能放在较小的圆盘上面。
5.2 只有一个圆盘
如果 A 柱上只有一个圆盘,那么直接移动到 C 柱即可。
text
A → C
只需要移动一次。
因此:
text
T(1) = 1
这里的 T(n) 表示移动 n 个圆盘的最少次数。
5.3 两个圆盘
当 A 柱上有两个圆盘时:
text
A:大盘、小盘
B:空
C:空
由于大盘不能放在小盘上面,因此不能直接将大盘移动到 C 柱后就结束。
正确移动过程:
text
第一步:A → B
第二步:A → C
第三步:B → C
总共需要三次。
text
T(2) = 3
5.4 三个圆盘
如果 A 柱上有三个圆盘,则需要移动七次。
移动过程:
text
1. A → C
2. A → B
3. C → B
4. A → C
5. B → A
6. B → C
7. A → C
因此:
text
T(3) = 7
可以发现:
随着圆盘数量增加,直接手动分析每一次移动会越来越复杂。
那么,能否将大问题拆分为更小的问题呢?
5.5 汉诺塔的递归思想
假设现在 A 柱上有 n 个圆盘。
我们的目标是:
text
将 n 个圆盘从 A 移动到 C
B 作为辅助柱
观察可以发现,最下面最大的圆盘只能在上面的 n - 1 个圆盘全部移走后,才能移动。
因此,整个问题可以拆分为三个步骤。
第一步:移动上面的 n - 1 个圆盘
将 A 柱上面的 n - 1 个圆盘,借助 C 柱移动到 B 柱。
text
起始柱:A
辅助柱:C
目标柱:B
第二步:移动最大的圆盘
将 A 柱最下面的最大圆盘移动到 C 柱。
text
A → C
第三步:移动剩下的 n - 1 个圆盘
将 B 柱上的 n - 1 个圆盘,借助 A 柱移动到 C 柱。
text
起始柱:B
辅助柱:A
目标柱:C
也就是说:
text
移动 n 个圆盘
|
v
移动 n - 1 个圆盘到辅助柱
|
v
移动最大的圆盘到目标柱
|
v
移动 n - 1 个圆盘到目标柱
注意:
移动 n - 1 个圆盘和移动 n 个圆盘,本质上仍然是同一类问题。
只是问题规模发生了变化。
因此,汉诺塔非常适合使用递归实现。
5.6 代码实现
java
public class HanoiDemo {
// 输出一次移动操作
public static void move(char from, char to) {
System.out.println(from + " -> " + to);
}
/**
* n:圆盘数量
* from:起始柱
* auxiliary:辅助柱
* to:目标柱
*/
public static void hanoi(
int n, char from, char auxiliary, char to) {
if (n <= 0) {
throw new IllegalArgumentException(
"圆盘数量必须大于 0"
);
}
// 终止条件:只剩一个圆盘
if (n == 1) {
move(from, to);
return;
}
// 1. 将上面的 n - 1 个圆盘移到辅助柱
hanoi(n - 1, from, to, auxiliary);
// 2. 将最大的圆盘移到目标柱
move(from, to);
// 3. 将 n - 1 个圆盘移到目标柱
hanoi(n - 1, auxiliary, from, to);
}
public static void main(String[] args) {
hanoi(3, 'A', 'B', 'C');
}
}
运行结果:
text
A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C
5.7 如何理解三个参数的位置变化?
这是汉诺塔最容易出错的地方。
观察方法定义:
java
hanoi(int n, char from, char auxiliary, char to)
其中:
text
from 表示起始柱
auxiliary 表示辅助柱
to 表示目标柱
在第一次递归调用中:
java
hanoi(n - 1, from, to, auxiliary);
为什么 to 和 auxiliary 的位置发生变化?
因为这次递归的目标已经不是将圆盘移动到原来的 to,而是将上面的 n - 1 个圆盘移动到原来的辅助柱。
因此:
text
原来的 from → 新的起始柱
原来的 to → 新的辅助柱
原来的 auxiliary → 新的目标柱
同样,第三次调用:
java
hanoi(n - 1, auxiliary, from, to);
表示:
text
从原来的辅助柱出发
借助原来的起始柱
移动到原来的目标柱
理解汉诺塔时,重点不是记住参数位置,而是始终明确每次递归调用的起始柱、辅助柱和目标柱分别是什么。
5.8 汉诺塔的移动次数
假设移动 n 个圆盘的最少次数为 T(n)。
根据前面的分析:
移动 n 个圆盘,需要:
text
移动 n - 1 个圆盘
+ 移动最大的圆盘一次
+ 再移动 n - 1 个圆盘
因此:
text
T(n) = T(n - 1) + 1 + T(n - 1)
整理:
text
T(n) = 2 × T(n - 1) + 1
因为:
text
T(1) = 1
所以:
text
T(2) = 2 × 1 + 1 = 3
T(3) = 2 × 3 + 1 = 7
T(4) = 2 × 7 + 1 = 15
T(5) = 2 × 15 + 1 = 31
可以推导出:
text
T(n) = 2^n - 1
因此,汉诺塔的最少移动次数为:
text
2^n - 1
对于上面的递归实现:
- 时间复杂度:
O(2^n)。 - 递归调用栈空间复杂度:
O(n)。
这里时间复杂度较高,是因为必须输出指数级数量的移动步骤。
5.9 汉诺塔的核心思想
通过汉诺塔可以进一步理解递归的一个重要特点:
当前层不需要亲自解决每个子问题的全部细节,只需要明确如何利用子问题的结果完成当前问题。
例如:
对于移动 n 个圆盘:
我们只需要明确:
- 先将
n - 1个圆盘移到辅助柱。 - 再移动最大的圆盘。
- 最后将
n - 1个圆盘移到目标柱。
至于 n - 1 个圆盘具体应该如何移动,则交给下一层递归完成。
这就是递归解决复杂问题的重要方式。
六、递归常见问题与注意事项
6.1 缺少终止条件
错误示例:
java
public static void func(int n) {
System.out.println(n);
func(n - 1);
}
这段代码没有终止条件,会持续调用自身。
正确思路:
java
public static void func(int n) {
if (n <= 0) {
return;
}
System.out.println(n);
func(n - 1);
}
递归必须能够在有限步骤内结束。
6.2 递归没有向终止条件靠近
例如:
java
public static void func(int n) {
if (n == 0) {
return;
}
func(n + 1);
}
对于正整数 n,递归调用反而不断增大参数,无法到达 n == 0。
所以,写出终止条件不等于递归一定能够终止。
还需要检查每一次递归是否在缩小问题规模。
6.3 忽略递归调用后的代码
例如:
java
public static void print(int n) {
if (n == 0) {
return;
}
print(n - 1);
System.out.println(n);
}
调用:
java
print(3);
输出结果为:
text
1
2
3
而不是:
text
3
2
1
因为输出语句位于递归调用之后,必须等下一层递归返回后才能执行。
因此,分析递归时,不仅要观察递归调用,还要注意它前后的其他代码。
6.4 递归层数过深
Java 的方法调用需要使用栈空间。
当递归层数过深时,可能出现:
text
java.lang.StackOverflowError
这是由于当前线程的调用栈无法继续容纳更多调用层次。
需要注意,递归的最大安全深度并不是固定值,它会受到线程栈大小、每层栈帧空间开销及运行环境等因素影响。
对于层数特别深、能够方便转化为循环的问题,可以考虑使用迭代方式实现。
6.5 重复计算
斐波那契数列的朴素递归就是典型例子。
java
return fib(n - 1) + fib(n - 2);
它会不断重复计算相同的子问题。
如果问题具有大量重复子问题,可以考虑:
- 使用记忆化保存已计算的结果。
- 使用循环实现。
- 使用动态规划优化计算过程。
具体如何优化,需要根据实际问题判断。
6.6 递归一定比循环慢吗?
不一定。
递归与循环只是不同的程序组织方式。
对于简单的重复计算,循环通常能够避免额外的方法调用栈开销。
但对于树的遍历、分治等具有明显递归结构的问题,递归可能更加自然、清晰。
因此,选择递归还是迭代,需要综合考虑:
- 问题本身的结构。
- 时间复杂度。
- 空间复杂度。
- 递归调用深度。
- 代码的可读性与可维护性。
七、递归解题思路总结
经过前面的练习,可以发现,递归题目虽然形式不同,但分析方式具有很多共同点。
面对一个递归问题,可以按照以下步骤思考。
7.1 第一步:明确方法的功能
首先,不要急着编写递归调用。
应该先明确:
当前方法究竟要解决什么问题?
例如:
java
factorial(n)
表示:
text
计算 n 的阶乘。
java
digitSum(n)
表示:
text
计算 n 的各位数字之和。
java
fib(n)
表示:
text
计算斐波那契数列的第 n 项。
只有明确方法的功能,才能进一步分析递归关系。
7.2 第二步:寻找递归终止条件
思考:
什么情况下可以直接得到结果,不再需要继续递归?
例如:
计算阶乘:
java
if (n == 0 || n == 1) {
return 1;
}
计算数字之和:
java
if (n < 10) {
return n;
}
汉诺塔:
java
if (n == 1) {
move(from, to);
return;
}
终止条件应当与题目本身的定义保持一致。
7.3 第三步:建立递归关系
思考:
如何利用规模更小的同类问题,解决当前问题?
例如:
阶乘:
java
return n * factorial(n - 1);
整数各位数字之和:
java
return n % 10 + digitSum(n / 10);
斐波那契数列:
java
return fib(n - 1) + fib(n - 2);
这些语句虽然不同,但本质上都在使用子问题的结果解决当前问题。
7.4 第四步:分析递归展开与返回
对于不容易理解的递归,可以选取较小的数据进行手动分析。
例如:
java
factorial(3)
观察:
text
factorial(3)
|
v
factorial(2)
|
v
factorial(1)
然后再分析返回:
text
factorial(1) = 1
factorial(2) = 2
factorial(3) = 6
如果仍然不清楚,可以结合前一篇学习的 IDEA Debug 调试功能,在递归方法中设置断点。
通过观察:
- 每一层递归的参数值。
- 当前方法的局部变量。
- 方法调用栈中的调用层次。
- 递归调用返回后继续执行的代码。
可以更加直观地理解递归的完整执行过程。
7.5 第五步:检查边界与效率
最后,还需要检查:
- 输入是否存在非法值?
- 递归是否一定能够终止?
- 是否会发生整数溢出?
- 递归层数是否过深?
- 是否存在重复计算?
- 是否有更合适的迭代实现?
因此,写出能够运行的递归代码只是第一步。
能够正确分析其终止性、执行过程与复杂度,才是真正理解递归。
下一篇文章将进入 Java 数组的学习,介绍一维数组、二维数组以及数组的常见操作,为后续学习数据结构打下基础。