拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

小鱼的数字游戏:数组倒序输出的多语言解法与踩坑指南

小鱼的数字游戏:数组倒序输出的多语言解法与踩坑指南

1. 一道"小鱼的数字游戏",为什么能让新手原地卡壳

这道题我第一次刷的时候,心里想的是"这也算必刷?"。题目短、场景萌,读起来不过三行:小鱼看到一串数字,以 0 结尾,它要把这些数字倒着念出来。乍看就是"输入一串数,倒序输出"而已。但等我真正动手写代码,才发现里面藏着的坑一个接一个:输入什么时候停?停下来的那个 0 算不算数据?如果数字个数事先不知道,数组到底开多大?输出最后一个数字后面要不要带空格?这些细节不亲手踩过,根本不会意识到。

我后来把这道题放进自己的"数组入门必刷清单",是因为它用最小的问题模型,把数组相关的四个基本功全考了一遍:第一,怎么处理不确定长度的输入;第二,怎么把读到的数据按顺序存进数组;第三,怎么用下标访问数组,尤其是下标从 0 开始这个事实;第四,怎么控制输出的格式,既不丢数据,也不多输出。很多读者觉得算法题难在思路,但《小鱼的数字游戏》恰恰是"思路一句话,实现十处坑"的典型。它考验的不是你会不会背题解,而是你对数组这种最基础数据结构,是否真的养成了肌肉记忆。

更关键的是,这道题在众多算法题单里被放在"必刷"位置,不是因为得用多么高深的技巧,而是因为它能帮你打通"输入-存储-遍历-输出"这条最核心的链路。后面你刷二维数组、指针数组、动态数组、字符串数组,本质上都是在这条链路上变花样。比如你看到树状数组的模板题,会觉得复杂,但追根到底,仍然是"把数据放进某种结构,再按某种规则取出来"。《小鱼的数字游戏》就是这个最初的原点。

所以别看它简单,我建议你把它当成一个"标准件"来刷:至少用三种语言各写一遍,再用递归和栈各写一遍。当你发现所有写法都能在五分钟内一次通过时,你对数组的掌控感会完全不一样。以下我会从最朴素的固定数组解法一路讲到递归和栈,把每步背后的理由和常见的翻车现场都摊开说。

2. 先啃最朴素的解法:固定长度数组加倒序输出

2.1 完整代码与逐行拆解

如果是在 C 语言环境下做这题,大多数题解的第一版会长这样:

#include <stdio.h> int main() { int a[1000]; // 假设最多 1000 个数,够用了 int n = 0; // n 记录实际读了多少个数 int x; while (scanf("%d", &x) == 1 && x != 0) { a[n] = x; n++; } for (int i = n - 1; i >= 0; i--) { printf("%d ", a[i]); } return 0; }

这段代码耐着性子拆开看,其实每个字符都在跟"数组"打交道。

先看int a[1000]。这是声明一块连续的内存空间,下标从 0 到 999。题目没说最多输入多少个数,只说"以 0 结束",所以现实中我们必须给数组定一个尺寸。这里的 1000 是我随手写的,很多初学者看到这种"拍脑袋"的数字就害怕,觉得不严谨。但很多 OJ 题目确实不会直接告诉你输入上限,你只能根据题目描述和数据范围来估算。比如题目说"小鱼看见一串数字",没有给具体长度,那 1000 通常够用——但够用不等于安全,后面我会专门讲怎么避免在这个地方翻车。

int n = 0是计数器。每存一个数,n 加一。它同时暗示了下一个空闲位置在哪:第一次读到的数存在a[0],第二次存a[1],第 n 次存a[n-1]。最终 n 就是有效数字的个数,也是倒序遍历的起点。

进入循环后,scanf("%d", &x) == 1表示成功读到一个整数;&& x != 0表示读到 0 就停止。注意这个写法把"读取成功"和"不是 0"两个条件写在了一起,顺序不能交换。如果先判断x != 0,但你还没读到 x,或者读到文件末尾时x的内容是未定义的,逻辑就会出问题。scanf返回 1 说明确实有输入,这样后面的判断才有意义。

循环体a[n] = x; n++;是标准的"存数-后移"操作。很多新手喜欢写成a[n++] = x;,虽然能少写一行,但初次接触时反而容易混淆。我建议一开始就老老实实分开写,等彻底理解下标变化后再压缩。

最后是循环输出:

for (int i = n - 1; i >= 0; i--) { printf("%d ", a[i]); }

如果读入了 5 个数,它们分别存在a[0]到a[4],那么倒序输出的起点就是n - 1 = 4,终点是i >= 0。这里有三个容易写错的地方:写成i = n会访问a[5],越界了,Windows 下程序可能直接崩,Linux 下可能"碰巧"输出一个垃圾值;写成i > 0会漏掉a[0],也就是第一个读入的数;写成i--还是i--后置自减,倒序没问题,但如果用死循环配合 break 就不太直观。所以,这短短一行循环,其实是在检验你对"数组下标边界"的敏感度。

2.2 为什么数组下标要从 0 开始

每次讲数组,总有人问:为什么不从 1 开始,多好理解啊?从 1 开始,第一个元素是a[1],第 n 个元素是a[n],循环也好写。但实际上,数组下标从 0 开始是 C 语言的设计基石,因为这直接和内存地址挂钩。

数组是连续内存的抽象。a[i]的本质是"从首地址偏移 i 个元素大小的内存"。如果首地址是 base,每个元素占 size 个字节,那么a[i]的地址就是base + i * size。下标从 0 开始,意味着第一个元素偏移量为 0,正好落在首地址上,不需要多余的计算;如果下标从 1 开始,访问a[1]就要做一次base + (1-1)*size的换算,虽然现代编译器能优化掉,但语言设计之初追求简洁高效,所以干脆让数组下标从 0 开始,让"第 i 个位置"就是"偏移 i 个位置"。

这一点在倒序时尤其明显。a[n-1]是最后一个元素,a[0]是第一个元素。如果下标从 1 开始,倒序就得从a[n]到a[1],别扭感一样存在。既然 C 的数组天生从 0 开头,我们不如彻底接受它,并把它刻进骨子里:凡是"用下标遍历",都要时刻问自己,起点和终点到底是谁。

2.3 数组初始化的坑:memset 和局部变量的默认值

固定数组解法还有一个隐藏考点:要不要初始化a?代码里我用int a[1000];直接声明,然后只写了n个位置,后面倒序也只访问这n个位置。只要保证所有访问都发生在0到n-1之间,数组里其他位置是什么值根本无关紧要。

但很多初学者会被"初始化数组"这个概念绊住。有人习惯写int a[1000] = {0};,把整个数组清零,觉得这样更"干净"。对于本题这完全没必要,反而可能养成坏习惯——因为你没有访问未赋值的区域,清零是在浪费计算资源。更大的坑在于,如果使用局部变量却不初始化,编译器有时候会给出警告或随机值。比如你可能这么写:

int a[1000]; int n; int x; while (...) { a[n] = x; n++; }

这里n没有初始化,它的初始值取决于栈上残留数据,可能是个随机值。于是a[n]可能写到数组之外,程序莫名其妙崩溃。我见过太多同学排查半天查不到原因,最后发现只是忘记给n赋零。所以,固定数组解法的第一原则不是"清空数组",而是"让计数器从 0 开始"。

另外,如果确实想把一个数组初始化成全 0,可以用memset(a, 0, sizeof(a)),但要注意memset是按字节填充的,对int数组来说,填 0 恰好有效,填 其他数字(比如 1)却不会得到 1。如果哪天你想把数组全初始化为 -1,就别用memset了,老老实实用循环。这些细节都属于数组操作的基础功,在必刷题里多锻炼,后面自然就顺手。

3. 多语言横向对比:用 Java、Python、C++ 怎么写更舒服

一道算法题用多种语言各写一遍,价值在于帮你剥离"语言特性"和"算法本质"。数组的存储与倒序思想是不变的,但每种语言提供的容器不同,写出来的代码风格也不同。

3.1 Java:用 ArrayList 解决长度未知问题

Java 的普通数组和 C 差不多:int[] a = new int[1000];,也需要手动维护n计数器。但更舒服的做法是直接用ArrayList<Integer>,它的长度可以动态增长,完全不需要预设上限:

import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); ArrayList<Integer> list = new ArrayList<>(); while (sc.hasNextInt()) { int x = sc.nextInt(); if (x == 0) { break; } list.add(x); } for (int i = list.size() - 1; i >= 0; i--) { System.out.print(list.get(i) + " "); } sc.close(); } }

ArrayList底层仍然是数组,但它帮你管理了扩容逻辑。当你add到第 11 个元素时,底层数组会翻倍拷贝,然后把新元素放进去。这个细节在学习阶段很有价值:你知道它方便,但也应该知道它背后的"复制"成本。如果题目的数据量达到十万、百万,频繁扩容是有开销的,Java 的话你可以预估容量后直接new ArrayList<>(100000),给初始容量,减少扩容次数。

倒序输出时,使用list.size() - 1当作起点,和 C 语言的n - 1一个道理。唯一的差异是:Java 的for循环写System.out.print(list.get(i) + " "),末位会多一个空格。很多 OJ 对"行末多余空格"是宽容的,但也有严格判题系统会判 Presentation Error。如果你的强迫症犯了,可以用一个flag来控制第一个输出前不打印空格,但那是另一个话题。

3.2 Python:list 加切片或 reversed 一步到位

Python 写这道题,代码量能少到惊人:

nums = [] while True: x = int(input()) if x == 0: break nums.append(x) print(*nums[::-1])

nums.append和 Java 的add类似,是动态数组list在尾部追加元素。nums[::-1]是切片倒序,返回一个新列表,*是把列表元素展开成多个参数传给print,默认用空格分隔,正好满足题目要求的输出格式。

但这份"优雅"背后,你一定要理解它到底做了什么。nums[::-1]会创建一个长度等于原列表的新列表,然后逐个复制元素。对于这道题,数据量不大,无所谓。如果你在数据量巨大的场景下追求内存效率,可以改用reversed(nums),它返回一个迭代器,不会额外复制整个列表:

print(*reversed(nums))

reversed是惰性求值,从尾部向前遍历。不过把迭代器展开到print里时,还是会一次性处理所有元素,但至少没有中间副本。对初学者来说,我更推荐先写显式的循环:

for i in range(len(nums) - 1, -1, -1): print(nums[i], end=' ')

这行代码更接近 C 语言的眼界,也更容易和数组下标概念对上号。Python 的切片是很香,但如果你依赖它而忘记了底层下标逻辑,等你去做 C/C++ 的题目时会很不适应。我自己的习惯是:竞赛时怎么快怎么来,写题解时一定要写出那个显式循环,把基础夯实。

3.3 C++:vector 与反向迭代器

C++ 的vector既保留了数组的连续内存,又具备动态扩容能力:

#include <bits/stdc++.h> using namespace std; int main() { vector<int> v; int x; while (cin >> x && x != 0) { v.push_back(x); } for (auto it = v.rbegin(); it != v.rend(); ++it) { cout << *it << ' '; } return 0; }

cin >> x && x != 0的逻辑和 scanf 版本一致:成功读入且不是 0。rbegin()指向最后一个元素,rend()指向第一个元素之前的位置,反向遍历时用++it实际上是向开头移动。这其实就是"倒序"的迭代器语法糖。

你也可以不用迭代器,直接用下标:

for (int i = (int)v.size() - 1; i >= 0; --i) { cout << v[i] << ' '; }

要小心v.size()的返回类型是size_t(无符号整数),如果写成for (int i = v.size() - 1; i >= 0; --i),当v.size()为 0 时,v.size() - 1会变成一个巨大的无符号数,然后被转换成int,通常是 -1 或溢出,导致循环不执行或死循环。稳妥做法是加一个(int)强制转换,或者用v.empty()特判。这也是数组题里经典的"无符号整型"大坑,我能想到,是因为我踩过。

3.4 不同语言在"逆序"上的思路其实一样

对比下来你会发现,无论 C、Java、Python、C++,核心都是四步:初始化一个能存储数据的容器、读入、判断终止条件、倒序访问。语言容器换了几种,但"下标从后向前走"的思想从未变过。

这也解释了为什么数组题总是被拿来当"第一关"。你通过这道题掌握的,不是某一种 API,而是一种抽象能力:把一组有序数据放进一段连续空间,然后按任意顺序访问它。这种能力不绑定语言,在各种业务场景里都会用到,比如 VBA 里处理一列数、JavaScript 里操作 JSON 数组、MATLAB 里取出矩阵的多列,本质上都是"选一种容器,按索引访问"。所以,如果你能把《小鱼的数字游戏》用两三种语言刷熟,后面的路会顺很多。

4. 不建数组也能逆序:递归与栈的底层逻辑

上面所有解法都建了数组(或类似数组的容器)。但《小鱼的数字游戏》还有两种更"反直觉"的解法,它们不显式使用数组,也能达到同样的效果。理解这两种解法,能加深你对"程序调用栈"和"数据结构栈"的理解。

4.1 递归解法:函数调用栈替你存数据

递归版 C 代码极其简洁:

#include <stdio.h> void solve() { int x; if (scanf("%d", &x) != 1) { return; } if (x == 0) { return; } solve(); // 先递归读后面的数 printf("%d ", x); // 回溯时输出当前数 } int main() { solve(); return 0; }

这个函数做的事情是:读一个数 x,如果它既不是输入结束也不是 0,就先不输出,而是去递归调用solve()读下一个数;等递归调用全部返回后,再输出 x。

为什么这样就能倒序?因为函数调用本身是"后进先出"的。第一次调用solve()读到的 1,会等待第二次调用返回后才输出;第二次调用读到的 2,会等待第三次调用返回后才输出……最后一次读到 0 时,直接返回,不输出。于是最内层的调用先返回,先输出最后读到的那个非零数,然后一层一层回溯,输出之前的数。最终输出的顺序就是输入顺序的逆序。

递归解法妙就妙在,它把"数组"藏进了系统调用栈。每个函数的局部变量 x 都存在栈帧里,调用栈天然支持后进先出,所以不需要显式数组。代价是什么呢?如果输入的数字非常多,递归层数会很多,系统栈可能不够用,导致栈溢出。对于这道题 1000 个数问题不大,但如果是十万个数,递归就可能爆栈。这也让你直观理解为什么有些算法题会限制递归深度,为什么很多生产环境里"递归转循环"是一个基本优化手段。

4.2 手动模拟栈:用数组实现 LIFO 行为

用数组模拟栈,代码会这样:

#include <stdio.h> int main() { int stack[1000]; int top = 0; int x; while (scanf("%d", &x) == 1 && x != 0) { stack[top++] = x; } while (top > 0) { printf("%d ", stack[--top]); } return 0; }

这里的stack本质上还是一个数组,但用法完全向"栈"靠拢:top指向栈顶的下一个空位,push就是stack[top++] = x,pop就是--top后取出stack[top]。第二个while循环每次--top,输出的顺序天然是倒序。

你会发现,显式栈和递归是同一枚硬币的两面。递归靠系统维护的调用栈,显式栈靠我们自己维护的数组。当你写stack[top]时,就是在模仿递归中的"返回地址和局部变量"。很多算法题,比如树的遍历、括号匹配、表达式求值,都可以用这两种方式互相转换。把《小鱼的数字游戏》当作"栈的入门实验",你会更容易理解"调用栈"不是一个抽象概念,而是实实在在的内存行为。

4.3 三种解法的时空复杂度对比

我做题时喜欢列一张小表,帮助自己判断用什么解法最合适:

解法额外空间时间复杂度风险点
固定数组O(N)(N 是实际数字个数,但需预设上限)O(N)数组上限不够、计数器未初始化
动态容器(Java ArrayList / C++ vector / Python list)O(N),可能略大于 N(容量冗余)O(N)扩容带来的摊销成本
递归O(N)(调用栈空间)O(N)N 过大时栈溢出
显式栈(数组模拟)O(N)O(N)和数组一样有上限

注意,递归解法的空间复杂度是 O(N),这往往被新手忽略。他们以为递归"不用数组就省内存",其实系统栈占的空间不比数组小,甚至往往更大。所以,递归更适合让你理解运行时栈的概念,不适合用来压缩空间。

这四种解法我都建议亲手敲一遍。敲完之后你会明白:算法题的"最优解"不一定是最花哨的,而是要和题目数据范围、语言特性、运行环境匹配。像《小鱼的数字游戏》这种小数据题,用哪种都行,但你必须清楚每种解法底层的空间开销,因为日后刷到大数据量的数组题,这些资源意识会救你一命。

5. 我做这道题踩过的坑和总结出的刷题姿势

题目简单,不代表没有坑。我把这几年教新手时最常见的错误整理了一份,你如果还没踩过,很可能会在某个深夜被它们折磨。

5.1 坑一:把结束标志 0 也当成有效数字输出

最常见的翻车写法是:

while (scanf("%d", &x) == 1) { a[n++] = x; } // 然后直接把 a[0] 到 a[n-1] 倒序输出

这样会把结束标志 0 存进数组,输出时也会输出 0。题目要求"小鱼看到一串数字,0 表示结束",0 本身不是游戏内容,所以必须排除。你需要在循环体里判断,或者直接在循环条件里把x != 0写上。这个错误往往不会被编译器察觉,运行结果也"看起来正常",只是多了一个 0 在答案末尾,导致判题 WA。

5.2 坑二:数组开太小,直接越界

我见过有人写int a[5];然后读入 100 个数字。程序可能当时没崩,因为越界写到了相邻内存,覆盖了其他变量,或者幸运地写进了未使用的内存。但这种"没崩"是最危险的,因为提交到 OJ 后可能因为内存校验而 Runtime Error,而且复现起来很蛋疼。

更隐蔽的是,有些同学知道要开大数组,但不知道"大"的上限,于是写了int a[1000000];放在 main 函数里。这在某些 OJ 上会导致栈内存不够而崩溃,因为局部大数组占的是栈空间,栈通常只有几 MB。解决方案有两个:一是把数组声明为全局变量(静态存储区),二是用动态内存分配malloc或new。如果你在用固定数组刷题,建议一开始就把数组放在全局区域,避开栈大小限制。

5.3 坑三:把scanf返回值当成可选项

while (scanf("%d", &x) == 1 && x != 0)里的== 1不是装饰。有些 OJ 的输入是纯数字,有些可能会夹杂换行、文件结束符。如果你只写while (scanf("%d", &x)),当输入结束时scanf返回EOF,其值是 -1,在逻辑判断里是"真",循环不会终止,程序会不断读取失败,陷入死循环。所以,任何涉及不确定输入长度的题目,都要习惯性地检查scanf的返回值。这是 C 语言的"握手协议",确实啰嗦,但很安全。

5.4 刷题姿势:先写伪代码,再翻译成语言

我给自己定了一条规矩:除非是送分题,否则绝不在拿到题后立刻写代码。至少先花一分钟把思路用自然语言描述出来。这道题的伪代码可以写成:

初始化一个存储整数的结构(比如数组) 计数器 n = 0 循环读入整数 x 如果读入失败,退出循环 如果 x 等于 0,退出循环 把 x 存入结构,n 加 1 从 n-1 到 0 逐个输出结构中的数

写完伪代码,你就可以往任意语言里填语法。C 的int a[1000],Java 的ArrayList,Python 的list,C++ 的vector,都是"存储整数的结构"。你会发现,语言差异只存在于"容器声明"和"API 调用",逻辑骨架是完全共通的。

最后分享一个实际经验:我刷这题时,喜欢把"输出最后一个数字后不输出多余空格"也一起实现。做法是用一个布尔变量控制:

for (int i = n - 1; i >= 0; i--) { if (i < n - 1) printf(" "); printf("%d", a[i]); }

这样输出格式是5 4 3 2 1,而不是5 4 3 2 1。虽然题目多半不会判错,但严格模式下,这种干净输出会给你省去很多不必要的 WA。把每一道简单题的输出都做到位,到了复杂题,"格式严谨"就成了一种本能,你就不用再操心这些旁枝末节了。

返回列表