题目分析

题目链接:https://leetcode.com/problems...
从表面上看,题目问有多少种方式为表达式增加括号,实际上等价于找出表达式所有可能的计算次序,每一种画括号的方式一一对应于一种计算次序。

增加括号的过程虽然在纸上写着简单,但是如果真的用程序来模拟它,会非常繁琐且容易出错。对于这种难以用程序模拟的过程,应该尝试找出等价、更容易编码的过程。

表达式的计算次序很容易被计算机表示,因为表达式的计算是递归的(语法树),我们可以通过递归的方式确定表达式的计算次序:
对于一个表达式,我们只要确定它最后计算哪个符号,这个符号的两边就是子表达式,然后递归地确定子表达式的计算次序,直到表达式由单个数字组成。

递归实现

class Solution {
 public:
  vector<int> diffWaysToCompute(string input) {
    vector<int> res;
    const int size = input.length();
    for (int i = 0; i < size; ++i) {
      char c = input[i];
      if (c == '+' || c == '-' || c == '*') {
        auto v1 = diffWaysToCompute(input.substr(0, i));
        auto v2 = diffWaysToCompute(input.substr(i + 1));
        for (int r1 : v1)
          for (int r2 : v2)
            res.push_back(c == '+' ? r1 + r2 : c == '-' ? r1 - r2 : r1 * r2);
      }
    }
    if (res.empty()) res.push_back(stoi(input));
    return res;
  }
};

这是一个非常典型的分治算法,它会尝试枚举出所有可能的语法树。首先它确定一个根节点(最后计算的符号),然后,根据2个子表达式,找出所有可能的左右子树。

改进为动态规划算法

递归实现分治有一个弊端:子问题会被重复计算很多次。具体到这个问题,题目给出的例子中:

2*3这个乘法计算了2次,同理,3-44*5都计算了2次,如果输入表达式再长一些,会有更长的式子被重复计算、重复次数更多。
这是递归的“遗忘”特性造成的,除非你手动在递归过程中保存结果,否则如果递归算法下次递归到同一个子表达式,它会重复计算一遍。

动态规划恰好能避免这个问题。

class Solution {
 public:
  vector<int> diffWaysToCompute(string input) {
    // 数字保存到nums,将算符保存到ops
    stringstream ss(input + '+');
    int num = 0;
    char op = ' ';
    vector<int> nums;
    vector<int> ops;
    while (ss >> num && ss >> op) {
      nums.push_back(num);
      ops.push_back(op);
    }
    ops.pop_back();
    const int size = nums.size();

    vector<vector<vector<int>>> dp(size, vector<vector<int>>(size));

    for (int i = 0; i < size; ++i) {
      dp[i][i].push_back(nums[i]);
      for (int j = i - 1; j >= 0; --j) {
        // 计算[j, i](nums数组的下标范围)的所有可能结果
        for (int k = j; k < i; ++k) {
          // 左子表达式:[j,k], 右子表达式:[k+1,i]
          const vector<int> left = dp[j][k];
          const vector<int> right = dp[k + 1][i];
          for (int x : left)
            for (int y : right) switch (ops[k]) {
                case '+':
                  dp[j][i].push_back(x + y);
                  break;
                case '-':
                  dp[j][i].push_back(x - y);
                  break;
                case '*':
                  dp[j][i].push_back(x * y);
                  break;
              }
        }
      }
    }
    return dp[0][size - 1];
  }
};

动态规划中,很重要的一点就是确定合适的子问题计算次序,使得计算父问题的时候,子问题在此之前就已经计算好了。
在上面的实现中,i是右游标,j是左游标。i从0开始往右移动,j从i-1开始往左移动,依次计算表达式[j, i]所有可能的结果。按照这种计算顺序,当我们将表达式[j, i]划分为左子表达式[j,k], 右子表达式[k+1,i]时,这两个子表达式的结果必定在此之前已经算好了,且已经存储在dp数组中。

更多阅读

  • 《算法概论》6.3对比递归和动态规划的部分: Recursion? No, thanks.

csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.