基础 DP 做题记录

简要题意
给定台阶数 \(n\le10^5\) 和一步至多跨越台阶数 \(k\le10^2\) ,初始在 \(0\) 级,求方案数 \(\pmod {10^5+3}\)

思路
\(f_i\) 表示走到第 \(i\) 级台阶的方案数,题意直接说明了可以从前 \(k\) 级台阶转移过来,考虑每次在以经处理好的台阶前新加一级产生的影响就是对于之后的 \(k\) 级的每一种方案都新产生了一种方案。所以有转移方程:

\[f_i=\sum_{j=i-k}^{i-1}f_j \]

最后答案就是 \(f_n\)
暴力转移即可通过,复杂度 \(O(kn)\)
也可以对 \(f\) 前缀和优化成 \(O(n)\)

$O(n)$ 前缀和优化代码
#include<bits/stdc++.h>
using namespace std;

const int maxn = 1e5 + 10, mo = 1e5 + 3;
int f[maxn], s[maxn], n, k;

int main() {
	ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
	cin >> n >> k;
	
	f[1] = 1, s[1] = 1;
	for(int i = 2; i <= n + 1; i++) {
		f[i] = (s[i - 1] - s[max(0, i - k - 1)] + mo) % mo;
		s[i] = (s[i - 1] + f[i]) % mo;
	}
	
	cout << f[n + 1];
} 

简要题意
给出 \(n\le10^2\)\(n\) 个有序身高 \(t_i\),求出最小的 \(k\) 使得除去 \(k\) 个人剩下 \(n-k\) 个身高形成先严格单增再严格单减的序列。

思路
无论是从起始点还是终止点开始考虑都很困难,再加上枚举中间点朴素转移可以达到 \(O(n^5)\) 的时间复杂度。这是不能接受的。考虑若分别求出从一点结尾的最长上升子序列和从一点开始的最长下降子序列,把两段拼起来 \(-1\) 就能得到所有中间点构成的符合题意的序列长度。其中的最大值即 \(n-k\) 的最大值,这样就求出了最小的 \(k\)
求最长上升子序列和下降子序列可以做到 \(O(n\log n)\),当然朴素的 \(O(n^2)\) 也能通过。

$O(n^2)$ 朴素dp代码
#include<bits/stdc++.h>
using namespace std;

const int maxn = 1e2 + 5;
int n, h[maxn];
int f1[maxn], f2[maxn];

int main() {
	ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
	
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> h[i];
	
	for(int i = 1; i <= n; i++){
		f1[i] = 1;
		for(int j = 1; j < i; j++) {
			if(h[j] < h[i]) f1[i] = max(f1[i], f1[j] + 1);
		}
	}
	for(int i = n; i >= 1; i--){
		f2[i] = 1;
		for(int j = i + 1; j <= n; j++) {
			if(h[j] < h[i]) f2[i] = max(f2[i], f2[j] + 1);
		}
	}
	
	int res = 0;
	for(int i = 1; i <= n; i++) res = max(res, f1[i] + f2[i] - 1);
	cout << n - res;
}

简要题意
\(k\le10^4\) 个任务,分布在时间 \(n\le10^4\) 中,给出每个任务的起始时间 \(l\) 和持续时长 \(t\) (左开右闭),要求从 \(t=1\) 开始有任务起始时必须选择一个任务做,求最长空闲时间。

思路
考虑把最长休息时间作为状态,如果正序枚举,发现选择做任务会对之后未赋值的状态产生影响,有后效性。所以倒序转移。设 \(f_i\) 表示时间 \(i\) 开始做任务的最长休息时间。如果这个时间 \(i\) 没有起始的任务,即这个时间是空闲的,那么直接由时间 \(i+1\) 转移过来;如果有起始的任务,那就在这些任务完成时间中休息时间最长的作为转移。所以有转移方程:

\[f_i=\begin{cases} \max \limits_{l_j=i}f_{i+t_j} & \exists l_j=i\\ f_{i+1}+1 & Otherwise. \end{cases} \]

最后答案是 \(f_1\)
倒序枚举状态,枚举所有任务找起始时间 \(l_j=i\)\(j\) 转移,复杂度 \(O(kn)\)
实际上可以用桶排序优化找的那一步,复杂度优化到 \(O(n+k)\)

$O(kn)$ dp代码
#include<bits/stdc++.h>
using namespace std;

const int maxn = 1e4 + 10;
int n, k;
int l[maxn], t[maxn];
int f[maxn];

int main() {
    ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
	
	cin >> n >> k;
	for(int i = 1; i <= k; i++) cin >> l[i] >> t[i];
	
	for(int i = n; i >= 1; i--) {
		bool flag = false;
		for(int j = 1; j <= k; j++) {
			if(i == l[j]) {
				flag = true;
				f[i] = max(f[i], f[i + t[j]]);
			}
		}
		if(!flag) f[i] = f[i + 1] + 1;
	}
	
	cout << f[1];
}

这个题也可以转换成图论最短(长?)路,这里不多提。

简要题意
给定 \(n\le5\times 10^3\)\(n\) 个整数 \(a_1\sim a_n\),求最长下降子序列长度和这个长度下不重复的下降子序列方案数。

思路
\(n^2\) 最长下降子序列是容易的,由于方案时刻在变化,考虑动态维护,即在找最长下降子序列时维护方案数。设 \(f_i\) 表示以 \(i\) 结尾最长下降子序列长度,\(g_i\) 表示不重复的下降子序列方案数。枚举 \(j<i\),考虑存在相同方案的必要条件是 \(f_i=f_j \wedge a_i=a_j\),我们发现这同样是充分的,如果在每次 \(f_i\) 更新结束之后。此时我们让 \(g_j\) 清空,因为 \(g_i\) 一定包含了 \(g_j\) 的一切情况;对于 \(a_j>a_i\wedge f_i=f_j+1\) 的情况,一定是这次 \(f_i\) 转移过的状态之一,所以 \(g_i\) 要加上 \(g_j\)。整理得到转移方程:

\[f_i=\begin{cases} \max \limits_{a_j>a_i}f_{j}+1 & \exists a_j>a_i\\ 1 & Otherwise. \end{cases} \]

\[g_i=\begin{cases} 0&f_i=f_j\wedge a_i=a_j \\ \sum \limits_{a_j>a_i\wedge f_i=f_j+1}g_i+g_j & \exists a_j>a_i\wedge f_i=f_j+1\\ 1 & Otherwise. \end{cases} \]

注意 \(f_i,g_i\) 的转移要在同一个外循环内,即 \(f_i,g_i\)\(i\)同步的

$O(n^2)$ dp代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;

const int maxn = 5e3 + 10;
int n, a[maxn];
int f[maxn];
ll g[maxn];

int main() {
	ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
	
	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 = 1; j < i; j++) {
			if(a[j] > a[i]) f[i] = max(f[i], f[j] + 1);
		}
		for(int j = 1; j < i; j++) {
			if(a[j] == a[i] && f[j] == f[i]) g[j] = 0;
			if(a[j] > a[i] && f[j] + 1 == f[i]) g[i] += g[j];
		}
		if(!g[i]) g[i] = 1;
	}
	
	int cnt = 1; ll ans = 0;
	for(int i = 1; i <= n; i++) cnt = max(cnt, f[i]);
	for(int i = 1; i <= n; i++) if(f[i] == cnt) ans += g[i];
	
	cout << cnt << " " << ans;
	return 0;  
}

简要题意
给定 \(n\le4\times10^4,m\le n\),和 \(n\) 个值域为 \([1,m]\cap N\) 的有序数,现将其划分为连续段,记连续段中出现不同 \(p_i\) 值的个数为 \(k\),每一段的贡献为 \(k^2\)。求划分后的最小贡献。

思路
由于具体的划分情况不重要,我们直接设 \(f_i\) 表示前 \(i\) 个数划分后的最小贡献,枚举转移过来的状态 \(f_j\),容易得到转移方程:

\[f_i=f_{j-1}+cnt(j,i)^2 \]

其中 \(cnt(j,i)\) 表示 \(p_j\)\(p_i\) 不同值的个数,可以开 \(n\) 个桶动态维护,但是这样时间空间复杂度都是 \(O(n^2)\),理论上不能通过极限数据(但是数据过水好像很多人都 \(O(n^2)\) 冲过去了?)。
还要深挖性质。注意到 \(f_i\le i\),又有 \(f_i=f_{j-1}+k^2\),所以 \(k^2<i\)。进一步的,当 \(i=n\)\(k^2<n\)。所以 \(k\) 存在一个上界 \(\sqrt n\) 。我们只用维护 \(\sqrt n\) 个桶就可以找到最小的贡献。时空复杂度 \(O(n\sqrt n)\)

$O(n\sqrt n)$ dp代码
#include<bits/stdc++.h>
using namespace std;

const int maxn = 4e4 + 10, maxsq = 2e2 + 10;
int n, m, a[maxn];
int pos[maxsq], t[maxsq][maxn], cnt[maxsq];
int f[maxn];

int main() {
	ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
	
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> a[i];
	
	for(int i = 1; i <= n; i++) {
		for(int j = 1; j * j <= n; j++){
			if(!t[j][a[i]]) cnt[j]++; t[j][a[i]]++;
			while(cnt[j] > j) {
				if(t[j][a[pos[j]]] == 1) cnt[j]--; t[j][a[pos[j]]]--;
				pos[j]++; 
			}
		}
		f[i] = f[i - 1] + 1;
		for(int j = 1; j * j <= n && pos[j]; j++) {
			f[i] = min(f[i], f[pos[j] - 1] + j * j);
		}	
	}
	
	cout << f[n];
} 

简要题意
给定 \(n\le10^2, m\le2\times10^3\)\(n\times m\) 矩阵,横行纵列分别表示不同的烹饪方法和主要食材,矩阵上每个数表示会做的不同主菜。对于不同的做菜方案有以下限制:1.每个方案至少有一道菜;2.每个烹饪方法互不相同;3.每个菜品数量为 \(k\) 的方案每种主要食材不超过 \(\lfloor\frac k2\rfloor\) 个。求方案数 \(\bmod{998244353}\)

思路
先理解题意,菜品数 \(k\) 应该不超过 \(n\) 且不小于 \(2\) 。对于每个格子的菜,选择了那么同一行的其它菜就不能选了,所以转移时同一行的菜属于同一个过程,根据加法原理可以加起来作为一个整体记为 \(sum_i\) 。发现第三个限制很棘手,菜的数量要分开考虑,每一列的状态也要分开考虑,这样的时间和空间是难以接受的。
考虑容斥,拿所有的方案数减去不合法的方案数。
满足限制一二的所有方案:对于每一行的菜品,包括不取一共有 \(sum_i+1\) 种情况,根据乘法原理有 \(\prod_{i=1}^n(sum_i+1)-1\),其中减去的 \(1\) 的是每一行都不取的情况(第一条限制)。
不合法方案:由于要违反第三限制,我们需要某些列选择的菜品数大于 \(\lfloor\frac k2\rfloor\)。然而这样的列如果存在那么有且仅会只有这一列,因为其它列的菜品数之和小于 \(\lfloor\frac k2\rfloor\)。不妨枚举单独的一列 \(t\) 选择的菜品数为 \(k'\);为了转移的方便,我们考虑前 \(i\) 行其他列选 \(j\) 个。那么我考虑对于 \(f_{i,j,k'}\) 的转移方程:首先,对于在 \(i\) 行中选一种菜,如果选第 \(t\) 列,产生的方案数为 \(f_{i-1,j,k'-1}\times a_{i,t}\);接着,对于不选 \(t\) 列的情况,产生的方案数为 \(f_{i-1,j-1,k'}\times (sum_i-a_{i,t})\);最后,如果不在第 \(i\) 行选菜,继承先前的方案数 \(f_{i-1,j,k'}\)。即有转移方程:

\[f_{i,j,k'}=f_{i-1,j,k'-1}\times a_{i,t} + f_{i-1,j-1,k'}\times (sum_i-a_{i,t}) + f_{i-1,j,k'} \]

实现时要枚举 \(t,i,j,k'\),最后在总方案数中把 \(k'>j\)\(f_{i,j,k'}\) 减掉就是合法的方案数。
复杂度达到 \(O(mn^3)\)。这对于大部分测试点来说足够,但是无法通过全部数据。

优化
考虑到 \(j,k'\) 是我们为了找到不合法方案数的指标,实际上只与它们的相对大小有关,而与其具体的值无关。不妨令 \(j'=k'-j\),再将值域整体向右平移 \(n\) 处理掉负指标。一一对应上文的三种情况,产生方案数分别有 \(f_{i-1,j'-1}\times a_{i,t}\)\(f_{i-1,j'+1}\times (sum_i - a_{i,t})\)\(f_{i-1,j'}\)。即有新的转移方程:

\[f_{i,j'}=f_{i-1,j'-1}\times a_{i,t}+f_{i-1,j'+1}\times (sum_i - a_{i,t})+f_{i-1,j'} \]

现在实现时只需要枚举 \(t,i,j'\) 三个指标,总方案数减去 \(1\le j'\le n\)\(f_{i,j'+n}\) 即为答案。
复杂度 \(O(mn^2)\)。可以通过所有数据。

$O(mn^2)$ 优化后代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll; 

const int maxn = 1e2 + 10, maxm = 2e3 + 10, mo = 998244353;
int n, m, a[maxn][maxm];
ll s = 1, sum[maxn], f[maxn][maxn << 1];

int main() {
	ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
	cin >> n >> m;
	for(int i = 1; i <= n; i++) {
		for(int j = 1; j <= m; j++) {
			cin >> a[i][j]; 
			(sum[i] += a[i][j]) %= mo;
		}
		s = 1ll * s * (sum[i] + 1) % mo;
	}
	(s += mo - 1) %= mo;
	
	for(int t = 1; t <= m; t++) {
		f[0][n] = 1;
		for(int i = 1; i <= n; i++) {
			for(int j = n - i; j <= n + i; j++) {
				f[i][j] = f[i - 1][j];
				(f[i][j] += 1ll * f[i - 1][j - 1] * a[i][t] % mo) %= mo;
				(f[i][j] += 1ll * f[i - 1][j + 1] * (sum[i] - a[i][t]) % mo) %= mo;
			}
		}
		
		for(int j = 1; j <= n; j++) {
			(s += mo - f[n][j + n]) %= mo;
		}
		memset(f, 0, sizeof f);
	}
	
	cout << s;
} 

简要题意
\(n\) 门课,第 \(i\) 门课有一门先修课 \(k_i\)\(k_i=0\) 表示没有先修课),选择一门课获得学分 \(s_i\)。现在要选择 \(m\) 门课,每门课必须先选先修课,求获得的学分的最大值。

思路
发现这些课之间依赖关系构成了森林,但是如果把 \(k_i=0\) 看作是必选的先修课,一共选 \(m+1\) 门课,那依赖关系就形成了一棵树。
考虑如果要选这棵树上的任意一门课,就要把这个节点到根路径上的所有课都选了。如果从一般树形 DP 子树的角度来看这个问题,那么在某一棵子树内选了一门课,那么子树内这个节点到子树的根路径上所有的课都要选。

不妨设 \(f_{u,i}\) 表示在以 \(u\) 为根的子树中选 \(i\) 门课获得的最多学分。这里我们钦定这些课到 \(u\) 以及这棵树的根节点路径上的课都是选好的,只是在 DP 方程里面没有体现。易得 \(f_{u,1}=s_u\)\(f_{u,0} = 0\)。下面我们来考虑根据树的结构来设计转移方程。

在依赖 \(u\) 的几个节点 \(v\) 为根的子树中,我们通过 \(dfs\) 先求出 \(v\) 子树中选不同数量的课得到的最大学分 \(f_{v,j}\)。考虑现在加入了一些子树,再加入子树 \(v\) 时怎么更新答案。类似于 \(0/1\) 背包的,我们枚举背包的容量更新最大的贡献,即:

\[f_{u,i+j}=max(f_{u,i+j},f_{u,i}+f_{u,j}) \]

\(dfs\) 时一边更新贡献一边维护子树大小 \(sz\) 即可。注意边界要符合实际意义。
时间复杂度是 \(O(nm)\)

代码实现
#include<bits/stdc++.h>
using namespace std;

const int maxn = 3e2 + 10;
int n, m, s[maxn], f[maxn][maxn];
vector<int> e[maxn];

int dfs(int u) {
	int tot = 1;
	f[u][1] = s[u];
	for(int v : e[u]) {
		int sz = dfs(v);
		for(int i = min(tot, m + 1); i; i--) {//所有合并过的子树 
			for(int j = 1; j <= sz && i + j <= m + 1; j++) { //子树v 
				f[u][i + j] = max(f[u][i + j], f[u][i] + f[v][j]);
			}
		}
		tot += sz;
	}
	return tot;
}

int main() {
	ios :: sync_with_stdio(false); cin.tie(0); cout.tie(0);
	
	cin >> n >> m;
	for(int i = 1; i <= n; i++) {
		int u; cin >> u >> s[i];
		e[u].push_back(i);
	} dfs(0);
	
	cout << f[0][m + 1];
	return 0;
}
posted @ 2025-01-20 08:56  Ydoc770  阅读(25)  评论(0)    收藏  举报