在题库里翻到“火星文计算”这个标题时,我第一反应是:这题怕是让把 a、b、c 直接替换成数字就完事了吧?真正动手之后我才发现,光是“连续火星数字怎么切分”这一条,就能把不少人卡在 60 分以下。今天把这套题用 Java、JS、Python 三语言完整复盘一遍,题目本身不复杂,但跨语言实现时踩到的细节问题相当值得记录。
1. 这是一道翻译题,不是一道天文题
1.1 题面规则:火星人用五进制,还把数字藏进了字母
我拿到的题面是这样定义的:火星文的数字不是 0~9,而是只有五个数字符号,分别用 a、b、c、d、e 表示十进制里的 0、1、2、3、4。运算符号和括号与地球上的写法完全一致,还是 +、-、*、/ 和左右括号。输入是一串由这些符号组成的火星文表达式,要求输出计算结果,但输出也必须用火星文表示。
这句话里藏着两个容易理解偏的坑。第一个坑是“五进制”,很多同学以为只是字符替换,把 a 换成 0、b 换成 1 就结束,实际上火星文里的数字是五进制数,比如字符串 “cb” 不是地球数字 3、1 拼起来,而是五进制数 “21”,换算成十进制是 2*5+1=11。第二个坑是“连续数字”,火星文没有小数点也没有逗号,“bc” 就是一个完整的五进制数,不是一个 b 一个 c 两个数,所以写解析器的时候必须把连续出现的数字字符当作一个 token 读出来,不能一个字符一个字符地单独处理。
题目分值写的是 100 分,但实际测试点不是只有一组样例。我在本地把官方样例跑通以后,以为直接提交就能过,结果第一次只拿了 30 多分,问题就出在没有处理多位数和空格上。这也是我今天为什么要写这篇复盘的原因。
1.2 真正的考点链
如果把这道题的“新卷”外壳拆掉,它考的核心链路是四条:
- 字符映射:在 a~e 与 0~4 之间建立一一对应关系。
- 词法解析:把连续数字字符识别成一个五进制数,把运算符和括号识别成独立 token。
- 中缀表达式求值:处理 + - * / 的优先级和括号,输出十进制整数。
- 进制转换:把十进制结果转成五进制,再映射回 a~e 输出。
这四条里,第 3 条是传统计算器题,第 2 条是字符串处理题,第 1 条和第 4 条是进制转换题。也就是说,“火星文计算”其实是一道综合题,单独会某一项都不够,必须全部串起来。
说句实在话,很多人第一眼觉得它难,是因为“火星文”这个包装比较唬人。一旦你看穿它本质上就是“自定义进制 + 中缀表达式求值”,思路就会清晰很多。你要是已经会写十进制整数计算器,那这道题就只多了一个字符映射层和一个进制转换层,难度没有本质提升。
用 Java、JS、Python 三语言写,还有一个额外考点:三种语言对整数除法的处理不一样。Java 的 / 直接向零取整,Python 的 // 是向下取整,JS 的 / 得到浮点数,如果不加处理,同一组测试用例三套代码可能得到三种结果。这个我在后面专门拆开讲。
1.3 最容易出现的错误理解:简单字符替换
我用一个对比来说明为什么“只替换”不可行。假设输入是:
text复制d * (b + c) + e / c
如果只做字符替换,可以把这个字符串替换成 3 * (1 + 2) + 4 / 2,然后把这段地球表达式交给已有的计算器去算,最后结果 11 再替换成火星文。问题在于,题目不会保证表达式里的每个数字都是单个火星字符。一旦输入变成:
text复制dcb * b
简单替换会把 dcb 当成 d、c、b 三个独立操作数,但它其实是五进制数 86。你只有专门写一段“连续读入数字字符”的词法解析代码,才能正确区分 dcb 是一个数字。所以“字符替换”只是整道题最外层的一小步,真正的核心在词法和语法解析上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法:先把火星文变回地球数字,算完再变回去
2.1 一张映射表解决“读”和“写”
最省心的做法是在代码里维护一张统一映射表,我这里直接用一个字符串当表:
- 下标 0 对应字符 a,表示数字 0
- 下标 1 对应字符 b,表示数字 1
- 下标 2 对应字符 c,表示数字 2
- 下标 3 对应字符 d,表示数字 3
- 下标 4 对应字符 e,表示数字 4
这样读火星字符时,DIGITS.indexOf(ch) 就是它的十进制数值;写结果时,DIGITS.charAt(v) 就是把十进制一位数转回火星字符。在 JS 和 Python 里也是一样的思路。使用字符串而不是哈希表的原因是代码量最少,并且天然保证“映射表”的顺序就是五进制的位权顺序,不容易写错。
在实际工程里,我一般会把这种映射抽成一个独立函数,而不是散落在表达式求值代码里。因为后面如果要扩展支持更大进制,只需要换表,不用动算法。
2.2 五进制与十进制的双向互转
五进制转十进制的方法非常直观。比如火星字符串 “dcb”,从最左边开始,逐步累乘 5:
- 初始 value = 0
- 遇到 d(值为 3):value = 0*5 + 3 = 3
- 遇到 c(值为 2):value = 3*5 + 2 = 17
- 遇到 b(值为 1):value = 17*5 + 1 = 86
所以 “dcb” 是十进制 86。反过来,十进制转五进制用短除法,每次除以 5 取余数,逆序拼接。比如 86:
- 86 / 5 = 17 余 1
- 17 / 5 = 3 余 2
- 3 / 5 = 0 余 3
余数逆序是 3, 2, 1,对应 d、c、b,结果就是 “dcb”。这两个互转过程在三种语言里写法略有差异,但逻辑完全一致。
这里我建议你在测试时专门打印十进制中间值。很多时候你的计算逻辑没问题,但进制转换写反了,最后输出全是错的。先验证 toMars(11) 应该返回 cb,再验证 calculate("dcb*b") 的中间结果是不是 86*1,这样定位错误很快。
2.3 中缀表达式求值:双栈法的完整逻辑
火星文表达式是标准的中缀表达式,最适合快速实现的就是双栈法:一个操作数栈存数值,一个运算符栈存 +、-、*、/ 和括号。算法可以概括成这样:
- 读到数字 token,直接解析成十进制数字并压入操作数栈。
- 读到左括号,压入运算符栈。
- 读到右括号,一直弹出运算符并计算,直到遇到左括号,再把左括号弹掉。
- 读到运算符,先比较它和栈顶运算符的优先级,如果栈顶优先级大于等于当前优先级,就先把栈顶计算掉,再把当前运算符压栈。
- 表达式读完以后,把运算符栈里剩余的运算符依次弹出计算。
这里最关键的是“大于等于”这个判断。乘除优先级高于加减,所以同一个优先级范围内从左到右计算;当遇到 14 / 2 * 3 这种连续乘除时,只有用“大于等于”才能保证先算左边的除法再算乘法,否则会算出 14 / (2*3) 的错误结果。
如果你更熟悉后缀表达式,可以先转成逆波兰再计算。但从代码实现来看,双栈法不需要额外保存后缀表达式,直接在原表达式上完成计算,内存更省,代码也更短。我自己在笔试环境下优先用双栈法,因为它最容易一次写对。
3. Java 版实现:类封装 + 双栈解析
3.1 类结构设计
Java 版我建议把火星文计算封装成一个独立的类,至少包含四个方法:字符映射、数字解析、整表达式计算、结果转回火星文。这样看起来代码量稍多,但结构清楚,后续如果题目扩展,比如加入幂运算,只需要改一个地方。
数据范围方面,输入表达式不长,但乘法可能导致中间结果超过 int 范围。比如五进制数字最多能表示到多少取决于输入长度,但稳妥起见,我在 Java 版里统一使用 long 类型,避免在边界用例上吃“溢出”的亏。栈用 ArrayDeque,比 Stack 更现代,也更快。
3.2 完整代码
下面是我实际提交通过的一版 Java 实现:
java复制import java.util.*;
public class MarsCalculator {
static final String DIGITS = "abcde";
static int digitValue(char ch) {
return DIGITS.indexOf(ch);
}
static char digitChar(int v) {
return DIGITS.charAt(v);
}
static long parseMarsNumber(String s, int[] idx) {
long val = 0;
while (idx[0] < s.length() && DIGITS.indexOf(s.charAt(idx[0])) >= 0) {
val = val * 5 + digitValue(s.charAt(idx[0]));
idx[0]++;
}
return val;
}
static int priority(char op) {
if (op == '+' || op == '-') return 1;
if (op == '*' || op == '/') return 2;
return 0;
}
static void applyOp(Deque<Long> nums, char op) {
long b = nums.pop();
long a = nums.pop();
switch (op) {
case '+': nums.push(a + b); break;
case '-': nums.push(a - b); break;
case '*': nums.push(a * b); break;
case '/': nums.push(a / b); break;
}
}
static long calculate(String expr) {
String s = expr.replaceAll("\\s+", "");
Deque<Long> nums = new ArrayDeque<>();
Deque<Character> ops = new ArrayDeque<>();
int[] idx = {0};
while (idx[0] < s.length()) {
char ch = s.charAt(idx[0]);
if (DIGITS.indexOf(ch) >= 0) {
nums.push(parseMarsNumber(s, idx));
continue;
}
if (ch == '(') {
ops.push(ch);
idx[0]++;
} else if (ch == ')') {
while (!ops.isEmpty() && ops.peek() != '(') {
applyOp(nums, ops.pop());
}
if (!ops.isEmpty()) ops.pop();
idx[0]++;
} else if (ch == '+' || ch == '-' || ch == '*' || ch == '/') {
while (!ops.isEmpty() && ops.peek() != '(' &&
priority(ops.peek()) >= priority(ch)) {
applyOp(nums, ops.pop());
}
ops.push(ch);
idx[0]++;
} else {
throw new IllegalArgumentException("非法字符: " + ch);
}
}
while (!ops.isEmpty()) {
applyOp(nums, ops.pop());
}
return nums.pop();
}
static String toMars(long num) {
boolean neg = num < 0;
long n = Math.abs(num);
if (n == 0) return "a";
StringBuilder sb = new StringBuilder();
while (n > 0) {
sb.append(digitChar((int)(n % 5)));
n /= 5;
}
if (neg) sb.append('-');
return sb.reverse().toString();
}
public static void main(String[] args) {
String expr = "d * (b + c) + e / c";
long result = calculate(expr);
System.out.println(result); // 11
System.out.println(toMars(result)); // cb
}
}
3.3 这个写法里容易忽略的细节
第一个细节是 parseMarsNumber 里的 int[] idx。Java 方法传参是值传递,直接在方法里修改一个局部变量不会影响外层循环位置,所以我把读取位置放在长度为 1 的数组里,这是常见的小技巧,也可以改成一个类字段或者返回一个包含数值和位置的对象。
第二个细节是除法的取整。Java 对整数的 / 是向零取整,也就是 -3 / 2 = -1,这和数学里的向下取整不一样。如果火星文计算题目允许出现负数中间结果,这个行为本身就能满足“向零取整”的惯例,但如果题目约定其他取整方式,就要在这里做额外处理。
第三个细节是 toMars 里对 0 的处理。五进制数 0 的循环除法结果是空字符串,必须单独返回 "a",否则会输出空串,直接判错。我在给同学 review 代码时,至少有两个人在这里翻车。
4. JavaScript 版实现:代码最短,陷阱最多
4.1 与 Java 版的差异
JS 版整体思路与 Java 完全一致,都是双栈 + 映射表。不同的是 JS 没有真正的整数类型,所有的数都是 Number,也就是浮点数,所以除法得到的结果天然带小数。要得到整数结果,必须显式取整。
另一个差异是数组方法。JS 的 Array.prototype.push/pop 天然支持当栈用,少写不少代码。读取字符串时我用一个对象 { i: 0 } 替代 Java 的 int[] idx,本质是同一个套路:让嵌套函数能修改外层遍历位置。
平时我排查问题的时候,也会先在 Node.js 里把 calculate 和 toMars 分别跑一遍,确认中间结果再接上主逻辑,比一口气写完再调要省很多时间。
4.2 完整代码
javascript复制const DIGITS = 'abcde';
function digitValue(ch) {
return DIGITS.indexOf(ch);
}
function parseMarsNumber(s, pos) {
let val = 0;
while (pos.i < s.length && DIGITS.includes(s[pos.i])) {
val = val * 5 + digitValue(s[pos.i]);
pos.i++;
}
return val;
}
function priority(op) {
if (op === '+' || op === '-') return 1;
if (op === '*' || op === '/') return 2;
return 0;
}
function applyOp(nums, op) {
const b = nums.pop();
const a = nums.pop();
switch (op) {
case '+': nums.push(a + b); break;
case '-': nums.push(a - b); break;
case '*': nums.push(a * b); break;
case '/': nums.push(Math.trunc(a / b)); break;
}
}
function calculate(expr) {
const s = expr.replace(/\s+/g, '');
const nums = [];
const ops = [];
const pos = { i: 0 };
while (pos.i < s.length) {
const ch = s[pos.i];
if (DIGITS.includes(ch)) {
nums.push(parseMarsNumber(s, pos));
continue;
}
if (ch === '(') {
ops.push(ch);
pos.i++;
} else if (ch === ')') {
while (ops.length && ops[ops.length - 1] !== '(') {
applyOp(nums, ops.pop());
}
if (ops.length) ops.pop();
pos.i++;
} else if (ch === '+' || ch === '-' || ch === '*' || ch === '/') {
while (ops.length && ops[ops.length - 1] !== '(' &&
priority(ops[ops.length - 1]) >= priority(ch)) {
applyOp(nums, ops.pop());
}
ops.push(ch);
pos.i++;
} else {
throw new Error('非法字符: ' + ch);
}
}
while (ops.length) {
applyOp(nums, ops.pop());
}
return nums.pop();
}
function toMars(num) {
const neg = num < 0;
let n = Math.abs(num);
if (n === 0) return 'a';
let result = '';
while (n > 0) {
result = DIGITS[n % 5] + result;
n = Math.floor(n / 5);
}
return neg ? '-' + result : result;
}
// 调用示例
const expr = "d * (b + c) + e / c";
const result = calculate(expr);
console.log(result); // 11
console.log(toMars(result)); // cb
4.3 为什么除法必须用 Math.trunc
很多 JS 新手在写计算器时会直接用 / 之后什么都不管,这在全是正数的用例里看起来没问题,比如 4 / 2 = 2。但一旦出现 -3 / 2,JS 的结果是 -1.5,而火星文计算要求整数结果,直接把这个小数存进操作数栈就会让后续比较产生不可控的结果。
Math.trunc 的作用是直接砍掉小数部分,-3 / 2 变成 -1,与 Java 的整数除法行为一致。Python 里也有对应的处理,我在下一节说。如果你选择用 Math.floor,那 -3 / 2 会得到 -2,方向完全不同,所以这里不能随手用 Math.floor。
另外,n % 5 在 JS 中对于正数没有歧义,但在 toMars 里我已经先取了 Math.abs(num),所以 % 的结果一定是非负的,这一步必须放在取绝对值之后,否则负数取余会得到负数下标,直接访问 DIGITS[-1] 会得到 undefined,坑得很。
5. Python 版实现:逻辑最直白,但要小心句法误导
5.1 完整代码
Python 版本的代码最接近伪代码,读起来很舒服。用列表当栈,用字符串的 in 判断字符类型,用 index() 取映射值,整体非常简洁:
python复制DIGITS = "abcde"
def digit_value(ch):
return DIGITS.index(ch)
def parse_mars_number(s, pos):
val = 0
while pos < len(s) and s[pos] in DIGITS:
val = val * 5 + digit_value(s[pos])
pos += 1
return val, pos
def priority(op):
if op in "+-":
return 1
if op in "*/":
return 2
return 0
def apply_op(nums, op):
b = nums.pop()
a = nums.pop()
if op == '+':
nums.append(a + b)
elif op == '-':
nums.append(a - b)
elif op == '*':
nums.append(a * b)
else:
nums.append(int(a / b))
def calculate(expr):
s = ''.join(expr.split())
nums = []
ops = []
i = 0
while i < len(s):
ch = s[i]
if ch in DIGITS:
val, i = parse_mars_number(s, i)
nums.append(val)
continue
if ch == '(':
ops.append(ch)
i += 1
elif ch == ')':
while ops and ops[-1] != '(':
apply_op(nums, ops.pop())
if ops:
ops.pop()
i += 1
elif ch in "+-*/":
while ops and ops[-1] != '(' and priority(ops[-1]) >= priority(ch):
apply_op(nums, ops.pop())
ops.append(ch)
i += 1
else:
raise ValueError(f"非法字符: {ch}")
while ops:
apply_op(nums, ops.pop())
return nums.pop()
def to_mars(num):
neg = num < 0
n = abs(num)
if n == 0:
return "a"
chars = []
while n > 0:
chars.append(DIGITS[n % 5])
n //= 5
if neg:
chars.append('-')
return ''.join(reversed(chars))
# 调用示例
expr = "d * (b + c) + e / c"
result = calculate(expr)
print(result) # 11
print(to_mars(result)) # cb
Python 的 parse_mars_number 直接返回两个值,比 Java 的 int[] idx 更自然。calculate 函数里 val, i = parse_mars_number(s, i) 这行既拿到了数值,又把外层循环的 i 更新成数字结束后的下一个下标,非常 Pythonic。
5.2 int(a/b) 与 // 的区别
Python 的 // 是向下取整,不是向零取整。比如 -3 // 2 = -2,而 Java 和 JS 的 Math.trunc(-3/2) 都是 -1。为了让三版行为一致,我在 Python 里写的是 int(a / b),先做浮点除法再向零截断。
但这里有个隐藏风险:如果数字很大,a / b 的浮点除法可能丢失精度。对于在线评测题通常的数字范围,这个写法够用,但如果输入的表达式特别长、结果接近浮点数精度上限,推荐还是自己写一个向零取整的整数除法辅助函数。我一般这么写:
python复制def trunc_div(a, b):
return abs(a) // abs(b) * (1 if (a >= 0) == (b >= 0) else -1)
这个函数先统一用绝对值整除,再根据两个数的符号决定正负,完全不需要经过浮点数,也不会有精度问题。
5.3 简化写法的边界影响
Python 版本看起来短,但有两处“看起来没问题”的写法需要留意。一是 s = ''.join(expr.split()) 去空格,它会把表达式里所有空白字符都删掉,包括空格、制表符、换行,这在题目只要求处理空格时是安全的,但如果未来的扩展题希望把换行当作表达式结束符,这个写法反而不合适。
二是 DIGITS.index(ch) 在遇到非映射字符时会抛 ValueError,这本是好事,但如果你用 ch in DIGITS 判断后再调用 index,效率略低。对本题输入规模完全没有影响,重点在于你要知道 index 和 in 都是顺序查找,如果未来字符集变大,换用字典映射会更合适。
6. 三版实现对比与边界条件复盘
6.1 代码量与性能对比表
| 语言 | 核心代码行数 | 整数除法处理 | 主要注意点 |
|---|---|---|---|
| Java | 100 行左右 | / 天然向零取整 |
栈类型、溢出、StringBuilder 反转 |
| JavaScript | 80 行左右 | 需 Math.trunc | 浮点陷阱、负号取余 |
| Python | 70 行左右 | 需 int(a/b) 或自定义函数 | // 方向错误、浮点精度 |
这里的行数只是参考,去掉注释后的实际有效代码。性能方面,三版都是 O(n) 扫描加 O(n) 弹栈,常数也很小,普通评测数据跑起来都在毫秒级,没必要做额外优化。
6.2 最容易翻车的三个边界问题
第一次提交只拿部分分的朋友,绝大多数是踩了下面这三个坑。
第一个是除零。火星文里的 a 表示 0,所以表达式完全可能出现 c / a 这种直接除零的输入。题目为了保证可计算,通常会在题面里写“输入保证除法不会出现除数为 0”,但我在自测时会习惯性加一个判断。如果评测系统没保证,那你得明确是返回特殊值还是抛异常,否则系统比对输出时你拿不到分。
第二个是负数。只要表达式里出现减法,结果就可能为负。Java 和 JS 的取整规则一致,Python 需要特别注意。另外 toMars 函数必须对负数先取绝对值再循环除 5,否则结果错误。我在实际写第一版时忘了在 Python 里处理负数,结果有一个测试点只有四组人能过,我就是被刷下去的那一个。
第三个是多位数 token 的切分。火星文里 bc 是一个数,不是两个数。如果解析器看到单个数字字符就算一个操作数,那 bc + d 会被错误解析成 b 和 c 两个相邻操作数,栈里没有运算符可以直接计算,直接抛异常或返回错误结果。这也是为什么我在三个语言里都写了 parseMarsNumber 循环读取连续数字字符。
6.3 如何在评测系统里稳定拿到 100 分
我把自己的测试流程固定成四步,每次都能快速定位问题。
先写一个“火星文转十进制”的小工具,随便输入几个多位数,人工验证映射和进制是否算对。然后构造一组优先级表达式,比如 b + c * d,核对乘除是否优先于加减。接着测括号嵌套,比如 b + (c * d - e)。最后测空格和异常输入,比如字符串两端有空格的表达式,以及完全由空格组成的空表达式。
如果这四步都能通过,提交分数基本就是 90 分以上。剩下的分值通常藏在“结果转火星文为 a”和“连续乘除从左到右”这两个细节里,测试的时候一定不要漏掉。
7. 想从 100 分往 120 分走?扩展思路
7.1 支持一元负号
标准中缀表达式里,-b + c 这种开头就是负号的情况,用当前双栈法会直接报错。要支持它也简单,在判断到 - 的时候如果操作数栈为空,或者前一个 token 是左括号,就把它当作一元负号处理,压入一个 0 作为左操作数。也就是把 -b 转换成 0 - b,这样整个双栈算法不用改。
7.2 支持幂运算
如果题目加一个 ^ 运算符,只改两个地方:优先级表里给 ^ 一个比乘除更高的优先级,applyOp 里加一个 Math.pow 或者 ** 分支。需要注意的是幂运算通常是右结合的,2 ^ 3 ^ 2 应该先算 3^2=9 再算 2^9=512,当前双栈的“>=”逻辑要改成“栈顶优先级大于当前”时才算,也就是只处理左结合运算符。
7.3 支持变量或自定义进制
想做得更通用,可以把 DIGITS 从常量改成构造参数,比如传入 "0123456789" 就是地球十进制,传入 "abcdefghijklmnopqrst" 就是二十进制。这个改动对三个版本都不难,核心是保证所有模块都用同一个字符表,不要散落硬编码。还可以给表达式的操作数支持变量名,用上下文对象查值,这样题目的适用范围瞬间扩大很多,但主流程依然是一致的。
我在实际工作中就用这套思路改过一个“自定义进制表达式计算器”,最后被用到配置系统里做灰度参数解析。一道看起来像刷题的题目,能延展出很实际的价值。
