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)\) 的转移

既然我不选这棵子树,那么子树的子树显然也不选。而且这条边也可以随便看守。即

\[dp(x,0)\gets\sum_{u\in son_x}dp(u,0)\times dp(x,0)\times2 \]

其中 \(son_i\) 表示 \(i\) 的子树构成的节点集合(不包含 \(i\))。

难点显然在下一个转移,这个转移其实会树形 dp 就会了。(bushi

\(dp(x,1)\) 的转移

以下均有 \(u\in son_{x}\)

考虑三种情况:

  1. \(x\) 有了军营,但 \(u\) 没有。那么这条边可以不选。即 \(dp(x,1) = dp(x,1)\times dp(u,0)\times2\)
  2. \(x,u\) 都有军营,那么 \((x,u)\) 显然一定要守。\(dp(x,1) = dp(x,1)\times dp(u,1)\)
  3. \(x\) 没有军营,但 \(u\) 有。那么 \((x,u)\) 也要守,以通往 \(x\) 子树之外的军营。\(dp(x,1) = dp(x,0)\times dp(u,1)\)

综上,\(dp(x,1)\) 转移如下:

\[dp(x,1) = \sum dp(x,1)\times(2dp(u,0) + dp(u,1)) + dp(x,0)\times dp(u,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\) 可以这样更新:

\[ans \gets ans + 2^{子树中边的个数-1} = \begin{cases} ans + dp_{u,1} & u = root \\ ans + 2^{sz(root) - sz(u) - 1}\times dp_{u,1} & \text{otherwise.} \end{cases}\]

点击查看代码
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\)(即区间赋值)的情况分类讨论。(这一块是有点难。)

  1. \(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}\)。其他增量也不难写出。
  2. 否则若 \(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\) 对应位置上的值。
  3. 否则若 \(M_2\) 中的 \(yy\) 有值:直接可以类比 2. 的情况。(太长了写不下
  4. 否则:可以类比 \(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

然后你就可以愉快的套板子了!

Code.

CSP-S

posted @ 2025-08-23 00:21  RedAccepted  阅读(32)  评论(0)    收藏  举报