刷洛谷的P1553时,我第一反应是:数字反转?这不就是把字符串倒过来吗。等我真正提交了几次,看到评论区里一排排WA,才发现这道“P1553 数字反转(升级版)”远没有名字那么人畜无害。整数还好办,一旦出现小数点、分数线和百分号,很多人第一次写的代码就开始翻车。这篇文章我就用实际做题的视角,把这道题的拆解思路、完整代码、边界测试和踩坑原因一次说清楚,给正准备入门字符串处理的同学一份可以直接照着写的参考。
1. 一题四吃:升级版到底升级在哪
1.1 四种输入形态背后的共同点
P1553和早期版本的“数字反转”最大的区别就是输入形态不再只有整数,而是整数、小数、分数、百分数四种数据混在一起。题目本身要求的动作其实只有一个,就是把数字部分倒过来,但不同的数据形态对“倒过来”的处理细节并不一样。
整数最简单,直接反转整个字符串,然后把前导零去掉。比如输入508,反转得到805,没有前导零问题;输入600,反转得到006,去掉前导零后是6。
小数要拆成整数部分和小数部分,分别反转。整数部分反转后去前导零,小数部分反转后同样要处理前导零。比如123.456,整数部分反转得到321,小数部分654反转后是456,因为654反转后没有前导零问题,输出就是321.456。
分数的处理逻辑和小数非常像,把分数线当成分隔符,分子分母分别反转,各自去前导零。百分数则是把百分号前面的数字反转后去零,百分号原样放回末尾。
仔细看你会发现,四种形态本质上共用同一套操作:找到分隔符,切开数字段,反转每一段,去掉每段的前导零,再拼回去。区别只在于分隔符不同,以及百分号的符号位置是在数字后面而不是中间。
1.2 为什么说它本质上是字符串题,不是数学题
很多人拿到这道题的第一反应是读成数字,用除法和取模做反转。这个思路对纯整数还有救,一旦遇到小数和分数就会出大问题。
最简单的例子就是前导零。输入600,如果用整数读取,得到的数值本来就是600,反转处理起来还可以;但输入700.00100,小数部分以零开头,而且末尾还有两个多余的零。如果先转成浮点数,精度丢失不说,末尾的零到底算不算有效位,浮点数根本表达不了。分数10/100里的分母100,转成整数后也看不出原始字符串里到底有几个零,而题目要求输出时只保留一个0,这部分信息只有字符串能完整保留。
所以P1553真正的考点是字符串处理基本功:定位符号、切片、反转、按规则去零、重新拼接。它不要求高深算法,但要求你对字符串操作的每个细节都心里有数。
我自己第一次做的时候也试图用C++的stoi偷懒,结果在小数部分吃了大亏。后来把思路切换到“纯字符串”之后,代码结构立刻清晰了,也意识到这类题天生就是给字符串处理练手的。
1.3 先建立稳定的处理模型
既然本质是字符串题,写代码之前最好先建立一个固定的处理模型,不要每次看到一种输入临时想一套逻辑。我的模型有四步:
- 识别输入类型,找到对应的分隔符(
.、/、%;没有分隔符就是整数)。 - 用分隔符把字符串切成若干段,百分号比较特殊,只需要去掉末尾的
%再处理数字段。 - 对每个数字段执行“反转 + 去前导零”的公共操作。
- 按原格式拼接回去:小数在中间加点,分数在中间加斜杠,百分数在末尾加百分号。
这套模型的好处是:所有数字段共用同一个反转去零函数,代码里不会出现“整数一种写法,小数另一种写法”的分叉,逻辑一致,出错的概率自然低。后面讲实现时,我也一直沿用这个模型。
2. 核心思路拆解:分离、反转、去零、拼接
2.1 符号分离:找对分隔符等于成功了一半
第一步是判断字符串属于哪种类型。判断顺序没有硬性要求,因为题目保证输入合法,不会出现同时包含小数点和分数线的情况。但我建议按分数、百分数、小数、整数的顺序来判断,逻辑上更顺:先处理带斜杠的,再处理带百分号的,然后处理带小数点的,最后剩下的就是纯整数。
如果你用C++,可以直接用find函数查子串:s.find('/') != string::npos就说明是分数。用Python则更简单,直接用in操作符判断:'/' in s。
这里有一个小坑:判断百分数时要小心。百分号在字符串末尾,比如100%,你不能简单地把%当成普通字符忽略,而是要把它单独切出来,留在输出末尾。同样,小数点的位置也要找准,我这边的做法是先找到分隔符的下标,然后用substr切出左右两段,确保分隔符本身不混进数字段里。
还有一个我见过不少新手踩的点:有些人会把100%单独拿出来特判,然后对100做整数反转。其实完全没必要,百分号只是分隔符的一种,去掉%之后剩下的数字段走公共的反转去零逻辑就行,代码能少一个分支。
2.2 反转操作:定制的去零函数才是核心
反转字符串本身不难,Python里一行[::-1]搞定,C++里reverse()函数也能直接改原串。真正的核心在反转之后的去零操作。
我推荐写一个公共函数,处理单个数字段,比如叫rev_clean。它的逻辑是:
- 反转这个数字段。
- 从左边开始删掉所有
0,但至少保留一个字符。
这个“至少保留一个字符”非常关键。比如输入整数0,反转后还是0,如果无脑删零就会变成空字符串,输出就没内容了。再比如小数0.000,整数部分0反转后是0,必须保留为0;小数部分000反转后也是000,删完前导零同样必须剩下一个0,这样结果才是0.0。
为什么不去掉反转后末尾的零?因为末尾的零在反转后的字符串里可能是有意义的。拿百分数100%举例,去掉%后数字段是100,反转得到001,删掉前导零得到1。这里1后面没有多余的零,问题不大。但如果是小数部分,删除规则要统一:题目要求的是“反转后不能有前导零”,并没有要求不能有尾随零。所以反转后只清左侧的零,不清右侧的零,这样最符合大多数题解和评测数据的处理方式。
2.3 拼接顺序:先处理完所有段,再拼符号
很多人在拼接阶段翻车,不是因为反转写错,而是因为拼接顺序乱了。我习惯的处理方式是:先把所有数字段处理完,保存成干净的字符串,最后再一次性拼回完整结果。
比如分数10/100,分子是10,分母是100。先单独处理分子:反转得到01,去前导零得到1。再单独处理分母:反转得到001,去前导零得到1。最后拼接成1/1。如果你边处理边拼接,很容易出现把1和1中间忘了加斜杠的情况。
百分数更要注意符号位置。百分号是后缀符号,要等数字段处理完后,用数字结果 + '%'的形式输出。我曾经见过有人把百分号放到了数字前面,输出%1而不是1%,这种错误在人工检查时很难发现,但提交就是WA。
小数拼接时,整数部分和小数部分之间用.连接。注意小数部分即使全零,也要保留一个小数点和一个0,比如0.000的输出应该是0.0,而不是直接把小数点也删掉。
3. 完整代码实现:Python 与 C++ 双版本
3.1 Python 版本:逻辑清晰,一行反转
Python 写这类字符串题非常舒服,内置的字符串切片天然支持反转,代码可以写得非常短。下面这份是我实际提交过的版本,核心逻辑全部收敛在rev_clean函数里。
def rev_clean(part: str) -> str: part = part[::-1] # 去掉前导 0,但至少保留一个字符 while len(part) > 1 and part[0] == '0': part = part[1:] return part s = input().strip() if '/' in s: a, b = s.split('/', 1) print(rev_clean(a) + '/' + rev_clean(b)) elif '%' in s: a = s[:-1] print(rev_clean(a) + '%') elif '.' in s: a, b = s.split('.', 1) print(rev_clean(a) + '.' + rev_clean(b)) else: print(rev_clean(s))重点看while len(part) > 1 and part[0] == '0'这一行。循环条件里先判断长度大于1,再判断首字符是不是0,两者缺一不可。如果输入是0,长度是1,不会进入循环,直接返回0。如果输入是000,反转后还是000,循环会一直删到只剩一个0,返回0。这个函数对任意数字段都安全。
s.split('/', 1)里的第二个参数1表示只分割一次,防止字符串里出现多个斜杠时把后面部分切碎。虽然题目保证数据合法,但写代码时多考虑一步总没错。小数部分同理,用split('.', 1)。
3.2 C++ 版本:手写更踏实,细节更可控
如果参加比赛或者做在线评测,很多同学更习惯用C++。C++没有Python切片那么方便,但reverse()函数可以原地反转字符串,写起来也不麻烦。
#include <bits/stdc++.h> using namespace std; void rev_clean(string &t) { reverse(t.begin(), t.end()); while (t.size() > 1 && t[0] == '0') { t.erase(t.begin()); } } int main() { string s; cin >> s; size_t pos; if ((pos = s.find('/')) != string::npos) { string a = s.substr(0, pos); string b = s.substr(pos + 1); rev_clean(a); rev_clean(b); cout << a << '/' << b << '\n'; } else if ((pos = s.find('%')) != string::npos) { string a = s.substr(0, pos); rev_clean(a); cout << a << '%' << '\n'; } else if ((pos = s.find('.')) != string::npos) { string a = s.substr(0, pos); string b = s.substr(pos + 1); rev_clean(a); rev_clean(b); cout << a << '.' << b << '\n'; } else { rev_clean(s); cout << s << '\n'; } return 0; }C++版本里我用了string::npos来判断find是否找到子串。s.substr(pos + 1)取出分隔符后面的部分,注意下标从0开始,所以pos + 1才是分隔符后的第一个字符位置。
很多人写C++时喜欢用while (t[0] == '0') t.erase(0, 1),这样写会有问题:如果字符串变成空串,再去访问t[0]就是未定义行为。所以一定要在循环条件里加上t.size() > 1,先保证还有至少一个字符,才去删零。这个细节在Python里也有对应版本,本质是同一个边界问题。
3.3 为什么不直接用int转换来去零
代码写到这里,可能有人会问:反转后直接转成整数,不就把前导零去掉了吗?比如001转成int变成1,Python里int("001")就是1,C++里stoi("001")也是1,不是更省事?
这个思路在纯整数反转里确实能跑通,但放到P1553里有两个致命问题。
第一是会溢出。题目给出的数字可能很长,反转后超过int甚至long long的范围很正常。一旦溢出,结果完全不可控。第二是语义问题,尤其在小数部分。比如小数部分0100,反转后是0010,用int转换得到10,输出时变成a.10。这本身就可能和题目要求不一致,而且不同人对“小数部分反转”的理解不同,直接用int会把字符串的原始形态毁掉,后续想调整格式都无从下手。
所以我在代码里坚持用字符串去零,而不是转型去零。这不仅仅是防溢出,更重要的是保证处理模型对四种数据形态完全一致,少一个特例,就少一个出错点。
4. 边界测试与踩坑实录
4.1 一组建议必测的用例
写字符串题最怕的不是逻辑复杂,而是边界数据没覆盖。我整理了一份测试清单,每次写完P1553的代码都会在本地跑一遍,比直接提交省心得多。
| 输入 | 类型 | 预期输出 | 测试意图 |
|---|---|---|---|
| 0 | 整数 | 0 | 单个零不能删成空串 |
| 600 | 整数 | 6 | 反转后的前导零要去掉 |
| 10200 | 整数 | 201 | 中间零保留,两侧零删除 |
| 0.000 | 小数 | 0.0 | 整数部分和小数部分全为零时都要保留一个0 |
| 123.456 | 小数 | 321.654 | 常规小数场景 |
| 10/100 | 分数 | 1/1 | 分子分母反转后各自去零 |
| 1/10 | 分数 | 1/1 | 分母反转后01去零变1 |
| 100% | 百分数 | 1% | 百分号要放回末尾 |
| 1200% | 百分数 | 21% | 反转去零后正常输出 |
这张表不用背下来,核心就是围绕“零”和“符号位置”两个维度测试。零的测试要覆盖左侧零、右侧零、全为零三种情况;符号的测试要覆盖小数点在中间、分数线在中间、百分号在末尾三种情况。
4.2 我实际提交中反复踩过的三类错误
第一类错是把零全删光。我最早写的去零循环没有判断长度,结果遇到输入0直接返回空字符串,拼接后输出就少了一块。后来加了“至少保留一个字符”的条件,这类问题才算根治。这个错误看起来很低级,但很多刚接触字符串处理的同学都会犯,本质上是没有意识到反转后的字符串长度可能本身就很小。
第二类错是小数部分按整数处理。有一次我图省事,反转小数部分后直接调用了stoi去转型,结果遇到700.00100这类输入,小数部分的末尾零被当成普通整数的一部分处理,输出格式和我预期完全不一样。后来我仔细读了题目,发现题目真正的考点就是“反转后去前导零”,而不是“数值等价反转”。所以不要自行创造规则,严格按题目描述的字符串操作来,代码才稳。
第三类错是百分号位置颠倒。我犯过把100%处理成%1的错,原因是拼接时先拼了百分号再拼数字。这种错误很难靠肉眼发现,因为样例里如果恰好没有百分数,你根本不会注意。所以我后来总结了一条规则:百分号不是分隔符结构里的一部分,而是后缀标记,处理完数字段以后原样追加即可。
4.3 关于小数部分的一些争议与处理心得
我必须坦白说,P1553的小数部分是网上讨论最多的地方。不同题解对小数部分的处理逻辑存在差异,尤其是遇到末尾带零的小数时,输出结果可能很不一样。比如某个输入的小数部分是00100,按反转后去前导零的逻辑,得到的是100;但如果你先按数值反转的思路,可能得到的是1。两种结果表面看都是对的,但放进OJ评测里可能只有一种能过。
我的建议是:先把你采用的规则明确写出来,再去对齐题目的描述。如果你用的规则是“反转每个数字段,再删除每段前导零,每段至少保留一个字符”,那就用这个规则统一处理所有类型,不要为小数部分单独发明新规则。我提供的代码就是把所有部分一视同仁,这样你在排查问题时只需要记住一个函数的行为,不用记一堆分支特例。
如果你提交后卡在小数数据上,优先去读题目原文里的“反转后不能有前导零”这句话,再对照自己的代码检查是否真的只删了前导零。很多时候WA不是因为代码写错,而是因为处理规则和题目预期有一字之差。
4.4 如何自己构造测试数据
除了上面那张表,我还会用一个小技巧来构造测试数据:先固定数字段,再用不同符号拼接。比如我先确定数字段是12345,然后分别生成12345、12345.67890、12345/67890、12345%,这样能快速检查同一个数字段在不同分隔符下是否都被正确处理。
更狠一点的测试方式是专门构造“全是0”的段,比如0.0、0/0、0%。这些输入虽然看起来极端,但往往能一针见血地暴露去零循环的边界问题。刷题多的人都知道,边界就是区分“会做”和“做对”的分水岭。
5. 这道题带给我的几点编程观
5.1 字符串题拼的是边界感
做P1553最大的收获,是意识到字符串处理的难点从来不在“怎么写反转”,而在“边界怎么守住”。一个while循环的条件少写了半个判断,一个空字符串就被你删出来了;一个分隔符的位置没找对,整个字符串就切错了。这些错误一旦发生,逻辑再怎么对都救不回来。
后来我再做类似的字符串题,都会先问自己一个问题:输入最短是什么样?输入最极端是什么样?这两个问题想清楚了,代码的骨架基本就稳了。这道题里最短的输入是0,最极端的输入是全部是零的小数,把它们都处理好了,普通用例基本不会出问题。
5.2 从刷题到工程:同一套方法在业务里也很常见
别觉得P1553只是竞赛题,字符串分割、去零、格式化拼接这套操作,在实际业务开发里到处都是。比如金额展示时把0012300转成12,300,日期处理时把2024-01-05拆成年月日再拼成2024年1月5日,本质上都是“定位分隔符→处理片段→重新拼接”的流程。
我甚至觉得,刷这种“简单题”比刷复杂算法题对工程帮助更大,因为日常工作里绝大多数代码都是在处理这种边界繁琐但不复杂的字符串逻辑。把P1553这种题吃透,你写业务代码时处理空字符串、处理多余空格、处理格式异常时会下意识地更谨慎,这本身就是一种很好的思维训练。
5.3 我的个人刷题习惯:先写清单再写代码
经历过P1553的几次WA之后,我养成了一个习惯:拿到题先不急着敲键盘,先在草稿纸上把输入类型、分隔符、每段要做什么、怎么拼接列成清单,然后对照清单写代码。这个习惯看起来浪费时间,但实际能省下大量调试时间,尤其是面对这种分类讨论比较多的题目。
P1553的清单我到现在还记得:整数=无分隔符反转去零;小数=切两段分别反转去零,中间加点;分数=切两段分别反转去零,中间加斜杠;百分数=去掉百分号反转去零,末尾加百分号。后来遇到更复杂的题目,我依然沿用了这个先清单后代码的方式,很少再因为漏掉某个分类而重写整个程序。这道题带给我的,不只是会做一类反转题,而是养成了一套面对分类讨论题目时的稳定打法。