Java黑皮书5.9经典题:单循环滚动更新求前两名

发布时间:2026/10/5 11:34:27
Java黑皮书5.9经典题:单循环滚动更新求前两名 读过Java黑皮书的同学大概率对第5章课后题5.9有印象输入学生个数、每个学生的名字和分数最后找出得分最高的前两个学生。题目读起来平淡无奇却是无数初学者第一次真正被状态维护绊倒的地方。我在带学生的过程中看过太多程序能跑但结果不对的提问十有八九就栽在这章这类题上。这篇就把这道题从需求到实现、从边界测试到扩展变形完整拆一遍适合刚学完循环的入门读者也适合想巩固基础算法的老手。先交代一下这道题的背景。它出自梁勇的《Java程序设计教程》因为封面是黑色的在国内被通称为Java黑皮书是很多高校Java课程的指定教材。第5章讲的是循环而5.9这道题故意放在数组知识之前目的很明确希望你别靠数组和排序而是用一个循环边读边维护冠军和亚军两个状态把前两名找出来。1. 题目到底在考什么把需求翻译成程序语言1.1 表面需求与隐藏考点这道题表面上的需求有三条第一用Scanner提示用户输入学生个数n第二循环n次每次都读一个姓名和一个分数第三循环结束后输出分数最高和第二高的学生姓名与分数。单看这三条感觉就是个比大小的问题。但如果你只盯着表面需求很容易写出先把所有数据存下来最后再比较的实现——也就是用数组存n个姓名和n个分数排序后再取前两个。逻辑上没错结果也对但这么做恰恰避开了这道题真正的考点。梁勇把这道题放在第5章而数组是第6章以后才讲的内容这说明作者的意图非常明确在不用数组的前提下通过一个循环把存储和比较两个问题同时解决。你要学会的是滚动更新的能力——每读一条数据立刻跟当前的冠军、亚军比较然后更新状态。整个过程不需要记住其他任何学生的信息内存占用和n无关永远是O(1)。很多第一次做这道题的同学会陷入一个误区非要先把所有数据存起来再慢慢比。我能理解这种冲动人脑在处理多个人名和分数时天然倾向于列一个表再看。但程序不一样程序最擅长的事情恰恰是来一条处理一条。这个思维模式上的转变比这道题的语法本身更重要也是你从会写循环走向会用循环解决问题的关键一步。1.2 为什么先存再排不是最优解有同学会问我都知道数组了为什么非要按教材的节奏来确实用数组实现也能得出正确答案而且直觉上更接近人脑理解问题的方式先开两个数组一个存姓名一个存分数全部读完后按分数排序最后取排序后的第一、第二个元素输出。这种方法最典型的代价是排序的时间复杂度——全排序是O(n log n)而这道题只需要前两个用单趟循环只要O(n)。当学生数量从5个变成500万个时全排序的额外开销会被放大得很明显。更不巧的是如果题目改成流式输入也就是数据不是一次性给全而是实时到达那先存再排就彻底失效了因为数据量可能大到根本存不下。我特别强调这一点是想让你明白用数组不是说错而是用错了场景。等你以后处理日志、传感器数据、用户点击流时会发现很多场景下数据都是一条一条到达的根本没有先存完再处理的机会。单趟循环加滚动更新应该是你面对这类数据的第一反应。2. 核心逻辑详解两个变量如何撑起全天候排名2.1 初始化是第一步用极端值占位先想一个问题要记录当前最高分和当前第二高分最少需要几个变量正确答案是四个最高分学生的姓名、最高分分数、第二高分学生姓名、第二高分分数。注意姓名和分数是捆绑的一组状态不能只存分数不存姓名否则最后输出时找不到人。初始化怎么设很多第一次写的人会写int highestScore 0;和int secondHighestScore 0;。这看上去没问题毕竟学生分数一般在0到100之间。但万一输入了负数分数比如竞赛里的扣分制或者数据录入错误这个初值就会让最高分为负数的选手永远比不过0结果直接出错。更稳妥的写法是把初值设成整数类型的最小值int highestScore Integer.MIN_VALUE;和int secondHighestScore Integer.MIN_VALUE;。这样无论来什么分数第一次比较都会被正确覆盖。这个习惯非常重要在从数组里找最大最小值初始化比较基准等场景里是通用的。举个例子如果你写过找最小值的循环初值通常也会设为Integer.MAX_VALUE原理完全一样。简单记当你不知道数据范围时就拿类型极值当空位占位别用业务上的常理去猜。2.2 状态更新的三种情况循环里每读进一个新学生的成绩就可能发生三种情况之一情况A新成绩比当前最高分还高。新学生上位成为新的最高分原来的最高分自动降级为第二高分。这里最容易踩坑很多人只更新了最高分忘了把老冠军挪到第二的位置结果第二高分永远留在初始值。情况B新成绩没超过最高分但超过了当前第二高分。最高分不动只把第二高分替换成新学生。情况C新成绩比第二高分还低或者等于第二高分。什么都不用做这个学生排名在前两名之外。翻译成代码就是经典的if-else结构。这里必须强调两个if之间要用else连接不能写成两个独立的if。如果去掉else当一个新成绩同时超过最高分和第二最高分时第一个if已经把它设为最高分了第二个if又把它设为第二最高分同一个学生同时占两个名次亚军就被污染了。为了把状态变化看清楚我强烈建议初学者用纸笔把下面这个表格走一遍。我用一组真实数据演示张三89、李四93、王五76、赵六91。当前输入读取前状态最高 / 第二判断过程读取后状态最高 / 第二张三89MIN / MIN89 MIN命中情况A张三89 / 空李四93张三89 / 空93 89命中情况A李四93 / 张三89王五76李四93 / 张三8976不大于93也不大于89命中情况C李四93 / 张三89赵六91李四93 / 张三8991不大于93但大于89命中情况B李四93 / 赵六91重点看第二行当李四以93分刷新纪录时原先的冠军张三自动降级成了亚军。如果代码里漏掉降级两步赵六最后拿到的亚军位置就会被残留的空值顶掉程序输出第二名为空。这一行代码就是整道题的心脏。2.3 并列分数怎么办关于 和 的差异再看一个容易被忽略的细节新成绩跟当前最高分相等算不算超过最高分这取决于你用大于还是大于等于做判断。如果用if (score highestScore)那么并列最高分的学生会被放到第二高位置因为它通常大于当前第二高分最高分保持不变。如果三个人都是90分处理结果是第一个90分成为最高分第二个90分成为第二高分第三个90分因为既不大于最高分、也不大于第二高分被忽略。输出仍然是正确的两个前两名符合题意。如果用if (score highestScore)来处理并列就会出问题第二个90分把第一个90分挤到第二第三个90分又把第二个90分挤到第二。如果同分的人数很多更早的同分记录会被不断覆盖看上去像后来者居上。虽然最终输出的仍是两个90分的人但丢失了先到者优先的公平性行为也比较反直觉。所以我的建议是统一用严格大于让先出现的学生稳定待在高位。这个细节在写排名系统时经常碰到值得提前养成习惯。3. 完整代码与运行实测3.1 标准解法完整代码下面给出完整的可运行代码。我把输入提示写得友好一些方便你直接复制进IDE测试。import java.util.Scanner; public class Exercise05_09 { public static void main(String[] args) { Scanner input new Scanner(System.in); // 1. 读取学生个数 System.out.print(请输入学生个数: ); int numOfStudents input.nextInt(); // 2. 边界检查少于2人无法产生前两名 if (numOfStudents 2) { System.out.println(学生个数至少为2程序结束。); System.exit(1); } // 3. 用四个变量记录当前冠军和亚军的状态 String highestName ; String secondHighestName ; int highestScore Integer.MIN_VALUE; int secondHighestScore Integer.MIN_VALUE; // 4. 单趟循环边读边更新 for (int i 1; i numOfStudents; i) { System.out.print(请输入第 i 个学生的名字: ); String name input.next(); System.out.print(请输入 name 的分数: ); int score input.nextInt(); if (score highestScore) { secondHighestScore highestScore; secondHighestName highestName; highestScore score; highestName name; } else if (score secondHighestScore) { secondHighestScore score; secondHighestName name; } } // 5. 输出结果 System.out.println(最高分学生: highestName 分数: highestScore); System.out.println(第二高分学生: secondHighestName 分数: secondHighestScore); } }几个细节说清楚。第一循环我写成for (int i 1; i numOfStudents; i)这样提示语能直接显示请输入第1个学生而不是第0个学生。从0开始是程序员习惯从1开始更符合用户直觉两者逻辑完全等价。第二System.exit(1)在输入个数不合法时直接终止程序返回非0状态码表示异常退出。如果你还没学到这个写法换成return也一样因为从main方法返回就会结束程序。第三我用了input.next()读取姓名默认以空白符作为分隔教材习题里单英文单词名字够用名字带空格的情况见第4.2节。3.2 运行效果与边界测试我用JDK 17实测了这段代码正常输入下的运行效果如下请输入学生个数: 4 请输入第1个学生的名字: ZhangSan 请输入ZhangSan的分数: 89 请输入第2个学生的名字: LiSi 请输入LiSi的分数: 93 请输入第3个学生的名字: WangWu 请输入WangWu的分数: 76 请输入第4个学生的名字: ZhaoLiu 请输入ZhaoLiu的分数: 91 最高分学生: LiSi分数: 93 第二高分学生: ZhaoLiu分数: 91边界情况我也全部跑过输入2个学生时正常输出两个名次输入1个学生时程序提示学生个数至少为2并退出不会输出空名字输入负数分数时两个初值都是Integer.MIN_VALUE负数也能正确上榜三人同分并列最高时输出前两个输入者后面的人不会覆盖前者行为符合预期。这种提前把边界情况想清楚的习惯就是防御性编程。一道题能不能处理好1个学生、负数分数、并列分数这些边缘输入往往就是60分和90分的分水岭。测试的时候别只拿一组好人好数据跑一遍就完事至少要把正常、最小边界、异常数据三个方向都试一遍。3.3 用数组和排序的另一条路延伸对照前面说过用数组不是这章的正解但它是个有价值的对照。如果你想直观感受两种写法的差异可以参考下面这个版本import java.util.Scanner; public class Exercise05_09ArrayVersion { public static void main(String[] args) { Scanner input new Scanner(System.in); System.out.print(请输入学生个数: ); int n input.nextInt(); String[] names new String[n]; int[] scores new int[n]; for (int i 0; i n; i) { System.out.print(请输入第 (i 1) 个学生的名字: ); names[i] input.next(); System.out.print(请输入分数: ); scores[i] input.nextInt(); } // 选择排序每一轮把未排序部分的最大值交换到前面 for (int i 0; i n - 1; i) { int maxIndex i; for (int j i 1; j n; j) { if (scores[j] scores[maxIndex]) { maxIndex j; } } int tempScore scores[i]; scores[i] scores[maxIndex]; scores[maxIndex] tempScore; String tempName names[i]; names[i] names[maxIndex]; names[maxIndex] tempName; } System.out.println(最高分: names[0] 分数: scores[0]); System.out.println(第二高分: names[1] 分数: scores[1]); } }这个版本思路直白但有两个明显的代价一是额外的数组内存二是先排序再取前二排序是O(n log n)而单趟循环是O(n)。当输入量巨大或数据是实时流动时这个方案就不适用了。我把它列出来是想让你看清楚存下来排和边读边记在思路和开销上的差别而不是建议你把它当常规答案。4. 常见问题与踩坑实录4.1 高频翻车现场速查表这些年我看过不少人在5.9这道题上交出各种错法把最高频的几种整理成一张表方便你对号入座典型错误现象根本原因解决方案只更新最高分不更新第二高分亚军输出为空或初始值忘记冠军降级这一步情况A里先降级再更新冠军初值设成0负数分数永远选不中初值覆盖了合法数据区间用Integer.MIN_VALUE初始化两个独立的if并列同一个学生同时是冠军和亚军第二个if也会命中高分记录改成if-else结构提示输出第0个学生用户提示不友好循环变量从0开始直接拼接从1开始循环或输出时i1输入个数后读到空名字第一个名字总为空串nextInt()留下了换行符用nextLine()吞掉换行或改用next()用判断并列最高分多个同分时更早的人被顶掉等于被当成超过处理统一用严格大于最后一行值得多说两句。看上去只是比多容忍了一个等号但在并列数据多的时候行为差异非常明显。如果你希望输出的是最先到达的两位最高分同学用严格大于更稳如果你有意做成后到者占先那才考虑大于等于但要确保你的测试用例能覆盖这种语义。4.2 名字带空格与换行符陷阱教材习题里学生名字都是单词比如ZhangSan、LiSi用input.next()没问题。但如果你把程序改成中文环境或者测试Zhang San这种带空格的名字next()只会读到空格前的部分后面的残留词会干扰下一次读取把分数输入搞乱。要支持带空格的名字需要两个改动。一是读取名字改用input.nextLine()二是在nextInt()之后单独调用一次input.nextLine()把换行符消费掉否则nextLine()会直接返回空串。示例结构如下int numOfStudents input.nextInt(); input.nextLine(); // 吃掉nextInt()留在缓冲区的换行符 for (int i 1; i numOfStudents; i) { System.out.print(请输入第 i 个学生的名字: ); String name input.nextLine(); // 可以读取Zhang San System.out.print(请输入分数: ); int score input.nextInt(); input.nextLine(); // 再消费一次换行符避免影响下一轮 }这块是Java初学者的老大难本质原因是Scanner的nextInt、nextLine混用时缓冲区里残留的换行符会被nextLine当成本次内容。记住口诀读数字后手动吞一次换行能少踩很多坑。4.3 调试方法把中间状态打出来看如果你改完代码结果还是不对我推荐一个最朴素的调试方法在循环末尾临时加一行System.out把当前最高分、第二高分打印出来观察状态是否按预期滚动。比如System.out.println(处理后: 最高 highestName ( highestScore ), 第二 secondHighestName ( secondHighestScore ));很多逻辑错误比如冠军没有降级亚军被同一个人占两次这类问题通过这一行中间输出会立刻现形。定位完问题记得把调试输出删掉或者注释掉再提交干净版本。另外一个常见的清理技巧如果你在用IDE可以直接在if分支里打断点逐行观察变量变化这比靠眼睛瞪代码高效得多。5. 变体与扩展一道题吃透Top-K问题5.1 同类变形最低分、平均分、总成绩学会这题后你会发现同一章后面的很多题其实就是换皮。把换成找最高分就变成找最低分再加一个sum变量在循环里累加循环结束时除以学生个数就得到平均分。核心思路完全一样单趟循环加滚动状态没有任何新语法要学。我建议你别只做老师留的那一两道题而是把第5章这堆循环题一鼓作气刷完。找最大值、找第二最大值、求平均值、判断回文、统计字符个数全都在循环加状态这套框架里。你写得越多对循环体里放什么、状态怎么变的感觉就越准后面学数组、集合、递归的时候会省很多力。5.2 从前两名到前K名的进阶思路最后做个升华。当前两个学生变成前K个学生难度明显提升。如果K很小比如K5你可以维护一个长度为K的数组每来一个分数就插入合适位置把最小的挤掉这就是小顶堆的朴素版本。如果K较大或者输入量极大直接上Java里的PriorityQueue这是个现成的堆结构复杂度O(n log K)比全排序O(n log n)划算得多。核心思想和本题一样只保留你要的那部分状态不用管排名靠后的人。很多面试题比如找数组里前K大的数处理流式数据的中位数都能追溯到你现在写的这几行if-else上。我在实际带项目的过程中发现能把这种基础题讲透的人写起业务逻辑来状态管理通常也不容易乱因为底层都是对什么该留、什么该丢的判断。所以别觉得课后题简单就不屑于写我直到现在写Top-K的代码时偶尔还会想起这道5.9教我的那个教训先想清楚状态怎么流动再动手写循环。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询