1 解题要点
动态归划是用来优化递归重复计算缺点的,其中主要思想是用空间换时间,即手动设置一张表格,自底向上将表格填满,从而能够将递归O(2^n)时间复杂度优化到O(n^2)或者其它幂函数级别。
1.1 何时知道该使用动态归划解决问题呢?
- 当题目中所求解决问题为以下三种类型时,常常可以用动规解决:
- 求最大值或者最小值;
- 统计方案个数;
- 判断是否可行;
- 当然,以上三种类型问题通常也可以通过递归解决,但是如果题目有严格的执行时间要求,却对空间没有任何限制,应当直接考虑动规;
2.2 解决一道动规题目应该从哪几个方面分析问题?
- 状态函数
状态函数是一道动规题目最核心的部分,想到正确的状态函数后应当立即写下,后续步聚随时用来理清思路。状态函数分析技巧:
- 首先判断状态的维度,一般题目输入为数组的个数有关系,当然背包类型问题会把容量作为一个新的维度;
- 因为前i个数据的状态包含前i-1个数据的状态, 这样规定有得于推导出状态方程;
- 状态函数的描述常常包含关键字:前i个数据,恰好,能否,的最大值(最小值);
- 状态函数的正确与否通常还需要结合状态方程才能判断;
-
状态方程
用来联系状态之间的关系,一般通过第i个元素的值来判断,f[i]与之前状态的联系,通常是:条件判断 + min, max, 或 + const,来将当前状态与之间状态联系起来。当然,如果状态方程难以建立,那么也有可能是状态函数设定错误。 -
初始状态
因为状态方程通常包含f[i-1]或其它之前的状态,这要求我们在填表之前就需要把表格的边界部分填充完毕,这样才能根据边缘部分值来推导出表格内部元素的值。
- 通常需要确定f[i][0]与f[0][j] (一维状态函数也是同理),这时候根据严格根据状态函数的定义来推断边缘状态的值即可;
- 为了让状态方程的形势统一,并且也为了使用初始化不那么麻烦,有时候会多加1行1列,并给定一些特殊值:0,false, INT_MAX, INT_MIN等,这种方法大多数情况都比较有效。但有时候增加虚拟行列反而会使用答案变得繁琐,并且出bug了不容易调,如min_path_sum。所有有时虚拟行列的状态不能十分确定其值,就不使用了。
- 答案所在位置
通常情况下,最右下角(二维),或者最右端(一维),对应的元素为最终所求解的答案,但是也有一些特例需要再最后一行遍历次才能知道,如backpack、longest-common-substring、longest-increasing-subsequence
2 四种经典动规题型
- 矩阵中路径规划 (10%)
- 此类型题目的状态方程比较容易写出,因为矩阵中的每个点只能由左边位置状态与上边位置状态转移过来;
- 这种类型题目如何不设置虚拟行列不会对初始化造成太大麻烦,就考虑不加虚拟行列,如unique-paths
int uniquePaths(int m, int n) {
int **f = new int*[m];
for (int i = 0; i < m; i++) {
f[i] = new int[n];
}
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (i == 0 || j == 0) {
f[i][j] = 1;
}
else {
f[i][j] = f[i-1][j] + f[i][j-1];
}
}
}
return f[m-1][n-1];
}
- 单序列的动规 (40%)
- 状态函数常常描述为前i个数字/字符的 最优解?/能否达到最优解?
- 代表问题:
jump-game
palindrome-partitioning-ii
- 双序列的动规 (40%)
- 状态函数常常描述为A序列的前i个数字/字符 与 B序列的前j个数字/字符 所达到的最优解?/能否达到最优解?
- 通过讨论第i个字符与第j个字符的是否相等来将当前状态与之前状态联系起来;
- 代表问题:
longest-common-subsequence
edit-distance
- 背包类型动规 (10%)
- 背包问题的一个特征是,不仅题目给定的序列要作为DP表的一个维度,而且题目中给定的一些变量或者常量都有可能作为DP表的一个新维度,在建立状态方程时,这个新维度变化通常是离散调整的,如背包问题中的$ f[i-1][j-A[i-1]]$。
- k sum: 当题目中没有说明k的值,要想到使用动态规划解决。当k=2时,即为two sum问题,要想到来用两根指针的O(n)方法解决。
- 经典背包问题解析
backpack,这题目是比较难想的,因此做为经典题应该反复理解。
(1)状态函数:背包问题的给定数据有点类似于单序列问题,这让我会习惯性假设 “前i-1个元素的最优解,来推导前i个元素的最优解”,但是在推导状态方程时会发现,前i-1个元素如果达到最优了,那么背包余下容量可能不能装下一个更大的物品,使得前i个元素达到最优,这时候前i-1个元素与前i个元素的状态关系难以建立。如,m = 12,A = [2, 3, 5, 7]。而正确的状态函数是bool型的,描述为:前i个数能否挑一些,恰好组成和为j,是为数不多的Yes/No型动规问题
(2)状态方程:如果当前容量能够装下第i个物品A[i-1] (即j >= A[i-1] ?)并且 前i-1个物品跳出一些恰好能够装入剩余j-A[i-1]的容量中,那么:装入第i个物品,并且立flag说明:前i个物品挑出一些恰好能装入容量为j的背包中。否则:不能,不装第i个物品,并询问前i-1个物品挑出一些能否恰好装入容量为j的背包中,以此来决定f[i][j]为true or false。
(3)初始化的过程:
考虑f[i][0]:前i个数,一个都不取,和恰好为0,所有全部分初始化为true
考虑f[0][j]:前0个数,无论怎么取,和不都可能达到j (j != 0),因此全部初始化为false
(4)因为我们的状态函数表示恰好能组成和为j,那么f[nums][m]不一定为true,因为m可能比我们得到的最大物品重量和要多一点余量,所以要向前找第一个为true的j
code
int backPack(int m, vector<int> A) {
int nums = A.size();
vector<vector<bool>> f;
for (int i = 0; i <= nums; i++) {
f.push_back(vector<bool>(m + 1, false));
}
for (int i = 0; i <= nums; i++) {
f[i][0] = true;
}
for (int i = 1; i <= nums; i++) {
for (int j = 1; j <= m; j++) {
f[i][j] = f[i-1][j];
if (j >= A[i-1] && f[i-1][j-A[i-1]]) {
f[i][j] = true;
}
}
}
for (int j = m; j >=0; j--) {
if (f[nums][j]) {
return j;
}
}
return m;
}
3 值得回味的题目
min_path_sum
palindrome-partition II:难点在于优化时间复杂度,并且用DP求回文串
longest-increasing-subsequence:难点在于状态方程的含义如何定义?如何推出状态转移方程?
backpack ii
min-adjust-cost