九章算法系列(#4 Dynamic Programming)-课堂笔记

时间:2021-02-13 06:18:58

前言

时隔这么久才发了这篇早在三周前就应该发出来的课堂笔记,由于懒癌犯了,加上各种原因,实在是应该反思。好多课堂上老师说的重要的东西可能细节上有一些急记不住了,但是幸好做了一些笔记,还能够让自己回想起来。动态规划算是我的一道大坎了,本科的时候就基本没有学过,研一的时候老师上课也是吃力的跟上了老师的步伐,其实那个时候老师总结的还是挺好的:把动态规划的题目都分成了一维动规、二维遍历、二维不遍历等一系列的问题。这次听了老师的课程,觉得还是需要更加集中的去把各种题进行一个分类吧,然后有针对的去准备,虽然据说这一块在面试中也不容易考到,但是毕竟是难点,还是需要好好准备一下的。因为在dp这个方面,我算是一个比较新手的新手,所以大家可以当作一起入门内容来看这篇博客。

Outline:

  • 了解动态规划
    • Triangle
  • 动态规划的适用范围
  • 坐标型动态规划
    • Minimum Path Sum
    • Climbing Stairs
    • Jump Game
    • Longest Increasing Subsequence
  • 单序列动态规划

    • Word Break
  • 双序列动态规划
    • Longest Common Subsequence
  • 总结

课堂笔记


1.了解动态规划

就不过多的做解释了,直接来一个经典的题目。

给定一个数字三角形,找到从顶部到底部的最小路径和。每一步可以移动到下面一行的相邻数字上。

样例

比如,给出下列数字三角形:

[

[2],

[3,4],

[6,5,7],

[4,1,8,3]

]

从顶到底部的最小路径和为11 ( 2 + 3 + 5 + 1 = 11)。

拿到这个题目,如果不知道动态规划的话,想必大家第一反应就是遍历全部的路径,然后求出最小的值就可以。这个想法的话,跟二叉树的遍历有一点类似,但是大体还是不一样的,因为二叉树在分岔以后就各自保留子树,而这个题的不能考虑为二叉树的情况,这个结构可以画成如下的情况比较直观:

[2],

[3,4],

[6,5,7],

[4,1,8,3]

其中,2只能移动到3、4,3只能移动到6、5,同理,5只能移动到1,8……所以总结下来就是:当前的元素只能移动到下方和右下方的元素,即(i,j)只能移动到(i+1,j)或(i+1,j+1)。这样的话,DFS来做搜索就好了。

    int bestans = INT_MAX;
void travers(int i, int j, int sum, vector<vector<int> > &triangle) {
if (i == triangle.size()) {
// 遍历到最底层
bestans = bestans > sum ? sum : bestans;
return;
}
travers(i + , j, sum + triangle[i][j], triangle);
travers(i + , j + , sum + triangle[i][j], triangle);
}
int minimumTotal(vector<vector<int> > &triangle) {
// write your code here
travers(, , , triangle);
return bestans;
}

这种算是最暴力的方法了,显然时间复杂度是0(2^n)的,因为每层的每个元素都有两个选择。我就没有在lintcode上提高了,显然是LTE的。这时候就需要回顾我们之前学过的分治法了,也可以用分治的方法分别求出下方和右下方两种选择的和,然后来求出最小的。直接把代码贴出来吧(Bug Free):

    int DivideConquer(int i, int j, vector<vector<int> > &triangle) {
if (i == triangle.size()) {
return ;
} return triangle[i][j] + min(
DivideConquer(i + , j, triangle),
DivideConquer(i + , j+ , triangle));
}
int minimumTotal(vector<vector<int> > &triangle) {
// write your code here
return DivideConquer(, , triangle);
}

这个方法比起直接做travers来的更加容易思考一些,回顾了一下上节课讲的东西,但是复杂度还是一样的。到这里大家应该能够想到了,因为和都是由上面的节点累加起来的,我们可以只遍历一次,把前面得到的结果记录下来,这样就不需要从头去做遍历了。所以可以对分治法进行改进,代码如下(Bug Free):

    int minimumTotal(vector<vector<int> > &triangle) {
// write your code here
int n = triangle.size();
int m = triangle[n-].size();
vector<vector<int> > dp(n, vector<int>(m)); // 初始化原点
dp[][] = triangle[][]; // 初始化三角形的边缘
for (int i = ; i < n; ++i) {
dp[i][] = dp[i - ][] + triangle[i][];
dp[i][i] = dp[i - ][i - ] + triangle[i][i];
} for (int i = ; i < n; ++i) {
for (int j = ; j < i; ++j) {
dp[i][j] = min(dp[i - ][j], dp[i - ][j - ]) + triangle[i][j];
}
} return *min_element(dp[n - ].begin(),dp[n - ].end());
}

这个应该算最基本的动态规划了,其中用到的一个想法就是:打小抄。用一个dp二维数组来存储之前的路径的和,能够很大程度减小搜索的次数。这里又需要谈一下之前说过的二叉树的问题了,如果这个问题是一个二叉树的话,就不需要用动态规划的方法来做了,因为二叉树没有重复计算的部分,左子树不会有到右子树的部分,这样就没有打小抄的必要了。这里也就引出了动态规划和分治法的根本区别:动态规划存在重复计算的部分,而分治法是没有的,也就是说,由全局的问题分成子问题的时候,分治法的子问题是完全独立的,相互之间没有交集,而动态规划的方法是有交叉部分的。


2.动态规划的适用范围

这个内容我个人认为对于面试是非常重要的,因为之前有面试官给我出过一个求出所有可行解的问题,当时我就是用dp来考虑,显然最后就用一个三维动态规划来解决了,这种就给了自己很大的麻烦。所以动态规划在一定程度上很容易和DFS这样的场景混淆。

满足下面三个条件之一:

  • 求最大值最小值
  • 判断是否可行
  • 统计方案个数

则极有可能是使用动态规划的方法来求解的。之前求所有解的话,肯定是要去遍历然后求出满足情况的解的方法,而不是动态规划这样的模式。

以下情况是不使用动态规划的情况:

  • 求出所有具体的方案
  • 输入数据是一个集合而不是序列
  • 暴力算法的复杂度已经是多项式级别
    • 动态规划擅长于优化指数级别的复杂度到多项式级别

动态规划就是四个重要的要素:

  • 状态
  • 方程
  • 初始化
  • 答案

3. 坐标型动态规划

这种类型的题目在面试中出现的概率大概是15%,比如第1部分的那个题目就是一个坐标型动态规划的题。它的四要素如下:

  • state:f[x]表示从起点走到坐标x
  • function:研究走到x,y这个点之前的一步
  • initiaize:起点
  • answer:终点

这样的题目主要就是在坐标上来进行一个处理。

先上一个极度简单的题目:

Minimum Path Sum

(http://www.lintcode.com/zh-cn/problem/minimum-path-sum/)

给定一个只含非负整数的m*n网格,找到一条从左上角到右下角的可以使数字和最小的路径。

这里就不需要多说了,跟我们上面那个题目其实就是一样的道理,这里不过是从上方或者左方两个方向到达该点,直接用这个方法来计算就好了。直接上代码(Bug Free):

    int minPathSum(vector<vector<int> > &grid) {
// write your code here
int m = grid.size();
int n = grid[].size();
vector<vector<int> > dp(m + , vector<int>(n + )); // initialize
dp[][] = grid[][]; for (int i = ; i < m; ++i) {
dp[i][] = dp[i - ][] + grid[i][];
}
for (int j = ; j < n; ++j) {
dp[][j] = dp[][j - ] + grid[][j];
} // state and function
for (int i = ; i < m; ++i) {
for (int j = ; j < n; ++j) {
dp[i][j] = grid[i][j] + min(dp[i - ][j], dp[i][j - ]);
}
} // answer
return dp[m - ][n - ];
}

不得不提一句,其实这里可以使用滚动数组,不断更新dp的值,就不需要开辟m*n那么大的空间,具体的滚动数组的方法我会在之后的进阶篇里面写到。

然后就是一个比较简单的题目,Climbing Stairs,题目如下:

Climbing Stairs

(http://www.lintcode.com/zh-cn/problem/climbing-stairs/)

假设你正在爬楼梯,需要n步你才能到达顶部。但每次你只能爬一步或者两步,你能有多少种不同的方法爬到楼顶部?

样例

比如n=3,1+1+1=1+2=2+1=3,共有3中不同的方法

返回 3

这个题目对我本人来说还是有渊源的,我记得第一次面试的时候问的算法题就是这个题,当时我是真的算法渣,完全没有考虑到该怎么做,就连斐波那契尔数列都没有想到,所以就用暴力求解的方法做出来了,现在回想一下,当年大三的时候真是太low了。

其实这个题就是一个斐波那契尔数列,因为一次可以走两步或者一步,也就是说第i步的前一步可能是i-2,也可能是i-1,所以就跟上一题走方格是一样的问题,然后把前面两种情况加起来就可以,这个题也可以用递归来做,复杂度是n^2,用动态规划的情况复杂度是n。代码如下(Bug Free):

    int climbStairs(int n) {
// write your code here
vector<int> dp(n + );
dp[] = ;
dp[] = ;
dp[] = ; for(int i = ; i <= n; ++i) {
dp[i] = dp[i - ] + dp[i - ];
} return dp[n];
}

接下来再来一题:

Jump Game

(http://www.lintcode.com/zh-cn/problem/jump-game/)

给出一个非负整数数组,你最初定位在数组的第一个位置。

数组中的每个元素代表你在那个位置可以跳跃的最大长度。   

你的目标是使用最少的跳跃次数到达数组的最后一个位置。

样例

给出数组A = [2,3,1,1,4],最少到达数组最后一个位置的跳跃次数是2(从数组下标0跳一步到数组下标1,然后跳3步到数组的最后一个位置,一共跳跃2次)

这个题是动态规划里面的典型题目,不过还是需要用到一些小trick。直接上代码吧:

    int jump(vector<int> A) {
// wirte your code here
int n = A.size();
vector<int> dp(n + ); dp[] = ;
for (int i = ; i < n; ++i) {
dp[i] = INT_MAX;
for (int j = ; j < i; ++j) {
if (dp[j] != INT_MAX && A[j] + j >= i) {
dp[i] = dp[j] + ;
break;
}
}
}
return dp[n - ];
}

方法很简单,就是用一个dp数组存储当前第i步需要多少步能够到达,有一个关键的地方就是:每次在判断当前位置i的时候,需要赋值为最大值,这里就可以用这个INT_MAX来作为判断第j个点是否能够到达,如果可以的话,就把i从j的位置+1,用这种方法来求出当前i的点需要的步数,然后直接break就可以了。

说到坐标型动态规划的代表题,那一定就是(LIS)这个题目了。虽然说这个是求最长递增自序列,看上去像是一个序列的问题,但是它更多的是去解决一个坐标跳转的问题。

Longest Increasing Subsequence

(http://www.lintcode.com/problem/longest-increasing-subsequence/)

给定一个整数序列,找到最长上升子序列(LIS),返回LIS的长度。

说明

最长上升子序列的定义:

最长上升子序列问题是在一个无序的给定序列中找到一个尽可能长的由低到高排列的子序列,这种子序列不一定是连续的或者唯一的。

https://en.wikipedia.org/wiki/Longest_increasing_subsequence

样例

给出 [5,4,1,2,3],LIS 是 [1,2,3],返回 3

给出 [4,2,4,5,3,7],LIS 是 [2,4,5,7],返回 4

这个题目我认为是需要大家背下来的,能够在2分钟之内不暇思索就要写出来的题目,其实就是考虑第i个元素,是否加上前面的某个元素j,平且判断当前的个数是否大于加上j以后的个数。然后在所有的dp数组里面找到最大的那个值就是最长子序列的长度。直接上代码吧(Bug Free):

    int longestIncreasingSubsequence(vector<int> nums) {
// write your code here
int n = nums.size();
if (n == ) {
return ;
}
vector<int> dp(n + , ); for (int i = ; i < n; ++i) {
for (int j = ; j < i; ++j) {
if (nums[j] < nums[i]) {
dp[i] = max(dp[i], dp[j] + );
}
}
}
return *max_element(dp.begin(), dp.end());
}

4. 单序列动态规划

这种类型的动态规划一般在面试中出现的概率是30%,它的四要素表示如下:

  • state:f[i]表示前i个位置/数字/字符,第i个...
  • function: f[i]=f[j]...j是i之前的一个位置
  • initialize: f[0]
  • answer: f[n]..
  • 一般answer是f(n)而不是f(n-1)
    • 因为对于n个字符,包含前0个字符(空串),前1个字符......前n个字符。

其中有一个小技巧:

一般有N个数字/字符,就开N+1个位置的数组,第0个位置单独留出来作初始化.(跟坐标相关的动态规划除外)

那就直接来做一个题目吧,引出这个章节:

Word Break

(http://www.lintcode.com/problem/word-break/)

给出一个字符串s和一个词典,判断字符串s是否可以被空格切分成一个或多个出现在字典中的单词。

样例

给出

s = "lintcode"

dict = ["lint","code"]

返回 true 因为"lintcode"可以被空格切分成"lint code"

这个就是一个典型的序列的问题,用i表示当前位置,j表示字符串的长度,在这之前可以先遍历整个dict,求出其中最长的字符串MaxLength,然后保证j小于这个数即可。代码如下:

    int getMaxLength(unordered_set<string> &dict) {
int maxLength = ; // 试试看中文
for (unordered_set<string>::iterator it = dict.begin(); it != dict.end(); ++it) {
maxLength = maxLength > (*it).length() ? maxLength : (*it).length();
}
return maxLength;
} bool wordBreak(string s, unordered_set<string> &dict) {
// write your code here
int n = s.length();
vector<bool> dp(n + , false); dp[] = true; int MaxLength = getMaxLength(dict); for (int i = ; i <= n; ++i) {
for (int j = ; j <= MaxLength && j <= i; ++j) {
string tmp = s.substr(i - j, j);
if (dp[i - j] && dict.find(tmp) != dict.end()) {
dp[i] = true;
break;
}
}
}
return dp[n];
}

这个题如果不用MaxLength来控制j的范围的话,会超时。


5. 双序列动态规划

这种题目我个人的理解就是字符串的对应关系,分别用i和j去表示两个字符串,然后通过操作来计算相应的问题。

Longest Common Subsequence

(http://www.lintcode.com/zh-cn/problem/longest-common-subsequence/)

给出两个字符串,找到最长公共子序列(LCS),返回LCS的长度。

说明

最长公共子序列的定义:

  • 最长公共子序列问题是在一组序列(通常2个)中找到最长公共子序列(注意:不同于子串,LCS不需要是连续的子串)。该问题是典型的计算机科学问题,是文件差异比较程序的基础,在生物信息学中也有所应用。
  • https://en.wikipedia.org/wiki/Longest_common_subsequence_problem

样例

给出"ABCD""EDCA",这个LCS是 "A" (或 D或C),返回1

给出 "ABCD""EACB",这个LCS是"AC"返回 2

这个题目其实考察的地方就在于状态转移方程。如果字符串A的第i个位置与字符串B的第j个位置相等,那么当前状态自动从(i-1,j-1)状态+1即可;如果不相等,那么从(i-1,j)或者(i,j-1)中取得最大值来作为当前的状态的最大值。代码如下(Bug Free):

    int longestCommonSubsequence(string A, string B) {
// write your code here
int n = A.size();
int m = B.size();
vector<vector<int> > dp(n + , vector<int>(m + ));
for (int i = ; i <= n; ++i) {
for (int j = ; j <= m; ++j) {
dp[i][j] = max(dp[i - ][j], dp[i][j - ]);
if (A[i - ] == B[j - ]) {
dp[i][j] = dp[i - ][j - ] + ;
}
}
}
return dp[n][m];
}

总结

动态规划是没有打过竞赛的小伙伴们都怕的一个章节,这个章节我总结的不多,是因为有些题目还没有理解的足够深,所以怕误导大家,就不敢放上来了。但是面试还是需要好好准备一下,记住之前所说的几种可能用到动态规划和不可能用到动态规划的情况即可,个人感觉面试过程能够写出多项式级别的复杂度已经算还可以了,如果之后能够进一步到滚动数组或者压缩到一维数组之类的,那就更能够加分了。