题目描述

✅ 241. 为运算表达式设计优先级

image-20260928235049366

image-20260928235049367

题意分析

保持数字和运算符的原有顺序,通过改变括号位置决定运算次序,返回所有括号方案的计算结果。题目不是求一个最大值或最小值,也不是按通常的乘法优先规则计算一次。

不同方案可能得到相同数值,这些结果仍要分别保留。输入中的整数不带正负号,所以每个 +、-、* 都是连接左右两个子表达式的运算符,多位数字则必须作为一个整体。

解法:分治枚举最后运算符 + 记忆化

核心思路

[!blue]

直接枚举括号放在哪些字符间很难避免遗漏。换一个角度:无论括号怎样安排,整个表达式总有一次最后执行的运算。设它位于下标 i,那么它左边和右边的子表达式都必须先算完,再用这个运算符合并。

定义 dfs(expr) 返回子表达式 expr 的全部计算结果。扫描 expr 中的每个运算符,把它依次视为最后一次运算,递归求出左右结果列表 left 和 right。左侧的任意一种括号方案都能与右侧的任意一种方案搭配,因此需要两层循环,枚举每个 a 与每个 b,追加 a op b。

这种拆法既完整又不会重复枚举同一方案:每个完整括号方案都有唯一的最后运算符,它对应一次确定的切分;该切分下的左右括号方案再由递归分别枚举。反过来,每一对左右方案也都能由当前运算符合法连接。相同数值可能来自不同方案,因此列表不能去重。

如果整段没有运算符,它就是一个完整整数,直接解析并返回只含这个数的列表,这是递归终点。不能以字符串长度是否为 $1$ 判断终点,因为输入也可能包含多位数。代码中,有运算符的合法子表达式至少能组合出一个结果,所以 res 为空恰好表示没有遇到运算符。

不同切分会反复求解相同的子表达式,用 memo 按字符串内容缓存整个结果列表。缓存命中时直接读取;父问题仍照常枚举列表中的每一项,所以复用计算不会丢失括号方案的数量。左右子串都比原串短,递归最终一定到达整数。

解题步骤

  1. 建立 memo,记录每个子表达式对应的结果列表;递归开始时先查缓存。
  2. 扫描当前子串,遇到 +、- 或 * 时,把它作为最后执行的运算符。
  3. 递归求左右子串,对左右结果做笛卡尔积,并按当前运算符合并。减法不能交换操作数顺序。
  4. 若整段没有运算符,将其作为多位整数解析,这是递归基准。
  5. 缓存当前列表后返回;列表中的重复值必须保留。

代码实现

class Solution {
    public List<Integer> diffWaysToCompute(String expression) {
        return dfs(expression, new HashMap<>());
    }

    private List<Integer> dfs(String expression, Map<String, List<Integer>> memo) {
        if (memo.containsKey(expression)) {
            return memo.get(expression);
        }

        List<Integer> res = new ArrayList<>();

        for (int i = 0; i < expression.length(); i++) {
            char op = expression.charAt(i);

            if (op != '+' && op != '-' && op != '*') {
                continue;
            }

            // 枚举最后一次执行的运算符,左右两侧结果再两两组合。
            List<Integer> left = dfs(expression.substring(0, i), memo);
            List<Integer> right = dfs(expression.substring(i + 1), memo);

            // 不同括号方案即使数值相同也分别保留,不能对组合结果去重。
            for (int a : left) {
                for (int b : right) {
                    res.add(calculate(a, b, op));
                }
            }
        }

        // 没有运算符时把整段作为一个数解析,也包含多位数。
        if (res.isEmpty()) {
            res.add(Integer.parseInt(expression));
        }

        memo.put(expression, res);

        return res;
    }

    private int calculate(int a, int b, char op) {
        if (op == '+') {
            return a + b;
        }

        if (op == '-') {
            return a - b;
        }

        return a * b;
    }
}
import "strconv"

func diffWaysToCompute(expression string) []int {
    memo := make(map[string][]int)
    var dfs func(expr string) []int
    dfs = func(expr string) []int {
        if res, ok := memo[expr]; ok {
            return res
        }

        res := make([]int, 0)
        for i := 0; i < len(expr); i++ {
            op := expr[i]
            if op != '+' && op != '-' && op != '*' {
                continue
            }

            // 枚举最后一次执行的运算符,左右两侧结果再两两组合。
            left := dfs(expr[:i])
            right := dfs(expr[i+1:])
            // 不同括号方案即使数值相同也分别保留,不能对组合结果去重。
            for _, a := range left {
                for _, b := range right {
                    res = append(res, calcExpr(a, b, op))
                }
            }
        }
        // 没有运算符时把整段作为一个数解析,也包含多位数。
        if len(res) == 0 {
            value, _ := strconv.Atoi(expr)
            res = append(res, value)
        }
        memo[expr] = res
        return res
    }
    return dfs(expression)
}

func calcExpr(a int, b int, op byte) int {
    if op == '+' {
        return a + b
    }
    if op == '-' {
        return a - b
    }
    return a * b
}

复杂度分析

  • 时间复杂度:设运算符数为 $n$,完整括号方案数为卡特兰数 $C_n = \frac{1}{n+1}\binom{2n}{n}$,即使数值相同,也要输出这 $C_n$ 项。含 $k$ 个运算符的区间至多有 $n-k+1$ 个,每个区间生成 $C_k$ 个结果;缓存后所有区间的组合总量至多为 $\sum_{k=0}^{n}(n-k+1)C_k = O(C_n)$。扫描、字符串截取及哈希还有多项式开销,整体由指数增长的 $C_n$ 主导。
  • 空间复杂度:$O(C_n)$。各区间缓存的结果总数同样是 $O(C_n)$,递归深度为 $O(n)$;记忆化减少重复计算,但不能减少题目要求返回的方案数。

关键点总结

[!green]

  • 把括号方案看成二叉运算树,枚举最后运算符就是枚举根节点。
  • 子问题返回结果列表,合并方式是左右列表的笛卡尔积。
  • 根节点唯一保证枚举不漏;数值相同不代表方案相同,因此不能去重。
  • 记忆化消除重复子表达式,复杂度下界仍由卡特兰数量级的输出决定。

易错点总结

[!yellow]

  • 右子串必须从运算符后一位开始;写成 substring(i) 会让递归无法缩小。
  • 基准条件是“没有运算符”,不是“长度为 1”,否则 11 这样的多位数会返回空列表。
  • 结果不能放入集合去重;相同数值仍可能对应不同括号方案。
  • 左右操作数不能调换,减法的方向必须与原表达式一致。
  • 缓存必须在递归入口读取、返回前写入,否则重复区间仍会被指数级展开。

相似题目

题目 难度 关联与区别
面试题 08.14. 布尔运算 中等 同样枚举最后运算符并组合两侧结果,布尔题可把结果压缩为0和1的计数。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/98576916
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!