1. 这道"深基"题,卡住了多少人的第一次提交
如果你在洛谷搜索框里输入"P5727",大概率会看到两种帖子:一种人贴出自己正序输出的代码,然后配一句"为什么全WA";另一种人在评论区回复"你把输出反过来就过了"。这道题作为《深入浅出程序设计竞赛》数组章节的例3,表面上是模拟冰雹猜想的变化过程,实际上真正想让你练的是"用数组存下中间结果,再反着打印"。很多刚接触信息学奥赛的新手,前面几道题做得顺风顺水,到这一题突然被"倒序输出"卡住,其实不是不会模拟,而是没读懂题面想要什么。
先把这个题的核心价值说清楚:P5727是一道纯粹练"递推/模拟 + 容器存储"的入门题,适合刚学完循环、正准备接触数组的同学。它不考任何高深算法,时间复杂度几乎可以忽略,但它在"读题"和"边界处理"两个维度上非常典型。你只要能把这题吃透,后面遇到"先计算再逆序输出"这类问题,基本上不用再花时间琢磨。
1.1 冰雹猜想到底是什么
冰雹猜想,也叫科拉茨猜想、3n+1猜想、角谷猜想。规则很简单:给出一个正整数,如果它是奇数,就乘以3再加1;如果它是偶数,就直接除以2。重复执行这两条规则,最终一定会落到1。
举个例子,从20开始:
20是偶数,除以2得10;10是偶数,除以2得5;5是奇数,乘3加1得16;16是偶数,除以2得8;8除以2得4;4除以2得2;2除以2得1。整个过程写下来就是:20→10→5→16→8→4→2→1。
这个数列跳来跳去,一会儿冲高一会儿回落,很像冰雹在云层里上下翻滚,所以叫"冰雹猜想"。虽然数学家到现在都没完全证明"所有正整数最终都会到1",但在洛谷这道题给定的数据范围内,这个性质是必然成立的,所以放心大胆模拟就可以了,不需要担心循环跳不出来。
1.2 为什么叫"深基5.例3"
熟悉洛谷的同学都知道,"深基"指的是《深入浅出程序设计竞赛》这套教材。第5章讲的是数组,例3就是这一题。教材把它放在数组章节,意图特别明显:希望你能把每一轮变化后的数字按顺序存起来,最后用数组的逆序遍历把结果倒过来输出。如果你只用一个变量从头算到尾,边算边输出,那你得到的是正序结果,正好和题目要求相反。
这道题的数据范围我记得是1到10的9次方这个级别。这个范围很有意思,正好踩在C++里int类型可能溢出的边缘上,后面我会专门花一章讲这个坑。入门选手如果只盯着"模拟过程"这件事,很可能在本地测试小数据时全都对,一提交就超时或WA,根源往往不在算法,而在数据类型的选用。
2. 题面真正的要求:不是把过程算出来,而是倒着说出来
读题是信息学竞赛里最容易翻车的一步,P5727就是活生生的例子。很多人看完题目描述,觉得"哦,不就是把变化过程输出嘛",直接写一个while循环,每变化一步就打印一个数,结果样例都过不了。
2.1 规则拆分与最容易写错的循环
先把规则拆成机械的步骤:
- 读入正整数n
- 把n放入过程序列
- 只要n不等于1,就重复:
- 如果n是奇数,把n改成3*n+1
- 如果n是偶数,把n改成n/2
- 把新的n放入过程序列
- 把过程序列倒序输出
有一个细节值得提醒:判断奇偶的依据,是"当前这一轮"的n值,而不是初始值。也就是说,n在变化过程中可能一会儿奇一会儿偶,循环体内每次进入都要重新判断。有的新手会把奇偶判断放在循环外面,只根据初始n决定后面一路怎么变,这显然是错的。
还有一个新手很容易忽略的点:先把初始的n存进序列,再进入循环。如果你先把n算一步再存,或者完全忘了存初始值,输出结果就会少一个数字。不信你试一下,输入20,如果忘记存初始的20,最终输出就少了一项,提交必WA。
2.2 倒序输出:为什么这道题放在"数组"这一章
我们继续拿20做例子。整个过程是20→10→5→16→8→4→2→1,按照题目的输出要求,你需要输出的是:1 2 4 8 16 5 10 20。很多第一次做这题的人会不理解:"凭什么要倒着输出?"其实你看题面给的样例输出就知道了,这个题目要求的就是倒序。
为什么教材要这样设计?因为正序输出太简单了,边算边打印就行,根本用不到数组。一旦要求倒序输出,你就必须把中间每一步存下来,等算完以后再从后往前访问。这正是数组最典型的应用场景。换句话说,这道题不是考你冰雹猜想的数学性质,而是考你"会不会用一个容器装数据,并且按指定方向遍历输出"。
这里我建议新手养成一个习惯:拿到题先看样例,把样例的输入输出手动推一遍。以20为例,自己在草稿纸上写出变化链条,再对照样例输出的顺序,你立刻就会发现"原来要倒着输出"。这个习惯能帮你避开至少一半的读题坑。
3. 可直接提交的代码:C++、Python与递归写法
思路捋清楚以后,实现就很直接了。用一个动态数组(C++的vector或者Python的list)记录每一步的结果,循环结束后从最后一个元素往前打印。
3.1 C++版:vector存储与倒序打印
我直接给出一个稳妥的C++写法:
#include <bits/stdc++.h> using namespace std; int main() { long long n; cin >> n; vector<long long> seq; seq.push_back(n); while (n != 1) { if (n & 1) { n = 3 * n + 1; } else { n /= 2; } seq.push_back(n); } for (int i = (int)seq.size() - 1; i >= 0; --i) { cout << seq[i]; if (i > 0) cout << ' '; } cout << '\n'; return 0; }几个细节说一下。判断奇数我用的是n & 1,这个位运算的意思是"看二进制最低位是不是1",等价于n % 2 == 1,速度略快,写法也干净。新人如果看不惯,写成if (n % 2 == 1)完全没问题,效果一样。
输出的时候,我在每个数后面判断一下:如果不是最后一个数,就输出空格,否则输出换行。这样能保证行尾没有多余空格,避免一些比较严苛的评测系统报Presentation Error。如果你懒得判断,直接每个数后面跟一个空格,大部分评测系统也能过,但我不建议养成这种习惯。
3.2 Python版:注意整除运算
Python写起来更短:
n = int(input()) seq = [n] while n != 1: if n % 2 == 1: n = 3 * n + 1 else: n //= 2 seq.append(n) print(*reversed(seq))Python这里有一个经典坑:整除必须用//,不能用/。/在Python3里得到的是浮点数,一旦出现小数,整个运算链就毁了。我用//,保证结果一直是整数。另外print(*reversed(seq))会把列表展开成空格分隔的一行,非常方便。
Python的int没有固定位数限制,不太存在C++那种溢出问题,但我在Python里也选择把所有中间结果放进列表,因为Python同样需要倒序输出,用列表天然合适。
3.3 不用数组也能倒序:递归写法
这一节算一个延伸思考。如果你学过递归,会发现在这里也可以不用数组,靠递归的"回溯"特性实现倒序输出:
void dfs(long long n) { cout << n; if (n == 1) { cout << '\n'; return; } cout << ' '; if (n & 1) dfs(3 * n + 1); else dfs(n / 2); }调用dfs(20),会先打印20,然后递归进去打印10,再递归进去打印5……一直到打印1之后开始回溯。因为每一层都在"进入下一层之前"先打印了当前数,所以最终屏幕上出现的顺序是20、10、5、16、8、4、2、1——注意这是正序,不是题目要求的倒序。如果你非要靠递归实现倒序,可以把输出语句放到递归调用之后,也就是"先递归到底,再一层层回来的时候打印",这样就能得到1、2、4、8、16、5、10、20的顺序。
不过这道题我并不建议新手用递归。它放在数组章节,核心考点就是数组的逆序访问,用递归属于"炫技",而且递归初学时容易绕晕,不如老老实实开个vector。等以后你熟练了,再回头品味这些不同写法之间的联系也不迟。
4. 最多的WA来源:隐藏在3n+1里的整数溢出
这道题最大的坑,不是输出顺序,而是数据类型。我见过大量提交记录卡在这里,小数据全对,一提交不是WA就是TLE,最后发现是int溢出。
4.1 int上限与溢出后的诡异行为
C++里int是32位有符号整数,上限是2147483647,也就是大约21亿。题目给的n可能到10的9次方,也就是10亿,看起来10亿小于21亿,读入没问题。但问题在于,冰雹猜想变化过程中有一个关键操作:奇数变3n+1。
假设n是10亿零1,这是一个奇数。下一步需要计算3×1000000001+1,结果是3000000004。这个数值已经超过了int能表示的最大正值2147483647。在常见的补码机器上,这个值会环绕成一个负数。从语言标准的角度说,有符号整数溢出属于未定义行为,但在绝大多数实际编译环境中,你看到的就是这个数字变成负数,然后程序的行为开始失控。
一旦n变成负数,事情就麻烦了。下一次循环判断奇数时,负数按位与1的结果仍然可能是1,程序会继续执行3n+1,在负数的世界里越陷越深,永远收敛不到1。你的while(n != 1)会变成一个死循环,最后评测系统报"Time Limit Exceeded"。这也是为什么有些同学测试小数据时没问题,因为小数据的中间结果根本碰不到int上限;一旦数据范围一大,立刻翻车。
4.2 溢出的边界值计算
我帮你算一下这个溢出的临界点。int能表示的最大值是2147483647,3n+1小于等于这个值的条件是3n+1≤2147483647,也就是n≤715827882。换句话说,当n是奇数且大于715827882时,第一步就会突破int上限。
这个数字并不遥远。洛谷这题的数据范围如果给到10的9次方,那么大量输入从一开始就会触发溢出。更麻烦的是,冰雹猜想的中间值并不一定是"先增大后减小"那么温和,它会在序列中反复冲高,峰值可能远高于初始值。即使初始n只有几百万,序列中间也可能出现比较大的数字。所以不管你输入是多少,把所有中间变量和存储容器都放宽到long long,是最稳妥的选择。
4.3 从变量到容器,全程long long
不少新手认为"只要循环里的n用long long,数组用int存没事,反正最终结果都是正数"。这个想法是错的。你vector里存的虽然是long long计算出来的结果,但如果vector ,每个元素在存入时都会被截断成int,溢出数据照样丢失,后面的逆序输出自然也是错的。
正确的做法是全程统一:读入用long long,循环变量用long long,vector ,递归参数也用long long。一层都不能漏。还有一点,如果你用printf输出long long,格式要写成%lld而不是%d,漏了会得到莫名其妙的输出。如果不想纠结格式串,直接用cout最省心。
5. 提交失败对照表:从输出顺序到边界特判
做题最烦的不是不会,而是"本地全对,一交就WA"。我在洛谷讨论区看到过太多P5727的求助帖,问题来来回回就那么几个。这里我整理一份对照表,你提交前逐条检查,能省下不少冤枉时间。
| 症状 | 大概率原因 | 修复方式 |
|---|---|---|
| 输出是正序,样例都对不上 | 没理解"倒序输出" | 用数组存储,最后从后往前遍历 |
| 输入1时输出为空 | 先进入循环再存数 | 先把初始n存入序列,再开始循环 |
| 输出结果少了初始数字 | 忘记把起始n push进去 | 循环前先push_back(n) |
| 运行超时 | int溢出导致负数死循环 | 全程改用long long |
| 答案错误且数值很大很怪 | vector元素还是int,发生截断 | 容器类型也改成long long |
| 行尾多空格被判格式错 | 输出循环逻辑不严谨 | 最后一个元素后换行而非空格 |
| 小数据全对,大数据WA | 边界条件没覆盖 | 手动测n=1、n=715827883等 |
5.1 常见错误与修复方式
第2条"输入1时输出为空"值得单独说一下。如果代码写成这样:先while(n != 1)再存结果,那么当n本来就等于1时,循环体一次都不执行,序列为空,输出自然什么都没有。实际题目要求输出1,因为变化过程就一个数:1。解决方法是先把初始的n存进序列,或者对n==1单独特判输出1。
关于"正序输出"这个问题,我当年第一次做也踩了。我当时的想法是:题面明明说"输出变化过程",那我一步一步打印有什么问题?后来看了样例输出才发现它给的是反过来的。这个经历让我养成一个习惯:任何题目,先看样例,再动手写代码。样例不会骗人,它比题面的大段描述更容易暴露真实要求。
5.2 一套完整的自测流程
我推荐新手在提交前,按下面的流程自测一遍,尤其是对于P5727这种入口简单但细节多的题:
第一步,先在草稿纸上手推一个简单样例。比如输入20,手动算出20→10→5→16→8→4→2→1,然后模拟代码输出,看看是否得到1 2 4 8 16 5 10 20。如果这一步对不上,说明思路就有问题,先别急着提交。
第二步,测试边界n=1。期望输出是"1"。
第三步,测试一个稍微大一点的奇数,比如n=1000000001。这一步是为了检查你的程序是否会死循环,如果用的是long long,很快就能出结果。
第四步,把代码里的调试输出全部删掉。有些同学喜欢在循环里加cerr << n << endl来看中间过程,这个可以,但提交前记得清理。cerr的输出会走标准错误流,虽然不影响答案,但是会在评测系统里留下多余内容,万一把错误流和答案流混在一起,后果很麻烦。
6. 这类模拟递推题,学会一个套路就能秒一片
P5727做完以后,我强烈建议你别急着继续往下刷,停下来复盘一下这道题背后的通用解法。信息学竞赛里有一大类题目可以归为"模拟递推 + 反序输出",它们的套路几乎完全一样。
6.1 通用三步法
第一步,把题目规则机械翻译成循环。不要思考任何优化,先把"把奇数变3n+1、偶数除以2,直到1"这种规则一字不落地写成代码。模拟题最忌讳自作聪明跳过某些轮次,你就是老实按规则走,结果通常不会错。
第二步,根据数据范围确定类型。这是很多人直接忽略的一步。看到数据范围可能超过int,就要立刻把long long拿出来。我建议新手开一个习惯:只要是洛谷题,除非确定范围很小,否则变量类型一律往大里开。反正long long在64位机器上和int性能差距很小,不存在超时风险。
第三步,判断输出方向。题目要求正序输出,你就边算边打印;题目要求倒序输出,你就开一个数组或vector存下来,最后逆序遍历。很多题目会把输出顺序当成一个隐含考点,你多留一个心眼就能少错一次。
6.2 后续可以怎么扩展
这套"先存储再逆序"的套路,本质上是在练习"结果的呈现顺序不一定要等于计算顺序"。你以后会遇到很多变形题:有的要求把计算过程存下来后按奇偶分组输出,有的要求把中间结果插入到某个特定位置再输出,还有的会要求你同时记录"每一步的序号"。
举个很常见的例子:类似P5727的题目,题目不会明说"请倒序输出",而是给一个看起来莫名其妙的样例输出,让你自己推断。这时候读样例就成了最重要的能力。我见过不少选手不是不会代码,而是花了半小时还没搞懂样例为什么长那样。信息学竞赛里,读题能力本身就是一道隐形的坎。
另外,如果哪天你学到递归,可以回来看看这题。你会发现用递归做倒序输出比数组更优雅,但你要理解递归的调用栈本质上也是一个"数组",它同样是在保存每一层的信息,最后从栈顶一层层弹出来。数据结构学到后面你会越来越觉得,"数组存一下再倒着看"这个思想极其基础,极其重要,几乎所有领域都在用。
最后分享一点我个人的做题体会。P5727这种入门题,你花一下午把各种奇怪写法都试一遍,其实比快速AC更有价值。试着用int写一版,亲眼看看它怎么爆;试着边算边打印,看看正序和倒序的区别;试着把vector换成固定长度数组,看看越界报错是什么样的。这题考的不是你能不能AC,而是你有没有真正理解模拟、存储和输出之间的配合逻辑。刷题数量固然重要,但像这种信息量集中的好题,多折腾几次,比盲目刷十道简单题管用。