原创 by zoe.zhang
动态规划真的很好用啊,但是需要练习,还有很多技巧要学习。
1.滚动数组
动态规划是用空间换取时间,所以通常需要为DP数组开辟很大的内存空间来存放数据,但有的时候空间太大超过内存限制,特别是在OJ的时候,容易出现MLE的问题。而在一些动规的题目中,我们可以利用滚动数组来优化空间。
适用条件:DP状态转移方程中,仅仅需要前面若干个状态,而每一次转移后,都有若干个状态不会再被用到,也就是废弃掉,此时可以使用滚动数组来减少空间消耗。
优点:节约空间。
缺点:在时间消耗上没什么优势,有可能还带来时间上的额外消耗。在打印方案或者需要输出每一步的状态的时候比较困难,因为此时只有一个最终的状态结果。
举例:
1)一维数组:斐波那契数列
// 至少100个内存空间 int d[100] = { 0 }; d[0] = 1; d[1] = 1; for (int i = 2; i < 100; i++) d[i] = d[i - 1] + d[i - 2]; cout << d[99]; // 滚动数组 int d[3] = { 0 }; d[0] = 1; d[1] = 1; for (int i = 2; i < 100; i++) d[i%3] = d[(i - 1)%3] + d[(i - 2)%3]; cout << d[99];
2) 二维数组:
// 至少100*100的内存空间 int dp[100][100]; for (int i = 1; i < 100; i++) for (int j = 1; j < 100; j++) d[i][j] = d[i - 1][j] + d[i][j - 1]; // 滚动数组 int dp[2][100]; for (int i = 1; i < 100; i++) for (int j = 1; j < 100; j++) d[i%2][j] = d[(i - 1)%2][j] + d[i%2][j - 1];
注意这里取模%的操作是比较通用的操作,因为有些滚动数组可能有超过3维以上的维度,需要用到多个状态。
不过我们在做动态规划的题目中,最常见用到的就是二维的滚动数组,所以我们可以用一些其他方法来代替取模操作(取模还是比较费时的)。可以用&1来代替%2的操作,也可以设置一个变量t,因为只有0和1两种状态,可以使用^来改变t的状态,也可以使用 t = 1-t 来变换t 的状态,这些比取模的操作都要快一些。
2.堆砖块
【题目】小易有n块砖块,每一块砖块有一个高度。小易希望利用这些砖块堆砌两座相同高度的塔。为了让问题简单,砖块堆砌就是简单的高度相加,某一块砖只能使用在一座塔中一次。小易现在让能够堆砌出来的两座塔的高度尽量高,小易能否完成呢。
输入包括两行:第一行为整数n(1 ≤ n ≤ 50),即一共有n块砖块;第二行为n个整数,表示每一块砖块的高度height[i] (1 ≤ height[i] ≤ 500000)
输出描述:如果小易能堆砌出两座高度相同的塔,输出最高能拼凑的高度,如果不能则输出-1;保证答案不大于500000。
输入例子:3
2 3 5 输出例子:5
【解答】:借鉴别人的答案,dp[i][j] 的值为较矮的那块砖的值,i值为第 i 块砖,j 为 两块砖之间的高度差,那么结果要求 d[n][0] 的值。
状态的转移:a[i] 是第 i 块砖的高度
(1)第 i 块砖不放: d[i][j] = d[i-1][j];
(2)第 i 块砖放在矮的那堆上,高度依旧比原来高的矮: d[i][j] = d[i-1][j+a[i]]+a[i] (此时高度差为j; 原来的高度差为 j+ a[i])
(3)第 i 块砖放在矮的那堆上,高度依旧比原来高的高: d[i][j] = d[i-1][a[i]-j]+a[i]-j (此时原来高的变为矮的,原来的高度差为 a[i]- j, 然后求原来高的高度)
(4)第 i 块砖放在高的那堆上: d[i][j] = d[i-1][j-a[i]]
初始化:d[0][i]=-1, d[0][0]=0。
这里我们需要使用滚动数组,因为需要开辟的空间有50 *50 0000,很大,所以建议使用滚动数组来优化一下空间。
【程序】
#include <iostream> #include <cstring> #include <algorithm> using namespace std; const int maxh = 500000 + 5; int a[55]; int dp[2][maxh]; // 2维数组 int main() { int n; int sum = 0; cin >> n; for (int i = 1; i <= n; i++) { cin >> a[i]; sum += a[i]; //这里有点疑问 } memset(dp[0], -1, sizeof(dp[0])); // 初始化边际条件 dp[0][0] = 0; int t = 1; for (int i = 1; i <= n; i++) { for (int j = 0; j <= sum; j++) { dp[t][j] = dp[t ^ 1][j]; //【1】 注意 t^1 if ((j + a[i] <= sum) && (dp[t ^ 1][j + a[i]] >= 0)) dp[t][j] = max(dp[t][j], dp[t ^ 1][j + a[i]] + a[i]); if ((a[i] - j >= 0) && (dp[t ^ 1][a[i] - j] >= 0)) // && 的短路操作 dp[t][j] = max(dp[t][j], dp[t ^ 1][a[i] - j] + a[i] - j); if (j - a[i] >= 0 && dp[t ^ 1][j - a[i]] >= 0) dp[t][j] = max(dp[t][j], dp[t ^ 1][j - a[i]]); } t ^= 1; // 也可以将 所有的 t^1 换成: t = 1- t } cout << (dp[t ^ 1][0] == 0 ? -1 : dp[t ^ 1][0]); return 0; }
另外一种滚动数组是采用的取模:%2 也就是& 1的操作:
#include <iostream> #include <cstdio> #include <cstring> #include <algorithm> using namespace std; const int maxh = 500000 + 5; int a[55]; int d[2][maxh]; int main() { int n; int sum = 0; scanf("%d", &n); for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); sum += a[i]; } memset(d[0], -1, sizeof(d[0])); d[0][0] = 0; for (int i = 1; i <= n; i++) { for (int j = 0; j <= sum; j++) { d[i & 1][j] = d[(i - 1) & 1][j]; if (j + a[i] <= sum && d[(i - 1) & 1][j + a[i]] >= 0) d[i & 1][j] = max(d[i & 1][j], d[(i - 1) & 1][j + a[i]] + a[i]); if (a[i] - j >= 0 && d[(i - 1) & 1][a[i] - j] >= 0) d[i & 1][j] = max(d[i & 1][j], d[(i - 1) & 1][a[i] - j] + a[i] - j); if (j - a[i] >= 0 && d[(i - 1) & 1][j - a[i]] >= 0) d[i & 1][j] = max(d[i & 1][j], d[(i - 1) & 1][j - a[i]]); } } printf("%d\n", d[n & 1][0] == 0 ? -1 : d[n & 1][0]); return 0; }
3.最长回文子序列(Palindromic sequence)
这里有几个概念最长公共子序列LCS,最长公共子串,最长回文子序列,最长回文子串,子序列通常要求元素之间不是连续,而子串要求元素之间必须是连续的。
LCS是最经典的动态规划例题,LPS最长回文子序列也是经常用得到,这里做一点探讨。(LCS 在之前一篇博文已经讲过了,这里只讲一下LPS)
求LPS有两种方法:
1)假设有一个序列S,其最长回文子序列为Sp,其逆序序列是ST,则 LPS(S)= Sp = LCS(S,ST),就是说最长回文子序列就是序列S与其逆序ST之间的最长公共子序列,这个很好理解,在求解时套用LCS的解法即可。这个思想已经可以很好的解决LPS的问题,只有一单缺点,就是逆序操作可能需要消耗一点时间和空间。
2)直接动态规划,列出状态转移方程。LPS(i,j)是 序列S(i,i+1,i+2,……j)的最长回文子序列
//动态规划求解最长回文子序列,时间复杂度为O(n^2) int lpsDp(char *str, int n) { int dp[10][10], tmp; memset(dp, 0, sizeof(dp)); for (int i = 0; i < n; ++i) dp[i][i] = 1; for (int i = 1; i < n; ++i) { tmp = 0; //考虑所有连续的长度为i+1的子串,str[j....j+i] for (int j = 0; j + i < n; j++) { if (str[j] == str[j + i]) tmp = dp[j + 1][j + i - 1] + 2; else tmp = max(dp[j + 1][j + i], dp[j][j + i - 1]); dp[j][j + i] = tmp; } } return dp[0][n - 1]; //返回字符串str[0...n-1]的最长回文子序列长度 }
例:poj1159 给你一个字符串,可在任意位置添加字符,最少再添加几个字符,可以使这个字符串成为回文字符串。(这一道用到了LPS和滚动数组。)
int FindLenLPS(int n) { for (int i=0; i<2; ++i) { for (int j=0; j<n; ++j) { AnsTab[i][j]=0; } } AnsTab[0][0]=1; int refresh_col; int base_col; for (int i=1; i<n; ++i) //从第一列开始,共需更新n-1列(次);自左向右 { refresh_col=i%2; base_col=1-refresh_col; AnsTab[refresh_col][i]=1; for (int j=i-1; j>=0; --j) //自下而上 { //应用状态转移方程 if (inSeq[j]==inSeq[i]) { AnsTab[refresh_col][j]=2+AnsTab[base_col][j+1]; } else { AnsTab[refresh_col][j]=max(AnsTab[refresh_col][j+1], AnsTab[base_col][j]); } } } return AnsTab[(n-1)%2][0]; //这就是LPS的长度 }