山东暑假集训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, 1}\),如果选择的全是 \(dp_{v, 1}\),那么一定加上 \(\min(dp_{v, 0} - dp{v, 1})\)。
再看一道:
CF543D Road Improvement。
我们设 \(dp_u\) 表示以 \(u\) 为根时的总方案数,我们会发现:
然后我们考虑如何换根。
我们会发现,实际上和答案有关的 \(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老师讲的是真好。
由于五一学的东西很多,基本上全部讲过,具体可见我的同学的博客。
浙公网安备 33010602011771号