动手动脑与课后实验性问题整理

动手动脑与课后实验性问题整理

课程:Java 语言程序设计
教师:王建民
班级:信2505-2 学号:20253909 姓名:刘家瑞
教材/课件:第二讲《方法》
说明:本文整理课件中的「动手动脑」与「课后实验性」问题,共 5 处。文中给出的每一段代码都在本机编译运行过,代码、输出、结论一一对应,不是纸上推演。

序号 课件出处 类型 主题
1 第 23-24 页 动手动脑 纯随机数发生器(手写线性同余算法)
2 第 28-29 页 动手动脑 方法重载(MethodOverload)
3 第 42-44 页 课后实验性 阶乘为什么会出现负数(定长数值溢出)
4 第 46 页 课后实验性 浮点数能不能用 == 比较
5 第 30-40 页 课后实验性 递归:阶乘的递归与递推、汉诺塔

问题 1:编写一个方法,用纯随机数发生器算法生成指定数目的随机整数

课件第 23-24 页「完全手写代码实现随机数生成」

1.1 实验代码

课件给出的公式是线性同余发生器(LCG):

seed = (seed × Multiplier + Increment) % Modulus
Modulus = 2^31 - 1 = 2147483647,Multiplier = 16807 = 7^5,Increment = 0

我把它封装成一个类,顺带把「用 int 做中间运算」的错误版本也写出来做对照:

public class LcgRandomTest {

    private static final long MODULUS    = Integer.MAX_VALUE;   // 2^31 - 1 = 2147483647
    private static final long MULTIPLIER = 16807L;              // 7^5
    private static final long INCREMENT  = 0L;

    private long seed;

    LcgRandomTest(long seed) {
        if (seed == 0) throw new IllegalArgumentException("种子不能为 0,否则序列会永远卡在 0");
        this.seed = seed;
    }

    /** 下一个随机整数,范围 [0, MODULUS) */
    int next() {
        seed = (seed * MULTIPLIER + INCREMENT) % MODULUS;
        return (int) seed;
    }

    /** 生成 count 个随机整数 */
    int[] generate(int count) {
        int[] result = new int[count];
        for (int i = 0; i < count; i++) result[i] = next();
        return result;
    }

    /** 故意用 int 做中间运算的错误版本,用来观察溢出 */
    static class IntVersion {
        private int seed = 1;
        int next() {
            seed = (seed * 16807 + 0) % Integer.MAX_VALUE;
            return seed;
        }
    }
    // main 方法见 Program/LcgRandomTest.java
}

1.2 运行结果

========== 1. 生成 1000 个随机整数 ==========
生成个数:1000
前 10 个:16807  282475249  1622650073  984943658  1144108930  470211272  101027544  1457850878  1458777923  2007237709

========== 2. 分布均匀性检查(等分 10 个区间) ==========
区间          个数    比例
 0%~ 10%      96      9.6%
10%~ 20%      97      9.7%
20%~ 30%      97      9.7%
30%~ 40%      93      9.3%
40%~ 50%      117     11.7%
50%~ 60%      111     11.1%
60%~ 70%      110     11.0%
70%~ 80%      94      9.4%
80%~ 90%      96      9.6%
90%~100%      89      8.9%
(1000 个样本,理想情况每桶 100 个)

========== 3. 可复现性:同种子两次结果一致吗 ==========
两次都取第 1 个数:16807 / 16807 → 相同?true

========== 4. 中间运算用 int 会怎样 ==========
序号   long 中间量      int 中间量     一致吗
  1    16807          16807         一致
  2    282475249      282475249     一致
  3    1622650073     1622647863    ★不一致
  4    984943658      -1199696159   ★不一致
  5    1144108930     1578110407    ★不一致
  6    470211272      1878557649    ★不一致

========== 5. 种子为 0 的情形 ==========
构造时被拦下:种子不能为 0,否则序列会永远卡在 0
若不拦,种子 0 连续算三次仍是:0

========== 6. 与 JDK 自带 Random 对照 ==========
手写 LCG 第 1 个      :16807
手写 LCG 第 2 个      :282475249
JDK Random(1) 第 1 个 :-1155869325

1.3 分析

观察点一:第一个数必定等于乘数 16807,这不是巧合。

初始种子取 1,第一步算的就是 1 × 16807 % 2147483647 = 16807。所以序列首项必然是乘数本身——这同时也说明 LCG 是确定性算法:种子定死,整个序列就定死了。

观察点二:1000 个样本分 10 桶,每桶 89~117 个。

理想值每桶 100 个,实际波动在 ±17% 以内,属于正常的统计涨落。如果出现某桶 20 个、某桶 300 个这种量级的偏差,才说明算法有问题。我这组数据里 40%~50% 那桶最高(117),90%~100% 最低(89),仍在可接受范围内。

观察点三:中间量用 int 会在第 3 个数就开始失真。

这是我自己额外加的一组对照。seed × 16807 在第 3 步已到 1622650073 × 16807 ≈ 2.7×10^13,远超 int 的 21 亿上限,高位被截断后 % 2147483647 的结果和 long 版本完全不同。第 4 步直接变成了负数(-1199696159)——因为溢出后符号位翻转了。所以中间量必须声明成 long。

观察点四:种子 0 是必须堵死的边界。

若种子为 0,则 0 × 16807 % M = 0,之后永远是 0。我在构造方法里直接抛异常拦下,比事后排查「随机数怎么全是 0」要省事得多。另外模数 2^31-1 是素数,非零种子可以遍历 2147483646 个不同值才重复,周期极长。

观察点五:结果和 JDK 的 Random 对不上,但两者都是 LCG。

JDK 用的是 48 位模数(2^48)和乘数 25214903917,精度与分布质量都优于课件的教学版本。课件选 31 位是为了能用 int 演示、方便观察规律。

1.4 结论

  1. 纯随机数发生器的核心公式是 seed = (seed × 16807) % 2147483647,属于线性同余发生器。
  2. 中间运算必须用 long:seed × 16807 最大约 3.6×10^13,用 int 会在第 3 个数就失真。
  3. 种子必须非零,否则序列永远停在 0;建议在构造时就拦截。
  4. 模数 2^31-1 是素数,非零种子的周期可达 2147483646。
  5. 同一算法 + 同一种子 → 结果可复现;要「真随机」必须引入时间等外部熵源。
  6. 手写 LCG 与 JDK Random 结果不同,因为模数和乘数都不一样。

问题 2:请看以下代码,你发现了什么特殊之处?(Demo: MethodOverload)

课件第 28-29 页「动手动脑」+「练习:查看 System.out.println() 的重载」

2.1 实验代码

public class MethodOverloadTest {

    // ① 参数类型不同
    public static int square(int x) { return x * x; }
    public static double square(double y) { return y * y; }

    // ② 参数个数不同
    public static int add(int a, int b) { return a + b; }
    public static int add(int a, int b, int c) { return a + b + c; }

    // ③ 参数类型顺序不同
    public static String join(String s, int n) { return "字符串在前:" + s + n; }
    public static String join(int n, String s) { return "整数在前:" + n + s; }

    // 【不构成重载】只有返回值类型不同,放开下面这行即编译错误
    // public static double square(int x) { return x * x; }

    public static void main(String[] args) {
        System.out.println("square(7)    = " + square(7));
        System.out.println("square(7.5)  = " + square(7.5));
        System.out.println("add(1, 2)    = " + add(1, 2));
        System.out.println("add(1, 2, 3) = " + add(1, 2, 3));
        System.out.println(join("Java", 17));
        System.out.println(join(17, "Java"));

        // 反射列出 println 的所有重载版本
        int count = 0;
        for (java.lang.reflect.Method m : java.io.PrintStream.class.getMethods()) {
            if (!m.getName().equals("println")) continue;
            Class<?>[] ps = m.getParameterTypes();
            StringBuilder sb = new StringBuilder();
            for (Class<?> p : ps) sb.append(sb.length() == 0 ? "" : ", ").append(p.getSimpleName());
            count++;
            System.out.println("  " + count + ". println(" + sb + ")");
        }
        System.out.println("→ 共 " + count + " 个同名方法,全靠参数列表区分");
    }
}

2.2 运行结果

========== 1. 参数类型不同 ==========
square(7)    = 49
square(7.5)  = 56.25

========== 2. 参数个数不同 ==========
add(1, 2)    = 3
add(1, 2, 3) = 6

========== 3. 参数类型顺序不同 ==========
字符串在前:Java17
整数在前:17Java

========== 4. 没有精确匹配时的自动类型提升 ==========
square('a')  = 9409   ← char 提升为 int,命中 square(int)
square(7L)   = 49.0   ← long 无精确匹配,只能提升为 double

========== 5. 课件练习:System.out.println 的重载 ==========
  1. println(String)
  2. println(Object)
  3. println(float)
  4. println(double)
  5. println(char[])
  6. println(boolean)
  7. println()
  8. println(char)
  9. println(int)
  10. println(long)
→ 共 10 个同名方法,全靠参数列表区分

注:反射返回的方法顺序不保证与源码书写顺序一致,这里按实际输出列出。

2.3 分析

观察点一:这段代码的「特殊之处」就是方法重载。

同一个类里出现了两个都叫 square 的方法。按常理「同名会冲突」,但这里编译通过,因为它们的参数列表不同:一个收 int,一个收 double。编译器按调用处实参的实际类型,静态地选出对应版本——这叫编译期绑定。

观察点二:构成重载的三种方式我都验了一遍。

参数类型不同(square)、参数个数不同(add)、参数类型顺序不同(join),三种都能正常重载。join("Java", 17) 和 join(17, "Java") 输出不同,说明编译器确实按「先 String 后 int」和「先 int 后 String」区分成了两个方法。

观察点三:没有精确匹配时,会走自动类型提升。

square('a') 里实参是 char,两个候选(int / double)都不精确匹配。char 可以拓宽为 int,int 比 double 更「贴近」,所以命中 square(int),得到 97² = 9409。
square(7L) 的实参是 long:long 不能收窄成 int,只能拓宽成 double,所以命中 square(double),结果是 49.0 而不是 49。这个 49.0 很容易被忽略。

观察点四:返回值类型不参与重载判断。

我特意用注释保留了那行「只改返回值」的写法。放开它,javac 会报「已在类中定义了方法」。原因很实际:调用 square(7) 时,编译器无法从调用语句看出你想要哪个返回值,光靠返回值区分在语法上就不可行。

观察点五:println 是重载最经典的实践。

反射列出 PrintStream.println 一共 10 个重载。如果没有重载,就得写成 printlnInt、printlnString、printlnDouble……调用者要记 10 个名字,而且传 int 时可能误调 printlnString 造成隐性转换。重载把「同一语义、不同入参类型」的操作统一成一个名字,这是它最大的工程价值。

2.4 结论

  1. 构成重载的条件:方法名相同,且参数个数 / 类型 / 类型顺序至少有一项不同。
  2. 返回值类型不参与重载判断,仅返回值不同会导致编译错误。
  3. 重载在编译期根据实参类型静态决定调用哪个版本,与运行时无关。
  4. 实参没有精确匹配时走自动类型提升,可能调用到「看起来不像」的那个版本(如 square(7L) → 49.0)。
  5. 重载 ≠ 重写(override):重写发生在父子类之间、方法签名完全相同;重载发生在同一个类内。
  6. 重载的工程价值:统一语义的方法名,降低调用者的记忆负担。

问题 3:程序错了吗?为什么阶乘数会出现负数?(演示 CalculateN 的 BUG)

课件第 42-44 页,课后实验性问题

3.1 实验代码

import java.math.BigInteger;

public class FactorialOverflowTest {

    /** 用 int 递归计算阶乘 */
    static int factorialInt(int n) {
        if (n <= 1) return 1;
        return n * factorialInt(n - 1);
    }

    /** 用 long 递归计算阶乘 */
    static long factorialLong(int n) {
        if (n <= 1) return 1L;
        return n * factorialLong(n - 1);
    }

    /** 用循环算出的「正确答案」,作为裁判 */
    static BigInteger truth(int n) {
        BigInteger r = BigInteger.ONE;
        for (int i = 2; i <= n; i++) r = r.multiply(BigInteger.valueOf(i));
        return r;
    }

    /** 用 BigInteger 递归计算阶乘 */
    static BigInteger factorialBig(int n) {
        if (n <= 1) return BigInteger.ONE;
        return BigInteger.valueOf(n).multiply(factorialBig(n - 1));
    }
    // main 方法见 Program/FactorialOverflowTest.java
}

这里的关键设计是加了一个 truth(n) 作为裁判:用循环 + BigInteger 算出正确答案,再拿 int / long 的结果去比对,才能精确定位「从第几项开始错」,而不是靠肉眼找负号。

3.2 运行结果

========== 1. int 版本:逐项对照正确答案 ==========
 n    int 计算结果     正确吗
  1    1                正确
 ...(2~11 均正确,略)
 12    479001600        正确
 13    1932053504       ★出错
 14    1278945280       ★出错
 15    2004310016       ★出错
 16    2004189184       ★出错
 17    -288522240       ★出错
 18    -898433024       ★出错
 19    109641728        ★出错
 20    -2102132736      ★出错
→ int 版本从第 13 项开始出错

========== 2. int 版本从哪一项开始变成负数 ==========
第一个变成负数的项:第 17 项 → -288522240
注意:第 13 项就已经算错了,但要到第 17 项才看得见负号

========== 3. int / long 的理论边界 ==========
Integer.MAX_VALUE = 2147483647
Long.MAX_VALUE    = 9223372036854775807
12! = 479001600(小于 int 上限)
13! = 6227020800(已超过 int 上限)

========== 4. long 版本:只是推迟,没有解决 ==========
 18!  long = 6402373705728000       正确
 19!  long = 121645100408832000     正确
 20!  long = 2432902008176640000    正确
 21!  long = -4249290049419214848   ★出错
 22!  long = -1250660718674968576   ★出错
 23!  long = 8128291617894825984    ★出错
→ long 版本从第 21 项开始出错

========== 5. BigInteger 版本:完全正确 ==========
10! = 3628800   (与循环版一致:true)
15! = 1307674368000   (与循环版一致:true)
20! = 2432902008176640000   (与循环版一致:true)
25! = 15511210043330985984000000   (与循环版一致:true)
50! = 30414093201713378043612608166064768844377641568960512000000000000   (与循环版一致:true)

3.3 分析

观察点一:程序本身没写错,错的是数据类型选得太小。

factorialInt 的递归逻辑完全正确。问题出在 int 只有 32 位,而 13! = 6227020800 已经超过 Integer.MAX_VALUE(2147483647)。从 13! 开始结果就不对了。

观察点二:「算错」比「变负」出现得早得多。

这是我这次最想强调的一点:第 13 项就已经是错的,但一直到第 17 项才露出负号。 中间 13~16 项的结果(1932053504、1278945280、2004310016、2004189184)全都是正数,看起来人畜无害,实际早已失真。所以「盯着负号排查溢出」这个思路是错的——等看到负号时,答案已经错了 4 项了。

观察点三:负数的来源是符号位被截断。

int 用 32 位存储,最高位是符号位。乘法结果超出 32 位后高位被丢弃,留下的低 32 位如果最高位是 1,就被解释成负数。第 17 项恰好是第一个「最高位翻成 1」的项。

观察点四:换成 long 只是把出错点往后推。

long 撑到 20! 都正确(20! ≈ 2.43×10^18 < 9.22×10^18),21! 开始出错。这说明换更大的定长类型只是「延后」,无法根治——只要位数固定,总有一个 n 会溢出。

观察点五:BigInteger 才能真正解决。

25!、50! 的输出完全正确,位数任意增长,并且与独立写出的循环版本逐一吻合。代价是运算比原生类型慢,而且必须用 add / multiply 等方法,不能直接写 + *。

3.4 结论

  1. 阶乘出现负数不是逻辑 bug,是数值溢出——超范围的高位被截断,符号位翻转。
  2. int 版本从 13! 就已经算错,但直到 17! 才显现为负数;靠找负号排查会漏掉前面 4 项。
  3. long 的正确范围截止到 20!,21! 起溢出。
  4. Java 的定长整数类型(byte / short / int / long)位数固定,无法表示任意大的数。
  5. 需要任意精度整数用 BigInteger,需要任意精度十进制小数用 BigDecimal。
  6. 验证溢出类问题的通用手法:另写一个可靠的版本当裁判,逐项比对,才能定位「从哪一项开始错」。

问题 4:如果要比较两个浮点数,能用 == 吗?

课件第 46 页,课后实验性问题

4.1 实验代码

import java.math.BigDecimal;

public class FloatCompareTest {

    /** 差值阈值法:绝对误差 */
    static boolean nearlyEqual(double a, double b, double epsilon) {
        return Math.abs(a - b) < epsilon;
    }

    /** 相对误差法:对大数量级和极小数量级都适用 */
    static boolean relativelyEqual(double a, double b, double relEps) {
        if (a == b) return true;
        double diff = Math.abs(a - b);
        return diff <= Math.max(Math.abs(a), Math.abs(b)) * relEps;
    }

    public static void main(String[] args) {
        // 1. 课件现象
        double i = 0.0001;
        double j = 0.00010000000000000001;
        System.out.println("i == j  →  " + (i == j));
        System.out.println("i 的精确值:" + new BigDecimal(i));
        System.out.println("j 的精确值:" + new BigDecimal(j));
        // 其余输出见 Program/FloatCompareTest.java
    }
}

4.2 运行结果

========== 1. 课件示例:i 与 j 相等吗 ==========
i = 1.0E-4
j = 1.0E-4
i == j  →  true
i 的精确值:0.000100000000000000004792173602385929598312941379845142364501953125
j 的精确值:0.000100000000000000004792173602385929598312941379845142364501953125
两者二进制表示完全相同?true

========== 2. 经典反例:0.1 + 0.2 ==========
0.1 + 0.2          = 0.30000000000000004
0.1 + 0.2 == 0.3   → false
两者之差           = 5.551115123125783E-17

========== 3. 正确做法:差值阈值法 ==========
nearlyEqual(0.1+0.2, 0.3, 1e-10)   = true
nearlyEqual(1.0, 1.0000001, 1e-10) = false

========== 4. 固定绝对阈值的失效场景 ==========
nearlyEqual(1e9, 1e9+1, 1e-6)    = false   ← 只差 1,却判为不等
nearlyEqual(1e-12, 2e-12, 1e-6)  = true   ← 差了整整一倍,却判为相等
relativelyEqual(1e9, 1e9+1, 1e-9)  = true
relativelyEqual(1e-12, 2e-12, 1e-9) = false

========== 5. 需要精确十进制时用 BigDecimal ==========
new BigDecimal("0.1").add(new BigDecimal("0.2")) = 0.3
若用 double 构造:new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625

========== 6. 相邻的两个 double 到底差多少 ==========
Math.ulp(0.1)              = 1.3877787807814457E-17
0.1 之后的下一个可表示数   = 0.10000000000000002
两者之差                   = 1.3877787807814457E-17

4.3 分析

观察点一:i == j 返回 true,不是因为「近似相等」,而是它们真的是同一个数。

我把两个数的精确值都打印出来了,逐位完全相同。原因:double 只有 52 位尾数,约合 15~17 位有效十进制数字。0.00010000000000000001 比 0.0001 多出的那点差值在 double 的分辨率之下,转换时被直接丢弃,于是两个不同的字面量落到了同一个内存表示上。

观察点二:课件示例之所以「看起来对」,是因为它挑的两个数太接近了。

看第 6 组数据:0.1 之后的下一个可表示数只差 1.39×10^-17(这就是 Math.ulp(0.1),即「最小精度单位」)。也就是说,在这个量级上,任何小于 10^-17 的差别都会被抹平。课件示例恰好落在了这个区间里。

观察点三:0.1 + 0.2 != 0.3 才是浮点比较的常态。

0.1 和 0.2 都无法用二进制精确表示,相加后累积误差,结果成了 0.30000000000000004,与 0.3 相差约 5.55×10^-17,所以 == 返回 false。这不是 Java 的 bug,是 IEEE 754 浮点标准本身的特性,C / C++ / Python 表现完全一致。

观察点四:正确做法是「差值阈值法」,判断 |a - b| < epsilon。

课件里写的就是 Math.abs(i - j) < 1e-10。这里要提醒一处课件笔误:源码里写的是 Math.Abs(大写 A),Java 方法名区分大小写,正确写法是 Math.abs,照抄会报「找不到符号」。

观察点五:固定绝对阈值在大数或极小数量级下都会失效。

  • nearlyEqual(1e9, 1e9+1, 1e-6) → false。两个数只差 1,但绝对误差 1 远大于阈值 10^-6,判为不等;
  • nearlyEqual(1e-12, 2e-12, 1e-6) → true。两个数差了整整一倍,可绝对误差只有 10^-12,远小于阈值,被判成了相等。

改用相对误差后,两处都判对了(true / false)。所以更严谨的做法是相对误差,或基于 Math.ulp() 比较。

观察点六:BigDecimal 用 double 构造仍带误差。

new BigDecimal(0.1) 打印出来是一长串 0.1000000000000000055511151...——因为 double 本身就存不准。必须用字符串构造:new BigDecimal("0.1"),此时 0.1 + 0.2 才精确等于 0.3。

4.4 结论

  1. 计算机无法精确表示大多数十进制小数,所以不能用 == 比较两个浮点数。
  2. 课件示例中 i == j 返回 true,是因为两个字面量在 double 精度下被表示为同一个值,精确值逐位相同。
  3. 常见反例:0.1 + 0.2 != 0.3,误差约 5.55×10^-17。
  4. 正确做法:判 Math.abs(a - b) < epsilon(差值阈值法)。课件源码的 Math.Abs 是笔误,应为 Math.abs。
  5. 固定绝对阈值在大数量级下会「把只差 1 的判成不等」,在极小数量级下会「把差一倍的判成相等」,工程上更推荐相对误差或 Math.ulp() 方案。
  6. 金额等要求精确十进制的场景必须用 BigDecimal,且要用字符串构造,不要用 double 构造。

问题 5:递归——阶乘的递归与递推实现,以及汉诺塔

课件第 30-40 页「三、递归」,含第 39 页「递归 vs 递推」与课后参考答案 TowersOfHanoi

5.1 实验代码

public class RecursionTest {

    private static int calls = 0;
    private static int maxDepth = 0;

    /** 递归求 n! —— 三要素:结束条件、自调用、规模递减 */
    static long factorialRecursive(int n, int depth) {
        calls++;
        if (depth > maxDepth) maxDepth = depth;
        if (n <= 1) return 1L;                           // 结束条件
        return n * factorialRecursive(n - 1, depth + 1);  // 自调用,参数 n 递减
    }

    /** 递推(循环)求 n! —— 同一问题,方向相反 */
    static long factorialIterative(int n) {
        long result = 1L;
        for (int k = 2; k <= n; k++) result *= k;
        return result;
    }

    /** 汉诺塔:把每一步移动记下来 */
    static void hanoi(int disks, int from, int to, int via, List<String> steps) {
        if (disks == 1) {
            steps.add(from + " --> " + to);
            return;
        }
        hanoi(disks - 1, from, via, to, steps);
        steps.add(from + " --> " + to);
        hanoi(disks - 1, via, to, from, steps);
    }
    // main 方法见 Program/RecursionTest.java
}

我给递归版本加了两个计数器:一是调用总次数,二是最大嵌套深度。有了这两个数字,「递归三要素」和「递归的代价」就能用数据说话,而不是靠讲。

5.2 运行结果

========== 1. 阶乘:递归 vs 递推 ==========
 n    递归结果          递推结果          一致吗
  5   120               120               一致
 10   3628800           3628800           一致
 15   1307674368000     1307674368000     一致
 18   6402373705728000  6402373705728000  一致
→ 同一问题两种写法结果完全相同

========== 2. 递归的三要素(以 10! 为例) ==========
调用总次数:10,最大嵌套深度:10
① 结束条件:if (n <= 1) return 1;
② 自调用  :factorialRecursive(n - 1, depth + 1)
③ 规模递减:参数 n 每次减 1,必然触达结束条件

========== 3. 汉诺塔(3 个盘子) ==========
移动过程(起始柱 --> 目标柱):
  1 --> 3
  1 --> 2
  3 --> 2
  1 --> 3
  2 --> 1
  2 --> 3
  1 --> 3
实际步数:7

========== 4. 步数规律验证:2^n - 1 ==========
盘子数  理论步数(2^n-1)  实际步数  一致吗
   1    1                 1          一致
   2    3                 3          一致
   3    7                 7          一致
   4    15                15         一致
   5    31                31         一致
   6    63                63         一致
   7    127               127        一致
   8    255               255        一致

========== 5. 递归的代价 ==========
计算 20! 的最大嵌套深度:20 层
递归靠方法调用栈保存每层状态,深度过大 → StackOverflowError
递推只用一个 result 变量累乘,几乎不占额外栈空间

========== 6. 64 个盘子要多久 ==========
最少步数 = 2^64 - 1 = 18446744073709551615
每秒走一步,需要约 584554049253 年,也就是约 5845 亿年

5.3 分析

观察点一:递归与递推对同一问题给出完全相同的结果。

5!、10!、15!、18! 四组数据,递归版和循环版一字不差。这印证了课件第 39 页的判断:递推和递归可以互换。区别只在思路方向——递归是「由后至前再回来」(先求 n-1,再乘 n 返回),递推是「从前到后」(从 1 一路乘到 n)。

观察点二:递归的三要素在这段代码里全部可见。

  • 结束条件:if (n <= 1) return 1;——没有它就会无限递归到栈溢出;
  • 自调用:factorialRecursive(n - 1, depth + 1);
  • 规模递减的控制变量:参数 n 每次减 1,这是递归能终止的根本保证。

实测 10! 的调用总次数是 10、最大嵌套深度是 10。这个数据说明:递归的调用次数与嵌套深度是同一量级,规模递减一旦断掉,深度就会无限增长。

观察点三:汉诺塔是递归思想最漂亮的体现。

三根柱子编号 1、2、3,把 3 个盘子从 1 移到 3,实测过程是:

1 --> 3    (先把小盘挪到目标柱)
1 --> 2    (中盘挪到中转柱)
3 --> 2    (小盘从中转柱叠到中盘上)
1 --> 3    (最大盘直达目标柱)
2 --> 1    (小盘挪回起始柱)
2 --> 3    (中盘叠到最大盘上)
1 --> 3    (小盘归位)

共 7 步。关键是把「移 n 个盘」拆成「移 n-1 个盘 + 移 1 个盘 + 移 n-1 个盘」,这与课件第 33 页给出的递归定义完全对应。

观察点四:2^n - 1 的验证。

从 1 个盘到 8 个盘,理论步数与程序实际统计的步数逐一吻合,说明递归写法在逻辑上没多算也没少算任何一步。顺带用 BigInteger 算了一下 64 个盘:2^64 - 1 = 18446744073709551615 步,按每秒一步需要约 5845 亿年——这正是汉诺塔作为「世界末日」传说的数学依据。

观察点五:递归不是免费的。

计算 20! 时嵌套 20 层。每层都要把参数和返回地址压入调用栈,深度过大就会抛 StackOverflowError。递推版只用一个 result 变量累乘,几乎不占额外栈空间。所以课件第 39 页强调「要依据具体情况进行决断」。

5.4 结论

  1. 递归的三要素:结束条件 + 自调用 + 规模递减的控制变量,缺一不可。
  2. 递归与递推对同一问题可给出相同结果,区别在推导方向(由后至前 vs 从前到后)。
  3. 实测 10! 的调用次数与嵌套深度均为 10,两者同量级增长。
  4. 汉诺塔 n 个盘子的最少移动步数恰为 2^n - 1,程序实测 1~8 个盘全部吻合。
  5. 64 个盘子需要约 1.84×10^19 步,每秒一步需约 5845 亿年。
  6. 递归的代价是调用栈开销,深度过大有栈溢出风险;递推空间效率更高但代码可能不够直观。
  7. 工程选择原则:结构天然递归时用递归,否则优先递推。

总结

一、本讲知识点汇总

知识点 关键结论 对应问题
静态方法定义 访问权限 static 返回值类型 方法名(参数列表) { } 全讲
手写 LCG seed = (seed × 16807) % 2147483647,中间量必须用 long,种子必须非零 问题 1
随机数生成 Math.random() 返回 [0,1);Random 类功能更全、模数位宽更大 问题 1
方法重载 方法名相同 + 参数列表不同;返回值不参与判断 问题 2
自动类型提升 实参无精确匹配时按提升顺序选版本,可能调用到「不像」的那个 问题 2
整数溢出 int 从 13! 起算错、17! 起变负;long 撑到 20! 问题 3
大数处理 BigInteger(整数)/ BigDecimal(小数),后者要用字符串构造 问题 3、4
浮点比较 禁止 ==,用 Math.abs(a-b) < epsilon;大数应改用相对误差 问题 4
递归三要素 结束条件 + 自调用 + 规模递减变量 问题 5
递归 vs 递推 结果相同、方向相反、空间代价不同 问题 5
汉诺塔 最少步数 = 2^n - 1 问题 5

二、贯穿全讲的一条主线

第二讲的主题是「方法」——把重复的逻辑封装成可复用的模块。

  • 为什么要方法:课件开篇用李冰「积薪烧之」对比愚公「碎石垦壤」,说明方法的改进比体力的投入更能突破极限。程序里同理:一个方法封装一段逻辑,调用者只需知道「怎么用」,不必关心「怎么实现」。
  • 方法怎么组织:重载让同一语义的方法共用一个名字(问题 2),调用者不用记一堆变体名。
  • 方法怎么调用自己:递归(问题 5)是方法调用自己的特例,能用极短的代码表达复杂的结构,但必须严守三要素。
  • 方法处理的对象:方法是逻辑,数据是对象。数据类型选错,逻辑再对也白搭——阶乘变负数(问题 3)和浮点判等(问题 4)都是「数据表示」的坑,不是「方法写得不对」。

三、踩坑记录

# 坑 现象 解决
1 LCG 中间量用 int 从第 3 个数起序列失真,第 4 个数直接变负数 中间量声明为 long,最后再转 int
2 种子取 0 序列永远输出 0 构造方法里直接拦截,要求种子非零
3 只改返回值类型想做重载 编译报「已在类中定义了方法」 返回值不参与重载判断,必须改参数列表
4 靠「找负号」排查阶乘溢出 看到负号时答案已经错了 4 项(13! 就错了,17! 才变负) 用 BigInteger 循环版当裁判逐项比对
5 以为 long 能解决阶乘溢出 21! 起照样出错 定长类型只能推迟溢出,要 BigInteger
6 0.1 + 0.2 == 0.3 判假 结果 0.30000000000000004 改用 Math.abs(a-b) < 1e-10
7 绝对阈值对大数失效 nearlyEqual(1e9, 1e9+1, 1e-6) 判为不等 大数量级改用相对误差或 Math.ulp()
8 照抄课件源码的 Math.Abs 报「找不到符号:方法 Abs(double)」 Java 方法名区分大小写,应为 Math.abs
9 用 double 构造 BigDecimal 仍然带着二进制误差 必须用字符串构造:new BigDecimal("0.1")
10 递归深度过大 大数阶乘可能 StackOverflowError 深度敏感场景改用递推

四、示例程序清单

文件 对应问题 说明
LcgRandomTest.java 问题 1 手写线性同余发生器:1000 个随机数、分布统计、int/long 对照、种子边界
MethodOverloadTest.java 问题 2 重载的三种合法形式 + 非法形式 + 自动类型提升 + println 重载清单
FactorialOverflowTest.java 问题 3 int / long / BigInteger 三种阶乘实现对照,用 BigInteger 循环版当裁判定位出错点
FloatCompareTest.java 问题 4 浮点判等的错误做法与正确做法,含相对误差法与 Math.ulp() 对照
RecursionTest.java 问题 5 阶乘递归 / 递推对照 + 调用次数与深度统计 + 汉诺塔 + 2^n-1 验证
posted @ 2026-09-25 14:05  liujerry  阅读(3)  评论(0)    收藏  举报