左哥算法 - 递归算法

301 阅读15分钟

递归基本知识

1. 递归的本质

递归就像是一个套娃过程:

  • 大问题包含小问题
  • 小问题还可以继续拆分
  • 直到到达最基本的情况

2. 内存工作原理

用经典的阶乘计算来说明:

fun factorial(n: Int): Int {
    // 基本情况(递归出口)
    if (n == 1) {
        return 1
    }
    // 递归调用
    return n * factorial(n - 1)
}

当调用 factorial(3) 时,内存栈的工作过程:

步骤1: 入栈 factorial(3)
    └── 需要等待 factorial(2) 的结果
        └── 需要等待 factorial(1) 的结果
            └── 返回 1
        └── factorial(2) 得到结果:2
    └── factorial(3) 得到结果:6

3. 内存栈示意图

│                │
│  factorial(1)  │ ← 栈顶
│  factorial(2)  │
│  factorial(3)  │ ← 栈底
└────────────────┘

4. 递归的三个关键要素

  1. 递归出口
if (n == 1) return 1  // 明确的终止条件
  1. 递归调用
factorial(n - 1)  // 调用自身,但问题规模变小
  1. 问题分解
n * factorial(n - 1)  // 把大问题分解为小问题

5. 完整流程图

graph TD
    A[开始] --> B{是否达到基本情况?}
    B -->|是| C[返回基本结果]
    B -->|否| D[分解问题]
    D --> E[递归调用]
    E --> F[等待子问题结果]
    F --> G[合并结果]
    G --> H[返回结果]
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style C fill:#bfb,stroke:#333,stroke-width:2px
    style D fill:#fbf,stroke:#333,stroke-width:2px
    style E fill:#fbb,stroke:#333,stroke-width:2px

6. 实际案例:二叉树遍历

class TreeNode(
    val value: Int,
    var left: TreeNode? = null,
    var right: TreeNode? = null
)

fun preorderTraversal(root: TreeNode?) {
    // 1. 递归出口
    if (root == null) return
    
    // 2. 处理当前节点
    println(root.value)
    
    // 3. 递归调用
    preorderTraversal(root.left)   // 处理左子树
    preorderTraversal(root.right)  // 处理右子树
}

7. 注意事项

好的,让我详细讲解递归算法的注意事项:

1. 栈溢出问题
// 🔴 错误示例:容易栈溢出
fun recursiveFunction(n: Int) {
    println(n)
    recursiveFunction(n + 1)  // 永远不会结束
}

// ✅ 正确示例:添加边界条件
fun recursiveFunction(n: Int) {
    if (n > 1000) return  // 添加边界条件
    println(n)
    recursiveFunction(n + 1)
}

栈空间分析:

每次递归调用占用栈空间:
└── 局部变量
└── 参数
└── 返回地址
└── 其他信息

示意图:
调用深度增加 ──→ 栈空间消耗增加 ──→ 栈溢出
│   递归调用N   │ 
│   递归调用3   │  ↑
│   递归调用2   │  │ 栈空间
│   递归调用1   │  │ 消耗
│   主函数      │  │
└──────────────┘
2. 重复计算问题

以斐波那契数列为例:

// 🔴 错误示例:存在大量重复计算
fun fib(n: Int): Int {
    if (n <= 1) return n
    return fib(n - 1) + fib(n - 2)
}

// ✅ 正确示例:使用备忘录模式
fun fib(n: Int): Int {
    val memo = mutableMapOf<Int, Int>()
    
    fun fibWithMemo(n: Int): Int {
        // 检查是否已经计算过
        if (memo.containsKey(n)) return memo[n]!!
        
        val result = when {
            n <= 1 -> n
            else -> fibWithMemo(n - 1) + fibWithMemo(n - 2)
        }
        
        // 存储计算结果
        memo[n] = result
        return result
    }
    
    return fibWithMemo(n)
}

重复计算分析图:

flowchart TD
    A(fib4) --> B(fib3)
    A --> C(fib2)
    B --> D(fib2)
    B --> E(fib1)
    C --> F(fib1)
    C --> G(fib0)
    D --> H(fib1)
    D --> I(fib0)
3. 递归深度控制
// 🔴 危险示例:递归深度不可控
fun processTree(node: TreeNode?) {
    if (node == null) return
    processTree(node.left)
    processTree(node.right)
}

// ✅ 安全示例:添加深度控制
fun processTree(node: TreeNode?, depth: Int = 0) {
    if (node == null || depth > MAX_DEPTH) return
    processTree(node.left, depth + 1)
    processTree(node.right, depth + 1)
}
4. 尾递归优化
// 🔴 普通递归:每次调用都需要保存状态
fun factorial(n: Int): Int {
    if (n <= 1) return 1
    return n * factorial(n - 1)  // 需要等待子调用返回后再计算
}

// ✅ 尾递归优化:直接返回结果
tailrec fun factorial(n: Int, accumulator: Int = 1): Int {
    if (n <= 1) return accumulator
    return factorial(n - 1, n * accumulator)  // 直接返回结果
}

内存对比:

普通递归的栈:          尾递归优化后:
│  factorial(1)  │    │  factorial(1, 6)  │
│  factorial(2)  │    └──────────────────┘
│  factorial(3)  │    
└────────────────┘    
5. 实践建议总结
  1. 设置合理的终止条件
fun recursiveFunction(params: Type) {
    // 1. 检查边界条件
    if (终止条件) return
    
    // 2. 检查递归深度
    if (depth > MAX_DEPTH) return
    
    // 3. 检查参数合法性
    require(params.isValid) { "Invalid parameters" }
    
    // 递归调用
    recursiveFunction(新参数)
}
  1. 使用缓存优化
class RecursiveCalculator {
    private val cache = mutableMapOf<Key, Result>()
    
    fun calculate(params: Params): Result {
        // 检查缓存
        cache[params]?.let { return it }
        
        // 计算结果
        val result = // ... 递归计算
        
        // 存入缓存
        cache[params] = result
        return result
    }
}
  1. 考虑迭代替代
// 递归版本
fun factorial(n: Int): Int {
    return if (n <= 1) 1 else n * factorial(n - 1)
}

// 迭代版本
fun factorial(n: Int): Int {
    var result = 1
    for (i in 1..n) {
        result *= i
    }
    return result
}
  1. 资源管理
class RecursiveProcessor {
    private var recursionDepth = 0
    private val maxDepth = 1000
    
    fun process(data: Data) {
        try {
            recursionDepth++
            if (recursionDepth > maxDepth) {
                throw StackOverflowError("Max recursion depth exceeded")
            }
            // 递归处理
        } finally {
            recursionDepth--
        }
    }
}

通过以上这些注意事项和优化方案,可以让递归算法更加安全和高效。关键是要注意:

  • 控制递归深度
  • 避免重复计算
  • 合理使用内存
  • 考虑性能优化
  • 做好异常处理

8. 递归的优缺点

优点:

  • 代码简洁清晰
  • 问题解决思路自然

缺点:

  • 空间复杂度高
  • 可能栈溢出
  • 重复计算多

9. 内存分析图

栈内存使用示意:

调用前:
┌──────────┐
│   主函数   │
└──────────┘

第一次递归:
┌──────────┐
│  递归调用1 │
├──────────┤
│   主函数   │
└──────────┘

第二次递归:
┌──────────┐
│  递归调用2 │
├──────────┤
│  递归调用1 │
├──────────┤
│   主函数   │
└──────────┘

10. 实践建议

  1. 确保有明确的递归出口
  2. 每次递归都要向出口靠近
  3. 考虑使用尾递归优化
  4. 警惕重复计算
  5. 必要时使用备忘录模式

通过以上解释和图示,我们可以看到递归算法的工作原理就像是一个自动化的问题分解和结果合并的过程,关键在于正确设置递归出口和确保问题规模在逐步减小。

递归的应用注意事项

一、什么是递归?

递归就是函数自己调用自己,通过把大问题分解成相同的小问题来解决。

二、适合使用递归的问题

  1. 具有递归特征的数据结构
// 二叉树遍历
fun traverseTree(node: TreeNode?) {
    if (node == null) return
    
    traverseTree(node.left)   // 遍历左子树
    println(node.value)       // 处理当前节点
    traverseTree(node.right)  // 遍历右子树
}
  1. 问题可以分解为相似的子问题
// 斐波那契数列
fun fibonacci(n: Int): Int {
    if (n <= 1) return n
    return fibonacci(n - 1) + fibonacci(n - 2)
}
  1. 需要回溯的问题
// 迷宫寻路
fun findPath(maze: Array<Array<Int>>, x: Int, y: Int): Boolean {
    if (x < 0 || y < 0 || x >= maze.size || y >= maze[0].size) return false
    if (maze[x][y] == 1) return false  // 墙
    if (maze[x][y] == 2) return true   // 终点
    
    maze[x][y] = 1  // 标记已访问
    
    // 四个方向尝试
    return findPath(maze, x+1, y) || 
           findPath(maze, x-1, y) || 
           findPath(maze, x, y+1) || 
           findPath(maze, x, y-1)
}

三、不适合使用递归的问题

  1. 重复计算多的问题
// 不好的示例:普通递归计算斐波那契
fun badFibonacci(n: Int): Int {
    if (n <= 1) return n
    return badFibonacci(n - 1) + badFibonacci(n - 2)  // 大量重复计算
}

// 更好的方式:使用动态规划
fun betterFibonacci(n: Int): Int {
    val dp = IntArray(n + 1)
    dp[0] = 0
    dp[1] = 1
    for (i in 2..n) {
        dp[i] = dp[i-1] + dp[i-2]
    }
    return dp[n]
}
  1. 调用层次很深的问题
// 可能导致栈溢出的递归
fun deepRecursion(n: Int) {
    if (n == 0) return
    deepRecursion(n - 1)  // 当 n 很大时会栈溢出
}

// 更好的方式:使用循环
fun betterIteration(n: Int) {
    var current = n
    while (current > 0) {
        current--
    }
}

四、递归的流程图

flowchart TD
    A[开始] --> B{是否满足基本情况?}
    B -->|是| C[返回基本结果]
    B -->|否| D[将问题分解为子问题]
    D --> E[递归调用解决子问题]
    E --> F[合并子问题的解]
    F --> G[返回结果]
    C --> H[结束]
    G --> H

五、编写递归的要点

  1. 确定基本情况(递归出口)
if (n <= 1) return n  // 基本情况
  1. 确定递归关系
return recursiveCall(n-1) + recursiveCall(n-2)  // 递归关系
  1. 避免重复计算
// 使用记忆化
val memo = mutableMapOf<Int, Int>()
fun memoizedFib(n: Int): Int {
    if (n <= 1) return n
    if (memo.containsKey(n)) return memo[n]!!
    
    memo[n] = memoizedFib(n-1) + memoizedFib(n-2)
    return memo[n]!!
}

六、递归优化建议

  1. 使用尾递归
// 普通递归
fun factorial(n: Int): Int {
    if (n <= 1) return 1
    return n * factorial(n - 1)
}

// 尾递归优化
tailrec fun factorialTail(n: Int, acc: Int = 1): Int {
    if (n <= 1) return acc
    return factorialTail(n - 1, n * acc)
}
  1. 考虑使用循环替代
// 递归版本
fun sum(n: Int): Int {
    if (n <= 0) return 0
    return n + sum(n - 1)
}

// 循环版本
fun sumIterative(n: Int): Int {
    var result = 0
    for (i in 1..n) {
        result += i
    }
    return result
}

七、总结

适合递归的问题特征:

  1. 问题可以分解为相似的子问题
  2. 具有明确的基本情况
  3. 子问题之间相互独立
  4. 问题的解可以由子问题的解组合得到

不适合递归的问题特征:

  1. 调用层次过深
  2. 存在大量重复计算
  3. 对时间和空间效率要求很高
  4. 问题可以简单地用循环解决

通过合理使用递归,可以使代码更清晰、优雅,但也要注意其局限性,在适当的场景选择合适的解决方案。

递归的典型题目

(1)汉诺塔问题

汉诺塔从最左边移动到最右边的全部过程 让我为您详细讲解汉诺塔问题:

1. 问题描述

  • 有三根柱子:左(A)、中(B)、右(C)
  • n 个盘子从小到大叠在左边柱子上
  • 目标是将所有盘子移动到右边柱子
  • 每次只能移动一个盘子
  • 大盘子不能放在小盘子上面

2. 解决思路

假设有 n 个盘子,我们可以将问题分解为三个步骤:

  1. 将 n-1 个盘子从 A 移动到 B(借助 C)
  2. 将最大的盘子从 A 移动到 C
  3. 将 n-1 个盘子从 B 移动到 C(借助 A)

3. 代码实现

fun hanoi(n: Int, from: Char, auxiliary: Char, to: Char) {
    if (n == 1) {
        println("移动盘子 1 从 $from$to")
        return
    }
    
    // 步骤1:将n-1个盘子从源柱移动到辅助柱
    hanoi(n - 1, from, to, auxiliary)
    
    // 步骤2:将最大的盘子从源柱移动到目标柱
    println("移动盘子 $n$from$to")
    
    // 步骤3:将n-1个盘子从辅助柱移动到目标柱
    hanoi(n - 1, auxiliary, from, to)
}

// 使用示例
fun main() {
    hanoi(3, 'A', 'B', 'C')
}

4. 执行过程(以3个盘子为例)

初始状态:
A: [3,2,1]
B: []
C: []

步骤1: 1 从 A 到 C
A: [3,2]
B: []
C: [1]

步骤2: 2 从 A 到 B
A: [3]
B: [2]
C: [1]

步骤3: 1 从 C 到 B
A: [3]
B: [2,1]
C: []

步骤4: 3 从 A 到 C
A: []
B: [2,1]
C: [3]

步骤5: 1 从 B 到 A
A: [1]
B: [2]
C: [3]

步骤6: 2 从 B 到 C
A: [1]
B: []
C: [3,2]

步骤7: 1 从 A 到 C
A: []
B: []
C: [3,2,1]

5. 流程图

flowchart TD
    A[开始] --> B{n == 1?}
    B -- 是 --> C[直接移动盘子]
    B -- 否 --> D[将n-1个盘子从源柱移到辅助柱]
    D --> E[将第n个盘子从源柱移到目标柱]
    E --> F[将n-1个盘子从辅助柱移到目标柱]
    C --> G[结束]
    F --> G

6. 关键点总结

  1. 递归基础:当 n=1 时,直接移动盘子
  2. 问题分解:将大问题分解为相同的小问题
  3. 递归过程
    • 移动上面 n-1 个盘子
    • 移动最底下的盘子
    • 移动刚才的 n-1 个盘子
  4. 时间复杂度:O(2^n - 1),每增加一个盘子,步骤数翻倍

7. 理解要点

  • 每次递归都是在解决规模更小的相同问题
  • 关键是理解"辅助柱"的作用
  • 整个过程可以看作是大盘子的移动和小盘子的重新排列
  • 递归的层数等于盘子的数量

这个问题是理解递归的经典案例,通过将复杂问题分解为相同的小问题来解决。每一步都遵循相同的规则,只是规模在不断减小,直到达到最基本的情况(n=1)。

(2)打印字符串的全部子序列

让我为您详细讲解打印字符串全部子序列的递归算法原理。

1. 基本概念

子序列:字符串中保持相对顺序的字符组合(可以不连续) 例如:"abc" 的子序列包括:["", "a", "b", "c", "ab", "ac", "bc", "abc"]

2. 代码实现

fun printAllSubsequences(str: String) {
    process(str.toCharArray(), 0, "")
}

private fun process(chars: CharArray, index: Int, path: String) {
    // 基础情况:到达字符串末尾,打印当前路径
    if (index == chars.size) {
        println(path)
        return
    }
    
    // 不要当前字符,直接跳到下一个
    process(chars, index + 1, path)
    
    // 要当前字符,将其加入路径
    process(chars, index + 1, path + chars[index])
}
为什要从index +1 开始

让我详细解释为什么要从 index + 1 开始:

1. 核心原理

index 表示当前正在处理的字符位置。每次递归都需要向后移动处理下一个字符,所以用 index + 1。

2. 具体分析

以字符串 "abc" 为例:

当 index = 0 时:
- 处理字符 'a'
- 两个选择:要'a'或不要'a'
- 无论哪种选择,下一步都要处理 index = 1 的字符'b'

当 index = 1 时:
- 处理字符 'b'
- 两个选择:要'b'或不要'b'
- 下一步处理 index = 2 的字符'c'

当 index = 2 时:
- 处理字符 'c'
- 两个选择:要'c'或不要'c'
- 下一步 index = 3,等于字符串长度,递归终止
3. 执行流程示例
处理 "abc":

index = 0 ('a')
├── 不要'a': path=""    → index+1
│   ├── 不要'b': path=""    → index+1
│   │   ├── 不要'c': path="" → 输出
│   │   └── 要'c':  path="c" → 输出
│   └── 要'b':  path="b"   → index+1
│       ├── 不要'c': path="b" → 输出
│       └── 要'c':  path="bc" → 输出
└── 要'a':  path="a"   → index+1
    ├── 不要'b': path="a"   → index+1
    │   ├── 不要'c': path="a" → 输出
    │   └── 要'c':  path="ac" → 输出
    └── 要'b':  path="ab"  → index+1
        ├── 不要'c': path="ab" → 输出
        └── 要'c':  path="abc" → 输出
4. 如果不用 index + 1 会怎样?

如果使用 index 而不是 index + 1:

// 错误示例
process(chars, index, path)  // 而不是 index + 1

会导致:

  1. 无限递归 - 因为永远在处理同一个位置的字符
  2. 最终栈溢出 - StackOverflowError
5. index + 1 的重要性
  1. 保证前进性

    • 每次递归都向前推进一个字符
    • 确保能够遍历完整个字符串
  2. 避免重复处理

    • 每个字符只被处理一次
    • 保证算法的正确性
  3. 确保终止条件

    • index 最终会等于字符串长度
    • 递归能够正常结束
6. 代码执行示意
printAllSubsequences("ab")

// 执行过程:
index = 0 ('a')
  ↓
index = 1 ('b')  // 通过 index + 1 前进
  ↓
index = 2 (结束)  // 通过 index + 1 达到终止条件

而不是:
index = 0 ('a')
  ↓
index = 0 ('a')  // 如果不用 index + 1 会卡在这里
  ↓
index = 0 ('a')  // 无限循环

所以,index + 1 是这个递归算法能够正确工作的关键要素,它确保了:

  1. 递归的前进性
  2. 避免重复处理
  3. 能够正常终止
  4. 生成所有可能的子序列

3. 执行流程图

graph TD
    A[开始 str='abc'] --> B[index=0 path='']
    B -->|不要'a'| C[index=1 path='']
    B -->|要'a'| D[index=1 path='a']
    
    C -->|不要'b'| E[index=2 path='']
    C -->|要'b'| F[index=2 path='b']
    D -->|不要'b'| G[index=2 path='a']
    D -->|要'b'| H[index=2 path='ab']
    
    E -->|不要'c'| I[输出:'']
    E -->|要'c'| J[输出:'c']
    F -->|不要'c'| K[输出:'b']
    F -->|要'c'| L[输出:'bc']
    G -->|不要'c'| M[输出:'a']
    G -->|要'c'| N[输出:'ac']
    H -->|不要'c'| O[输出:'ab']
    H -->|要'c'| P[输出:'abc']

4. 详细执行步骤

以字符串 "abc" 为例:

  1. 第一层递归(处理'a')

    • 路径1:不要'a',path=""
    • 路径2:要'a',path="a"
  2. 第二层递归(处理'b')

    • 从路径1延伸:
      • 不要'b',path=""
      • 要'b',path="b"
    • 从路径2延伸:
      • 不要'b',path="a"
      • 要'b',path="ab"
  3. 第三层递归(处理'c')

    • 每个路径都面临选择要或不要'c'
    • 最终生成所有可能的组合

5. 核心原理总结

  1. 决策树原理
  • 每个字符都面临"要"或"不要"两个选择
  • 形成一个二叉决策树
  • 树的深度等于字符串长度
  1. 递归要素
  • 基础情况:index 到达字符串末尾
  • 递归参数:当前处理的字符位置(index)和已构建的路径(path)
  • 递归过程:对每个字符做出两种选择
  1. 时间复杂度
  • O(2^n),n为字符串长度
  • 每个字符有两种选择,总共有n个字符

6. 关键点解析

  1. 路径保存
// path 参数记录当前构建的子序列
path + chars[index]  // 选择当前字符时,将其加入路径
path                 // 不选择当前字符时,保持路径不变
  1. 递归终止
if (index == chars.size) {
    println(path)  // 到达字符串末尾时打印结果
    return
}
  1. 分支处理
// 两个递归分支代表两种选择
process(chars, index + 1, path)         // 不要当前字符
process(chars, index + 1, path + chars[index])  // 要当前字符

7. 优化建议

  1. 使用StringBuilder优化字符串拼接
fun process(chars: CharArray, index: Int, sb: StringBuilder) {
    if (index == chars.size) {
        println(sb.toString())
        return
    }
    
    process(chars, index + 1, sb)  // 不要当前字符
    
    sb.append(chars[index])  // 要当前字符
    process(chars, index + 1, sb)
    sb.deleteCharAt(sb.length - 1)  // 恢复现场
}
  1. 使用Set去重(如果需要)
val result = mutableSetOf<String>()

fun process(chars: CharArray, index: Int, path: String) {
    if (index == chars.size) {
        result.add(path)
        return
    }
    // ... 递归逻辑
}

这个算法的精髓在于通过递归构建决策树,每个节点代表一个选择(要或不要当前字符),最终遍历所有可能的组合。理解了这个核心原理,就能更好地应用到类似的问题中。

(3)给你一个栈 请逆序这个栈

让我用通俗易懂的方式解释栈的递归逆序算法。

1. 基本思路

要逆序一个栈,我们需要:

  1. 先把栈底元素取出来
  2. 对剩余的栈进行逆序
  3. 再把之前取出的栈底元素放到栈顶
2. 代码实现
// 主函数:逆序栈
fun reverse(stack: Stack<Int>) {
    if (stack.isEmpty()) return
    
    // 1. 取出栈底元素
    val bottom = getBottomElement(stack)
    // 2. 递归处理剩余元素
    reverse(stack)
    // 3. 将栈底元素压入栈顶
    stack.push(bottom)
}

// 辅助函数:获取并移除栈底元素
fun getBottomElement(stack: Stack<Int>): Int {
    // 1. 弹出当前元素
    val item = stack.pop()
    
    if (stack.isEmpty()) {
        // 2.1 如果栈空了,说明item就是栈底元素
        return item
    } else {
        // 2.2 继续递归获取栈底元素
        val bottom = getBottomElement(stack)
        // 2.3 将当前元素压回栈中
        stack.push(item)
        // 2.4 返回栈底元素
        return bottom
    }
}
3. 执行流程图
graph TD
    A[开始] --> B{栈是否为空?}
    B -- 是 --> C[返回]
    B -- 否 --> D[获取栈底元素]
    D --> E[递归逆序剩余栈]
    E --> F[将栈底元素压入栈顶]
    F --> C

    subgraph 获取栈底元素流程
    G[弹出当前元素] --> H{栈是否为空?}
    H -- 是 --> I[返回当前元素]
    H -- 否 --> J[递归获取栈底]
    J --> K[压回当前元素]
    K --> L[返回栈底元素]
    end
4. 具体示例

假设初始栈为:[1, 2, 3](3在栈顶)

  1. 第一次调用reverse:
初始状态:[1, 2, 3]
获取栈底元素1: [2, 3]
递归处理[2, 3]
  1. 第二次调用reverse:
当前状态:[2, 3]
获取栈底元素2: [3]
递归处理[3]
  1. 第三次调用reverse:
当前状态:[3]
获取栈底元素3: []
递归处理[]
返回并压入3: [3]
  1. 返回到第二次调用:
压入元素2: [3, 2]
  1. 返回到第一次调用:
压入元素1: [3, 2, 1]
5. 关键点总结
  1. 递归的核心思想

    • 将大问题分解为小问题
    • 利用栈的特性逐层处理
  2. 两个关键操作

    • 获取栈底元素
    • 逆序剩余元素
  3. 时间复杂度:O(n²)

    • 每次获取栈底元素需要O(n)
    • 需要对n个元素进行处理
  4. 空间复杂度:O(n)

    • 递归调用栈的深度
6. 注意事项
  1. 确保栈不为空再操作
  2. 递归过程中正确维护栈的状态
  3. 注意递归的终止条件
  4. 考虑大数据量时的性能问题

这个算法虽然不是最优的(可以用O(n)的额外空间实现O(n)的时间复杂度),但是它很好地展示了递归的思想和栈的操作原理。