vlambda博客
学习文章列表

动态规划只能用来求最值吗?

你有没有过这样的经历?有的题目用动态规划方法去套的话,很快就能想出解法,但是如果没有提示,你是死活想不到用动态规划来做的。实际上,这也是动态规划算法的一大难点。因此,有很多文章会总结动态规划问题的特征。前几天我看到一篇文章里是这么写的:

动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。

那么,动态规划问题真的就是「求最值」的问题吗?

先说答案:半对半错。动态规划本质上求的是最优解,而不是最大值/最小值。不过很多题目是简化版,只要求你返回最大值/最小值。

这篇文章将讨论一个各种题解、总结文章、套路文章都没有涉及到的一个有趣话题,有关动态规划算法的本质。希望深入了解动态规划的同学,千万不要错过这篇文章。

最优解 vs. 最大值

动态规划究竟是用来求什么的?其实,答案就在书本里。我们可以翻开《算法导论》第 15 章:

We typically apply dynamic programming to optimization problems.

我们通常用动态规划来求最优化问题。

那么,最优化问题又是什么呢?这一类问题通常有多个可行解,我们要从这些可行解中找到一个最优的可行解。

让我们以打家劫舍问题为例看看「最优可行解」的含义:我们的小偷要在一排房子里选几个房子偷东西,而且两间相邻的房子不能都去偷。那么,我们制定的偷窃方案,只要没有相邻的房子,就是一个可行的偷窃方案,即一个可行解。可行的偷窃方案有很多种,其中存在一种最优的方案,偷到的金额最大,这便是最优的可行解。

那么,求「最优解」和求「最大值」有什么区别呢?请看以下两个不同版本的打家劫舍问题:

打家劫舍问题(原版,来自 LeetCode 98)

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

打家劫舍问题(变种)

问题设置和原版问题一样,但是要输出偷窃金额最高的偷窃方案(即偷哪些房子)。

可以看到,原版打家劫舍问题求的是最大值,而变种打家劫舍问题求的是最优解。

最大值和最优解的比较

求最优解要比求最大值更难。 因为知道了最优的偷窃方案,我们就自然知道了偷窃的最高金额;而知道偷窃的最高金额,却不能得到对应的偷窃方案。

我们在 LeetCode 上做的很多动态规划题目,都是求最大值的,可以说是简化版的动态规划题目。这也是动态规划算法被很多人诟病「没有实际应用」的原因。试想,如果你是那个小偷,只告诉你能偷窃到的最高金额,却不告诉你具体的偷窃方案,那这有什么卵用呢?

幸运的是,我们可以在求最大值的基础上求出最优解。接下来我们以「打家劫舍」和「最长公共子序列」两个经典的题目为例,讲讲该怎么求出这个最优解。

例题一:打家劫舍

我们来求解这个变种版的打家劫舍问题:

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算偷窃金额最高的偷窃方案(即偷哪些房子)。

这里假设你已经对原题的解法很熟悉了。原题的解法可以回顾文章:

在原题中,子问题 表示「从前 个房子中能偷到的最大金额」。我们得到的子问题递推关系为:

例如有五间房子,金额分别为 ,则最终求出的 DP 数组中的值如下图所示:

动态规划只能用来求最值吗?
原版的 DP 数组

DP 数组中每个格子放的是一个数,表示当前能偷到的最大金额。但是这只存储了最终的结果,丢掉了具体的偷窃方案。那么,如何得到具体的偷窃方案呢?我们尝试修改 DP 数组。

尝试一:DP 数组存储具体方案

我们首先想到的方案是修改 DP 数组,让数组中每个格子放置当前最优的偷窃方案,如下图所示:

动态规划只能用来求最值吗?
修改后的 DP 数组

例如,dp[3] = [0, 2] 表示偷前 3 个房子的最优方案是偷第 0、第 2 号房子。我们按照以下规则构造 DP 数组:

  • 如果 ,那么 dp[k] 复制 dp[k-1] 的内容;
  • 如果 ,那么 dp[k] 复制 dp[k-2] 的内容,并追加元素 k-1(偷第 号房子)。

可见,只要修改 DP 数组,让其存放具体方案,我们就可以用动态规划求出最优解的具体内容。

不过,如果我们这样定义 DP 数组的话,算法的时间、空间复杂度都会升高。

先看空间复杂度。DP 数组的长度为 。原本 DP 数组中每个格子放一个数,总体空间复杂度为 。现在 DP 数组中每个格子放具体方案,最多可能会有 个数,总体空间复杂度上升到

那么时间复杂度呢?由于在构造 DP 数组的时候,需要复制方案的内容,最多可能会复制 个数,因此总体时间复杂度也会从 上升到

那么,有没有更好的方法,保持原先的时间和空间复杂度呢?

尝试二:使用 back 数组

我们再思考一下 DP 数组的构造过程。dp[k] 的内容要么是复制 dp[k-1] ,要么是复制 dp[k-2]。那么,其实我们不需要真的去复制内容,只需要记录 dp[k] 的内容是「复制于 dp[k-1]」还是「复制于 dp[k-2]」就可以了。如下图所示,我们用往回的箭头表示这种「复制于」信息:

动态规划只能用来求最值吗?
在 DP 数组上添加「复制于」信息

既然 dp[n] 是我们求出的全部 间房子的最大金额,那么从 dp[n] 出发,沿着箭头一路向前,就可以找出构成最大金额的具体方案了。 在这个例子中,从 dp[5] 出发找到 dp[5] -> dp[3] -> dp[1],就可以知道子问题的计算顺序是

在写代码时,为了表示这种「复制于」信息,我们另外使用一个「back 数组」。「DP 数组」和「back 数组」中存储的内容为:

  • DP 数组仍然是其原始的形式,记录 的值,即「偷前 间房子的最大金额」;
  • Back 数组中的值要么是 -1,要么是 -2。如果 back[k] = -1,表示 dp[k]dp[k-1] 计算而来;如果 back[k] = -2,表示 dp[k]dp[k-2] 计算而来。
动态规划只能用来求最值吗?
DP 数组与 back 数组

Back 数组,顾名思义,就是让你能从 dp[n] 一路往回找,知道子问题是怎么计算出来的。

那么,动态规划算法的代码分为两个部分:

  • 第一部分与求最大值的代码相同,构造 DP 数组,依次计算 DP 数组中每个元素的值。同时在计算 DP 数组的时候,顺便计算出 back 数组的内容。
  • 第二部分是从 dp[n] 出发,根据 back 数组向前找,得到最优解的具体方案。

最终,我们得到的题解代码为:

public List<Integer> rob(int[] nums) {
    int N = nums.length;
    int[] dp = new int[N+1];
    int[] back = new int[N+1];
    dp[0] = 0;
    dp[1] = nums[0];
    back[1] = -2;
    for (int k = 2; k <= N; k++) {
        // 计算 DP 数组与 back 数组
        if (dp[k-1] >= dp[k-2] + nums[k-1]) {
            dp[k] = dp[k-1];
            back[k] = -1;
        } else {
            dp[k] = dp[k-2] + nums[k-1];
            back[k] = -2;
        }
    }
    // 根据 back 数组得到具体偷窃方案
    List<Integer> res = new ArrayList<>();
    int k = N; // 从 dp[N] 开始向前寻找
    while (k > 0) {
        if (back[k] == -2) {
            res.add(k-1); // 确定要偷的房子 
        }
        k = k + back[k]; // 根据 back 数组移动指针 k
    }
    Collections.reverse(res); // 由于是倒序向前寻找,最后还要反转一下
    return res;
}

例题二:最长公共子序列

接下来,我们看看二维的最长公共子序列(LCS)问题如何求最优解。我们首先看一下两个不同版本的 LCS 问题:

最长公共子序列(原版,来自 LeetCode 1143)

给定两个字符串 st,返回这两个字符串的最长公共子序列的长度。若这两个字符串没有公共子序列,则返回 0。

一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。例如,"ace" 是 "abcde" 的子序列。

最长公共子序列(变种)

给定两个字符串 st,返回这两个字符串的最长公共子序列。若这两个字符串没有公共子序列,则返回空字符串。

很显然,LeetCode 上的原题还是求「最大值」,而不是求「最优解」,这又是一道降低难度的题目。我们已经在上一篇文章中讲解了原题的解法:

接下来我们看看如何求「最优解」,即具体的最长公共子序列。

尝试一:DP 数组存储具体方案

我们先考虑能不能用 DP 数组存储具体的方案。在原题中,dp[i][j] 存储的是「s[0..i)t[0..j) 的最长公共子序列的长度」。我们考虑让 dp[i][j] 直接存储最长公共子序列,而不是其长度。

同样的,这种方法会导致在构造 DP 数组的时候,不停地复制字符串,导致算法的时间、空间复杂度升高。

那么,这种方法仍然是不可取的。

尝试二:使用 back 数组

那么,我们尝试使用「DP 数组」+「back 数组」的方案。

首先我们来看一下 DP 数组是什么样子的。以 s = “ABCBDAB”t = “BDCABA” 为例,DP 数组的内容为:

动态规划只能用来求最值吗?
DP 数组的内容

那么,back 数组中应该存什么值呢?我们再回顾一下子问题的递推关系:

可以看到,dp[i][j] 的值可能来自左上(即 dp[i-1][j-1])、上方(即 dp[i-1][j])或左方(即 dp[i][j-1])。

动态规划只能用来求最值吗?
子问题的值可能来自三个方向

那么,back 数组的元素可以取三个值,即「左上」、「上」、「左」,分别用「↖︎」、「↑」、「←」表示。

动态规划只能用来求最值吗?
back 数组的三种取值

为了节省空间,也为了看着更直观,我们把 DP 数组和 back 数组的内容画在一起。下图格子中的数字是 DP 数组的元素,箭头是 back 数组的元素。

动态规划只能用来求最值吗?
DP 数组与 back 数组的内容

同样的,我们可以从 dp[m][n] 出发,根据 back 数组一路往回找,其实就是按照「箭头」的方向回退:

动态规划只能用来求最值吗?
根据 back 数组的回退路径

如何在回退的过程中找到最长公共子序列呢?我们发现,回顾子问题的递推关系,当 dp[i][j] 是由 dp[i-1][j-1] 计算得来的时候,实际上是我们发现了 st 有一个公共的字符,因而在 st 中都删掉了一个字符。

那么,我们只需要在回退的过程中找到所有的向「左上」回退的位置,并记录对应的字符。下图中蓝色三角形表示向「左上」回退的位置,将这些位置对应到 st,可以轻松地看出最长公共子序列是 “BCBA”。

在回退中找出公共子序列

我们可以根据以上思路写出题解代码:

int NONE = 0// 回退终点
int UPPER_LEFT = 1// 向左上回退
int UPPER = 2// 向上回退
int LEFT = 3// 向左回退

public String lcs(String s, String t) {
    if (s.isEmpty() || t.isEmpty()) {
        return "";
    }
    int m = s.length();
    int n = t.length();
    int[][] dp = new int[m+1][n+1];
    // back 数组的取值只可能是 UPPER_LEFT、UPPER、LEFT 或 NONE
    int[][] back = new int[m+1][n+1];
    for (int i = 0; i <= m; i++) {
        for (int j = 0; j <= n; j++) {
            // 计算 DP 数组与 back 数组
            if (i == 0 || j == 0) {
                dp[i][j] = 0;
                back[i][j] = NONE;
            } else {
                if (s.charAt(i-1) == t.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1] + 1;
                    back[i][j] = UPPER_LEFT;
                } else if (dp[i-1][j] > dp[i][j-1]) {
                    dp[i][j] = dp[i-1][j];
                    back[i][j] = UPPER;
                } else {
                    dp[i][j] = dp[i][j-1];
                    back[i][j] = LEFT;
                }
            }
        }
    }
    // 根据 back 数组回退,得到具体的 LCS
    int i = m;
    int j = n;
    StringBuilder sb = new StringBuilder();
    while (i > 0 && j > 0) {
        if (back[i][j] == UPPER_LEFT) {
            sb.append(s.charAt(i-1));
            i--;
            j--;
        } else if (back[i][j] == UPPER) {
            i--;
        } else if (back[i][j] == LEFT) {
            j--;
        } else {
            break;
        }
    }
    return sb.reverse().toString();
}

求「最优解」的一般流程

实际上,任何的动态规划问题都可以通过添加一个「back 数组」的方法,来求出最优解的具体方案。我们可以仿照原先的「动态规划四步骤」,总结出求最优解的具体方案的步骤流程:

  • 定义子问题
  • 写出子问题的递推关系
  • 确定 DP 数组的计算顺序
  • 按顺序计算 DP 数组与 back 数组
  • 从最后的子问题出发,根据 back 数组回退,得到最优解的具体方案

需要注意的是,以上步骤并不包括「空间优化」。如果想求出最优解的具体方案的话,是不能进行空间优化的,否则会丢失一些必要的信息。

总结

经过以上两道题目的讲解,你是否对动态规划求「最优解的具体方案」有所领悟呢?

LeetCode 中大部分的题目都只需要求出「最大值」,而求「最优解」的题目并不算多。但是理解了最优解的求法,可以帮你更好地理解动态规划的本质,尤其是「DP 数组」究竟代表着什么。

本文中涉及的两道变种的题目在 LeetCode 上并没有原题,如果你想写写代码练手的话,可以去做 AtCoder 上的这道 LCS 题目:https://atcoder.jp/contests/dp/tasks/dp_f

往期文章