Leetcode726 Number of Atoms 题解

483 阅读4分钟

为了试试看我是否有能力处理 Hard 类型题目,就随机挑选了一道。是字符串处理问题。题意就是给出一个化学式,求出所有元素及其个数。

合理的化学式应该只包含大写字母、小写字母、左右括号和数字。而且一个化学元素名必然是大写字母开头,其余字母都是小写字母。

我的确输了。做是做出来了,但是时间太久了。

很明显需要用到栈,然后就是按顺序读字符串做处理了。我刚开始构造了一个树结构来存储一个化学式,树节点(一个基团)的定义是

function Group() {
    this.children = [];
    this.count = 0;
}

count是基团的个数,children是一个包含元素(字符串)或者其他基团(Group对象)的数组。

为了便于处理,对字符串中单个元素或基团加上了一个数字1,同时加上结尾指示符$。(这步其实没必要)

// Mg(OH)2 -> Mg1(O1H1)2
function process(str) {
    str += '$';
    let res = '';
    let j = 0;
    for (let i = 0; i < str.length; i++) {
        const c = str[i];
        if (c >= 'A' && c <= 'Z' || c == '(' || c == ')' || c == '$') {
            if (j - 1 >= 0 && !(res[j - 1] >= '0' && res[j - 1] <= '9') && res[j - 1] != '(') {
                res += '1';
                j++;
            }
        }
        res += c;
        j++;
    }
    return res;
}

然后核心逻辑

var countOfAtoms = function(formula) {
    formula = process(formula);
    let element = '';
    let group = null;
    let count = 0;
    let i = 0;
    let root = new Group();
    const stack = [root];
    while (i < formula.length) {
        const c = formula[i];
        if (c >= 'A' && c <= 'Z' || c == '$') {
            if (i > 0) {
                update();
            }
            element += c;
        } else if (c >= 'a' && c <= 'z') {
            element += c;
        } else if (c >= '0' && c <= '9') {
            count = count * 10 + parseInt(c);
        } else if (c == '(') { //进入一个新的 group
            if (i > 0) {
                update();
            }
            root = new Group();
            stack.push(root);
        } else {  // ')'
            update();
            group = stack.pop();
            root = stack[stack.length - 1];
        }
        i++;
    }
    root = stack[0];
    root.count = 1;
    const obj = traverse(root);
    return Object.keys(obj).sort().map(x => {
        return x + (obj[x] == 1 ? '' : obj[x]);
    }).join('');
}

其中这个update函数用来处理elementgroup两个变量到dict里去。

function update() {
    if (element.length > 0) {
        group = new Group();
        //元素也当做一个基团处理
        group.children.push(element); 
        group.count = count;
        element = '';
    }
    if (group) {
        root.children.push(group);
        group.count = count;
        group = null;
    }
    count = 0;
}

update方法调用时机为遇到大写字母、结束符、左右括号时,这些时候都代表即将结束当前基团或即将进入下个基团。

例如 K4(ON(SO3)2)2 构造出来的树是这样的:

         +-------------+
         |    root     |
         |             |
         |  count: 1   |
         +--+------+---+
            |      |
      +-----+      +------+
      |                   |
+-----+---+          +----+-----+
|   [K]   |          |    []    |
| count:4 |          | count: 2 |
+---------+          +-+--+---+-+
                       |  |   |
           +-----------+  |   +---------+
           |              |             |
           |              |             |
      +----+----+     +---+-----+  +----+-----+
      |   [O]   |     |    [N]  |  |    []    |
      | count:1 |     | count:1 |  |  count:2 |
      +---------+     +---------+  +--+--+----+
                                      |  |
                          +-----------+  |
                          |              |
                          |              |
                     +----+----+     +---+-----+
                     |   [S]   |     |    [O]  |
                     | count:1 |     | count:3 |
                     +---------+     +---------+

接下来就是对树进行后序遍历(?),把子节点上的所有元素个数统计到根节点上,返回一个{element: count}字典。

function traverse(root) {
    if (!(root instanceof Group)) {
        return {
            [root]: 1
        };
    }
    let sum = {};
    for (const sub of root.children) {
        const res = traverse(sub);
        for (const key of Object.keys(res)) {
            if (sum[key]) {
                sum[key] += res[key];
            } else {
                sum[key] = res[key];
            }
        }
    }
    for (const key of Object.keys(sum)) {
        sum[key] *= root.count;
    }
    return sum;
}

我觉得思路上是没有问题的,但是对于这道题来说太麻烦了……这绝对是不能用来面试的。

经过考虑,我觉得转换成树这一步是没必要的,栈里面直接存字典就好了。核心逻辑没有改变,update的变化也不大。代码改成了:

/**
 * @param {string} formula
 * @return {string}
 */
var countOfAtoms = function(formula) {
    formula += '$';
    const stack = [];
    let dict = {}, count = 0, element = '', group = null;  //group不再是树节点,而是{}
    for(const c of formula) {
        if (c >= 'A' && c <= 'Z') {
            update();
            element += c;
        } else if (c >= 'a' && c <= 'z') {
            element += c;
        } else if (c >= '0' && c <= '9') {
            count = count * 10 + parseInt(c);
        } else if (c == '(') {
            update();
            stack.push(dict);
            dict = {};
        } else {  //处理结尾符的逻辑放到了这里
            update();
            group = dict; //这是 group 唯一的赋值处
            dict = stack.pop();
        }
    }
    //使用 reduce 代码更漂亮了,注意要有初始字符串''
    return Object.keys(group).sort().reduce((s, x) => s + x + (group[x] == 1 ? '' : group[x]), '');
    
    function update() {
        if (count == 0) count = 1;
        if (element.length > 0) {
            if (dict[element]) {
                dict[element] += count;
            } else {
                dict[element] = count;
            }
            element = '';
        }
        if (group) { //只有刚刚处理完')'或者到结尾,group 才不是 null
            group.times(count);
            dict.merge(group);
            group = null;
        }
        count = 0;
    }
}

Object.prototype.merge = function(dict) {
    for (const key of Object.keys(dict)) {
        if (this[key]) {
            this[key] += dict[key];
        } else {
            this[key] = dict[key];
        }
    }
}

Object.prototype.times = function(n) {
    for (const key of Object.keys(this)) {
        this[key] *= n;
    }
}

尽管代码更加清晰了,但是多处调用update看着还是很麻烦。还能再优雅一些吗?我参考了最快的(48 ms)代码示例,改写成了下面的样子:

var countOfAtoms = function(formula) {
    let dic = {};
    const stk = [1];  //stack 顶数代表当前处理的基团/元素的乘数
    for(let i = formula.length - 1; i >= 0; i--) {
        if(formula[i]=='(') {
            stk.pop();
            continue;
        }
        const t = stk[stk.length-1];
        let j = i;
        while (formula[i] >= '0' && formula[i] <= '9') {
            i--;
        }
        const n = t * ((i < j) ? Number(formula.substring(i + 1, j + 1)) : 1);
        if(formula[i] == ')') {
            stk.push(n);
            continue;
        }
        j = i
        while (formula[i] >= 'a' && formula[i] <= 'z'){
            i--;
        }
        const s = formula.substring(i, j + 1);
        dic[s] = dic[s] || 0;
        dic[s] += n;
    }
    return Object.keys(dic).sort()
            .reduce((s, c) => s + (dic[c] > 1 ? c + dic[c] : c), '');
};

他的做法挺巧的,从右往左处理,因为基团或元素的个数是写在右边的,所以这种方法更好。看懂比较容易,一次性写对不易,可能会有些细节上的坑。