Java 基础入门(七):递归详解——执行原理、方法调用栈与经典练习

一、递归的基本概念

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 柱。

移动过程中必须遵守以下规则:

  1. 每次只能移动一个圆盘。
  2. 只能移动某根柱子最上面的圆盘。
  3. 较大的圆盘不能放在较小的圆盘上面。

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 个圆盘:

我们只需要明确:

  1. 先将 n - 1 个圆盘移到辅助柱。
  2. 再移动最大的圆盘。
  3. 最后将 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 数组的学习,介绍一维数组、二维数组以及数组的常见操作,为后续学习数据结构打下基础。

相关推荐
若亦_1 小时前
排序>>交换排序
java·数据结构·算法
Wang's Blog1 小时前
MongoDB 地理位置上报实战:从接口设计到覆盖写入的完整实现
java·交友
鬼手点金1 小时前
Skills:领域能力的可组合单元
java·人工智能·笔记·自动化·1024程序员节
captain37610 小时前
MyBatis(入门5)其他查询操作
java·数据库·spring boot·mybatis
Novproxy_Z10 小时前
商用代理避坑:如何识别商家拿机房 IP 冒充住宅 IP
java·网络·tcp/ip
文丑颜不良啊11 小时前
LeetCode1481~1500题目解析
java·leetcode
2601_9628857211 小时前
quotes 字段全解:涨跌幅、振幅、换手率的口径与单位
java·前端·spring
前端精髓12 小时前
调用openrouter的免费API模型
java·前端·javascript
步行cgn12 小时前
Spring MVC 拦截器 Interceptor 详解:从执行流程到实战应用
java·spring·mvc