qinghuan
← Java SE / 全部笔记

单元 05 / Java SE

P0 · 必会主线L0 · 零基础

方法、作用域与递归:把程序拆成可验证步骤

把大程序拆成输入输出清楚的方法,分清局部变量与共享对象,再为递归写好结束条件。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能把 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

按顺序看,每一步做了什么

  1. 写明参数、返回值与非法输入。
  2. 先实现无需递归的基本情况。
  3. 选择严格缩小的规模度量。
  4. 证明子结果组合既不遗漏也不重复。
  5. 分析调用深度和结果范围。

03对照实现代码

嵌套数组格式化中的递归与当前路径集合 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/Arrays.java · java.util.Arrays#deepToString(Object[])

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
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 引用的集合是有意共享的状态。形参复制引用并不会自动复制集合,因此下层加入和移除会影响上层看到的对象。这个例子把传值、共享可变对象和递归组合在一起;初读可先理解普通分治求和,再回来看图结构的循环检测。

代码先调用谁,再调用谁

  1. 公开 deepToString 建立一次调用的辅助状态
  2. 辅助重载处理空值和数组元素
  3. 遇到对象数组时检查当前路径
  4. 递归返回后移除当前数组并完成格式化

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) 中的所有元素之和。

时间 O(n)。额外空间 O(log n) 调用栈,空数组为 O(1)。

递推 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。程序包含固定输入自测,断言失败时会抛出错误。

Main.java · 完整可运行程序 · Java
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 中的计算提取为独立方法,并手工解释递归返回过程。

本篇重点加练

重点增加调用选择与递归推进练习,防止只会运行样例而不能解释方法行为。

  • 方法签名和重载
  • 递归终止与推进

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为“判断能否报名”和“打印报名结果”设计方法签名,比较返回值与输出副作用;再设计一组同名重载并说明编译器需要使用哪些信息选择方法。

    提交内容
    签名、调用样例与独立编译反例。
    怎样算完成
    • 返回合同与打印行为分开
    • 重载依据完整

    练到的知识点参数与返回类型 · void 与 return · 方法签名和重载

  2. 作业 02代码推演

    写一个字段 value、方法参数 value 和块内不同局部变量并存的例子,逐行标明每次名称引用指向哪个声明;另构造一个越出作用域的编译错误。

    提交内容
    作用域图、编译记录和运行观察。
    怎样算完成
    • 遮蔽与变量寿命区分
    • 声明位置能逐一定位

    练到的知识点局部变量作用域 · 名称遮蔽

  3. 作业 03代码推演

    给出 f(int)、f(long) 与 f(double) 三个重载,使用整数、小数字面量和显式转换分别调用;预测选择并用方法自己的标签输出验证。

    提交内容
    独立代码、预测矩阵与运行记录。
    怎样算完成
    • 实参表达式类型逐项标注
    • 没有把返回类型当作选择输入

    练到的知识点方法签名和重载

综合应用

  1. 作业 04设计评审

    课程平均分方法接收两个整数成绩,约定有效范围 0 到 100。先定义正常、零分和越界成绩的处理合同,再比较返回特殊数字与明确失败两种接口的可用性;不需要使用数组。

    提交内容
    输入合同与调用方处理示例。
    怎样算完成
    • 失败不与有效结果混淆
    • 合同适用于所有调用路径

    练到的知识点输入合同与失败方式

  2. 作业 05代码推演

    递归计算从 n 到 1 的求和,输入 n=4,画出进入、暂停和返回的调用栈;再设计一个不能推进到终止条件的变体,仅分析或用很小上限运行。

    提交内容
    调用栈图、终止论证和受控实验记录。
    怎样算完成
    • 每次调用的局部状态独立
    • 终止与资源深度分别考虑

    练到的知识点递归终止与推进 · 调用栈与递归深度

  3. 作业 06编程实践

    实现整数幂的循环版和递归版,指数只允许非负,明确 0 的 0 次方和溢出政策;对小范围底数与指数交叉测试。

    提交内容
    两份 Java 方法、自写边界及交叉测试、运行证据。
    怎样算完成
    • 两实现共享同一合同
    • 递归深度有说明
    • 中间乘法也受范围约束

    练到的知识点参数与返回类型 · void 与 return · 输入合同与失败方式 · 递归终止与推进 · 调用栈与递归深度

  4. 作业 07编程实践

    实现只处理非负整数的递归位数统计,并提供两个参数类型不同的调用示例;先说明零输入、负输入与递归推进合同,不使用数组。

    提交内容
    Java 实现、自写边界测试、调用选择记录与运行证据。
    怎样算完成
    • 每次递归都有可度量推进
    • 重载或转换选择被明确解释

    练到的知识点方法签名和重载 · 递归终止与推进

  5. 作业 08设计评审

    递归方法的参数有时减一,有时保持不变,业务还允许零和负值。设计审查递归终止的检查表,要求给出最小不能证明推进的输入。

    提交内容
    度量候选、分支表与问题样本。
    怎样算完成
    • 每条递归路径单独分析
    • 有限输入测试与终止论证分开

    练到的知识点递归终止与推进

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成图结构后:嵌套目录深度由外部输入决定,评审直接递归扫描的资源风险,设计深度、工作量和取消预算,不要求真正扫描个人目录。

    提交内容
    带限制的遍历规则与极端输入验收案例。
    怎样算完成
    • 终止性和栈安全分开
    • 预算触发后结果语义明确

    练到的知识点输入合同与失败方式 · 递归终止与推进 · 调用栈与递归深度

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    有人认为“方法名字相同就是递归,参数每次变小就能结束”。分别构造检验两句话的最小调用关系,说明还需要哪些精确条件。

    提交内容
    调用图、参数轨迹和条件说明。
    怎样算完成
    • 重载目标和自调用被区分
    • 数值范围与终止条件明确

    练到的知识点方法签名和重载 · 递归终止与推进

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗