三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Java在线笔试输入输出优化:从Scanner到BufferedReader的性能抉择

Java在线笔试输入输出优化:从Scanner到BufferedReader的性能抉择

1. 从“本地IDE”到“在线笔试”:为什么你的Java代码在牛客上跑不起来?

如果你和我一样,是从学生时代或者日常项目开发中走过来的Java程序员,第一次接触牛客网、赛码网这类在线笔试平台时,大概率会栽在第一个坑里:输入输出。这太正常了。我们平时在IntelliJ IDEA或者Eclipse里写代码,运行一个main方法,要么是直接读取项目里的文件,要么是弹出一个控制台,等着我们手动输入。这种交互模式,我称之为“对话式”或“文件式”开发。

但笔试平台完全是另一套逻辑。它更像是一个黑盒自动化评测系统。你的代码被编译后,会被一个评测机(Judge)执行。评测机会预先准备好若干组测试数据(通常是文本格式),作为标准输入(System.in)喂给你的程序。然后,它会捕获你程序的标准输出(System.out),并逐字节地与标准答案进行比对。任何多余的输出,比如你调试时习惯性打印的System.out.println(“请输入:”),或者输出格式里多了一个空格、少了一个换行,都会导致“答案错误”或者“格式错误”。

所以,笔试中的输入输出,核心是让你的程序能正确、高效地处理来自System.in的、格式固定的数据流,并将结果以严格符合要求的格式输出到System.out。这不仅仅是语法问题,更是思维模式的切换。下面这张表清晰地展示了这种差异:

场景输入来源输出目标交互方式典型错误
本地开发/IDE手动键盘输入、项目内文件、GUI输入框控制台、日志文件、GUI界面交互式或文件读取,格式灵活无(开发者自行验证)
在线笔试/机试评测机通过System.in注入的预设数据流评测机从System.out捕获输出完全非交互,格式必须严格匹配多/少打印提示语、行尾空格、格式不符

明白了这个根本区别,我们再来系统性地拆解Java在处理这类输入输出时的工具箱。核心就是两个类:ScannerBufferedReader。很多人只知道Scanner,觉得它简单,但一到处理大数据量或者复杂格式就超时,这就是没理解它们各自的“性能性格”和适用场景。

2. 两大核心工具:Scanner与BufferedReader的深度抉择

在Java中,从标准输入读取数据,主流就是ScannerBufferedReader。选择哪一个,直接决定了你程序在笔试中的性能和稳定性。

2.1 Scanner:便捷但“沉重”的瑞士军刀

Scanner是基于正则表达式进行令牌(Token)解析的。它用起来确实方便,nextInt(),nextDouble(),next(), 甚至nextLine(), 感觉什么都能干。

import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int a = sc.nextInt(); double b = sc.nextDouble(); String c = sc.next(); // 读取下一个以空白符分隔的字符串 sc.nextLine(); // 读取掉数字后面的换行符,这是一个经典坑点 String line = sc.nextLine(); // 读取一整行 sc.close(); } }

它的优点很明显:

  1. 类型直接转换nextInt()直接返回int,无需手动Integer.parseInt
  2. 令牌化读取:自动按空白符(空格、制表符、换行)分割输入,适合格式规整的数据。

但它的缺点在笔试中可能是致命的:

  1. 性能开销大:由于内部使用正则表达式解析,其速度远慢于BufferedReader。当输入数据量达到10^5级别或以上时,使用Scanner很容易导致超时(Time Limit Exceeded, TLE)。
  2. 缓存机制不同Scanner的缓存较小,频繁的底层IO操作也会拖慢速度。
  3. nextLine()的陷阱:这是最经典的坑。nextInt()这类方法不会消费数字后面的换行符\n。当你紧接着调用nextLine()时,它会立刻读取到那个被剩下的换行符,从而得到一个空字符串。解决方案是在nextInt()后额外调用一次sc.nextLine()来“吞掉”这个换行符。

个人经验:在早期或数据量小的笔试中,用Scanner图个快没问题。但一旦题目涉及大量数据读入(比如图论的边数、数组元素),我会毫不犹豫地切换到BufferedReader。养成看题目数据范围的习惯,如果nm超过10^4,就别用Scanner了。

2.2 BufferedReader:为性能而生的“老黄牛”

BufferedReader是纯粹的字符流读取器,它配备了默认8KB的缓冲区,一次性从流中读取一大块数据到内存,后续读取都直接从内存中的缓冲区获取,极大地减少了底层IO系统调用的次数,这是它高性能的根源。

它本身只提供read()readLine()方法,所以通常需要配合String.split()StringTokenizer来进行分割。

import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); // 读取一行,并按空格分割 String[] firstLine = br.readLine().split(" "); int n = Integer.parseInt(firstLine[0]); int m = Integer.parseInt(firstLine[1]); // 读取多行数据 int[] array = new int[n]; for (int i = 0; i < n; i++) { array[i] = Integer.parseInt(br.readLine()); // 每行一个数字 } // 对于一行内有很多数字的情况,使用StringTokenizer效率更高 String line = br.readLine(); java.util.StringTokenizer st = new java.util.StringTokenizer(line); while (st.hasMoreTokens()) { int num = Integer.parseInt(st.nextToken()); // ... 处理num } } }

为什么在笔试中更推荐BufferedReader?

  1. 速度碾压:在处理大规模数据时,其速度可以是Scanner的5-10倍,是避免TLE的利器。
  2. 内存可控:你可以通过构造函数指定缓冲区大小(虽然通常不需要)。
  3. 无类型解析开销:它只负责读字符串,类型转换由你控制,更透明。

它的“麻烦”之处:

  1. 需要处理IOException,通常直接在main方法后throws IOException
  2. 所有数据都需要手动解析(Integer.parseInt,Double.parseDouble),但这反而避免了Scanner的一些隐式错误。
  3. 需要额外处理分割逻辑,对于复杂格式,代码会稍显冗长。

关于StringTokenizer:这是一个专门用于分割字符串的古老类,比String.split()(基于正则)在仅分割空格时效率更高。在追求极致性能的竞赛场景下,有人会用。但对于绝大多数笔试,split()的简洁性已经足够,不必过度优化。

选型结论:对于任何严肃的、可能涉及大数据量的笔试,BufferedReader作为默认选择。把Scanner仅用于快速原型验证或数据量极小的场景。

3. 高频输入模式实战:从“A+B”到复杂结构

笔试的输入格式千变万化,但归根结底可以归纳为几种经典模式。掌握这些模式的模板,能让你在看到题目时,快速写出正确的IO代码,把精力集中在算法逻辑上。

3.1 模式一:已知数据组数

这是最简单的情况。第一行一个整数T,表示测试数据的组数,后面跟着T组数据。

输入示例:

3 1 5 10 20 7 3

模板代码:

BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); int T = Integer.parseInt(br.readLine()); for (int i = 0; i < T; i++) { String[] line = br.readLine().split(" "); int a = Integer.parseInt(line[0]); int b = Integer.parseInt(line[1]); // 处理a和b,输出结果 System.out.println(a + b); }

3.2 模式二:未知数据组数,直到特定终止符

输入包含多组数据,每组数据格式相同,但没有明确组数,以特定的终止条件结束(如读到0 0)。

输入示例(每组两个数,以0 0结束):

1 2 5 3 9 0 0 0

模板代码:

BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String line; while ((line = br.readLine()) != null && !line.equals("0 0")) { String[] parts = line.split(" "); int a = Integer.parseInt(parts[0]); int b = Integer.parseInt(parts[1]); // 处理并输出 System.out.println(a + b); }

这里br.readLine() != null的判断是为了防止平台输入流意外结束,是一个好习惯。

3.3 模式三:先读数组长度,再读数组

非常常见的模式,用于读取一个数组或列表。

输入示例:

5 1 3 5 7 9

模板代码:

BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); int n = Integer.parseInt(br.readLine()); String[] strArray = br.readLine().split(" "); int[] nums = new int[n]; for (int i = 0; i < n; i++) { nums[i] = Integer.parseInt(strArray[i]); } // 如果数组元素是分行给出的,则用循环读n次 // for (int i = 0; i < n; i++) { // nums[i] = Integer.parseInt(br.readLine()); // }

3.4 模式四:二维矩阵或图的邻接表

例如,第一行n m表示n个节点m条边,后面m行每行u v表示一条边。

输入示例:

4 5 1 2 2 3 3 4 1 3 2 4

模板代码(构建邻接表):

BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String[] nm = br.readLine().split(" "); int n = Integer.parseInt(nm[0]); int m = Integer.parseInt(nm[1]); List<List<Integer>> graph = new ArrayList<>(); for (int i = 0; i <= n; i++) { // 节点编号从1开始,多开一个位置 graph.add(new ArrayList<>()); } for (int i = 0; i < m; i++) { String[] uv = br.readLine().split(" "); int u = Integer.parseInt(uv[0]); int v = Integer.parseInt(uv[1]); graph.get(u).add(v); graph.get(v).add(u); // 如果是无向图 }

3.5 模式五:混合类型与不规则格式

有时一行内包含数字和字符串,或者数据以不规则方式排列。核心思路是按行读取,然后灵活使用split()substring()或正则表达式进行解析

示例:读取“姓名 年龄 分数”

Alice 25 89.5 Bob 30 92.0
String line; while ((line = br.readLine()) != null && !line.isEmpty()) { String[] parts = line.split(" "); String name = parts[0]; int age = Integer.parseInt(parts[1]); double score = Double.parseDouble(parts[2]); // 处理... }

踩坑实录:有一次笔试,题目要求读取一个由逗号和空格混合分隔的字符串,如“apple, banana, cherry”。我下意识用了split(" "),结果“apple,”被当成了一个整体,导致后续解析错误。正确的做法是split(",\\s*"),或者用ScanneruseDelimiter方法。所以,仔细阅读题目中关于分隔符的描述,哪怕是一个标点符号。

4. 输出格式化与性能:别让打印拖了后腿

输入搞定了,输出同样有讲究。错误的输出格式是导致“格式错误”的直接原因,而低效的输出方式在需要打印大量数据时也可能成为性能瓶颈。

4.1 格式化输出:String.format与System.out.printf

当输出要求保留小数、控制宽度或对齐时,必须使用格式化输出。

  • System.out.printf: 类似于C语言的printf,直接格式化并打印。
    double value = 123.456789; System.out.printf("%.2f\n", value); // 输出: 123.46 System.out.printf("Name: %10s, Age: %03d\n", "Tom", 7); // 输出: Name: Tom, Age: 007
  • String.format: 先构建格式化字符串,再输出。更灵活,可以用于拼接。
    String result = String.format("The average is: %.4f", avg); System.out.println(result);

关键点注意换行!printf默认不换行,务必在格式字符串末尾加上\n,或者后续单独调用println()。很多格式错误就是因为少了这个换行。

4.2 高性能输出:StringBuilder的必要性

System.out.printprintln每次调用都涉及底层IO操作,频繁调用(例如在循环中打印数万行结果)会非常慢。这时,我们需要用StringBuilder在内存中构建完整的输出字符串,最后一次性打印。

低效做法:

for (int i = 0; i < 100000; i++) { System.out.println(result[i]); // 调用10万次IO,极慢! }

高效做法:

StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100000; i++) { sb.append(result[i]).append("\n"); // 在内存中拼接 } System.out.print(sb.toString()); // 仅一次IO操作

对于输出量巨大的题目(比如某些图论路径输出),这个优化可能直接让你从TLE变成AC。

4.3 输出格式的魔鬼细节

  1. 行尾空格:题目要求输出数组元素用空格分隔,最后一个元素后面不能有空格。这是一个超级高频的坑。
    // 错误示例(最后一个数后面多空格): for (int num : nums) { System.out.print(num + " "); } // 正确做法: StringBuilder sb = new StringBuilder(); for (int i = 0; i < nums.length; i++) { sb.append(nums[i]); if (i != nums.length - 1) { sb.append(" "); } } System.out.println(sb);
  2. 大小写与标点:输出“YES”还是“Yes”?“Case #1:”还是“case 1:”?必须和题目要求一字不差
  3. 多Case输出:每个测试用例的输出是否要空行分隔?通常题目会说明“每个输出占一行”或“每组输出后跟一个空行”。如果不确定,看样例输出是最准的。

5. 笔试环境下的实战技巧与避坑指南

掌握了基本方法,还需要一些实战技巧来应对考场的紧张和平台的特性。

5.1 完整的、可复用的IO工具类

我习惯在笔试开始前,先在代码区写好一个IO工具类,或者至少把BufferedReaderStringBuilder的声明写好。这能节省时间,避免手忙脚乱。

import java.io.*; import java.util.*; public class Main { // 快速输入输出模板 static BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); static PrintWriter out = new PrintWriter(System.out); // 也可以用PrintWriter,性能稍好 static StringTokenizer st; static String next() throws IOException { while (st == null || !st.hasMoreTokens()) { st = new StringTokenizer(br.readLine()); } return st.nextToken(); } static int nextInt() throws IOException { return Integer.parseInt(next()); } static long nextLong() throws IOException { return Long.parseLong(next()); } public static void main(String[] args) throws IOException { // 使用nextInt(), nextLong()快速读取 int n = nextInt(); int m = nextInt(); // ... 解题逻辑 out.flush(); // 如果用PrintWriter,需要flush } }

这个模板集成了BufferedReaderStringTokenizer,提供了快速读取基本类型的方法。注意,PrintWriter需要最后flush()

5.2 处理“无输入”和边界条件

  • 何时停止读取?最稳健的循环条件是while ((line = br.readLine()) != null)。这在本地调试时,需要手动触发EOF(在Windows命令行按Ctrl+Z,在Unix-like系统或IDE按Ctrl+D)。
  • 空行和多余空格:有些平台提供的测试数据,行末可能有空格,或者两组数据间有空行。使用trim()方法可以去除字符串首尾的空白符,提高鲁棒性:String[] parts = br.readLine().trim().split("\\s+");。这里的\\s+可以匹配一个或多个空白符,比单个空格更安全。
  • 大数处理:注意数据范围!如果题目说结果可能很大,要用long而不是int。甚至要考虑BigIntegerBigDecimal

5.3 本地调试与平台提交的差异

  1. 类名必须为Main:这是绝大多数国内笔试平台(牛客、赛码等)的硬性规定。
  2. 不要有package声明:直接写public class Main,不要加package com.xxx;
  3. 关闭Scanner/BufferedReader:虽然不关在笔试环境下通常也能过,但良好的习惯是调用close()方法。如果使用全局静态的BufferedReader,有时不关也没问题。
  4. 在本地模拟平台输入:在IDE里,你可以创建一个input.txt文件,把样例输入放进去,然后运行程序时重定向标准输入,这样能完美模拟笔试环境。
    # 命令行方式 java Main < input.txt
    在IDEA中,可以在运行配置里设置“Redirect input from”指向你的输入文件。

5.4 遇到“超时”或“内存超限”的排查思路

如果代码逻辑正确但结果超时,首先怀疑IO:

  • 是否用了Scanner读大数据?立刻换成BufferedReader模板。
  • 是否在循环内频繁调用System.out.println改用StringBuilder拼接后一次性输出。
  • String.split使用是否过于频繁?对于每行都要分割的密集操作,考虑使用StringTokenizer

内存超限则可能因为:

  • 用了Scanner(它本身开销较大)。
  • 存储数据的集合(如ArrayList)没有预估初始大小,在大量添加时频繁扩容。
  • 不必要的对象创建(如在循环内new StringTokenizer,其实可以复用)。

6. 从ACM模式到核心代码模式:策略的转变

我们上面讨论的都是ACM模式,即需要自己处理完整输入输出的模式。这也是牛客、赛码等平台笔试的主流。但还有一种常见模式是核心代码模式(或函数式模式),例如在LeetCode或某些公司的笔试中。

在核心代码模式下,你只需要实现一个特定的函数或方法,输入参数已经以方法参数的形式(如int[] nums,String s)提供给你,你需要返回结果。完全不需要处理System.inSystem.out

策略转变

  • ACM模式:考察完整程序的构建能力,包括IO、逻辑、异常处理。重点在“外部接口”。
  • 核心代码模式:聚焦于算法逻辑本身。重点在“内部实现”。

在练习和准备时,要分清题目要求。如果是核心代码模式,却花时间去写输入输出,就南辕北辙了。通常题目描述会明确指出“你需要实现以下函数”。

7. 总结与个人工具箱

经过无数次笔试的“毒打”,我的Java IO工具箱已经非常固定:

  1. 默认选择BufferedReader+StringBuilder组合。这是应对大数据量笔试的“黄金搭档”。
  2. 备用选择:对于格式极其复杂、需要正则表达式灵活匹配的输入,或者快速写思路验证时,才考虑Scanner
  3. 必备技巧
    • 使用.trim().split("\\s+")来安全分割。
    • StringBuilder组装大规模输出。
    • 严格匹配输出格式,特别注意行尾空格和换行。
    • 在代码开头准备好快速IO模板。
  4. 心态:把输入输出看作一道固定的“前菜”,用最短的时间、最稳的方式解决它,把宝贵的思考和调试时间留给真正的算法逻辑。

最后,再分享一个我自己的小习惯:在开始写核心逻辑前,我会先用最快的速度把输入样例正确地读入并打印出来,确保IO部分没有问题。这就像飞行员起飞前的检查单,能避免很多低级错误导致的整体崩溃。毕竟,如果数据都读错了,后面算法再精妙也是零分。

← 返回列表