
AtCoder Beginner Contest 450 复盘笔记
比赛链接:AtCoder Beginner Contest 450
原始代码:7.24--450目录
整理内容:题面、我的代码、代码注释、AI 题解/对话与线段树补课资料
总结
| 题目 | 核心知识点 | 本次情况 | 复杂度 |
|---|---|---|---|
| A - 3,2,1,GO | 模拟、格式控制 | 独立 AC | |
| B - Split Ticketing | 三点枚举、上三角矩阵 | 独立 AC;曾遇到 vector 越界 | |
| C - Puddles | 网格建模、并查集、虚拟边界点 | 独立 AC;路径压缩和特殊根的复盘 | |
| D - Minimize Range | 排序、同余类、贪心 | 独立 AC | |
| E - Fibonacci String | 前缀计数、递推状态、Fibonacci 分解 | 有思路;看题解后独立默写 | $O(26( |
| F - Strongly Connected 2 | 可达性转化、DP、排序、乘法懒标记线段树 | 看题解和对话后独立默写 | |
| G - Random Subtraction | 线性期望、符号系数、对称性、递推 | 战略性放弃改错 |
这场最值得保留的经验:
- 不要只看代码的表面循环上界;
break、单调性和小约束可能把复杂度压到完全不同的量级。 vector的“容器大小”和“预留容量”是两件事;reserve不会产生可访问元素。- 线段树节点下标
ind、维护区间左端点l、数据下标三者不能混淆。 - 读题解时要追踪状态不变量:E 的
n<=len[k]、F 的“最大可达点”正是整个转移正确的基础。 - G 的代码很短,但真正的门槛是概率期望与对称性;没有基础时先记为待补知识,不把抄来的代码当成掌握。
A - 3,2,1,GO
题意
给定正整数 ,输出
数字之间用逗号 , 分隔。
思路
从 倒序循环到 。输出当前数字后,只有当前数字不是 时才输出逗号,这样不会在末尾多一个逗号。
我的代码
#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
题意
有 个从西向东排列的车站。 表示从车站 上车、在车站 下车的费用()。判断是否存在 ,使得在 下车再重新上车的总费用更低:
思路
输入只给出 的费用,因此用 a[i][j] 保存上三角矩阵。三重循环枚举 ,发现一个满足条件的三元组就输出 Yes 并返回。
踩坑:vector<int> g[N] 不是二维数组
曾经的错误代码是:
vector<int> g[N];
cin >> g[i][j];
g[i] 是一个初始长度为 的 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
题意
网格中 # 是黑格,. 是白格。白格按四方向连通组成连通块,求不包含外边界格子的白色连通块数量。
形式化地说,白格连通块如果不包含第 行、第 行、第 列或第 列的格子,就应该计数。
建模:把边界外看成一个虚拟点
格子 映射为:
并查集下标 专门表示“网格外部”。遍历每个白格:
- 与右侧白格合并;
- 与下侧白格合并;
- 若当前格在边界上,则与虚拟点 合并。
只检查右、下两个方向就够了,因为左、上方向会在对应格子处理时完成,避免重复合并。
最后遍历所有白格,若 u.get(idx)==idx,说明它是一个连通块的根。由于所有边界块已经与 合并,和 连通的块不会再被统计。
并查集中的两个重要修正
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. 虚拟点 必须保持根
边界白格与 合并后,后续合并不能让 挂到普通节点下面,否则“是否接触外界”的标记会丢失。因此保留你的特殊判断:
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
题意
给定正整数序列 和正整数 。可以任意次选择一个位置,将对应元素加 。求最终
的最小值。
关键观察:每个元素只能在自己的同余类中移动
对某个 加 不会改变 。把数组排序后,设当前最大值为 。除最大值外,每个元素都可以先尽可能加到不超过 :
这样得到的数组已经把所有值压到一个长度小于 的窗口附近。重新排序后,答案的一种候选是当前最大值减最小值。
接着考虑“把某个元素再加一次 ”的情况。排序数组相邻位置之间形成周期断点,枚举每个断点即可得到另一批候选,取最小值。
代码中的边界提醒
原代码:
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
题意
定义:
对每个询问 ,求字符 在 的第 到第 个字符中出现了多少次。
约束中 ,但 不可能构造出来。
对话中的第一层:为什么只算到
因为
所以 是 的前缀,进而 是 的前缀。先取所有询问右端点中的最大值:
这里的 表示“取最大值”,所以 maxR 就是所有询问中最大的右端点。接着令 为满足 的最小下标,即:
其中 表示“取最小值”,竖线 \mid 可以读作“满足”。整条公式的意思是:从 中找到第一个长度足以覆盖 maxR 的字符串,并把它的下标记为 。
那么所有询问涉及的前缀都完全落在 内。由于长度按 Fibonacci 递推增长,;本题最大只需约 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] 是 的长度,而不是当前询问的剩余长度。
预处理二:基础串前缀计数
定义 preX[c][p] 表示:字符串 的前 个字符,也就是下标 到 的字符中,字符 出现的次数。
例如 ,那么 preX['a'][4]=2,因为前 4 个字符 abac 中有两个 a。
因此 preX[c][0]=0,查询前 个字符就是 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 同理。
预处理三:每个 的总计数
定义 cnt[i][c] 为字符 在完整 中的出现次数:
查询过程中如果整块 被取走,就可以 加上 cnt[k-1][c],不用再进入这棵子树。
calc(k,n,ch) 的状态与转移
calc(k,n,ch) 表示: 的前 个字符中,字符 ch 出现的次数。
当 时,:
- 若 ,所取前缀完全位于 ,令
k--,n不变; - 否则,完整取走 ,执行
剩余前缀落在 的开头。ret+=cnt[k-1][ch]; n-=len[k-1]; k-=2;
为什么 k-=2 合法?因为在循环入口总有
当 时,新的
所以 (k-2,n') 仍然是一个合法的前缀状态。
对话中的疑问:最后为什么可以直接查 X/Y 前缀
循环结束时 k 只有 1 或 2。因为上面的不变量始终成立,所以:
因此真正执行路径中,下面的防御性截断通常永远不会触发:
if(n>len[1]) n=len[1];
它不是周期取模,也不是把 len[1]<n<len[2] 的后半段丢掉;正常调用根本不会出现这种情况。你把这两句注释掉仍能 AC,说明它们确实不是本题正常路径所必需的。对初学者来说,若保留,应该明确写上“防御性代码,正常流程不可达”,否则容易误以为字符串具有周期性。
区间答案
前缀计数相减:
你对话中曾写成两个 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:从 扣掉完整前缀后,剩余长度必在 范围内;这个理解是正确的。 - 你追问
[&]是否会改外部K,最终明确了“捕获变量”和“形参副本”的区别。 - 你识别出基础情况的截断是防御性代码,并实际注释掉后重新 AC;这比单纯记住代码更重要。
- 默写时最有价值的三个错点是:
vector越界、二维前缀下标反了、区间答案写成 。
F - Strongly Connected 2
题意
有 个点和 条有向边:
- 可选择删除的边为 ,其中 ;
- 固定存在边 ,即
在 条可选边中选择一个子集删除,求删除后图仍然强连通的方案数,模 。
经典概念:强连通与强连通分量
在有向图中,如果从顶点 沿有向边能够走到顶点 ,就称 可以到达 。
一张有向图是强连通图,当且仅当任意选择两个顶点 ,都同时满足:
也就是“任意两点相互可达”。只满足单向可达并不够。
强连通分量(Strongly Connected Component,简称 SCC)是一个极大的顶点集合,集合内任意两点都相互可达。“极大”表示不能再加入集合外的其他顶点,同时保持任意两点相互可达。
一张有向图强连通,等价于整张图只有一个强连通分量,并且这个分量包含所有顶点。本题要求统计删除可选边后,整张图仍然只有一个强连通分量的方案数;它不是要求计算强连通分量的数量。
第一层转化:强连通等价于 能到达
固定的递减链保证任意点都能回到 1。若 1 能到达 N,则:
- 任意点 可以沿递减链回到 1;
- 1 可以到达 N;
- N 再沿递减链到达任意点 。
因此任意两点互相可达,图强连通。反过来,强连通当然要求 1 能到达 N。
所以只需统计:选择哪些可选边后,1 能否到达 N。
DP 状态
把“删除边”换个角度看成“选择保留哪些边”。处理完一部分可选边后,定义:
为什么一个最大值就够?因为固定边都是从大编号指向小编号。若目前能到达 ,那么 都可达;可达集合一定是前缀。状态只需记录前缀的右端点。
初始只有点 1 可达:dp[1]=1。
为什么要按 排序
按 升序处理边,是为了让“ 的状态可以永久丢弃”成立。若当前边起点为 且 ,当前到不了这条边;而后续边的起点都不小于当前 ,也更不可能从 跳出去。
如果不排序,先遇到起点很大的边时丢弃 ,后面可能还有起点更小、能够救回这个状态的边,答案就会漏算。对话里用 (6,7) 和 (5,10) 说明了这个反例。
处理边 的转移
分情况:
到不了当前边的起点;排序保证后面也没有更小起点的边可以救回它。直接忽略。
若选择边 ,所有这些状态都能跳到 ,因此汇聚为:
不选择边时,原来的 dp[r] 保持不变。注意 也应该包含在求和中:从 选择这条边后最大可达点仍是 ,但确实多了一种“选/不选”方案。
当前已经能到达比 更大的点,选不选当前边都不改变最大可达点 ,所以:
代码中批量执行 range_mul(y+1,n,2)。这不是“把未来状态刷一遍”,而是对每个已经存在的方案真实地乘上两种选择;其中为 0 的状态乘 2 仍为 0,只是自然包含在批量操作中。
线段树对应关系
线段树叶子的 sum 就是 dp[r],内部节点 sum 是对应区间的 DP 总和。每条边需要三种操作:
query(x,y):求转移汇聚所需的区间和;add_point(y,s):把选择当前边的方案加到dp[y];range_mul(y+1,n,2):处理已经超过 的状态。
复杂度为每条边 ,总复杂度 。
你的线段树迁移过程
你原来学的是“区间加 + 区间和”模板:
本题改成“区间乘 + 区间和”:
因此懒标记单位元从 变成 。另外,本题需要额外补一个“单点加”。这正是你自己重新写 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 是否能到 N。
- 你问
sort(edges.begin(),edges.end())是否为了让最大可达点从小到大更新;更精确的答案是:排序建立了 DP 的无后效性,使r<x的状态可以安全丢弃。 - 你把
[y+1,N]的乘 2 理解成 DP 刷表,随后修正为“对已存在状态的选/不选两种方案真实翻倍”。 - 你指出
query(x,y)是借助固定的递减链和当前边,把 的所有方案汇聚到 ;这是转移的核心。 - 你从自己的“区间加”模板迁移出了本题的“区间乘 + 单点加”模板,并通过
tag单位元从 0 改为 1 理解了懒标记。 - 你询问
#define int long long。它可以快速规避类型宽度问题,但会污染所有int,实际写模板时仍要清楚ll的语义和取模位置。
线段树补课资料
- 模板总结:
F - Strongly Connected 2/线段树/16第16章_树状数组与线段树/模板总结/ - 练习:
.../practice/ - 区间最值:
11HZOJ-222.cpp、13HZOJ-222.cpp - 区间和与懒标记:
12HZOJ-223.cpp - 本题专用模板:
本题的线段树模板.cpp
线段树模板对照表
| 项目 | 区间加 + 区间和 | 区间乘 + 区间和(F 题) |
|---|---|---|
| 懒标记单位元 | 0 | 1 |
apply | sum += len * val;tag += val | sum *= val;tag *= val |
| 下放 | 子节点加上父 tag | 子节点乘上父 tag |
| 本题额外操作 | 无 | 单点加 dp[y] += s |
| 合并 | 子区间和相加 | 子区间和相加 |
G - Random Subtraction(战略性放弃)
题意
给定非负整数序列。每轮均匀随机选择两个有序且不同的位置 ,令 ,删除二者并在末尾加入 ,直到只剩一个数 。求 ,模 。
样例中第一次选择 得到 ,第二次选择 得到 ,所以顺序 很重要。
对话中发生的关键误读
你和 AI 讨论时最初把选择理解成无序对,只列出 种情况,进而错误地认为样例 的最终平方总是 1。重新对照题面后发现:题目写的是选择两个不同的整数 , 是有序对,共 种; 与 的结果不同。
这是 G 题很重要的读题提醒:涉及“取两个下标”时,要确认是有序抽样还是无序抽样。
官方解法的骨架
最终结果可写成:
展开平方:
随机性只在符号乘积上。设
由于符号向量的分布只依赖于 ,与具体的 无关;对称性又使任意一对原始下标的乘积期望相同。因此:
其中
官方再固定第一步合并的两个元素,利用:
- 被合并的两个系数乘积恒为 ;
- 它们与第三个系数的交叉项相互抵消;
- 其余元素的配对分布等价于规模 的问题;
得到 的递推。你的代码没有直接存 ,而是存
于是最终答案可以直接写成:
代码从 D_2=-1 开始,用逆元在线性时间内递推 。这部分的公式细节依赖概率对称性,不能只凭代码变量名反推;等系统补完期望线性性后再重新推导。
为什么当前放弃是合理的
这题代码只有一层循环和逆元,但难点集中在:
- 期望的线性性:不要求随机变量独立,也可以逐项取期望;
- 符号系数的对称性与“只依赖 ”;
- 有序选择下的递推计数;
- 模意义下用逆元表示分数。
你目前还没有系统学习概率论与期望,且平时 ABC 通常做到前三题左右。这不是普通的“再多调几次代码”能补上的缺口,先把 G 标记为战略性放弃,比背下 AI 代码更诚实也更有效。
对话链接:G 题 DeepSeek 对话
以后补题顺序
- 先学期望的线性性,特别是“不要求独立”这一点;
- 手算 ,确认有序选择对结果的影响;
- 再读官方对 的递推;
- 最后再看代码中的逆元和
D_N变形。
赛后训练清单
- 不看代码重新写一遍 C 的“虚拟边界点 + 特殊根优先”并查集。
- 修正 D 中
a[n+1]的边界写法,确认所有候选断点。 - 不看题解默写 E:
len、preX/preY、cnt、calc四个状态。 - 不看题解默写 F:先写 DP 转移,再写“区间乘 + 区间和 + 单点加”线段树。
- 线段树练习目录中的 HZOJ-222、HZOJ-223 各独立重写一次。
- G 暂不改错;先补期望线性性与对称性,再回头验证样例 。
