动态规划(二) 线性dp+区间dp
又来优快云搬运笔记了,菜鸡一枚,生怕日后复习看不懂代码,所以进行了保姆级注释,希望能帮到在各个点卡住的小伙伴们~
这篇博客包含了Acwing题库282、895、897、898题,java代码实现,是 线性dp和区间dp的内容。
动态规划问题解决一致的思路:
898.数字三角形
题目描述
思路分析
i 表示行,j 表示列(如下图)。用 f [i] [j] 表示所有从起点走到 (i,j) 位置的路径的最大长度。则 f [i] [j] 可以分为两个子集:到(i,j) 的路径是来自左上方的点或右上方的点——如下图所示:
代码实现
static int N = 510;
static double INF = 1e9;
static int[][] a = new int[N][N]; //存储数字三角形
static double[][] f = new double[N][N];
public static void main(String[] args) {
Scanner myscanner = new Scanner(System.in);
int n = myscanner.nextInt();
// 接收数字三角形
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i ; j++)
a[i][j] = myscanner.nextInt();
// 将f初始化为无穷小
for (int i = 1; i <= n; i++)
//细节:j从0~i+1,因为边界位置可能存在没有左上或右上的点,如果不初始化成负无穷
// 那之后比较的时候,如果碰到比如左上角的是负数,右上角没点的情况
// 就会取右上角——因为f数组初始化的0更大
for (int j = 0; j <= i+1 ; j++)
f[i][j] = -INF;
f[1][1] = a[1][1];
for (int i = 2; i <= n; i++)
for (int j = 1; j <= i ; j++)
f[i][j] = Math.max(f[i-1][j-1]+a[i][j],f[i-1][j]+a[i][j]);
double res = -INF;
for (int i = 1; i <= n; i++) //找最后一排的最大值,即我们要求的全局最大值
if(res<f[n][i])
res = f[n][i];
System.out.println((int)res);
}
细节:当涉及到 f [i-1] [j-1] 时,i循环从1开始,避免越界问题。
895.最长上升子序列长度
给定一个长度为 N的数列,求数值严格单调递增的子序列的长度最长是多少。
输入格式:第一行包含整数 N。第二行包含 N个整数,表示完整序列。
输出格式:输出一个整数,表示最大长度。
思路分析
用 f [i] 表示以第 i 个数为结尾的最长上升子序列。则 f [i] 可划分为:该子序列的前一个数的下标为0、该子序列的前一个数的下标为1…该子序列的前一个数的下标为 i-1,如下图:
代码实现
static int N = 1010;
static int[] a = new int[N];
static int[] f = new int[N];
public static void main(String[] args) {
Scanner myscanner = new Scanner(System.in);
int n = myscanner.nextInt();
for (int i = 1; i <= n; i++)
a[i] = myscanner.nextInt();
for (int i = 1; i <= n ; i++) {
f[i] = 1; //只有a[i]一个数
for (int j = 1; j < i; j++) //遍历到 i之前
if (a[j]<a[i]) //如果前面位置为j的数更小,那就可以考虑更新
f[i] = Math.max(f[i],f[j]+1);
}
int res = 1;
for (int i = 1; i <= n; i++)
res = Math.max(res,f[i]);
System.out.println(res);
}
如果要输出这个最长上升子序列,则
static int[] g = new int[N]; // 存储最长上升子序列
for (int i = 1; i <= n ; i++) {
f[i] = 1;
g[i] = 0;
for (int j = 1; j < i; j++) {
if (a[j] < a[i]) {
if (f[i] < f[j] + 1) {
f[i] = f[j] + 1;
g[i] = j; //记录i的前缀
}
}
}
}
int k = 1;
for (int i = 1; i <= n; i++) //找到最长上升子序列的末位下标
if(f[k]<f[i])
k=i;
System.out.println(f[k]);
int len = f[k];
for (int i = 1; i <= len; i++) {
System.out.println(a[k]);
k=g[k]; //循环往前找前缀
}
记录一个低级错误:
for (int i = 1; i <= f[k]; i++) {
System.out.println(a[k]);
k=g[k];
}
如果这样写,因为k的改变导致f[k]也在改变,所以循环退出的条件是错的,应该先定义一个len来存储f[k]。
897.最长公共子序列
给定两个长度分别为 N和 M的字符串 A和 B,求既是 A 的子序列又是 B 的子序列的字符串长度最长是多少。
输入格式:第一行包含两个整数 N、M。第二行为一个长度为 N 的字符串 A。第三行为一个长度为 M的字符串B。
输出格式:输出一个整数,表示最大长度。
思路分析
用 f [i] [j] 表示所有在 a 序列的前 i 个字母中出现,且在 b 序列的前 j 个字母中出现的子序列的最大长度。此处的集合划分是一个难点:对于每一个 f [i] [j] ,包不包含a[i]、b[j]可以划分成四种情况:
- 情况1:a[i],b[j] 均不在
- 情况2:a[i]不在,b[j]在
- 情况3: a[i] 在,b[j] 不在
- 情况4: a[i],b[j] 均存在于 最长公共子序列中 (前提a[i]==b[j])
其中00和11的情况好表示,重点是中间两种情况。实际上,中间两种情况并不等价于图中所示的式子。但好处在于, f [ i-1 ] [ j ]虽然自己本身就有4种子集,但一定会包括这里我们要的01的情况;10同理。而我们将来要求的是最大值,因此就算不完全等价也无碍。又因为f [ i-1 ] [ j ]和f [ i ] [ j-1 ]的情况包括了f [ i-1 ] [ j-1 ]的情况,则可以将00的情况删去。
另一个思路理解集合划分:
f[i] [j-1]的含义是A前i个字符,B前j-1个字符的最长公共子序列长度 -->②
a[i] 可能在也可能不在,b[j]一定不在。
而情况3 的限制是:a[i] 一定在,b[j]一定不在 -->①
很显然①是②的子集,即②包含了①,并不一定为①
则我们可以用f[i] [j-1]它来表示 情况1和情况3
因为 f[i] [j-1]其实是a[i]在时 的最长公共子序列的长度 和 a[i]不在时的长度 的最大值
同理:f[i-1] [j] 不能表示情况2,但可以用来表示 情况1和情况2
我们需要 求得的是 max(情况1,情况2,情况3,情况4)
而:f[i-1] [j-1]+1 可以表示情况4 --> a
f[i] [j-1]=max(情况1,情况3) --> b
f[i-1] [j]=max(情况1,情况2) --> c
所以我们最终只需要 求 max(a,b,c) 即可
代码实现
static int N = 1010;
static int[][] f = new int[N][N];
public static void main(String[] args) {
Scanner myscanner = new Scanner(System.in);
int n = myscanner.nextInt();
int m = myscanner.nextInt();
char[] a = new char[N];
char[] b = new char[N];
String aa = myscanner.next();
String bb = myscanner.next();
for(int i=1; i<=n; i++){
a[i] = aa.charAt(i-1);
}
for(int i=1; i<=m; i++){
b[i] = bb.charAt(i-1);
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[i][j] = Math.max(f[i-1][j],f[i][j-1]);
if (a[i]==b[j])
f[i][j]=Math.max(f[i][j],f[i-1][j-1]+1);
}
}
System.out.println(f[n][m]);
}
282.石子合并(区间dp)
设有 N 堆石子排成一排,其编号为 1,2,3,…,N。每堆石子有一定的质量,现在要将这 N堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。
例如有 4堆石子分别为
1 3 5 2
, 我们可以先合并 1、2 堆,代价为 4,得到4 5 2
, 又合并 1、2 堆,代价为 9,得到9 2
,再合并得到 11,总代价为 4+9+11=24;如果第二步是先合并 2、3 堆,则代价为 7,得到4 7
,最后一次合并代价为 11,总代价为 4+7+11=22。问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
输入格式:第一行一个数 N 表示石子的堆数 N。第二行 N个数,表示每堆石子的质量(均不超过 1000)。
输出格式:输出一个整数,表示最小代价。
思路分析
用 f [i] [j] 表示所有将第i堆石子到第j堆石子合并成一堆石子的合并方式的最小代价。
集合划分:只考虑最后一步的所有情况:对于最后一步,所有的情况为:左边区间有1个石子、右边区间有k-1个石子;左边区间有2个石子、右边区间有k-2个石子…左边区间有k-1个石子、右边区间有1个石子。
则区间 i~j 合并的代价就 = 左边区间合并的最小代价f [i] [k] + 左边区间合并的最小代价f [ k+1 ] [j] + 两个区间合并的代价(左区间的总重量+右区间的总重量,即 i~j 堆石子的总重量,即 j的前缀和 - i 的前缀和,即s[j] - s[i-1]。(s[i]为第 i 堆石子的前缀和)(前缀和:到第 i 个位置的累计值)。遍历所有有可能划分的 k 值,取最小值就是我们要求的最小代价。
代码实现
static int N =310;
static int[] a =new int[N];
static double[][] f =new double[N][N];
public static void main(String[] args) {
Scanner myscanner = new Scanner(System.in);
int n = myscanner.nextInt();
for (int i = 1; i <= n; i++)
a[i] = myscanner.nextInt();
for (int i = 1; i <= n; i++) //求前缀和
a[i] += a[i-1];
for (int len = 1; len <n ; len++) { //遍历所有可能的区间长度
for (int i = 1; i+len <= n ; i++) { //遍历所有可能的区间起点
int l = i;
int r = i+len;
f[l][r] = 1e8;
for (int k = l; k < r; k++) //遍历所有可能的左右区间分割点
f[l][r] = Math.min(f[l][r],f[l][k] +f[k+1][r]+a[r]-a[l-1] );
}
}
System.out.println((int)f[1][n]);
}
细节 :所有的区间dp问题枚举时,第一维通常是枚举区间长度(从小到大);第二维枚举起点 i (从小到大)(右端点 j 自动获得,j = i + len )。
区间长度从小到大遍历的原因:f[l] [r] = min(f [l] [r] , f [l] [k] +f [k+1] [r]+a[r]-a[l-1] )。k从小到大递增,意味着计算 f[l] [r] 时要用到的f [l] [k] 应该已知,即区间长度更小的情况应该先被计算。
区间第一维,起点第二维的原因:按这个顺序遍历的话,f [l] [k] 满足已知(因为大方向是len,k<len,肯定被计算过了);同上,f [k+1] [r] 也应该先于f[l] [r]已知,只有在大方向是len的情况,才有可能满足(因为 [k+1,r] 区间长度一定<len)。若先从起点遍历,此时的f [k+1] [r]就还是未知的(因为大方向是起点,起点还没到k+1)。
当然了,如果能倒着遍历,满足条件也可以。
总结:遇到不确定循环的维度时,多观察一下待定式子,等号右边一定是确定已知的东西,才能更新等号左边的东西,那么等号右边的东西就一定要保证先于等号左边确定值。可以根据这个点来确定大方向应该先遍历谁。