5.3 动态规划 | 数位统计dp、状态压缩dp、树形dp、记忆化搜索
这是我的一个算法网课学习记录,道阻且长,好好努力
数位统计dp
数位问题尤其需要注意分类讨论
计数问题
例题:AcWing 338. 计数问题 - AcWing(题解)
给定两个整数 a 和 b,求 a 和 b 之间的所有数字中0~9的出现次数。
例如,a=1024,b=1032,则 a 和 b 之间共有9个数如下:
1024 1025 1026 1027 1028 1029 1030 1031 1032
其中‘0’出现10次,‘1’出现10次,‘2’出现7次,‘3’出现3次等等…
思路整理:
AcWing 338. 计数问题—基础课的思路+基础课代码完整注释 - AcWing
在讨论计数的时候需要分清楚,计数得分分为计0的数
与 不计0的数
然后分为下面这样的一些情况进行讨论
(第二个分类中的2.1情况是不用写的)
最后是用前缀和的思想求出答案
关于ios::sync_with_stdio(false);和cin.tie(0);cout.tie(0);_绀香零八的博客-优快云博客 (关于这个不太了解的东西)
Ans
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
// 获取num中以l下标对应数字开头,r结尾组成的数是多少(vector转换为int数值)
int get(vector<int> num, int l, int r)
{
int res = 0;
for (int i = l; i >= r; i -- ) res = res * 10 + num[i];
return res;
}
// 求10的x次方
int pow(int u)
{
int res = 1;
while (u -- ) res *= 10;
return res;
}
// 计数1~n中,x出现的次数
int sum(int n, int x)
{
if (!n) return 0; // 排除n为0的情况(根据题意)
vector<int> num; // num数组用于倒序存储数字每一位上的数是多少
// 存入num数组
while (n)
{
num.push_back(n % 10);
n /= 10;
}
// 注意 更新n 此时n表示原来的n有多少位
n = num.size();
int res = 0;
for (int i = n - 1 - !x; i >= 0; i -- ) // 当x==0时,需要从第二位开始枚举,因为0不能在首位(!x)
{
// case 1
if (i < n - 1) // 当x!=0时,枚举最高位时,这种情况是不存在的
{
res += get(num, n - 1, i + 1) * pow(i);
if (!x) res -= pow(i); // 如果x==0需要减去0在首位的计数
}
// case 2
if (num[i] == x) res += get(num, i - 1, 0) + 1; // d == x
else if (num[i] > x) res += pow(i); // d > x
}
return res;
}
int main()
{
ios::sync_with_stdio(0); // 是否兼容stdio
cin.tie(0), cout.tie(0); // 解除cin与cout的绑定
int a, b;
while (cin >> a >> b, a)
{
if (a > b) swap(a, b); // 保证读入顺序始终是a<b
for (int i = 0; i <= 9; i ++ )
cout << sum(b, i) - sum(a - 1, i) << ' '; // 前缀和的思想
cout << endl;
}
return 0;
}
状态压缩dp
状态压缩指的是用二进制数和位运算来储存或者修改一些状态。
蒙德里安的梦想
例题:AcWing 291. 蒙德里安的梦想
求把 N×M 的棋盘分割成若干个 1×2 的长方形,有多少种方案。
例如当 N=2,M=4 时,共有 55 种方案。当 N=2,M=3 时,共有 3 种方案。
如下图所示:
思路大概是 棋盘问题+数据范围
把整个棋盘划分为若干个1*1
的格子,然后用1*2
的小长方形去将整个棋盘填充。当横向的小长方形的摆放方式唯一确定之后,总的方案数也就随之确定。(用竖着放的也是一个道理)
1、状态表示: f[i][j]
,i
表示当前在第 i
列,j
表示从 i - 1
列中伸到第 i
列(因为是1*2
的方格)的方格的状态,这个状态可以用二进制数来表示。j
状态位等于1表示上一列有横放格子,即本列有格子捅出来。
2、状态计算:
if (0 <= k <(1<<n)) f[i][j] += f[i-1][k]
本列的每一个状态都由上列所有“合法”状态转移过来
转移条件:
1、在第 i
列放方格的位置上不能有 i-1
列伸出的方格,即 (j & k) == 0
2、因为求的方格为横着摆放的方格种数,因此在计算时还要考虑当第i列摆放完后,第i
列中不能存在连续的奇数个空格,即 j | k
中不存在连续奇数个0,这一个条件我们可以通过预处理来得到。
初始化条件:
1、f[0][0] = 1
,第0列只能是状态0,无任何格子捅出来。
2、返回值为 f[m][0]
。第m + 1
列不能有东西捅出来。
Ans
#include <cstring>
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
typedef long long LL;
const int N = 12, M = 1 << N; // M的每一位二进制位储存一个状态
int n, m;
LL f[N][M];
bool st[M]; // 记录第i种情况是否符合不存在连续奇数个0
int main()
{
while (cin >> n >> m, n || m)
{
memset(f, 0, sizeof f); // 初始化f为0
// 对每一列进行预处理 遍历所有情况将含有连续奇数个0的可能剔除
for (int i = 0; i < 1 << n; i ++ )
{
int cnt = 0; // 初始化计数器为0 用于记录当前这一列0的个数
st[i] = true; // 记录这个状态被枚举过且可行
for (int j = 0; j < n; j ++ )
if (i >> j & 1) // 如果i的二进制第j位为1(>>优先级更高)
{
if (cnt & 1) st[i] = false; // 判断是否是连续奇数个0
// cnt = 0;
}
else cnt ++ ;
if (cnt & 1) st[i] = false; // 在末尾再判断一下 最后一个1下面的0 是否为奇数
}
f[0][0] = 1; // 第一列什么都没放
for (int i = 1; i <= m; i ++ ) // 遍历每一列
for (int j = 0; j < 1 << n; j ++ ) // 枚举第i列的每一个状态
for (int k = 0; k < 1 << n; k ++ ) // 枚举第i-1列的每一个状态 寻找可以与第i列进行转换的状态
if ((j & k) == 0 && st[j | k]) // i上不能有i-1伸出的方格 且合并这两种情况不存在连续奇数个0
f[i][j] += f[i - 1][k]; // 这种状态下它的方案数等于之前每种k状态数目的和
cout << f[m][0] << endl;
}
return 0;
}
最短Hamilton路径
例题:AcWing 91. 最短Hamilton路径
给定一张 n 个点的带权无向图,点从 0~n-1 标号,求起点 0 到终点 n-1 的最短Hamilton路径。 Hamilton路径的定义是从 0 到 n-1 不重不漏地经过每个点恰好一次。
这也是一个dp状态压缩问题。用二进制上的数字来表示一个点的状态,然后通过位运算来进行一系列的更改、判断操作。
对于状态转移方程:f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);
其含义就是取到达终点j之前的这些点,也就是这些所有可能的状态,取其最小值。
Ans
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 20, M = 1 << N;
int n;
int w[N][N]; // w用于存放和表征无权图
int f[M][N]; // f[i][j] i是二进制数 用于记录走过的所有的点 j表示终点
int main()
{
// 输入数据
cin >> n;
for (int i = 0; i < n; i ++ )
for (int j = 0; j < n; j ++ )
cin >> w[i][j];
memset(f, 0x3f, sizeof f); // 求属性最小值,初始化为无穷大
f[1][0] = 0; // 0是起点 对起点初始化
for (int i = 0; i < 1 << n; i ++ ) // 遍历所有的情况
for (int j = 1; j < n; j ++ ) // 遍历走到哪个点
if (i >> j & 1) // 如果走到j这个点
for (int k = 0; k < n; k ++ ) // k表示走到j这个点之前以k为终点的最短距离
if ((i - (1 << j)) >> k & 1)
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]); // 状态转移方程更新最短距离
cout << f[(1 << n) - 1][n - 1] << endl; // 输出所有点都走过了 且最终点是n-1的最短距离
// 注意 << 优先级低于 + 和 -; 可能需要加()
return 0;
}
树形dp
树形dp,用递归实现,遍历子节点。
没有上司的舞会
例题:AcWing 285. 没有上司的舞会
Ural大学有N名职员,编号为1~N。
他们的关系就像一棵以校长为根的树,父节点就是子节点的直接上司。
每个职员有一个快乐指数,用整数 Hi 给出,其中 1≤i≤N。
现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。
在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。
思路是
将状态分成了两种情况:一种是选择该点(上司),则其子节点(下属)不选择(不参加;而另一种是不选择该点(上司),则子节点(下属)可选择(参加)也可以不选择。
状态转移方程表示为:f[u][0] += max(f[j][0], f[j][1]);
f[u][1] += f[j][0];
Ans
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 6010;
int n;
int happy[N]; //每个职工的高兴度
int h[N], e[N], ne[N], idx; // 使用邻接表存树
int f[N][2]; // 第一个参数表示以u为根的子树的方案 第二个参数表示是否选这个点
bool has_father[N]; // 判断当前节点是否有父节点
void add(int a, int b) // 把a插入树中
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs(int u)
{
f[u][1] = happy[u]; // 若选择u节点 则先加上这个节点的高兴度
for (int i = h[u]; i != -1; i = ne[i]) // 递归 遍历树
{
int j = e[i];
dfs(j); // 回溯
// 状态转移方程
f[u][0] += max(f[j][0], f[j][1]);
f[u][1] += f[j][0];
}
}
int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; i ++ ) scanf("%d", &happy[i]);
memset(h, -1, sizeof h); // 初始化数组f中元素为-1 (因为找的是最大值)
for (int i = 0; i < n; i ++ )
{
int a, b;
scanf("%d%d", &a, &b); // 输入关系 b是a的上司
has_father[a] = true;
add(b, a);
}
int root = 1; // 用于寻找根节点
while (has_father[root]) root ++ ;
dfs(root); // 从根节点深度优先搜索
printf("%d\n", max(f[root][0], f[root][1])); // 输出选和不选的方案的最大值
return 0;
}
记忆化搜索
记忆化搜索本质其实是动态规划,但是实现方式采用的是深度优先的形式,其独特之处在于并不需要像深度优先搜索一样重复枚举所有的情况,而是将已经计算过的子问题保存下来。
滑雪
例题:AcWing 901 滑雪
给定一个R行C列的矩阵,表示一个矩形网格滑雪场。
矩阵中第 i 行第 j 列的点表示滑雪场的第 i 行第 j 列区域的高度。
一个人从滑雪场中的某个区域内出发,每次可以向上下左右任意一个方向滑动一个单位距离。
当然,一个人能够滑动到某相邻区域的前提是该区域的高度低于自己目前所在区域的高度。
下面给出一个矩阵作为例子:
1 2 3 4 5
16 17 18 19 6
15 24 25 20 7
14 23 22 21 8
13 12 11 10 9
在给定矩阵中,一条可行的滑行轨迹为24-17-2-1。
在给定矩阵中,最长的滑行轨迹为25-24-23-…-3-2-1,沿途共经过25个区域。
现在给定你一个二维矩阵表示滑雪场各区域的高度,请你找出在该滑雪场中能够完成的最长滑雪轨迹,并输出其长度(可经过最大区域数)。
思路是 记忆化搜索
注意,对于这个题目,输入一定要是拓扑图(不能有环),否则就会一直在环上转。
f[i][j]
表示从(i, j)
出发能够完成的最长滑雪轨迹的长度。由题意可知,如果当前点未移动到边界,则可以从当前点向上下左右四个点中高度低于当前点的位置移动,直到移动(搜索)到某个点高度小于四周的点而不能移动为止。状态转移方程可以表示为f[x][y] = max(f[x][y], f[x + dx[i]][y + dy[i]] + 1)
。
Ans
#include <iostream>
#include <cstring>
#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};
// int d[] = {1, 0, -1, 0, 1};
// 更巧妙的做法 两两组队 即可构成四个方向向量
int dp(int x, int y)
{
int &v = f[x][y]; // c++特性 引用 (v等价于f[x][y])
if (v != -1) return v;
v = 1; // 初始化 至少会走当前点
for (int i = 0; i < 4; i ++ )
{
int a = x + dx[i], b = y + dy[i];
if (a >= 1 && a <= n && b <= m && b >= 1 && h[a][b] < h[x][y]) // 如果未越界且高度低于当前点
v = max(v, dp(a, b) + 1); // 更新路径长度max
}
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", res);
return 0;
}
addition
在写代码的时候其实不仅仅要考虑时间复杂度和空间复杂度,还有代码复杂度也是一个很重要的考虑的点。
在满足时间复杂度与空间复杂度的条件下,应该选取代码复杂度较低的答案。