“新卷 100 分题里,火星文计算算是我见过比较有‘欺骗性’的一道。名字听着玄乎,实际上就是给你一套自定义运算符,让你模拟计算。但就是这个模拟,把不少人的字符串功底和栈功底打回原形。这道题 Java、JS、Python 三语言都能写,而且核心解法完全一致,区别只在语言层面的类型处理上。这篇文章不绕弯子,直接把这题从题目规则、解法思路到三种语言的完整实现讲透,末尾还附上我实际踩过的坑。刷机考的同学,或者平时练算法想练‘新运算规则模拟’这一类题的朋友,直接往下看就行。”
1. 先看懂题:火星文计算到底在考什么
1.1 火星文的运算规则还原
先把这个题的规则模板摆出来。题目一般会给你两种运算符,常见写法是#和$,然后规定:
x#y = 4*x + 3*y + 2x$y = 2*x + y + 3
输入是一个只包含数字和这两种运算符的字符串表达式,比如7#6$5#12。数字都是无符号整数,表达式中没有括号,也没有负数。要求你按照火星人的运算优先级,把整个表达式的结果算出来。
这里要特别强调一个关键设定:运算符有优先级。常见版本里是#的优先级高于$,也就是说,遇到7#6$5#12这种串,你不能盲目从左往右算,得先把所有#运算算完,再算$运算。我在多个版本的题目里看到的设定基本一致,但稳妥起见,拿到题第一步先确认优先级关系,别想当然。
这个规则本身很简单,就是两个函数替换。但题目把简单的规则包装成了“火星文”,又加上了优先级,就让很多平时只会算普通四则运算的人懵了。其实你把它翻译成人话就是:宇宙中有一群火星人,他们不用加减乘除,只用#和$这两种不可理喻的运算符号,你作为地球程序员,得按他们的规矩来。
1.2 优先级与结合性,决定了解题方向
运算符优先级是这题的核心考点。普通四则运算里,*和/优先于+和-,你已经形成肌肉记忆了,但换成了火星文运算符,你就得重新建模。
#优先级高意味着什么?意味着#两边的数字要更早地绑定在一起。以7#6$5#12为例:
- 先算
7#6 = 4*7 + 3*6 + 2 = 48 - 再算
5#12 = 4*5 + 3*12 + 2 = 58 - 最后算
48$58 = 2*48 + 58 + 3 = 157
注意第二步这个操作:5#12里的5在$的右边,但因为它跟12之间是#,优先级更高,所以这个5必须跳过$,先去跟后面的12结合。这就是优先级对计算顺序的强制影响。
还有一个容易忽略的点:相同运算符连续出现时,按什么方向结合?比如1#2#3。火星文的#一般默认左结合,也就是先算1#2,再用结果跟3算。这一点和普通四则运算里100 / 5 / 2从左往右算是一个道理。题目一般不会明说“左结合”,但表达式本身就隐含了这个语义,栈法天然支持左结合,所以你用栈去模拟的时候不用单独处理。
1.3 这道题在机考中的定位
从题目价值来看,火星文计算属于典型的“规则模拟题”。这类题有两个特点:一是题面花哨,二是代码量不大。它不考你复杂的算法设计,考的是你能不能把一条条文字描述准确翻译成代码逻辑,并且在翻译过程中不出边界错误。
在实际机考中,这类 100 分题通常出现在试卷的前半部分,属于“保分题”。它的定位就是让有基本编程能力的人能拿分,但很多人会在以下几个点上翻车:多位数解析、优先级顺序、类型溢出、运算符栈的残留处理。这些我在后面每一节都会讲到。
另外,这类题换个皮就能继续考,比如把运算符改成+和*但重新定义规则,或者加入负数和括号。所以你不应该只背这道题的答案,而是要把“解析表达式 + 用栈控制优先级”这个套路吃透,这才是做一题顶十题的正确姿势。
2. 解法选型:为什么我推荐“两遍栈”
2.1 正则替换法:短小精悍,但前置条件太多
先说说最快能想到的解法:正则替换。既然#优先级高,那就先用正则把所有的a#b匹配出来,替换成计算结果,再处理$。比如 Python 可以这样写:
import re def replace_op(match): x, op, y = int(match.group(1)), match.group(2), int(match.group(3)) if op == '#': return str(4 * x + 3 * y + 2) return str(2 * x + y + 3) expr = "7#6$5#12" while '#' in expr: expr = re.sub(r'(\d+)#(\d+)', replace_op, expr) while '$' in expr: expr = re.sub(r'(\d+)\$(\d+)', replace_op, expr) print(expr)这段代码跑7#6$5#12能得到 157,没问题。但它有几个隐患:
第一个隐患是while循环。re.sub虽然会替换所有匹配项,但同一个位置替换完之后的字符串可能产生新的#组合。比如1#2#3,第一轮re.sub会同时匹配1#2和2#3,但请注意:re.sub是从左到右扫描,匹配1#2并替换成结果后,扫描位置已经跳到后面,不会拿新生成的结果再去跟3匹配。这就是样例可能出错的地方,所以必须加while循环,直到表达式里没有对应运算符为止。
第二个隐患是正则表达式的可读性和调试成本。表达式一复杂,正则本身的转义、贪婪匹配、分组索引都会让人头大。机考时间紧张,我不推荐在这个题上用正则作为主解法,但它作为思路提一下没坏处,因为有时候看别人用正则写得特别短,你会忍不住想试,试完发现坑比想象多。
2.2 栈模拟法:稳扎稳打,适合机考
我推荐的解法是两遍栈。什么叫做两遍?第一遍扫描表达式,只处理高优先级的#,把$留在运算符栈里;第二遍处理剩下的$。这样把两级优先级的运算拆成两个单级运算,思路非常直白,几乎不会出错。
用栈的好处是什么?在处理#的时候,一旦发现当前运算符栈顶是#,说明栈顶数字和当前数字之间的#可以立即计算。因为#优先级最高,而且左结合,所以这时候算一定是对的。遇到$就先不着急,因为它右边可能还有#没算完,把$压栈,等第一遍结束再统一处理。
这个思路可以推广到任意多级优先级:优先级最高的先全算完,再算次高的,依次类推。只要题目把优先级关系给清楚,这个套路就是通用的。
很多同学可能会想,那我用一个栈同时处理数字和运算符,遇到高优先级就算一次,最后再来一轮,不也一样吗?其实也是可以的,常见写法是用一个Object栈,存Long和Character。但那样在 Java 里要做类型判断和强转,代码啰嗦不说,还容易出现ClassCastException。相比之下,数字栈 + 运算符栈并行维护的方式更加清晰,三语言都能写出一致的结构,排查问题也更方便。
2.3 递归下降法:很学院派,性价比不高
还有一种思路是递归下降。把表达式定义成:表达式 = 项 ($ 项)*,项 = 数字 (# 数字)*。然后写两个递归函数互相调用。这在编译器原理里是正统做法,能处理更复杂的文法,也显得你水平很高。
但放在机考场景里,我建议你慎用。第一,这道题的文法非常简单,递归下降属于高射炮打蚊子,代码量更大;第二,递归函数要处理边界返回条件、操作数解析、递归深度,任何一个地方写漏都可能整体崩掉。机考核心是稳定拿分,不是炫技。等你把栈法写到肌肉记忆,再学递归下降也不迟。
综合来看,我的建议很明确:机考上用两遍栈,练题也建议先把两遍栈吃透。这是代码量、可读性、正确性三者平衡下来最好的方案。
3. 三语言完整实现与代码解读
3.1 Python 实现:十分钟能写完的版本
Python 写这道题很舒服,因为没有类型声明,也没有数据溢出问题。代码结构可以直接按“数字栈 + 运算符栈”来:
import sys def calc(x: int, y: int, op: str) -> int: if op == '#': return 4 * x + 3 * y + 2 return 2 * x + y + 3 def solve(s: str) -> int: s = s.strip() nums = [] ops = [] i, n = 0, len(s) while i < n: if '0' <= s[i] <= '9': num = 0 while i < n and '0' <= s[i] <= '9': num = num * 10 + (ord(s[i]) - ord('0')) i += 1 while ops and ops[-1] == '#': ops.pop() left = nums.pop() num = calc(left, num, '#') nums.append(num) else: ops.append(s[i]) i += 1 ans = nums[0] for j, op in enumerate(ops): ans = calc(ans, nums[j + 1], op) return ans if __name__ == "__main__": line = sys.stdin.readline() if line: print(solve(line))核心逻辑在第一遍的while循环里。解析出一个数字后,看看运算符栈顶是不是#,是的话就弹出#和左操作数,跟当前数字做一次计算,直到栈顶不是#为止。这个“直到”非常关键,因为可能连续有多个#,比如1#2#3,处理完第一个#之后,新结果还得继续跟后面的数字算,所以要用while而不是if。
第二遍更简单,第一遍结束后,运算符栈里只剩$,数字栈里存的是各个被#组合好的“块”,按顺序从左到右算即可。
3.2 Java 实现:类型和容器是主要考点
Java 版主要是注意类型。火星文的4*x + 3*y + 2很容易把int撑爆,尤其当数字到了亿级,结果直接是几十亿,远超int的 21 亿上限,所以一律用long。
用ArrayList做数字栈和运算符栈,比Stack类和ArrayDeque都要顺手,因为题目最后需要按索引访问数字栈里的元素,ArrayList能直接get,很方便。代码如下:
import java.util.*; public class Main { static long calc(long x, long y, char op) { if (op == '#') { return 4 * x + 3 * y + 2; } return 2 * x + y + 3; } public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.nextLine().trim(); ArrayList<Long> nums = new ArrayList<>(); ArrayList<Character> ops = new ArrayList<>(); int i = 0, n = s.length(); while (i < n) { char c = s.charAt(i); if (Character.isDigit(c)) { long num = 0; while (i < n && Character.isDigit(s.charAt(i))) { num = num * 10 + (s.charAt(i) - '0'); i++; } while (!ops.isEmpty() && ops.get(ops.size() - 1) == '#') { ops.remove(ops.size() - 1); long left = nums.remove(nums.size() - 1); num = calc(left, num, '#'); } nums.add(num); } else { ops.add(c); i++; } } long ans = nums.get(0); for (int j = 0; j < ops.size(); j++) { ans = calc(ans, nums.get(j + 1), ops.get(j)); } System.out.println(ans); } }这段代码里有一个看起来不起眼但很重要的细节:当我准备把当前数字num入栈时,如果运算符栈顶是#,我先把已压入的nums最后一个元素弹出来,跟num做一次calc,结果继续作为新的num。这不只是省一个变量,它还保证了连续多个#时计算顺序的正确性。
ArrayList.remove(size - 1)是删除末尾元素,时间复杂度 O(1),不会造成性能问题。如果你非要用LinkedList之类的结构,反而可能因为频繁在尾部操作而有额外开销,所以ArrayList在这个场景是最合适的。
3.3 JavaScript 实现:用 BigInt 避免精度翻车
JS 版最容易翻车的点就是 Number 精度。JavaScript 的 Number 类型是 IEEE 754 双精度浮点数,超过 2 的 53 次方(约 9 千万亿)就会出现精度丢失。火星文计算里x#y = 4*x + 3*y + 2,如果输入的数字本身是 10 位数甚至更大,一乘起来危险系数非常高。
所以我建议直接在 JS 版本里全程用 BigInt。BigInt 和 Number 不能直接混用,需要小心处理字符串转数字。不多说,直接上代码:
const readline = require('readline'); function calc(x, y, op) { if (op === '#') { return 4n * x + 3n * y + 2n; } return 2n * x + y + 3n; } function solve(s) { s = s.trim(); const nums = []; const ops = []; let i = 0; const n = s.length; while (i < n) { const c = s[i]; if (c >= '0' && c <= '9') { let num = 0n; while (i < n && s[i] >= '0' && s[i] <= '9') { num = num * 10n + BigInt(s.charCodeAt(i) - 48); i++; } while (ops.length > 0 && ops[ops.length - 1] === '#') { ops.pop(); const left = nums.pop(); num = calc(left, num, '#'); } nums.push(num); } else { ops.push(c); i++; } } let ans = nums[0]; for (let j = 0; j < ops.length; j++) { ans = calc(ans, nums[j + 1], ops[j]); } return ans.toString(); } const rl = readline.createInterface({ input: process.stdin }); rl.on('line', (line) => { console.log(solve(line)); });注意BigInt(s.charCodeAt(i) - 48)这种写法,字符编码直接减 48 得到数字,比BigInt(s[i])更直接,因为BigInt("7")也能用,但每次创建一个字符串再转换,稍显多余。当然,如果输入规模不大,两种写法性能差异可以忽略,关键是保持类型统一。
JS 版第一遍处理#的逻辑跟 Python 版完全一致。第二遍算$时,ans已经是 BigInt,calc返回的也是 BigInt,最后输出的时候用toString()转成普通字符串,不然控制台或者 OJ 判定会比较麻烦。
4. 测试用例这样设计,才不容易漏
4.1 基础用例与期望结果
写题最忌讳只拿题目样例测一次就跑。火星文计算这个体量,至少得准备四类用例:普通混合表达式、连续相同运算符、单个数字、大数。我直接把我在本地调试时常跑的用例贴在下面。
7#6$5#12:这是题目常见样例。先算7#6=48,再算5#12=58,最后算48$58=157。期望结果是 157。
1#2#3:连续#左结合。先算1#2=12,再算12#3=59。期望结果是 59。
1$2$3:连续$左结合。先算1$2=7,再算7$3=20。期望结果是 20。
100$100#100:混合优先级,100#100=702,再算100$702=905。期望结果是 905。
5:只有单个数字,没有运算符,期望结果就是 5。这个用例专门测程序对“没有运算符”场景的健壮性。
我把这些用例整理成表格,方便你对照验证:
| 输入表达式 | 计算过程 | 期望输出 |
|---|---|---|
7#6$5#12 | 7#6=48,5#12=58,48$58=157 | 157 |
1#2#3 | 1#2=12,12#3=59 | 59 |
1$2$3 | 1$2=7,7$3=20 | 20 |
100$100#100 | 100#100=702,100$702=905 | 905 |
5 | 无运算符,直接输出 | 5 |
4.2 大数与边界用例
大数用例主要用来验证类型选择。比如输入1000000000$1000000000,也就是两个 10 亿:
2 * 1000000000 + 1000000000 + 3 = 3000000003
这个结果已经超过了 32 位int的上限 2147483647,如果 Java 用int就会溢出成负数。所以 Java 必须用long,JS 建议用BigInt,Python 本身任意精度,没有这个烦恼。
再比如999999999#999999999:
4 * 999999999 + 3 * 999999999 + 2 = 6999999998
这个数值对long来说没问题,但对 JS 的 Number 来说也已经比较危险了,因为接近百亿。如果输入数字再大一两个数量级,Number 就会在连续运算中产生精度误差,所以 JS 版用 BigInt 是更稳妥的做法。
边界方面,还可以加一个“运算结果出现在表达式中间”的用例,比如1$2#3$4。手动算一下:2#3=17,1$17=22,22$4=51。这个用例能验证程序在$右侧遇到#时,是否先把#处理完再回头算$。
5. 实操中我踩过的坑和排查技巧
5.1 多位数解析和空格清理
我第一版 Python 代码踩过最蠢的坑:默认表达式里的数字都是一位数,直接用int(s[i])去取。结果遇到12#34这种用例就完全乱了,因为在解析1和2中间的那个#位置上,代码会收到一个#字符,导致解析错位。
正确做法永远是循环连续读取数字。解析数字的循环写法在三种语言里高度一致:从当前索引开始,只要字符是数字就把num = num * 10 + digit,同时i++。注意在解析完数字后,外层while不要再做一次i++,否则会跳过一个字符。这个“双递增”的冲突是我见过很多新手最容易犯的错。
另外,OJ 输入可能带行尾回车甚至空格,所以拿到输入后先trim()或strip()。别小看这一步,有时候你以为自己算法错了,实际上只是输入里多了一个空格导致字符比对失败。
5.2 优先级搞反之后的连锁反应
再说一个非常隐蔽的坑:题目里到底谁优先?
我见过网上流传的版本,有的写#优先级高于$,也有的写$优先级高于#。如果你按习惯直接写“先处理 #”,而题目恰恰相反,那整个结果就是错的。我实际做这道题时,第一遍看的题目版本是#高,第二遍换个平台又遇到了优先级反过来的版本。
应对方法很简单:不要背“谁优先”,而是背“代码里怎么控制优先级”。用两遍栈法时,只需要把第一遍while判断的运算符换成高优先级那个。比如题目规定$优先级高于#,那第一遍就处理$,判断条件改成ops[ops.length - 1] === '$',其余逻辑完全不用动。这就是抽象层次清晰的代码带来的好处。
优先级搞反时,最典型的症状是:小数据用例可能碰巧对,但混合运算符的用例全错。所以测试用例里一定要放一两个“低优先级运算符右边还跟着高优先级运算符”的表达式,比如1$2#3。
5.3 换语言时的类型陷阱
同一个算法,从 Python 改到 Java 再改到 JS,最容易出问题的地方就是类型。
Java 里最大的坑是int溢出。可能你本地测试全过,OJ 上数据一大就挂。解决方式是所有数值统一用long,包括从ArrayList<Long>取出来的值。注意ArrayList<Long>里不能直接放char,所以运算符单独用一个ArrayList<Character>,两套栈各司其职。
JS 里最大的坑是BigInt和Number混用。10n + 5这种写法会直接报 TypeError,不会像 Java 那样自动转。所以一旦决定用BigInt,就要确保初始值、中间变量、返回结果全部是BigInt。我最初的版本在num = num * 10n + BigInt(...)这里忘了加n,结果一直报错,排查了半天。
Python 里类型基本不用操心,但要注意input()读进来的是字符串,如果直接拿去跟'0'比较字符,别漏了strip()。另外 Python 的int虽然不会溢出,能处理超大数,但计算量大的时候还是要注意性能,不过这道题输入规模一般不大,不用担心。
5.4 运算符栈残留导致的二次处理问题
还有一种场景我也想特别提醒:第一遍处理完之后,运算符栈里理论上只剩$,但如果你在处理#时用的是if而不是while,就可能在1#2#3这种连续#的表达式中,把第一次计算的中间结果直接入栈,导致栈里残留#。到第二遍统一算$时,你会惊讶地发现ops的长度和nums的长度对不上,程序直接数组越界。
我自己调试时遇到这个问题,第一反应是怀疑calc函数写错了,后来打印每一步的状态才定位到是while和if的区别。这个经验非常重要:只要同一优先级运算符可能在表达式中连续出现,就必须用while处理,而不是if。这也解释了为什么两遍栈法里第一遍处理的是“当前解析数字之前能算的所有高优先级运算”,而不是“只算最近一次”。
排查技巧也很简单:在关键步骤打日志,打印nums和ops的内容。比如处理完7#6$5#12的第一遍后,正确的中间状态是nums=[48, 58],ops=['$']。如果你的中间状态变成了nums=[48, 5]、ops=['$', '#']之类,那说明#没有被完全消化,问题多半出在if/while的选择上。
最后分享一个小建议
火星文计算这道题,说穿了就是“自定义运算符 + 优先级模拟”的入门代表。它不考高深的数据结构,但很考验你能不能把规则翻译准确。我实际刷题过程中的体会是:先把两遍栈的代码结构背熟,再把运算符优先级和连续同优先级的场景想清楚,这道题基本就是送分题。如果你后续想扩展,可以试着把表达式里加上括号、负数,或者把运算符从两个增加到三个,解法思路依然成立,只是第一遍、第二遍甚至第三遍的处理顺序要跟着调整。这个变化方向,比单纯背答案更有价值。