山东暑假集训2025 II

Day 4 ~ Day 7

Day 4

依旧是 DP,DP一整天。

树形DP

mzh:什么是树形DP?字面意思。

来看一道题目:
P2458 [SDOI2006] 保安站岗。我们考虑定义一个状态 \(dp_{u, 0/1/2}\),分别表示 \(u\) 被自己覆盖、\(u\) 被父亲覆盖、\(u\) 被儿子覆盖。
现在有了智慧的状态之后,我们考虑一下状态转移方程:

\[dp_{u, 0} = \sum\min (dp_{v, 0}, dp_{v, 1}, dp_{v, 2}) + c_u \]

\[dp_{u, 2} = \sum \min(dp_{v, 0}, dp_{v, 1}) \]

\[dp_{u, 1} = \sum \min(dp_{v, 0}, dp_{v, 1}) \]

对于 \(dp_{u, 1}\),如果选择的全是 \(dp_{v, 1}\),那么一定加上 \(\min(dp_{v, 0} - dp{v, 1})\)

再看一道:
CF543D Road Improvement
我们设 \(dp_u\) 表示以 \(u\) 为根时的总方案数,我们会发现:

\[dp_u = \prod (dp_v + 1) \]

然后我们考虑如何换根。
我们会发现,实际上和答案有关的 \(dp_v\)\(dp_u\) 的值有关。
我们找找有啥关系:设 \(f_u\) 表示换完根之后的贡献,我们会发现通过前缀积和后P1896 [SCOI2005] 互不侵犯缀积可以推出,再根据我们刚才得到的公式即可求解。

看一个不一般的DP:
P2014 [CTSC1997] 选课要是CTSC的题都这么简单那我岂不是 NOI Au 了
我们设一个状态,\(dp_{u, i}\) 表示节点 \(u\) 选择了 \(i\) 门课程的答案。
我们可以发现,\(u\) 的子节点只能选择 \(i - 1\) 门课程,因为 \(u\) 是前导课,所以我们必须学习,所以对于这些进行枚举再转移即可。

P3354 [IOI 2005] Riv 河流,由于笔者没太听懂,先待补。

状压DP

先前笔者再给学弟们讲课时,讲到某人出的一道状压DP(虽然他到现在也在狂呼:那是 DFS + 打表),我就说了一句:状压DP的精髓在于状压。那什么是状压呢?就是把状态压缩一下。

来看一道状压DP:
P1896 [SCOI2005] 互不侵犯
这是一道经典题目,我们考虑DP:
\(dp_{i, j, k}\) 表示第 \(i\) 行状态为 \(j\) 并且一共放了 \(k\) 个国王的答案。我们考虑转移:
假设 \(dp_{i - 1, l, r}\) 为一个合法状态,且 \(j\)\(l\) 也是合法的,那么我们可以轻松得到:
\(dp_{i, j, k + r}\) 的答案。以此类推。

计数DP

我们来看一道:
P10982 Connected Graph。我们发扬人类智慧,开始分析。
我们设 \(dp_i\) 表示 \(i\) 个点的最终方案数,那么 \(dp_n\) 自然就是答案。
考虑容斥原理,我们的答案就是所有的减掉不合法的。
那么所有的方案有多少个呢?答案是:\(2^\binom{n}{2}\)
我们再考虑不合法情况。
我们单独考虑一下 \(1\) 号节点,当且仅当 \(1\) 所在的连通块和剩下的点没有连边的时候是不合法的。我们计算这种情况下的方案数:\(\sum_{j = 1}^{i - 1}dp_j \times \binom{i - 1}{j - 1} \times 2^\binom{i - j}{2}\)。我们根据公式递推即可。
下一道题:
CF559C Gerald and Giant Chess
我们考虑容斥原理(lzy:咋又是容斥啊),对于每一个黑色点,我们考虑从 \((1, 1)\)\((n, m)\) 且必须经过这个点的所有方案数。这个是可以根据组合数学计算出来的。没了。
真的没了吗?
这种计算方法会算重!
我们思考一下,对于一个黑色点 \((a, b)\) 和一个黑色点 \((c, d)\),假设 \(a \le c\)\(b \le d\),那么会不会存在一条路径既经过 \((a, b)\) 又经过 \((c, d)\)?那是有可能吗,那是一定!
这样的话我们就把这条不合法路径计算了两遍,这是不可取的。我们不妨钦定 \((a, b)\) 为某些路径会经过的第一个黑色点,那么从 \((1, 1)\)\((a, b)\) 的这些路径上肯定没有其他的黑色点。
是不是感觉有点熟悉?
这不是子问题吗!
所以,我们可以考虑递归得到答案。
没了,这回可是真没了。

Day 5

树上问题

yx:LCA 大家都会,那就来点难度。
紫题......

树剖

什么是树剖?简单理解:别人都是啃老,只有树剖啃小。
考虑对于一个节点 \(u\),它的 \(siz\) 最大的子节点记为 \(v\),那么 \(v\) 就是 \(u\) 的重儿子,别的都是轻儿子。
那么,\(u\)\(v\) 的这条边就被称为重链。
好了,树剖就没了。
那么树剖可以拿来干啥?
首先,我们可以通过树剖求解 LCA。我们每一次都走 \(u\) 的重链,直接跳到首个节点的父亲节点,然后反复,直到走到同一个节点即可。
其次,我们可以通过搭配一些数据结构(万恶的线段树),来进行一些匹配操作。下面来一道树剖的例题:
P3384 【模板】重链剖分/树链剖分,我们得用某种数据结构进行优化。
考虑树状数组,通过区间加和和区间查询操作可以轻松维护 \(1\)\(2\)
再通过树剖本身的求解公共路径的方式,可以维护 \(3\)\(4\)。这样我们就可以得到答案。下面放代码。

#include <bits/stdc++.h>
using namespace std;
#define int long long

const int M = 1e5 + 10;
int n, m, rt, mod, idx;
int siz[M], dep[M], son[M], fa[M];
int id[M], top[M], a[M];
vector < int > G[M];
int tree1[M], tree2[M];

int lowbit(int x) {
	return x & -x;
}

void add1(int pos, int x) {
	int wantadd = (pos - 1) * x % mod;
	while (pos <= n) {
		tree1[pos] = (tree1[pos] + x) % mod;
		tree2[pos] = (tree2[pos] + wantadd) % mod;
		pos += lowbit(pos);
	}
}

void add2(int pos, int x) {
	int wantadd = pos * x % mod;
	int p = pos + 1;
	while (p <= n) {
		tree1[p] = (tree1[p] - x + mod) % mod;
		tree2[p] = (tree2[p] - wantadd + mod) % mod;
		p += lowbit(p);
	} 
}

int ask(int pos) {
	int ans = 0;
	int p = pos;
	while (p) {
		ans = (ans + pos * tree1[p] % mod) % mod;
		ans = (ans - tree2[p] + mod) % mod;
		p -= lowbit(p);
	}
	return ans;
}

int query(int l, int r) {
	return (ask(r) - ask(l - 1) + mod) % mod;
}

void dfs1(int u, int father) {
	fa[u] = father; siz[u] = 1;
	dep[u] = dep[father] + 1;
	for (auto v : G[u]) {
		if (v == father) continue ;
		dfs1(v, u);
		siz[u] += siz[v];
		if (siz[v] > siz[son[u]]) 
			son[u] = v;
	}
} 

void dfs2(int u, int pre) {
	top[u] = pre;
	id[u] = ++ idx;
	add1(id[u], a[u]);
	add2(id[u], a[u]);
	if (!son[u]) return ;
	dfs2(son[u], pre);
	for (auto v : G[u]) {
		if (v == fa[u]) continue ;
		if (v == son[u]) continue ;
		dfs2(v, v);
	}
}

int lca(int u, int v) {
	int ans = 0;
	while (top[u] != top[v]) {
		if (dep[top[u]] < dep[top[v]]) 
			swap(u, v);		
		ans = (ans + query(id[top[u]], id[u])) % mod;
		u = fa[top[u]];
	}
	if (dep[u] > dep[v]) swap(u, v);
	ans = (ans + query(id[u], id[v])) % mod;
	return ans; 
}

void add3(int u, int v, int x) {
	while (top[u] != top[v]) {
		if (dep[top[u]] < dep[top[v]]) swap(u, v);
		add1(id[top[u]], x);
		add2(id[u], x);
		u = fa[top[u]];
	}
	if (dep[u] > dep[v]) swap(u, v);
	add1(id[u], x);
	add2(id[v], x);
}

int qson(int u) {
	return query(id[u], id[u] + siz[u] - 1);
}

void add4(int u, int x) {
	add1(id[u], x);
	add2(id[u] + siz[u] - 1, x);
}

signed main() {
	
	cin >> n >> m >> rt >> mod;
	for (int i = 1; i <= n; i ++) 
		cin >> a[i];
	for (int i = 1; i < n; i ++) {
		int u, v;
		cin >> u >> v;
		G[u].push_back(v);
		G[v].push_back(u); 
	}
	dfs1(rt, 0);
	dfs2(rt, rt);
	for (int t = 1; t <= m; t ++) {
		int opt, x, y, z;
		cin >> opt;
		if (opt == 1) {
			cin >> x >> y >> z;
			add3(x, y, z);
		} else if (opt == 2) {
			cin >> x >> y;
			cout << lca(x, y) << "\n";
		} else if (opt == 3) {
			cin >> x >> z;
			add4(x, z);
		} else {
			cin >> x;
			cout << qson(x) << "\n";
		}
	}
	
	return 0;
} 

我们也可以考虑使用线段树进行优化,但是笔者更擅长树状数组,故仅放树状数组。

Tarjan

讲个笑话:tarjan 的名字笔者一次也没一次性拼对

我们需要明确 tarjan 算法是来干啥的。很简单那,求解联通分量的。
首先明确啥事连通分量:
对于一个有向图 \((V, E)\),若所有的 \(u\) 都能到达任意的 \(v\),那么我们称这个图是强连通。如果一些点组成的点集是强联通的,那么这个点集被称为连通分量。
这里补一个概念,如果把有向图 \((V, E)\) 化成无向图之后是一个联通的,那么这就叫做弱连通分量。

如何使用 tarjan 算法?
我们维护节点 \(u\) 的两个性质:\(dfn_u\)\(low_u\)\(id_u\) 表示 \(u\) 号节点的欧拉顺序。我们同时维护一个栈,栈里的每一个元素表示 \(u\) 能够到达的节点。如果 \(dfn_u = low_u\),就说明我们现在得到了一个连通分量。
说的抽象,不如看看代码:

#include <bits/stdc++.h>
using namespace std;

const int M = 1e5 + 10;
int n, m, cnt, scc;
int a[M], b[M], dp[M], ind[M], dfn[M], low[M], bel[M];
bool vis[M];
stack < int > stk;
vector < int > g[M], G[M];

void tarjan(int u) {
	dfn[u] = low[u] = ++ cnt;
	stk.push(u), vis[u] = 1;
	for (auto v : G[u]) {
		if (!dfn[v]) {
			tarjan(v);
			low[u] = min(low[u], low[v]);
		} else if (vis[v]) {
			low[u] = min(low[u], dfn[v]);
		}
	}
	if (dfn[u] == low[u]) {
		scc ++;
		while (!stk.empty() && stk.top() != u) {
			int x = stk.top();
			vis[x] = 0;
			bel[x] = scc;
			stk.pop();
		}
		vis[u] = 0; stk.pop(); bel[u] = scc;
	}
}

int main() {
	
	cin >> n >> m;
	for (int i = 1; i <= n; i ++) 
		cin >> a[i];
	for (int i = 1; i <= m; i ++) {
		int u, v;
		cin >> u >> v;
		G[u].push_back(v);
	} 
	for (int i = 1; i <= n; i ++) 
		if (!dfn[i]) tarjan(i);
	for (int u = 0; u <= n; u ++) {
		for (auto v : G[u]) {
			if (bel[v] == bel[u]) continue ;
			ind[bel[v]] ++;
			g[bel[u]].push_back(bel[v]);
		}
	}
	for (int i = 1; i <= n; i ++) 
		b[bel[i]] += a[i];
	queue < int > q;
	for (int i = 1; i <= scc; i ++) 
		if (!ind[i]) 
			q.push(i), dp[i] = b[i];
	while (!q.empty()) {
		int u = q.front(); q.pop();
		for (auto v : g[u]) {
			dp[v] = max(dp[v], dp[u] + b[v]);
			ind[v] --;
			if (!ind[v]) 
				q.push(v);
		}
	}
	int ans = 0;
	for (int i = 1; i <= scc; i ++) 
		ans = max(ans, dp[i]);
	cout << ans << "\n";
	
	return 0;
}

这其实是P3387的代码,简单解释一下,tarjan 求所用的连通分量,那么我们留着它们其实没啥用,不如把他们看成一个点,然后把这些合并之后的点都找出一个拓扑序列,然后在上面跑一个很简单的 DP。没了。
剩下的题目大同小异,tarjan 变化不大,只是 main 函数有一定的区别,主要就是注意统计的方式。

最短路

关于 SPFA,它死了,死因:被卡到 \(O(nm)\)
关于 Dijkstra,它死了,死因:无法处理负边权;
关于 Ford,它死了,死因:复杂度是 \(O(n^2m)\)
关于 Folyd,它死了,死因:复杂度 \(O(n^3)\)
关于 Johnson,它死了,死因:复杂度 \(O(nm + nm \log m)\)
那请问还有谁活着?

好的,这里笔者默认所有的读者学过刚才说的前四个算法,直接讲解第五个。
我们考虑先使用 spfa 算法进行负环判断,由于 Johnson 求的是全源最短路,我们在判断负环的同时也求出了某个超级源点(一般是 \(0\) 号点)到各个点的距离,然后我们得到一个公式:$$h_v \le h_u + w$$
我们来思考一下,这个其实很显然的,因为假设不满足这个不等式,那么 \(v\) 也可以被再一次松弛。
我们考虑把原来的边权转化回去,得到了 \(h_u + w - h_v\),我们发现这个是恒正的,所以我们可以跑每个点的 dijkstra。
思路比较巧妙,但是很可惜不在 CCF 考纲里。

然后还有啥线段树优化建图,笔者就听懂了一点,简单介绍一下:
如果 \(u\) 要向 \([l, r]\) 之间的每一个点连边的话,我们不妨使用线段树,把 \(u\) 向在线段树里的对应区间连边,同时线段树的父亲节点向它的子区间也是连一条边。如果反着的话,那就反向建边即可。

Day 6

图论+数论,还让不让人活啊

欧拉回路

怎么又是欧拉

我们来看看一些基础概念:
欧拉路径:经过连通图中所有边恰好一次的路径;
欧拉回路:经过连通图中所有边恰好一次的回路;
欧拉图:有欧拉回路的图(好像是废话);
半欧拉图:有欧拉路径但没有欧拉回路的图(废话)。

咋判定呢?
很简单的,就是小学的一笔画问题:
对于有向图,每个点的入度等于出度;
对于无向图,每个点的度都是偶数或者有且仅有两个的度是奇数。

我们来看一道题目:
P1127,我们对于每一个单词的首尾字母相接,然后就形成了一个图,再在图上进行判断即可。

在看一道好玩的题目,我们对于每一个点,每一次经过它就改变它的状态,我们使用 \(0/1\) 来标记,最后再跑一边判欧拉图即可。

图论到此结束。

数论

现在才知道,zxy老师讲的是真好。

由于五一学的东西很多,基本上全部讲过,具体可见我的同学的博客

posted @ 2025-07-17 19:49  Bob1108  阅读(4)  评论(0)    收藏  举报