读完这篇,你能做什么
能把 main 中的计算提取为独立方法,并手工解释递归返回过程。
学习理由、准备要求与知识点
为什么学它
方法把输入、计算和输出分离,形成可复用且容易验证的程序单元。
先读这些内容
知识点清单
- 参数与返回类型
void与 return- 方法签名和重载
- 局部变量作用域
- 名称遮蔽
- 输入合同与失败方式
- 递归终止与推进
- 调用栈与递归深度
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
方法把一个明确的动作单独起名,通过参数接收输入,通过返回值交出结果。例如把“算平均分”和“打印平均分”拆开,同一个计算结果就能拿去显示、保存或测试。方法名说明动作,参数列出真正需要的数据;如果方法偷偷依赖许多会变化的字段,调用者就很难判断这次调用会发生什么。
Java 调用方法时总是复制参数的值。基本类型复制数字等值,引用类型复制指向对象的引用;形参是方法里新建的局部变量。给形参重新赋值不会改掉外部变量,但通过引用修改同一个可变对象,外部可以看见。作用域只说明某个名字在哪些代码里可用,不说明对象一定何时消失。对象能否被程序继续访问(可达)是另一个问题;仅仅还有别的对象引用它,不等于它仍然可达。
重载允许同名方法有不同参数列表,编译器依据可见签名和实参类型等规则选择适用版本;不能只靠返回类型重载。递归则是方法直接或间接调用自身,把问题交给规模更小的同类问题。写递归先列出无需继续调用的基本情况,再证明每次调用都靠近它;如果规模不缩小,漂亮的方法名也无法阻止无限递归和栈耗尽。
文中这几个词,先认一认
- 形参
- 方法内部接收这次传入值的局部变量,与调用方变量不是同一个变量。
- 作用域
- 一段代码中可以直接使用某个变量或类型名字的范围。
- 基本情况
- 递归到这里已经能直接给出答案,不需要再调用自己。
- 递归深度
- 递归调用尚未返回时累计套了多少层;过深可能耗尽调用栈。
02看看它怎么工作
调用者暂停在调用点,被调用方法得到自己的执行上下文。返回值送回调用位置之后,调用者再继续后面的计算。递归的每一层虽然执行同一份方法代码,却拥有不同的参数和局部状态;因此不应把所有层的局部变量想象成同一个盒子。调试时选择不同栈帧,正好能观察这一差别。
要说明递归为什么能算对,可以先假设更小的问题已经算对,再检查怎样把它们的结果合成当前答案,并确认所有分支最终都会走到不用再递归的基本情况。不过,能结束和算得对是两件事:即使一定结束,漏算一个元素仍会得到错误的和。处理一段范围时,常用左闭右开区间,也就是包含左端、不包含右端。这样左右端点相等就表示空范围,长度直接等于右端减左端。
每进入一层递归,通常都要保留这一层的参数和返回位置。Java 17 不保证把最后一步的递归调用自动改成不增加调用层数的形式,所以把递归放在最后一行,也不能放心接受任意深的输入。每次把规模减半,调用层数通常增长得较慢;每次只少一项,层数则可能与输入数量一起增长。下图的求和把区间分成两块互不重叠的子区间,要检查的是区间确实越来越短。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["求和区间 lo 到 hi,不含 hi"] --> B{"区间长度"}
B -->|"零"| C["返回 0"]
B -->|"一"| D["返回唯一元素"]
B -->|"大于一"| E["在中点拆成不重叠两半"]
E --> F["递归求左半和"]
E --> G["递归求右半和"]
F --> H["相加后返回"]
G --> H按顺序看,每一步做了什么
- 写明参数、返回值与非法输入。
- 先实现无需递归的基本情况。
- 选择严格缩小的规模度量。
- 证明子结果组合既不遗漏也不重复。
- 分析调用深度和结果范围。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/Arrays.java · java.util.Arrays#deepToString(Object[])
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
public final class SourceModel {
public static String render(Object[] array) {
Set<Object[]> path = Collections.newSetFromMap(new IdentityHashMap<>());
return render(array, path);
}
private static String render(Object[] array, Set<Object[]> path) {
if (array == null) return "null";
// 已在当前递归路径中说明存在回边,停止继续展开。
if (!path.add(array)) return "[...]";
StringBuilder out = new StringBuilder("[");
for (int i = 0; i < array.length; i++) {
if (i > 0) out.append(", ");
Object value = array[i];
out.append(value instanceof Object[]
? render((Object[]) value, path) : String.valueOf(value));
}
path.remove(array); // 返回时撤销路径状态,允许兄弟分支再次展开。
return out.append(']').toString();
}
}真实 Arrays.deepToString 先建立缓冲区与已访问路径集合,再调用内部辅助重载。辅助方法针对不同基本类型数组分支处理,遇到对象数组则递归展开。模型只展开 Object[],普通值转成文本,不模拟基本类型数组分支、容量估算和所有格式细节;这段源码用于理解方法分工与递归边界。
对象数组可以把自己放入一个元素,也可以通过其它数组间接指回自己。因此只依靠数组长度减少并不能证明格式化会结束,必须识别当前路径里的回边。数组的身份比内容相等更适合这个问题,因为我们要判断是否回到了同一个容器,而不是两个容器碰巧装着相同内容。
路径集合在进入时加入、返回时移除。若把它改成永不移除的全局访问集合,同一个子数组在两个兄弟位置出现时,第二次会被误认为循环。共享子结构和当前路径中的环不是同一个概念,撤销状态说明了递归返回阶段也可能有重要工作,不能只阅读向下调用的代码。
局部变量在每一层独立,但 path 引用的集合是有意共享的状态。形参复制引用并不会自动复制集合,因此下层加入和移除会影响上层看到的对象。这个例子把传值、共享可变对象和递归组合在一起;初读可先理解普通分治求和,再回来看图结构的循环检测。
代码先调用谁,再调用谁
- 公开
deepToString建立一次调用的辅助状态 - 辅助重载处理空值和数组元素
- 遇到对象数组时检查当前路径
- 递归返回后移除当前数组并完成格式化
04动手写一个例子
用平衡递归计算数组和
对 int 数组返回精确 long 总和,空数组结果为零,null 拒绝。使用左闭右开区间分治,不修改输入,也不复制子数组。
- 输入 1
[1,2,3,4]- 输出 1
10
- 输入 2
[]- 输出 2
0
- 输入 3
[2147483647,2147483647]- 输出 3
4294967294
对空区间直接返回零,对单元素区间直接返回该值;否则选中点,把左右两段分别交给同一个辅助方法。中点使用 lo + (hi - lo) / 2,避免先把两个大下标相加。两个区间分别是 [lo,mid) 与 [mid,hi),拼起来恰好是原区间,没有交叉也没有缺口。
正确性由长度归纳得到:长度零和一的返回显然符合定义;更长区间的两个子区间严格更短,假设它们返回各自精确和,相加就得到整体和。把返回类型设为 long,使每个叶子的 int 值先进入更大的结果类型,避免在某一层先发生 int 溢出。
树的叶子对应数组元素,内部节点负责一次合并,所以总工作随元素数线性增长。虽然递归写法看起来有两次调用,并不意味着指数时间,因为两次调用负责互不重叠的不同元素。最大同时存在的调用路径每次把区间长度约减半,额外栈空间是对数级。
为什么这样写是对的sum(a,lo,hi) 的返回值定义为且只包含 [lo,hi) 中的所有元素之和。
递推 T(n)=T(floor(n/2))+T(ceil(n/2))+O(1),非空叶子 n 个,总节点 O(n);单条递归路径长度为 O(log n)。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
public class Main {
static long sum(int[] values) {
if (values == null) throw new IllegalArgumentException("values");
return sum(values, 0, values.length);
}
private static long sum(int[] a, int lo, int hi) {
// 左闭右开区间允许自然表示空范围。
if (lo == hi) return 0L;
if (hi - lo == 1) return a[lo];
int mid = lo + (hi - lo) / 2;
// 子区间互不重叠;不复制数组,只传递边界。
return sum(a, lo, mid) + sum(a, mid, hi);
}
static void check(long expected, int... values) {
if (sum(values) != expected) throw new AssertionError(expected);
}
public static void main(String[] args) {
// 普通、空、单项、异号与超 int 总和。
check(10L, 1,2,3,4);
check(0L);
check(-7L, -7);
check(0L, -3,1,2);
check(4294967294L, Integer.MAX_VALUE, Integer.MAX_VALUE);
boolean rejected = false;
try { sum(null); } catch (IllegalArgumentException e) { rejected = true; }
if (!rejected) throw new AssertionError("null");
System.out.println("PASS 6");
}
}这些特殊情况也要试一试
- 空区间返回加法单位元零。
- 单元素无需再拆分。
- 奇数长度由两个不同长度子区间覆盖。
- 结果类型在递归返回与合并中始终是
long。
进阶选读 · 可选深入:显式栈与任务并行
场景输入结构可能极深,或独立子问题大到值得并行。
做法用显式栈保存尚待处理的区间以控制深度;并行时设置足够大的分割阈值,避免为每个元素创建任务。
复杂度顺序求和仍 O(n) 工作;理想并行跨度可降,但调度与内存带宽会限制收益。
好处与代价显式栈更啰嗦,并行也非自动更快;普通求和通常先选择清楚的循环实现。
05项目里怎么用
方法说明里应写清:能否传 null、会不会修改输入、没有结果时返回空还是抛异常。参数太多可能是职责太杂,但把参数藏进全局对象并不能解决问题。输入输出和副作用清楚,测试才能独立准备数据,也让调用者知道这次调用会改变什么。
递归适合树形定义和分治结构,却不适合无界深度而缺乏控制的外部输入。处理不可信嵌套数据时,应设置深度与总节点限制;限制深度只防止一条路径过长,不能防止大量浅层节点消耗资源。还要考虑错误退出时共享辅助状态如何清理,以及调用者是否会复用它。
重载应让不同签名保持一致的概念含义,避免参数类型微小变化就切换完全不同的业务行为。null 实参、自动装箱和可变参数可能让重载选择更难读;必要时使用不同方法名。对核心逻辑先保持纯计算,日志、输入输出与事务协调放在外层,减少测试依赖。
容易踩的坑
- 把重新绑定形参误认为修改调用者变量。
- 只检查递归基本情况,却没有证明规模缩小。
- 把 Java 尾递归当成无限深度安全保证。
- 路径集合不撤销,误把共享节点当成环。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 循环 | 状态与空间成本直接 | 复杂树结构可能需要手工栈 | 线性扫描与简单累积 |
| 递归 | 贴近分治和树形定义 | 需要控制调用深度 | 结构天然递归的问题 |
| 显式栈 | 能自行控制容量与暂停恢复 | 代码状态更多 | 深层结构与可恢复遍历 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么 Java 仍然是传值?
展开参考答案与推导
方法接收的是实参值的复制。引用值复制后,两边可能指向同一个对象,所以对象修改可见;但让形参指向新对象不会改变调用者变量,这两种操作必须分开理解。
02两次递归调用为什么不是必然指数复杂度?
展开参考答案与推导
要看子问题规模与重复程度。本题两次调用分担互不重叠的区间,总叶子只有 n 个;若两次都重复计算几乎同一规模的问题,复杂度才可能迅速增长。
03基本情况是否足以保证终止?
展开参考答案与推导
不足以。还需证明每条递归分支都严格接近基本情况,且不会绕回原状态。数组环格式化无法用长度递减证明,就需要路径检测来阻止回边继续展开。
换个情况,还会处理吗
01辅助方法改了列表,调用者为什么看见变化?
展开参考答案与推导
复制的是列表引用值,并未复制列表对象。两边通过不同变量访问同一个可变对象,修改元素会共享;若需要隔离,应明确创建快照并考虑元素本身是否仍可变。
02递归测试小输入通过,大输入栈溢出怎么办?
展开参考答案与推导
分析最大调用深度而非只看运行次数。线性深度可以改循环或显式栈,平衡分治可减少深度;对于外部嵌套结构还应限制深度与节点数,并保留失败契约。
03同一子数组显示第二次就变成循环标记,哪里错?
展开参考答案与推导
可能使用了全局访问集合且返回时没有移除。应区分当前路径和全部访问历史;只有当前路径中再次遇到同一容器才是回边,兄弟位置共享容器可以合法展开。
写代码试一试
01添加奇数长度测试
- 输入 / 条件
[2,3,4]- 输出 / 预期
9
提示 检查中点两侧覆盖。
展开参考答案与推导
加入 check(9L,2,3,4)。左段一个元素、右段两个元素,仍然恰好覆盖三项;如果右段从 mid+1 开始会漏项,此测试能直接暴露该边界错误。
02把求和改成最大值
- 输入 / 条件
[]- 输出 / 预期
明确拒绝或返回缺失状态
提示 最大值没有自然 int 单位元。
展开参考答案与推导
非空单项返回该值,合并取左右最大。空数组应单独定义契约,例如 OptionalInt.empty,不能随意返回零,因为全负数组的最大值可能小于零。
03避免辅助方法被外部传入坏区间
- 输入 / 条件
lo=-1,hi=2- 输出 / 预期
外部不可直接调用
提示 使用私有方法和公开入口。
展开参考答案与推导
公开入口接收数组并建立合法边界,辅助方法声明 private,递归内部保持不变量。若确需开放区间 API,则一次性检查零到长度之间的端点关系。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能把 main 中的计算提取为独立方法,并手工解释递归返回过程。
本篇重点加练
重点增加调用选择与递归推进练习,防止只会运行样例而不能解释方法行为。
- 方法签名和重载
- 递归终止与推进
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为“判断能否报名”和“打印报名结果”设计方法签名,比较返回值与输出副作用;再设计一组同名重载并说明编译器需要使用哪些信息选择方法。
- 提交内容
- 签名、调用样例与独立编译反例。
- 怎样算完成
- 返回合同与打印行为分开
- 重载依据完整
练到的知识点参数与返回类型 · void 与 return · 方法签名和重载
- 作业 02代码推演
写一个字段 value、方法参数 value 和块内不同局部变量并存的例子,逐行标明每次名称引用指向哪个声明;另构造一个越出作用域的编译错误。
- 提交内容
- 作用域图、编译记录和运行观察。
- 怎样算完成
- 遮蔽与变量寿命区分
- 声明位置能逐一定位
练到的知识点局部变量作用域 · 名称遮蔽
- 作业 03代码推演
给出
f(int)、f(long)与f(double)三个重载,使用整数、小数字面量和显式转换分别调用;预测选择并用方法自己的标签输出验证。- 提交内容
- 独立代码、预测矩阵与运行记录。
- 怎样算完成
- 实参表达式类型逐项标注
- 没有把返回类型当作选择输入
练到的知识点方法签名和重载
综合应用
- 作业 04设计评审
课程平均分方法接收两个整数成绩,约定有效范围 0 到 100。先定义正常、零分和越界成绩的处理合同,再比较返回特殊数字与明确失败两种接口的可用性;不需要使用数组。
- 提交内容
- 输入合同与调用方处理示例。
- 怎样算完成
- 失败不与有效结果混淆
- 合同适用于所有调用路径
练到的知识点输入合同与失败方式
- 作业 05代码推演
递归计算从 n 到 1 的求和,输入 n=4,画出进入、暂停和返回的调用栈;再设计一个不能推进到终止条件的变体,仅分析或用很小上限运行。
- 提交内容
- 调用栈图、终止论证和受控实验记录。
- 怎样算完成
- 每次调用的局部状态独立
- 终止与资源深度分别考虑
练到的知识点递归终止与推进 · 调用栈与递归深度
- 作业 06编程实践
实现整数幂的循环版和递归版,指数只允许非负,明确 0 的 0 次方和溢出政策;对小范围底数与指数交叉测试。
- 提交内容
- 两份 Java 方法、自写边界及交叉测试、运行证据。
- 怎样算完成
- 两实现共享同一合同
- 递归深度有说明
- 中间乘法也受范围约束
练到的知识点参数与返回类型 · void 与 return · 输入合同与失败方式 · 递归终止与推进 · 调用栈与递归深度
- 作业 07编程实践
实现只处理非负整数的递归位数统计,并提供两个参数类型不同的调用示例;先说明零输入、负输入与递归推进合同,不使用数组。
- 提交内容
- Java 实现、自写边界测试、调用选择记录与运行证据。
- 怎样算完成
- 每次递归都有可度量推进
- 重载或转换选择被明确解释
练到的知识点方法签名和重载 · 递归终止与推进
- 作业 08设计评审
递归方法的参数有时减一,有时保持不变,业务还允许零和负值。设计审查递归终止的检查表,要求给出最小不能证明推进的输入。
- 提交内容
- 度量候选、分支表与问题样本。
- 怎样算完成
- 每条递归路径单独分析
- 有限输入测试与终止论证分开
练到的知识点递归终止与推进
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成图结构后:嵌套目录深度由外部输入决定,评审直接递归扫描的资源风险,设计深度、工作量和取消预算,不要求真正扫描个人目录。
- 提交内容
- 带限制的遍历规则与极端输入验收案例。
- 怎样算完成
- 终止性和栈安全分开
- 预算触发后结果语义明确
练到的知识点输入合同与失败方式 · 递归终止与推进 · 调用栈与递归深度
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
有人认为“方法名字相同就是递归,参数每次变小就能结束”。分别构造检验两句话的最小调用关系,说明还需要哪些精确条件。
- 提交内容
- 调用图、参数轨迹和条件说明。
- 怎样算完成
- 重载目标和自调用被区分
- 数值范围与终止条件明确
练到的知识点方法签名和重载 · 递归终止与推进
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读规范英文
Java 17 语言规范:类、方法与记录类 (新标签页)↗
按需阅读构造器、方法重载、static
/final、枚举和 record 的定义与约束。 - 实践规范英文
Java 17 语言规范:名称与访问控制 (新标签页)↗
阅读作用域、名称遮蔽和 public
/protected/package/private访问规则。 - 深入规范英文
Java 17 语言规范:语句与控制流 (新标签页)↗
阅读块、if、for、while、break、return 和资源管理语句的执行顺序。
- 实践实践指南英文
Java 代码可视化:逐行看变量、引用和方法调用 (新标签页)↗
先写两个变量指向同一个数组,再通过其中一个变量修改数组。运行前画出你预计的关系,运行后逐步核对;再练一次传参和一次递归。只使用可视化也能完成这些练习。 可视化功能免费,页面为英文且有广告。只适合短小示例,不支持完整的多文件项目;页面带 AI 问答功能,输入代码会交给外部服务处理,练习请用不含私密信息的小例子。
- 深入参考文档英文
OpenJDK 17u java.util.Arrays (新标签页)↗
定位
deepToString的公开入口、递归辅助重载与路径集合撤销。 - 深入参考文档英文
Java17 Arrays API (新标签页)↗
阅读
deepToString对嵌套数组与循环引用的约定。