多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java黑皮书5.9题拆解:从最高分到前两名,一次遍历搞定Top2

Java黑皮书5.9题拆解:从最高分到前两名,一次遍历搞定Top2 如果你翻过Java入门那本封皮发黑、厚得像砖头的《Java程序设计教程》就是大家习惯叫的Java黑皮书那么第5章课后题5.9你大概率是有印象的。这道题表面上就是个找最高分学生的小练习但真正写起来很多人在第一步到底是显示最高分学生还是显示前两个学生上就已经卡住了。我第一次做这道题时是在IDE里删了写、写了删最后发现自己对前两个这三个字的理解跟教材原意完全不一样。今天就把这道题的完整拆解、常见实现思路、边界情况处理以及它和Java面试里求数组第二大元素的变形关系一次性讲清楚。无论是正在啃黑皮书的初学者还是想巩固基础编程逻辑的读者这篇都值得收藏。1. 题意拆解这道题藏着一个容易误读的逻辑陷阱1.1 原题到底在问什么教材原文的大致意思是编写程序提示用户输入学生个数然后再依次输入每个学生的名字和分数最后显示获得最高分的学生。这里有个关键矛盾——中文版标题写的是找出得最高分的前两个学生而题面正文又写的是最后显示获得最高分的学生这两句话指向并不完全一致。为什么会造成这种歧义因为英文原版这道题其实要求输入N个学生后同时找出最高分和次高分并按名次显示前两个学生。中文翻译时标题保留了前两个的说法正文却漏译成了只显示一个学生。这种不一致在教材里并不少见所以做题之前必须先搞清楚你要实现哪个版本。我个人的建议是把两个版本都实现一遍。只找最高分的版本用来理解扫描最大值的基础逻辑找前两名版本的用来理解双变量追踪的进阶思路。后面第3节会详细展开这两种写法的差异。1.2 前两个学生的三种合理解读写代码之前先把前两个这个需求拆开看因为不同理解会得到完全不同的程序逻辑只显示最高分学生最简单读入所有学生数据遍历一遍找出最高分对应的名字即可。如果只看这一个需求甚至连数组都不用存。显示最高分和次高分学生需要记录两个学生的名字和分数遍历时同时维护两个最值变量这种写法更贴近英文原题。显示得分最高的前两名允许并列如果两个学生分数相同是都算第一名还是先来后到后一种情况要额外定义并列规则初学者最容易在这个点上翻车。下面所有实现都以第二种理解为主因为这才是黑皮书原题的真正考点也和找出得最高分的前两个学生这个标题最贴合。1.3 这道题在整本书里的位置第5章讲的是循环但这道题其实已经提前用上了数组和最值扫描的思想。它处在从单纯循环语法过渡到数组处理之间是个承上启下的典型题目。黑皮书喜欢这么设计题目看起来只用了刚才学过的循环但如果你不动脑写出来的代码会很笨拙如果动脑就会自然引出下一章数组的知识点。这道题对应到真实开发场景其实就是在一个数据集合里找Top-N的雏形。现在面试题里常见的求数组第二大值求Top K个最大值追根溯源都能落到这题上。所以别小看5.9刷透它能省后面不少事。2. 从零到一只用数组和循环写出最直白的版本2.1 为什么第一版最好用数组把数据存起来先说一个很多人问的问题既然只显示最高分学生为什么非要先用数组把所有名字和分数存一遍一边输入一边记录当前最高分不就行了吗答案是可以但那不是这道题最好的练习方式。一边输入一边更新代码更短但实际很难扩展——一旦需求从找最高分变成找前两个最高分或者按分数排序输出你就得重写。用数组先存起来等于把数据采集和数据处理拆成了两个阶段这是更工程化的思维方式也为后面第3节的双变量追踪代码打好了基础。另外黑皮书这道题本身的位置就在数组预热阶段用数组实现最贴合教材意图。真到实际项目里处理这种逻辑时我一般会用集合类而不是数组但那是第5节的事现在先把基础版本写扎实。2.2 核心代码先存数据再找最大值下面这段代码是只找最高分学生的版本用两个数组分别存名字和分数import java.util.Scanner; public class FindTopStudent { public static void main(String[] args) { Scanner input new Scanner(System.in); System.out.print(请输入学生个数); int numOfStudents input.nextInt(); input.nextLine(); // 消费掉nextInt留下的换行符 String[] names new String[numOfStudents]; double[] scores new double[numOfStudents]; for (int i 0; i numOfStudents; i) { System.out.print(请输入第 (i 1) 个学生的姓名); names[i] input.nextLine(); System.out.print(请输入该学生的成绩); scores[i] input.nextDouble(); input.nextLine(); // 消费掉换行符 } double maxScore scores[0]; int maxIndex 0; for (int i 1; i numOfStudents; i) { if (scores[i] maxScore) { maxScore scores[i]; maxIndex i; } } System.out.println(最高分学生 names[maxIndex] 成绩 scores[maxIndex]); } }这段代码看起来简单但有几个细节值得反复琢磨。2.3 容易被忽略的三个细节第一个细节是input.nextLine()的消费换行符操作。当你用nextInt()读完学生个数后输入缓冲区里其实还残留一个换行符。如果不先input.nextLine()把它读掉紧接着第一次执行names[i] input.nextLine()时读到的是一个空字符串。这个坑Java初学者十个人里有九个会踩而且踩完还不知道为什么。第二个细节是maxScore的初始化。很多初学写法喜欢把maxScore初始化为0但这在分数允许为负数时会出问题。比如所有学生都考了-10分一个学生考了-5分那-5比0小永远不会进入if分支算法直接失效。最稳妥的做法是用double maxScore scores[0]来初始化循环从i1开始这样不管分数正负都能正确工作。第三个细节是用数组下标记录而不是记录名字本身。当找到新的最大值时我们不直接更新maxName字符串而是更新maxIndex最后才通过names[maxIndex]取名字。这样做的优势是如果后续还需要分数、学号、班级等其他信息用同一个下标就能全部取到。只存名字的做法虽然也能跑但信息丢失很严重。3. 真正的考点来了一次遍历同时找最高分和次高分3.1 两种典型的找前两名思路如果需求明确了要显示得分最高的前两个学生怎么实现大部分人能想到两种思路各有各的特点。思路A复制数组法。先复制一份分数数组找到最高分后把这个最高分标记为负无穷或从数组中删除再在剩下的数据里找最高分这个剩下数据的最高分就是次高分。这种思路符合直觉代码好写但至少要遍历数组两遍而且还要额外处理复制数组和删除元素两个步骤空间和时间开销都不小。思路B双变量追踪法。只遍历一遍数组同时维护两个变量当前最高分和当前次高分。每读到一个新分数先判断它是否比最高分大如果比最高分大就把原来的最高分降级为次高分如果只比次高分大就更新次高分。这种思路更优雅、效率更高也是面试官最想看到的解法。下面重点展开思路B因为这才是这道题从入门练习升级到面试考点的关键。3.2 双变量追踪法完整实现import java.util.Scanner; public class FindTopTwoStudents { public static void main(String[] args) { Scanner input new Scanner(System.in); System.out.print(请输入学生个数); int numOfStudents input.nextInt(); input.nextLine(); String[] names new String[numOfStudents]; double[] scores new double[numOfStudents]; for (int i 0; i numOfStudents; i) { System.out.print(请输入第 (i 1) 个学生的姓名); names[i] input.nextLine(); System.out.print(请输入该学生的成绩); scores[i] input.nextDouble(); input.nextLine(); } // 初始化先假定第一个学生是最高分次高分暂时不存在 String firstName names[0]; double firstScore scores[0]; String secondName null; double secondScore Double.NEGATIVE_INFINITY; for (int i 1; i numOfStudents; i) { if (scores[i] firstScore) { // 新来的分数比第一高原来的第一降为第二 secondScore firstScore; secondName firstName; firstScore scores[i]; firstName names[i]; } else if (scores[i] secondScore) { // 新来的分数介于第一和第二之间更新第二 secondScore scores[i]; secondName names[i]; } } System.out.println(最高分学生 firstName 成绩 firstScore); System.out.println(次高分学生 secondName 成绩 secondScore); } }这段代码严格按照先比较第一再比较第二的顺序执行一共只遍历了一次数组时间复杂度和只找最高分完全一样都是O(n)。3.3 为什么secondScore要初始化为负无穷这是双变量追踪法里最关键的一个设计决定。次高分变量在程序开始时还没有任何意义——因为只有一个学生时根本不存在次高分。但循环里会出现else if (scores[i] secondScore)这样的比较如果secondScore初始值设不好前几个数据的判断就会出错。把secondScore初始化为Double.NEGATIVE_INFINITY负无穷意思是当前还没有一个有效的次高分。这样任意一个真实分数只要介于firstScore之下都比负无穷大能正确被记录为第二。你也可以用一个足够小的数比如-1e9来模拟这个效果但直接用Double.NEGATIVE_INFINITY既干净又不会出现溢出的隐患。有一个容易出错的替代写法是把secondScore初值设成scores[0]。表面看没有问题实际遇到第一个学生恰好是最高分时就会出错循环到第二个学生的分数比第一个低scores[1] secondScore为false程序就漏掉了第二名的记录。负无穷初值规避了这类初值污染算法的经典坑。3.4 成绩完全相同时这个程序会怎么表现假设五个学生成绩分别是90、85、90、78、92双变量追踪法的表现是初始化first 90学生Asecond 负无穷读到85不更新first更新second为85读到9090 90为false90 85为truesecond更新为90学生C读到78无更新读到9292 90为truefirst更新为92学生Esecond更新为90学生A最终输出最高分92学生E次高分90学生A。注意这里“次高分”取的是先遇到的90分学生A而不是后遇到的90分学生C——因为用的是严格大于号相同分数的后来者不会被记录成第二名。如果希望后遇到的90替代先遇到的90可以把else if里的改成。这个需求细节没有标准答案完全取决于业务规则但你必须清楚自己的代码在并列时是哪种行为。4. 边界条件和测试用例别让程序在输入阶段就崩了4.1 输入阶段最经典的坑nextInt和nextLine混用第2节代码里多次出现了input.nextLine()这个不是多余的而是处理Scanner缓冲区问题的标准操作。我把这个坑单独拿出来讲因为它在考试、作业、面试手写代码里出现的频率太高了。问题是这样的nextInt()只读取整数不会读取后面的换行符。当用户输入5并按下回车时缓冲区里是5\nnextInt()只取走了5\n还留在缓冲区。紧接着如果程序调用nextLine()它会直接读到这个残留的\n返回空字符串。很多人第一次跑程序时会发现第一个学生的名字“没有输入就直接跳过了”就是这个原因。解决办法就是在nextInt()后紧接一个input.nextLine()来“消费”掉换行符。这件事没有任何技术含量但不知道就是不知道偶尔还会造成严重的精神内耗。4.2 学生个数为0或负数怎么办如果用户输入的学生个数是0new String[0]创建的数组长度为0程序执行到double maxScore scores[0]时会抛出ArrayIndexOutOfBoundsException。负数也一样数组长度不能为负直接运行报错。在教材作业阶段一般不需要处理这种极端输入但如果你想写出更健壮的程序可以在读取个数后加一个判断if (numOfStudents 0) { System.out.println(学生个数必须大于0); return; // 或者 System.exit(1); }这样程序至少不会在非法输入时崩溃而是给出明确提示。这已经是真实开发中防御式编程的初步形态了。4.3 成绩输入为负数时的表现教材里的成绩通常默认为正数但真实情况里可能有负分比如竞赛扣分制。前面已经说过maxScore用scores[0]初始化可以应对负分。双变量追踪版本里secondScore初始化为负无穷也能应对。如果你的代码把maxScore初始化为0一旦所有成绩都是负数算法就会把一个虚构的0分当成最高分输出完全错误的结果。所以我的建议是从一开始就养成用数据里的第一个元素初始化最值变量的习惯不要贪图方便写 0。这个习惯会在处理其他类似问题时帮你省掉大量调试时间。4.4 完整测试用例参考下面是我跑这道题时通常会覆盖的测试用例你可以直接拿来用测试场景输入预期输出常规情况3个学生成绩60、80、70最高分学生280分次高分学生370分成绩全为负数2个学生-5、-10最高分-5分那个学生第一名有两个并列3个学生90、90、80按严格大于号最高分90次高分90先遇到的那个只有1个学生1个学生成绩77最高分77次高分为null学生个数为00程序报错或通过防御代码提示非法只有1个学生的场景值得多说一句在双变量追踪版本里secondName是nullsecondScore是负无穷所以最后输出次高分学生null成绩-Infinity。这在业务上是不合理的但在教材范围内可以接受。如果想让程序更友好可以在输出前判断secondName null然后打印没有第二名。5. 从课后题到实战这题目在真实项目里的几种变形5.1 用对象代替两个平行数组黑皮书这道题用两个数组分别存名字和分数在教学上是合理的但到了真实项目里更规范的做法是定义一个学生类把名字和分数封装在一起class Student { String name; double score; Student(String name, double score) { this.name name; this.score score; } }然后用Student[] students new Student[numOfStudents]或ArrayListStudent来存储。这样在排序、查找、显示时不需要同时维护两个数组的下标同步关系代码更清晰也不容易出现两个数组长度不一致这种低级错误。我在实际项目中几乎从不用平行数组存关联数据用对象数组/集合才是主流。5.2 如果需求变成输出前三名双变量追踪法可以很方便地扩展到Top-N但如果你要前三名甚至前五名再写三五个变量就会变得很痛苦。这时候更好的做法是用数组存前N名的结果或者直接引入PriorityQueue优先队列做Top-K问题。面试里经常考的在一万个数字里找出最大的三个本质就是这道题的加强版。初学阶段先把双变量的写法吃透之后再学堆和优先队列就会觉得水到渠成。我见过很多学了优先队列的人回过头来不会写双变量追踪——这就是基本功不牢的典型表现。双变量追踪是手工实现Top2优先队列是工具实现TopK两者互相验证绝不是谁替代谁的关系。5.3 用数组排序来解这道题还有一种取巧的解法把学生按分数降序排序然后取前两个。用Arrays.sort加自定义Comparator可以做到但这里要说明一点排序解法的时间复杂度是O(n log n)而双变量追踪是O(n)。数据量小看不出差别数据量一大就差出几个数量级。所以排序解法虽然代码更短但在算法层面并不是最优解。黑皮书把这个题放在循环章节而不是排序章节目的本身就不是让你排序而是让你体会如何在一次遍历中同时做两个判断。如果把题做成了排序就失去了这题原汁原味的训练价值。5.4 真实项目里的前两名往往比这个复杂在实际项目中找最高分的前两个学生通常不是一个孤立需求。它会带上一堆附加条件只统计某个班级的学生、只统计及格线以上的成绩、分数相同时按照学号排序……这些条件会让代码复杂度直线上升。但无论怎么变核心算法依然是扫描一次维护最值状态。在我自己写过的评分系统里这个逻辑通常会被拆成一个独立方法输入一个学生列表输出一个包含前两名对象的数组。这样做的好处是业务逻辑和输入输出完全分离将来要加第三名、加并列策略、加分班级过滤都只需要改这一个方法。Black Book的课后题是玩具但把玩具扩展成工程的能力恰恰是面试官想看到的。写在最后几个让我印象深刻的编码习惯从这道5.9题延伸开来其实能提炼出几个很实用的编码习惯。第一凡是初始化一个最值变量优先用数据中的第一个元素而不是拍脑袋写个0或9999这能让代码天然免疫正负边界问题。第二Scanner的nextInt()和nextLine()混用时要警惕残留换行符这个坑我今天写出来一次希望你能少踩一次。第三凡是遇到前N个最强/最大/最近的需求先停下来想一下——你需要的只是一个能跑的结果还是一个能在数据规模扩大后依然扛得住的方案如果答案是后者双变量追踪和优先队列这些基本功就绕不过去。这道黑皮书课后题看起来平平无奇但它从只有一个最高分到前两个学生再到Top-N问题每一层递进都在反复训练同一个底层能力对一组数据做有状态的扫描。熟练掌握这个能力之后你会发现很多所谓的高难度面试题拆开来看都是在这些基础逻辑上套了一层更复杂的壳而已。
返回列表