查看原文
其他

关于动态规划,你想知道的都在这里了!

Your DevOps Guy AI科技大本营 2020-12-18

作者 | Your DevOps Guy
翻译| 火火酱~,责编 | 晋兆雨
出品 | AI科技大本营
头图 | 付费下载于视觉中国

什么是动态规划?它又有什么重要的呢?


在本文中,我将介绍由Richard Bellman在20世纪50年代提出的动态规划(dynamic programming)概念,这是一种强大的算法设计技术——将问题分解成多个小问题,存储它们的解,通过将其结合在一起,最终得到原始问题的解决方案。
FAANG编程面试中最难的问题通常都属于这一类。你在面试的过程中也很可能会被要求解决这样的问题,因此,了解这项技术的重要性自然不言而喻。接下来,我将解释什么是动态规划,给出一个解决动态规划问题的秘诀,并且和大家一起分析几个示例,以便你能够更好地理解其应用场合和应用方法。
和我以往有关编程面试的文章一样,在本文中,我将分享自己在使用这种方法解决问题时的思考过程,这样当你在面对其中一个问题时,按照这个过程一定也能解决。不需要死记硬背,我们只需要通过了解技术和实践,将想法转化成代码技能。编程的重点不在于学习编程语言,而在于分析问题,考虑不同的解决方案,从中选出最优解,然后通过某种编程语言将其转化为现实。


动态规划


动态规划是一种解决最优化、搜索和计数问题的通用技术,这些问题都可以被分解为多个子问题。要应用动态规划,问题就必须具备以下两个属性:
  • 最优子结构(Optimal substructure)
  • 重叠子问题(Overlapping subproblems)
(1)最优子结构
如果大小为n的问题的最优解可以由大小小于n的问题的同一实例的最优解推导出,则该问题具有最优子结构。
例如,如果从巴黎到莫斯科的最短路径会经过柏林,那么可以由巴黎到柏林的最短路径和柏林到莫斯科的最短路径组成。
如果一个问题可以通过组合非重叠子问题的最优解来解决,这种策略被称为分治法。这就是归并排序和快速排序不属于动态规划问题的原因。
(2)重叠子问题
举一个大家都很熟悉的例子,斐波那契数列,即从第三项开始,每一项都等于前两项之和。斐波那契数列可以表示为
F(0) = F(1) = 1
F(n) = F(n-1) + F(n-2)
大家都说一张图片胜过千言万语,所以......(摘自《Elements of programming interviews》)              
要想解出F(n),就需要解出F(n-1)和F(n-2),但是F(n-1)又需要F(n-2)和F(n-3)。这样一来,F(n-2)是重复的,来自于同一个问题的两个不同实例——计算一个斐波那契数。
这可以用一个递归函数来表示:
  • 要想解决一个大小为n的问题,我们可以调用相同的函数来解决同一问题的一个实例,但实例规模比原始问题规模小一些。
  • 我们一直不断地调用该函数,直到到达基础用例,也就是停止条件,在此处即n = 0或n = 1。
这就引出了递归和动态规划之间的关系。


递归和动态规划


从概念上讲,动态规划涉及到递归问题。我们希望通过同一个问题的较小实例来解决原始问题,而递归是在代码中实现这一点的最佳选择。与纯递归函数的不同之处在于,我们将用空间来换取时间:我们将存储各子问题的最优解,进而高效地找到原始问题的最优解。
当然,这并不是说我们都必须使用递归来解决动态规划问题。还可以通过一种迭代方法来编写动态规划解决方案。
自下而上的动态规划
我们需要将所有子问题的解决方案填入表格(从基本用例开始),并用它来构建我们正在寻找的解决方案。这个过程是通过迭代的方式完成的,你可以从下面列别中任选其一作为存储子问题解决方案的数据结构:
  • 多维(以及一维)数组——最常用的方法;
  • 哈希表;
  • 二叉搜索树;
自上而下的动态规划
编写递归算法并添加缓存层,以避免重复的函数调用。
或许现在看起来有点糊涂,但等一会儿讲到示例后,一切都会清楚得多。


如何解决动态规划问题


如果一个问题想要通过动态规划来解决的话,就必须具备最优子结构和重叠子问题这两个属性。当直觉告诉你动态规划或许是一个可行的解决方案时,你需要验证其是否具备这两个属性。
下面让我们试着感受一下,什么样的问题可以用动态规划来解决。一切以“找到”开头的问题:
  • ... ...的前n个元素;
  • ... ...的所有方式;
  • 有多少种... ...方式;
  • 第n个... ... ;
  • ... ...的最优解决方案;
  • ... ...的最短小/最大/最短路径;
都是潜在“候选人”。
解决动态规划问题的步骤
很不幸,解决动态规划问题并没有什么通用秘诀。我们需要在经历过很多问题之后,才能逐渐掌握其诀窍。这确实不容易,毕竟这可能会是你在面试中遇到的最难的问题了。但也先不要气馁,简单来讲,就是用相对简单的工具针对问题进行建模,并不需要花哨的数据结构或算法。
我已经解决过很多此类问题了,但有时还是会觉得毫无头绪,找不到解决方法。练习得越多,就越容易。以下这几点或许能带你走近解决动态规划问题的秘诀:
  • 证明重叠子问题和次优结构特性。
  • 定义子问题。
  • 定义递归。
  • 编写自上而下或自下而上的动态规划解决方案。
复杂度分析因问题而异,但一般来说,时间复杂度可以表示为:
时间~子问题个数*每个子问题的时间
计算自下而上解决方案的空间复杂度很简单,因为其等于存储子问题解决方案所需的空间(多维数组)。

示例


我已经根据所涉及的独立维度的数量对问题进行了分类。这一步并不是必须的,但我发现在设计解决方案时,遵循一定的心理模型是非常有用的。随着编写的代码越来越多,你会找到一些模式,而这就是其中之一。不妨试一下,如果觉得有用的话就用起来吧。

一维问题

(1)斐波那契

因为现在大家都已经对这个问题非常熟悉了,所以我就直接给出递归解决方案:
int fib(int n) {
  if (n == 0 || n == 1)
    return 1;
  else
    return fib(n - 1) + fib(n - 2);
  }
}
从递归到自上而下的过程通常都有固定的模式:
  • 检查我们需要的值是否已经在缓存中了。如果是,就返回它。
  • 如果不是,就在返回之前先缓存的解决方案。
int fib(int n) {
  vector<int> cache(n + 1-1);
  return fib_helper(n, cache);
}
int fib_helper(int n, vector<int> &cache) {
   if(-1 != cache[n])
     return cache[n];
   if (n == 0 || n == 1)
     cache[n] = 1;
  else
    cache[n] = fib_helper(n - 1, cache) + fib_helper(n - 2, cache);
  return cache[n];
}
这里,用到自下而上的解决方案,我们通过构建一个表(从基本用例为起点),来形成要找的问题的解决方案。这个表是一个一维数组:我们只需要存储较小的问题的解,就可以推导出原始问题的解。
int fib(int n) {
    vector<int> f(n + 10);  
    f[1] = 1;
    for(int i = 2; i <= n; i++)
       f[i] = f[i - 1] + f[i - 2];
    return f[n];
}
额外空间优化
这种方法可以进一步优化内存,但并不会优化时间(也可以通过其他技术更快地计算斐波纳契数列,但这就是另一篇文章的内容了),只需要使用3个变量,而不必使用数组,因为我们只需要跟踪两个值,即f (n - 1)和f (n - 2),就可以得到我们想要的输出——f (n)。
int fib(int n) {  
    if (n == 0 || n == 1)
      return 1;
    //Variables that represent f(n - 1), f(n - 2) and f(n)
    int n1= 1, n2 = 1, f = 0;
    for (int i = 2; i <= n; i++) {
        f= n1 + n2;
        n2 = n1;
        n1 = f;
    }
    return f;
}
这种方法更高级,也更常见。如果只需要跟踪:
  • 几个变量,或许我们就可以摆脱一维数组,将其变成几个变量。
  • 二维矩阵中的几行,或许我们可以将其减少成几个一维数组。
  • 等等... ...
通过降维,我们提高了空间的复杂度。现在,你不必记住所有的细节,但在进行过一些实践之后,要试着自己提出这些优化方案,从而增强自己分析问题并将想法转化为代码的能力。在面试中,我会选择更简单的版本,只讨论潜在的优化方案。只有在编写了自己的“标准化”动态规划解决方案,并且时间充足的时候,才动手实施这些优化。


(2)爬楼梯

假设你正在爬一段有n个台阶的楼梯,每次可以爬1或2个台阶。那么要想爬到顶端的话,一共有多少种不同的方法呢?
例1:
  • 输入:2
  • 输出:2
  • 说明:有两种方法可以爬到顶端:1阶+1阶和2阶
例2:
  • 输入:3
  • 输出:3
  • 说明:有三种方法可以爬到顶端:1阶+ 1阶+ 1阶,1阶+ 2阶,2阶+ 1阶
解决方案
先试着自己解决一下这个问题。你可能会想到一个递归解决方案。回顾一下我的说明和前面的示例,看看是否可以自行编写出自上而下的的解决方案。
提示一下:既然这个问题以“有多少种方式”开头,那就应该能想到采用动态规划的潜在可能性。
在这种情况下,如果想要到达第N阶,就要经过第N-1阶或第N-2阶,因为一次可以爬1阶或2阶。如果我们能解决这两个子问题的话,就可以找到一般问题的解。我们将f(N)称为到第N阶的方法数。
  • 要得到f(N),就要先求出f(N-1)和 f(N-2)。
  • 要得到f(N-1),就要先求出f(N-2)和 f(N-3)。
  • 要得到f(N-2),就要先求出f(N-3)和 f(N-4)。
不需要继续罗列下去了吧,你应该已经发现了:
  • 这个问题有重叠的子问题:你需要多次计算f(N-2), f(N-3), f(N-4),... ...
  • 这个问题向我们展现了最优子结构:通过f(N-1)和f(N-2)的最优解,可以得到f(N)的最优解。
这表示我们可以通过动态规划来求解该问题。
因为我已经在上一个例子中写过代码了,所以这里就不再写代码了。
大家可以在下方链接中试着编写并测试一下自己的解决方案。
(链接地址:https://leetcode.com/problems/climbing-stairs/?ref=hackernoon.com


(3)最长递增子序列

给定一个未排序的整数数组,求最长递增子序列的长度。
例如,对于数组[10,9,2,5,3,7,101,18]而言,其输出为4,即序列[2,3,7,101]
解决方案
我们需要找到大小为n的数组的最长递增子序列的长度。这听起来像是一个可以通过动态规划来解决的优化问题,那么让我们来试一下。假设我们已经有了大小为N的问题的解,称其为s(n),然后我们在数组中增加了一个额外元素,称为Y。那么,你能重复使用X的解决方案来解决这个新问题么?这个问题通常会为我们带来一些启发。
在这里,我们需要知道新元素是否可以扩展任一现有序列:
  • 迭代数组中的每一个元素,我们称其为X。
  • 如果新元素Y大于X,那么序列可以扩展一个元素。
  • 如果我们已经存储了所有子问题的解,那么获取新长度是非常简单的——只需在数组中进行查找即可。我们可以根据子问题的最优解得出新问题的解。
  • 返回新的最长递增子序列的长度。
我们似乎得到了某种算法。继续我们的分析:
  • 最优子结构:我们已经证明了大小为n的问题的最优解可以由子问题的最优解计算出来。
  • 重叠子问题:要想计算s(n),则需要s(0), s(1),... ...,s(n-1)。同样,要计算s(n-1),则需要s(0), s(1),... ...,s(n-2)。同样的问题需要进行多次计算。
以下是该自下而上解决方案的代码。
int lengthOfLIS(const vector<int>& nums) {
if(nums.empty())
return 0;
vector<int> dp(nums.size(), 1);
int maxSol = 1;
for(int i = 0; i < nums.size(); ++i){
for(int j = 0; j < i; ++j){
if(nums[i] > nums[j]){
dp[i] = max(dp[i], dp[j] + 1);
}
}
maxSol = max(maxSol, dp[i]);
}
return maxSol;   
}
大家可以在下方链接中试着编写并测试一下自己的解决方案。
(链接地址:https://leetcode.com/problems/longest-increasing-subsequence/?ref=hackernoon.com


(4)有多少BST

对于给定的n,有多少结构唯一的存储值为1... ...n的BST(二叉搜索树)?
例子:
  • 输入:5
  • 输出:42
  • 说明:给定n = 5, 总共有42个唯一的BST
解决方案
我们一起来看看这个例子。假设我们有数字1、2、3、4、5,如何定义BST?
我只需要选择其中一个数作为根,先假设其为数字3,则:
  • 3是根
  • 3的左边是数字1和2
  • 3的右边是数字4和5
我们可以解决(1,2)和(4,5)的相同子问题(暂且称其为解决方案L和R),数一数以3为根可以形成多少个BST,即L*R。如果我们对每一个可能的根都这样做,并且把所有的结果相加的话,就可以得到我们所需的解决方案C(n)。如你所见,有条不紊地从几个例子出发,可以帮助我们更好地设计算法。
其实,我们只需要:
  • 选一个元素作为BST的根;
  • 解决(1到根-1)和(根+1到n)两个数字的相同问题;
  • 将每个子问题的两个结果相乘;
  • 将其加到我们的运行总计上;
  • 继续下一个根;
实际上,我们并不关心数组两边的数字是什么。我们只需要子树的大小,即根的左右两边的元素个数。这个问题中的每个实例都会产生相同的结果。在之前的例子中,L和R都是C(2)的解。我们只需要计算一次C(2),缓存,然后重复使用即可。
int numTrees(int n) {
vector<int> dp(n + 10);
dp[0] = 1;
dp[1] = 1;
for(int i = 2; i <= n; ++i){
for(int j = 0; j < i; ++j){
dp[i] += dp[j] * dp[i - 1 - j];
}
}
return dp.back();
}
大家可以在下方链接中试着编写并测试一下自己的解决方案。
(链接地址:https://leetcode.com/problems/unique-binary-search-trees/?ref=hackernoon.com

二维问题

这些问题通常比较难建模,因为它涉及两个维度。常见的例子是,在两个字符串中迭代,或移动映射。
  • 自上而下的解决方案和之前没有太大的区别:找到递归并使用缓存。
  • 对于自下而上的解决方案,一个2D数组就足以存储结果了。像我之前提到的,可能会减少一个或几个一维数组,但是没有必要太在意。之所以提到这一点只是以防你在解决问题时看到会有点摸不着头脑。
我曾在另一篇文章中说过,学习是迭代的。首先,要把注意力集中在理解基础知识上,然后再一点一点地增加更多的细节。

(1)最小路径和

给定m×n的非负数网格,找出一条从左上到右下的路径,使路径上所有数字之和最小。
注意:你只能选择向下移动或向右移动。
例子:
  • 输入:[ [1,3,1], [1,5,1], [4,2,1] ]
  • 输出:7
  • 说明:因为路径1→3→1→1→1总和最小
解决方案
最小化问题应该会让你想到动态规划。进一步分析,路径可以经过任意单元格C(i,j)(即不在上边框或左边框),单元格A = (i-1, j)和B=(i,j-1)。由此,我们发现,有些问题需要进行多次计算。此外,我们如果知道A和B的最优解,就可以计算出当前单元格的最优解为min(sol (A),sol(B)) + 1,因为我们只能通过当前单元格来表示A或B,要想移动到当前单元格就需要多走一步。换句话说,这是一个最优子结构和重叠问题。我们可以使用动态规划。
下面是由下而上的解决方案。
int minPathSum(const vector<vector<int>>& grid) {
const int nrow = grid.size();
if(nrow == 0)
return 0;
const int ncol = grid[0].size();
vector<vector<int>> minSum(nrow, vector<int>(ncol, 0));
minSum[0][0] = grid[0][0];
for(int col = 1; col < ncol; ++col)
minSum[0][col] = minSum[0][col - 1] + grid[0][col];
for(int row = 1; row < nrow; ++row)
minSum[row][0] = minSum[row - 1][0] + grid[row][0];
for(int col = 1; col < ncol; ++col){
for(int row = 1; row < nrow; ++row){
minSum[row][col] = min(minSum[row - 1][col], minSum[row][col - 1]) + grid[row][col];
}
}
return minSum[nrow - 1][ncol - 1];
}

边界条件定义在矩阵的边界上。只有一种方法可以获得边界上的点:从上一个点向右或向下移动一个格子。
大家可以在下方链接中试着编写并测试一下自己的解决方案。
(链接地址:https://leetcode.com/problems/minimum-path-sum/?ref=hackernoon.com


(2)背包问题

给定两个整数数组val[0..n - 1]和wt [0 . .n-1],分别表示与n个物品相关的值和权重。同时,给定一个代表背包容量的整数W,求val [ ]的最大值子集,保证这个子集的权重之和小于或等于W。背包里的物品都必须保持完整,要么选择完整的物品,要么不选(0 - 1属性)。
解决方案
试着想出一个递归解决方案。在此基础上,添加一个缓存层,我们就会得到一个自上而下的动态规划解决方案了!
意思是,对于每一样物品,我们都有两个选择:
  • 我们可以把这样物品放到背包里(如果合适的话),背包的总价值增加,容量减少。
  • 我们可以放弃这样物品,背包里的价值和容量不变。
在试过所有组合之后,我们只选择最大值。这个过程极其缓慢,但却是迈向最终解决方案的第一步。
我们必须在两个选项之间做出决定(向集合中添加一个元素或跳过它),在许多问题中都面临这样的选择,所以我们一定要了解,并理解其应用场合和方式。
// Recursive. Try to turn this into a piece of top-down DP code.int knapSack(int W, int wt[], int val[], int n) {
    if (n == 0 || W == 0)
        return 0;
    if (wt[n - 1] > W)
        return knapSack(W, wt, val, n - 1);
    else
        return max(val[n - 1] + knapSack(W - wt[n - 1],  wt, val, n - 1), knapSack(W, wt, val, n - 1));
}

以下是由下而上的解决方案:
// C style, in case you are not familiar with C++ vectorsint knapSack(int W, int wt[], int val[], int n) {
    int i, w;
    int K[n + 1][W + 1];
    for (i = 0; i <= n; i++) {
        for (w = 0; w <= W; w++) {
            if (i == 0 || w == 0)
                K[i][w] = 0;
            else if (wt[i - 1] <= w)
                K[i][w] = max( val[i - 1] + K[i - 1][w - wt[i - 1]], K[i - 1][w]);
            else
                K[i][w] = K[i - 1][w];
        }
    }
    return K[n][W];
}


(3)最长公共子序列

给定两个字符串text 1和text 2,返回它们最长公共子序列的长度。
字符串的子序列是在不改变其余字符相对顺序的情况下,从原字符串中删除一些字符(也可以不删除)后生成的新字符串,例如,“ace”是“abcde”的子序列,但“aec”不是。两个字符串共同的子序列就被称为其公共子序列。
如果没有公共子序列的话,则返回0。
例子:
  • 输入:text 1 = “abcde”, text 2 = “ace”
  • 输出:3
  • 说明:最长公共子序列是 “ace” ,且其长度为3
解决方案
同样,计算最长X的问题,动态规划应该可以帮得上忙。
鉴于大家已经有了一些动态规划的经验了,我就直接从示例中的两个属性说起。我们将字符串称为A和B,这个问题的解为f(A, B),解题思路是看最后两个字符是否相等:
  • 如果相等,那么LCS的长度至少为1。我们需要调用f(A[0:n-1], B[0:n-1])来查找该索引前的LCS,并加1,因为A[n]和B[n]是相同的。
  • 如果不相等,我们就删除两个字符串的最后一个字符——一次删一个,并查找生成LCS的路径。换句话说,我们取f(A[0: n-1], B)和f(A, B[0:n-1])的最大值。
  • 重叠子问题:我们来看看可能会出现的调用:(“abcde”, “ace”)产生x1 = (“abcd”, “ace”)和y1 = (“abcde”, “ac”);x1将产生x12 = (“abc”, “ace”) 和y12= (“abcd”, “ac”);y1将产生(“abcd”, “ac”)和(“abcde”, “a”)。如你所见,同样的问题需要计算很多次。
  • 最优子结构:与最长递增子序列非常类似。如果我们在其中一个字符串A’中添加一个额外的字符,就可以从所有的缓存结果中快速计算出解决方案,而这些结果是我们解A和B得到的。
虽然用例子来证明理论并不是开始数学证明的好方法,但是对于应付编程面试来说已经绰绰有余了。
int longestCommonSubsequence(const string &text1, const string &text2) {
const int n = text1.length();
const int m = text2.length();
vector<vector<int>> dp(n + 1vector<int>(m + 1,0));
for(int i = 1; i <= n; i++){
  for(int j = 1; j <= m; j++){
    if(text1[i-1] == text2[j-1])
      dp[i][j] = dp[i-1][j-1]+1;
    else 
      dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
    }
}
return dp[n][m];
}
大家可以在下方链接中试着编写并测试一下自己的解决方案。
(链接地址:https://leetcode.com/problems/longest-common-subsequence/?ref=hackernoon.com)

总结


我们一定要了解这些问题,因为很多其他问题都只是在此基础上的变种而已,但是不要死记硬背。主动去了解动态规划的使用场景和应用方式,并坚持练习,直到可以轻松地将自己的想法转换成代码。如你所见,这是需要讲究方法的。我们不一定要用到高级的算法或数据结构知识才能解决问题,数组就足够了。
我没有完成时间/空间复杂度分析,大家可以把它作为课后练习。欢迎大家随时在评论中留言,提出问题或分享观点。

原文链接:

https://hackernoon.com/all-you-need-to-know-about-dynamic-programming-0tj3e5l

本文由AI科技大本营翻译,转载请注明出处

更多精彩推荐

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存