AtCoder Beginner Contest 450 复盘笔记

比赛链接:AtCoder Beginner Contest 450
原始代码:7.24--450 目录
整理内容:题面、我的代码、代码注释、AI 题解/对话与线段树补课资料

总结

题目核心知识点本次情况复杂度
A - 3,2,1,GO模拟、格式控制独立 ACO(N)O(N)
B - Split Ticketing三点枚举、上三角矩阵独立 AC;曾遇到 vector 越界O(N3)O(N^3)
C - Puddles网格建模、并查集、虚拟边界点独立 AC;路径压缩和特殊根的复盘O(HWα(HW))O(HW\alpha(HW))
D - Minimize Range排序、同余类、贪心独立 ACO(Nlog⁡N)O(N\log N)
E - Fibonacci String前缀计数、递推状态、Fibonacci 分解有思路;看题解后独立默写$O(26(
F - Strongly Connected 2可达性转化、DP、排序、乘法懒标记线段树看题解和对话后独立默写O((N+M)log⁡N)O((N+M)\log N)
G - Random Subtraction线性期望、符号系数、对称性、递推战略性放弃改错O(N)O(N)

这场最值得保留的经验:

  1. 不要只看代码的表面循环上界;break、单调性和小约束可能把复杂度压到完全不同的量级。
  2. vector 的“容器大小”和“预留容量”是两件事;reserve 不会产生可访问元素。
  3. 线段树节点下标 ind、维护区间左端点 l、数据下标三者不能混淆。
  4. 读题解时要追踪状态不变量:E 的 n<=len[k]、F 的“最大可达点”正是整个转移正确的基础。
  5. G 的代码很短,但真正的门槛是概率期望与对称性;没有基础时先记为待补知识,不把抄来的代码当成掌握。

A - 3,2,1,GO

题意

给定正整数 NN,输出

N,N−1,…,1N,N-1,\ldots,1

数字之间用逗号 , 分隔。

思路

从 NN 倒序循环到 11。输出当前数字后,只有当前数字不是 11 时才输出逗号,这样不会在末尾多一个逗号。

我的代码

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

void solve()
{
    int n;cin>>n;
    for(int i=n;i>=1;i--)
    {
        cout<<i;
        if(i!=1) cout<<",";
    }
    cout<<"\n";
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

代码:签到题.cpp


B - Split Ticketing

题意

有 NN 个从西向东排列的车站。Ci,jC_{i,j} 表示从车站 ii 上车、在车站 jj 下车的费用(i<ji<j)。判断是否存在 a<b<ca<b<c,使得在 bb 下车再重新上车的总费用更低:

Ca,b+Cb,c<Ca,c.C_{a,b}+C_{b,c}<C_{a,c}.

思路

输入只给出 i<ji<j 的费用,因此用 a[i][j] 保存上三角矩阵。三重循环枚举 a,b,ca,b,c,发现一个满足条件的三元组就输出 Yes 并返回。

踩坑:vector<int> g[N] 不是二维数组

曾经的错误代码是:

vector<int> g[N];
cin >> g[i][j];

g[i] 是一个初始长度为 00 的 vector,g[i][j] 访问的是不存在的元素,属于越界写入。这里应使用固定二维数组:

int a[N][N];
cin >> a[i][j];

或者先对每个 g[i] 调用 resize,但本题固定上界下二维数组更直接。

我的代码

#include <bits/stdc++.h>
using namespace std;
const int N=110;
int a[N][N];

void solve()
{
    int n;cin>>n;
    for(int i=1;i<n;i++)
    {
        for(int j=i+1;j<=n;j++) cin>>a[i][j];
    }

    for(int i=1;i<n;i++)
    {
        for(int j=i+1;j<=n;j++)
        {
            for(int k=i+1;k<j;k++)
            {
                if(a[i][k]+a[k][j]<a[i][j])
                {
                    cout<<"Yes"<<"\n";
                    return;
                }
            }
        }
    }

    cout<<"No"<<"\n";
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

代码:模拟.cpp


C - Puddles

题意

网格中 # 是黑格,. 是白格。白格按四方向连通组成连通块,求不包含外边界格子的白色连通块数量。

形式化地说,白格连通块如果不包含第 11 行、第 HH 行、第 11 列或第 WW 列的格子,就应该计数。

建模:把边界外看成一个虚拟点

格子 (i,j)(i,j) 映射为:

id⁡(i,j)=(i−1)W+j.\operatorname{id}(i,j)=(i-1)W+j.

并查集下标 00 专门表示“网格外部”。遍历每个白格:

  1. 与右侧白格合并;
  2. 与下侧白格合并;
  3. 若当前格在边界上,则与虚拟点 00 合并。

只检查右、下两个方向就够了,因为左、上方向会在对应格子处理时完成,避免重复合并。

最后遍历所有白格,若 u.get(idx)==idx,说明它是一个连通块的根。由于所有边界块已经与 00 合并,和 00 连通的块不会再被统计。

并查集中的两个重要修正

1. 路径压缩必须递归到父节点

你代码中的注释记录了曾经的错误:

////好像今天有点没睡醒,板子都能写错
//return fa[x]=(x==fa[x]?x:fa[x]);
return fa[x]=(x==fa[x]?x:get(fa[x]));

fa[x] 可能不是根,必须继续 get(fa[x]),并把最终根赋回 fa[x]。

2. 虚拟点 00 必须保持根

边界白格与 00 合并后,后续合并不能让 00 挂到普通节点下面,否则“是否接触外界”的标记会丢失。因此保留你的特殊判断:

if(aa==0)////0的权重最高!!!!!!!
{
    fa[bb]=aa;
    size[aa]+=size[bb];
}
else
{
    fa[aa]=bb;
    size[bb]+=size[aa];
}

这不是普通按大小合并,而是带语义的“特殊根优先”。

我的代码

#include <bits/stdc++.h>
using namespace std;
const int N=1e3+10;
char g[N][N];

class UnionSet
{
public:
    vector<int> fa,size;
    UnionSet(int n):fa(n+1),size(n+1)
    {
        for(int i=0;i<=n;i++)
        {
            fa[i]=i;
            size[i]=1;
        }
    }
    int get(int x)
    {
        //路径压缩:递归找到根,并把当前点直接连到根
        return fa[x]=(x==fa[x]?x:get(fa[x]));
    }
    void merge(int a,int b)
    {
        int aa=get(a),bb=get(b);
        if(aa==bb) return;
        if(aa==0)////0的权重最高!!!!!!!
        {
            fa[bb]=aa;
            size[aa]+=size[bb];
        }
        else
        {
            fa[aa]=bb;
            size[bb]+=size[aa];
        }
    }
};

void solve()
{
    int H,W;
    cin>>H>>W;
    for(int i=1;i<=H;i++)
    {
        for(int j=1;j<=W;j++) cin>>g[i][j];
    }

    UnionSet u(H*W);
    for(int i=1;i<=H;i++)
    {
        for(int j=1;j<=W;j++)
        {
            if(g[i][j]=='#') continue;

            int idx=(i-1)*W+j;
            if(j+1<=W&&g[i][j+1]=='.') u.merge(idx,idx+1);
            if(i+1<=H&&g[i+1][j]=='.') u.merge(idx,idx+W);

            if(i==1||i==H||j==1||j==W) u.merge(idx,0);
        }
    }

    int ans=0;
    for(int i=1;i<=H;i++)
    {
        for(int j=1;j<=W;j++)
        {
            if(g[i][j]=='#') continue;
            int idx=(i-1)*W+j;
            if(u.get(idx)==idx) ans++;
        }
    }

    cout<<ans<<"\n";
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

代码:并查集.cpp


D - Minimize Range

题意

给定正整数序列 AA 和正整数 KK。可以任意次选择一个位置,将对应元素加 KK。求最终

max⁡(A)−min⁡(A)\max(A)-\min(A)

的最小值。

关键观察:每个元素只能在自己的同余类中移动

对某个 aia_i 加 KK 不会改变 ai mod Ka_i\bmod K。把数组排序后,设当前最大值为 aNa_N。除最大值外,每个元素都可以先尽可能加到不超过 aNa_N:

ai←ai+K⌊aN−aiK⌋.a_i\leftarrow a_i+K\left\lfloor\frac{a_N-a_i}{K}\right\rfloor.

这样得到的数组已经把所有值压到一个长度小于 KK 的窗口附近。重新排序后,答案的一种候选是当前最大值减最小值。

接着考虑“把某个元素再加一次 KK”的情况。排序数组相邻位置之间形成周期断点,枚举每个断点即可得到另一批候选,取最小值。

代码中的边界提醒

原代码:

for(int i=1;i<=n;i++)
{
    a[i]+=k;
    int tmp=a[i]-a[i+1];
    ans=min(ans,tmp);
}

最后一次会读 a[n+1]。由于 a 是全局数组,这次读取通常不会崩溃,但它不是合法的数组元素,也不应该依赖全局区的默认零值。更严谨的写法是显式处理首尾断点,或者循环到 n-1 后单独考虑跨首尾的候选。

我的代码

#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N=2e5+10;
int a[N];

void solve()
{
    int n,k;
    cin>>n>>k;
    for(int i=1;i<=n;i++) cin>>a[i];
    sort(a+1,a+1+n);

    //先让所有值尽可能靠近当前最大值
    for(int i=1;i<n;i++)
    {
        int num=(a[n]-a[i])/k;
        a[i]+=k*num;
    }
    sort(a+1,a+1+n);

    int ans=a[n]-a[1];

    //枚举跨过一个周期后的相邻间隔
    for(int i=1;i<=n;i++)
    {
        a[i]+=k;
        int tmp=a[i]-a[i+1];
        ans=min(ans,tmp);
    }

    cout<<ans<<"\n";
}

signed main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

代码:贪心.cpp


E - Fibonacci String

题意

定义:

S1=X,S2=Y,Si=Si−1+Si−2(i≥3).S_1=X,\qquad S_2=Y,\qquad S_i=S_{i-1}+S_{i-2}\quad(i\ge3).

对每个询问 (Li,Ri,Ci)(L_i,R_i,C_i),求字符 CiC_i 在 S1018S_{10^{18}} 的第 LiL_i 到第 RiR_i 个字符中出现了多少次。

约束中 Ri≤1018R_i\le10^{18},但 S1018S_{10^{18}} 不可能构造出来。

对话中的第一层:为什么只算到 SKS_K

因为

Si=Si−1+Si−2,S_i=S_{i-1}+S_{i-2},

所以 Si−1S_{i-1} 是 SiS_{i} 的前缀,进而 SKS_K 是 S1018S_{10^{18}} 的前缀。先取所有询问右端点中的最大值:

maxR=max⁡(R1,R2,…,RQ).maxR=\max(R_1,R_2,\ldots,R_Q).

这里的 max⁡\max 表示“取最大值”,所以 maxR 就是所有询问中最大的右端点。接着令 KK 为满足 ∣SK∣≥maxR|S_K|\ge maxR 的最小下标,即:

K=min⁡{k∣∣Sk∣≥maxR}.K=\min\{k\mid |S_k|\ge maxR\}.

其中 min⁡\min 表示“取最小值”,竖线 \mid 可以读作“满足”。整条公式的意思是:从 S1,S2,…S_1,S_2,\ldots 中找到第一个长度足以覆盖 maxR 的字符串,并把它的下标记为 KK。

那么所有询问涉及的前缀都完全落在 SKS_K 内。由于长度按 Fibonacci 递推增长,K=O(log⁡max⁡R)K=O(\log\max R);本题最大只需约 88~90 层。

这一点不是“把大字符串截断后近似”,而是前缀完全相同,所以答案严格不变。

预处理一:长度

你的默写中特别标出了这个状态更新:

K=2;
while(len[K]<maxR)
{
    K++;
    len[K]=len[K-1]+len[K-2];
}

如果 len 是 vector,len[K] 在这里还不存在,直接赋值会越界;实际代码使用:

len.push_back(len[K-1]+len[K-2]);

这里的状态含义很清晰:len[i] 是 SiS_i 的长度,而不是当前询问的剩余长度。

预处理二:基础串前缀计数

定义 preX[c][p] 表示:字符串 XX 的前 pp 个字符,也就是下标 00 到 p−1p-1 的字符中,字符 cc 出现的次数。

例如 X=abacaX=\texttt{abaca},那么 preX['a'][4]=2,因为前 4 个字符 abac 中有两个 a。

因此 preX[c][0]=0,查询前 pp 个字符就是 preX[c][p]。每处理一个新位置,把上一列的 26 个字符计数全部复制,再给当前字符加一:

for(int i=0;i<lenX;i++)
{
    for(int c=0;c<26;c++) preX[c][i+1]=preX[c][i];
    preX[X[i]-'a'][i+1]++;
}

这正是你在 ////重点(记笔记) 中强调的“状态复制 + 单点更新”。preY 同理。

预处理三:每个 SiS_i 的总计数

定义 cnt[i][c] 为字符 cc 在完整 SiS_i 中的出现次数:

cnt[i][c]=cnt[i−1][c]+cnt[i−2][c].cnt[i][c]=cnt[i-1][c]+cnt[i-2][c].

查询过程中如果整块 Sk−1S_{k-1} 被取走,就可以 O(1)O(1) 加上 cnt[k-1][c],不用再进入这棵子树。

calc(k,n,ch) 的状态与转移

calc(k,n,ch) 表示:SkS_k 的前 nn 个字符中,字符 ch 出现的次数。

当 k≥3k\ge3 时,Sk=Sk−1+Sk−2S_k=S_{k-1}+S_{k-2}:

  1. 若 n≤len[k−1]n\le len[k-1],所取前缀完全位于 Sk−1S_{k-1},令 k--,n 不变;
  2. 否则,完整取走 Sk−1S_{k-1},执行
    ret+=cnt[k-1][ch];
    n-=len[k-1];
    k-=2;
    剩余前缀落在 Sk−2S_{k-2} 的开头。

为什么 k-=2 合法?因为在循环入口总有

n≤len[k]=len[k−1]+len[k−2].n\le len[k]=len[k-1]+len[k-2].

当 n>len[k−1]n>len[k-1] 时,新的

n′=n−len[k−1]≤len[k−2],n'=n-len[k-1]\le len[k-2],

所以 (k-2,n') 仍然是一个合法的前缀状态。

对话中的疑问:最后为什么可以直接查 X/Y 前缀

循环结束时 k 只有 1 或 2。因为上面的不变量始终成立,所以:

k=1⇒n≤len[1],k=2⇒n≤len[2].k=1\Rightarrow n\le len[1],\qquad k=2\Rightarrow n\le len[2].

因此真正执行路径中,下面的防御性截断通常永远不会触发:

if(n>len[1]) n=len[1];

它不是周期取模,也不是把 len[1]<n<len[2] 的后半段丢掉;正常调用根本不会出现这种情况。你把这两句注释掉仍能 AC,说明它们确实不是本题正常路径所必需的。对初学者来说,若保留,应该明确写上“防御性代码,正常流程不可达”,否则容易误以为字符串具有周期性。

区间答案

前缀计数相减:

ans=calc(K,R,C)−calc(K,L−1,C).ans=calc(K,R,C)-calc(K,L-1,C).

你对话中曾写成两个 R:

int ans=calc(K,R[i],C[i])-calc(K,R[i],C[i]);

这会恒等于 0,是必须优先检查的“答案区间减法”错误。

Lambda [&] 的对话复盘

[&] 捕获 lambda 定义所在作用域中的自动变量引用,但 (int k,int n,int ch) 是 lambda 自己的形参。k-- 修改的是形参副本,不会修改外部的 K。相反,若 lambda 中执行 lefts.insert(x) 或 good+=...,这些外部对象是按引用捕获的,就会直接改变外部状态。

我的默写代码

下面保留你的变量命名、base-1 风格和注释风格;仅修正 len.push_back、preX[ch][n] 和 <= 等关键错误:

////重点(记笔记):这题有很多地方的状态更新和状态定义都非常经典
#include <bits/stdc++.h>
using namespace std;
#define int long long

int maxR;
int K;

void solve()
{
    string X,Y;
    cin>>X>>Y;
    int Q;cin>>Q;
    vector<int> L(Q+1),R(Q+1),C(Q+1);
    for(int i=1;i<=Q;i++)
    {
        cin>>L[i]>>R[i];
        char ch;cin>>ch;
        C[i]=ch-'a';
        maxR=max(maxR,R[i]);
    }

    int lenX=X.size(),lenY=Y.size();
    vector<int> len;
    len.push_back(0);//base-1
    len.push_back(lenX);
    len.push_back(lenY);
    K=2;
    while(len[K]<maxR)
    {
        K++;
        //vector还没有len[K],不能直接赋值
        len.push_back(len[K-1]+len[K-2]);
    }

    vector<vector<int>> preX,preY;
    preX.assign(26,vector<int>(lenX+1));
    for(int i=0;i<lenX;i++)
    {
        for(int c=0;c<26;c++) preX[c][i+1]=preX[c][i];
        preX[X[i]-'a'][i+1]++;
    }
    preY.assign(26,vector<int>(lenY+1));
    for(int i=0;i<lenY;i++)
    {
        for(int c=0;c<26;c++) preY[c][i+1]=preY[c][i];
        preY[Y[i]-'a'][i+1]++;
    }

    vector<vector<int>> cnt;
    cnt.assign(K+1,vector<int>(26,0));
    for(int c=0;c<26;c++)
    {
        cnt[1][c]=preX[c][lenX];
        cnt[2][c]=preY[c][lenY];
    }
    for(int i=3;i<=K;i++)
    {
        for(int c=0;c<26;c++)
        {
            cnt[i][c]=cnt[i-1][c]+cnt[i-2][c];
        }
    }

    auto calc=[&](int k,int n,int ch)
    {
        if(n<=0) return 0LL;
        int ret=0;
        while(k>=3)
        {
            if(n<=len[k-1]) k--;
            else
            {
                ret+=cnt[k-1][ch];
                n-=len[k-1];
                k-=2;
            }
        }

        //正常流程下这里的n一定没有超过对应基础串长度
        if(k==1) return ret+preX[ch][n];
        else return ret+preY[ch][n];
    };

    for(int i=1;i<=Q;i++)
    {
        int ans=calc(K,R[i],C[i])-calc(K,L[i]-1,C[i]);
        cout<<ans<<"\n";
    }
}

signed main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

代码目录:默写过一遍思路.cpp

E 题与 AI 对话中的有效内容

对话链接:E 题 DeepSeek 对话

  • 你先自己解释了 k-=2:从 Sk−1S_{k-1} 扣掉完整前缀后,剩余长度必在 Sk−2S_{k-2} 范围内;这个理解是正确的。
  • 你追问 [&] 是否会改外部 K,最终明确了“捕获变量”和“形参副本”的区别。
  • 你识别出基础情况的截断是防御性代码,并实际注释掉后重新 AC;这比单纯记住代码更重要。
  • 默写时最有价值的三个错点是:vector 越界、二维前缀下标反了、区间答案写成 R−RR-R。

F - Strongly Connected 2

题意

有 NN 个点和 N−1+MN-1+M 条有向边:

  • 可选择删除的边为 Xi→YiX_i\to Y_i,其中 Xi<YiX_i<Y_i;
  • 固定存在边 i+1→ii+1\to i,即 2→1,3→2,…,N→N−1.2\to1,3\to2,\ldots,N\to N-1.

在 MM 条可选边中选择一个子集删除,求删除后图仍然强连通的方案数,模 998244353998244353。

经典概念:强连通与强连通分量

在有向图中,如果从顶点 uu 沿有向边能够走到顶点 vv,就称 uu 可以到达 vv。

一张有向图是强连通图,当且仅当任意选择两个顶点 u,vu,v,都同时满足:

u 可以到达 v,v 也可以到达 u.u\text{ 可以到达 }v,\qquad v\text{ 也可以到达 }u.

也就是“任意两点相互可达”。只满足单向可达并不够。

强连通分量(Strongly Connected Component,简称 SCC)是一个极大的顶点集合,集合内任意两点都相互可达。“极大”表示不能再加入集合外的其他顶点,同时保持任意两点相互可达。

一张有向图强连通,等价于整张图只有一个强连通分量,并且这个分量包含所有顶点。本题要求统计删除可选边后,整张图仍然只有一个强连通分量的方案数;它不是要求计算强连通分量的数量。

第一层转化:强连通等价于 11 能到达 NN

固定的递减链保证任意点都能回到 1。若 1 能到达 N,则:

  • 任意点 uu 可以沿递减链回到 1;
  • 1 可以到达 N;
  • N 再沿递减链到达任意点 vv。

因此任意两点互相可达,图强连通。反过来,强连通当然要求 1 能到达 N。

所以只需统计:选择哪些可选边后,1 能否到达 N。

DP 状态

把“删除边”换个角度看成“选择保留哪些边”。处理完一部分可选边后,定义:

dp[r]=当前从 1 出发能到达的最大编号恰为 r 的方案数.dp[r]=\text{当前从 1 出发能到达的最大编号恰为 }r\text{ 的方案数}.

为什么一个最大值就够?因为固定边都是从大编号指向小编号。若目前能到达 rr,那么 1,2,…,r1,2,\ldots,r 都可达;可达集合一定是前缀。状态只需记录前缀的右端点。

初始只有点 1 可达:dp[1]=1。

为什么要按 XX 排序

按 XX 升序处理边,是为了让“r<Xr<X 的状态可以永久丢弃”成立。若当前边起点为 XX 且 r<Xr<X,当前到不了这条边;而后续边的起点都不小于当前 XX,也更不可能从 rr 跳出去。

如果不排序,先遇到起点很大的边时丢弃 rr,后面可能还有起点更小、能够救回这个状态的边,答案就会漏算。对话里用 (6,7) 和 (5,10) 说明了这个反例。

处理边 (x,y)(x,y) 的转移

分情况:

r<xr<x

到不了当前边的起点;排序保证后面也没有更小起点的边可以救回它。直接忽略。

x≤r≤yx\le r\le y

若选择边 x→yx\to y,所有这些状态都能跳到 yy,因此汇聚为:

dp[y]+=∑r=xydp[r].dp[y]\mathrel{+}=\sum_{r=x}^{y}dp[r].

不选择边时,原来的 dp[r] 保持不变。注意 r=yr=y 也应该包含在求和中:从 yy 选择这条边后最大可达点仍是 yy,但确实多了一种“选/不选”方案。

r>yr>y

当前已经能到达比 yy 更大的点,选不选当前边都不改变最大可达点 rr,所以:

dp[r]←2dp[r](r>y).dp[r]\leftarrow2dp[r]\qquad(r>y).

代码中批量执行 range_mul(y+1,n,2)。这不是“把未来状态刷一遍”,而是对每个已经存在的方案真实地乘上两种选择;其中为 0 的状态乘 2 仍为 0,只是自然包含在批量操作中。

线段树对应关系

线段树叶子的 sum 就是 dp[r],内部节点 sum 是对应区间的 DP 总和。每条边需要三种操作:

  1. query(x,y):求转移汇聚所需的区间和;
  2. add_point(y,s):把选择当前边的方案加到 dp[y];
  3. range_mul(y+1,n,2):处理已经超过 yy 的状态。

复杂度为每条边 O(log⁡N)O(\log N),总复杂度 O((N+M)log⁡N)O((N+M)\log N)。

你的线段树迁移过程

你原来学的是“区间加 + 区间和”模板:

sum+=length×val,tag+=val,sum\mathrel{+}=length\times val,\qquad tag\mathrel{+}=val,

本题改成“区间乘 + 区间和”:

sum←sum×val,tag←tag×val.sum\leftarrow sum\times val,\qquad tag\leftarrow tag\times val.

因此懒标记单位元从 00 变成 11。另外,本题需要额外补一个“单点加”。这正是你自己重新写 SegTree 时得出的结论:模板的递归结构没有变,改变的是维护的运算和单位元。

F 题最重要的代码错误

你第一次默写时写成:

if(l==r)
{
    sum[l]+=val;
    return;
}

这里 l 是当前区间的左端点,ind 才是线段树节点编号。正确写法:

sum[ind]=(sum[ind]+val)%MOD;

这是本次最典型的“区间坐标”和“树节点下标”混淆。

我的默写代码(修正版)

#include <bits/stdc++.h>
using namespace std;
#define int long long
typedef pair<int,int> PII;
const int MOD=998244353;

class SegTree
{
    int n;
    vector<int> sum,tag;
    void add_point(int ind,int l,int r,int pos,int val)
    {
        if(l==r)
        {
            ////sum[l]+=val;严重错误!!!!!!
            sum[ind]=(sum[ind]+val)%MOD;
            return;
        }
        DOWN(ind);
        int mid=(l+r)/2;
        if(pos<=mid) add_point(ind*2,l,mid,pos,val);
        else add_point(ind*2+1,mid+1,r,pos,val);
        UP(ind);
    }
    void DOWN(int ind)
    {
        if(tag[ind]==1) return;
        sum[ind*2]=(sum[ind*2]*tag[ind])%MOD;
        sum[ind*2+1]=(sum[ind*2+1]*tag[ind])%MOD;
        tag[ind*2]=(tag[ind*2]*tag[ind])%MOD;
        tag[ind*2+1]=(tag[ind*2+1]*tag[ind])%MOD;
        tag[ind]=1;
    }
    void UP(int ind)
    {
        sum[ind]=(sum[ind*2]+sum[ind*2+1])%MOD;
    }
    void range_mul(int ind,int l,int r,int ql,int qr,int val)
    {
        if(ql<=l&&r<=qr)
        {
            sum[ind]=(sum[ind]*val)%MOD;
            tag[ind]=(tag[ind]*val)%MOD;
            return;
        }
        DOWN(ind);
        int mid=(l+r)/2;
        if(ql<=mid) range_mul(ind*2,l,mid,ql,qr,val);
        if(qr>mid) range_mul(ind*2+1,mid+1,r,ql,qr,val);
        UP(ind);
    }
    int query(int ind,int l,int r,int ql,int qr)
    {
        if(ql<=l&&r<=qr) return sum[ind];
        DOWN(ind);
        int ret=0;
        int mid=(l+r)/2;
        if(ql<=mid) ret=(ret+query(ind*2,l,mid,ql,qr))%MOD;
        if(qr>mid) ret=(ret+query(ind*2+1,mid+1,r,ql,qr))%MOD;
        return ret;
    }
public:
    SegTree(int _n):n(_n),sum(4*n+5,0),tag(4*n+5,1){}
    void add_point(int pos,int val)
    {
        add_point(1,1,n,pos,val);
    }
    void range_mul(int ql,int qr,int val)
    {
        if(ql>qr) return;
        range_mul(1,1,n,ql,qr,val);
    }
    int query(int ql,int qr)
    {
        if(ql>qr) return 0;
        return query(1,1,n,ql,qr);
    }
};

////思路:由于所有点都可以回退到1,所以我们只需要求1到n的方案总数就可以了
void solve()
{
    int n,m;
    cin>>n>>m;
    vector<PII> edges;
    for(int i=1;i<=m;i++)
    {
        int x,y;
        cin>>x>>y;
        edges.emplace_back(x,y);
    }
    sort(edges.begin(),edges.end());

    SegTree seg(n);
    seg.add_point(1,1);
    ////这里dp[r]就是sum[r]
    for(auto [x,y]:edges)
    {
        int s=seg.query(x,y);
        seg.add_point(y,s);
        if(y<n) seg.range_mul(y+1,n,2);
    }
    cout<<seg.query(n,n)<<"\n";
}

signed main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

F 题对话复盘

对话链接:F 题 DeepSeek 对话

  1. 你先问“强连通性是什么意思”,最后自己总结出:所有点能沿固定链回到 1,因此只需判断 1 是否能到 N。
  2. 你问 sort(edges.begin(),edges.end()) 是否为了让最大可达点从小到大更新;更精确的答案是:排序建立了 DP 的无后效性,使 r<x 的状态可以安全丢弃。
  3. 你把 [y+1,N] 的乘 2 理解成 DP 刷表,随后修正为“对已存在状态的选/不选两种方案真实翻倍”。
  4. 你指出 query(x,y) 是借助固定的递减链和当前边,把 x≤r≤yx\le r\le y 的所有方案汇聚到 yy;这是转移的核心。
  5. 你从自己的“区间加”模板迁移出了本题的“区间乘 + 单点加”模板,并通过 tag 单位元从 0 改为 1 理解了懒标记。
  6. 你询问 #define int long long。它可以快速规避类型宽度问题,但会污染所有 int,实际写模板时仍要清楚 ll 的语义和取模位置。

线段树补课资料

  • 模板总结:F - Strongly Connected 2/线段树/16第16章_树状数组与线段树/模板总结/
  • 练习:.../practice/
  • 区间最值:11HZOJ-222.cpp、13HZOJ-222.cpp
  • 区间和与懒标记:12HZOJ-223.cpp
  • 本题专用模板:本题的线段树模板.cpp

线段树模板对照表

项目区间加 + 区间和区间乘 + 区间和(F 题)
懒标记单位元01
applysum += len * val;tag += valsum *= val;tag *= val
下放子节点加上父 tag子节点乘上父 tag
本题额外操作无单点加 dp[y] += s
合并子区间和相加子区间和相加

G - Random Subtraction(战略性放弃)

题意

给定非负整数序列。每轮均匀随机选择两个有序且不同的位置 (i,j)(i,j),令 a=Ai,b=Aja=A_i,b=A_j,删除二者并在末尾加入 a−ba-b,直到只剩一个数 xx。求 E[x2]E[x^2],模 998244353998244353。

样例中第一次选择 (3,1)(3,1) 得到 (5,−4)(5,-4),第二次选择 (1,2)(1,2) 得到 99,所以顺序 (i,j)(i,j) 很重要。

对话中发生的关键误读

你和 AI 讨论时最初把选择理解成无序对,只列出 (N2)\binom N2 种情况,进而错误地认为样例 (4,5,0)(4,5,0) 的最终平方总是 1。重新对照题面后发现:题目写的是选择两个不同的整数 i,ji,j,(i,j)(i,j) 是有序对,共 ∣A∣(∣A∣−1)|A|(|A|-1) 种;(3,1)(3,1) 与 (1,3)(1,3) 的结果不同。

这是 G 题很重要的读题提醒:涉及“取两个下标”时,要确认是有序抽样还是无序抽样。

官方解法的骨架

最终结果可写成:

x=∑i=1NciAi,ci∈{1,−1}.x=\sum_{i=1}^N c_iA_i,\qquad c_i\in\{1,-1\}.

展开平方:

x2=∑iAi2+2∑i<jcicjAiAj.x^2=\sum_i A_i^2+2\sum_{i<j}c_ic_jA_iA_j.

随机性只在符号乘积上。设

CN=E[∑i<jcicj].C_N=E\left[\sum_{i<j}c_ic_j\right].

由于符号向量的分布只依赖于 NN,与具体的 AiA_i 无关;对称性又使任意一对原始下标的乘积期望相同。因此:

E[x2]=S2+2CNN(N−1)(S12−S2),E[x^2]=S_2+\frac{2C_N}{N(N-1)}(S_1^2-S_2),

其中

S1=∑iAi,S2=∑iAi2.S_1=\sum_iA_i,\qquad S_2=\sum_iA_i^2.

官方再固定第一步合并的两个元素,利用:

  • 被合并的两个系数乘积恒为 −1-1;
  • 它们与第三个系数的交叉项相互抵消;
  • 其余元素的配对分布等价于规模 N−1N-1 的问题;

得到 CNC_N 的递推。你的代码没有直接存 CNC_N,而是存

DN=2CNN(N−1),D_N=\frac{2C_N}{N(N-1)},

于是最终答案可以直接写成:

E[x2]=DNS12+(1−DN)S2.E[x^2]=D_NS_1^2+(1-D_N)S_2.

代码从 D_2=-1 开始,用逆元在线性时间内递推 DND_N。这部分的公式细节依赖概率对称性,不能只凭代码变量名反推;等系统补完期望线性性后再重新推导。

为什么当前放弃是合理的

这题代码只有一层循环和逆元,但难点集中在:

  1. 期望的线性性:不要求随机变量独立,也可以逐项取期望;
  2. 符号系数的对称性与“只依赖 NN”;
  3. 有序选择下的递推计数;
  4. 模意义下用逆元表示分数。

你目前还没有系统学习概率论与期望,且平时 ABC 通常做到前三题左右。这不是普通的“再多调几次代码”能补上的缺口,先把 G 标记为战略性放弃,比背下 AI 代码更诚实也更有效。

对话链接:G 题 DeepSeek 对话

以后补题顺序

  1. 先学期望的线性性,特别是“不要求独立”这一点;
  2. 手算 N=2,3N=2,3,确认有序选择对结果的影响;
  3. 再读官方对 CNC_N 的递推;
  4. 最后再看代码中的逆元和 D_N 变形。

赛后训练清单

  • 不看代码重新写一遍 C 的“虚拟边界点 + 特殊根优先”并查集。
  • 修正 D 中 a[n+1] 的边界写法,确认所有候选断点。
  • 不看题解默写 E:len、preX/preY、cnt、calc 四个状态。
  • 不看题解默写 F:先写 DP 转移,再写“区间乘 + 区间和 + 单点加”线段树。
  • 线段树练习目录中的 HZOJ-222、HZOJ-223 各独立重写一次。
  • G 暂不改错;先补期望线性性与对称性,再回头验证样例 83/383/3。