动态规划真的太难了,动态规划真的太难了,动态规划真的太难了。把这句话说三遍之后好像就没那么难了。这期学习总结想用分享(讲解)的口吻记录,希望以后复习的时候看到,能让自己瞬间回味起现在坐在电脑前掉头发的快感。
提起DP就不得不讲到分治法,我觉得DP算是分治法的兄弟吧,前者把复杂问题分解成关联子问题,后者则是分解成独立子问题。有一说一,DP可比分治难多了。DP的难点在哪里呢,难在寻找状态量和列出状态转移方程——状态是记录子问题最优解的数据,状态转移则是子问题之间的递推。问题往往要求的是最优解的数据,稍微一想就能明白是要解出状态转移方程的状态。当然,这样说来太抽象了。
我想提一个困扰我两节自习课(对就是这么笨)才全部捋清楚的最少硬币问题,同时借此来解释“DP适用于有重叠子问题和最优子结构性质的问题”中两个高深莫测的词汇是啥意思。
有5种硬币,面值分别为1,5,10,20,50,数量无限。输入非负整数S(S<=221),选用硬币,使其和为S,要求输出最少的硬币组合。
简单的解法如下:
#include<iostream>
using namespace std;
const int money = 221;
const int value = 5;
in type[value] ={ 1,5,10,20,50 };
int Min[money];
void dp()
{
for(int k=0;k<money;k++)
Min[k]=INT_MAX;
Min[0]=0;
for(int j=0;j<value;j++)
for(int i=type[j];i<money;i++)
Min[i]=min(Min[i],Min[i-type[J]]+1);
}
int main()
{
int s;
dp();
while(cin>>s)
{
cout<<Min[s]<<endl;
}
return 0;
}
重复子问题是啥的,我们求Min[i]的硬币数,其实就是求Min[i-1]的最少硬币数再加上1元,这个1元可能会涉及进制转化(比如正好凑满5个1元硬币,升级成1个5元硬币),这个就是子问题的拆分,也得到了一个递归的思想。
我把这种归纳的思想总结为:从个例(已知的值)推广到普通例子(带字母的值),状态是所求的玩意(已知的值)推广到任意情况。
特别的,这段代码和常见的题解不同,main()中使用了打表的处理方法,即在输入金额之前提前用dp()算出目标范围内每一个金额的解,得到Min[money]的【答案表】,然后再读取金额,查表直接输出,查一次表的复杂度只有O(1),对时间有极大的优化。
有一个问题也是我在这段代码中才意识到的。之前上课时候的例题经常将状态大括号初始化为-1,我想当然是某种“仪式”,就像要把数组初始化为0,指针初始化为ptrnull一样。很显然,我错了。很多例题所求的最优解是最大值,如最大上升子序列和等等,那么用到的数值比较函数就是max,求数据本身和状态转移后数据那个更大,那么将数据本身赋值为-1,就相当于取了正数的最小值。同理,硬币问题求的是最少的硬币数,那么定义了INT_MAX这个最大数,就可以用min函数比较出状态转移后是否较少了。
还有一道矩阵类的题目,也很有趣:
在一个由 0 和 1 组成的二维矩阵内,找到只包含 1 的最大正方形,并返回其面积。
示例:
输入: 1 0 1 0 0 1 0 1 1 1 1 1 1 1 1 1 0 0 1 0 输出: 4
int maximalSquare(char[][] matrix)
{
if (matrix.length == 0 || matrix[0].length == 0)
return 0;
}
int m = matrix.length, n = matrix[0].length;
int[][] dp = new int[m][n];
int maxLength = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (matrix[i][j] == '1') {
if (i == 0 || j == 0) {
dp[i][j] = matrix[i][j] == '1' ? 1 : 0;
} else {
dp[i][j] = Math.min(dp[i - 1][j],
Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}
maxLength = Math.max(dp[i][j], maxLength);
}
}
}
return maxLength * maxLength;
}
这段解法选择了右下方的点,接下来无非是要找另外三个点,这三个点分别在当前点的上方,左方,以及左上方,也就是从这个点往这三个方向去做延伸,具体延伸的距离是和其相邻的三个点中的状态有关。因为我考虑的是正方形的右下方的顶点,因此状态可以定义成 “当前点为正方形的右下方的顶点时,正方形的最大面积”。
有了状态,再看相邻的位置的状态(这个就是从个例往普例推广的过程!)这里我们需要取三个方向的状态的最小值才能确保我们延伸的是全为【1】的正方形,也就是
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1)
当然,千万千万要考虑特殊情况(临界情况)比如前面的硬币问题,在金额为0的时候,需要的硬币也为0,所以要特别的定义Min[0]=0.同样的,在这一道题中,也需要考虑位置,如果是第一行的元素,以及第一列的元素,表明该位置无法同时向三个方向延伸,状态直接给为 1 即可,其他情况就按我们上面得出的递推方程来计算当前状态。
对于矩阵类的动态规划,相对来说比较简单,这一类动态规划也比较好识别,一般输入的参数就是一个矩阵,解题的时候,我们只需要从当前位置出发考虑状态即可,通常来说当前位置的状态的求解仅仅需要借助其相邻位置的状态,通常我们也不需要考虑非常隐蔽的边界条件,一般需要做的初始化操作都可以从矩阵中,以及题目中的信息得出。
我大概整理了线性DP的常见问题,这也是接下来两周还是要不断练习练习再练习的知识点:
编辑距离 | 扔鸡蛋问题 | 整数背包 | 最大独立集 | 最长公共子序列 |
最长公共递增子序列 | 最长公共子串 | 最长上升子序列 | 最长回文子序列 | 最长回文子串 |
最长不重复子串 | 矩阵链乘 | 最大正方形矩阵 | 最长链对 | 最大递增子序列和 |
最优二叉搜索树 | 回文分割 | 最大两段子段和 | 最大M子段和 | 最长有序子序列和 |
还有区间DP,树形DP,状压DP,插头DP什么什么的,还是要多看多看多做多做啊。
毕竟嘛,熟能生巧。