Java算法题精解:洛谷P1765手机九宫格按键模拟与编程思维训练
1. 项目概述从“手机”按键到编程思维的跨越看到“Java 洛谷 P1765 手机”这个标题很多刚接触编程的朋友可能会一愣以为是要用Java写一个手机App或者模拟器。其实不然这是一道非常经典的、来自知名在线评测平台“洛谷”的算法题题号P1765。这道题的核心是模拟一个古老但充满生活气息的场景在传统九宫格物理键盘的手机上输入一段英文句子需要按多少次键。对于从那个时代过来的开发者这题能瞬间勾起回忆对于新生代程序员这是一个绝佳的、将生活问题抽象为计算逻辑的入门练习。它不涉及复杂的算法但极其考验对问题的理解、细节的把握以及代码的严谨性是巩固Java基础语法、训练边界条件处理能力的绝佳沙盒。这道题的价值在于它完美地诠释了编程中“建模”的思想。你需要把一个具象的、依赖肌肉记忆的操作比如按“abc”键一下是a两下是b转化为冰冷的、精确的数学规则和条件判断。在解决过程中你会反复用到数组或字符串映射、循环遍历、条件分支这些最基础的编程构件。很多人在面试或笔试中栽在简单题上往往不是因为算法不会而是因为细节考虑不周。P1765正是这样一道“细节魔鬼”它能清晰地暴露你在问题分解和逻辑严密性上的短板。接下来我将带你彻底拆解这道题从理解题意、设计思路到代码实现、边界测试最后分享一些只有踩过坑才知道的优化技巧和洛谷刷题的通用心得。2. 核心需求解析与问题建模2.1 题目原意与规则还原洛谷P1765“手机”题目的描述大致如下给定一个由小写字母、空格组成的字符串代表要输入的英文句子我们需要计算在传统九宫格键盘上输入它所需的总按键次数。传统的九宫格键盘布局规则是数字键2-9分别对应多个字母2: abc3: def4: ghi5: jkl6: mno7: pqrs8: tuv9: wxyz空格键通常被映射到数字键1或0在本题中输入一个空格需要按1次键。输入规则要输入某个字母需要按下其对应的数字键若干次。例如输入‘a’需要按2键1次输入‘b’需要按2键2次输入‘c’需要按2键3次。一个关键细节易错点如果连续输入的两个字母位于同一个数字键上则需要在它们之间插入一个等待时间在本题中体现为额外的一次按键。例如输入“ab”‘a’和‘b’都在键2上。输入‘a’按一下2后如果要输入‘b’不能直接接着按2因为手机会认为你是想继续输入‘a’。所以你需要先按一个其他键比如#或*本题中通常理解为按一次“下一个”键或等待键然后再按2键两下输入‘b’。因此“ab”的总按键次数是 (1次 for ‘a’) (1次 等待) (2次 for ‘b’) 4次。所以我们的程序核心任务就是遍历输入字符串的每一个字符根据上述规则累加计算出总按键次数。2.2 从问题到算法的思维转换理解规则后我们需要将其转化为计算机能执行的逻辑。这里的关键是建立“字符”到“按键次数”以及“所属按键组”的映射关系。第一步建立映射。最直观的方法是使用两个并行数组或一个二维数组但更优雅且易于维护的方法是使用String数组。String[] keyMap {, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz};数组下标对应数字键。keyMap[2] “abc”keyMap[3] “def”以此类推。keyMap[0]和keyMap[1]我们用空字符串占位因为数字键0和1在本题目规则中通常不用于输入字母空格单独处理。第二步计算单个字符按键数。对于一个字符ch我们需要遍历keyMap数组从2到9找到ch位于哪个String即哪个数字键中。找到后该字符的按键次数就是它在对应String中的索引位置 1。因为索引从0开始按第1下是索引0的字符。第三步处理连续同键字符。这是本题的难点。我们需要在遍历字符串时不仅知道当前字符ch的按键次数还要知道它属于哪个数字键假设我们用currentKey表示。同时我们需要记住前一个字符所属的数字键prevKey。如果currentKey prevKey说明连续两个字符在同一个键上那么除了累加当前字符的按键次数还需要额外加1代表等待或分隔按键。否则直接累加当前字符的按键次数即可。初始状态下prevKey可以设置为一个不可能的值比如-1。第四步处理空格。空格字符‘ ’需要单独处理。其按键次数固定为1。并且空格与任何字母或另一个空格都不存在“同键”问题因为空格键是独立的。所以当遇到空格时currentKey可以设置为一个特殊值如0或1但注意不要和字母键的2-9冲突这样它永远不会与字母键的prevKey相等也就不会触发额外加1的规则。通过这四步我们完成了从生活规则到抽象算法的建模。接下来就是将这个模型用Java代码严谨地实现出来。3. Java实现详解与代码逐行分析有了清晰的思路我们就可以动手编写代码了。这里我将提供一个健壮、易读的Java实现并附上详细的注释。3.1 基础版本实现import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner scanner new Scanner(System.in); // 读取一行输入题目说明可能包含空格所以必须用nextLine String input scanner.nextLine(); scanner.close(); // 九宫格键盘映射索引0和1空置索引2-9对应数字键2-9 String[] keyMap {, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz}; int totalPresses 0; // 总按键次数 int prevKey -1; // 上一个字符所属的数字键初始化为-1保证第一个字符不会误判为同键 // 遍历输入字符串的每一个字符 for (int i 0; i input.length(); i) { char ch input.charAt(i); int currentKey -1; int pressesForChar 0; // 情况1当前字符是空格 if (ch ) { pressesForChar 1; // 空格按1次 currentKey 0; // 将空格“所属”的键设为0一个不会与2-9冲突的值 } else { // 情况2当前字符是字母 // 遍历数字键2-9寻找字符所在的键位 for (int key 2; key 9; key) { int index keyMap[key].indexOf(ch); if (index ! -1) { // 找到了 currentKey key; // 按键次数 在字符串中的位置索引 1 pressesForChar index 1; break; // 找到后立即跳出内层循环 } } } // 关键判断如果当前字符和前一个字符在同一个数字键上需要额外加一次等待 if (currentKey prevKey) { totalPresses 1; // 先加上等待的这一次按键 } // 累加当前字符本身的按键次数 totalPresses pressesForChar; // 更新prevKey为当前键供下一个字符判断使用 prevKey currentKey; } // 输出结果 System.out.println(totalPresses); } }代码逻辑拆解输入处理使用Scanner.nextLine()读取整行确保能捕获包含空格的句子。映射定义keyMap数组是核心查找表。遍历与查找对每个字符先判断是否为空格。若是直接赋值按键次数和虚拟键值。若不是则遍历keyMap[2]到keyMap[9]使用String.indexOf()方法查找字符位置。同键判断通过currentKey和prevKey的比较决定是否添加等待按键。这个判断必须放在累加当前字符按键次数之前因为等待发生在前一个字符输入完毕、准备输入当前字符之时。状态更新处理完一个字符后及时将currentKey赋给prevKey实现状态的滚动更新。注意这里有一个非常重要的细节关于空格的处理。我将空格的currentKey设为0。为什么是0因为字母键只可能映射到2-9。设置成0或1可以确保currentKey prevKey这个条件在空格与字母、空格与空格之间都不会成立除非前一个也是空格且我们也设成了0但题目中连续空格是否需要等待通常规则里空格键按下即输入连续空格就是连续按不需要额外等待。所以即使prevKey和currentKey都是0我们也不应加等待。因此更严谨的做法是在判断同键时额外排除空格的情况。上面的代码为了逻辑清晰先这样写后面我们会讨论更完善的版本。3.2 优化与健壮性改进基础版本虽然能通过很多测试用例但可能存在一些边界问题。让我们来优化它。改进点1更高效且统一的位置计算对于字母我们每次都用indexOf在短字符串中查找效率没问题。但我们可以预先计算好每个字母的(所属键, 按键次数)避免在循环中反复查找。这属于典型的“以空间换时间”对于本题输入规模虽非必需但是一种很好的编程思维训练。// 在main方法外或内部静态区域可以定义两个数组直接通过字符ASCII码映射 // pressCount[ch] 表示字符ch所需的按键次数 // keyBelong[ch] 表示字符ch所属的数字键 int[] pressCount new int[128]; // 足够覆盖小写字母和空格 int[] keyBelong new int[128]; // 初始化映射 String[] keyMap {, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz}; for (int key 2; key 9; key) { String letters keyMap[key]; for (int j 0; j letters.length(); j) { char ch letters.charAt(j); pressCount[ch] j 1; keyBelong[ch] key; } } // 单独设置空格 pressCount[ ] 1; keyBelong[ ] 0; // 特殊值 // 这样在主循环中对于每个字符ch int pressesForChar pressCount[ch]; int currentKey keyBelong[ch]; // 无需内层循环查找直接O(1)时间复杂度获取改进点2更严谨的同键判断逻辑我们需要明确只有两个都是字母且属于同一个数字键时才需要加等待。空格与任何字符包括另一个空格之间都不需要等待。// 在主循环内部获取pressesForChar和currentKey后假设已用上述数组法优化 if (prevKey ! -1) { // 不是第一个字符 // 只有当 前一个键和当前键相同 且 当前键不是空格键(0) 时才需要等待 if (currentKey prevKey currentKey ! 0) { totalPresses 1; // 添加等待 } } totalPresses pressesForChar; prevKey currentKey;这样即使连续空格currentKey和prevKey都是0因为currentKey ! 0条件不成立也不会错误地增加等待次数。改进点3输入范围与错误处理题目明确输入是小写字母和空格。但作为一个健壮的程序我们可以考虑无效输入。不过对于在线评测OJ系统通常保证输入合法所以我们可以省略复杂的校验以保持代码简洁。但在实际工程或面试中可以提及这一点。综合以上改进我们得到第二个版本import java.util.Scanner; public class Main { public static void main(String[] args) { // 预计算映射表 int[] pressCount new int[128]; int[] keyBelong new int[128]; String[] keyMap {, , abc, def, ghi, jkl, mno, pqrs, tuv, wxyz}; for (int key 2; key 9; key) { String letters keyMap[key]; for (int j 0; j letters.length(); j) { char ch letters.charAt(j); pressCount[ch] j 1; keyBelong[ch] key; } } pressCount[ ] 1; keyBelong[ ] 0; Scanner sc new Scanner(System.in); String input sc.nextLine(); sc.close(); int total 0; int prevKey -1; for (int i 0; i input.length(); i) { char ch input.charAt(i); int curKey keyBelong[ch]; int curPress pressCount[ch]; if (prevKey ! -1 curKey prevKey curKey ! 0) { total 1; // 同键等待 } total curPress; prevKey curKey; } System.out.println(total); } }这个版本效率更高逻辑更清晰是提交到洛谷的推荐版本。4. 测试用例设计与常见“坑点”分析一道题能否ACAccept通过不仅取决于核心算法更取决于对边界情况和特殊输入的考虑。下面我们设计一系列测试用例来验证程序的正确性。4.1 标准功能测试输入预期输出说明a1单个字母最简单情况。ab4经典同键案例a(1) 等待(1) b(2) 4。ba4同上顺序不影响同键逻辑。ad3不同键a(1) d(1) 2等等d在键3上按1次。所以是112不对仔细算a(按2一下)d(按3一下)总次数2。这里容易想当然。hello world需计算包含空格和字母的组合。一个空格1单个空格。两个空格2连续空格不应有等待。aa3同字母连续a(1) 等待(1) a(1) 3。s4字母在键7(pqrs)的第4位按4次。z4字母在键9(wxyz)的第4位按4次。4.2 边界与陷阱测试输入预期输出潜在“坑点”空字符串0题目可能不会给但自己测试要考虑。循环不会执行total初始为0应输出0。我们的代码能处理。长字符串如1000个‘a’)1999第一个a按1次后面每个a都需要先等待再按1次。公式1 999 * (11) 1999。测试程序性能和大数处理本题结果在int范围内。开头就是空格按规则计算prevKey初始为-1第一个字符是空格curKey0同键判断条件curKey ! 0为false不会加等待正确。字母与空格交替如a a1 1 1 3检查空格键0与字母键2-9是否会被误判为同键。我们的判断条件curKey ! 0避免了此问题。全部是‘s’或‘z’4 * n测试最大按键次数字母的连续输入。实操心得在洛谷做题一定要充分利用题目提供的“样例输入/输出”。先确保样例通过。然后必须自己设计更全面的测试用例特别是边界情况。像“连续同键”、“开头结尾空格”、“空输入”、“单字符极值s/z”这些都是出题人喜欢埋伏笔的地方。在本地用这些用例测试通过后再提交能极大提高一次AC的概率。4.3 调试技巧打印中间变量当你对结果有疑问时最有效的调试方法是在循环内打印关键变量。for (int i 0; i input.length(); i) { // ... 获取ch, curKey, curPress ... System.out.printf(字符%c: 键%d, 需按%d次, prevKey%d, , ch, curKey, curPress, prevKey); if (prevKey ! -1 curKey prevKey curKey ! 0) { System.out.print(【同键等待1】); total 1; } total curPress; System.out.println(累计次数 total); prevKey curKey; }通过这样的输出你可以清晰地看到每个字符处理时的逻辑分支和累加过程快速定位是映射错误、同键判断错误还是累加逻辑错误。5. 洛谷刷题环境配置与提交指南对于Java选手在洛谷做题并不仅仅是写对算法还要适应其在线评测环境。这里有几个关键点需要注意。5.1 Java程序的标准结构洛谷的评测机运行你的Main类中的main方法。标准结构如下// 导入需要的包通常只需要java.util.Scanner import java.util.Scanner; // 类名必须是 Main public class Main { public static void main(String[] args) { // 你的代码逻辑 Scanner sc new Scanner(System.in); // ... 处理输入 ... sc.close(); // 关闭Scanner是好习惯 // ... 计算 ... System.out.println(result); // 输出结果注意不要输出多余空格或文字 } }重要规则类名必须为Main。必须使用public static void main(String[] args)作为入口。程序应从标准输入System.in读取向标准输出System.out打印结果且结果必须严格匹配题目要求通常就是一个数字或字符串不要加“答案是”之类的提示。确保不要使用package语句。5.2 输入输出效率考量对于本题输入规模很小使用Scanner完全足够。但如果遇到需要读取大量数据如10万行的题目Scanner可能会成为性能瓶颈。这时可以考虑换用BufferedReader。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 input br.readLine(); // 读取一行 // ... 处理input ... // 如果需要读取多个整数可以用StringTokenizer或split后解析 // String[] parts input.split( ); // int a Integer.parseInt(parts[0]); } }对于P1765Scanner足矣但了解更高效的IO方式对后续刷题有益。5.3 在洛谷提交的完整流程在本地IDE如IntelliJ IDEA, Eclipse或编辑器中按照上述标准结构编写并调试代码确保通过自己设计的多种测试用例。登录洛谷找到题目P1765。将你的Java代码完整复制粘贴到网页的代码编辑框中。选择语言为“Java”。点击“提交”。等待评测结果。常见结果有AC (Accepted): 通过恭喜。WA (Wrong Answer): 答案错误。回去检查逻辑特别是边界用例。TLE (Time Limit Exceeded): 超时。算法效率不足但对于本题几乎不可能。RE (Runtime Error): 运行时错误。可能是数组越界、空指针、栈溢出等。检查循环边界和输入处理。CE (Compilation Error): 编译错误。检查语法、类名、是否误用了不支持的Java版本特性。注意事项洛谷的Java评测环境可能有特定的Java版本如OpenJDK 8/11/17。避免使用过高版本特有的API。像我们上面用的代码只用了标准库兼容性很好。6. 从P1765延伸的编程思维训练解决P1765不仅仅是为了AC一道题更是为了锻炼和巩固以下核心编程能力这些能力在面试和实际开发中至关重要。6.1 抽象建模能力这是本题最核心的锻炼点。如何将“按手机键盘”这个具体行为转化为“映射查找”和“状态比较”的抽象过程关键在于识别出不变的数据键盘布局映射和变化的状态前一个按键是什么。这种“状态机”思想在解析协议、处理用户交互流、游戏逻辑中无处不在。例如解析一个自定义格式的字符串或者判断一个括号序列是否有效都需要维护类似的前置状态。6.2 边界条件与细节处理能力“魔鬼在细节中”。本题的细节在于“同键等待”规则。很多初学者会忽略这一点或者错误地在所有字符间都加等待。在面试中面试官常常通过修改题目条件来考察你的思维严密性。比如他们可能会问“如果规则变成连续按同一个键第二次按的间隔如果小于0.5秒则认为是输入同一个键的第二个字母否则认为是新的开始。你怎么设计” 这就要求你不仅能实现基础规则还能思考规则变化带来的影响。6.3 代码优化意识我们从最直观的双重循环查找优化到了使用查表法pressCount和keyBelong数组。这种优化将时间复杂度从O(n * m)n为字符串长度m为平均每个键的字母数虽然这里m很小降到了O(n)并且常数时间更小。在实际开发中这种“预计算”或“空间换时间”的思想非常普遍比如缓存、索引、查找表Look-up Table等。6.4 测试驱动开发TDD思维在动手写代码前先设计测试用例。这能帮你理清需求提前发现歧义。写完代码后用这些用例验证。这种习惯能极大提升代码质量和一次通过率。对于更复杂的项目这就是单元测试的雏形。7. 常见问题与排查实录即使思路清晰实际编码和提交时也可能遇到各种问题。下面是我在帮助他人解答和自身实践中遇到的一些典型情况。7.1 为什么我的程序在洛谷上总是WAWrong Answer可能原因1同键等待逻辑错误。这是最常见的错误。检查你的判断条件是否包含了“两个字符都是字母”的前提。错误示例// 错误只要键相同就加等待忽略了空格 if (currentKey prevKey) { totalPresses 1; }修正必须确保当前键不是代表空格的特殊值如0。if (prevKey ! -1 currentKey prevKey currentKey ! SPACE_KEY) { totalPresses 1; }可能原因2空格按键次数计算错误。题目明确空格按1次。确保你没有将其误算为0次或与其他键混淆。可能原因3字母到按键次数的映射错误。最典型的错误是认为‘a’按1次所以索引0对应1次于是直接用了索引值index而不是index 1。另一种错误是keyMap数组定义错误比如键与字母串对应关系弄混。排查方法使用第4.3节的打印调试法用一个短字符串如”ab “在本地运行对照手动计算的结果一步步核对。7.2 我用了BufferedReader但出现了RERuntime Error或WA可能原因1未处理字符串末尾的换行符或空白。BufferedReader.readLine()会读取一行包括换行符但会丢弃换行符。通常没问题。但如果题目输入可能有多行或者末尾有空格需要仔细处理。对于本题一行句子直接读取即可。可能原因2数组越界。如果你用了类似pressCount[ch]的数组且ch可能是大写字母或其他字符就会越界。题目说只有小写字母和空格所以理论上安全。但为健壮性可以加判断或使用更大的数组如256。可能原因3未关闭流或处理IOException。使用BufferedReader需要声明或捕获IOException。最简单的做法是在main方法后加throws IOException。7.3 我在本地运行正确但洛谷显示CECompilation Error可能原因1类名不是Main。洛谷要求公共类名必须为Main。检查你的代码开头是不是public class Main。可能原因2使用了不支持的Java版本特性。确保代码符合Java 8的基本语法。避免使用varJava 10、新的API等。可能原因3代码中存在中文字符或特殊格式。直接从某些编辑器复制代码可能会引入中文空格、全角字符等不可见字符。在提交前最好在纯文本编辑器如Notepad中检查一下或者直接在洛谷的编辑框里重敲关键部分。7.4 如何进一步提升此类题目的解题速度形成肌肉记忆对于键盘映射这种固定数据直接硬编码在代码里不要每次现推。模板化输入输出准备一个Java快速IO的模板遇到大数据量题目时直接套用。先画图再编码在纸上或注释里写出核心逻辑流程图或状态转移图能极大减少逻辑错误。写完即测每实现一个核心功能如单个字符计算就写个简单的测试验证一下不要全部写完再测。这道P1765“手机”题就像编程路上的一个老朋友它简单到不会让你畏惧却又严谨到足以让你反思。它提醒我们编程的本质是将现实世界的规则无歧义地翻译给计算机听。这个过程里对细节的锱铢必较对边界情况的穷追猛打正是新手蜕变为合格开发者的必经之路。下次当你再遇到类似“模拟”、“映射”、“状态判断”的问题时不妨回想一下这道题和它的“同键等待”陷阱那份审慎和周密会让你走得更稳。