一文吃透多路递归:从汉诺塔到杨辉三角的实践指南

176 阅读14分钟

在递归算法的世界里,我们通常先接触 “单路递归”—— 比如斐波那契数列(虽然优化后常用迭代),每次递归仅调用自身一次。但当问题需要拆解为多个子问题依次求解时,“多路递归” 就成了更强大的工具。它能将复杂问题拆解为结构相似的多个子任务,通过组合子任务的结果得到最终答案。

本文将从多路递归的核心概念入手,结合汉诺塔(经典移动问题)和杨辉三角(组合数计算)两个案例,带你掌握多路递归的设计思路、代码实现与优化技巧,最终能独立解决类似的递归问题。

一、什么是多路递归?先理清核心概念

在正式讲案例前,我们先明确“多路递归”的定义,避免和其他递归类型混淆:

  • 单路递归:函数在递归过程中,仅调用自身一次。例如求 n 的阶乘(f(n) = n * f(n-1)),每次递归只依赖一个子问题的结果。
  • 多路递归:函数在递归过程中,调用自身两次及以上。例如本文要讲的汉诺塔,每次移动都需要调用两次递归函数处理不同柱子的盘子;杨辉三角的每个元素,也需要依赖上方两个元素的递归结果。

多路递归的核心思想是“分而治之”:将原问题拆解为多个规模更小、结构相同的子问题,分别求解后合并结果。其关键在于两点:

1、找到递归边界:当子问题规模小到可以直接求解时,停止递归(避免无限循环);

2、明确递归关系:原问题与子问题之间的数学/逻辑关联

二、案例一:汉诺塔——理解多路递归的 “任务拆分”

汉诺塔是递归算法的 “入门经典”,也是理解多路递归 “任务拆分逻辑” 的最佳案例。我们先明确问题规则,再逐步推导递归思路。

1、汉诺塔问题规则

有3根柱子(A、B、C)和n个大小不同的盘子(从小到大编号 1~n),初始时所有盘子都叠在A柱上(大的在下,小的在上)。要求:

  • 每次只能移动 1 个盘子;
  • 任何时刻都不能让 “大盘子在小盘子上方”;
  • 最终将所有盘子从A柱移动到C柱(B 柱作为辅助)。

2. 递归思路:从 “小问题” 推导 “大问题”

直接思考n个盘子的移动方式很复杂,我们可以先从小规模问题入手,找到规律:

(1)当 n=1 时(边界条件)

只有 1 个盘子,直接从 A 移到 C 即可,步骤:A → C。

(2)当 n=2 时

需要借助 B 柱,分 3 步:

  1. 把盘子 1(小)从 A → B;
  1. 把盘子 2(大)从 A → C;
  1. 把盘子 1(小)从 B → C。

此时发现:移动 2 个盘子的核心是 “先把上方小盘子移到辅助柱,再把下方大盘子移到目标柱,最后把小盘子从辅助柱移到目标柱”。

(3)当 n=3 时

基于 n=2 的思路,我们可以把 “上方 2 个盘子” 看作一个整体,拆解为 3 个核心任务:

  1. 把 A 柱上的“盘子1 + 盘子2”移到 B 柱(此时 C 柱作为辅助,复用 n=2 的逻辑);
  1. 把 A 柱上剩下的“盘子 3”(最大的)移到 C 柱;
  1. 把 B 柱上的“盘子1 + 盘子2”移到 C 柱(此时 A 柱作为辅助,再次复用 n=2 的逻辑)。

此时,n=3 的问题被拆解为 “2 个 n=2 的子问题”+“1 个直接移动”,这就是多路递归的核心——用多个小规模子问题的解,组合成原问题的解。

汉诺塔n=3.png

图 1:汉诺塔 n=3 时的核心移动过程 —— 通过三步操作,完成从 “a柱初始状态” 到 “c柱最终叠放” 的转移,直观体现多路递归的 “子问题拆分与组合” 逻辑。

(4)推广到 n 个盘子

对于 n 个盘子,递归关系可总结为:

  • 子问题 1:将 A 柱上的 n-1 个盘子,通过 C 柱辅助,移动到 B 柱;
  • 直接操作:将 A 柱上剩下的第 n 个盘子(最大),移动到 C 柱;
  • 子问题 2:将 B 柱上的 n-1 个盘子,通过 A 柱辅助,移动到 C 柱。

3、代码实现与验证

我们用Java实现上述逻辑,并测试 n=3 的情况

3.1 代码完整展示

import java.util.LinkedList;

public class HanoiTower {
    static LinkedList<Integer> a = new LinkedList<>();
    static LinkedList<Integer> b = new LinkedList<>();
    static LinkedList<Integer> c = new LinkedList<>();

    static void init(int n){
        for (int i = n; i >= 1; i--) {
            a.addLast(i);
        }
    }
   
    static void move(int n,LinkedList<Integer> a,LinkedList<Integer> b,LinkedList<Integer> c){
        if(n==0){
            return;
        }
        move(n-1,a,c,b);
        c.addLast(a.removeLast());
        print();
        move(n-1,b,a,c);
    }

    public static void main(String[] args) {
        init(3);
        print();
        move(3,a,b,c);
    }

    private static void print() {
        System.out.println("----------");
        System.out.println(a);
        System.out.println(b);
        System.out.println(c);
    }
}

3.2 代码结构解析

(1)数据结构定义
static LinkedList<Integer> a = new LinkedList<>();
static LinkedList<Integer> b = new LinkedList<>();
static LinkedList<Integer> c = new LinkedList<>();
  • 用LinkedList模拟三根柱子(a、b、c),其中存储的Integer表示圆盘的大小(数字越大,圆盘越大)。
  • LinkedList的addLast()和removeLast()方法模拟从柱子顶端添加/移除圆盘(符合栈的“后进先出”特性)。
(2)初始化方法 init(int n)
static void init(int n){
        for (int i = n; i >= 1; i--) {
            a.addLast(i);
        }
    }
  • 功能:初始化 a 柱,将n个圆盘按从小到大的顺序放入 a 柱(例如 n=3 时,a 柱为[3,2,1],3在最底部,1在顶端)。
(3)递归移动方法 move(...)
/**
 * @param n 圆盘个数
 * @param a 源头
 * @param b 辅助
 * @param c 目标
 */
static void move(int n, LinkedList<Integer> a, LinkedList<Integer> b, LinkedList<Integer> c) {
    if (n == 0) {
        return; //没有圆盘需要移动时直接返回
    }
    //步骤1:将n-1个圆盘从a柱通过c柱的辅助,移动到b柱
    move(n-1,a,c,b);
    // 步骤2:将第n个圆盘(最大的那个)从a柱直接移动到c柱
    c.addLast(a.removeLast());
    print(); // 打印移动后的状态
    //步骤3:将n-1个圆盘从b柱通过a柱的辅助,移动到c柱
    move(n-1,b,a,c);
}
  • 递归思想:将 n 个圆盘的移动分解为 3 步(利用递归将大问题拆解为小问题):

    1. 先把上方 n-1 个圆盘,借助目标柱 c,从源柱 a 移到辅助柱 b ;
    2. 把最底部的第 n 个圆盘(最大)从源柱 a 直接移到目标柱 c ;
    3. 再把 n-1 个圆盘,借助源柱 a ,从辅助柱 b 移到目标柱 c 。
  • 时间复杂度:递归公式为T(n) = 2*T(n-1) + c,解得时间复杂度为O(2ⁿ)(移动 n 个圆盘需要 2ⁿ-1 步)。

(4)打印方法 print()
private static void print() {
    System.out.println("----------");
    System.out.println(a);
    System.out.println(b);
    System.out.println(c);
}
  • 功能:打印每一步移动后三根柱子的状态(展示圆盘的分布),便于观察移动过程;
(5)主方法 main(...)
public static void main(String[] args) {
    init(3); // 初始化3个圆盘到a柱
    print(); // 打印初始状态
    move(3, a, b, c); // 开始移动3个圆盘(从a到c,借助b)
}
  • 流程:初始化 3 个圆盘到 a 柱,打印初始状态后,调用move方法开始移动,最终所有圆盘将从 a 柱移到 c 柱。

3.3 执行结果示例(n=3 时)

[3, 2, 1]
[]
[]
----------
[3, 2]
[]
[1]
----------
[3]
[2]
[1]
----------
[3]
[2, 1]
[]
----------
[]
[2, 1]
[3]
----------
[1]
[2]
[3]
----------
[1]
[]
[3, 2]
----------
[]
[]
[3, 2, 1]
  • 每一步移动后会打印状态,最终 C 柱会变为[3,2,1],完成移动。

4、复杂度分析

  • 时间复杂度:每次移动 n 个盘子需要 2 次移动 n-1 个盘子的操作(子问题 1 和子问题 2),再加上 1 次直接移动。递推公式为 T(n) = 2*T(n-1) + 1,边界条件T(1)=1。解得 T(n) = 2ⁿ - 1,即O(2ⁿ)
  • 空间复杂度:递归调用栈的深度为 n(每次递归 n 减 1,直到 n=1),因此空间复杂度为O(n)。

三、案例二:杨辉三角 —— 多路递归的 “结果合并”

杨辉三角(也称帕斯卡三角)是组合数学中的经典模型,其每个元素的值等于上方两个相邻元素之和,这一特性天然适配多路递归的 “多子问题结果合并” 逻辑。我们先明确杨辉三角的规则,再结合你提供的 Java 代码推导实现思路。

1、杨辉三角的核心规则

杨辉三角的结构满足以下 3 个条件:

  • 第 0 行只有 1 个元素,值为 1(行号从 0 开始,方便后续递归计算);
  • 每一行的第 0 个元素和最后 1 个元素,值均为 1;
  • 对于第 n 行第 k 个元素(0 < k < n+1),其值等于第 n-1 行第 k-1 个元素与第 n-1 行第 k 个元素之和,即 C(n,k) = C(n-1,k-1) + C(n-1,k)(C(n,k) 表示从 n 个元素中选 k 个的组合数)。

2、递归思路

我们可以从 “递归计算单个元素” 和 “格式化打印三角” 两个维度实现杨辉三角,核心思路与多路递归逻辑完全契合:

(1)维度一:递归计算单个元素的值

通过 element(int i, int j) 方法实现,核心是明确递归边界与递归关系:

  • 递归边界:当 j == 0(每行第 0 个元素)或 i == j(每行最后 1 个元素)时,元素值为 1;
  • 递归关系:对于 0 < j < i,元素值 = 上一行左侧元素(i-1 行 j-1 列) + 上一行右侧元素(i-1 行 j 列),即 element(i,j) = element(i-1,j-1) + element(i-1,j),这正是多路递归的典型应用 —— 依赖两个子问题结果合并得到当前结果。

(2)维度二:格式化打印完整三角

通过两个辅助方法配合实现:

  • printSpace(int n, int i):根据总行数 n 和当前行号 i,计算并打印每行左侧的空格,实现三角居中效果(空格数量为 (n-1-i)*2,确保不同行数的三角结构对称);
  • print(int n):循环生成每行元素,先调用 printSpace 打印左侧空格,再通过嵌套循环调用 element 方法获取每行每个元素的值,并用 printf("%-4d", element(i,j)) 格式化元素(左对齐,占 4 个字符位),保证元素排列整齐。

3、代码实现:基于 Java 的完整实现

public class PascalTriangle{
    /**
     * 多路递归:计算杨辉三角第i行第j列的元素值
     * @param i 行号(从0开始)
     * @param j 列号(从0开始)
     * @return 第i行第j列的元素值
     */
    private static int element(int i,int j){
        if(j==0 || i==j){
            return 1;
        }
        return element(i-1,j-1)+element(i-1,j);
    }
    /**
     * 辅助方法:打印每行左侧的空格,实现三角居中效果
     * @param n 杨辉三角的总行数
     * @param i 当前行号(从0开始)
     */
    public static void printSpace(int n,int i){
        int num = (n-1-i)*2;
        for (int j = 0; j < num; j++) {
            System.out.print(" ");
        }
    }

    public static void print(int n){
        //循环遍历每一行(行号从0到n-1)
        for (int i = 0; i < n; i++) {
            //先打印当前行左侧的空格,确保居中
            printSpace(n,i);
            //循环遍历当前行的每一列(列号从0到i)
            for (int j = 0; j <= i; j++) {
                System.out.printf("%-4d",element(i,j));
            }
            //一行打印完成后换行
            System.out.println();
        }
    }
    //主方法:程序入口,测试打印10行杨辉三角
    public static void main(String[] args) {
        print(10);
    }
}

4. 代码运行结果

在 Java 环境中运行上述代码,将输出 10 行居中对齐的杨辉三角,结果如下

                  1   
                1   1   
              1   2   1   
            1   3   3   1   
          1   4   6   4   1   
        1   5   10  10  5   1   
      1   6   15  20  15  6   1   
    1   7   21  35  35  21  7   1   
  1   8   28  56  70  56  28  8   1   
1   9   36  84  126 126 84  36  9   1   

5. 复杂度分析与优化:避免重复计算

上述 Java 代码的核心逻辑存在 “大量重复计算” 的问题:例如计算第 5 行第 2 个元素(值为 10)时,会重复计算第 4 行第 1 个元素(值为 4)、第 3 行第 0 个元素(值为 1)等,导致随着总行数 n 增大,计算效率明显下降。

(1)基础实现的复杂度

  • 时间复杂度:计算单个元素的时间为 O(2ⁱ)(i 为当前行号),生成 n 行三角的总时间为 O(n·2ⁿ),当 n=20 时,计算耗时会显著增加;
  • 空间复杂度:递归调用栈的深度为 n(计算第 n-1 行元素时,栈深度达到最大),同时存储打印结果无需额外空间,总空间复杂度为 O(n)。

(2)二维数组缓存避免重复计算

核心:通过二维数组缓存已计算的元素值,让每个元素 (i,j) 仅计算一次:

  • 初始化二维数组 triangle,初始值均为 0(未计算状态);
  • 计算元素前先判断 triangle[i][j] 是否大于 0,若大于 0 则直接返回(已计算过);
  • 计算完成后将结果存入 triangle[i][j],供后续调用复用。

代码实现如下:

public class PascalTriangle{
    /**
     * @param triangle 二维数组:缓存已计算的元素值,避免重复递归
     * @param i 行号(从0开始)
     * @param j 列号(从0开始)
     * @return 第i行第j列的元素值
     */
    private static int element(int[][] triangle,int i,int j){
        //缓存判断:若该位置已计算过(值>0),直接返回(核心优化点)
        if(triangle[i][j]>0){
            return triangle[i][j];
        }
        if(j==0 || i==j){
            triangle[i][j]=1;
            return 1;
        }
        triangle[i][j]=element(triangle,i-1,j-1)+element(triangle,i-1,j);
        return triangle[i][j];
    }

    public static void printSpace(int n,int i){
        int num = (n-1-i)*2;
        for (int j = 0; j < num; j++) {
            System.out.print(" ");
        }
    }

    public static void print(int n){
        //二维数组初始化后元素默认值为 0
        int[][] triangle =new int[n][];
        for (int i = 0; i < n; i++) {
            triangle[i]=new int[i+1];
            //打印当前行左侧空格,确保三角居中
            printSpace(n,i);
            for (int j = 0; j <= i; j++) {
                //循环获取当前行每个元素的值(依赖缓存,无重复计算)
                System.out.printf("%-4d",element(triangle,i,j));
            }
            System.out.println();
        }
    }
    public static void main(String[] args) {
        print(10);
    }
}

(3)复杂度对比

对比维度基础版(含重复计算)优化版(二维数组缓存)
时间复杂度(单个元素)计算第 i 行第 j 个元素时,因每个元素依赖两个子元素,递归调用次数呈指数增长,时间复杂度为 O(2ⁱ)每个元素 (i,j) 仅计算一次,后续直接从数组缓存读取,时间复杂度降至 O(1)
时间复杂度(整体)生成前 n 行需计算 n(n+1)/2 个元素,总时间复杂度为 O(n·2ⁿ)生成前 n 行仅需计算 n(n+1)/2 次,总时间复杂度为 O(n²)
空间复杂度仅需递归调用栈存储调用关系,栈深度最大为 n-1(即最大行号),空间复杂度为 O(n)需额外维护二维数组 triangle(大小为 n(n+1)/2),叠加递归调用栈空间,总空间复杂度为 O(n²)
核心差异无缓存导致大量重复计算,时间效率极低,仅适合小规模场景用空间换时间,通过数组缓存消除重复计算,时间效率大幅提升,支持中大规模场景

四、总结:掌握多路递归,解锁复杂问题求解能力

通过汉诺塔与杨辉三角案例可知,多路递归本质是 “分治 + 合并”,将原问题拆解为 2 个及以上相似子问题后组合结果,区别于单路递归的 “线性依赖”,设计时需明确递归边界与递归关系。

从案例中还能得到启示,汉诺塔可借 “整体代换” 拆解多步骤依赖任务,杨辉三角可用缓存(如二维数组)将时间复杂度从 O (n·2ⁿ) 降至 O (n²) 实现 “空间换时间”,正如笛卡尔所说:“把每个复杂的问题分解成尽可能简单的部分,然后逐个解决,再将结果组合起来,就能攻克最困难的挑战。”