二维动态规划降维误差一般为多少_关于动态规划,你想知道的都在这里了!

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

作者 | Your DevOps Guy

翻译| 火火酱~,责编 | 晋兆雨

出品 | AI科技大本营

01

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

在本文中,我将介绍由Richard Bellman在20世纪50年代提出的动态规划(dynamic programming)概念,这是一种强大的算法设计技术——将问题分解成多个小问题,存储它们的解,通过将其结合在一起,最终得到原始问题的解决方案。

FAANG编程面试中最难的问题通常都属于这一类。你在面试的过程中也很可能会被要求解决这样的问题,因此,了解这项技术的重要性自然不言而喻。接下来,我将解释什么是动态规划,给出一个解决动态规划问题的秘诀,并且和大家一起分析几个示例,以便你能够更好地理解其应用场合和应用方法。

和我以往有关编程面试的文章一样,在本文中,我将分享自己在使用这种方法解决问题时的思考过程,这样当你在面对其中一个问题时,按照这个过程一定也能解决。不需要死记硬背,我们只需要通过了解技术和实践,将想法转化成代码技能。编程的重点不在于学习编程语言,而在于分析问题,考虑不同的解决方案,从中选出最优解,然后通过某种编程语言将其转化为现实。

02

动态规划

动态规划是一种解决最优化、搜索和计数问题的通用技术,这些问题都可以被分解为多个子问题。要应用动态规划,问题就必须具备以下两个属性:

最优子结构(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。

这就引出了递归和动态规划之间的关系。

03

递归和动态规划

从概念上讲,动态规划涉及到递归问题。我们希望通过同一个问题的较小实例来解决原始问题,而递归是在代码中实现这一点的最佳选择。与纯递归函数的不同之处在于,我们将用空间来换取时间:我们将存储各子问题的最优解,进而高效地找到原始问题的最优解。

当然,这并不是说我们都必须使用递归来解决动态规划问题。还可以通过一种迭代方法来编写动态规划解决方案。

自下而上的动态规划

我们需要将所有子问题的解决方案填入表格(从基本用例开始),并用它来构建我们正在寻找的解决方案。这个过程是通过迭代的方式完成的,你可以从下面列别中任选其一作为存储子问题解决方案的数据结构:

多维(以及一维)数组——最常用的方法;

哈希表;

二叉搜索树;

自上而下的动态规划

编写递归算法并添加缓存层,以避免重复的函数调用。

或许现在看起来有点糊涂,但等一会儿讲到示例后,一切都会清楚得多。

04

如何解决动态规划问题

如果一个问题想要通过动态规划来解决的话,就必须具备最优子结构和重叠子问题这两个属性。当直觉告诉你动态规划或许是一个可行的解决方案时,你需要验证其是否具备这两个属性。

下面让我们试着感受一下,什么样的问题可以用动态规划来解决。一切以“找到”开头的问题:

... ...的前n个元素;

... ...的所有方式;

有多少种... ...方式;

第n个... ... ;

... ...的最优解决方案;

... ...的最短小/最大/最短路径;

都是潜在“候选人”。

解决动态规划问题的步骤

很不幸,解决动态规划问题并没有什么通用秘诀。我们需要在经历过很多问题之后,才能逐渐掌握其诀窍。这确实不容易,毕竟这可能会是你在面试中遇到的最难的问题了。但也先不要气馁,简单来讲,就是用相对简单的工具针对问题进行建模,并不需要花哨的数据结构或算法。

我已经解决过很多此类问题了,但有时还是会觉得毫无头绪,找不到解决方法。练习得越多,就越容易。以下这几点或许能带你走近解决动态规划问题的秘诀:

证明重叠子问题和次优结构特性。

定义子问题。

定义递归。

编写自上而下或自下而上的动态规划解决方案。

复杂度分析因问题而异,但一般来说,时间复杂度可以表示为:

时间~子问题个数*每个子问题的时间

计算自下而上解决方案的空间复杂度很简单,因为其等于存储子问题解决方案所需的空间(多维数组)。

05

示例

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

一维问题

(1)斐波那契

因为现在大家都已经对这个问题非常熟悉了,所以我就直接给出递归解决方案:

intfib( intn){

if(n == 0|| n == 1)

return1;

else

returnfib(n - 1) + fib(n - 2);

}

}

从递归到自上而下的过程通常都有固定的模式:

检查我们需要的值是否已经在缓存中了。如果是,就返回它。

如果不是,就在返回之前先缓存的解决方案。

intfib( intn){

vector< int> cache(n + 1, -1);

returnfib_helper(n, cache);

}

intfib_helper( intn, vector< int> &cache){

if( -1!= cache[n])

returncache[n];

if(n == 0|| n == 1)

cache[n] = 1;

else

cache[n] = fib_helper(n - 1, cache) + fib_helper(n - 2, cache);

returncache[n];

}

这里,用到自下而上的解决方案,我们通过构建一个表(从基本用例为起点),来形成要找的问题的解决方案。这个表是一个一维数组:我们只需要存储较小的问题的解,就可以推导出原始问题的解。

intfib( intn){

vector< int> f(n + 1, 0);

f[ 1] = 1;

for( inti = 2; i <= n; i++)

f[i] = f[i - 1] + f[i - 2];

returnf[n];

}

额外空间优化

这种方法可以进一步优化内存,但并不会优化时间(也可以通过其他技术更快地计算斐波纳契数列,但这就是另一篇文章的内容了),只需要使用3个变量,而不必使用数组,因为我们只需要跟踪两个值,即f (n - 1)和f (n - 2),就可以得到我们想要的输出——f (n)。

intfib( intn){

if(n == 0|| n == 1)

return1;

//Variables that represent f(n - 1), f(n - 2) and f(n)

intn1= 1, n2 = 1, f = 0;

for( inti = 2; i <= n; i++) {

f= n1 + n2;

n2 = n1;

n1 = f;

}

returnf;

}

这种方法更高级,也更常见。如果只需要跟踪:

几个变量,或许我们就可以摆脱一维数组,将其变成几个变量。

二维矩阵中的几行,或许我们可以将其减少成几个一维数组。

等等... ...

通过降维,我们提高了空间的复杂度。现在,你不必记住所有的细节,但在进行过一些实践之后,要试着自己提出这些优化方案,从而增强自己分析问题并将想法转化为代码的能力。在面试中,我会选择更简单的版本,只讨论潜在的优化方案。只有在编写了自己的“标准化”动态规划解决方案,并且时间充足的时候,才动手实施这些优化。

(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)。同样的问题需要进行多次计算。

以下是该自下而上解决方案的代码。

intlengthOfLIS( constvector< int>& nums){

if(nums.empty)

return0;

vector< int> dp(nums.size, 1);

intmaxSol = 1;

for( inti = 0; i < nums.size; ++i){

for( intj = 0; j < i; ++j){

if(nums[i] > nums[j]){

dp[i] = max(dp[i], dp[j] + 1);

}

}

maxSol = max(maxSol, dp[i]);

}

returnmaxSol;

}

大家可以在下方链接中试着编写并测试一下自己的解决方案。

(链接地址: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),缓存,然后重复使用即可。

intnumTrees( intn){

vector< int> dp(n + 1, 0);

dp[ 0] = 1;

dp[ 1] = 1;

for( inti = 2; i <= n; ++i){

for( intj = 0; j < i; ++j){

dp[i] += dp[j] * dp[i - 1- j];

}

}

returndp.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,要想移动到当前单元格就需要多走一步。换句话说,这是一个最优子结构和重叠问题。我们可以使用动态规划。

下面是由下而上的解决方案。

intminPathSum( constvector< vector< int>>& grid){

constintnrow = grid.size;

if(nrow == 0)

return0;

constintncol = grid[ 0].size;

vector< vector< int>> minSum(nrow, vector< int>(ncol, 0));

minSum[ 0][ 0] = grid[ 0][ 0];

for( intcol = 1; col < ncol; ++col)

minSum[ 0][col] = minSum[ 0][col - 1] + grid[ 0][col];

for( introw = 1; row < nrow; ++row)

minSum[row][ 0] = minSum[row - 1][ 0] + grid[row][ 0];

for( intcol = 1; col < ncol; ++col){

for( introw = 1; row < nrow; ++row){

minSum[row][col] = min(minSum[row - 1][col], minSum[row][col - 1]) + grid[row][col];

}

}

returnminSum[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)

return0;

if(wt[n - 1] > W)

returnknapSack(W, wt, val, n - 1);

else

returnmax( val[n - 1] + knapSack(W - wt[n - 1], wt, val, n - 1), knapSack(W, wt, val, n - 1));

}

以下是由下而上的解决方案:

// C style, in case you are notfamiliar with C++ vectorsint knapSack( intW, intwt[], intval[], intn) {

inti, w;

intK[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;

elseif(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];

}

}

returnK[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得到的。

虽然用例子来证明理论并不是开始数学证明的好方法,但是对于应付编程面试来说已经绰绰有余了。

intlongestCommonSubsequence( conststring&text1, conststring&text2){

constintn = text1.length;

constintm = text2.length;

vector< vector< int>> dp(n + 1, vector< int>(m + 1, 0));

for( inti = 1; i <= n; i++){

for( intj = 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]);

}

}

returndp[n][m];

}

大家可以在下方链接中试着编写并测试一下自己的解决方案。

(链接地址:https://leetcode.com/problems/longest-common-subsequence/?ref=hackernoon.com)

06

总结

我们一定要了解这些问题,因为很多其他问题都只是在此基础上的变种而已,但是不要死记硬背。主动去了解动态规划的使用场景和应用方式,并坚持练习,直到可以轻松地将自己的想法转换成代码。如你所见,这是需要讲究方法的。我们不一定要用到高级的算法或数据结构知识才能解决问题,数组就足够了。

我没有完成时间/空间复杂度分析,大家可以把它作为课后练习。欢迎大家随时在评论中留言,提出问题或分享观点。

原文链接:

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

本文由AI科技大本营翻译,转载请注明出处返回搜狐,查看更多

责任编辑:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值