递归与动态规划:从二叉树遍历到爬楼梯问题

128 阅读5分钟

一、递归思想介绍

1. 递归的本质

递归是一种自我调用的编程技巧,其核心是将复杂问题拆解为更小的相同问题,通过解决子问题最终得到原问题的解。递归的执行过程可以类比为“剥洋葱”——层层深入,再逐层返回。


2. 递归的核心逻辑

(1) 问题分解
  • 目标:将大问题拆分成多个结构相同的子问题

  • 示例

    • 爬楼梯问题:爬到第 n 阶楼梯的方式数 = 爬到第 n-1 阶的方式数(最后一步爬1阶) + 爬到第 n-2 阶的方式数(最后一步爬2阶)。
    • 二叉树前序遍历:处理当前节点后,分别递归处理左子树和右子树。
(2) 状态回溯
  • 递归调用栈:每次递归调用都会将当前状态(如参数、局部变量)压入调用栈,当子问题解决后,从栈顶回溯到上一层状态,继续执行后续逻辑。

  • 示例

    • 在爬楼梯问题中,计算 climb(5) 时,会依次调用 climb(4) 和 climb(3),直到到达终止条件 n <= 2,然后逐步返回结果并累加。

二、案例分析:前序遍历与爬楼梯问题


案例一:二叉树的前序遍历

问题描述 给定一棵二叉树,要求按前序遍历的顺序输出所有节点的值。
前序遍历定义:根节点 → 左子树 → 右子树。

1. 解题思路与原理

递归解法的核心思想
  • 问题分解:将大问题拆解为处理当前节点、左子树、右子树三个子问题。
  • 终止条件:当当前节点为空时,直接返回(无需处理)。
  • 递归关系
    • 先处理当前节点(加入结果数组)。
    • 再递归处理左子树。
    • 最后递归处理右子树。

以如下二叉树为例:

image.png

前序遍历顺序:A → B → D → E → C → F

执行流程

  1. 访问根节点 A → 加入结果

  2. 递归处理左子树 B: a. 访问 B → 加入结果 b. 递归处理 B 的左子树 D → 加入结果 c. 递归处理 B 的右子树 E → 加入结果

  3. 递归处理右子树 C: a. 访问 C → 加入结果 b. 递归处理 C 的右子树 F → 加入结果


2. 代码实现

function preorderTraversal(root) {
    const result = []; // 存储遍历结果
    
    function dfs(node) {
        if (!node) return; // 终止条件:空节点无需处理
        result.push(node.val); // 根
        dfs(node.left); // 左
        dfs(node.right); // 右
    }
    
    dfs(root); // 从根节点开始递归
    return result;
}

3. 代码分析

(1) 函数定义

  • preorderTraversal(root):主函数,接收二叉树的根节点 root,返回前序遍历结果。
  • dfs(node):递归函数,处理以 node 为根的子树。

(2) 终止条件

  • if (!node) return;:当当前节点为空时,直接返回,防止无限递归。

(3) 递归关系

  • 根节点处理result.push(node.val) 将当前节点的值加入结果数组。
  • 左子树递归dfs(node.left) 处理左子树。
  • 右子树递归dfs(node.right) 处理右子树。

(4) 执行流程

  1. 初始调用 dfs(root)
  2. 处理根节点 A,加入结果数组。
  3. 递归调用 dfs(B),处理左子树。
  4. dfs(B) 中,处理 B,加入结果,递归调用 dfs(D)
  5. dfs(D) 中,处理 D,加入结果,左右子树为空,返回。
  6. 返回到 dfs(B),继续递归调用 dfs(E),处理 E,加入结果。
  7. 返回到 dfs(A),递归调用 dfs(C),处理 C,加入结果,递归调用 dfs(F)
  8. 最终返回结果数组 [A, B, D, E, C, F]

(5) 时间与空间复杂度

  • 时间复杂度:O(n),每个节点访问一次。
  • 空间复杂度:O(h),h 为树的高度(递归调用栈的空间)。

案例二:爬楼梯问题

问题描述

假设你正在爬楼梯,每次可以爬 1 阶或 2 阶。问:爬到第 n 阶楼梯有多少种不同的方法?

1. 解题思路与原理

递归解法的核心思想
  • 问题分解:到达第 n 阶的方法数 = 到达第 n-1 阶的方法数(最后一步爬 1 阶) + 到达第 n-2 阶的方法数(最后一步爬 2 阶)。

  • 终止条件:当 n <= 2 时,直接返回 n(因为 n=1 有 1 种方法,n=2 有 2 种方法)。

  • 递归关系climbStairs(n) = climbStairs(n-1) + climbStairs(n-2)

n=4 为例:

image.png

最终结果:3 + 2 = 5(方法数)。


2. 代码实现

function climbStairs(n) {
    if (n <= 2) return n; // 终止条件
    return climbStairs(n - 1) + climbStairs(n - 2); // 递归关系
}

3. 代码详细分析

(1) 函数定义

  • climbStairs(n):主函数,接收楼梯阶数 n,返回到达第 n 阶的方法数。

(2) 终止条件

  • if (n <= 2) return n;:当 n=1n=2 时,直接返回 n(基准条件)。

(3) 递归关系

  • climbStairs(n - 1):从第 n-1 阶爬 1 阶到达第 n 阶。
  • climbStairs(n - 2):从第 n-2 阶爬 2 阶到达第 n 阶。

(4) 执行流程

  1. 调用 climbStairs(4)
  2. 分解为 climbStairs(3) + climbStairs(2)
  3. climbStairs(3) 分解为 climbStairs(2) + climbStairs(1)
  4. climbStairs(2) 返回 2,climbStairs(1) 返回 1。
  5. 最终结果:2 + 1 + 2 = 5

(5) 时间与空间复杂度

  • 时间复杂度:O(2^n),存在大量重复计算(如 climbStairs(3) 被多次调用)。
  • 空间复杂度:O(n),递归调用栈的最大深度为 n

三、递归与遍历的典型应用场景

1. 树结构问题

  • 二叉树遍历:前序、中序、后序(如序列化树、复制树)。
  • 树的构造:通过前序+中序或后序+中序还原树。
  • 路径问题:寻找路径和、最长路径。

2. 序列问题

  • 斐波那契数列:经典递归问题(如爬楼梯的简化版)。
  • 组合问题:生成所有组合(如全排列、子集)。
  • 分治算法:将问题拆解为独立子问题(如快速排序、归并排序)。

四、总结:递归思维的通用性与局限性

1. 递归的优势

  • 代码简洁:直接映射问题定义(如树遍历、斐波那契)。
  • 逻辑清晰:天然符合人类分步解决问题的思维方式。

2. 递归的局限性

  • 效率问题:未优化的递归可能产生大量重复计算(如 climb(5) 调用 climb(3) 两次)。
  • 栈溢出风险:深度过大的递归可能导致调用栈溢出(如链状树)。

3. 递归 vs 动态规划的选择

  • 选择递归:问题规模较小、子问题无重叠(如二叉树遍历)。
  • 选择动态规划:存在大量重叠子问题(如爬楼梯、背包问题)。