1. 项目概述与核心需求解析
“评委打分”这个场景,无论是校园里的演讲比赛、公司内部的技能评比,还是电视上的选秀节目,都太常见了。核心逻辑也出奇的一致:去掉一个最高分,去掉一个最低分,然后计算剩余分数的平均值。这个逻辑看似简单,但对于Java初学者,或者正在准备面试的朋友来说,它却是一个绝佳的综合性练手项目。它几乎串联了Java SE阶段最核心的几个知识点:控制台输入(Scanner)、数组(或集合)的操作、循环遍历、条件判断以及基本的算术运算。
很多人拿到这个题目,第一反应可能就是“这太简单了”,然后草草写几行代码了事。但如果你愿意稍微深入一点,就会发现里面藏着不少“坑”和可以优化的点。比如,评委人数是固定的还是可变的?分数是整数还是小数?如果出现并列的最高分或最低分,是只去掉一个,还是全部去掉?这些边界情况,恰恰是面试官最喜欢追问的地方,也是区分“能写代码”和“能写好代码”的关键。
所以,我们今天不单单是完成一个功能,而是要把它当作一个完整的微型项目来剖析。我会从最基础的版本开始,一步步迭代,加入异常处理、考虑更复杂的情况,并分享一些我在实际开发中总结出来的心得和容易踩的坑。无论你是刚学完Java基础想找项目练手,还是在为面试刷题做准备,相信这篇内容都能给你带来一些实实在在的收获。
2. 基础版本实现:从零开始构建打分系统
我们先从最直观、最易理解的版本开始。这个版本假设评委人数固定,分数为整数,并且我们只使用最基础的数组和循环。
2.1 环境准备与核心工具:Scanner
在Java中,要从控制台获取用户的输入,最常用的就是java.util.Scanner类。它就像是一个“扫描仪”,可以从各种输入源(如系统标准输入System.in)中读取数据。
import java.util.Scanner; public class JudgeScoreBasic { public static void main(String[] args) { // 创建Scanner对象,绑定到标准输入流 Scanner scanner = new Scanner(System.in); System.out.print("请输入评委人数:"); int judgeCount = scanner.nextInt(); // 读取下一个整数 // ... 后续逻辑 scanner.close(); // 使用完毕后关闭,释放资源 } }注意:
Scanner的nextInt()、nextDouble()等方法在读取数据时,不会消耗输入流中的换行符\n。如果你在读取数字后紧接着要读取字符串(比如nextLine()),这个残留的换行符会被立刻读取,导致字符串读取为空。一个常见的处理技巧是在nextInt()后多加一句scanner.nextLine()来“吞掉”这个换行符。
2.2 数据存储与收集:数组的运用
确定了评委人数后,我们需要一个容器来存储每一位评委给出的分数。数组(Array)是最简单、最直接的顺序存储结构。
// 根据评委人数创建分数数组 int[] scores = new int[judgeCount]; System.out.println("请依次输入" + judgeCount + "位评委的分数:"); for (int i = 0; i < judgeCount; i++) { System.out.print("第" + (i + 1) + "位评委分数:"); scores[i] = scanner.nextInt(); }这里我们用了一个for循环来依次填充数组。数组下标从0开始,但给用户提示时我们从1开始,更符合日常习惯。
2.3 核心逻辑实现:查找极值与计算均值
这是整个程序的心脏部分。我们需要遍历数组,找出最大值和最小值,同时计算所有分数的总和。
// 初始化最大值和最小值。通常将最大值设为一个非常小的数,最小值设为一个非常大的数。 // 也可以直接使用数组的第一个元素进行初始化,这样更安全。 int maxScore = scores[0]; int minScore = scores[0]; int sum = scores[0]; // 总和也从第一个元素开始累加 // 从数组的第二个元素开始遍历(下标1) for (int i = 1; i < scores.length; i++) { int currentScore = scores[i]; sum += currentScore; // 累加总分 // 更新最高分 if (currentScore > maxScore) { maxScore = currentScore; } // 更新最低分 if (currentScore < minScore) { minScore = currentScore; } }找到极值并有了总分后,计算平均分的公式就很简单了:(总分 - 最高分 - 最低分) / (评委人数 - 2)。这里有一个细节:除数可能是0吗?如果评委人数小于3,去掉两个分数后就没有分数可平均了。这是一个重要的边界情况,必须处理。
// 计算有效评委人数(去掉最高最低分后) int validJudgeCount = scores.length - 2; // 边界情况处理:如果评委人数不足3人,则无法进行去掉最高最低分的操作 if (validJudgeCount <= 0) { System.out.println("评委人数不足,无法计算去掉最高最低分后的平均分。"); // 这里可以选择直接计算平均分,或者结束程序 double average = (double) sum / scores.length; System.out.println("所有分数的平均分为:" + average); } else { // 计算最终平均分 double finalAverage = (double)(sum - maxScore - minScore) / validJudgeCount; // 格式化输出,保留两位小数 System.out.printf("去掉一个最高分:%d,去掉一个最低分:%d%n", maxScore, minScore); System.out.printf("最终平均分是:%.2f%n", finalAverage); }注意类型转换(double),这是为了进行浮点数除法,得到带小数的精确结果。如果使用整数除法,结果会被截断。
2.4 基础版本完整代码与测试
将以上部分组合起来,就是一个完整的基础版本。
import java.util.Scanner; public class JudgeScoreBasic { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); System.out.print("请输入评委人数:"); int judgeCount = scanner.nextInt(); if (judgeCount <= 0) { System.out.println("评委人数必须大于0。"); scanner.close(); return; // 结束程序 } int[] scores = new int[judgeCount]; System.out.println("请依次输入" + judgeCount + "位评委的分数:"); for (int i = 0; i < judgeCount; i++) { System.out.print("第" + (i + 1) + "位评委分数:"); scores[i] = scanner.nextInt(); } // 查找最大值、最小值,并求和 int maxScore = scores[0]; int minScore = scores[0]; int sum = scores[0]; for (int i = 1; i < scores.length; i++) { int currentScore = scores[i]; sum += currentScore; if (currentScore > maxScore) { maxScore = currentScore; } if (currentScore < minScore) { minScore = currentScore; } } // 计算并输出结果 int validCount = scores.length - 2; if (validCount <= 0) { System.out.println("评委人数不足3人,无法去掉最高最低分。"); double average = (double) sum / scores.length; System.out.println("所有分数的平均分为:" + average); } else { double finalAverage = (double)(sum - maxScore - minScore) / validCount; System.out.println("最高分:" + maxScore); System.out.println("最低分:" + minScore); System.out.println("最终平均分:" + finalAverage); // 使用printf格式化输出 System.out.printf("最终平均分(保留两位小数):%.2f%n", finalAverage); } scanner.close(); } }测试一下: 输入评委人数5,分数依次为:90, 85, 92, 88, 95。 程序会输出:最高分95,最低分85,总和450。去掉极值后,有效分数为90, 92, 88,总和270,平均分90.0。逻辑正确。
3. 进阶优化:处理边界与提升健壮性
基础版本能跑通,但离“健壮”还差得远。用户可能输入非数字,评委人数可能输入负数,分数可能超出合理范围(比如0-100分制)。我们一步步来加固它。
3.1 输入验证与异常处理
Scanner的nextInt()方法在用户输入非整数时会抛出InputMismatchException。我们必须捕获这个异常,并给用户友好的提示。
import java.util.InputMismatchException; // ... 其他import int judgeCount = 0; boolean validInput = false; while (!validInput) { System.out.print("请输入评委人数(正整数):"); try { judgeCount = scanner.nextInt(); if (judgeCount > 0) { validInput = true; } else { System.out.println("输入错误,评委人数必须是正整数。"); } } catch (InputMismatchException e) { System.out.println("输入错误,请输入一个整数。"); scanner.next(); // 清除缓冲区中错误的输入,避免死循环 } }对于分数的输入,我们也需要类似的验证。可以封装一个方法:
/** * 获取指定范围内的整数分数 * @param scanner Scanner对象 * @param prompt 提示信息 * @param min 最小有效值 * @param max 最大有效值 * @return 有效的分数 */ public static int getValidScore(Scanner scanner, String prompt, int min, int max) { while (true) { System.out.print(prompt); try { int score = scanner.nextInt(); if (score >= min && score <= max) { return score; } else { System.out.printf("分数必须在%d到%d之间,请重新输入。%n", min, max); } } catch (InputMismatchException e) { System.out.println("输入错误,请输入一个整数。"); scanner.next(); // 清除无效输入 } } }然后在主循环中调用:
for (int i = 0; i < judgeCount; i++) { scores[i] = getValidScore(scanner, "第" + (i + 1) + "位评委分数(0-100):", 0, 100); }3.2 并列最高分/最低分的处理策略
这是一个容易忽略的边界情况。假设分数是 [95, 95, 80, 90],最高分95出现了两次。基础版本的逻辑是“去掉一个最高分和一个最低分”,那么是只去掉一个95,还是两个都去掉?这取决于业务规则。
策略一:只去掉一个实例(基础版本逻辑)我们的基础版本在求和时,sum包含了所有分数。在最后计算时,我们只减去了maxScore和minScore各一次。这意味着如果最高分有多个,我们只扣除了其中一个最高分的值,剩下的最高分仍然参与平均。这符合“去掉一个最高分”的字面意思。同理,最低分也是如此。
策略二:去掉所有最高/最低分实例如果需要去掉所有并列的极值,我们就不能在最后简单地减去一个值。我们需要在遍历时,记录下哪些分数是最高分,哪些是最低分,或者在计算总和时就不把这些分数加进去。
修改求和的循环:
int maxScore = scores[0]; int minScore = scores[0]; int sum = 0; int maxCount = 0; int minCount = 0; // 第一轮遍历,找出最大值和最小值 for (int score : scores) { if (score > maxScore) maxScore = score; if (score < minScore) minScore = score; } // 第二轮遍历,计算总和,并统计极值出现次数 for (int score : scores) { sum += score; if (score == maxScore) maxCount++; if (score == minScore) minCount++; } // 计算最终平均分 int totalRemoved = maxCount + minCount; int validCount = scores.length - totalRemoved; if (validCount <= 0) { System.out.println("所有分数都是最高分或最低分,无法计算平均分。"); } else { // 减去所有最高分和最低分的总和 double finalAverage = (double)(sum - maxScore * maxCount - minScore * minCount) / validCount; System.out.printf("去掉了 %d 个最高分(%d分), %d 个最低分(%d分)。%n", maxCount, maxScore, minCount, minScore); System.out.printf("最终平均分是:%.2f%n", finalAverage); }选择哪种策略,一定要和需求方确认清楚。在面试中,如果能主动提出这个边界情况并给出解决方案,绝对是加分项。
3.3 使用集合(ArrayList)的动态版本
基础版本使用数组,要求评委人数固定。如果我们希望程序更灵活,允许中途结束输入或者动态增加评委,ArrayList是更好的选择。
import java.util.ArrayList; import java.util.Scanner; public class JudgeScoreDynamic { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); ArrayList<Integer> scoreList = new ArrayList<>(); System.out.println("请输入评委分数(输入-1结束输入):"); while (true) { System.out.print("分数(-1结束):"); try { int input = scanner.nextInt(); if (input == -1) { break; // 结束输入 } if (input >= 0 && input <= 100) { // 假设分数范围0-100 scoreList.add(input); } else { System.out.println("分数应在0-100之间,已忽略。"); } } catch (InputMismatchException e) { System.out.println("输入无效,请输入整数。"); scanner.next(); } } if (scoreList.size() < 3) { System.out.println("有效分数不足3个,无法计算。"); scanner.close(); return; } // 将ArrayList转换为数组,方便后续操作(也可以直接用ArrayList操作) // 这里为了复用之前的逻辑,先转换 int[] scores = new int[scoreList.size()]; for (int i = 0; i < scoreList.size(); i++) { scores[i] = scoreList.get(i); } // ... 后续查找极值、计算平均分的逻辑与之前相同 // 注意:如果使用“去掉所有极值”的策略,用ArrayList会更方便,可以直接remove元素。 } }使用ArrayList的好处是代码更灵活,内存使用也更高效(动态扩容)。缺点是对于纯粹的数字遍历和计算,其性能比数组略差(有自动装箱/拆箱和边界检查开销),但在这种小规模场景下完全可以忽略。
4. 架构与设计模式思考:让代码更优雅
当我们把基本功能做稳定后,可以思考一下代码的组织结构。一个简单的控制台程序似乎用不上设计模式,但良好的分层和职责分离能让代码更清晰、更易测试、更易维护。
4.1 职责分离:MVC思想的简单应用
我们可以粗略地将程序分为三层:
- 模型(Model):负责数据和核心业务逻辑。比如,一个
ScoreCalculator类,它接收一个分数列表,负责计算最高分、最低分、平均分。 - 视图(View):负责与用户交互,输入和输出。在我们的控制台程序中,就是
main方法里那些System.out.print和Scanner操作。 - 控制器(Controller):负责协调模型和视图。它从视图获取输入,传递给模型处理,再将模型的结果返回给视图展示。
我们来重构一下代码:
1. 模型类 ScoreCalculator
public class ScoreCalculator { /** * 计算去掉一个最高分和一个最低分后的平均分 * @param scores 分数数组 * @return 平均分 * @throws IllegalArgumentException 如果数组为null或长度小于3 */ public static double calculateAverage(int[] scores) { if (scores == null || scores.length < 3) { throw new IllegalArgumentException("分数数组不能为null且长度必须大于等于3"); } int max = scores[0]; int min = scores[0]; int sum = scores[0]; for (int i = 1; i < scores.length; i++) { int current = scores[i]; sum += current; if (current > max) max = current; if (current < min) min = current; } return (double)(sum - max - min) / (scores.length - 2); } /** * 计算去掉所有最高分和最低分后的平均分 * @param scores 分数数组 * @return 包含平均分和移除信息的对象(这里简单用数组返回) */ public static Object[] calculateAverageRemoveAll(int[] scores) { // ... 实现逻辑,返回平均分、最高分、最低分、移除个数等信息 // 为了简化,这里不展开,实际可以用一个自定义的Result类 return null; } }2. 视图和控制器(在Main类中)
import java.util.Scanner; import java.util.InputMismatchException; public class JudgeScoreMVC { public static void main(String[] args) { // View & Controller Scanner scanner = new Scanner(System.in); int[] scores = null; try { // 1. 获取输入(View) scores = getScoresFromUser(scanner); // 2. 调用模型处理(Controller) double avg = ScoreCalculator.calculateAverage(scores); // 3. 展示结果(View) System.out.printf("最终平均分:%.2f%n", avg); } catch (IllegalArgumentException e) { System.out.println("计算错误:" + e.getMessage()); } catch (Exception e) { System.out.println("发生未知错误:" + e.getMessage()); } finally { scanner.close(); } } private static int[] getScoresFromUser(Scanner scanner) { // ... 包含输入验证的复杂逻辑 // 返回一个有效的分数数组 return new int[]{90, 85, 92, 88, 95}; // 示例返回 } }经过这样的拆分,ScoreCalculator类变得非常纯净,只关心计算逻辑,很容易编写单元测试。而主程序则专注于流程控制和用户交互。虽然对于这个小程序来说有点“杀鸡用牛刀”,但这种思想在构建更大、更复杂的系统时至关重要。
4.2 使用Stream API的现代写法
如果你使用的是Java 8及以上版本,利用Stream API可以让代码变得异常简洁和优雅。它通过声明式的方式处理集合,避免了显式的循环。
import java.util.Arrays; import java.util.IntSummaryStatistics; public class JudgeScoreStream { public static void main(String[] args) { int[] scores = {90, 85, 92, 88, 95}; // 将int数组转换为IntStream IntSummaryStatistics stats = Arrays.stream(scores).summaryStatistics(); long count = stats.getCount(); int max = stats.getMax(); int min = stats.getMin(); long sum = stats.getSum(); if (count < 3) { System.out.println("分数个数不足。"); return; } double average = (double)(sum - max - min) / (count - 2); System.out.println("最高分:" + max); System.out.println("最低分:" + min); System.out.printf("平均分:%.2f%n", average); // 更极致的单行写法(可读性稍差,但很酷) double avg = Arrays.stream(scores) .sorted() // 排序 .skip(1) // 跳过最小的一个(最低分) .limit(scores.length - 2) // 限制长度,相当于也去掉了最大的一个 .average() // 求平均 .orElse(Double.NaN); // 如果流为空,返回NaN System.out.printf("使用Stream排序法计算的平均分:%.2f%n", avg); } }Arrays.stream(scores).summaryStatistics()这一行代码,直接帮我们拿到了总和、最大值、最小值、平均值和个数。sorted().skip(1).limit(n-2).average()这个链式调用则展示了另一种思路:先排序,然后跳过第一个(最低分),再只取前n-2个(自然就去掉了最后一个,即最高分),最后求平均。Stream API的代码非常简洁,但要注意它可能比手写循环有额外的开销(比如排序),在性能极端敏感的场合需要测试。
5. 常见问题、调试技巧与性能考量
即使是一个简单的程序,在编写和运行过程中也会遇到各种问题。这里我总结几个常见坑点和调试方法。
5.1 典型问题排查清单
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 输入数字后程序直接结束或跳过下一次输入 | nextInt()后残留换行符被nextLine()读取 | 在nextInt()后调用一次scanner.nextLine()消耗换行符 |
| 计算出的平均分总是整数(小数部分为0) | 整数除法 | 确保在除法运算前,将被除数或除数转换为double类型,如(double) sum / count |
数组索引越界ArrayIndexOutOfBoundsException | 访问了不存在的数组下标,如scores[-1]或scores[scores.length] | 检查循环条件,确保下标在0到length-1之间 |
| 输入非数字时程序崩溃或进入死循环 | 未处理InputMismatchException | 使用try-catch捕获异常,并在catch块中调用scanner.next()清除错误输入 |
| 最高分/最低分初始化错误导致结果不对 | 初始值设置不合理,如max=0,但所有分数都是负数 | 使用数组的第一个元素来初始化max和min是最安全的方法 |
| 当所有分数相同时,计算有效人数为负 | 未检查validCount = scores.length - 2的结果 | 在计算前判断if (scores.length < 3),进行特殊处理 |
5.2 调试技巧:如何定位逻辑错误
当程序运行结果不符合预期时,不要急于重写代码。学会调试是程序员的基本功。
- 打印中间变量:这是最朴素也最有效的方法。在关键步骤后,打印出变量的值。
// 在查找极值的循环里 for (int i = 1; i < scores.length; i++) { int currentScore = scores[i]; sum += currentScore; System.out.println("当前分数:" + currentScore + ", 当前总和:" + sum); // 调试语句 if (currentScore > maxScore) { maxScore = currentScore; System.out.println("更新最高分为:" + maxScore); // 调试语句 } // ... } - 使用IDE的调试器:以IntelliJ IDEA或Eclipse为例。
- 在你怀疑有问题的代码行左侧单击,设置一个断点。
- 以“Debug”模式运行程序。
- 程序会在断点处暂停,你可以将鼠标悬停在变量上查看其当前值。
- 使用“Step Over”(F8)逐行执行,“Step Into”(F7)进入方法内部,“Resume”(F9)继续运行到下一个断点。
- 在“Variables”窗口可以查看和修改变量的值。这是理解程序运行流程、定位隐蔽错误的利器。
- 单元测试:为你的核心计算方法(如
ScoreCalculator.calculateAverage)编写单元测试。使用JUnit等框架,传入各种边界情况的数组(如空数组、单个元素、全部相同的元素、正序、逆序数组),验证输出是否符合预期。这能从根本上保证核心逻辑的正确性。
5.3 性能与扩展性浅析
对于这个“评委打分”程序,数据量很小(通常不超过几十个),所以任何写法的性能差异都可以忽略不计。但作为一种思维训练,我们可以探讨一下:
- 时间复杂度:无论用单次遍历找极值,还是先排序再取中间值,时间复杂度都是 O(n),其中n是分数个数。这是最优解。
- 空间复杂度:使用数组是 O(n)。如果分数是源源不断输入的流数据,我们甚至可以在不存储所有分数的情况下计算最终结果——只需要动态维护当前的总和、最大值、最小值即可。当新分数到来时,更新这些值。这能将空间复杂度降到 O(1)。
// 流式处理伪代码 int sum = 0, max = Integer.MIN_VALUE, min = Integer.MAX_VALUE, count = 0; while (有新的分数 score) { sum += score; count++; if (score > max) max = score; if (score < min) min = score; } // 最终计算时,依然需要 count >= 3 double avg = (double)(sum - max - min) / (count - 2); - 扩展性:如果业务规则变化,比如“去掉两个最高分和两个最低分”,或者“计算加权平均分”,我们基础版本的代码只需要修改计算部分。而如果采用了MVC或策略模式的设计,扩展起来会更加轻松,只需增加新的计算策略类,而不影响其他部分。
6. 从项目到面试:可能被问到的深入问题
如果你在面试中被要求手写这个程序,或者面试官看到你简历上有这个项目,他可能会沿着以下几个方向深入提问,考察你的知识广度和思考深度:
如果分数不是整数,是小数怎么办?
- 将
int数组改为double数组。 - 注意
Scanner用nextDouble()读取。 - 比较大小和求和的逻辑完全一致。但要注意浮点数的精度问题,对于严格的金融计算,可能会使用
BigDecimal。
- 将
如果不允许使用数组,你还有什么方法?
- 可以使用
ArrayList。 - 如果连集合都不能用,可以在输入过程中动态维护
sum,max,min,count,这就是上面提到的流式处理思路。但需要一次性输入所有分数,或者改变交互逻辑。
- 可以使用
你的程序是线程安全的吗?
- 目前的版本所有变量都是局部变量,在单线程的
main方法中运行,是线程安全的。 - 但如果
ScoreCalculator的方法被设计为静态工具方法,并被多个线程同时调用,且参数是共享的数组或集合,那么就需要考虑线程安全问题。不过对于这个简单的计算任务,通常不需要。
- 目前的版本所有变量都是局部变量,在单线程的
如何为这个程序编写单元测试?
- 针对
ScoreCalculator类,使用JUnit。 - 测试用例应包括:正常情况、边界情况(如刚好3个分数)、异常情况(如数组为null、长度不足)。
- 示例:
@Test public void testCalculateAverage_Normal() { int[] scores = {90, 85, 92, 88, 95}; double expected = (90+92+88) / 3.0; // (450-95-85)/3 = 270/3=90 double actual = ScoreCalculator.calculateAverage(scores); assertEquals(expected, actual, 0.001); // 第三个参数是允许的误差 } @Test(expected = IllegalArgumentException.class) public void testCalculateAverage_InvalidInput() { ScoreCalculator.calculateAverage(new int[]{90, 85}); // 长度不足,应抛异常 }- 针对
如果评委人数非常多(比如上百万),你的方法还有效吗?
- 时间复杂度 O(n) 仍然是可以接受的,遍历一次是必须的。
- 空间上,如果一次性加载所有分数到数组,内存可能压力很大。此时流式处理(不保存所有数据)是更好的选择。
- 还可以考虑将数据分片,用MapReduce的思想并行计算每个分片的总和、最大值、最小值,然后再汇总。但这对于本地程序来说过于复杂了。
把这个简单的项目吃透,背后涉及的输入输出、数据结构、算法逻辑、异常处理、代码设计、测试调试等知识点就都串联起来了。它就像一颗种子,能生长出很多编程必备的枝干。下次当你再看到“评委打分”时,希望你的思路不再局限于那几行求平均值的代码,而是能看到一个更广阔的程序设计世界。