NOIP(CSP-S)历史真题笔记
受老师驱使,把能力范围能看懂题解后写出来的 NOIp2017~2024 题都写了一遍。所以有空位非常正常。
NOIP 提高组
2022
T1
Tag:计数原理 + 前缀和。
首先发现 C 的方案可以通过在下面延伸得到 F。所以先只考虑 C 的情况。
题中并没有要求上下行的长度要一致,只是 C 的两个横要隔至少一行。
假设之前的行有 \(pre\) 个合法的横。考虑这一行每一个合法的横给答案的贡献。
我们可以乘法原理,这一行若有 \(k\) 个不同的合法的横,那么答案就增加了 \(pre\times k\)。处理完后要加上上一行的合法的方案数(因为他没要求两个横之间只能隔一行)。当遇到土坑的时候就令 \(pre=0\) 即可,表示没有合法状态了。
\(O(1)\) 查询每一行有多少个合法的横只需要对每一行进行后缀和(因为两个字母的横是向右延伸的)预处理就解决了。
有了 C 的答案,F 的情况就好办了。
想要求出 F 的方法数,只要求出这一列上有多少合法的 C 后,找到一个空位种上 F 的一竖就行了。
注意答案的更新顺序。
具体求法请点击看代码
int T, id, n, m;
char a[1010][1010];
ll c, f, ansc, ansf, s[1010][1010];
int main(){
int T, id; cin >> T >> id;
while(T --){
ansc = ansf = 0;
cin >> n >> m >> c >> f;
for(int i=1; i<=n; ++i){
for(int j=1; j<=m; ++j){
s[i][j] = 0;
cin >> a[i][j];
}
}
for(int i=1; i<=n; ++i){
for(int j=m-1; j; --j){
if(a[i][j] == '1'){
s[i][j] = -1;
} else if(a[i][j+1] == '0') {
s[i][j] = s[i][j+1] + 1;
}
}
}
for(int j=1; j<=m; ++j){ // 先枚举列
ll preC = 0, pre = 0;
for(int i=1; i<=n; ++i){
if(a[i][j] == '1'){
preC = pre = 0;
continue;
}
ansc = (s[i][j] * pre % mod + ansc) % mod;
ansf = (ansf + preC) % mod; // 上一行的 C + 当前行 = F
preC = (s[i][j] * pre % mod + preC) % mod; // 当前行 C 的个数
pre = (preC + max(0ll, s[i-1][j])) % mod; // 隔一行的最大横长
}
}
cout << (c * ansc) % mod << ' ' << (f * ansf) % mod << '\n';
}
return 0;
}
T2
Tag:贪心,Ad-hoc!!!!!实在太神仙、太抽象了。
I hate Ad-hoc! sto E.space orz
咕咕咕。1h 会 15 分,没救了。
T3
Tag:边双 + 缩点 + 树上计数 DP。有点。
没学过边双不要怕,其实缩点的情况跟 SCC 几乎一样。
初探
首先注意到一个性质:只有桥被“炸”才可能导致军营不连通。亦即我们可以随便(不)看守非桥边。所以不存在桥的极大子图可以被直接合并计算,而这个子图显然是一个边双。
类似于 SCC 缩点,我们也可以用边双连通分量缩成一个“点”,把无向连通图缩成一棵树。
令 \(c_u\) 表示双连通分量 \(u\) 中的点数,\(eg_u\) 表示 \(u\) 中的边数,共有 \(ecc\) 个双连通分量。
那么问题就变成了这样:
给定一棵树,设根为 \(1\)。每个点有 \(2^{eg_u}\) 种不建造军营的可能和 \((2^{c_u+eg_u} - 2^{eg_u})\) 种建军营的可能。求建造至少一个军营的方案数。
设计状态
注:如无特殊说明,以下所说的“子树”均包含根节点本身。
我们显然可直接设 \(dp(x,0)\) 表示 \(x\) 的子树不建军营的方法数;但 \(dp(x,1)\) 如果只表示 \(x\) 的子树建军营的方法数的话,考虑的东西就很多了。考虑增加合理的限制条件。
根据题目中要求军营在一条边被切断后必须互相可达,可以限制 \(dp(x,1)\) 中的军营必须可以通过看守的边到达 \(u\)。
\(dp(x,0)\) 的转移
既然我不选这棵子树,那么子树的子树显然也不选。而且这条边也可以随便看守。即
其中 \(son_i\) 表示 \(i\) 的子树构成的节点集合(不包含 \(i\))。
难点显然在下一个转移,这个转移其实会树形 dp 就会了。(bushi
\(dp(x,1)\) 的转移
以下均有 \(u\in son_{x}\)。
考虑三种情况:
- \(x\) 有了军营,但 \(u\) 没有。那么这条边可以不选。即 \(dp(x,1) = dp(x,1)\times dp(u,0)\times2\);
- \(x,u\) 都有军营,那么 \((x,u)\) 显然一定要守。\(dp(x,1) = dp(x,1)\times dp(u,1)\);
- \(x\) 没有军营,但 \(u\) 有。那么 \((x,u)\) 也要守,以通往 \(x\) 子树之外的军营。\(dp(x,1) = dp(x,0)\times dp(u,1)\)。
综上,\(dp(x,1)\) 转移如下:
显然 \(dp(x,1)\) 要先更新,这样才能保证 \(dp(x,0)\) 不会提前把子节点的情况算进去,导致算重。
边界就是每个点本身初始的答案,即 \(dp(u,0)=2^{eg_u}\) 和 \(dp(u,1) = (2^{c_u+eg_u} - 2^{eg_u})\)。
统计答案
难点 + 1。
现设当前统计到了 \(u\) 点,\(sz(i)\) 表示 \(i\) 的子树中边的数量。看似直接统计 \(\sum dp(u,1)\times2^{sz(1)-sz(u)}\)(即除去 \(x\) 的子树外任选)就可以了,实则不然。
这里需要限制 \((u,fa_u)\) 节点的父边(如果存在)必须不被守,否则这条边会被再算一次。它理应已经被记录到父亲节点的 dp 数组中。解决方法当然就是 \(-1\)。
那么,最终答案 \(ans\) 可以这样更新:
点击查看代码
const int N = 5e5 + 5;
const ll mod = 1000000007;
ll qpow(ll x, int y){
ll res = 1;
while(y){
if(y & 1) res = res * x % mod;
x = x * x % mod;
y >>= 1;
}
return res;
}
int n, m, dfn[N], low[N], tot, s[N], top, ecc, c[N], bel[N], eg[N], sz[N];
ll dp[N][2], ans;
vector<int> ee[1000001], e[1000001];
bool ins[N];
void dfs(int u, int fa){ // 不是桥的边没必要守,可以直接边双缩点
dfn[u] = low[u] = ++ tot;
ins[s[++ top] = u] = 1;
for(int v : ee[u]){
if(v == fa) continue;
if(! dfn[v]){
dfs(v, u);
low[u] = min(low[v], low[u]);
} else if(ins[v]){
low[u] = min(low[u], dfn[v]);
}
}
if(low[u] == dfn[u]){
++ ecc;
while(s[top] != u){
++ c[bel[s[top]] = ecc];
ins[s[top --]] = 0;
}
++ c[bel[u] = ecc];
ins[s[top --]] = 0;
}
}
void pre(int u, int fa){
sz[u] = eg[u];
for(int i : e[u]){
if(i != fa){
pre(i, u);
sz[u] += sz[i] + 1;
}
}
}
void work(int u, int fa){
for(int i : e[u]){
if(i == fa) continue;
work(i, u);
dp[u][1] = (dp[u][0] * dp[i][1] % mod + (dp[i][0] + dp[i][0] + dp[i][1]) % mod * dp[u][1]) % mod;
dp[u][0] = (dp[u][0] * (dp[i][0] + dp[i][0]) % mod) % mod;
}
if(u == 1){
ans = (ans + dp[1][1]) % mod;
} else {
ans = (dp[u][1] * qpow(2, sz[1] - sz[u] - 1) % mod + ans) % mod;
}
}
int main(){
cin >> n >> m;
for(int i=0, u, v; i<m; ++i){
cin >> u >> v;
ee[u].push_back(v);
ee[v].push_back(u);
}
dfs(1, 0); // 连通,直接缩就可以了
for(int i=1; i<=n; ++i){
for(int j : ee[i]){
if(bel[i] != bel[j]){
e[bel[i]].push_back(bel[j]);
} else {
++ eg[bel[j]];
}
}
}
for(int i=1; i<=ecc; ++i){
eg[i] >>= 1;
dp[i][0] = qpow(2, eg[i]);
dp[i][1] = qpow(2, eg[i] + c[i]) - dp[i][0];
}
pre(1, 0);
work(1, 0);
cout << (ans + mod) % mod;
return 0;
}
T4
部分分
- 暴力最值,在线回答,是 \(O(n^2q)\) 的。可以拿到 8 分的好成绩;
- 考虑离线,从小到大扫描右端点 \(r\)。设当前 \(r=r'\),\(x_i=\max_{j=i}^{r'} a_j\),\(y_i=\max_{j=i}^{r'} b_j\),\(s_i=\sum_{j=i}^{r'}\),那么对于一个 \((l,r')\) 的询问,答案就是 \(\sum_{i=l}^{r'}s_i\)。这个玩意是 \(O(n^2+nq)\) 的,成功砍下 20 分的高分。
最后一题拿 20 pts 固然较为优秀,但是我们不能只沉浸在短时的喜悦中!
正解(\(O((n+q)\log n)\) 线段树)
pre 中 pre
该做法必备前置技能:线段树维护区间历史和(可见 Blog 1 2)和 板子。
仍然使用扫描 \(r\) 的做法。答案可以转化成区间 \([l,r]\) 中 \(x_iy_i\) 的历史和。每次区间赋值更新最大值(可以直接从 \(a_r\) 或 \(b_r\) 能控制到的最远点为左端点更新),然后更新历史和。然后就可以离线回答询问了。
本题解不是使用矩阵乘法来辅助设计标记的。如有需要可以参见这里的 Part 5。
pre
乍一看用群论做辅助好像非常难理解,实则不然。而且我不能严谨叙述,民科中的民科。
其实有些东西说白了就是:
- 区间修改需要设计 信息 + 信息 -> 信息、标记 + 标记 -> 标记、信息 * 标记 -> 信息 三个东西;
- 线段树维护的信息至少要满足结合律,如区间最大子段和。
双半群模型
(注:此为我瞎 yy 的民科定义)
我们可以把线段树上的信息分成两种:
- 非标记:我们整体把这些信息看作一个元素,属于半群 \((D,+)\);
- 懒标记:仍然看作一个整体,它属于半群 \((M,\cdot)\)。
对于以上两个半群,有二元运算符 \(*\):\(D*M\to D\)。(对应 信息 * 标记 -> 信息)
它们的运算符满足:
- 结合律:\(\forall a\in D,b,c\in M, (a*b)*c=a*(b\cdot c)\);
- 分配律:\(\forall a,b\in D,c\in M, (a+b)*c=a*c+b*c\)。
这便形成了一个双半群模型。
应用
为了满足这个双半群模型,我们就要合理地设计 \(D,M\)。
大多数时候下传标记都是维护 \(D\) 的增量,不过也有其他的(如区间赋值)。
构造 \(D\)
先根据我们的想法设计。
看到 \(x_iy_i\),我们可以构造一个以此为最高次项的多项式形式的信息。
即 \(D=(s_{xy},s_x,s_y,h,len)\),维护了扫描线左侧的 \(\sum x_iy_i,\sum x_i,\sum y_i\) 和 \(x_iy_i\) 的历史和,以及 \(len\) 表示区间管辖范围。
构造 \(M\)
先考虑特殊的东西。我们需要区间赋值,那我们的下传标记信息至少有 \(xx,yy\) 表示对于区间 \(x\) 和 \(y\) 的赋值。
然后再套路化地设成增量的形式 \((xx,yy,c_{xy},c_x,c_y,c)\)。后四个元素表示 没有下传赋值标记 前,对子树内 \(D_h\) 贡献为 \(D_{s_{xy}}c_{xy}+D_{s_x}c_x+D_{s_y}c_y+D_{len}c\),对应 \(D\) 的 4 个信息的增量。(*)
由于加法标记依赖于还没有被覆盖的子区间信息,先赋值会打破这一依赖关系,导致加标记难以维护。所以要先算加法标记,再覆盖原值、下传。
构造 \(D+D\to D, D*M\to D\)
即 pushup 的信息合并和 pushdown 中的标记下传到子节点中的 \(D\) 对应的两个运算。
pushup 非常简单,直接对应项相加即可,不说了。
再构造 \(D*M\to D\)。我们先直接根据标记中的定义更新历史和 \(h\),再根据赋值标记 \(xx,yy\) 更新 \(s_{xy},s_x,s_y\)。
info merge(info p, const LazyTag &t, int len){ // D*M -> D
p.h += p.sxy * t.sxy + p.sx * t.sx + p.sy * t.sy + len * t.h, len;
if(t.xx && t.yy){ // 处理覆盖
p.sxy = len * t.xx * t.yy;
p.sy = len * t.yy;
p.sx = len * t.xx;
} else if(t.xx) {
p.sxy = p.sy * t.xx;
p.sx = len * t.xx;
} else if(t.yy) {
p.sxy = p.sx * t.yy;
p.sy = len * t.yy;
}
return p;
}
(由于此题中 \(len\) 较难直接在线段树上维护,这里的 \(len\) 是传参进来的,因此没有写成运算符的形式。)
构造 \(M\cdot M\to M\)
即 \(M_1\cdot M_2\to M\)。设 \(M_1\) 要作用在 \(M_2\) 上,区间长 \(len\)。
这里需要对 \(xx,yy\)(即区间赋值)的情况分类讨论。(这一块是有点难。)
- \(M_2\) 中的 \(xx,yy\) 都有值:先考虑当前 tag 对 \((M_2)_c\) 贡献,是 \(M_{1_{c_{xy}}}\times \sum x_iy_i\)。又有 \(x_i,y_i\) 分别相等,所以贡献变成 \((M_1)_{c_{xy}}\times(M_2)_{xx}\times(M_2)_{yy}\times len\),增量就是 \((M_1)_{c_{xy}}\times(M_2)_{xx}\times(M_2)_{yy}\)。其他增量也不难写出。
- 否则若 \(M_2\) 中的 \(xx\) 有值:从标记 \((M_2)_c\) 入手:
- 因为 \(x_i\) 相等,改写成 \((M_1)_{c_{xy}}\times(M_2)_{xx}\times\sum y_i\)。
- 对于 \(\sum y_i\),手动代回 tag 对历史和的贡献式子(*),即 \((M_2)_{c_y}\) 要加上 \((M_1)_{c_{xy}}\times(M_2)_{xx}\);(可能要掏出自己的笔手算)
- 考虑 tag 对历史和的贡献式子中 \(x\) 的这一项 \((M_1)_{c_x}\times\sum x_i\),直接改写成 \((M_1)_{c_x}\times(M_2)_{xx}\times len\),\((M_1)_{c_x}\times(M_2)_{xx}\) 就是 \((M_2)_c\) 的增量;
- 注意,这些式子都是增量,所以要加上原来 \(M_1\) 对应位置上的值。
- 否则若 \(M_2\) 中的 \(yy\) 有值:直接可以类比 2. 的情况。(
太长了写不下) - 否则:可以类比 \(D+D\to D\),直接对应项相加。
(求增量:显然把维护的东西忽略掉即可。)
最后,如果 \(M_1\) 上有赋值 tag,要传到 \(M_2\) 上。
LazyTag operator+(const LazyTag &t){ // t 为被作用的 Tag
LazyTag ret((xx ? xx : t.xx), (yy ? yy : t.yy), t.sxy, t.sx, t.sy, t.h);
if(t.xx && t.yy){
ret.h += sxy * t.xx * t.yy + sx * t.xx + sy * t.yy + h;
} else if(t.xx){
ret.sy += sxy * t.xx + sy;
ret.h += h + sx * t.xx;
} else if(t.yy){
ret.sx += sxy * t.yy + sx;
ret.h += h + sy * t.yy;
} else {
ret.sxy += sxy, ret.sx += sx, ret.sy += sy, ret.h += h;
}
return ret;
} // M*M -> M
然后你就可以愉快的套板子了!

浙公网安备 33010602011771号