ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java笔试ACM模式输入输出全攻略:从Scanner到BufferedReader的避坑指南

Java笔试ACM模式输入输出全攻略:从Scanner到BufferedReader的避坑指南 如果你刷了几百道力扣算法思路背得滚瓜烂熟结果一场笔试下来代码在本地跑得飞起一提交却是“编译失败”或者“答案错误”——那你大概率是栽在了ACM模式上。国内笔试平台牛客、赛码、各类OJ几乎清一色用这种模式不给函数签名不给核心代码模板你得自己写main入口自己从标准输入读数据自己把结果打印到控制台。很多人第一次接触会懵平时LeetCode的public int[] twoSum(int[] nums, int target)用惯了突然让你自己解析一行“[2,7,11,15]9”连数组怎么切分都要想半天。这篇文章我打算把Java在ACM模式下的那点事一次性讲透从输入输出的底层差异到高频题型的数据读取模板再到笔试时真正容易踩的坑。不管你是准备校招笔试还是刚开始刷OJ又或者从力扣转牛客不适应读完这份“必知必会”应该能少走很多弯路。注意这里不是讲算法思路本身——排序、DP、搜索这些经典题型的思路大家都懂难的恰恰是那一层“壳”把乱七八糟的输入变成你熟悉的数据结构把结果按指定格式输出。这一层壳就是ACM模式。1. 先搞清楚ACM模式和力扣模式到底差在哪1.1 两种模式的根本区别谁在“伺候”你的代码我用最简单的话做个类比。力扣模式像是餐厅里点了套餐你坐在餐桌前服务员把食材处理好、摆好盘你只需要负责炒菜那一步。题目给你函数签名和方法参数返回值也由系统接收你完全不用管数据怎么来、怎么走。ACM模式则是让你从买菜开始做起没有人帮你切菜备菜你得自己从标准输入流System.in里把原始数据捞出来自己洗、自己切、自己拼成能下锅的形状炒完菜还得自己装盘——调用System.out打印结果。中间的每一步格式稍有不对判题系统直接给你判错。举个例子力扣上的“两数之和”是这样的class Solution { public int[] twoSum(int[] nums, int target) { // 你只要处理算法逻辑 } }到了ACM模式同样一道题你的代码变成这样import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); // 输入可能是第一行一个整数n第二行n个数第三行target int n sc.nextInt(); int[] nums new int[n]; for (int i 0; i n; i) { nums[i] sc.nextInt(); } int target sc.nextInt(); // 自己调用算法自己设计输出的格式 int[] res twoSum(nums, target); System.out.println(res[0] res[1]); } }这绝不是简单的代码量增加而是多了一整套“输入解析 输出格式化”的工程能力。很多刚转型的人挂在第一轮不是因为算法不会而是读不到想要的数组。1.2 为什么国内笔试平台普遍采用ACM模式这个问题很多人问过力扣模式显然更贴近日常开发为什么笔试还要用ACM模式核心原因有三点。第一批量判题方便。ACM模式天然适配“标准输入 标准输出”的评测体系判题机只要对比你的标准输出和答案文件是否一致即可平台不需要为每道题单独编写测试驱动代码成本极低。第二防作弊和防模板化。力扣那种“只填核心函数”的方式网上题库代码满天飞背答案成本很低ACM模式要求你自己处理输入变相增加了题目门槛也规避了一部分直接抄模板的行为。第三竞赛文化的历史惯性。国内OJ和竞赛体系长期沿用这套规则牛客、赛码这些笔试平台从诞生起就继承了竞赛判题的标准校招季企业图省事自然沿用。理解了原因你就明白抱怨没用适应才是硬道理。从面试准备角度来说ACM模式其实逼着你把代码写完整——一个能独立处理数据输入、算法逻辑、输出控制的程序本身就是工程能力的缩影。1.3 除了输入输出ACM模式还考察什么别天真地认为ACM模式就是“力扣 输入输出拼接”。真正到了笔试现场你会发现它考察的维度比力扣广得多。首先是代码组织能力。一个可运行的Main类从import、异常处理到资源关闭处处有讲究。比如很多人不知道Scanner用完后关闭是好习惯虽然笔试里不关也不影响判题但代码分一定会丢分。其次是边界条件意识。输入可能为空行、可能有多余空格、可能有超出你预期的数据范围这些都要在代码层面处理掉。再次是性能意识。力扣的函数级评测会隐藏大量的数据读取耗时而ACM模式下System.in的读取方式直接决定你是否超时——这个问题我在第2节会重点讲。总之一句话ACM模式是把“做题”升级成了“写一个能解决特定输入的小程序”。你的代码不仅要正确还要健壮、高效、格式规范。2. 输入输出的基本功三种读取方案怎么选2.1 先记住一张对比表Java的输入读取方案五花八门我帮你按性能和用途整理好方案读取方式性能适用场景Scanner按标记读取nextInt/nextLine最慢数据量小十万元以内、字符串处理多BufferedReader StringTokenizer按行读取再切分较快常规笔试通用方案一百万以内无压力StreamTokenizer流式标记读取最快千万级数据、时间卡得紧的竞赛题提示笔试里90%的题目用BufferedReader StringTokenizer就足够了。不要一上来就迷信StreamTokenizer它处理字符串很别扭只有纯数值海量读取才值得用它。很多新手习惯用Scanner写到底觉得“代码短、好记”。我最初也这样后来在一次数据量约五百万次的笔试里Scanner直接超时换BufferedReader后一秒不到跑完。从那以后我再也不敢小看输入读取的性能差异。2.2 Scanner的常见用法和它埋的坑Scanner确实最符合人类直觉nextInt读整数、nextDouble读小数、nextLine读整行、hasNext判断还有没有数据。初学阶段用它完全没问题小数据量下性能差异可以忽略。但有几个坑你一定得知道。第一个坑是nextInt()和nextLine()混用。nextInt读取后不会消费掉行尾的换行符紧接着调用nextLine会读到空字符串。很多人在“先读整数再读一整行字符串”的题目上翻车多半就是这原因。第二个坑是频繁创建Scanner对象。有些人喜欢在循环里每次new一个性能极差还会导致流关闭异常。第三个坑是hasNext()在标准输入流里的语义它会一直阻塞等待输入结束和文件读取的EOF语义不同如果用错了会导致死循环。给你一个稳妥的Scanner写法Scanner sc new Scanner(System.in); int n sc.nextInt(); sc.nextLine(); // 吃掉换行 for (int i 0; i n; i) { String line sc.nextLine(); // 处理line } sc.close();这个写法能规避掉绝大部分Scanner的隐藏问题。但如果你知道题目的数据量可能很大或者时间限制在1秒以内请直接看下一种方案。2.3 常规笔试主力BufferedReader StringTokenizer这是我个人最推荐的日常笔试方案。逻辑清晰、性能够用、代码也不复杂。它的核心逻辑分两步先用BufferedReader按行读取文本再用StringTokenizer按分隔符把一行拆成多个标记token。为什么快BufferedReader内部有字符缓冲区避免每次读取都触发底层IOStringTokenizer则是纯内存的字符串切割没有Scanner那套复杂的类型解析逻辑。这套组合足以应对百万级数据的读取。import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.IOException; import java.util.StringTokenizer; public class Main { public static void main(String[] args) throws IOException { BufferedReader br new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st new StringTokenizer(br.readLine()); int n Integer.parseInt(st.nextToken()); int m Integer.parseInt(st.nextToken()); int[][] grid new int[n][m]; for (int i 0; i n; i) { st new StringTokenizer(br.readLine()); for (int j 0; j m; j) { grid[i][j] Integer.parseInt(st.nextToken()); } } br.close(); } }注意main方法声明了throws IOException。很多人觉得这像是偷懒或者不规范实际在笔试场景里这是完全正常的做法你不需要也不应该在这里做try-catch包裹——真出了IO异常程序直接崩OJ不会给你任何额外信息还不如让它自己终止。2.4 性能极限StreamTokenizer什么时候用如果你的笔试经验足够丰富你应该见过那种“第一行百万个整数、时限1秒”的题目。BufferedReader StringTokenizer可能勉强能过但如果你要处理的是千万级数据就得掏出StreamTokenizer了。它的原理是直接对输入流做字节级解析跳过类型检查、正则匹配等一切开销。读取数值的速度比Scanner快一个数量级比BufferedReaderStringTokenizer也快不少。代价是它处理字符串很蹩脚token默认都是double类型读字符串还要自己判断ttype属实麻烦。import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.StreamTokenizer; import java.io.IOException; public class Main { public static void main(String[] args) throws IOException { StreamTokenizer st new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in))); st.nextToken(); int n (int) st.nval; long[] arr new long[n]; for (int i 0; i n; i) { st.nextToken(); arr[i] (long) st.nval; } } }我的建议是这种方案当作保命底牌掌握平时练习保持BufferedReader为主即可。毕竟StreamTokenizer的字符串处理实在不够直观笔试中临时切换容易写错。2.5 输出也要讲究避免System.out.println的坑输入讲完了输出同样有讲究。System.out.println是带缓冲的但每次调用都会经过一次同步和流刷新循环打印十万行和循环打印一百行的性能差距足以决定你是否超时。正确的做法是用StringBuilder把所有输出结果拼接起来最后一次性打印。StringBuilder sb new StringBuilder(); for (int i 0; i n; i) { sb.append(arr[i]).append(\n); } System.out.print(sb.toString());如果输出格式复杂比如有多组空格分隔的结果先拼进StringBuilder最后统一输出。还有个实用技巧System.out.printf也可以但同样建议拼完了再统一打印。记住一个原则输出总共只调用一次System.out.print是最理想的状态。另一个容易被忽略的点是System.err。调试的时候用System.err.println打印中间状态是一种很好的本地调试手段因为标准输出和标准错误是两个流互不干扰线上判题也不会因为err流的内容影响结果。但注意调试代码在提交前一定要删干净否则一旦数据量大err输出也会拖慢程序。3. 高频题型的输入输出全套模板3.1 一维数组和单行多值笔试最常见的输入格式无外乎第一行是数据个数第二行是数据本身。前者告诉你要开多大的数组后者是数组内容。BufferedReader br new BufferedReader(new InputStreamReader(System.in)); int n Integer.parseInt(br.readLine().trim()); String[] parts br.readLine().trim().split( ); int[] arr new int[n]; for (int i 0; i n; i) { arr[i] Integer.parseInt(parts[i]); }有人总在.trim()上偷懒觉得无所谓。但有些OJ的输入行尾会带不可见的空白字符不trim的话split( )可能切出空字符串导致Integer.parseInt抛异常。这就是典型的“本地好好的提交就报错”。还有一种情况第一行和第二行都可能不确定。比如题目说“输入包含若干整数第一行为n接下来n个整数”有时候所有数据都在一行有时候分成两行甚至中间有空行。稳妥做法是先统计StringTokenizer的token数再动态分配数组StringTokenizer st new StringTokenizer(br.readLine()); int[] arr new int[st.countTokens()]; int idx 0; while (st.hasMoreTokens()) { arr[idx] Integer.parseInt(st.nextToken()); }这种做法能让你天然兼容“一行放完”和“多行放完”两种情况。3.2 二维数组和矩阵类题目图论、迷宫、动态规划经常用二维数组。输入格式通常是第一行两个整数n和m接下来n行每行m个元素。st new StringTokenizer(br.readLine()); int n Integer.parseInt(st.nextToken()); int m Integer.parseInt(st.nextToken()); char[][] grid new char[n][m]; for (int i 0; i n; i) { String line br.readLine(); for (int j 0; j m; j) { grid[i][j] line.charAt(j); } }这里有个细节很多迷宫题输入是0101011这样的连续字符没有空格。如果题目明确说了“矩阵元素之间用空格分隔”才可以用split( )否则直接charAt更稳妥。判断依据很简单——看样例输入。有空格就split没空格就charAt。3.3 字符串处理和字符串数组字符串题往往考验的不是算法而是API的熟悉程度。常见操作包括按逗号分割、按空格分割、大小写转换、去重排序。String line br.readLine(); line line.substring(1, line.length() - 1); // 去掉可能存在的[]包裹 String[] items line.split(,); for (String item : items) { item item.trim(); // 去掉多余空格 }还有一类输入是多次循环读取字符串直到EOF此时最稳的写法是String line; while ((line br.readLine()) ! null) { // 处理每一行 }不要试图提前判断行数也不要依赖hasNextLine之外的任何状态。readLine返回null就是文件的终点这是BufferedReader的标准行为。3.4 不确定行数的EOF读取数据结构题里经常有“输入包含多组测试数据每行一组处理到文件结束”的格式。这种题和力扣完全不一样你没有提前被告知有多少行只能自己读到末尾。String line; while ((line br.readLine()) ! null) { if (line.trim().isEmpty()) { continue; // 跳过空行防止干扰 } st new StringTokenizer(line); int a Integer.parseInt(st.nextToken()); int b Integer.parseInt(st.nextToken()); // 计算并拼接输出 }这里最隐蔽的坑是空行。有些样例在两组数据之间有空行有些没有你的解析逻辑必须对空行免疫。这也是我在第5节详细讲的一个高频错误来源。3.5 链表和二叉树的本地构建力扣里链表题直接给你头节点二叉树题直接给出根节点但ACM模式会给你一串序列你要自己还原结构。链表的还原相对直观把序列依次创建节点串起来。用虚拟头节点可以让代码清爽不少。class ListNode { int val; ListNode next; ListNode(int val) { this.val val; } } ListNode dummy new ListNode(-1); ListNode tail dummy; for (int num : arr) { tail.next new ListNode(num); tail tail.next; } ListNode head dummy.next;二叉树的还原复杂一些常见的是按层序遍历序列用-1表示空节点。还原时可以借助队列模拟BFS过程class TreeNode { int val; TreeNode left, right; TreeNode(int val) { this.val val; } } TreeNode root new TreeNode(arr[0]); QueueTreeNode queue new LinkedList(); queue.offer(root); int idx 1; while (!queue.isEmpty() idx arr.length) { TreeNode node queue.poll(); if (arr[idx] ! -1) { node.left new TreeNode(arr[idx]); queue.offer(node.left); } idx; if (idx arr.length arr[idx] ! -1) { node.right new TreeNode(arr[idx]); queue.offer(node.right); } idx; }这类还原代码不需要背得很死但一定要能在5分钟内无错写出。因为一旦卡在数据还原后面根本没时间写算法主体。3.6 BigInteger和BigDecimal高精度数据别用intACM题偶尔会出现超大数运算比如数值超过Long.MAX_VALUE的加法、精确到小数的计算。Java有现成的BigInteger和BigDecimal但这两个类的操作不是运算符要调用方法。BigInteger a new BigInteger(st.nextToken()); BigInteger b new BigInteger(st.nextToken()); System.out.println(a.add(b));注意不能用、-、*、/直接处理BigInteger。四则运算对应add、subtract、multiply、divide。很多人初学时在这里踩坑以为是运算符重载Java可没有这个机制。另外BigInteger在高频循环中性能较差能转long就转long只有明确数据范围超长时才用它。3.7 多组数据与格式化输出有的题目要求输出特定格式比如保留两位小数、左对齐、每个结果占一行。如果是double类型的精度控制我通常用String.formatsb.append(String.format(%.2f%n, result));如果你要拼装的是整数和字符串的混合体String.format依然好用但注意不要在一个巨大的for循环里频繁调用它——格式化的开销比简单拼接高得多。一条经验是只有当精度要求明确的时候才用格式化否则直接append(item).append( )。4. 必背算法模板从排序到搜索4.1 手写排序快排、归并、堆排ACM模式下Arrays.sort当然能用但很多面试官和OJ变体题会要求你自己实现排序至少你得清楚每个排序的本质否则遇到“稳定排序”“原地算法”“复杂度的常数项”等衍生问题会很吃力。我自己归纳的模板可以缩写为三套。快排重视partition和轴点选择归并重视辅助数组和merge的稳定性堆排重视上浮下沉和边界索引。// 快排核心是partition public static void quickSort(int[] arr, int l, int r) { if (l r) return; int pivot arr[l (r - l) / 2]; int i l, j r; while (i j) { while (arr[i] pivot) i; while (arr[j] pivot) j--; if (i j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; i; j--; } } quickSort(arr, l, j); quickSort(arr, i, r); }归并排序稳定、可预测适合数据量大的排序场景代价是需要O(n)的辅助空间。public static void mergeSort(int[] arr, int l, int r, int[] tmp) { if (l r) return; int mid l (r - l) / 2; mergeSort(arr, l, mid, tmp); mergeSort(arr, mid 1, r, tmp); int i l, j mid 1, k l; while (i mid j r) { if (arr[i] arr[j]) tmp[k] arr[i]; else tmp[k] arr[j]; } while (i mid) tmp[k] arr[i]; while (j r) tmp[k] arr[j]; for (i l; i r; i) arr[i] tmp[i]; }堆排序实现稍微复杂但它的原地排序特性和时间复杂度的稳定性让它在“数组很大且不能用额外空间”的题里有不可替代的位置。总之这三个排序模板你要能闭着眼写出来这是基本功中的基本功。4.2 二分查找边界再也不出错二分查找本身不难难的是边界写法。l和r的更新姿势稍有不同就可能导致死循环或者漏掉目标值。我推荐一套统一写法适用于绝大多数“找第一个/最后一个满足条件的位置”的场景public static int lowerBound(int[] arr, int target) { int l 0, r arr.length; // 注意r是开区间 while (l r) { int mid l (r - l) / 2; if (arr[mid] target) l mid 1; else r mid; } return l; }这套写法配合“lowerBound返回第一个大于等于target的位置”“upperBound返回第一个大于target的位置”几乎可以处理所有基于有序数组的查找问题。关键点是r arr.length开区间设计以及更新时l mid 1、r mid的对称性。每次你犹豫边界时就回想这个开区间设计能减少大量调试时间。4.3 前缀和、差分和滑动窗口前缀和是“区间和查询”的万金油。先算pre[i]表示前i个元素的和查询区间[l, r]的和就是pre[r] - pre[l - 1]。差分数组则是“区间修改”的利器比如对[l, r]每个元素加c可以差分数组做两次更新最后做前缀和还原。这两个套路配合HashMap能解决大量子数组和等于k的变形题。滑动窗口则更偏双指针思维固定左端点右端点不断右移维护窗口内状态。和前缀和不同它适合求“窗口内最大最小区间”这类极值问题。关键点在于更新窗口时要区分维护了哪些量比如sum、count、字符出现次数等。这三个“套路”我在力扣刷题里用得比DP还频繁因为一旦用对代码量极短而且不容易错。4.4 BFS和DFS框架搜索题的两种拼图搜索类题是ACM的大头。DFS适合求路径总数、连通块数量BFS适合求最短路径、层级遍历。框架可以直接背下来考场上有奇效。BFS标准模板QueueInteger queue new LinkedList(); boolean[] visited new boolean[n]; queue.offer(start); visited[start] true; int step 0; while (!queue.isEmpty()) { int size queue.size(); for (int i 0; i size; i) { int cur queue.poll(); // 对cur做处理 for (int next : graph[cur]) { if (!visited[next]) { visited[next] true; queue.offer(next); } } } step; }这里的size queue.size()是精髓确保每一层只处理当前层的节点这样step才能正确表示层数对“最短路径最少步数”这类题必不可少。DFS标准模板public static void dfs(int u, boolean[] visited) { visited[u] true; for (int v : graph[u]) { if (!visited[v]) { dfs(v, visited); } } }记住一个原则DFS要回溯就显式回溯不回溯就传参传递状态。很多人在“全排列”“组合总和”这类回溯题上写糊涂根源就是没想清楚状态时该恢复还是不恢复。4.5 进阶算法速记KMP、贪心、动态规划字符串匹配的KMP我一直建议在笔试前手写一遍因为“背模板”不太管用只有自己写过才能明白next数组的含义——它存的是“前缀与后缀的最长公共部分长度”。模板可以背但理解之后才写得稳。贪心和动态规划算是一类大话题。贪心的关键是“局部最优能否推出全局最优”常见于区间调度、活动选择。动态规划则要抓住“状态定义、转移方程、边界条件”三要素。面试高频的背包问题、最长公共子序列、最长递增子序列都属于这类。我的建议是基础状态转移方程要烂熟于心但更重要的是能根据题目描述快速迁移。5. 实战中的坑与排查经验5.1 输入没读完就返回这是ACM模式最典型的“低级错误”第一组数据处理完直接return忘记后面可能还有多组数据。很多题目的输入是多组测试用例你得把所有数据都读完并计算完最后一次性输出。偷懒的办法是把“处理数据”的逻辑封装成一个private static void solve(...)方法主循环负责读取全部输入这样不容易漏数据。5.2 Scanner的nextLine和数值读取冲突这个是老生常谈但依然高发。nextInt()不消费换行符如果你在它后面跟了nextLine()你读到的往往是一个空串。解决办法有两条路要么所有数据都用nextLine()读取后再手动parse要么每次调用nextInt()后立刻加一句sc.nextLine()把换行符吞掉。习惯后者比较省事前者更万无一失。5.3 超时Scanner和System.out.println的锅我在第2节提过这里再强调一次笔试超时八成不是因为算法复杂度高而是输入输出在疯狂拖后腿。Scanner的标记解析非常重System.out.println的每次刷新都有同步开销。换成BufferedReaderStringTokenizer和StringBuilder批量输出后同样逻辑的代码能从“TLE”变成“AC”。这是我在多次实战中验证过的结论不是理论推测。5.4 本地通过线上失败三个隐藏元凶“本地IDE跑得好好的一提交就错”这种现象每个ACM玩家都遇到过。优先排查三个方向第一判空。输入可能是空行、可能是空字符串你本地构造的样例都有数据但线上存在空数据的测试点。第二整数溢出。int范围是21亿多很多题目的答案或中间结果会超过这个值。能想到用long的地方直接用long别等爆了再改。第三数组下标越界。尤其是一些循环里你依赖length - 1或者mid1稍不留神就会越界。我的排查习惯是先把样例输入改成边界最小值和边界最大值各跑一遍再修改两三行数据做随机测试很快能定位问题。5.5 高效调试技巧学会构造小样例和利用System.err算法调试最忌讳的就是对着一个复杂样例死磕。正确的做法是构造一个最小规模的样例比如数组长度取2、3字符串长度取1把步骤在纸上演一遍。这样既能验证算法逻辑又能顺带检查下标边界。本地调试时善用System.err.println打印中间变量。System.out和System.err是两条独立输出流打印在err流的内容不会混入标准输出非常方便。但要记住提交前把所有调试用的System.err.println删掉否则程序在高频循环里打印大量调试信息副作用比不打印还严重。写在最后一点个人经验说实话我从力扣转ACM模式的最初两周是非常痛苦的总觉得多出来的输入输出代码恶心又无用。后来经历了一场线上笔试面对同样的数组题一个用Scanner的室友超时了而我换了BufferedReader稳稳过关才彻底改变态度——ACM模式考察的不只是算法更是你作为一个Java工程师处理数据底层的敏锐度。我的建议很简单从今天开始每次在力扣做完一道题都把核心逻辑抽出来主动用main函数加Scanner或BufferedReader重新写一遍。不用多一天两道坚持一个月你就能在任何笔试平台上做到“拿到题目直接手写输入输出”完全不需要经过大脑思考这一层。这种肌肉记忆比看十篇总结都管用。顺手再准备一个自己的模板文件覆盖第3节那些输入输出套路考试前看一眼心里就踏实了。
返回列表