动手动脑与课后实验性问题整理
动手动脑与课后实验性问题整理
课程: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 结论
- 纯随机数发生器的核心公式是
seed = (seed × 16807) % 2147483647,属于线性同余发生器。 - 中间运算必须用
long:seed × 16807最大约 3.6×10^13,用int会在第 3 个数就失真。 - 种子必须非零,否则序列永远停在 0;建议在构造时就拦截。
- 模数 2^31-1 是素数,非零种子的周期可达 2147483646。
- 同一算法 + 同一种子 → 结果可复现;要「真随机」必须引入时间等外部熵源。
- 手写 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 结论
- 构成重载的条件:方法名相同,且参数个数 / 类型 / 类型顺序至少有一项不同。
- 返回值类型不参与重载判断,仅返回值不同会导致编译错误。
- 重载在编译期根据实参类型静态决定调用哪个版本,与运行时无关。
- 实参没有精确匹配时走自动类型提升,可能调用到「看起来不像」的那个版本(如
square(7L)→49.0)。 - 重载 ≠ 重写(override):重写发生在父子类之间、方法签名完全相同;重载发生在同一个类内。
- 重载的工程价值:统一语义的方法名,降低调用者的记忆负担。
问题 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 结论
- 阶乘出现负数不是逻辑 bug,是数值溢出——超范围的高位被截断,符号位翻转。
int版本从 13! 就已经算错,但直到 17! 才显现为负数;靠找负号排查会漏掉前面 4 项。long的正确范围截止到 20!,21! 起溢出。- Java 的定长整数类型(byte / short / int / long)位数固定,无法表示任意大的数。
- 需要任意精度整数用
BigInteger,需要任意精度十进制小数用BigDecimal。 - 验证溢出类问题的通用手法:另写一个可靠的版本当裁判,逐项比对,才能定位「从哪一项开始错」。
问题 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 结论
- 计算机无法精确表示大多数十进制小数,所以不能用
==比较两个浮点数。 - 课件示例中
i == j返回 true,是因为两个字面量在double精度下被表示为同一个值,精确值逐位相同。 - 常见反例:
0.1 + 0.2 != 0.3,误差约 5.55×10^-17。 - 正确做法:判
Math.abs(a - b) < epsilon(差值阈值法)。课件源码的Math.Abs是笔误,应为Math.abs。 - 固定绝对阈值在大数量级下会「把只差 1 的判成不等」,在极小数量级下会「把差一倍的判成相等」,工程上更推荐相对误差或
Math.ulp()方案。 - 金额等要求精确十进制的场景必须用
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 结论
- 递归的三要素:结束条件 + 自调用 + 规模递减的控制变量,缺一不可。
- 递归与递推对同一问题可给出相同结果,区别在推导方向(由后至前 vs 从前到后)。
- 实测 10! 的调用次数与嵌套深度均为 10,两者同量级增长。
- 汉诺塔 n 个盘子的最少移动步数恰为 2^n - 1,程序实测 1~8 个盘全部吻合。
- 64 个盘子需要约 1.84×10^19 步,每秒一步需约 5845 亿年。
- 递归的代价是调用栈开销,深度过大有栈溢出风险;递推空间效率更高但代码可能不够直观。
- 工程选择原则:结构天然递归时用递归,否则优先递推。
总结
一、本讲知识点汇总
| 知识点 | 关键结论 | 对应问题 |
|---|---|---|
| 静态方法定义 | 访问权限 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 验证 |

浙公网安备 33010602011771号