Skip to content

元数据卡

  • 前置知识:方法(第5章)、调用栈(第5章)、循环(第4章)
  • 预计时间:45 分钟
  • 核心难度:
  • 阅读模式: 高度专注
  • 可选跳过:如果时间有限,可跳过"尾递归"和"互递归"部分;这是第5章的深入补充
  • 完成标志:能手写出至少两种递归模式(线性递归、树递归),理解递归与循环的差异,知道尾递归的思路

本章分层

  • 必读:基准情况(base case)与递归推进(recursive case)、递归调用栈过程与空间开销、递归 vs 循环的适用场景
  • 选读:树递归的重复计算问题、分治思想的递归表达
  • 深水区:尾递归原理(虽然 Java 不做 TCO)、记忆化(memoization)

本章不会要求你掌握

  • 尾递归优化在底层 JVM 的实现细节(Java 不支持)
  • 互递归在工程中的实际应用(极少数解析器场景)

你在哪

上完方法课,你绕到铁匠铺后院透透气,发现老陈站在那棵歪脖子树下朝你招手。

铁匠铺的后院里,老陈师傅指着一棵歪脖子树。

"爬到树顶,把最上面的那个果子扔下来。"

你仰头一看——树枝一层层分叉,每一层的分叉又生出更多分叉。你找到主树干,爬上第一根树枝,然后是第二根,第三根……到了某一层,你发现前面的路分成了两条。你选了右边那根,爬到顶,拿到果子,扔下来。然后你得退回来,从左边那根再爬上去。

"不对,"老陈说,"你不是在爬树——你是在重复同一个动作:在每根树枝上,先找到所有子树枝,对每个子树枝做同样的事。"

这就是递归。上一章学会了方法调方法,但有一个局面你还没面对:方法能不能调用自己

你的任务

阶乘、斐波那契、二叉树、文件目录遍历、快速排序——这些问题的共同点是什么?

它们的结构是自相似的:一个大问题和它的小版本长得一模一样。学会递归就是学会识别这种自相似性,然后用最少的代码解决最复杂的问题。

这章会带你走过从"循环实现"到"递归实现"的思维转变。你会看到什么时候递归是绝杀,什么时候它是个陷阱。你还将看到编译器工程师们想了什么办法来弥补递归的性能代价——以及这个办法为什么在实践中很少用到。

遭遇战 → 获得技能

第一幕:从阶乘说起——递归的呼吸

阶乘(factorial):5! = 5 × 4 × 3 × 2 × 1 = 120

你先用循环写一遍——这是你已经会的:

java
public class Factorial {
    public static void main(String[] args) {
        System.out.println("5! = " + factorialLoop(5));
        System.out.println("10! = " + factorialLoop(10));
    }

    static long factorialLoop(int n) {
        long result = 1;
        for (int i = 1; i <= n; i++) {
            result *= i;
        }
        return result;
    }
}

语言:Java 21 如何运行javac Factorial.java && java Factorial预期输出

5! = 120
10! = 3628800

现在,停一下。观察阶乘的规律:

5! = 5 × 4 × 3 × 2 × 1
   = 5 × (4 × 3 × 2 × 1)
   = 5 × 4!

4! = 4 × (3 × 2 × 1) = 4 × 3!

发现了吗?阶乘的定义本身是递归的

n! = n × (n-1)!       (当 n > 1 时)
1! = 1                (基准情况)

数学定义长什么样,代码就长什么样:

java
static long factorialRecur(int n) {
    if (n <= 1) {
        return 1;        // 基准情况(base case):递归的终点
    }
    return n * factorialRecur(n - 1);  // 递归情况:n! = n × (n-1)!
}

完整代码:

java
public class Factorial {
    public static void main(String[] args) {
        System.out.println("循环: 5! = " + factorialLoop(5));
        System.out.println("递归: 5! = " + factorialRecur(5));
        System.out.println("递归: 10! = " + factorialRecur(10));
    }

    static long factorialLoop(int n) {
        long result = 1;
        for (int i = 1; i <= n; i++) {
            result *= i;
        }
        return result;
    }

    static long factorialRecur(int n) {
        if (n <= 1) return 1;
        return n * factorialRecur(n - 1);
    }
}

预期输出

循环: 5! = 120
递归: 5! = 120
递归: 10! = 3628800

递归版只有三行核心逻辑。没有循环变量,没有累加器,没有 i++递归是在表达"是什么",不是"怎么做"

运行 factorialRecur(4) 时调用栈是这样的:

main
  └─ factorialRecur(4)
        ├─ 4 >= 1, 需要 4 * factorialRecur(3)
        │    └─ factorialRecur(3)
        │          ├─ 3 >= 1, 需要 3 * factorialRecur(2)
        │          │    └─ factorialRecur(2)
        │          │          ├─ 2 >= 1, 需要 2 * factorialRecur(1)
        │          │          │    └─ factorialRecur(1)
        │          │          │          └─ return 1       ← 到底了!
        │          │          └─ return 2 * 1 = 2
        │          └─ return 3 * 2 = 6
        └─ return 4 * 6 = 24  ← 最终结果

两个关键部件

部件作用如果忘了写
基准情况(base case)告诉递归"停在这里"无限递归 → StackOverflowError
递归情况(recursive case)让问题变得更小,逼近基准不会发生——因为你没写它也会无限递归(等等,那确实是同一个问题)

🐍 Python 差异:

python
def factorial(n: int) -> int:
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(f"5! = {factorial(5)}")   # 输出: 5! = 120
print(f"1000! = {factorial(1000)}")  # 💥 RecursionError: maximum recursion depth exceeded

Python 的递归栈默认深度限制是 1000。超过就抛 RecursionError —— 这不是栈溢出(内存不够),是 Python 自己的安全限制。你可以用 sys.setrecursionlimit(10000) 调大,但不推荐。Java 默认可以到 1000-2000 层(取决于栈大小),但也能调。

第二幕:递归 vs 循环——你该用哪个?

这不是"谁更好"的问题。这是"哪个工具适合哪个场景"的问题。

能用循环解决的问题,优先用循环。

对比循环递归
代码行数多(需要管理循环变量)少(直接表达数学定义)
性能快(没有函数调用开销)慢(每次递归 = 一次方法调用)
内存O(1)O(n)(压 n 层栈帧)
可读性线性思维自相似思维
适用场景线性遍历、顺序操作树状结构、分治、数学定义

来一个直观对比:

java
public class Compare {
    public static void main(String[] args) {
        int n = 10_000_000;

        long start = System.nanoTime();
        long sumLoop = sumLoop(n);
        long end = System.nanoTime();
        System.out.println("循环: " + sumLoop + " (" + (end - start) / 1_000_000 + "ms)");

        // 递归就算了,1000万层栈帧,你的电脑会哭
        // long sumRecur = sumRecur(n);  // 💥 试试?
    }

    static long sumLoop(int n) {
        long sum = 0;
        for (int i = 1; i <= n; i++) sum += i;
        return sum;
    }

    static long sumRecur(int n) {
        if (n <= 1) return 1;
        return n + sumRecur(n - 1);  // 优雅但脆弱——栈空间有限
    }
}

结论:处理 1000 万个数,循环轻松跑完。递归——跑到第几千层就炸了。

但你不是总在做线性遍历。有些问题,自然的表达方式就是递归

遍历一个文件夹(树状结构):

java
import java.io.File;

public class ListFiles {
    public static void main(String[] args) {
        listAllFiles(new File("."), 0);
    }

    static void listAllFiles(File dir, int depth) {
        File[] files = dir.listFiles();
        if (files == null) return;

        for (File file : files) {
            // 打印缩进
            for (int i = 0; i < depth; i++) System.out.print("  ");
            System.out.println(file.getName());

            // 如果是目录,递归进去
            if (file.isDirectory()) {
                listAllFiles(file, depth + 1);
            }
        }
    }
}

语言:Java 21 如何运行:在当前项目目录下运行 javac ListFiles.java && java ListFiles预期输出(你的项目目录):

.
  src
    main
      java
        ...

如果你用循环写目录遍历,你需要自己维护一个栈(或队列)去模拟递归。可以做到,但代码量会翻倍,而且读代码的人需要想一会儿才能看懂"哦这是在做深度优先遍历"。而递归版本,读的人一眼就知道:"它在递归地遍历目录。"

另一个经典例子:解析数学表达式。 (3 + 5) × (2 + 8 / 4)——你可以用栈做,但递归写一个表达式求值器,代码结构和它的语法树结构是天然对应的。

何时用递归:当你的数据本身就是递归结构(树、图、嵌套列表、XML/JSON)。 何时用循环:当你的数据是线性序列(数组、链表遍历)。

‍💡 老陈的折中:有些时候,循环 + 自己的显式栈是最佳方案——你有递归的表达力,又有循环的性能控制。那个显式栈其实就是在模仿 JVM 的调用栈,只不过你自己的栈在堆内存里,更可控。

第三幕:树递归与重复计算的代价

💡 前置预告:树递归和记忆化(memoization)是数据结构与算法(DSA)卷和动态规划(DP)卷的重要前置。 本章只做概念引入——在 Vol 2 中你会系统学习如何分析和优化它们。

斐波那契数列:

F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2)     (n ≥ 2)

直接翻译成递归:

java
public class Fib {
    public static void main(String[] args) {
        System.out.println("F(10) = " + fib(10));
        System.out.println("F(30) = " + fib(30));
        System.out.println("F(45) = " + fib(45));  // 等着,会很久
    }

    static long fib(int n) {
        if (n <= 1) return n;
        return fib(n - 1) + fib(n - 2);
    }
}

预期输出

F(10) = 55
F(30) = 832040
F(45) = 1134903170   // 这里开始卡了,可能要几十秒

F(45) 时发生了什么?

                        fib(5)
                       /       \
                  fib(4)        fib(3)
                 /      \       /     \
            fib(3)     fib(2) fib(2)  fib(1)
           /     \     /   \
       fib(2)  fib(1) ...
       /    \
    fib(1) fib(0)

fib(3) 被算了两次。fib(2) 被算了三次。 整个计算树里,同一棵子树被反复重新计算。

复杂度是多少?O(2ⁿ)——指数级。n=45 时大约 18 亿次递归。你电脑不卡才怪。

循环版本

java
static long fibLoop(int n) {
    if (n <= 1) return n;
    long a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
        long next = a + b;
        a = b;
        b = next;
    }
    return b;
}

复杂度:O(n)n=100 瞬间出结果。

这就是递归的经典陷阱:树递归的重复计算。你优雅的三行代码,隐藏着指数级的时间成本。

解决方案之一:记忆化(memoization)——把算过的结果存下来,下次直接拿:

java
import java.util.HashMap;
import java.util.Map;

public class FibMemo {
    static Map<Integer, Long> cache = new HashMap<>();

    static long fib(int n) {
        if (n <= 1) return n;
        if (cache.containsKey(n)) return cache.get(n);  // 算过了就直接拿

        long result = fib(n - 1) + fib(n - 2);
        cache.put(n, result);  // 存起来
        return result;
    }

    public static void main(String[] args) {
        System.out.println("F(100) = " + fib(100));  // 瞬间
    }
}

预期输出

F(100) = 354224848179261915075   // 对不起 Java 的 long 溢出

哦,long 不够装 F(100)。换成 BigInteger 就行了。但这不是重点——重点是有了记忆化,树递归从 O(2ⁿ) 变成了 O(n)

🐍 Python 差异:

Python 有内置的 functools.lru_cache 装饰器,一行实现记忆化:

python
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

Java 没有这种内置的装饰器模式,你得手动写 HashMap 缓存——但这对你来说是好事,因为你能看到缓存到底做了什么。

第四幕:互递归——两个人互相推锅

📎 附录性质:互递归在工程中很少出现,属于概念了解即可的附录内容。

递归不只是"方法调自己"。也可以是"方法A调方法B,方法B又调方法A"。

经典的例子:判断一个整数是奇数还是偶数,不用 % 操作符。

java
public class MutualRecur {
    public static void main(String[] args) {
        System.out.println("7 是奇数?" + isOdd(7));
        System.out.println("7 是偶数?" + isEven(7));
        System.out.println("42 是偶数?" + isEven(42));
    }

    static boolean isEven(int n) {
        if (n == 0) return true;
        return isOdd(n - 1);
    }

    static boolean isOdd(int n) {
        if (n == 0) return false;
        return isEven(n - 1);
    }
}

预期输出

7 是奇数?true
7 是偶数?false
42 是偶数?true

调用过程(isEven(3)):

isEven(3) → isOdd(2) → isEven(1) → isOdd(0) → return false
                                            ← false
                                      ← false
                               ← false
                          ← false

互递归在现实中不多见,但它展示了递归的核心思想在不同姿态下的应用:

  • 两个方法互相依赖——每个都把问题变小一点
  • 总有一个基准情况来终止

你以后可能会在状态机(特别是解析器中)遇到这种模式——状态A的处理函数调用状态B的处理函数,反之亦然。

📎 附录性质:尾递归优化是编译原理的概念。Java 不支持 TCO,此处只做知识拓展。

第五幕:尾递归——编译器帮我优化?

先看一个现象:

java
// 普通递归
static long sumRecur(int n) {
    if (n <= 1) return 1;
    return n + sumRecur(n - 1);  // 返回后还要做加法
}

执行 sumRecur(4)

sumRecur(4)    需要 4 + sumRecur(3) → 压栈等结果
  sumRecur(3)  需要 3 + sumRecur(2) → 压栈等结果
    sumRecur(2)  需要 2 + sumRecur(1) → 压栈等结果
      sumRecur(1)  return 1           → 弹出
    return 2 + 1 = 3                   → 弹出
  return 3 + 3 = 6                     → 弹出
return 4 + 6 = 10                      → 弹出

每一层都压着一个栈帧,因为 n + sumRecur(n-1) 的结果不是立刻返回的——它还要和 n 做加法。这些栈帧不能释放。

现在看尾递归:

java
// 尾递归版本
static long sumTail(int n, long acc) {
    if (n <= 1) return acc + 1;
    return sumTail(n - 1, acc + n);  // 返回的就是递归调用的结果
}

执行 sumTail(4, 0)

sumTail(4, 0)   → 需要 sumTail(3, 0+4=4)
  sumTail(3, 4) → 需要 sumTail(2, 4+3=7)
    sumTail(2, 7) → 需要 sumTail(1, 7+2=9)
      sumTail(1, 9) → return 9+1=10
    return 10
  return 10
return 10

不一样吗?还是压了四层栈啊?

区别在于语义sumTailreturn 语句中,递归调用是最后一个动作,而且它的结果被直接返回。这意味着——在理论上——当前栈帧不需要保留了。等你从递归调用返回后,只是把这个返回值再往上传。编译器可以优化它:直接把当前栈帧替换成下一层栈帧,而不是往上压新的一层。

如果优化了,执行变成了:

sumTail(4, 0)  → 栈帧变成 sumTail(3, 4)
               → 栈帧变成 sumTail(2, 7)
               → 栈帧变成 sumTail(1, 9)
               → 返回 10

一个栈帧用到结束。 时间复杂度不变,但空间复杂度从 O(n) 降到了 O(1)。这就是尾递归优化(Tail Call Optimization, TCO)。

它不仅能用在递归上。任何"最后一步是函数调用并直接返回"的场景,理论上都可以做尾调用优化。

现实情况:

Java:不支持 TCO。 原因复杂——主要是 Java 的安全模型(SecurityManager 需要在调用栈上记录谁调了谁)和中间文件(字节码)层面的限制。虽然 JVM 某些实现可以优化"同方法"的尾递归(叫尾递归消除),但 JLS 不要求,Oracle JDK 默认也不做。

Python:不支持 TCO。 Guido van Rossum 明确拒绝了 TCO——他认为"丢掉栈跟踪信息"会让 Python 更难调试,而且会"让你的代码依赖于一个编译器实现细节"。

C++:支持(编译器实现细节)。 现代 GCC/Clang 在 -O2-O3 优化级别下会自动做尾调用优化。你可以写 return sumTail(n-1, acc+n);,D 会变成循环。

函数式语言(Scheme, Haskell, Scala): TCO 是语言的必备功能。函数式编程没有循环,递归是唯一的"重复"手段——递归的效率不能是灾难级的。

所以:了解尾递归的思想,写 Java 和 Python 时基本用不上。但如果你以后去写 Scala、Kotlin 或函数式语言,它会成为一个重要的武器。

🏔 深入冒险:分治思想的递归表达

第六幕:分而治之(Divide and Conquer)

递归不只是用来替代循环的。它是一个更强大的思想工具——分治

分治三步走:

  1. 分解:把大问题分成两个(或多个)规模更小的子问题
  2. 解决:递归地解决每个子问题(基准情况直接解决)
  3. 合并:把子问题的解合并成大问题的解

经典的例子:数组求和——分治版

java
public class DivideSum {
    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 5, 6, 7, 8};
        System.out.println("总和 = " + sum(arr, 0, arr.length - 1));
    }

    static int sum(int[] arr, int left, int right) {
        if (left == right) {
            return arr[left];  // 基准情况:只有一个元素
        }

        int mid = (left + right) / 2;
        int leftSum = sum(arr, left, mid);
        int rightSum = sum(arr, mid + 1, right);
        return leftSum + rightSum;
    }
}

预期输出

总和 = 36

你看到分治和线性递归的区别了吗?

                               sum(0,7)
                         /                \
                    sum(0,3)            sum(4,7)
                   /        \          /        \
              sum(0,1)   sum(2,3)  sum(4,5)   sum(6,7)
              /    \      /    \    /    \      /    \
           sum(0) sum(1) sum(2) sum(3) sum(4) sum(5) sum(6) sum(7)

计算次序不是 1+2+3+4…的顺序相加,而是先把数组砍成两半,每一半再砍成两半……砍到只剩一个元素,开始往上合并。

为什么分治有用?

  1. 复杂度分析更直接sum 的分治版本,每层处理两个子问题,每层工作 O(1)(加个法),深度是 log₂n → 总复杂度还是 O(n)。但注意——这里没有节省时间,它在展示模式。

  2. 很多高效算法天然是分治的

    • 归并排序:把数组砍成两半,分别排序,再合并
    • 快速排序:选一个基准,把数组分成"小于基准"和"大于基准"两部分,递归排序
    • 二分搜索:每次砍掉一半
    • 二叉树搜索:每次走左或走右砍掉一半
  3. 分治天生并发。到了第14章,你可以把 sum(arr, left, mid)sum(arr, mid+1, right) 放在两个线程上跑。分治的"子问题互不依赖"是它天然并行的根源。

** C++ 差异:**

cpp
`#include <iostream>`
`#include <vector>`
`#include <algorithm>`
`#include <numeric>`
`#include <execution>`  // C++17, 需要 TBB

int main() {
`std::vector<int> arr = {1,2,3,4,5,6,7,8};`
    // 顺序
    auto sum1 = std::reduce(arr.begin(), arr.end(), 0);
    // 并行
    auto sum2 = std::reduce(std::execution::par, arr.begin(), arr.end(), 0);

    std::cout << "顺序: " << sum1 << "\n";
    std::cout << "并行: " << sum2 << "\n";
}

C++17 的 std::reduce 默认使用分治思想,并且你可以在它的标准库实现里看到一个"砍两半递归"的模式。而且 C++ 有标准化的并行算法——这是 Java 标准库没有的(需要自己去线程池里搞)。

常见陷阱

陷阱一:递归写久了,你所有问题都想用递归

java
// ❌ 你的冲动
static String repeat(String s, int n) {
    if (n <= 0) return "";
    return s + repeat(s, n - 1);
}

// ✅ 老实写
static String repeat(String s, int n) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < n; i++) sb.append(s);
    return sb.toString();
}

重复拼接字符串这种线性任务,循环简洁高效。递归反而复杂化。

经验法则:如果你的问题是线性的(走一遍列表、重复 N 次),用循环。如果你的问题是树状的(目录、表达树、决策树),用递归。

陷阱二:忘记基准情况——死循环的远房亲戚

java
// ❌ 你写了这样的代码
static int divide(int a, int b) {
    return 1 + divide(a - b, b);  // 假设 a >= b
    // 基准情况在哪?没有人知道。
}

修好了是这样的:

java
static int divide(int a, int b) {
    if (a < b) return 0;      // 基准:减不动了
    return 1 + divide(a - b, b);
}

陷阱三:递归太深了

你写了一个漂亮的递归程序,在测试时一切正常。然后在生产数据上——StackOverflowError

不是因为你的代码有 bug。是栈空间不够了

常见的兜底方案:

  1. 转循环——最可靠。能用循环就用循环。
  2. 增大栈空间——java -Xss2m YourProgram,临时解决问题,但治标不治本。
  3. 手动栈——把递归换成循环 + 显式栈——保留递归的逻辑清晰性,但把栈移到堆内存里。
java
import java.util.*;

public class ManualStack {
    // 用显式栈模拟递归的目录遍历
    static void listAllFilesIterative(File root) {
        Stack<File> stack = new Stack<>();
        stack.push(root);

        while (!stack.isEmpty()) {
            File dir = stack.pop();
            File[] files = dir.listFiles();
            if (files == null) continue;

            for (File file : files) {
                System.out.println(file.getPath());
                if (file.isDirectory()) {
                    stack.push(file);  // 手动压栈,替换递归
                }
            }
        }
    }

    public static void main(String[] args) {
        listAllFilesIterative(new File("."));
    }
}

这不是递归,这是迭代。 但你看到了吗——逻辑结构和递归版本几乎一样,区别在于你在自己操控栈(在堆上),不受 JVM 调用栈大小的限制。

P.S. File 已过时,应使用 java.nio.file.FilesPath。但这章的例子里 File 直观易懂,先让它服役。

陷阱四:递归加缓存(记忆化),还是递归加循环?

斐波那契的记忆化版本已经很好了。但在实际工程里,你还需要更务实的选择:

  • 如果问题规模已知且不大:递归就递归,省事
  • 如果问题规模大:考虑循环或手动栈
  • 如果你需要所有中间结果(比如求所有前缀和):循环自不必说

通关挑战

  • 🗡 热身(5 分钟,必做)
  1. 用递归写一个方法 int pow(int base, int exp),计算 base^exp。比如 pow(2, 5) = 32
  2. 用递归写一个方法 int sumDigits(int n),计算各位数字之和。比如 sumDigits(123) = 1+2+3 = 6
  3. 用递归写 void countdown(int n),倒计时输出 n, n-1, ..., 1, 0, 发射!
  • 挑战(30 分钟,选做)
  1. 二分搜索:给定已排序数组 {1, 3, 5, 7, 9, 11, 13, 15},用递归实现二分查找,返回目标值的索引。如果没找到返回 -1

  2. 文件搜索器:写一个程序,递归遍历整个项目目录,找到所有文件名包含关键词的文件(比如包含 "recursion".java 文件)。打印出它们的完整路径。

  3. 斐波那契性能测试:写一个 Benchmark 程序,分别测试普通递归版、记忆化递归版、循环版计算 F(40) 的时间。

    • 输出格式:
F(40) = 102334155
普通递归: 1234ms
记忆化递归: 0ms
循环: 0ms
  • 观察

-Xss 参数调大调用栈,看看递归阶乘最多能算到 n 是多少。比如 java -Xss2m Factorial 100000

java
public class FactorialMax {
    public static void main(String[] args) {
        int n = Integer.parseInt(args[0]);
        try {
            System.out.println(n + "! 计算中...");
            long result = factorial(n);
            System.out.println("成功!结果位数: " + String.valueOf(result).length());
        } catch (StackOverflowError e) {
            System.out.println("💥 栈溢出!n = " + n + " 太大了");
        }
    }
    static long factorial(int n) {
        if (n <= 1) return 1;
        return n * factorial(n - 1);
    }
}
  • 排障
java
// 这段代码试图判断 "一个字符串是不是回文",但有问题
// 比如 isPalindrome("racecar") 应该返回 true
// 它现在运行时会在某些输入上崩溃。找到原因并修复。

public class Palindrome {
    public static boolean isPalindrome(String s) {
        if (s.length() <= 1) return true;
        if (s.charAt(0) != s.charAt(s.length() - 1)) return false;
        return isPalindrome(s.substring(0, s.length() - 1));  // bug 在这里
    }

    public static void main(String[] args) {
        System.out.println(isPalindrome("racecar"));  // 期望 true
        System.out.println(isPalindrome("hello"));    // 期望 false
        System.out.println(isPalindrome("a"));        // 期望 true
    }
}

提示:看 return 语句里的递归子调用——它减少了字符串吗?减少了哪一头?回文的定义是"从两边往里比较",你只砍了一头,会无限循环吗?

验收标准

  • 你能手写一个简单的递归函数(阶乘、求和、文件遍历)
  • 你能正确解释递归的"基准情况"和"递归情况"的关系
  • 你能画出 factorialRecur(4) 的调用栈变化过程
  • 你能区分"可以用递归"和"应该用递归"(线性 vs 树状)
  • 你知道什么情况下递归会栈溢出
  • 你知道尾递归的原理(虽然 Java 用不上)
  • 你能解释分治的三步:分解、解决、合并

常见卡点

"跑模拟递归太烧脑,跟踪不下去。" 找一支笔。画出调用栈的每一帧——把方法名、参数值、还没算完的表达式写下来。画着画着就通了。每次遇到递归卡住,就用"画栈法"。

"我写了一个递归,但不知道它为什么能工作。" 把递归想象成"我信任更小的版本能解决更小的问题"——只要你有基准情况,并且在每一步都让问题变得更小,它就会收敛到基准情况。这叫"递归信念"(recursive leap of faith)。先信了,写出来测试,再回头理解它为什么对。

"为什么递归比循环慢那么多?" 每次递归调用 = 创建栈帧 + 压栈 + 上下文切换 + 执行 + 弹栈。一个 add 方法调用在纳秒级别。循环的 j++ 在同一个栈帧内,没有这些开销。如果递归深度 10, 000 层,你就多承担了 10, 000 次函数调用的开销。

"记忆化是把双刃剑吗?" 是的。它用空间换时间——你的缓存可能会无限增长。如果输入空间是连续的(比如 n 从 1 到 10^6),缓存会变得很大。所以在实际工程中,如果 n 可以非常大,考虑循环或迭代 DP,而不只是递归 + 缓存。

现在不需要理解

  • 为什么 Java 不做尾递归优化(涉及安全模型和 JVM 规范的历史原因)
  • 归并排序和快速排序的具体实现(第2卷会深度讲解)
  • 递归在函数式编程中的核心地位(第9卷的语言深度会讲)
  • 循环展开和循环不变量的编译器优化
  • 阿克曼函数——纯粹的理论递归,工程价值低

旅人笔记

递归 = 方法调用自己。核心是两点:基准情况(停止)和递归情况(靠近基准)。递归天然适合树状结构,但线性问题优先用循环。尾递归=递归调用是最后一步(Java 不优化它,但知道它很重要)。分治=把大问题砍成小问题,合并小答案成大答案。记住:递归是思维工具,不只是编程技巧。

下一站预告

从单兵作战到拆解任务,你已经掌握了一个程序的基本结构——变量、条件、循环、方法和递归。但你要处理的数据越来越多了。一个人的剑谱要记,十个人的也要记吗?一百个人的呢?下一章,我们打开数组的门——它不会给你更方便的变量命名,但让你用数字下标就能走遍整个数据集合。

Built with VitePress | Software Systems Atlas