数据结构与算法学习笔记----线性DP
@@ author: 明月清了个风
@@ first publish time: 2025.2.15
ps⭐️包含了几种常见的线性DP模型------数字三角形,最长上升子序列,最长公共子序列,最短编辑距离。给出了具体思路及证明过程和一些题目代码优化的过程,题目较多。
线性动态规划 (Linear Dynamic Programming,简称线性DP)是动态规划问题中的一种常见类型,其特点是状态转移方程中的状态是线性排列的,通常可以通过一维或二维数组来表示状态。线性DP问题通常具有明显的阶段性,每个阶段的状态只依赖于前一个或前几个阶段的状态。
线性DP的基本思路
- 定义状态 :根据问题的特点,定义状态表示。通常状态可以表示为一个一维或二维数组,例如
dp[i]
或 dp[i][j]
。
- 状态转移方程:找到状态之间的关系,即如何从已知状态推导出新的状态。状态转移方程是线性DP的核心。
- 初始化 :确定初始状态的值,通常是
dp[0]
或 dp[0][0]
等。
- 计算顺序:按照一定的顺序计算状态,通常是从小到大或从前往后。
- 结果:根据问题的要求,从最终的状态中提取结果。
上一篇中的01背包问题,其实也是一个典型的线性DP问题。
Acwing 898. 数字三角形
原题链接\]([898. 数字三角形 - AcWing题库](https://www.acwing.com/problem/content/900/))
给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。
```cpp
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
```
#### 输入格式
第一行包含整数 n n n,表示数字三角形的层数
接下来 n n n行,每行包含若干整数,其中第 i i i行表示数字三角形第 i i i层包含的整数。
#### 输出格式
输出一个整数,表示最大的路径数字和。
#### 数据范围
1 ≤ n ≤ 500 1 \\le n \\le 500 1≤n≤500,
− 1000 ≤ 三角形中的整数 ≤ 10000 -1000 \\le 三角形中的整数 \\le 10000 −1000≤三角形中的整数≤10000、
#### 思路
这是线性DP中的一道经典题,直接来看状态表示和状态计算方法:
对于状态表示,使用`f[i][j]`,它表示的集合是所有从起点走到`(i, j)`这个点的路径,我们需要求的属性值是这个集合中的最大值。
对于状态计算,我们将`f[i][j]`所表示的集合划分为两类:从左上方走下来的路径和从右上方走下来的路径。对于从左上方走下来的路径,当前点`a[i, j]`排除后,上一层状态就是`f[i - 1][j - 1] + a[i][j]`;对于从右上方走下来的路径,我们同理可以写成`f[i - 1][j] + a[i][j]`(这里是把整个矩阵看成下面这个形状了)
```cpp
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
```
动态规划的时间复杂度计算其实比较抽象,这里给出y总教的------状态数×转移计算量。在这一题中,状态数量就是 n 2 n\^2 n2,转移计算量是 O ( 1 ) O(1) O(1)的,因此时间复杂度就是 O ( n 2 ) O(n\^2) O(n2)。
下面就来看代码吧,代码中有个注意点,也是动态规划问题中的注意点,需要对初始状态进行初始化,并且每道题的初始化都会不一样,需要根据要求的东西进行,比如这里我们要求的是最大值,因此需要初始化为负无穷,并且在初始化时,需要注意所有要用到的状态都需要初始化(代码中注释的地方)
#### 代码
```cpp
#include
#include
#include
using namespace std;
const int N = 510, inf = 1e9;
int n;
int a[N][N];
int f[N][N];
int main()
{
cin >> n;
for(int i = 1; i <= n; i ++)
for(int j = 1; j <= i; j ++)
cin >> a[i][j];
for(int i = 0; i <= n; i ++)
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] = max(f[i - 1][j - 1] + a[i][j], f[i - 1][j] + a[i][j]);
int res = -inf;
for(int i = 1; i <= n; i ++) res = max(res, f[n][i]);
cout << res << endl;
return 0;
}
```
### Acwing 895. 最长上升子序列
\[原题链接\]([895. 最长上升子序列 - AcWing题库](https://www.acwing.com/problem/content/897/))
给定一个长度为 N N N的数列,求数值严格单调递增的子序列的长度最长是多少。
#### 输入格式
第一行包含整数 N N N,
第二行包含 N N N个整数,表示完整序列。
#### 输出格式
输出一个整数,表示最大长度。
#### 数据范围
1 ≤ N ≤ 1000 1 \\le N \\le 1000 1≤N≤1000,
− 1 0 9 ≤ 数列中的数 ≤ 1 0 9 -10\^9 \\le 数列中的数 \\le 10\^9 −109≤数列中的数≤109
#### 思路
这也是一个非常经典的DP问题,同样我们来考虑状态表示和状态计算方法(**需要明确的一点就是,很多动态规划问题的状态表示方法其实是一种经验论的东西,熟悉了所有经典的模型,再碰到新的题目时才有思路,很多题目的表示方法其实并没有特定的思路形成过程,需要积累**)
对于状态表示来说,使用`f[i]`,表示的集合是所有以第 i i i个数结尾的上升子序列,要求的属性值是最长的,也就是最大值
对于状态转移来说,因为我们的`f[i]`已经确定了表示的是以第 i i i个数结尾,因此我们的划分就应该从前面的数入手,也就是第 i − 1 i - 1 i−1个数是哪个数,因此可以划分为:没有第 i − 1 i - 1 i−1个数(也就是第 i i i个数就是第一个数)、第 i − 1 i - 1 i−1个数是第 1 1 1个数、......但是这样划分是有条件的,因为我们要求的是上升子序列,因此这里面的每一类不一定都存在,需要满足的条件是 a \[ i − 1 \] \< a \[ i \] a\[i - 1\] \< a\[i\] a\[i−1\]\
#include
#include
using namespace std;
const int N = 1010;
int a[N];
int f[N];
int n;
int main()
{
cin >> n;
for(int i = 1; i <= n; i ++) cin >> a[i];
for(int i = 1; i <= n; i ++)
{
f[i] = 1;
for(int j = 0; j < i; j ++)
if(a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
}
int res = 0;
for(int i = 1; i <= n; i ++) res = max(res, f[i]);
cout << res << endl;
return 0;
}
```
### Acwing 896. 最长上升子序列 II
\[原题链接\]([896. 最长上升子序列 II - AcWing题库](https://www.acwing.com/problem/content/898/))
给定一个长度为 N N N的数列,求数值严格单调递增的子序列的长度最长是多少。
#### 输入格式
第一行包含整数 N N N,
第二行包含 N N N个整数,表示完整序列。
#### 输出格式
输出一个整数,表示最大长度。
#### 数据范围
1 ≤ N ≤ 100000 1 \\le N \\le 100000 1≤N≤100000,
− 1 0 9 ≤ 数列中的数 ≤ 1 0 9 -10\^9 \\le 数列中的数 \\le 10\^9 −109≤数列中的数≤109
#### 思路
其实题目是一样的,只是数据范围发生了很大的变化,可以看到,如果用上一题的代码是肯定过不了的,因此需要进行优化
这里我们给出一个例子
```cpp
3 1 2 1 8 5 6
```
可以看出在遍历的过程中,`f[1] =1 `,其末尾数字就是`a[1] = 3`,遍历到第二个数的时候`f[2] = 1`,其末尾数字是`a[2] = 1`,这里我们就可以发现,现在有两个长度为 1 1 1的子序列了,那么这两个子序列哪个对我们更有用呢?很明显是第二个,因为他的末尾数字是 1 1 1,如果后面有一个数能够接在 3 3 3后面形成一个长度为 2 2 2的子序列,那么他一定可以接在 1 1 1后面同样形成一个长度为 2 2 2的子序列。
由此,我们可以总结出一个性质,**一个更优的子序列应该是末尾数字更小的子序列**。其实我们在前面已经用到过类似的思想了------单调队列(可以去复习一下)。
下面我们来看这个性质如何帮我们进行优化,首先我们肯定要把上面提到的这个性质存下来,也就是存储每种长度的最长上升子序列结尾的值,并且这个值应是合法的最小值。
每个长度的最长上升子序列的尾数的分布应是下图这样的分布------单调递增。可以简单证明一下,假设长度为 4 4 4的最长上升子序列存储的尾数是 x 1 x_1 x1,长度为 5 5 5的最长上升子序列存储的尾数是 x 2 x_2 x2,且有 x 1 \> x 2 x_1 \> x_2 x1\>x2,那么对于长度为 5 5 5的最长上升子序列而言,由于序列是单调递增的,其序列中的第 4 4 4个数 a a a一定小于 x 2 x_2 x2,那么就有 x 1 \> x 2 \> a x_1 \> x_2 \> a x1\>x2\>a,那么长度为 4 4 4的最长上升子序列存储的尾数应是 a a a,与假设矛盾,得证。

有了上面的性质,就可以考虑如何处理状态计算了。每当遍历到一个数 a a a时,我们都要考虑将其接在哪个序列中,我们存储了每种长度子序列的最优子序列(也就是尾数最小),因此对于一个新的数 a a a,我们应在**上面的单调递增的尾数中找到一个最大的小于数 a a a的数** ,将 a a a接在这个子序列后面,假设这个子序列原来长度为 l e n len len,那么我们要更新的尾数应是 l e n + 1 len + 1 len+1的,因为多接了一个 a a a上去,并且这个 a a a一定比原来 l e n + 1 len + 1 len+1长度的子序列尾数小,至于寻找的过程,很明显是用**二分**完成,可以看一下二分复习
#### 代码
```cpp
#include
#include
#include
#include
using namespace std;
const int N = 100010;
int n;
int a[N];
int q[N];
int main()
{
cin >> n;
for(int i = 0; i < n; i ++) cin >> a[i];
int len = 0;
q[0] = -2e9;
for(int i = 0; i < n; i ++)
{
int l = 0, r = len;
while(l < r)
{
int mid = l + r + 1 >> 1;
if(q[mid] < a[i]) l = mid;
else r = mid - 1;
}
q[l + 1] = a[i];
len = max(len, l + 1);
}
cout << len << endl;
return 0;
}
```
### Acwing 897. 最长公共子序列
\[原题链接\]([897. 最长公共子序列 - AcWing题库](https://www.acwing.com/problem/content/899/))
给定两个长度分别为 N N N和 M M M的字符串 A A A和 B B B,求既是 A A A的子序列又是 B B B的子序列的字符串长度最长是多少。
#### 输入格式
第一行包含两个整数 N N N和 M M M,
第二行包含一个长度为 N N N的字符串,表示字符串 A A A。
第二行包含一个长度为 M M M的字符串,表示字符串 B B B。
字符串均由小写字母构成。
#### 输出格式
输出一个整数,表示最大长度。
#### 数据范围
1 ≤ N , M ≤ 1000 1 \\le N, M \\le 1000 1≤N,M≤1000,
#### 思路
这也是一个很经典的模型,直接来看状态表示和状态计算。
对于状态表示,我们使用`f[i][j]`,他表示的集合是所有在第一个序列的前 i i i个字母,和第二个序列的前 j j j个字母中出现的子序列,我我们需要求的属性是集合中的子序列长度的最大值。
对于状态计算,我们根据`a[i]`和`b[j]`是否包含在子序列当中来进行集合划分,可以划分为:有`a[i]`有`b[j]`,有`a[i]`无`b[j]`,无`a[i]`有`b[j]`,无`a[i]`无`b[j]`,一共四类。
第一类我们可以直接用`f[i - 1][j - 1] + 1`表示,因为两个字母都在子序列中;同理,最后一类也很好求,可以直接用`f[i - 1][j - 1]`表示;对于中间两类,似乎可以用`f[i][j - 1]`和`f[i - 1][j]`表示,但其实并不能直接划等号,比如第二类有`a[i]`无`b[j]`,而我们用`f[i][j - 1]`表示,但他们并不是完全等价的,因为`f[i][j - 1]`表示的集合是存在于第一个序列的前`i`个字母和第二个序列的前`j - 1`个字母的子序列,而我们要的是一定包含第一个子序列的第`i`个字母并且出现在第二个序列的前`j - 1`个字母的子序列,同理对于`f[i - 1][j]`也是一样的。
但是这并不意味着我们不能这样表示,在上面的题目中我们提到对于集合的划分要不重不漏,但是对于某些题目来说会有例外,这里就是,因为我们**要求的是集合的最大值** , 并且我们用来表示第二类的这个集合`f[i][j - 1]`一定包含了我们的目标集合,因此重复并不会影响我们要求的最大值。
举个例子,我们要在甲乙丙中找一个最高的人,我们可以先在甲乙中间找一个高的,再在乙丙中间找一个高的,最后在比一下,这样并不会影响三个人中的最高的人是谁,之所以之前说要不重不漏是因为我们可能会碰到要求的集合的属性是数量,这种肯定不能重复,不然就会多算了。
#### 代码
```cpp
#include
#include
#include
#include
using namespace std;
const int N = 1010;
char a[N], b[N];
int f[N][N];
int n, m;
int main()
{
cin >> n >> m;
scanf("%s%s", a + 1, b + 1);
for(int i = 1; i <= n; i ++)
for(int j = 1; j <= m; j++)
{
f[i][j] = max(f[i - 1][j], f[i][j - 1]);
if (a[i] == b[j]) f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1);
}
cout << f[n][m] << endl;
return 0;
}
```
### Acwing 902. 最短编辑距离
\[原题链接\]([902. 最短编辑距离 - AcWing题库](https://www.acwing.com/problem/content/904/))
给定两个字符串 A A A和 B B B,现在要将 A A A经过若干操作变为 B B B,可进行的操作有:
1. 删除-将字符串 A A A中的某个字符删除
2. 插入-在字符串 A A A的某个位置插入某个字符
3. 替换-将字符串 A A A中的某个字符替换为另一个字符。
现在请你求出,将 A A A变为 B B B至少需要进行多少次操作。
#### 输入格式
第一行包含整数 n n n,表示字符串 A A A的长度。
第二行包含一个长度为 n n n的字符串 A A A。
第三行包含整数 m m m,表示字符串 B B B的长度
第四行包含一个长度为 m m m的字符串 B B B。
字符串均只包含大小写字母。
#### 输出格式
输出一个整数,表示最少操作次数。
#### 数据范围
1 ≤ n , m ≤ 1000 1 \\le n,m \\le 1000 1≤n,m≤1000,
#### 思路
这是对于两个字符串的另一种题目,同样来考虑状态表示和状态计算
(动态规划其实对于暴力搜索的优化,暴力搜索慢是因为遍历所有的方案,而动态规划实际上是记录了一个集合的方案的某个属性,比如上面几题都是最大值,这就省去了对所有方案的属性值的计算。)
对于状态表示,同样使用`f[i][j]`,他表示的集合是所有将字符串 A A A的 1 ∼ i 1 \\sim i 1∼i个字符变为字符串 B B B的 1 ∼ j 1 \\sim j 1∼j个字符的方案的步数,我们要求的是这些方案中的最小值。
对于状态计算,分情况讨论:
(这里回忆一下上面几题,我们通常都是根据当前字符或者上一个字符是否被包含进行分类,所以总结来看,集合的划分通常是考虑最后一步的选择)
这里我们有三种操作,首先考虑删除------如果我们要删除字符串 A A A的第 i i i个字符然后使 A A A和 B B B相同,那么就意味着字符串 A A A的 1 ∼ i − 1 1 \\sim i - 1 1∼i−1已经与字符串 B B B的 1 ∼ j 1 \\sim j 1∼j相同了,那也就是`f[i - 1][j] + 1`。
第二种操作是插入------我们需要添加一个字符使两个字符串相同,如果我们添加一项使其相等,那么在添加之前,字符串 A A A的 1 ∼ i 1 \\sim i 1∼i应该与字符串 B B B的 1 ∼ j − 1 1 \\sim j - 1 1∼j−1相等了,这样我们在字符串 A A A上添加一项等于 b \[ j \] b\[j\] b\[j\]就有两字符串相等,因此此集合的状态转移为`f[i][j - 1] + 1`。
第三种操作是修改------改完之后两字符串相等,因此修改的操作应该是将`a[i]`修改为`b[j]`,那么改之前如果`a[i]`已经等于`b[j]`是不需要修改的,因此这里要加一个判断;如果不相等则增加这一步操作,状态转移为`f[i - 1][j - 1] + 1`。
需要注意的点是**初始化** ,在最开始我们就提到了动态规划的题目是需要初始化一些状态的,因为状态的转移依赖于已知状态,而初始状态无法从其他状态转移而来,这题中的初始化操作在于将字符串 A A A的前 i i i个字符变为字符串 B B B的前 0 0 0个字符和将字符串 A A A的前 0 0 0个字符变为字符串 B B B的前 i i i个字符,分别只需要删除和添加 i i i个字符即可,具体看下面代码,要想一下`f[i][j]`表示的到底是什么。
#### 代码
```cpp
#include
#include
#include
#include
using namespace std;
const int N = 1010;
int n, m;
char a[N], b[N];
int f[N][N];
int main()
{
scanf("%d%s", &n, a + 1);
scanf("%d%s", &m, b + 1);
for(int i = 0; i <= m; i ++) f[0][i] = i;
for(int i = 0; i <= n; i ++) f[i][0] = i;
for(int i = 1; i <= n; i ++)
{
for(int j = 1; j <= m; j ++)
{
f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1);
if(a[i] != b[j]) f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
else f[i][j] = min(f[i][j], f[i - 1][j - 1]);
}
}
cout << f[n][m] << endl;
return 0;
}
```
### Acwing 899. 编辑距离
\[原题链接\]([899. 编辑距离 - AcWing题库](https://www.acwing.com/problem/content/901/))
给定 n n n个长度不超过 10 10 10的字符串以及 m m m次询问,每次询问给出一个字符串和一个操作次数上限。
对于每次询问,请你求出给定的 n n n个字符串中有多少个字符串可以在上限操作次数内经过操作变成询问给出的字符串。
每个对字符串进行的单个字符的插入、删除或替换算作一次操作。
#### 输入格式
第一行包含两个整数 n n n和 m m m。
接下来 n n n行,每行包含一个字符串,表示给定的字符串。
再接下来 m m m行,每行包含一个字符串和一个整数,表示一次询问。
字符串均只包含小写字母,且长度不超过 10 10 10。
#### 输出格式
输出共 m m m行,每行输出一个整数作为结果,表示一次询问中满足条件的字符串个数。
#### 数据范围
1 ≤ n , m ≤ 1000 1 \\le n,m \\le 1000 1≤n,m≤1000,
#### 思路
这一题其实就是将上一题求 n n n次就行了,只要计算一下时间复杂度是否够用,上一题的代码为 O ( 1 0 2 ) O(10\^2) O(102),再多乘 n m nm nm,则为 1 0 8 10\^8 108,正好在时间范围内(1s大概可以进行 1 0 7 ∼ 1 0 8 10\^7 \\sim 10\^8 107∼108次计算)
#### 代码
```cpp
#include
#include
#include
#include
using namespace std;
const int N = 15, M = 1010;
int n, m;
int f[N][N];
char str[M][N];
int edit_distance(char a[], char b[])
{
int la = strlen(a + 1), lb = strlen(b + 1);
for(int i = 0; i <= la; i ++) f[i][0] = i;
for(int i = 0; i <= lb; i ++) f[0][i] = i;
for(int i = 1; i <= la; i ++)
for(int j = 1; j <= lb; j ++)
{
f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1);
if(a[i] == b[j]) f[i][j] = min(f[i][j], f[i - 1][j - 1]);
else f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
}
return f[la][lb];
}
int main()
{
cin >> n >> m;
for(int i = 0; i < n; i ++) scanf("%s", str[i] + 1);
while(m --)
{
char s[N];
int limit;
scanf("%s%d", s + 1, &limit);
int res = 0;
for(int i = 0; i < n; i ++)
if(edit_distance(str[i], s) <= limit)
res ++;
cout << res << endl;
}
return 0;
}
```
f\[i\]\[j\] = min(f\[i\]\[j\], f\[i - 1\]\[j - 1\] + 1);
}
return f[la][lb];
}
int main()
{
cin \>\> n \>\> m;
for(int i = 0; i < n; i ++) scanf("%s", str[i] + 1);
while(m --)
{
char s[N];
int limit;
scanf("%s%d", s + 1, &limit);
int res = 0;
for(int i = 0; i < n; i ++)
if(edit_distance(str[i], s) <= limit)
res ++;
cout << res << endl;
}
return 0;
}
```
```