逆序三位数拆解:数位、前导零与判题机边界
1. 先别急着交卷:这道 10 分题的含金量比看起来大
在题目集里翻到第 7 章第 3 题,看到“逆序的三位数”这五个字加一个刺眼的 10 分,很多人的第一反应是:这么简单的题,五分钟搞定,交完就走。我当年也是这么想的,结果第一发提交就挂在一个我自己都没意识到的用例上——输入120,我输出了021。那一刻我才明白,这道题真正考的不是“会不会写循环”,而是“能不能把一个自然语言描述,翻译成判题机愿意接受的字节序列”。
这道题做的事情非常朴素:读入一个三位数,把它各位数字的顺序倒过来,输出新的数。它能解决的问题也很具体——帮你把“输入解析、数位拆解、边界处理、输出格式”这四件最基础的事一次性走通。适合谁来参考?刚接触编程语法的同学、准备带新人的师兄师姐、以及刷了几百题但对基础细节仍然含糊的人。别小看最后这一类,我见过太多能写线段树却在scanf返回值上翻车的人。
我打算把这道题彻底拆开:从题面里那些容易被跳过的字眼,到三种不同解法的取舍逻辑,再到多语言的完整代码、自测用例表、判题结果对照表,最后延伸到它可以变出的七八个练手方向。全篇都是我实际操作和带人过程中积累的东西,能直接抄作业,也能拿去当讲稿。
2. 题目还原:一句话背后藏着四个判断点
2.1 逐字读题:哪些字是陷阱,哪些字是安全区
题面通常就两三行:给定一个三位数,请输出它逆序后的数。就这两三行,至少要抠出四个判断点。
第一个判断点是“三位数”这个词。它限定了输入范围,也就是 100 到 999 之间的正整数,百位不可能为 0。这一条直接决定了我们拆位的时候不用考虑“首位是零”这种输入情况,n / 100拿到的必然是个 1 到 9 的数字。第二个判断点是“逆序后的数”,注意是“数”不是“字符串”。数是没有前导零这个概念的,021在数学上就是21。这两个字之间的差别,就是这道题最大的坑。
第三个判断点是“正整数”还是“整数”。题面说三位数,一般默认正数,不会出现-123。但如果题面写了“整数”且没有明确排除负数,稳妥的做法是先按正数写一版提交,看结果再决定要不要补负号分支。
第四个判断点是输出格式。绝大多数判题机做的是逐字节比对,末尾多一个换行可能没事,多一句“请输入三位数”就必然报错。这四条抠完,题目的轮廓才算清楚。
2.2 判题机到底在比什么:一个被严重低估的常识
我在带新人的时候发现,很多人对判题机制的理解是空白的,他们以为程序会“看懂”自己在做什么。实际情况是,判题机把你的程序输出和标准答案都当成一堆字节,从头到尾逐个比对,通常只做两件宽容的事:忽略每行末尾多余的空格,忽略文件最末尾多余的空行。除此之外全都要对上。
这就解释了一个特别常见的现象:本地运行看着完全正确,一交就错。因为你在本地看到的请输入一个三位数:321,在判题机眼里是请输入一个三位数:321这一整行,而标准答案是321,第一个字节就不一样。所以从这道题开始,就该养成一个习惯:所有面向判题机的程序,代码里一个提示字符都不要有,输出就是纯结果加一个换行。
还有个小细节值得提一下:printf("%d\n", rev);和printf("%d", rev);在绝大多数平台上都能过,但如果题目明确要求行末换行,或者你后面要循环输出多组结果,还是老实加上\n。我自己是习惯性加上的,省得以后改题的时候忘。
2.3 三条技术路线:字符串、数学、格式化,怎么选
同一道题至少有三条路能走通,选哪条取决于你想练什么。
第一条是字符串反转:把输入当字符串读进来,倒着输出。优点是直观,几乎不用动脑;缺点是必须额外处理前导零,否则120会变成021。第二条是数学取模:用整除和取余把每一位抠出来,重新按权值拼回去,或者用循环“边取边拼”。这条路最贴近“数”的本质,也是这道题真正想让你练的。第三条是格式化输出:把三位分别取出来,直接按逆序的格式打印。这条路最短,但对位数固定的题才有效。
| 解法 | 代码量 | 前导零处理 | 通用性 | 推荐场景 |
|---|---|---|---|---|
| 字符串反转 | 最短 | 需手动处理 | 强,任意长度 | Python、脚本类语言 |
| 数学取模循环 | 中等 | 天然正确 | 强,任意长度 | C、C++、Java 等 |
| 三位拆解格式化 | 最短 | 天然正确(走整数) | 弱,只适用定长 | 考试手写、快速验证 |
我的建议是:第一次做用拆解法,务必把%和/的关系想透;第二次做换成字符串法,逼自己想清楚前导零为什么会冒出来;第三次做换成循环法,体会“算法与位数无关”带来的通用性。同一道题写三遍,比刷三道新题收获大。
3. 数位拆解的原理:为什么 123 能变成 321
3.1 拆位三件套:/100、/10%10、%10到底在干什么
先说清楚十进制数的结构。一个三位数n = 100a + 10b + c,其中a是百位、b是十位、c是个位。计算机不知道什么叫“位”,它只知道整除和取余,所以我们要用这两个运算把a、b、c反推出来。
n % 10得到c,因为对 10 取余会把十位及以上全部扔掉,只留个位。n / 10得到10a + b,相当于把个位砍掉、整个数右移一位,所以先除再取余n / 10 % 10就能拿到b。n / 100直接把后两位砍光,剩下a。这三步的直觉是:取余负责“留下低位”,整除负责“丢掉低位”。理解了这一层,四位数、五位数都是一回事,只是多除几次。
拿到a、b、c之后,逆序的数就是100c + 10b + a。这就是“按权值重新拼装”。以123为例:c=3, b=2, a=1,拼出来300 + 20 + 1 = 321。以120为例:c=0, b=2, a=1,拼出来0 + 20 + 1 = 21。注意这里c是 0,权值100c直接归零,前导零自动消失了。这就是为什么拆解法走整数路径时天然不需要额外处理前导零——零在数值里本来就不占位。
3.2 循环写法:把“定长”这件事扔掉
拆位三件套的毛病是它写死了三位。想让它通用,就得用循环:
int rev = 0; while (n > 0) { rev = rev * 10 + n % 10; n /= 10; }这个循环的逻辑值得慢放一遍。初始rev = 0。第一轮把原来的个位取出来,加到rev的个位位置上;第二轮rev先乘 10 左移一位,把上一轮的数字推到十位,再把新的个位塞进去;以此类推。对整个数来说,效果就是每读一位,就把已经攒下的结果整体左移,让新来的数字占据最低位。这个过程和“把一叠牌一张张翻到另一只手”是一样的。
拿120走一遍:第一轮rev = 0*10 + 0 = 0,n = 12;第二轮rev = 0*10 + 2 = 2,n = 1;第三轮rev = 2*10 + 1 = 21,n = 0,循环结束。结果是21,正确。再拿100走一遍:第一轮rev = 0,n = 10;第二轮rev = 0,n = 1;第三轮rev = 1,n = 0。结果1,也正确——100逆序是001,数值上就是1。
注意:这个循环的退出条件是
n > 0。如果输入可能为 0,循环一次都不会执行,结果就是 0,恰好也是对的。但如果输入可能为负,n > 0会直接不进循环,输出 0,这就错了。遇到带负号的情形,得单独取绝对值、记住符号,最后再拼回去。
3.3 字符串路线的雷:"021"和21是两个东西
用 Python 写,一行int(input()[::-1])就能过。这里int()起了两个作用:一是把字符串转成整数,二是顺手把前导零吃掉——int("021")等于21。很多人正是因为这一行太顺,反而没意识到前导零是个问题,一换成 C 语言写字符串版本就翻车。
C 语言的字符串版本要这么写:读进char s[10],从后往前逐字符输出,但要跳过末尾(也就是反转后的开头)的0。而且还要考虑极端情况:如果输入是100,反转后是"001",跳过前两个 0 之后还剩"1",没问题。但如果哪天输入允许是0,反转后是"0",跳过前导零就什么都不剩了,得补一个0的特判。这种“边界看着不会发生,但换道题就发生了”的情况,是字符串路线最大的隐患。
还有一点常被忽略:用printf("%c")逐字符输出的循环,容易忘了最后没有换行,导致下一组数据接着上一行。要求输出单个数的题目里看不出来,一旦是多组数据就会连成一片。所以我写这类代码时,宁可在末尾统一补一个换行,也不省那一个字节。
3.4 格式化输出法:最短的路,最窄的适用面
还有一条更短的写法:把三位拆出来,直接按逆序打印。
int a = n / 100, b = n / 10 % 10, c = n % 10; printf("%d%d%d\n", c, b, a);等等,这样写是错的。printf里的%d是十进制整数格式,c为 0 时会输出0,120就变成021了。得改成整体计算:
printf("%d\n", c * 100 + b * 10 + a);这才是对的。区别在于:前一种是把三个数字当字符拼,后一种是把它们当权值加起来。这个差别看着微不足道,实际是整道题的核心——你到底是在操作“数字的字符串表示”,还是在操作“数字本身”。想清楚这一点,后面所有变体题都不容易错。
4. 手把手实操:四语言完整实现与自测记录
4.1 C 语言版本:从一行scanf开始把细节钉死
#include <stdio.h> int main(void) { int n; if (scanf("%d", &n) != 1) { return 0; } int rev = 0; while (n > 0) { rev = rev * 10 + n % 10; n /= 10; } printf("%d\n", rev); return 0; }逐行说。第一行包含头文件,printf和scanf都在里面。int main(void)里的void写明不带参数,是个好习惯。if (scanf(...) != 1) return 0;这一行很多人会省,但我强烈建议保留:scanf返回成功读入的项数,如果输入是空的或格式不对,它返回 0,此时n是个未初始化的垃圾值,后续循环可能跑出莫名其妙的结果,甚至被判题机记为运行时错误。加这一句,等于给程序装了个保险丝。
循环体就是前面讲的“边取边拼”。最后printf("%d\n", rev)输出结果并换行。整个程序没有一句提示语,这是刻意的。
编译的时候我习惯加几个开关:
gcc -std=c99 -Wall -Wextra -O2 -o reverse3 reverse3.c-Wall -Wextra把常见警告全打开,-O2开优化。这道题用不上优化,但养成开警告的习惯,能提前发现“变量未使用”“类型不匹配”这类低级问题。我用-std=c99是因为有些平台用c89的标准,//注释和循环内定义变量会出问题,写之前确认一下平台的语言标准更稳妥。
4.2 C++、Python、Java 三个版本的差异点
C++ 版本基本是把 C 的输入输出换掉:
#include <iostream> using namespace std; int main() { int n; if (!(cin >> n)) return 0; int rev = 0; while (n > 0) { rev = rev * 10 + n % 10; n /= 10; } cout << rev << endl; return 0; }差异在于cin >> n失败时表达式的值为假,所以写成if (!(cin >> n))。另外endl会刷新缓冲区,题目规模小无所谓,数据量大时用'\n'更省时间。
Python 版本可以极简:
import sys s = sys.stdin.read().strip().split()[0] print(int(s[::-1]))用sys.stdin.read()而不用input(),是因为有些题目的输入末尾带多余空行,input()在某些情况下可能读到空串。split()[0]取第一个 token,然后把多余空白全部挡在门外。s[::-1]是切片反转,int()顺手吃掉前导零。如果要保守一点,也可以写print(int(input().strip()[::-1])),单组数据完全够用。
Java 版本的关键是类名:
import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); if (!sc.hasNextInt()) return; int n = sc.nextInt(); int rev = 0; while (n > 0) { rev = rev * 10 + n % 10; n /= 10; } System.out.println(rev); } }多数判题机要求主类必须叫Main且不能有包声明,这一条不知道会白白浪费好几次提交。另外Scanner比较慢,这道题无所谓,遇到大数据量要换成BufferedReader。
4.3 自测用例表:六个用例把边界全覆盖
写完代码别急着交,先在本地跑一遍下面这组用例。这张表是我自己常用的最小集合:
| 用例编号 | 输入 | 期望输出 | 覆盖的边界 |
|---|---|---|---|
| 1 | 123 | 321 | 常规情况,三位都非零 |
| 2 | 120 | 21 | 个位为零,考察前导零 |
| 3 | 100 | 1 | 个位十位都为零,最极端 |
| 4 | 101 | 101 | 逆序后与原数相同 |
| 5 | 999 | 999 | 上界,全 9 |
| 6 | 200 | 2 | 中间位为零 |
实测下来,第 2、3、6 这三个用例是最容易挂的,尤其是第 3 个。我见过有人用%03d输出,前五个用例全过,第 3 个输出001直接错。也见过有人用字符串拼接,第 1 个用例过,第 2 个输出021。
跑测试的时候建议写个小脚本,把输入输出重定向进去对比,比手动敲快得多:
for t in "123 321" "120 21" "100 1" "101 101" "999 999" "200 2"; do in=${t% *}; want=${t#* } got=$(echo "$in" | ./reverse3) [ "$got" = "$want" ] && echo "PASS $in -> $got" || echo "FAIL $in -> $got (want $want)" done这段脚本我几乎每道基础题都会改一改复用,几秒钟跑完全部用例,比在网页上点来点去省心。
5. 常见问题与排查实录:那些让人抓狂的坑
5.1 输出类问题:多一个字符就是零分
多打了提示语。这是新手最高频的错误,printf("请输入三位数:")一出,必挂。判题机不会跳过它,它只会认为你的答案开头多了六个汉字。
输出前导零。用了%03d或者字符串直接打印,120变成021。判断标准很简单:题面说“输出逆序后的数”,是数就按整数格式输出。
多输出空行。有人在printf后面又加了个printf("\n"),或者循环里每轮都换行,结果末尾多一两个空行。部分平台对此宽容,部分不宽容,别赌。
大小写和标点。这道题不涉及,但同类题目常要求输出Yes/No或带句号的句子,照抄样例最稳。
5.2 判题结果对照速查表
| 判题结果 | 常见原因 | 排查顺序 |
|---|---|---|
| WA(答案错误) | 前导零未处理、提示语、结果算错 | 先用120、100自测 |
| PE(格式错误) | 行末多余空格、多余空行 | 检查所有输出语句 |
| RE(运行错误) | 数组越界、除零、scanf未校验 | 检查数组长度与输入校验 |
| CE(编译错误) | 语言选错、类名不是Main、缺头文件 | 看编译信息第一行 |
| TLE(超时) | 本题不可能,除非死循环 | 检查循环条件是否恒真 |
这张表我贴在便签上用了很久。它最大的价值是:看到 WA 就别去查编译问题,看到 CE 就别去改算法。方向错了,越努力越远。
5.3 三个翻车现场,都是我或身边人真实遇到过的
现场一:scanf没校验,垃圾值进循环。某同学本地测试全过,提交后随机挂。查了半天发现是他把n定义在函数里没初始化,某次测试环境输入末尾多一个空行,scanf失败,n是栈上的随机数,可能是个七位数,循环多跑几轮,结果错得离谱。加上if (scanf(...) != 1) return 0;之后立刻稳定。
现场二:字符串反转后没去零。用 C 写字符串版本,char s[10]; scanf("%s", s);然后倒着printf("%c")。123过、101过、120输出021挂。他自己测试只用了123,觉得没问题。后来加了120用例才发现。教训是:自测用例一定要包含带零的数。
现场三:Java 类名写成Reverse。本地 IDE 跑得好好的,提交连续 CE 五次,以为是代码问题,改了半天逻辑。第六次才发现编译信息里写着“找不到主类”。这类错误纯粹是平台约定,跟算法一点关系没有,但代价是时间和心态。
提示:每次提交失败,先花十秒看判题反馈的具体信息,再决定改什么。凭感觉改代码是最浪费时间的排查方式。
5.4 一份我自己用的提交前检查清单
写完不着急交,按这个顺序过一遍,能省掉大部分无效提交:
- 输入有没有校验返回值,异常输入会不会让程序跑飞
- 循环退出条件对不对,输入为 0 或 1 时会不会死循环
- 结果的计算是走整数还是走字符,有没有意外保留前导零
- 所有输出语句里有没有多余的中文、空格、空行
- 本地跑了几个用例,有没有覆盖个位为零、全零结尾、回文这三类
- 提交时选的语言和代码实际语言是否一致
这个清单看着啰嗦,实际过一遍不到一分钟。我用它挡掉了至少几十次无谓的 WA。
6. 从 10 分题往外扩:这道题还能怎么玩
6.1 五个变体,一次把数位操作练透
这道题是个很好的跳板,稍微改一改就是五道新题。
变体一:任意位数逆序。把输入范围放开到 1 到 10^9,验证你写的是不是循环版本。这是检验“有没有真正理解算法通用性”的最快方式。
变体二:带负号的逆序。输入-123,输出-321。要点是单独处理符号,把绝对值送进循环,最后再拼回负号。这一改就会发现,while (n > 0)这个条件在负数下直接失效,必须重构。
变体三:判断回文数。逆序之后和原数比一比,相等就是回文。121、1001是,120不是。这个变体把“逆序”从终点变成了中间步骤。
变体四:逆序后是否溢出。如果输入范围放大到接近整型上限,逆序后可能超出int的表示范围。这时候要换long long,或者提前判断。这是从基础题通往工程思维的一道门。
变体五:位运算版逆序。把十进制换成二进制,做 32 位反转。思路完全一样,只是把/10和%10换成移位和掩码。做完这个再回头看十进制版本,会有种“原来是一回事”的通透感。
6.2 拿它当讲稿:怎么把“数位”这个概念讲明白
如果你要带新人,这道题是讲“数位”概念的绝佳素材。我一般会先问一个问题:123里的2,凭什么代表二十?答案是因为它站在十位上,位置决定了权重。然后我会写123 = 1×100 + 2×10 + 3×1,让大家看到“位”的本质是“权值”。
接着问第二个问题:怎么把2单独拿出来?这时候引出%和/。123 % 10 = 3,因为对 10 取余就是把 10 的整数倍全部扔掉;123 / 10 = 12,因为整除就是把不足 10 的部分扔掉。两个操作一个留低位一个丢低位,配合起来就能从右往左一位位剥。
最后问第三个问题:为什么120逆序之后不是021?这时引出“字符串表示”和“数值”的区别。021作为字符串有三个字符,作为数值只有一个21。题面说的是“数”,所以按数值处理。这一路问下来,比直接讲代码有效得多。
6.3 训练方法:用 10 分题练出工程习惯
我的建议是,每道这样的基础题写三个版本。
第一版叫能过版,目标只有一个:跑通样例。写完就交,看看结果。第二版叫边界版,专门针对个位为零、全零结尾、最小值、最大值这几类情况改代码,然后自己造用例验证。第三版叫复用版,把逆序逻辑抽成一个函数int reverse_int(int n),加上参数校验和注释,扔进自己的小工具库。以后遇到回文判断、数字翻转、进制转换,直接调用。
三个版本写下来,同一道题花的时间大概是别人的三倍,但收获也差不多是三倍。我自己的工具库里就躺着这么一个reverse_int,后来在好几个题目里省了事。基础题的价值不在于它本身有多难,而在于它是你反复打磨“手感”的最便宜的材料。
我个人的体会是,越是被标注为 10 分的题,越值得多花十分钟。因为难的题错了,你会去查资料、会去调试、会记住;简单的题错了,你往往只会骂一句“手滑”,然后下次继续手滑。真正拉开差距的,从来不是那些让人挠头的难题,而是这些看着一眼就会、实则处处埋雷的小题。下次再遇到“逆序的三位数”,别急着点提交,先把120和100敲进输入框里跑一遍。