看了前文 动态规划解题套路框架,我知道了如何一步步优化动态规划问题;

看了前文前提概要,我知道了利用数学归纳法写出暴力解(状态转移方程)。

但就算我写出了暴力解,我很难判断这个解法是否存在重叠子问题,从而无法确定是否可以运用备忘录等方法去优化算法效率。

对于这个问题,其实我在动态规划系列的文章中写过几次,在这里再统一总结一下吧。

首先,最简单粗暴的方式就是画图,把递归树画出来,看看有没有重复的节点

比如最简单的例子,动态规划解题套路框架 中斐波那契数列的递归树:

image.png

这棵递归树很明显存在重复的节点,所以我们可以通过备忘录避免冗余计算。

但毕竟斐波那契数列问题太简单了,实际的动态规划问题比较复杂,比如二维甚至三维的动态规划,当然也可以画递归树,但不免有些复杂。

比如在 最小路径和问题 中,我们写出了这样一个暴力解法:

int dp(int[][] grid, int i, int j) {
    if (i == 0 && j == 0) {
        return grid[0][0];
    }
    if (i < 0 || j < 0) {
        return Integer.MAX_VALUE;
    }
 
    return Math.min(
            dp(grid, i - 1, j), 
            dp(grid, i, j - 1)
        ) + grid[i][j];
}

你不需要读过前文,光看这个函数代码就能看出来,该函数递归过程中参数 i, j 在不断变化,即「状态」是 (i, j) 的值,你是否可以判断这个解法是否存在重叠子问题呢?

假设输入的 i = 8, j = 7,二维状态的递归树如下图,显然出现了重叠子问题:

image.png

但稍加思考就可以知道,其实根本没必要画图,可以通过递归框架直接判断是否存在重叠子问题

具体操作就是直接删掉代码细节,抽象出该解法的递归框架:

int dp(int[][] grid, int i, int j) {
    dp(grid, i - 1, j), // #1
    dp(grid, i, j - 1)  // #2
}

可以看到 i, j 的值在不断减小,那么我问你一个问题:如果我想从状态 (i, j) 转移到 (i-1, j-1),有几种路径?

显然有两种路径,可以是 (i, j) -> #1 -> #2 或者 (i, j) -> #2 -> #1,不止一种,说明 (i-1, j-1) 会被多次计算,所以一定存在重叠子问题。

再举个稍微复杂的例子,后文 正则表达式问题 的暴力解代码:

bool dp(string& s, int i, string& p, int j) {
    int m = s.size(), n = p.size();
    if (j == n)  return i == m;
    if (i == m) {
        if ((n - j) % 2 == 1) return false;
        for (; j + 1 < n; j += 2) {
            if (p[j + 1] != '*') return false;
        }
        return true;
    }
 
    if (s[i] == p[j] || p[j] == '.') {
        if (j < n - 1 && p[j + 1] == '*') {
            return dp(s, i, p, j + 2)
               || dp(s, i + 1, p, j);
        } else {
            return dp(s, i + 1, p, j + 1);
        }
    } else if (j < n - 1 && p[j + 1] == '*') {
        return dp(s, i, p, j + 2);
    }
    return false;
}

代码有些复杂对吧,如果画图的话有些麻烦,但我们不画图,直接忽略所有细节代码和条件分支,只抽象出递归框架:

bool dp(string& s, int i, string& p, int j) {
    dp(s, i, p, j + 2);     // #1
    dp(s, i + 1, p, j);     // #2
    dp(s, i + 1, p, j + 1); // #3
}

和上一题一样,这个解法的「状态」也是 (i, j) 的值,那么我继续问你问题:如果我想从状态 (i, j) 转移到 (i+2, j+2),有几种路径?

显然,至少有两条路径:(i, j) -> #1 -> #2 -> #2 和 (i, j) -> #3 -> #3,这就说明这个解法存在巨量重叠子问题。

所以,不用画图就知道这个解法也存在重叠子问题,需要用备忘录技巧去优化。