动态规划

动态规划先化零为整,再化整为零。

化零为整即将不同的方案归到一类去,用一个状态来表示

化整为零即将每一个状态分割成若干个字迹再分别来求

DP能解的前提是为拓扑图不存在环

DP是特殊的最短路问题,是没有环的最短路entire

背包问题模型

每件物品i重量为$w_1$,价值为$v_i$,背包至多能装W重量的物体,问如何选择物品获得的价值最大。

背包问题解决的问题大致都是:从某些物品里,在某种限制下,选择一部分物品,使目标值最大。

背包问题是选择问题或组合问题的一种

DP问题的优化都是对状态转移方程/代码的等价变形

1. AcWing 2. 01背包问题

每件物品最多只用一次

71015FA210F785ABDAB0E6E8C6543B60

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// AcWing 2. 01背包问题  
#include <iostream>
#include <algorithm>

using namespace std;

const int N =1010;

int v[N], w[N];
int f[N][N]; // 状态表示,属性为MAX
// 隐含了初始化,没有物品的时候,背包的价值永远是0

int main(){
int n,m;
cin >> n >> m;

for (int i = 1; i <= n; ++ i) cin >> v[i] >> w[i];

// 边界条件:f[0][0~n] = 0;
for (int i = 1; i <= n; ++ i)
for (int j = 0; j <= m ; ++ j){
// 不选择当前物品,则价值等于前i-1个物品在同容量下的MAX
f[i][j] = f[i-1][j];
// 如果j没有v[i]大,则说明装不下第i件物品,那么含i的集合就是空集
// 选择当前物品,前提是j >= v[i],则价值等于前i-1个物品在同j-v[i]容量下的MAX加上第i个物品价值
if (j >= v[i]) f[i][j] = max(f[i-1][j], f
[i-1][j-v[i]] + w[i]);// 递推式
}
cout << f[n][m] << endl;
return 0;
}

python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# AcWing 2. 01背包问题  
def main():
N = 1010
v = [0] * N
w = [0] * N
f = [[0] * N for _ in range(N)] # 状态表示,属性为MAX
# 隐含了初始化,没有物品的时候,背包的价值永远是0

n, m = map(int, input().split())
for i in range(1, n + 1):
v[i], w[i] = map(int, input().split())

for i in range(1, n + 1):
for j in range(1, m + 1):
# 不选择当前物品,则价值等于前i-1个物品在同容量下的MAX
f[i][j] = f[i - 1][j]
# 如果j没有v[i]大,则说明装不下第i件物品,那么含i的集合就是空集
# 选择当前物品,前提是j >= v[i],则价值等于前i-1个物品在同j-v[i]容量下的MAX加上第i个物品价值
if j >= v[i]:
f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i])
print(f[n][m])

if __name__ == "__main__":
main()

优化——滚动数组:使用的两个前提条件:

  • 状态f(i, j)只与f(i-1, ...)有关,只与x坐标上一层有关

  • $f(i - 1, j)与f(i - 1, j - v_1)$ y坐标都小于等于j,都在f(i, j)的同一侧

DP问题的优化都是对状态转移方程/代码的等价变形
可以发现f[i][j] = max(f[i][j],f[i - 1][j - volumn[i]] + weight[i])只用到了i-1层的信息,因此可以用滚动数组来进行优化

滚动数组简介滚动数组是DP中的一种编程思想。简单的理解就是让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。起到优化空间,主要应用在递推或动态规划中(如01背包问题)。因为DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。所以用滚动数组优化是很有效的。利用滚动数组的话在N很大的情况下可以达到压缩存储的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 01背包问题——DP + 滚动数组优化
#include <iostream>
#include <algorithm>

using namespace std;

const int N =1010;

int v[N], w[N];
int f[N]; // 滚动数组降维

int main(){
int n,m;
cin >> n >> m;

for (int i = 1; i <= n; ++ i) cin >> v[i] >> w[i];

// 边界条件:f[0~n] = 0;
for (int i = 1; i <= n; ++ i)
// 注意j必须从大到小,从而保证用到的是第i-1层的信息
for (int j = m; j >=v[i] ; -- j)
f[j] = max(f[j], f[j-v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}

2. AcWing 3. 完全背包问题

每件物品有无限个

朴素的DP就是暴力+递归存储。先不用考虑优化问题,对于每一个不确定的选择维度,就都开一重循环进行枚举即可。

状态表示的维度和循环的重数不一定相同。对于复杂问题,往往需要在集合划分时也使用一重循环。此时先不要考虑优化问题,先上暴力枚举即可。而优化时只需对状态转移方程等价变形,用前面的状态替换方程的某些部分

集合划分时需要枚举每个物品选几个。

image-20230325122140072

朴素算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// AcWing 3. 完全背包问题   
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int v[N], w[N];
int f[N][N];

int main(){
int n, m;
cin >> n >> m;

for (int i = 1 ; i <= n; ++ i) cin >> v[i] >> w[i];

// 边界条件: f[0][0~n] = 0
for (int i = 1; i <= n; ++ i)
for (int j = 0; j <= m ; ++ j)
for (int k = 0; k * v[i] <= j; ++ k)
f[i][j] = max(f[i][j], f[i-1][j - k * v[i]] + k * w[i]);
cout << f[n][m] << endl;
return 0;
}
python
1
# AcWing 3. 完全背包问题   

对状态方程进行变形化简

这种变形化简实际上反映了利用两次递推关系。在集合划分时,由于我们思维能力的局限性,我们只沿着第一维i来寻找递推关系并推导,但对于有些复杂问题,我们要沿着第一维i和第二维j都寻找递推关系才能充分化简递推式。

这就需要我们在由第一维i推导出的递推式基础上,继续寻找沿着第二维j上的递推关系并对现有递推式进行化简。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// AcWing 3. 完全背包问题   
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int v[N], w[N];
int f[N][N];

int main(){
int n, m;
cin >> n >> m;

for (int i = 1 ; i <= n; ++ i) cin >> v[i] >> w[i];

// 边界条件: f[0][0~n] = 0
for (int i = 1; i <= n; ++ i)
for (int j = 0; j <= m ; ++ j){
f[i][j] = f[i-1][j];
if (j >= v[i]) f[i][j] = max(f[i][j], f[i][j - v[i]] + w[i]);
}
cout << f[n][m] << endl;
return 0;
}
python
1
# AcWing 3. 完全背包问题   

使用滚动数组优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 完全背包问题
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int v[N], w[N];
int f[N];

int main(){
int n, m;
cin >> n >> m;

for (int i = 1 ; i <= n; ++ i) cin >> v[i] >> w[i];

// 边界条件: f[0][0~n] = 0
for (int i = 1; i <= n; ++ i)
for (int j = v[i]; j <= m ; ++ j) // 注意j必须从小到大,从而保证用到的是第i层的信息
f[j] = max(f[j], f[j - v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}
python
1
# AcWing 3. 完全背包问题   

3. AcWing 4. 多重背包问题

多重背包问题每件物品i有$s_i$个

image-20230325121902131

注意区分多重背包和完全背包的不同:此处注意,直接借用完全背包的模板是不行的,因为物品数目是有限个,而非完全背包的无限个,在背包空间足够大时,递推会出现错误,因为会多出一项 $dp[i−1][j−(s_i+1)v_i]+s_iw_i$,这一项不能直接利用递推的性质得到 $dp[i−1][j−(s_i+1)v_i]+(s_i+1)w_i$,因为物品$i$最多也才$s_i$件,不可能取到$s_i+1$件,所以不能直接借用完全背包的模板来优化;

朴素算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 多重背包问题朴素算法
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;

int v[N], w[N], s[N];
int f[N][N];

int main(){
int n, m;
cin >> n >> m;

for (int i = 1; i <= n; ++ i) cin >> v[i] >> w[i] >> s[i];

// 边界条件:f[0][0~n] = 0
for (int i = 1; i <= n; ++ i)
for (int j = 0; j <= m; ++ j)
for (int k = 0; k <= s[i] && k * v[i] <= j; ++ k)
f[i][j] = max(f[i][j], f[i-1][j - k * v[i]] + k * w[i]);

cout << f[n][m] << endl;

return 0;
}

4. AcWing 5. 多重背包问题 II

使用二进制优化方法

枚举优化:二进制打包枚举

利用二进制优化方法,可以将多重背包问题转化为01背包问题

使用$1,2,4,\cdots,2k$这几个数的组合可以表示出$0$到$2{k+1} - 1$中的每一个数,注意每个数$2^i$最多只会被选1次。

对于任意一个数S,选取最大的k(满足$1+2+4+…+2^k = 2^{k+1} - 1 < S, 1+2+4+…+2^{k+1} = 2^{k+2} - 1>S$),
$c = s - (2^{k+1} - 1)$,则:$$0-S中的所有数都可以用1,2,4,…,2^k,c的组合表示出来。$$
注意因为c<2^{k+1}, c和2^{k+1} - 1间无漏缺。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 二进制优化法处理多重背包问题
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 25000;

int v[N], w[N];
int f[N];

int main(){
int n, m;
cin >> n >> m;

int cnt = 0;
for (int i = 1; i <= n; ++ i){
int a, b, s;
cin >> a >> b >> s;
int k = 1;
// 二进制优化,转换为01背包问题
while(k <= s){
cnt ++;
v[cnt] = a * k, w[cnt] = b * k;
s -= k;
k *= 2;
}
if (s > 0){
cnt ++;
v[cnt] = a * s, w[cnt] = b * s;
}
}

// 边界条件:f[0~cnt] = 0
// 01背包问题
for (int i = 1; i <= cnt; ++ i)
for (int j = m; j >= v[i]; -- j)
f[j] = max(f[j], f[j - v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}

5. AcWing 9. 分组背包问题

分组背包问题物品分为多个组,每组有多种类别物品

关键在于理解之前的背包问题以每件物品作为i,而分组问题以每组作为i,每组里再讨论选那个物品哪个物品

同样注意,对于复杂问题,往往需要在集合划分时也使用一重循环

分组背包中集合划分时需要枚举每组中选哪一个物品(包含一个也不选)。

类似完全背包问题的朴素算法

image-20230325121719809

朴素算法

类似完全背包问题的朴素算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 分组背包问题朴素算法
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;

int v[N][N], w[N][N],s[N];
int f[N][N];

int main(){
int n, m;
cin >> n >> m;

for (int i = 1; i <= n; ++ i){
cin >> s[i];
for (int j = 1; j <= s[i]; ++ j)
cin >> v[i][j] >> w[i][j];
}

for (int i = 1; i <= n; ++ i)
for (int j = 0; j <= m; ++ j){
f[i][j] = f[i-1][j]; // 先要等于不选本组的值
for (int k = 1; k <= s[i]; ++ k){

if (v[i][k] <= j) f[i][j] = max(f[i][j], f[i-1][j-v[i][k]] + w[i][k]);
// 注意这里f[i][j]不能用f[i-1][j]替换,因为考虑到每组里要进行比较
}
}

cout << f[n][m] << endl;
return 0;
}

滚动数组优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 110;

int v[N][N], w[N][N],s[N];
int f[N];

int main(){
int n, m;
cin >> n >> m;

for (int i = 1; i <= n; ++ i){
cin >> s[i];
for (int j = 1; j <= s[i]; ++ j)
cin >> v[i][j] >> w[i][j];
}

for (int i = 1; i <= n; ++ i)
for (int j = m; j >= 0; ++ j)
for (int k = 1; k <= s[i]; ++ k)
if (v[i][k] <= j) f[i][j] = max(f[i][j], f[i-1][j-v[i][k]] + w[i][k]);

cout << f[n][m] << endl;
return 0;
}

线性DP

递推式具有明显的线性关系。线性DP一般从前往后推,状态表示和集合划分都会将前i个数视为整体

1. AcWing 898. 数字三角形

image-20230325171911856

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// AcWing 898. 数字三角形
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510, INF = 1e9;

int a[N][N];
int f[N][N];


int main(){
int n;
cin >> n;
// 当递推式含有i-1,j-1时存储从1开始,方便应对边界0的情况
for (int i = 1; i <= n; ++ i)
for(int j= 1; j <= i; ++ j)
cin >> a[i][j];

for (int i = 1; i <= n ; ++ i)
for (int j = 0; j <= i + 1; ++ j)
f[i][j] = -INF; // 注意为了应对越界问题,要先赋值为极小值
// 注意j从0到i+1,而不是1到j——边界的越界问题

// 注意边界条件
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],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;
}

优化1——滚动数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// AcWing 898. 数字三角形
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510, INF = 1e9;

int a[N][N];
int f[N];


int main(){
int n;
cin >> n;
// 当递推式含有i-1,j-1时存储从1开始,方便应对边界0的情况
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)
f[i] = -INF; // 注意为了应对越界问题,要先赋值为极小值
// 注意j从0到i+1,而不是1到j——边界的越界问题

// 边界条件
f[1] = a[1][1];
for (int i = 2; i <= n; ++ i)
for (int j = i; j >= 1; -- j)
f[j] = max(f[j-1],f[j]) + a[i][j];

int res = -INF;
for (int i = 1; i <= n; ++ i) res = max(res, f[i]);

cout << res << endl;

return 0;
}


优化2——直接在存储数据的数组存储结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// AcWing 898. 数字三角形
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 510, INF = 1e9;

int a[N][N];

int main(){
int n;
cin >> n;
// 当递推式含有i-1,j-1时存储从1开始,方便应对边界0的情况
for (int i = 1; i <= n; ++ i)
for(int j= 1; j <= i; ++ j)
cin >> a[i][j];


for (int i = 1; i <= n; ++ i){
a[i][0] = -INF;
a[i][i+1] = -INF;
} // 注意为了应对越界问题,要先赋值为极小值
// 注意j从0到i+1,而不是1到j——边界的越界问题

// 边界条件
for (int i = 2; i <= n; ++ i)
for (int j = i; j >= 1; -- j)
a[i][j] = max(a[i-1][j-1],a[i-1][j]) + a[i][j];

int res = -INF;
for (int i = 1; i <= n; ++ i) res = max(res, a[n][i]);

cout << res << endl;

return 0;
}

2. AcWing 895. 最长上升子序列

状态为1维。

这里一个难点是集合的定义。集合的定义要满足递推包含性,方便进行递推。集合递推所用到的额外信息往往借助状态的维度进行查找,这也影响集合的定义。核心在于想清楚序列是怎么递推地一步步构成的,需要判断哪些条件。

集合定义时一定要满足递推包含性。首先,序列的构成显然具有递推性,即一个个数字往结尾加。而本题要判断字符能否加在一个序列最后构成上升子序列,关键要判断当前数字是否大于序列结尾字符。为此,为了进行递推,集合在定义时就必须能方便的表示出序列结尾的数字信息,即结尾数字的位置最好和维度相绑定。因此集合表示所有以第i个数结尾的上升子序列,而不是前i个数构成所有上升子序列的集合。

本题的集合划分也需注意。DP的核心思路是暴力+递推存储,对于复杂问题,往往需要在集合划分时也使用循环。本题中既然序列的递推关系由最后一个数表示和决定,划分时我们同样也可以讨论序列最后一个数的位置,这即对应于状态f。最特殊的情况是序列长度为0,其次可以遍历i前面的状态,取MAX。

image-20230325172802737

朴素算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 最长上升子序列朴素算法
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int a[N];
int f[N];

int main(){
int n;
cin >> n;
for (int i = 1; i <= n; ++ i)
cin >> a[i];

int res = 1;

// 边界条件 f[i] >= 1
for (int i = 1; i <= n; ++ i){
f[i] = 1; // 边界条件
for (int j = 1; j <= i - 1; ++ j)
if(a[j] < a[i]) f[i] = max(f[i], f[j] + 1) ;
res = max(res, f[i]);
}

cout << res << endl;
return 0;
}

输出状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

int a[N], f[N], g[N];
// g[N]用于记录状态转移的顺序

int main(){
int n;
scanf("%d", & n);

for (int i = 1; i <= n; ++ i) scanf("%d", &a[i]);

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; // 记录状态转移
}
}

int k = 1;
for (int i = 1; i <= n; ++ i)
if (f[k] < f[i])
k = i;

printf("%d\n", f[k]);

int s[N], len = f[k];
for (int i = 0; i < len; ++ i){
s[i] = a[k];
k = g[k];
}
for (int i = len - 1; i >= 0; -- i)
cout << s[i] << " ";
cout << endl;
return 0;
}

3. AcWing 896. 最长上升子序列 II

贪心加构造单调二分$O(nlogn)$

使用DP进行求解时会有冗余——会有多个长度相同的上升子串,针对问题所需求解的最长长度来说,不用考虑子串的内容是无所谓的,因此冗余。同时,对于这些长度相同的上升子串,应用贪心的思想,会有一个最优的子串——终端值最小的子串是最优子串,能插在其他等长串的数也一定能插在其后面。因此只用存每个长度的子串集合中的最优子串即可。

为此,对前面所有子串进行分类,使用一个数组维护前面每个长度下所有子串的最小的终端值。数组 q[i]表示长度为i的最长上升子序列的末尾元素的最小值。这里一定要把维护数组的含义理解好。

在这一前提下,我们发现一个重要性质——该维护数组一定是严格单调递增的。

可以使用反证法证明。证明数组q具有单调性,即证明i < j时,q[i] < q[j]。假设存在k<j时,q[k] > q[j],但在长度为j,末尾元素为q[j]的子序列A中,将后j-i个元素减掉,可以得到一个长度为i的子序列B,其末尾元素$t$必然小于q[j](因为在子序列A中,t的位置上在q[j]的后面),而我们假设数组d必须符合表示长度为 i 的最长上升子序列的末尾元素的最小值,此时长度为i的子序列的末尾元素t < q[j] < q[k],即t < d[k],所以q[k]不是最小的,与题设相矛盾,因此可以证明其单调性。

由此,我们只需要遍历所有字母,对于每个字母在维护数组中查找小于当前数的最大数,位置为r,表示该字母可以插入的最长上升子串长度为r,并用其替换后一个位置的数,表示插入后子串长度为r+1且恰为相同长度中终端值最小的子串。同时更新维护数组长度也是最长子串长度len

找到一个最大的小于当前数的数可以用 二分 来优化,使用l=mid模板

  • 二分的思路:

    • 先定义边界,l = 0, r = len, 其中lenq数组的长度
    • 然后确定check函数, 可以先找到不等式中c < x ≤ a ≤ bc
      • 通过q[r + 1] = a[i]来将x覆盖a的值
      • 同时也要考虑算法1的情况1, 需要扩大q数组的长度
        • r + 1 > len时, 表示超出了二分边界,这时就要len ++更新q的长度

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// AcWing 896. 最长上升子序列 II
// 贪心 + 二分
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 100010;

int n;
int a[N];
int q[N]; // q[i]存储当前长度为i的上升字串中的结尾的最小值

int main(){
scanf("%d", &n);

for (int i = 0; i < n; ++ i) scanf("%d", &a[i]);

int len = 0; // 记录维护数组q的长度,也是当前最长上升子串长度

// 贪心迭代顺序——按原序遍历串中每个数字
for (int i = 0; i < n; ++ i){
// 二分查找每个候选对象的最优插入位置——插到可以插入的最长子串
// 注意这里l始终初始为0,这样解决了a[i]小于任何长度大于1的子串结尾导致找不到插入位置的问题
// q[0]此时相当于哨兵,a[i]一定大于0,对应于至少可以自己构成一个长度为1的子串
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;
}
len = max(len, r + 1); // 更新最大长度
q[r + 1] = a[i]; // 更新维护数组
// 注意这里非常巧妙的处理了初始的情况
// 初始时l = r = 0, 直接将a[0]放入长度为1的子串的结尾维护位置中
}
printf("%d\n", len);
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# AcWing 896. 最长上升子序列 II
# 贪心 + 二分
def main():
n = int(input())
nums = list(map(int, input().split()))
q = [0] * (n + 1)
# 注意这里q[i]存储当前长度为i的上升字串中的结尾的最小值
# 因此从1开始存储,只要开n+1个位置

length = 0
for x in nums:
l, r = 0, length
# 注意这里l始终初始为0,这样解决了a[i]小于任何长度大于1的子串结尾导致找不到插入位置的问题
# q[0]此时相当于哨兵,a[i]一定大于0,对应于至少可以自己构成一个长度为1的子串
while l < r:
mid = l + r + 1 >> 1
if q[mid] < x: l = mid
else: r = mid - 1
length = max(length, r + 1)
q[r + 1] = x
# 注意这里非常巧妙的处理了初始的情况
# 初始时l = r = 0, 直接将a[0]放入长度为1的子串的结尾维护位置中

print(length)

if __name__ == "__main__":
main()

4. AcWing 897. 最长公共子序列

本题由于有两个字符串,所以使用二维状态分别表示两个字符串中位置。

集合定义同样思考公共子序列是如何递推地一步步构成的。只需判断第一个字符串i位置字符和第二个字符串j位置字符是否相同,如果相同就可以加上该字符(所有在ij前的序列都可以加上)。这只对应一个情况,而集合要对应于多种情况,同时可以发现定义时不需要额外限制条件。所以集合定义为所有在第一个序列前i个字母中出现,且在第二个序列前j个字母中出现的子序列。

注意本题集合划分比较困难。集合划分时讨论a[i]b[j]是否包含在子序列中,而易错点时a[i]b[j]两者相等只对应集合一种情况,只讨论两者大小关系是不够的。a[i]b[j]还可以与前面任意字符相等,从而被包含,为此需要划分成一下四类:

  • 所有在第一个序列前i-1个字母中出现,且在第二个序列前j-1个字母中出现的子序列(f[i-1, j-1])

  • 所有以第一个序列第i个字母中和第二个序列第j个字母结尾的子序列(a[i] == b[j]时才有,f[i-1, j-1]+1)

  • 所有以第一个序列第i个字母结尾,且在第二个序列前j-1个字母中出现的子序列

  • 所有以第二个序列第j个字母结尾, 且在第一个序列前i-1个字母中出现的子序列

显然这需要涉及讨论前面的字母,和状态时不直接对应的。但本题是MAX类,使用包含所需情况集合的状态也是可以来替换表示的。f[i-1, j]表示所有在第一个序列前i-1个字母中出现,且在第二个序列前j个字母中出现的子序列,一定包含了所有以第二个序列第j个字母结尾, 且在第一个序列前i-1个字母中出现的子序列的所有情况,但也包含了不以第二个序列第j个字母结尾的情况。但由于本题是最值问题,不影响问题求解,可替换表示。同理,可以用f[i, j-1]覆盖表示所有以第一个序列第i个字母结尾,且在第二个序列前j-1个字母中出现的子序列的所有情况。

同时可以发现,f[i-1, j]f[i, j-1]都覆盖表示了f[i-1, j-1]的情况,因此这种情况不用单列了。

这里还需要注意,之所以可以用f[i-1, j]f[i, j-1]进行替换表示,一个很重要的前提是f[i-1, j]f[i, j-1]仍都是f[i, j]的子集,没有超出其表示范围,从而不影响其最值结果。

若状态$s_{i-1} \subset k \subset f_i$,则当状态$s_{i-1}$不方便表示时,可以用$ k $替换表示

集合划分时可以重复包含,只要不影响结果就行。而当要求的结果是最值时划分出集合有重复往往时无影响。

image-20230325183652600

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 最长公共子序列朴素算法
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010;

char a[N], b[N];
int f[N][N];

int main(){
int n, m;
scanf("%d %d %s %s", &n, &m, 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;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def main():
n, m = map(int, input().split())
a = " " + input()
b = " " + input()
f = [[0] * (m + 1) for _ in range(n + 1)] # 注意存储从下标1开始

for i in range(1, n + 1):
for j in range(1, m + 1):
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)
print(f[n][m])

if __name__ == "__main__":
main()

4. AcWing 902. 最短编辑距离

要求是将字符串a变成字符串b,实现的过程显然具有递推性。两个字符串,状态为二维。实现要随维度一步步实现,集合可以定义成所有将a[1~i]变成b[1~j]的操作方式

集合划分:讨论最后一步的操作,根据三种操作划分

最短编辑距离

集合划分的方案虽然容易,但是每种方案如何取很容易错。增操作和删操作都意味着a[i]b[j]不是对应的,这和两者相不相等没有关系,可能已经和前面的字母已经匹配了。因此这些方案必须要进行对应操作。

但是插操作则不一样,插操作是将当前位置的数替换后a变成b,这意味着要将a[i]b[j]匹配,因此当a[i]b[j]已经相等了,就可直接对应,没必要进行此操作,因此需要先判断。这里注意a[i]b[j]对应的方案也是必须取方案,只不过当两者已经相等时只用取f[i-1][j-1],而当两者不等时需要取f[i-1][j-1]+1,区别在加1上。

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// AcWing 902. 最短编辑距离
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
const int N = 1010;

int n, m;
char a[N], b[N];
int f[N][N];

int main(){
scanf("%d%s%d%s", &n, a + 1, &m, b + 1);
// 注意DP一般从下标1存储,这与状态转移方程越界问题有关

// 注意初始化——从空串变成长度为i的串至少需要i次操作
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);
// 对于a[i]与b[j]对应的方案,即插操作方案,要讨论是否需要加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);
}
printf("%d\n", f[n][m]);
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# AcWing 902. 最短编辑距离
def main():
n = int(input())
a = " " + input()
m = int(input())
b = " " + input()
f = [[0] * (m + 1) for _ in range(n + 1)]

for i in range(n+1):
f[i][0] = i
for i in range(m + 1):
f[0][i] = i

for i in range(1, n + 1):
for j in range(1, m + 1):
f[i][j] = min(f[i-1][j], 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)
print(f[n][m])

if __name__ == "__main__":
main()

5. AcWing 899. 编辑距离

本题相当于求解多个最短编辑距离问题

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <algorithm>
#include <string.h>

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 <= lb; i ++ ) f[0][i] = i;
for (int i = 0; i <= la; i ++ ) f[i][0] = 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);
f[i][j] = min(f[i][j], f[i - 1][j - 1] + (a[i] != b[j]));
}

return f[la][lb];
}

int main()
{
scanf("%d%d", &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 ++ ;

printf("%d\n", res);
}

return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def main():
n, m = map(int, input().split())
s = [" " + input() for _ in range(n)]
f = [[0] * 1001 for _ in range(1001)]

for _ in range(m):
res = 0
b, l = input().split()
b = " " + b
l = int(l)

for a in s:
la = len(a) - 1
lb = len(b) - 1
for i in range(la + 1): f[i][0] = i
for i in range(lb + 1): f[0][i] = i

for i in range(1, la + 1):
for j in range(1, lb + 1):
f[i][j] = min(f[i-1][j], 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)
if f[la][lb] <= l:
res += 1
print(res)

if __name__ == "__main__":
main()

区间DP

递推式不具有线性关系。

区间dp的状态表示一般采用二维来表示一个区间。集合是从第i个元素到第j元素而不再都从第1个元素开始。

递推关系是从两个更小的区间合并成一个更大的区间。

区间dp要注意枚举顺序,需枚举区间长度,从长度为1的区间开始枚举。一般先枚举区间长度,再枚举区间左端点

AcWing 282. 石子合并

状态表示时要考虑到由于操作是将任意左右两堆合并,左右端点不确定且具有对称性,因此将左右端点作为两维,因此状态对应于一个区间。集合定义即所有将第i堆石子到第j堆石子合并成一堆的合并方式。

集合划分思考最后一步和倒数第二步的递推关系,显然无论区间有多长,要合成一堆,倒数第二步是合成左右两堆。由于合并顺序不定,因此左右两堆的分界点就会不同,对应的合成两堆的代价也不同。因此需要暴力枚举左右两堆的分界点。

合并两堆的代价是两堆的总重量,即区间和,因此需要使用前缀和。
$$
f[i, j] = min(f[i, k] + f[k + 1, j] + s[j] - s[i - 1]), k =i,\ldots, j - 1
$$
kij-1是因为左右都至少要有一堆

枚举顺序是先枚举区间长度,再枚举区间左端点。

image-20230325202545559

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 石子合并
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 310;

int s[N];// 前缀和
int f[N][N];

int main(){
int n;
cin >> n;
for (int i = 1; i <= n; ++ i) cin >> s[i];

for (int i = 1; i <= n; ++ i) s[i] += s[i-1];// 求前缀和

// 本题递推中只涉及区间数据的和
// 求任意区间的数据的和可以采用前缀和的方法

// 以区间长度从小到大来枚举所有状态
// 边界条件:区间长度为1,值为0
for (int len = 2; len <= n; len ++)
for (int i = 1; i + len - 1 <= n; ++ i){ // i为区间起点
int l = i, r = i + len - 1; // 区间左右端点
f[l][r] = 1e9; // 注意求的是最小值,一定要先把状态值初始化为比较大的值
for (int k = l; k < r; ++ k) // 注意k<r,补去等。 k为子区间的分界点
f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);
}

cout << f[1][n] <<endl;
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def main():
n = int(input())
s = [0] + list(map(int, input().split()))
f = [[float("inf")] * (n + 1) for _ in range(n + 1)]

for i in range(1, n + 1):
f[i][i] = 0
s[i] += s[i - 1] # 前缀和

# 注意这里的遍历范围是极容易错的
for len in range(2, n + 1):
for i in range(1, n + 1 - len + 1):
l, r = i, i + len - 1
for k in range(l, r):
f[l][r] = min(f[l][r], f[l][k] + f[k+1][r] + s[r] - s[l - 1])
print(f[1][n])

if __name__ == "__main__":
main()

组合BP

有限制的组合(选择)问题就是背包问题,可以使用动态规划,只考虑组合而不考虑排序

计数类DP

计数类DP即状态表示的集合的要求的属性为数量。而这种数量往往指组合的数量,可用DP解决

1. AcWing 900. 整数划分

组合问题首先就要看能不能用DP

思路一:转化为完全背包问题

题目中将正整数 n 表示成若干个正整数$n_i$之和,且划分是无关顺序的(一定按照从大到小排列),这种无关顺序的划分问题可以被视为一个组合问题。原题等价于从1~n中所有数中进行选择,使其和等于n,每个数可以选择无数次。因而这可以转化为完全背包问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 整数划分 思路一:转化为完全背包问题
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010, mod = 1e9 + 7;

int f[N][N];

int main(){
int n;
cin >> n;

// 边界条件: 空集作为一种方案
f[0][0] = 1;
for (int i = 1; i <= n; ++ i)
for (int j = 0; j <= n; ++ j){
f[i][j] = f[i-1][j] % mod;
if (j >= i) f[i][j] = (f[i][j] + f[i][j-i]) % mod;
}

cout << f[n][n] << endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 整数划分 思路一:转化为完全背包问题 + 滚动数组优化
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010, mod = 1e9 + 7;

int f[N];

int main(){
int n;
cin >> n;

// 边界条件: 空集作为一种方案
f[0] = 1;
for (int i = 1; i <= n; ++ i)
for (int j = i; j <= n; ++ j)
f[j] = (f[j] + f[j-i]) % mod;

cout << f[n] << endl;
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# f[i][j] = f[i-1][j] + f[i-1][j-1*i] + f[i-1][j-2*i] + ... + f[i-1][j-k*i]
# f[i][j-i] = f[i-1][j-i] + f[i][j-2*i] + ... + f[i-1][j-k*i]
def main():
n = int(input())
f = [[0] * (n + 1) for _ in range(n + 1)]
mod = int(1e9 + 7)

f[0][0] = 1 # f[0][0]表示的是从0个数中选体积为0的方案,也就是1个方案
for i in range(1, n + 1):
for j in range(n + 1):
f[i][j] = f[i-1][j] % mod
if j >= i:
f[i][j] = (f[i][j] + f[i][j-i]) % mod
print(f[n][n])

if __name__ == "__main__":
main()
1
2
3
4
5
6
7
8
9
10
11
12
13
def main():
n = int(input())
f = [0] * (n + 1)
mod = int(1e9 + 7)

f[0] = 1 # f[0][0]表示的是从0个数中选体积为0的方案,也就是1个方案
for i in range(1, n + 1):
for j in range(i, n + 1):
f[j] = (f[j] + f[j-i]) % mod
print(f[n])

if __name__ == "__main__":
main()

思路二

  • 集合:所有总和是i,并且恰好表示成j个数的和的方案

  • 集合划分:划分为最小值为1和最小值大于1两类

对于第一类,为f[i-1][j-1];对于第二类,为f[i-j][j];这两类的推到其实分别沿着第二维j和第一维i

一定掌握这种集合划分的思路,应用到了映射的思想。既然讨论的是选取方案,我们就可以把是否选取最小的一个元素作为不同点进行讨论,这里关键是能够进行映射才行。

只要能进行划分并能用状态表示出来即可

image-20230325225807811

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 1010, mod = 1e9 + 7;

int f[N][N];

int main(){
int n;
cin >> n;

// 边界条件: 空集作为一种方案
f[0][0] = 1;
for (int i = 1; i <= n; ++ i)
for (int j = 1; j <= i; ++ j)
f[i][j] = (f[i-1][j-1] + f[i-j][j]) % mod;

int res = 0;
for (int i = 1; i <= n; ++ i) res = (res + f[n][i]) % mod;

cout << res << endl;
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def main():
n = int(input())
f = [[0] * (n + 1) for _ in range(n + 1)]
mod = int(1e9 + 7)

f[0][0] = 1 # f[0][0]表示的是总和为0,由0个数构成,方案数为1
for i in range(1, n + 1):
for j in range(1, i + 1): # 注意选择的数最小为1,所以j至多为i
# 按照组成是否含有1来划分
f[i][j] = (f[i-1][j-1] + f[i-j][j]) % mod

print(sum(f[n]) % mod) # 注意最后别忘取余

if __name__ == "__main__":
main()

2. AcWing 1050. 鸣人的影分身

本题是AcWing 900. 整数划分的变形题。前面和整数划分完全一致,但多了一个限制条件——元素的个数,至多只能划分为N个数。

因为这个限制条件,整数划分的第一个思路——转化为完全背包问题就不可行了(完全背包对元素个数没有直接限制,每个元素可以取无数次)。但思路二仍是可行的,因为其将划分的元素数作为状态的一维

1
2
3
4
5
// AcWing 1050. 鸣人的影分身




数位DP

可参考以下博客的讲解:

数位DP重点在于分类讨论(即集合划分)。

首先我们要清楚数位dp解决的是什么问题:

求出在给定区间[A,B]内,符合条件f(i)的数i的个数。条件f(i)一般与数的大小无关,而与数的组成有关

由于数是按位dp,数的大小对复杂度的影响很小

对于[l,r]区间问题,我们一般把他转化为两次数位dp,即找[0,r]和[0,l-1]两段,再将结果相减就得到了我们需要的[l,r]

1. AcWing 338. 计数问题

  • 直接求解a-b之间的数的个数并不好求,这里要采用类似前缀和的思想来求解,先建立一个求解1~n之间的数的个数的函数,则$$count(a,b) = count(1,b) - count(1, a-1)$$

  • 针对于求每一位上数出现的个数,我们要进行分类讨论:

不妨设n = abcdefg,当前要求的数为k,分别求出其在每一位上出现的次数

不妨讨论其在第四位d的情况, 则1 <= xxxkyyy <= abcdefg。下面进行分类讨论:

(1) xxx = 000~abc - k,则yyy = 000~999,共abc*1000个

(2) xxx = abc

  1. d < k, abckyyy > abcdyyy,不成立

  2. d = k, yyy = 000~efg,共efg + 1个

  3. d > k, yyy = 000~999,共1000个

还要注意边界问题:

  • 当k放在首位是(1)的情况将不存在。

  • 当k=0时,不能作为前导零和首位,即xxx要从001开始而不能从000开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 数位dp——计数问题
#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

vector<int> num;

int get(vector<int> num, int l, int r){// 求出从第l位到第r位组成的数的大小
int res = 0;
for (int i = l; i >= r; -- i) res = res * 10 + num[i];
return res;
}

int power10(int x){// 求10^x的大小
int res = 1;
while(x--) res *= 10;
return res;
}

int count(int n, int x){
if (!n) return 0;

num.clear();
while(n){
num.push_back(n % 10); //首先将每一位提出来
n /= 10;
}
n = num.size(); // 保存总位数

int res = 0;
// vector从0开始,注意循环的范围
// 注意这里巧妙的写法:i = n - 1 - !x,这样写避免了0作为第一位
// 注意i表示第几位的位数
for (int i = n - 1 - !x; i >= 0; -- i){
if (i < n - 1){ // 作为首位时此情况不存在
res += get(num, n - 1, i + 1) * power10(i);
if (!x) res -= power10(i); // x = 0时从001开始
}
if (num[i] == x) res += get(num, i - 1, 0) + 1;
else if(num[i] > x) res += power10(i); // 注意i从0开始,所以这里不是i-1
}
return res;
}

int main(){

int a, b;
while(cin >> a >> b, a || b){
if (a > b) swap(a, b);

for (int i = 0; i <= 9; ++ i)
cout << count(b, i) - count(a - 1, i) << ' ';
cout << endl;
}
return 0;
}

状态压缩DP

可参考以下博客的讲解:

引入

对于组合问题或选择问题,我们可以使用01向量来表示每种组合的选择情况,如$x=<1,0,0,0,1>$。我们以1表示元素被选中,0表示元素没被选中。

而这种01表示实际上可以被视为一个二进制数。反过来,每一个二进制都可以用来表示一种组合问题中的选法。并且我们可以通过二进制的位运算来实现对选法的操作。这就直观的实现了选法的量化和处理。

所谓状态压缩DP即DP中的所有状态都用二进制编码表示

状态压缩

简单来说,状态压缩利用计算机二进制的性质来描述状态的一种方法。

利用前文的思路,我们将一个整数视为二进制数,并用二进制数的每一位表示选择的情况。每一个整数就是一个压缩的状态(组合的选法),这就是状态压缩。

之所以要用到状态压缩是因为在很多问题中状态表示往往比较复杂麻烦,不容易表示出来,需要用一些编码技术,把状态压缩的用简单的方式表示出来。典型方式就是当需要表示一个集合有哪些元素时,往往利用2进制用一个整数表示。

状压其实是一种很暴力的算法,因为它需要遍历每个状态,所以将会出现2^n的情况数量,不过这并不代表这种方法不适用:一些题目可以依照题意,排除冗余与不合法的方案,使一行的总方案数大大减少从而减少枚举。

这里实际上利用了十进制和二进制的一一对应关系,以二进制表示状态,以十进制作为存储和枚举编号

状态压缩DP

状态压缩通常和BFS或DP连用,当和DP一起使用时即为状态压缩DP。

动态规划本来就很抽象,状态的设定和状态的转移都不好把握,而状态压缩的动态规划解决的就是那种状态很多,不容易用一般的方法表示的动态规划问题,这个就更加的难于把握了。难点在于以下几个方面:状态怎么压缩?压缩后怎么表示?怎么转移?是否具有最优子结构?是否满足后效性?涉及到一些位运算的操作,虽然比较抽象,但本质还是动态规划。找准动态规划几个方面的问题,深刻理解动态规划的原理,开动脑筋思考问题。这才是掌握动态规划的关键。

由于状态都是二进制数,因此在状态计算时就要用到位运算进行操作

位运算

借助于位运算,我们可以实现对状态进行操作或访问。

常用位运算如下:

运算名 符号 效果
& 按位与 如果两个相应的二进制位都为1,则该位的结果值为1,否则为0
l 按位或 两个相应的二进制位中只要有一个为1,该位的结果值为1
^ 按位异或 若参加运算的两个二进制位值相同则为0,否则为1
~ 取反 ~是一元运算符,用来对一个二进制数按位取\反,即将0变1,将1变0
<< 左移 用来将一个数的各二进制位全部左移N位,右补0
>> 右移 将一个数的各二进制位右移N位,移到右端 的低位被舍弃,对于无符号数,高位补0

常用应用技巧如下:

位运算操作 代码
判断一个数字x二进制下第i位是不是等于1(这里i从0开始) if (( x >> i & 1 )if ((( 1 << i) & x ))
将一个数字x二进制下第i位更改成1 `x = x
lowbit运算——求一个数字最右端1对应的大小 lowbit(x) = x & -x
把一个数字二进制下最靠右的第一个1去掉 x = x - lowbit(x) = x - (x & -x)x = x & ( x − 1 )
用位运算求解$2^n$ 1 << n
取出i二进制下的第j i >> j & 1
用位运算判断奇偶 奇数:n & 1 == 1 偶数:n & 1 == 0

1. AcWing 291. 蒙德里安的梦想

首先需要注意到当我们将所有横向的小方格,纵向的小方格就只能保剩下的位置全部填满,因而只剩下一种填法。

所以整个的划分方案数就等于所有横向的小方格的方案数

这里要求看出组合/排序/选择问题的本质,去除冗余,并从多个对象中合理选择处理对象。

需注意横向小方格的方案必须保证最后剩下的位置都被纵向小方格填满。

如果我们以i作为列数,为了表示每一列中方格的排列情况就需要使用状态压缩,以二进制表示每一行是否存在方格。为此我们以整数j表示这种压缩状态。

当问题中的状态之间有关联性,需要将每种情况对应的状态具体存储下来用于后续处理,且状态为选择的情况时,就可以用二进制表示。讲白了,常见题目的原数组是固定的,而这里原数组动态变化,是我们讨论的对象,因此需要动态讨论表示出来。

同时可以发现二进制状态压缩隐含降维的作用,如果不使用状态压缩的话,维度会超过两维,状态转换会过于复杂,而使用状态压缩后每个选择状态都是一个一维向量,却只使用一个数的存储量,实现了降维,便于讨论和转移状态。本题实现了沿列这一维进行压缩,将所有行压缩到一个数值中。

另外需注意,由于小方格的大小是 $1 \times 2 $的,所以在我们研究第i + 1列时,第i列的组合情况就会对其造成影响。但需注意,这种影响并不是后效性,可以发现只有相邻的两列才会产生互相影响,而不会影响其他列。因而实际上,这是一种有条件约束、需分类讨论的递推关系,仍可以使用dp。

可以发现,第i列的每种组合情况实际上都等同于第i+1列所有初始分布情况,状态j + 1是在状态j的基础上得到的。换就话说为了得出j + 1,就要对前一列的所有状态j进行枚举,再取所有满足前面两种限制的状态j + 1

前面的两种限制可以量化为以下形式:

  1. 为了保证第i列和第i+1列不出现小方格的重叠放置,必须满足:
    $$
    (j \ & \ k) == 0
    $$

之所以这样写是因为我们的二进制表示中只会表示横向方格的第二个方格,如果当前列的状态中有方格,前一列就必须没有方格(实际表示要有空间存放第一个方格)。这一化简很重要。

  1. 为了保证最后第i-1能被纵向的小方格填满,第i列的状态j和第i+1列的状态k必须满足:

$$
(j\ |\ k)不存在连续奇数个0
$$

注意(j|k)表示的是第i-1列的选择情况而不是第i列。因此最后一列还要特判。

image-20230329212621963

朴素算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// AcWing 291. 蒙德里安的梦想——状压DP未优化做法
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 12, M = 1 << N; // 使用位运求解2^N
typedef long long LL;

LL f[N][M]; // 爆int
bool st[M];// 用于标记是否满足偶数

int main(){
int n, m;
while (cin >> n >> m, n || m){ // n=0,m=0时,表示输入终止
// 预处理出所有满足偶数的二进制表示状态
// 遍历所有可能的选择情况的二进制表示
for (int i = 0; i < 1 << n; ++ i){ // 使用位运求解2^n
int cnt = 0; // cnt表示当前这一段连续0的个数
st[i] = true;
// 2^n二进制有n位
// 预处理所有状态不存在奇数个0
// 之所以可以预处理是因为所有组合情况都通过二进制穷举出
// 这里一定要注意这种预处理思想,不是在组合前进行判断,而是在组合后进行判断;无论如何组合,都会包含在事先枚举出的所有状态中
// 遍历每个二进制表示的每一位
for (int j = 0; j < n; ++ j)
if (i >> j & 1){ // i的二进制中,从右往左数,取第j+1位上的值
// 当前这一段是1说明上一段连续0的段落已经截止
if (cnt & 1) st[i] = false;
// 判断上一段连续的0是奇数个还是偶数个
// cnt & 1取出cnt二进制的个位,当cnt为偶数时,个位为0,当cnt为奇数时,个位为1
cnt = 0; // 清空
}
else ++ cnt;
if (cnt & 1) st[i] = false; // 最后一段下方可能是边界不再存在1(小方格)
}

memset(f, 0, sizeof f); // memset 按照字节赋值

// 边界条件
f[0][0] = 1;// 第0列什么都不放,其状态为0,也不会存在小方格顶出到第1列,所以f[0][0] = 1(什么都不放也是选择方式),f[0][1~n] = 0(其余组合状态都不被选择)
// 枚举所有列
// 分割从第0列开始,但第0列是不用讨论的,只存储横向方格的第二个方格
// 因为j|k维护前一列,所以遍历到m
for (int i = 1; i <= m; ++ i)
// 枚举第i列所有状态
for (int j = 0; j < 1 << n; ++ j)
// 枚举第i-1列的所有状态
for (int k = 0; k < 1 << n; ++ k)
if ((j & k) == 0 && st[j | k])
// j & k 会取出在j和k中都为1的位,即都被选择的元素,它实际上就是两列重叠放置的情况
// j | k 会筛出在j或k中被选的元素,它实际上就是两列都放上小方格后第i-1列的组合情况,用于维护i-1列最后一定能被填满
f[i][j] += f[i - 1][k];
// 只要相邻两列的状态满足约束条件,前一列的该状态的选择数就会加在发f[i][j]上
// 这里是一种穷举方法,针对第i列的每一种状态,都枚举前一列的所有状态来判断是否可以组合在一起
// 不能有超出棋盘的方格,所以m对应的状态为全0
cout << f[m][0] << endl;
// 这里要理解f[i][j]里存的是什么,i是第i列,j是这一列的一种选择状态,f[i][j]表示在这一列处于这种选择状态时从第1列到第i列的所有选择数
// 最后一列由于涉及边界问题,不能再存放小方格,所以其状态只能是0,由此要求的答案为f[m][0]
}
return 0;
}

去除无效状态的优化写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <cstring>
#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

typedef long long LL;

const int N = 12, M = 1 << N;

int n, m;
LL f[N][M];
vector<int> state[M];
bool st[M];

int main()
{
while (cin >> n >> m, n || m)
{
for (int i = 0; i < 1 << n; i ++ )
{
int cnt = 0;
bool is_valid = true;
for (int j = 0; j < n; j ++ )
if (i >> j & 1)
{
if (cnt & 1)
{
is_valid = false;
break;
}
cnt = 0;
}
else cnt ++ ;
if (cnt & 1) is_valid = false;
st[i] = is_valid;
}

for (int i = 0; i < 1 << n; i ++ )
{
state[i].clear();
for (int j = 0; j < 1 << n; j ++ )
if ((i & j) == 0 && st[i | j])
state[i].push_back(j);
}

memset(f, 0, sizeof f);
f[0][0] = 1;
for (int i = 1; i <= m; i ++ )
for (int j = 0; j < 1 << n; j ++ )
for (auto k : state[j])
f[i][j] += f[i - 1][k];

cout << f[m][0] << endl;
}

return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def main():
# 状态是否有效
ok = [False] * (1 << 12)
# 每个状态能被转移过来的状态
state = {}

while True:
n, m = map(int, input().split())
M = 1 << n
if not n:
break
# 预处理每个状态是否合法(能否竖向插入)
for i in range(M):
cnt, valid = 0, True
for j in range(n):
if i >> j & 1:
if cnt & 1:
break
cnt = 0
else:
cnt += 1
if cnt & 1:
valid = False
ok[i] = valid
# 预处理每个状态的转移子集,求出每个状态后面能接的非法状态
for j in range(M):
state[j] = []
for k in range(M):
# 检查第 i - 2 列与第 i - 1 列是否冲突,二进制 1 表示当前行是牌板尾部
# 即状态前后表示要么是 [尾,头],要么是 [头,尾]
# 此时第 i - 1 列的最终态已确定,检查第 i - 1 列能否满足竖向插入
if not (j & k) and ok[j | k]:
state[j].append(k)
# DP 过程,前 i - 1 列安排完毕,第 i - 1 列能转移到第 i 列的方案数转移
dp = [[0] * M for _ in range(m + 1)]
dp[0][0] = 1
for i in range(1, m + 1):
for j in range(M):
for k in state[j]:
dp[i][j] += dp[i - 1][k]
# 前 m 列安排完毕,且没有牌板出界的所有方案数
print(dp[m][0])


if __name__ == "__main__":
main()

2. AcWing 91. 最短Hamilton路径

本题显然是具有最优子结构的:0经k到j的最优路径对应的0到k的路径也是最优的。因此考虑DP。

但是可以发现,路径的变化导致状态的变化。且由于这是一个无向图,行进的方向是任意的,且题目要求一个点只能遍历一次,因而结点间的顺序是相互影响的,即对应状态间是由互斥等关系的,因此就必须具体存储和讨论每个状态。又由于只用考察当前每个结点是否已经走过了,相当于组合或选择问题,可以使用二进制状态压缩。

又由于最后一个节点影响迭代,所以状态另一维度是最后到达的节点

集合:所有从0走到j,走过的所有点是i的所有路径属性:Min

集合划分:讨论倒数第二个点是0~n-1中的哪一个

这里需注意我们所关心的是经过了哪些点,最后一个点是谁,因而定义了以上的状态表示

为什么使用memset(f, 0x3f, sizeof f)来初始为极大值

为什么是 0x3f ?

0x3f中0x表示十六进制数 3f转化为二进制位0011 1111。
memset 按照字节赋值,因此我们把4个0011 1111填充到 32位的int上,此时f = 0x3f3f3f3f = 1061109567 是$10^9$级别的。

但问题是,为什么不是 0x4f 或者 0x5f ?

首先$10^9$级别足够大,其次,也是最重要的,0x3f3f3f3f + 0x3f3f3f3f 等于 0x7e7e7e7e = 2122219134,不会爆int。

在一般的电脑中,int占用4字节,32比特,数据范围为-21474836482147483647[$-2^31$$2^31-1$]。

但当用到初始化-INF,直接 memset(a, -0x3f, sizeof a) 也是可以的,无非就是0x3f取反加一嘛,从 0011 1111 变为 1100 0001。但是相加会爆,所以并不是很好。

image-20230329214550825

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 状压DP——最短Hamilton路径
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 20, M = 1 << N;

int d[N][N];
int f[M][N];

int main(){
int n;
cin >> n;
for (int i = 0; i < n; ++ i)
for (int j = 0; j < n; ++ j)
cin >> d[i][j];

memset(f, 0x3f, sizeof f); // 要求的是最小值,需要先初始化为无穷大

// 边界条件
f[1][0] = 0;// 从0走动0,距离为0(状态1表示只经过0)

// 枚举所有状态,i表示每种路径的状态
for (int i = 0; i < 1 << n; ++ i)
// j表示路径经过的最后一个点
for (int j = 0; j < n; ++ j) // 枚举每个点作为最后一个点
if (i >> j & 1)// 状态i要经过j
// k表示路径经过的倒数第二点
for (int k = 0; k < n ; ++ k) // 枚举每个点作为倒数第二个点
if (i >> k & 1) // 状态i要经过k
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + d[k][j]);
// i - (1 << j)将状态i中j这个选择置为0,即路径中去除j
// 这里利用了最后一个点的有无并不影响前面的路径,如果是中间某个点就不能随意去除了

cout << f[(1 << n) - 1][n - 1] << endl;
// 1 << n - 1恰表示所有点(二进制的每一位都是1)

return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def main():
n = int(input())
d = [list(map(int, input().split())) for _ in range(n)]
M = 1 << n
f = [[float("inf")] * n for _ in range(M)]

# 起点距离为 0
f[0][0] = 0
for i in range(M):
for j in range(n):
if i >> j & 1:
for k in range(n):
if i >> k & 1:
# 可到达点 k、j,且当前在点 k 中转到点 j 的最短路径
# f[i -(1 << j)][k]对应于挖去节点j,且最后的节点为k的最优路径值
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + d[k][j])
# 最后是全1的状态,终点为n-1
print(f[M-1][n-1])

if __name__ == "__main__":
main()

树形DP

1. AcWing 285. 没有上司的舞会

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// AcWing 285. 没有上司的舞会
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 6010;

int n;
int h[N], e[N], ne[N], idx;
int happy[N];
int f[N][2];
bool has_fa[N];

void add(int a, int b){
e[idx] = b, ne[idx] = h[a], h[a] = ++ idx;
}

void dfs(int u){
f[u][1] = happy[u];

for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
dfs(j);

f[u][1] += f[j][0];
f[u][0] += max(f[j][0], f[j][1]);
}
}

int main(){
scanf("%d", &n);

for (int i = 0; i < n - 1; ++ i) scanf("%d", &happy[i]);

memset(h, -1, sizeof h);
for (int i = 0; i < n - 1; ++ i){
int a, b;
scanf("%d%d", &a, &b);
add(b, a);
has_fa[a] = true;
}
int root = 1;
while (has_fa[root]) ++ root;

dfs(root);

printf("%d\n", max(f[root][0], f[root][1]));

return 0;
}

DP没有后效性 <==> 抽象出来的图论模型是一个拓扑图

能都进行递归的前提是图必须是拓扑图,不能出现环

记忆化搜索

1. AcWing 901. 滑雪

状态表示:集合:所有从(i,j)开始滑的路径属性:Max
集合划分:从(i,j)分别向上下左右滑

思路更好想,代码更好写,但运行会慢些,可能会爆栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 记忆化搜索——滑雪
#include <cstring>
#include <iostream>
#include <algorithm>

using namespace std;

const int N = 310;

int n, m;
int h[N][N];
int f[N][N];

int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
// 枚举上下左右四个方向的常用技巧:使用偏移量
// 使用偏移量是一种简化枚举的常用技巧,记住这种使用技巧


// 记忆化搜索——在dfs中加入存储
// dp(i, j)返回f[i][j]
int dp(int x, int y){
int &v = f[x][y];
if (v != -1) return v;
// 说明f[x][y]已经求出来了,直接返回即可
// 避免重复计算;不同于递推,递归中会出现重复问题,要进行标记
// 这里重复是因为我们在main()中会遍历所有f[i][j],而dp中会进行存储,避免重复计算

v = 1; // 至少为1
for (int i = 0; i < 4; ++ i){
int a = x + dx[i], b = y + dy[i];
if (a >= 1 && a <= n && b >= 1 &&b <= m && h[x][y] > h[a][b])// 注意边界问题
v = max(v, dp(a, b) + 1); // 注意要进行递归, dp[i][j]会返回f[i][j]
}

return v;
}

int main(){
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++ i)
for (int j = 1; j <= m; ++ j)
scanf("%d", &h[i][j]);

memset(f, -1, sizeof f);// 将所有状态都先初始化为-1,表示每个状态都没有被算过

int res = 0;
for (int i = 1; i <= n; ++ i)
for (int j = 1; j <= m; ++ j)
res = max(res, dp(i, j));

printf("%d\n", res);
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def main():
n, m = map(int, input().split())
h = [[0] * m] + [[0] + list(map(int, input().split())) for _ in range(n)]
f = [[-1] * (m + 1) for _ in range(n + 1)]
d_x = [-1, 0, 1, 0]
d_y = [0, 1, 0, -1]

def dp(x, y):
# f[x][y]已经求出来了,直接返回即可
if f[x][y] != -1:
return f[x][y]

# 注意初始化,至少为1
f[x][y] = 1
for dx, dy in zip(d_x, d_y):
nx, ny = x + dx, y + dy
if 1 <= nx <= n and 1 <= ny <= m and h[nx][ny] < h[x][y]:
f[x][y] = max(f[x][y], dp(nx, ny) + 1)
return f[x][y]

# 遍历每个状态
res = 0
for i in range(1, n + 1):
for j in range(1, m + 1):
res = max(res, dp(i, j))
print(res)

if __name__ == "__main__":
main()