LeetCode 726. 原子的数量
题目描述
题意分析
输入是一个合法的化学式字符串,输出要求把每种原子出现的总数统计出来,按原子名的字典序拼成一个字符串,数量为 1 时不写数字。这不是一个计算题,而是一个解析题:难点全在读懂输入的结构,而不在算什么。
输入的结构由三条规则拼出来:原子名是一个大写字母后跟零到多个小写字母(
Mg是一个原子而不是M加g);原子名后面可以紧跟一个多位十进制数表示个数,省略时为 1;括号可以任意嵌套,右括号后同样可以紧跟一个多位数,它对括号内的每一种原子都生效。
括号可以嵌套这一点是最强的算法信号:结构是递归的,而不是线性的,
K4(ON(SO3)2)3里的SO3要先乘 2 再随外层一起乘 3。凡是"内层结果需要被外层整体加工、且嵌套深度不定"的输入,就必须准备一个能保存"未完成的外层上下文"的容器,或者干脆写递归。
公式长度上限只有 1000,倍数不超过 1000,所以完全不必担心大数或性能,代码正确性和边界处理才是评分点。要留意的边界包括:数字缺省(
H2O里的O)、多位数(Be32要读成 32 而不是 3 和 2)、右括号紧跟另一个右括号(倍数为 1)、以及输出时数量恰好为 1 必须省略数字。
解法:递归下降解析括号作用域
核心思路
化学式由原子、数字和可嵌套括号组成,天然具有递归结构。与其展开字符串,不如让一次递归负责一个括号作用域,返回该作用域的“原子名 → 数量”统计表。
定义
parseGroup():从当前下标开始扫描,直到遇到右括号或字符串结尾,并维护不变量:函数返回时,统计表恰好包含当前作用域内已经完整解析的所有原子;下标停在当前作用域的右括号或字符串末尾。
遇到大写字母就读取完整原子名和紧随的多位数量,数量缺省为 1;遇到左括号就递归解析内层,递归返回后消费右括号及其倍数,再把内层每个计数乘倍数累加到当前表。
正确性可按括号嵌套深度归纳:没有括号时,每个原子及数量被准确累加;假设更深层作用域能正确返回统计表,那么当前层对它整体乘以右括号后的倍数并累加,也与化学式语义一致。最终最外层返回的表就是全式计数。
解题步骤
- 用全局或引用下标从左到右扫描,避免重复切分未处理后缀。
- 当前字符为大写字母:继续读取后续小写字母得到完整原子名,再读取多位数量,缺省为 1。
- 当前字符为
(:跳过左括号,递归解析内层;返回后跳过),读取倍数并合并内层统计。- 当前字符为
)或已到末尾:结束当前递归并返回统计表。- 最外层解析结束后按原子名字典序输出;数量为 1 时省略数字。
以
K4(ON(SO3)2)3为例:内层SO3得到{S:1,O:3},乘 2 后并入ON得{O:7,N:1,S:2};外层再乘 3,并与K4合并为{K:4,N:3,O:21,S:6},排序输出K4N3O21S6。
代码实现
import java.util.*;
class Solution {
private String formula;
private int index;
public String countOfAtoms(String formula) {
this.formula = formula;
this.index = 0;
Map<String, Integer> counts = parseGroup();
Map<String, Integer> sorted = new TreeMap<>(counts);
StringBuilder ans = new StringBuilder();
for (Map.Entry<String, Integer> entry : sorted.entrySet()) {
ans.append(entry.getKey());
if (entry.getValue() > 1) {
ans.append(entry.getValue());
}
}
return ans.toString();
}
private Map<String, Integer> parseGroup() {
Map<String, Integer> counts = new HashMap<>();
while (index < formula.length() && formula.charAt(index) != ')') {
if (formula.charAt(index) == '(') {
index++;
Map<String, Integer> inner = parseGroup();
index++;
int multiplier = parseNumber();
for (Map.Entry<String, Integer> entry : inner.entrySet()) {
counts.put(
entry.getKey(),
counts.getOrDefault(entry.getKey(), 0)
+ entry.getValue() * multiplier
);
}
} else {
String atom = parseAtom();
int count = parseNumber();
counts.put(atom, counts.getOrDefault(atom, 0) + count);
}
}
return counts;
}
private String parseAtom() {
int start = index++;
while (index < formula.length()
&& Character.isLowerCase(formula.charAt(index))) {
index++;
}
return formula.substring(start, index);
}
private int parseNumber() {
int start = index;
int value = 0;
while (index < formula.length()
&& Character.isDigit(formula.charAt(index))) {
value = value * 10 + formula.charAt(index++) - '0';
}
return index == start ? 1 : value;
}
}
import (
"sort"
"strconv"
"strings"
)
func countOfAtoms(formula string) string {
index := 0
counts := parseGroup(formula, &index)
atoms := make([]string, 0, len(counts))
for atom := range counts {
atoms = append(atoms, atom)
}
sort.Strings(atoms)
var ans strings.Builder
for _, atom := range atoms {
ans.WriteString(atom)
if counts[atom] > 1 {
ans.WriteString(strconv.Itoa(counts[atom]))
}
}
return ans.String()
}
func parseGroup(formula string, index *int) map[string]int {
counts := make(map[string]int)
for *index < len(formula) && formula[*index] != ')' {
if formula[*index] == '(' {
*index = *index + 1
inner := parseGroup(formula, index)
*index = *index + 1
multiplier := parseNumber(formula, index)
for atom, count := range inner {
counts[atom] += count * multiplier
}
} else {
atom := parseAtom(formula, index)
counts[atom] += parseNumber(formula, index)
}
}
return counts
}
func parseAtom(formula string, index *int) string {
start := *index
*index = *index + 1
for *index < len(formula) &&
formula[*index] >= 'a' && formula[*index] <= 'z' {
*index = *index + 1
}
return formula[start:*index]
}
func parseNumber(formula string, index *int) int {
start, value := *index, 0
for *index < len(formula) &&
formula[*index] >= '0' && formula[*index] <= '9' {
value = value*10 + int(formula[*index]-'0')
*index = *index + 1
}
if *index == start {
return 1
}
return value
}
复杂度分析
设公式长度为 $n$、最大嵌套深度为 $d$、不同原子数为 $m$。
- 时间复杂度:解析字符为 $O(n)$;计数表条目可能逐层向外合并,合并上界为 $O(nd)$,最坏即 $O(n^2)$;最终排序为 $O(m\log m)$。
- 空间复杂度:$O(n)$。递归深度、各层统计表中的总条目数和输出规模都受公式长度限制。
关键点总结
- 递归函数的返回值是“当前作用域的统计结果”,下标停在右括号前。
- 原子名与数字都是变长词法单元,必须一次完整读取;数字缺省值为 1。
- 右括号后的倍数作用于整个内层统计表,合并时必须累加而非覆盖。
- 最终排序只决定输出顺序,不参与解析过程。
易错点总结
- 把
Mg拆成M和g:原子名必须读取所有连续小写字母。- 数字只读取一位:
Be32会被错读为 3。- 递归返回后忘记跳过右括号:下一轮仍停在
),下标无法推进。- 倍数只乘最近一个原子:它应作用于括号内整张统计表。
- 合并时直接覆盖同名原子:外层已有计数会丢失。
- 直接遍历哈希表输出:顺序不保证满足字典序。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 20. 有效的括号 | 简单 | 只用栈判嵌套是否合法,不需要在栈里携带任何计算结果 |
| 394. 字符串解码 | 中等 | 栈里存的是待拼接的字符串前缀和倍数,展开的是字符不是计数 |
| 224. 基本计算器 | 困难 | 括号影响的是正负号,可用一个符号栈代替整张表 |
| 772. 基本计算器 III | 困难 | 括号内还有运算符优先级,需要在作用域上再叠一层求值逻辑 |
| 1096. 花括号展开 II | 困难 | 作用域内维护的是字符串集合,合并时做并集与笛卡尔积 |
| 736. Lisp 语法解析 | 困难 | 括号绑定的是变量作用域,需要沿栈回溯查找变量的最近定义 |