长春集训 Day 9 - DP 专题

长春集训 Day 9 - DP 专题

A1 - Centroids

link

Description

给定一棵大小为 \(n\) 的树,如果移除某个点后,树中每个连通分量的大小均不超过 \(\dfrac n2\),那么称该点为树的重心。你可以进行最多一次的如下操作:

  • 从树中移除一条边,并插入一条新边,使新图仍为一棵树。

对于每个顶点,你需判断是否可以通过最多一次的操作使得该顶点成为树的重心。

\(2 \le n \le 4 \times 10^5\)

Solution

这种“删边 - 加边”的问题,一般考虑换根 DP。

容易发现,一次操作等价于从原树的子树中切下一块,并将其接到另一个节点上。

因此,我们可以分情况讨论:

  • 如果点 \(u\) 已经为原树的重心,那么无需操作;
  • 如果点 \(u\) 不是重心,那么根据重心的性质,有且仅有一个与 \(u\) 相邻的子树(包含 \(u\) 的上部),使其大小严格大于 \(\dfrac n2\),且需要从这个子树中切下一个尽可能大的且大小不超过 \(\dfrac n2\) 的子树,并将其直接连接到 \(u\) 上。

现在问题转化为:对于树上的每个点 \(u\),如何快速求其祖先方向和儿子方向上,可以切下的最大合法子树块的大小。

\(dp_{u,0/1}\) 表示 \(u\) 的所有儿子子树中,能够切下的最大和次大合法子树块的大小。

一遍 DFS 可以求得每个子树的 \(siz\)。对于 \(u\) 的一个儿子 \(v\)

  • 如果 \(siz_v \le \dfrac n2\),那么 \(v\) 子树整体就是一个合法块,大小为 \(siz_v\)
  • 如果 \(siz_v \gt \dfrac n2\),那么不能切下整个 \(v\) 子树,但可以切下 \(v\) 子树内的最大合法块,大小为 \(dp_{v,0}\)

上述判断逻辑提供了 \(dp\) 数组的候选值,取其最大值为 \(dp_{u,0}\),次大值(不严格)为 \(dp_{u,1}\) 即可。

同时,为了后续方便,我们需要记录:

  • \(msub_u\),表示 \(u\) 的所有子节点中,子树最大的子节点的编号。
  • \(mson_u\),表示提供 \(dp_{u,0}\) 的儿子 \(v\)

再次 DFS 进行换根,计算来自祖先方向的贡献。

具体地,我们记 \(dp_{u,2}\) 表示在以 \(u\) 为根的树中,\(u\) 的祖先方向可以移除的最大合法块的大小。

换根时,当从父节点 \(u\) 转至子节点 \(v\) 时,我们需要为 \(v\) 计算出 \(u\) 的贡献。对于 \(v\) 来说,它的父侧部分由两部分组成:

  • 原树中 \(u\) 的父侧部分;
  • \(u\)\(v\) 以外的其它子节点的子树。

考虑 \(dp_{v,2}\) 的计算。\(u\) 父侧和兄弟侧的区域大小为 \(n - siz_v\)。如果它小于等于 \(\dfrac n2\),那么整个上方区域就是一个合法块;否则,需要在上方区域中切下一个最大的合法块,而对于 \(v\) 来说,它不能使用 \(u\) 子树中属于自己的那部分,因此:

  • 如果 \(v\) 恰为 \(u\) 的最大贡献儿子(即 \(mson_u = v\)),那么 \(v\) 能参考的兄弟子树的最大合法块是次大值 \(dp_{u,1}\)

  • 否则可以参考最大值 \(dp_{u,0}\)

同时,\(u\) 还能参考父侧贡献 \(dp_{u,2}\)

综上,\(dp_{v,2}\) 需要权衡以上三种来源的最大值。

对于答案,只需考虑从子树内和子树外割边分别是否合法即可。

Code

#include <bits/stdc++.h>
// #pragma GCC optimize(2)
#define int long long
#define inf 1e18
#define debug cout << '!';
#define filein(x) freopen(#x".in", "r", stdin);
#define fileout(x) freopen(#x".out", "w", stdout);
#define file(x) filein(x) fileout(x)
using namespace std;
const int N = 4e5+5;
int n;
vector<int> g[N];
int siz[N];
int maxsub[N]; // 某节点的所有子节点中,最大子树的根的编号
int dp[N][3]; // 0-子树内最大值  1-子树内次大值(不严格)  2-子树外最大值
int maxson[N]; // 提供某节点子树内 dp0(最大值)的子节点编号
bool ans[N];
void DFS1(int u, int fa) {
	siz[u] = 1;
	dp[u][0] = dp[u][1] = 0;
	maxson[u] = maxsub[u] = -1;
	for (auto v : g[u]) {
		if (v == fa) continue;
		DFS1(v, u);
		siz[u] += siz[v];
		if (maxsub[u] == -1 or siz[v] > siz[maxsub[u]]) {
			maxsub[u] = v;
		}
		int tmp = 0;
		if (siz[v] <= n/2) {
			tmp = siz[v];
		} else {
			tmp = dp[v][0];
		}
		if (dp[u][0] < tmp) {
			dp[u][1] = dp[u][0];
			dp[u][0] = tmp;
			maxson[u] = v;
		} else if (dp[u][1] < tmp) {
			dp[u][1] = tmp;
		}
	}
}
void DFS2(int u, int fa) {
	ans[u] = 1;
	if (maxsub[u] != -1 and siz[maxsub[u]] > n/2) {
		if (siz[maxsub[u]] - dp[maxsub[u]][0] > n/2) {
			ans[u] = 0;
		}
	} else if (n - siz[u] > n/2) {
		if (n - siz[u] - dp[u][2] > n/2) {
			ans[u] = 0;
		}
	}
	for (auto v : g[u]) {
		if (v == fa) continue;
		int tmp = 0;
		if (n - siz[v] <= n/2) {
			tmp = max(tmp, n - siz[v]);
		} else {
			tmp = max(tmp, dp[u][2]);
			if (maxson[u] == v) {
				tmp = max(tmp, dp[u][1]);
			} else {
				tmp = max(tmp, dp[u][0]);
			}
		}
		dp[v][2] = tmp;
		DFS2(v, u);
	}
}
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> n;
	for (int i = 1; i < n; i++) {
		int u, v; cin >> u >> v;
		g[u].push_back(v), g[v].push_back(u);
	}
	DFS1(1, -1);
	DFS2(1, -1);
	for (int i = 1; i <= n; i++) {
		cout << ans[i] << ' ';
	}
	return 0;
}

B1 - Number of Multisets

link

Description

给定正整数 \(n,k\),求满足下列条件的多重集 \(S\) 的数量对 \(998244353\) 取模的结果:

  • \(|S| = n,\ \sum\limits_{x\in S} x = k\)
  • \(\forall x \in S, x = \dfrac 1{2^i}, i \in \mathbb{N}\)

\(1 \le k \le n \le 3000\)

Solution

注意到一个合法的多重集一定是对形如 \(\{1,1,1,\cdots\}\) 这样的集合的若干前缀乘上若干次 \(\dfrac 12\) 得到的。因此对于这样的集合,我们设 \(dp_{i,j}\) 表示考虑前 \(i\) 个元素,凑出和为 \(j\) 的方案数。

  • 加入一个 \(1\) 时,有 \(dp_{i,j} \leftarrow dp_{i-1,j-1}\)
  • 对前缀乘上 \(\dfrac 12\) 时,有 \(dp_{i,j} \leftarrow dp_{i,2j}\)

显然这是完备的。答案为 \(dp_{n,k}\)

Code

#include <bits/stdc++.h>
// #pragma GCC optimize(2)
#define int long long
#define inf 1e18
#define debug cout << '!';
#define filein(x) freopen(#x".in", "r", stdin);
#define fileout(x) freopen(#x".out", "w", stdout);
#define file(x) filein(x) fileout(x)
using namespace std;
const int N = 3005, mod = 998244353;
int n, k, dp[N][N];
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> n >> k;
	dp[0][0] = 1;
	for (int i = 1; i <= n; i++) {
		for (int j = i; j >= 0; j--) {
			dp[i][j] = dp[i-1][j-1];
			if (j*2 <= i) dp[i][j] = (dp[i][j] + dp[i][j*2]) % mod;
		}
	}
	cout << dp[n][k];
	return 0;
}

C1 - Sonya and Problem Without a Legend 加强版

原题 加强版

Description

给定长度为 \(n\) 的数组 \(a\),可以进行若干次如下操作:

  • 选定一个元素,使其 \(+1\)\(-1\)。元素在操作过程中可以为负。

试通过最少的操作次数,将数组变为严格递增的。

\(1 \le n \le 3 \times 10^5,\ 1 \le a_i \le 10^9\)

Solution

考虑一个经典的 Trick:将每个元素减去对应的下标,可以使得限制从严格递增变为单调不降。具体地,记 \(b_i = a_i - i\)。后文将在 \(b\) 数组上操作。

先考虑朴素 DP。发现段的转移跟最后一位有关,所以设 \(f_{i}(t)\)(至于为什么要这样表示,后面你就懂了)表示仅考虑前 \(i\) 位,第 \(i\) 位元素恰好等于 \(t\) 的最少操作次数。

考虑转移,为了使得前 \(i-1\) 个数单调不降且 \(b_{i-1} \le b_i = j\),前 \(i-1\) 个数的状态一定是第 \(i-1\) 个数不超过 \(t\) 的最优情况。因此定义辅助数组 \(g_i(t)\) 表示前 \(i\) 位,第 \(i\) 位元素不超过 \(t\) 的最少操作次数。形式化地,有 \(g_i(t) = \min\limits_{y\le t} f_i(y)\)

有了 \(g\) 之后,\(f\) 的转移是显然的:\(f_i(t) = g_{i-1}(t) + |t-b_i|\)

事实上有了这个转移,离散化后可以做到 \(O(n^2)\),足以通过原题,但无法通过数据加强版。考虑优化。注意到绝对值函数有凸性,想到 Slope Trick 维护斜率。

我断言,\(\forall i\)\(f_i(t)\)\(g_i(t)\) 函数均是凸函数。考虑归纳证明。

初始时,\(g_0(t)\) 为一条直线,是凸函数。假设 \(g_{i-1}\) 是凸函数。因为 \(|t-b_i|\) 则是一个典型的凸函数,两凸函数相加仍为凸函数(可以用数学方法证明,这里不展开),因此 \(f_i\) 是凸函数。因为 \(g_i(t)\) 是对 \(f_i(t)\) 取前缀 \(\min\),即将 \(f_i(t)\) 的所有斜率大于 \(0\) 的部分截掉,因此 \(g_i(t)\) 仍然是凸函数。证毕。

Slope Trick 的核心步骤是,维护一个可重集 \(S\),用于存放所有拐点。规定:在某横坐标处,斜率跳跃了多少,就在这个点放多少个拐点。因此 \(|t-b|\) 的拐点集为 \(\{b,b\}\)

于是,一个凸函数可以由拐点可重集 \(S\) 和常数 \(C\)(用于表示函数最右侧直线的截距)来描述。

当两个凸函数相加时,\(S\) 直接合并,\(C\) 直接相加。

关注状态转移方程。设 \(g_{i-1}(t)\) 的拐点集为 \(S\),常数为 \(C\)

对于转移方程,有如下两个操作:

  • 加上 \(|t-b_i|\)

    对于 \(f_i(t)\)\(S' = S \cup \{b_i,b_i\},\ C' = C\)

  • 取前缀最小值:

    去除最右边的拐点(即从 \(S'\) 中取出最大的拐点 \(M\) 并删除一个),此时函数最低点会发生移动,整体常数 \(C'\) 的变化量为 \(\Delta_i = M - b_i\)。因为对于每个 \(i\)\(\Delta_i\) 都表示当前 \(g_i\) 最小值的变化量,所以 \(\sum\limits_{i=1}^n \Delta_i\) 就表示全局最小值,即为答案。

实现方面,我们可以维护一个优先队列作为拐点集。

需要再深入理解一下 Slope Trick。

Code

#include <bits/stdc++.h>
// #pragma GCC optimize(2)
#define int long long
#define inf 1e18
#define debug cout << '!';
#define filein(x) freopen(#x".in", "r", stdin);
#define fileout(x) freopen(#x".out", "w", stdout);
#define file(x) filein(x) fileout(x)
using namespace std;
const int N = 3005;
int n, ans, b;
priority_queue<int> q;
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> n;
	for (int i = 1; i <= n; i++) {
		cin >> b; b -= i;
		q.push(b); q.push(b);
		int M = q.top(); q.pop();
		ans += M - b;
	}
	cout << ans;
	return 0;
}

D1 - Helping People

Description

初始给定长度为 \(n\) 的数列 \(a\),有 \(q\) 个线段,其中第 \(i\) 个线段为 \([l_i,r_i]\),有 \(p_i\) 的概率使线段覆盖到的数字全部加 \(1\)。保证所有线段两两不互相交错,即它们只能相离或包含。

求操作完成后数列最大值的期望

\(1 \le n \le 10^5,\ 1 \le q \le 5000,\ \epsilon \le 10^{-6}\)

Solution

首先最大值的期望不是期望的最大值。否则这题会变得非常简单。

考虑把这个期望变假。具体地,有 \(E = \sum\limits_i p_i \cdot x_i\),因此我们只需要求出概率即可。

区间不相互交错的条件让我们想到在线段上建树(不是线段树哦),从而树上 DP 出概率。具体地,我们不妨定义 \(dp_{u,i}\) 表示 \(u\) 节点对应的区间内最大值 \(\le i\) 的概率。显然可以转移,但值域太大,存不下。

考虑把值域缩紧。由于某区间最大值 \(M\) 在某次操作后一定属于 \([M,M+q]\),所以属于以上区间的 \(i\) 才能构成合法状态。因此,可以定义 \(dp_{u,i}\) 表示 \(u\) 节点对应的区间内最大值 \(\le i + M_u\) 的概率。由此,分析 \(u\) 的每个子节点 \(v\),考察它们原本的最大值,可以得到状态转移

\[dp_{u,i} \leftarrow p_u \times \prod_{v\in \mathrm{son}(u)} dp_{v,i-M_v+M_u-1} + (1-p_u) \times \prod_{v\in \mathrm{son}(u)} dp_{v,i-M_v+M_u} \]

特别地,当 \(i=0\) 时只考虑后面的部分即可。

答案为

\[\sum_{i=0}^q (dp_{1,i} - dp_{1,i-1})(i+M_1) \]

可以用 ST 表维护最大值。

Code

注意这题树上 DP 和线段建树的代码实现,比较精妙。

#include <bits/stdc++.h>
// #pragma GCC optimize(2)
#define int long long
#define inf 1e18
#define debug cout << '!';
#define filein(x) freopen(#x".in", "r", stdin);
#define fileout(x) freopen(#x".out", "w", stdout);
#define file(x) filein(x) fileout(x)
using namespace std;
const int M = 5005, N = 1e5+5;
vector<int> g[M];
int n, q, a[N];
long double ans = 0;
struct Segment {
	int l, r, maxn;
	double p;
	friend bool operator < (Segment x, Segment y) {
		if (x.l == y.l) return x.r > y.r;
		return x.l < y.l;
	}
} seg[M];
struct SparseTable {
	int f[N][30], lg[N];
	void init() {
		lg[1] = 0;
		for (int i = 2; i <= n; i++) {
			lg[i] = lg[i/2] + 1;
		}
		for (int i = 1; i <= n; i++) {
			f[i][0] = a[i];
		}
		for (int j = 1; (1<<j) <= n; j++) {
			for (int i = 1; i <= n-(1<<j)+1; i++) {
				f[i][j] = max(f[i][j-1], f[i+(1<<(j-1))][j-1]);
			}
		}
	}
	int query(int l, int r) {
		int s = lg[r-l+1];
		return max(f[l][s], f[r-(1<<s)+1][s]);
	}
} st;
double dp[M][M];
void DFS(int u, int fa) {
	dp[u][0] = 1.0 - seg[u].p;
	for (auto v : g[u]) {
		if (v == fa) continue;
		DFS(v, u);
		dp[u][0] *= dp[v][seg[u].maxn - seg[v].maxn];
	}
	for (int i = 1; i <= q; i++) {
		double p0 = 1.0, p1 = 1.0;
		for (auto v : g[u]) {
			if (v == fa) continue;
			p0 *= dp[v][min(i-seg[v].maxn+seg[u].maxn-1,q)];
			p1 *= dp[v][min(i-seg[v].maxn+seg[u].maxn,q)];
		}
		dp[u][i] = seg[u].p * p0 + (1.0-seg[u].p) * p1;
	}
}
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> n >> q;
	for (int i = 1; i <= n; i++) {
		cin >> a[i];
	}
	st.init();
	for (int i = 1; i <= q; i++) {
		cin >> seg[i].l >> seg[i].r >> seg[i].p;
		seg[i].maxn = st.query(seg[i].l, seg[i].r);
	}
	seg[++q] = {1, n, st.query(1, n), 0.0};
	sort(seg+1, seg+1+q);
	for (int i = 2; i <= q; i++) {
		for (int j = i-1; j >= 1; j--) {
			if (seg[j].l <= seg[i].l and seg[i].r <= seg[j].r) {
				g[j].push_back(i), g[i].push_back(j);
				break;
			}
		}
	}
	DFS(1, -1);
	for (int i = 0; i <= q; i++) {
		ans += (dp[1][i] - (bool)i * dp[1][i-1]) * (i + seg[1].maxn);
	}
	cout << fixed << setprecision(12) << ans;
	return 0;
}

G1 - Boss, Thirsty

Description

给定一个 \(n \times m\) 的矩阵 \(a\)。在第 \(i\) 行可以选择 \([l,r]\) 区间内的数,并要求:

  • \(i\) 天选的数中,至少有一个与第 \(i-1\) 天选的数的纵坐标相同;
  • \(i\) 天选的数中,至少有一个与第 \(i-1\) 天选的数的纵坐标不同;

求所选数字的加和最大值。

多测,\(1 \le T \le 1000,\ 1 \le n,m,nm,\sum nm \le 2 \times 10^5,\ -10^9\le a_{i,j} \le 10^9\)

Solution

朴素 DP 是容易的,但是空间会爆。考虑定义 \(dp_{i,j,0/1}\) 表示前 \(i\) 天,第 \(i\) 天选择的左 / 右端点为 \(j\) 的最大收益,可以覆盖到所有区间。

设第 \(i\) 天选择区间为 \([l,r]\),第 \(i-1\) 天选择区间为 \([l',r']\),考虑分情况讨论转移:

  1. \(l < l'\)

    如图所示:

           l'            r'
           ---------------
    l            r
    ==============
    

    考虑固定 \(l\) 枚举 \(l'\),则 \(l'\) 必须从 \(l+1\) 开始;\(r\) 则为固定左端点 \(l\),右端点 \(\gt l'\) 的区间中加和最大的区间右端点,可以前缀和预处理,具体地,我们记 \(V(l,l')\) 表示上述变量,对每一行做前缀和,则有

    \[V(l,l') = \max_{j=l'}^m(s_j-s_{l-1}) \]

    则状态转移方程为

    \[dp_{i,l,0} = \max_{l'=l+1}^m(dp_{i-1,l',0} + V(l,l')) \]

  2. \(l = l'\)

    如图所示:

    l'          r'
    -------------
    l                     r
    =======================
    

    如果固定 \(l\) 枚举 \(l'\) 的话,无法确定合法的右端点位置,因此需要从右端点转移,即

    \[dp_{i,l,0} = \max_{r'=l}^m(dp_{i-1,r',1} + V(l,r'+1)) \]

  3. \(r > r'\)

  4. \(r = r'\)

    这两种情况同型,不予列出。

考虑优化。拿第一种情况举例,可以将 \(\max\limits_{j=l'}^m(s_j-s_{l-1})\) 变形为 \(\max\limits_{j=l'}^m(s_j)-s_{l-1}\),扫一遍求前缀和的后缀最大值即可。状态转移方程同理,也可以求出后缀最大值然后再转移。余下三种情况同理,但后两种情况需要维护的是前缀和的后缀最小值。具体细节见代码。

Code

#include <bits/stdc++.h>
// #pragma GCC optimize(2)
#define int long long
#define inf 1e18
#define debug cout << '!';
#define filein(x) freopen(#x".in", "r", stdin);
#define fileout(x) freopen(#x".out", "w", stdout);
#define file(x) filein(x) fileout(x)
using namespace std;
int T, n, m;
void solve() {
	cin >> n >> m;
	vector<vector<int>> a(n+5, vector<int>(m+5));
	vector<vector<int>> dp[2];
	dp[0] = dp[1] = vector<vector<int>>(n+5, vector<int>(m+5, -inf));
	vector<int> s(m+5, 0);
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			cin >> a[i][j];
		}
	}
	for (int j = 1; j <= m; j++) {
		s[j] = s[j-1] + a[1][j];
	}
	// BORDER
	int maxn = -inf;
	for (int l = m; l >= 1; l--) {
		maxn = max(maxn, s[l]);
		dp[0][1][l] = maxn - s[l-1];
	}
	int minn = inf;
	for (int r = 1; r <= m; r++) {
		minn = min(minn, s[r-1]);
		dp[1][1][r] = s[r] - minn;
	}
	// DP
	for (int i = 2; i <= n; i++) {
		s.clear();
		for (int j = 1; j <= m; j++) {
			s[j] = s[j-1] + a[i][j];
		}
		int maxn = -inf, maxs = -inf, mins = inf;
		for (int l = m; l >= 1; l--) {
			if (l+1 <= m) maxs = max(maxs, s[l+1]);
			maxn = max(maxn, dp[0][i-1][l+1] + maxs);
			dp[0][i][l] = max(dp[0][i][l], maxn - s[l-1]);
		}
		maxn = -inf, maxs = -inf, mins = inf;
		for (int l = m; l >= 1; l--) {
			if (l+1 <= m) maxs = max(maxs, s[l+1]);
			maxn = max(maxn, dp[1][i-1][l] + maxs);
			dp[0][i][l] = max(dp[0][i][l], maxn - s[l-1]);
		}
		maxn = -inf, maxs = -inf, mins = inf;
		for (int r = 1; r <= m; r++) {
			if (r-2 >= 0) mins = min(mins, s[r-2]);
			maxn = max(maxn, dp[1][i-1][r-1] - mins);
			dp[1][i][r] = max(dp[1][i][r], maxn + s[r]);
		}
		maxn = -inf, maxs = -inf, mins = inf;
		for (int r = 1; r <= m; r++) {
			if (r-2 >= 0) mins = min(mins, s[r-2]);
			maxn = max(maxn, dp[0][i-1][r] - mins);
			dp[1][i][r] = max(dp[1][i][r], maxn + s[r]);
		}
	}
	int ans = -inf;
	for (int i = 1; i <= m; i++) {
		ans = max({ans, dp[0][n][i], dp[1][n][i]});
	}
	cout << ans << '\n';
}
signed main() {
	cin.tie(0) -> sync_with_stdio(0);
	cin >> T;
	while (T--) {
		solve();
	}
	return 0;
}
posted @ 2026-08-09 22:20  L-Coding  阅读(6)  评论(0)    收藏  举报