CSPS 模拟赛1 · 完整题解(含全部分档代码)
CSPS 模拟赛1 · 完整题解(含全部分档代码)
四道题按难度排序:T2(结论题,最水)< T1(大模拟)< T3(图论 DP)< T4(博弈 + 数据结构,最难)。
第一部分 · 考场总体策略
1.1 时间分配建议(4 小时)
| 时段 | 建议任务 |
|---|---|
| 前 60 min | 通读四题;先做 T2(想通后 20 行代码)与 T1 的暴力档 |
| 60 ~ 150 min | 攻坚 T1 正解(分段历法)与 T3 部分分 / 正解 |
| 150 ~ 210 min | T4:先写 20% 暴力保底,再冲 55% 模拟,最后攻双队列正解 |
| 最后 30 min | 对拍 + 检查边界(a_i=0、k=0、n=1、换历边界、取模、long long) |
1.2 得分矩阵(全部经官方数据实测,单点按官方时限 1s 评估)
| 题目 | 档位 1 | 档位 2 | 档位 3 | 正解 |
|---|---|---|---|---|
| T1 星历换算 | 逐天模拟:30 分(实测过 4 点) | 离线单趟扫描:80 分(实测过 9 点) | — | 分段 + 二分:100 分 |
| T2 补丁管理 | 枚举模拟:20 分(实测过 8 点) | 枚举 + 按位:40 分(实测过 12 点) | 按位 ull:60 分(实测过 19 点) | __int128:100 分 |
| T3 指令流水线 | 递归展开:实测过 11 点(约 64 分) | 无乘法 / 无加法:各过对应档 | 树形结构(递归 DFS):实测过 20 点 | 双向拓扑 DP:100 分 |
| T4 商业兼并 | n=3 公式:20 分 | 博弈递归:实测过 11 点(约 55 分) | O(n²) 模拟:实测过 11 点 | 双队列:100 分 |
核心结论:这套卷子部分分极"慷慨"——四题只写暴力也能拿约 130 分,按档位升级可到 240+ 分。
考场第一原则:先暴力保底,再逐档升级,绝不裸奔死磕正解。
1.3 通用于四题的工作流
- 先建对拍环境:每道题先写"照题意直接做"的暴力(哪怕超时),再与升级版对拍。
T4 的博弈结论、T1 的换历边界都是"猜错/看漏就全错"的地方,对拍是唯一可靠的验证手段。 - 读入优化:
n,m,Q可达 \(10^5\sim 10^6\),T3/T4 必须手写快读或用ios::sync_with_stdio(false)+cin。 - 按数据范围读出类型:"年份 ≤ 1e9" → 中间量要到 \(3.6\times10^{11}\),必须
long long。
第二部分 · T1 星历换算— 100 分详解
2.1 题目本质(通俗版)
给你一个整数 \(r\),表示"从公元前 4713 年 1 月 1 日正午算起,过了多少整天",请输出 \(r\) 对应的公历日期。
规矩很绕,拆成四条:
- 公元 1582 年 10 月 15 日之后(含):用格里高利历——普通年份 365 天;闰年规则是
"400 的倍数,或 4 的倍数但不是 100 的倍数"。 - 公元 1582 年 10 月 5 日 ~ 10 月 14 日:不存在,10 月 4 日的下一天直接是 10 月 15 日。
- 1582 年 10 月 4 日之前(含):用儒略历——只要年份是 4 的倍数就是闰年(更宽松)。
- 公元没有 0 年:公元前 1 年的下一年是公元 1 年。所以公元前 1、5、9、13……年是闰年。
输出格式:公元后 Day Month Year,公元前 Day Month Year BC(无前导零)。
一眼看穿的重点:没有算法难点,全是"历法规则翻译 + 整数计算",细节多到爆。
数据范围按 \(r\) 分层(\(10^5\to10^7\to10^9\to\) 年份 \(10^9\)),梯度就是复杂度梯度——这就是出题人给你铺好的台阶。
2.2 应试策略
- 先想清楚"公元无 0 年"怎么表示(见 2.3 天文年编号),这一步错了,公元前所有测试点全错。
- 暴力也要完整实现换历(10-04 → 10-15)和公元前,否则 1~3 号点都过不了。
暴力档的分数同样是"完整规则"换来的,不能偷工。 - 所有手推常数必须用暴力验证(1721424 / 2299160 / 2299239 这几个分界值),不要靠"感觉"。
- 升级路径明确:逐天模拟(30 分)→ 排序离线扫描(80 分)→ 分段 + 年首公式 + 二分(100 分)。
第 2 档对第 1 档只改了 15 行,性价比极高,务必先拿。
2.3 统一建模(三档代码共用)
天文年编号 \(a\):消灭"公元无 0 年"。内部不存"公元前 y 年",统一存天文年 \(a\):
- 公元前 \(y\) 年 \(\Rightarrow a = 1 - y\)(前 4713 年 \(\to a=-4712\),前 1 年 \(\to a=0\));
- 公元 \(y\) 年 \(\Rightarrow a = y\)。
好处:年份轴变成连续整数 \(-4712,-4711,\dots,-1,0,1,\dots\),跨公元时 ++a 即可,零特判;
儒略历置闰变成一句话"\(a\equiv 0\pmod 4\)"(前 1 年 \(a=0\)、前 5 年 \(a=-4\)……全是 4 的倍数,自洽)。
输出时 \(a\le 0\) 输出 1-a 并加 BC 后缀。
闰年规则:\(a<1582\) 用儒略历(4 的倍数);\(a\ge1582\) 用格里高利历。
(1582 年 2 月虽在换历前,但 1582 不是 4 的倍数,两种规则结论相同,无歧义;真正分歧的年份如 1700 都在 1582 之后。)
四个分界儒略日(正解核心常数):
| 常数 | 值 | 含义 |
|---|---|---|
| 起点 | 0 | 前 4713-01-01 |
R_BC |
1721424 | 公元 1-01-01(前 4713 至公元 1 年共 4713 个天文年 \(-4712..0\),其中 4 的倍数年 1179 个:\(4713\times365+1179\)) |
R_END |
2299160 | 儒略历最后一天 1582-10-04 |
R_G |
2299239 | 1583-01-01(格里历) |
区间划分:
r < 1721424 公元前(a ∈ [-4712, 0],儒略历)
1721424 ≤ r ≤ 2299160 公元 1~1582(儒略历,末天 10-04)
2299161 ≤ r < 2299239 1582-10-15 ~ 12-31(格里历的"尾巴",单独特判)
r ≥ 2299239 公元 1583+(格里高利历)
2.4 档位 1:逐天模拟(30 分,sol1_brute_30pts.cpp)
思路(通俗版):最朴素的想法——"日子一天一天过"。对每个询问,从起点
\((a,m,d)=(-4712,1,1)\) 出发,走 \(r\) 步"明天"。每一步:
- 日期加 1,若超过当月天数,进位到月、年;
- 换历特判:当走到 1582-10-05 时,直接跳到 10-15(那 10 天不存在)。
复杂度 \(O(Q\cdot r)\):测试点 1~3 的 \(r\le10^5\),约 \(10^8\) 次推进,实测稳过;
测试点 4(\(r\le3\times10^5\))实测 0.49s 过;测试点 5 起超时。
// sol1_brute_30pts.cpp — 档位1:对每个询问逐天模拟(实测 AC 1~4)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int md[13]={0,31,28,31,30,31,30,31,31,30,31,30,31};
bool leap(ll y) // 天文年 y 是否闰年
{
if(y<1582) return ((y%4)+4)%4==0; // 儒略历(含公元前)
return (y%4==0&&y%100!=0)||y%400==0; // 格里高利历
}
int dayOfMonth(ll y,int m)
{
if(m==2&&leap(y)) return 29;
return md[m];
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int q;
cin>>q;
while(q--)
{
ll r;
cin>>r;
ll y=-4712; // 公元前 4713 年,儒略日 0
int m=1,d=1;
int cur=dayOfMonth(y,m);
for(ll i=0;i<r;i++)
{
if(++d>cur)
{
d=1;
if(++m>12){m=1;++y;}
cur=dayOfMonth(y,m);
}
else if(y==1582&&m==10&&d==5) d=15; // 换历:10月4日之后是10月15日
}
if(y<=0) cout<<d<<" "<<m<<" "<<1-y<<" BC"<<"\n";
else cout<<d<<" "<<m<<" "<<y<<"\n";
}
return 0;
}
实测:测试点 1~4 AC(最大 0.49s);测试点 5 输出正确但约 5s 超时;6~10 TLE。
2.5 档位 2:离线排序 + 单趟扫描(80 分,sol2_offline_80pts.cpp)
思路(通俗版):档位 1 慢在"每个询问都从头模拟一遍"。注意到题目没有强制在线——
先把所有询问按 \(r\) 从小到大排序,然后从 r=0 只向后扫一遍:扫到某天时,若它恰好是某个
询问的 \(r\),就地记录答案。复杂度从 \(O(Q\cdot r_{\max})\) 变成 \(O(r_{\max}+Q\log Q)\),
一千万天实测只要 0.15s。改动约 15 行,通过范围从 \(r\le3\times10^5\) 推到 \(r\le10^7\)。
// sol2_offline_80pts.cpp — 档位2:询问排序 + 单趟逐天扫描(实测 AC 1~9)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=100005;
const int md[13]={0,31,28,31,30,31,30,31,31,30,31,30,31};
int q;
pair<ll,int> qs[N]; // (r, 原始下标)
ll outY[N]; // 每个询问对应的 天文年/月/日
int outM[N],outD[N];
bool leap(ll y)
{
if(y<1582) return ((y%4)+4)%4==0;
return (y%4==0&&y%100!=0)||y%400==0;
}
int dayOfMonth(ll y,int m)
{
if(m==2&&leap(y)) return 29;
return md[m];
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin>>q;
for(int i=0;i<q;i++)
{
cin>>qs[i].first;
qs[i].second=i;
}
sort(qs,qs+q);
ll y=-4712; // 起点:公元前 4713 年 1 月 1 日,r=0
int m=1,d=1;
int cur=dayOfMonth(y,m);
int i=0; // 已回答的询问数
ll r=0; // 当前处于儒略日 r
while(i<q)
{
while(i<q&&qs[i].first==r) // 相同 r 的询问一并处理
{
outY[qs[i].second]=y;
outM[qs[i].second]=m;
outD[qs[i].second]=d;
i++;
}
if(i==q) break;
if(++d>cur)
{
d=1;
if(++m>12){m=1;++y;}
cur=dayOfMonth(y,m);
}
else if(y==1582&&m==10&&d==5) d=15; // 换历跳日
r++;
}
for(int i=0;i<q;i++)
{
if(outY[i]<=0) cout<<outD[i]<<" "<<outM[i]<<" "<<1-outY[i]<<" BC"<<"\n";
else cout<<outD[i]<<" "<<outM[i]<<" "<<outY[i]<<"\n";
}
return 0;
}
实测:测试点 1~9 AC(最大 0.87s,其中 9 号 \(r\le10^9\) 是 1s 时限边缘,属机器速度红利,不可依赖);
测试点 10 TLE。按 1s 官方时限,可靠上限仍按 80 分(1~8) 估。
2.6 正解:分段 + 年首公式 + 二分年份(100 分,sol3_full_100pts.cpp)
思路(通俗版):扫描再快也只是"线性",\(r\) 到 \(3.6\times10^{11}\) 时必须"跳"。
关键思想:只要知道"某年 1 月 1 日的儒略日是多少",就能二分求出 \(r\) 落在哪一年,再用
"年内第几天"拆出月日。于是问题变成写两个单调函数:
- 儒略历段年首 \(f(a)=(a+4712)\times365+\lfloor(a-1)/4\rfloor+1179\)(\(a\le1582\),可为负);
- 格里历段年首 \(g(a)=2299239+(a-1583)\times365+(P(a-1)-P(1582))\),
其中 \(P(t)=\lfloor t/4\rfloor-\lfloor t/100\rfloor+\lfloor t/400\rfloor\) 是 \([1,t]\) 内格里历闰年数。
这两个函数都严格单调 → 用二分(最大满足 \(f/g(a)\le r\) 的 \(a\))定位年份。
1582 年格里历"尾巴"(10-15 ~ 12-31)只有 78 天,单独手写一个小区间即可。
二分模板说明:与大家熟悉的 bin(x) 写法一致——while(l<=r) 循环,
判断条件满足就更新答案并继续朝一个方向收缩(本题要找"最大的可行年份",所以 ans=mid,l=mid+1),
不满足就反向收缩;循环结束时 ans 即所求(找不到时保持初值 -1)。
// sol3_full_100pts.cpp — 正解:四段划分 + 年首公式 + 二分年份(实测 AC 1~10)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int md[13]={0,31,28,31,30,31,30,31,31,30,31,30,31};
const ll R_BC =1721424; // r=1721424 <=> 公元 1 年 1 月 1 日
const ll R_END=2299160; // r=2299160 <=> 1582 年 10 月 4 日(儒略历最后一天)
const ll R_G =2299239; // r=2299239 <=> 1583 年 1 月 1 日
ll fdiv(ll a,ll b) // 向下取整除法(负数时 C++ 除法向零取整,需修正)
{
ll q=a/b;
if(a%b!=0&&(a<0)!=(b<0)) q--;
return q;
}
ll fJ(ll y) // 儒略历段年首(y<=1582,可为负)
{
return (y+4712)*365+fdiv(y-1,4)+1179;
}
ll P(ll t) { return t/4-t/100+t/400; } // [1,t] 内格里历闰年数
ll fG(ll y) // 格里历段年首(y>=1583)
{
return R_G+(y-1583)*365+(P(y-1)-P(1582));
}
// 二分:在 [L,R] 中找最大的 y 使 fJ(y)<=x(没有则返回 -1)
ll binJ(ll L,ll R,ll x)
{
ll l=L,r=R,ans=-1;
while(l<=r)
{
ll mid=(l+r)/2;
if(fJ(mid)<=x) ans=mid,l=mid+1; // 年首还在这天之前,往后试
else r=mid-1; // 年首太靠后,往前试
}
return ans;
}
// 二分:在 [L,R] 中找最大的 y 使 fG(y)<=x
ll binG(ll L,ll R,ll x)
{
ll l=L,r=R,ans=-1;
while(l<=r)
{
ll mid=(l+r)/2;
if(fG(mid)<=x) ans=mid,l=mid+1;
else r=mid-1;
}
return ans;
}
bool leapJ(ll y) { return ((y%4)+4)%4==0; }
bool leapG(ll y) { return (y%4==0&&y%100!=0)||y%400==0; }
void getMD(ll doy,bool lp,int &m,int &d) // 年内第 doy 天(0起) -> 月日
{
for(int i=1;i<=12;i++)
{
int len=md[i]+(i==2&&lp);
if(doy<len){m=i;d=(int)doy+1;return;}
doy-=len;
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int q;
cin>>q;
while(q--)
{
ll r,y;
int m,d;
bool lp;
cin>>r;
if(r<R_BC) // 公元前:y ∈ [-4712,0]
{
y=binJ(-4712,0,r);
lp=leapJ(y);
getMD(r-fJ(y),lp,m,d);
}
else if(r<=R_END) // 公元 1~1582(儒略历)
{
y=binJ(1,1582,r);
lp=leapJ(y);
getMD(r-fJ(y),lp,m,d);
}
else if(r<R_G) // 1582-10-15 ~ 12-31(格里历尾巴)
{
y=1582; lp=false; // 1582 平年
getMD(287+(r-(R_END+1)),lp,m,d); // 287 = 10-15 的年内 doy
}
else // 1583+(格里历),答案年份 <= 1e9
{
y=binG(1583,1000000000LL,r);
lp=leapG(y);
getMD(r-fG(y),lp,m,d);
}
if(y<=0) cout<<d<<" "<<m<<" "<<1-y<<" BC"<<"\n";
else cout<<d<<" "<<m<<" "<<y<<"\n";
}
return 0;
}
验证过程(严谨性流程,供参考):
- 题面样例 1/2 全过(三档都过)。
- 构造 1762 个询问的边界对拍集:随机 \(r\in[0,10^5]\) + 六个分界点前后 ±25 天 +
年份 4/100/300/1500/1580/1584/1600/1700/1900/2000/2400 的 2 月末/年末窗口,正解与暴力输出完全一致。 - 官方 10 个测试点逐一 diff。
2.7 T1 实测通过汇总
环境:Windows 10 + Git Bash,g++ (MinGW-W64) 13.2.0,-O2 -std=c++17,官方时限 1s。
| 测试点 | 规模 | 档位1 暴力 | 档位2 离线 | 档位3 正解 |
|---|---|---|---|---|
| 1 | Q=1000, r≤365 | AC | AC | AC |
| 2 | Q=1000, r≤1e4 | AC | AC | AC |
| 3 | Q=1000, r≤1e5 | AC | AC | AC |
| 4 | Q=1e4, r≤3e5 | AC 0.49s(擦边) | AC | AC |
| 5 | Q=1e4, r≤2.5e6 | 输出对,5s 超时 | AC | AC |
| 6 | Q=1e5, r≤2.5e6 | TLE | AC | AC |
| 7 | Q=1e5, r≤5e6 | TLE | AC | AC |
| 8 | Q=1e5, r≤1e7 | TLE | AC | AC |
| 9 | Q=1e5, r≤1e9 | TLE | AC 0.87s(边缘,不可依赖) | AC |
| 10 | Q=1e5, 年份≤1e9 | TLE | TLE | AC |
按官方 1s 时限折算:档位 1 = 30 分稳(1~3,4 擦边),档位 2 = 80 分稳(1~8,9 擦边),正解 = 100 分。
2.8 T1 可复用技巧
- 历法/时间类题的万能骨架:锚点日期 + "年首序号函数 \(f(年)\)" + 年内 doy + 月长表拆分。
查询 = "定位年份(二分,\(f\) 单调即可)+ 拆月日"两步,不必手推周期循环的边界特判。 - 周期化:儒略历 4 年 = 1461 天,格里历 400 年 = 146097 天。凡"跨很多年"的计数,先找周期再数余数。
- 公元前无 0 年 → 天文年编号 \(a=1-y\):把不连续的纪年映射成连续整数轴,加法、取模、周期全部自然成立;只在输出时换回 BC。
- 负数取模:C++ 的
/、%向零取整,写 \(\lfloor a/b\rfloor\) 必须自造fdiv(见正解),判整除用((a%k)+k)%k==0。 - 换历/跳日这类"日历空洞":在逐天模拟里是一次特殊跳转(10-04→10-15),在公式法里是一段手写小区间;两种表示互相验证。
- 离线化换复杂度:无强制在线时,"逐询问各算各"→"排序后共享一次扫描"是零成本大优化(本题 30 分 → 80 分只改 15 行)。
- 暴力即对拍器:先花 20 分钟把规则完整的暴力写稳,它既是保底分又是正解的随机 + 边界对拍基准。
- 读数据范围读出类型:"年份 ≤ 1e9" 直接翻译成"中间量可能到 4e11,开 long long"。
- 输出格式细节清单:BC 后缀、无前导零、单空格、行末换行——模拟题最后 10 分钟专门过一遍格式。
第三部分 · T2 补丁管理— 100 分详解
3.1 题目本质(通俗版)
有 \(2^k\) 台"潜在服务器",编号 \(0\sim2^k-1\)(就是 \(k\) 位二进制数)。现在已部署 \(n\) 台,编号 \(a_i\)。
有 \(m\) 条规则:"如果已部署的某台编号的第 \(p\) 位是 1,就必须装第 \(q\) 个补丁"。装完补丁得到一份清单。
现在问:还有多少个未部署的编号 \(x\),把它加进来后,补丁清单一点不变?
一句话直觉:某台服务器加进来会"触发"哪些规则,只取决于它编号里哪些位是 1。
规则的核心是"某位一旦出现 1 就要买饲料"——所以饲料编号 \(q\) 跟答案完全无关,可以扔掉。
3.2 分析思路(从暴力到正解的推理链)
第一步:暴力模拟(20 分)。\(k\le5\) 时一共才 32 个编号,枚举每个编号 \(x\),
把 \(x\) 加进动物园重新生成清单,看变没变。\(O(2^k(n+m))\)。
第二步:发现"位被锁死"(40 分)。一条规则"第 \(p\) 位是 1 则买 \(q\)"意味着:
若现在所有已部署动物在 \(p\) 位都是 0(没触发过 \(q\)),那么任何新增动物在 \(p\) 位都必须为 0,
否则就会新增补丁、清单变化。于是:
has= 已有 \(n\) 个编号的按位或(哪些位已被占过 1);need= \(m\) 条规则里出现过的那一位 \(p\) 的集合;- 被锁死的位 =
forbidden = need & ~has(规则要求过、但当前没人占 1 的位)。
新增编号 \(x\) 合法 \(\Leftrightarrow\) (x & forbidden) == 0。单次判断变成 \(O(1)\) 位运算,
枚举 \(2^{20}=10^6\) 个编号实测只要 70ms。
第三步:不枚举,直接数(60 分)。合法的 \(x\) 有 \(2^k\) 个,约束只限制 forbidden 里那些位
必须为 0,其余位任意。所以:
第四步:处理 \(k=64\)(100 分)。1ULL << 64 是未定义行为,\(2^{64}\) 装不进
unsigned long long。用 unsigned __int128 算 (1<<free) - n 再手写十进制输出即可。
3.3 档位 1:暴力枚举(20 分,zoo20.cpp,实测过 8 个点)
// zoo20.cpp — 20% 档暴力:枚举 2^k 个编号,逐个模拟清单(实测过 8 个点)
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n,m,c,k;
cin>>n>>m>>c>>k;
vector<ull> a(n);
for(int i=0;i<n;i++) cin>>a[i];
vector<int> p(m),q(m);
for(int i=0;i<m;i++) cin>>p[i]>>q[i];
vector<char> feed(c+1,0);
for(int i=0;i<m;i++)
for(int j=0;j<n;j++)
if((a[j]>>p[i])&1ULL){feed[q[i]]=1;break;}
ull lim=(k==64)?0ULL:(1ULL<<k);
long long ans=0;
for(ull x=0;x<lim;x++)
{
bool inZoo=false;
for(int j=0;j<n;j++) if(a[j]==x){inZoo=true;break;}
if(inZoo) continue;
vector<char> f2=feed;
bool changed=false;
for(int i=0;i<m;i++)
if(!f2[q[i]]&&((x>>p[i])&1ULL)){f2[q[i]]=1;changed=true;}
if(!changed) ans++;
}
cout<<ans<<"\n";
return 0;
}
3.4 档位 2:枚举 + 按位 O(1) 判断(40 分,zoo40.cpp,实测过 12 个点)
// zoo40.cpp — 40% 档:枚举 + 按位判断 (x & forbidden)==0(实测过 12 个点)
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n,m,c,k;
cin>>n>>m>>c>>k;
ull has=0;
vector<ull> a(n);
for(int i=0;i<n;i++){cin>>a[i];has|=a[i];}
ull need=0;
for(int i=0;i<m;i++)
{
int p,q;
cin>>p>>q;
need|=(1ULL<<p); // q 与答案无关,丢弃
}
ull forbidden=need&~has; // 被锁死的位:要求出现过、但当前没人占 1
ull lim=(k==64)?0ULL:(1ULL<<k);
ull ans=0;
for(ull x=0;x<lim;x++)
if((x&forbidden)==0) ans++;
ans-=(ull)n;
cout<<ans<<"\n";
return 0;
}
3.5 档位 3:按位统计(60 分,zoo60.cpp,实测过 19 个点)
// zoo60.cpp — 60% 档:按位统计(unsigned long long,实测过 19 个点)
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n,m,c,k;
cin>>n>>m>>c>>k;
ull has=0,x;
for(int i=0;i<n;i++){cin>>x;has|=x;}
ull need=0;
for(int i=0;i<m;i++)
{
int p,q;
cin>>p>>q;
need|=(1ULL<<p);
}
int locked=__builtin_popcountll(need&~has);
int freeBits=k-locked;
ull ans;
if(freeBits==64) ans=0; // 2^64 超出 ull,本档不处理,交给正解
else ans=(1ULL<<freeBits)-(ull)n;
cout<<ans<<"\n";
return 0;
}
3.6 正解:__int128 处理 2^64(100 分,zoo100.cpp,实测过全部 20 个点)
与 60% 档唯一区别是输出:自由位达 64 时用 unsigned __int128 计算并手写十进制输出。
同时用 ios::sync_with_stdio(false) + cin 加速输入(\(n,m\) 最大 \(10^6\),输入约 20MB)。
// zoo100.cpp — 正解(100 分):按位统计 + __int128 处理 2^64(实测过全部 20 个点)
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
typedef unsigned __int128 u128;
void print128(u128 x) // 手写十进制输出(不能直接 cout/printf)
{
if(x==0){cout<<"0"<<"\n";return;}
string s;
while(x>0)
{
s+=(char)('0'+(int)(x%10));
x/=10;
}
reverse(s.begin(),s.end());
cout<<s<<"\n";
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n,m,c,k;
cin>>n>>m>>c>>k;
ull has=0,x;
for(int i=0;i<n;i++){cin>>x;has|=x;}
ull need=0;
for(int i=0;i<m;i++)
{
int p,q;
cin>>p>>q;
need|=(1ULL<<p); // q 与答案无关,丢弃
}
int locked=__builtin_popcountll(need&~has);
int freeBits=k-locked;
u128 ans=((u128)1)<<freeBits;
ans-=(u128)n;
print128(ans);
return 0;
}
编译命令(统一):g++ -O2 -std=c++17 -o zoo100 zoo100.cpp
3.7 T2 实测通过汇总(20 个测试点,单点 6s 时限)
| 测试点 | 关键规模 | zoo20 暴力 | zoo40 枚举+按位 | zoo60 按位 ull | zoo100 正解 |
|---|---|---|---|---|---|
| 1~8 | n≤13, k≤12 | PASS | PASS | PASS | PASS |
| 9~12 | n≤30, k≤28 | TLE | PASS | PASS | PASS |
| 13~19 | n≤1e6, k≤63 | TLE | TLE | PASS | PASS |
| 20 | n=0, m=0, k=64 | WA(溢出) | WA(溢出) | WA(溢出) | PASS |
汇总:zoo20 过 8/20;zoo40 过 12/20;zoo60 过 19/20(只差 k=64 的溢出点);
zoo100 过 20/20。两个官方样例(13、2)四份代码均通过。
说明:官方数据分档不严格,低档做法实际过点都超过承诺数(20% 暴力凭 k≤12 过了 8 个点……),
这很正常——多过点 = 白赚,但考场上仍按承诺档位估分最稳妥。
3.8 T2 可复用技巧
- 位运算统计集合:\(n,m\) 可达 \(10^6\),逐位逐动物检查是 \(O(nm)\) 必爆;把 \(n\) 个编号 OR 成
一个has、\(m\) 个 \(p\) OR 成一个need,一切判定变成常数时间位运算——位运算题通用套路。 - 先找"无关变量":\(q\)(饲料编号)只决定"会不会多买饲料",而答案要的正是"会不会多买",所以 \(q\) 可整体丢弃。做题先找无关量,模型立刻变简单。
- 64 位溢出处理:
1ULL << 64是未定义行为,\(2^{64}\) 放不进unsigned long long。
对策:用unsigned __int128承载(1 << freeBits) - n;输出不能printf("%llu"),须手写逐位取模输出。 - 移位防御:
1ULL << p要求 \(0\le p\le63\);题面保证 \(p<k\le64\),故 k=64 时 \(p\) 最大 63,按位做法依然合法。 - 组合数学收尾:约束只限制若干位取值(其余任意)时,总数就是 \(2^{\text{自由位}}\),记得减掉已占用的 \(n\) 个。
第四部分 · T3 指令流水线— 100 分详解
4.1 题目本质(通俗版)
维护 \(n\) 个数(对 998244353 取模),有 \(m\) 条指令,三类:
1 P V:把 \(a_P\) 加上 \(V\);2 V:所有数乘 \(V\);3 C g1..gC:依次执行 \(C\) 条子指令(保证调用关系无递归,构成 DAG)。
再给一个长度为 \(Q\) 的调用序列,依次执行后输出所有数。
难点只有一句:加法和乘法的顺序不可交换——一次加法最终会被"它之后发生的所有乘法"放大。
例如先加 1 再乘 2,得到 \(2a+2\);先乘 2 再加 1,得到 \(2a+1\)。指令嵌套 + 重复调用,
直接展开模拟的复杂度是爆炸的,必须把"之后的乘法"整体打包计算。
测试点分档(题面):
| 测试点 | \(n,m,Q\le\) | \(\sum C_j\) | 特殊限制 |
|---|---|---|---|
| 1~2 | 1000 | \(=m-1\) | 调用关系为树 |
| 3~4 | 1000 | \(\le100\) | 无 |
| 5~6 | 20000 | \(\le40000\) | 不含第 2 类 / 不含第 1 类 |
| 7 | 20000 | \(=0\) | 无 |
| 8~9 | 20000 | \(=m-1\) | 调用关系为树 |
| 10~11 | 20000 | \(\le2\times10^5\) | 无 |
| 12~13 | \(10^5\) | \(\le2\times10^5\) | 不含第 2 类 / 不含第 1 类 |
| 14 | \(10^5\) | \(=0\) | 无 |
| 15~20 | \(10^5\) | \(\le10^6\) | 无 |
4.2 应试策略
- 先写暴力(递归展开执行):30 分钟内可写完调完,稳拿 1~4;\(\sum C=0\) 的 7、14 天然能过;
实测还顺带过了 12,共 11 个点(约 64 分)。 - 性质档必须拿:5~6 与 12~13 是送分——"只有加法"用拓扑 DP 数执行次数、"只有乘法"
用总乘积 DP,两份代码都极短。 - 树结构档(12、89):把正解的 DP 用递归 DFS 实现(编译时
-Wl,--stack,268435456开大栈),
是本卷"正解思路但更简单实现"的典型,也是通向正解的跳板。 - 正解核心一句:"每个加法的贡献 = 加的值 × 该次加法之后所有乘法系数之积"。
把"之后"拆成两层:调用序列里的之后(全局后缀积) 与 函数内部调用表里的之后(兄弟后缀积),
在 DAG 上两个拓扑方向各做一次 DP。
4.3 档位 0:暴力递归展开(brute.cpp,实测 AC 1~9, 12, 14)
思路:读入后对序列每个函数递归执行:type1 单点加,type2 全体乘,type3 依次递归。
复杂度 \(O(Q\cdot \text{展开量})\),小数据与浅层嵌套能过。
// brute.cpp — 暴力档:按语义递归展开执行整个调用序列(实测 AC 1~9,12,14)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD=998244353;
const int N=100005;
int n,m,Q;
int T[N],P[N];
ll V[N];
vector<int> g[N];
ll a[N];
void exec(int u) // 递归执行指令 u(保证无递归调用)
{
if(T[u]==1) a[P[u]]=(a[P[u]]+V[u])%MOD;
else if(T[u]==2)
for(int i=1;i<=n;i++) a[i]=a[i]*V[u]%MOD;
else
for(int v:g[u]) exec(v);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
cin>>m;
for(int j=1;j<=m;j++)
{
cin>>T[j];
if(T[j]==1) cin>>P[j]>>V[j];
else if(T[j]==2) cin>>V[j];
else
{
int c;
cin>>c;
while(c--){int x;cin>>x;g[j].push_back(x);}
}
}
cin>>Q;
while(Q--){int f;cin>>f;exec(f);}
for(int i=1;i<=n;i++) cout<<a[i]<<(i==n?'\n':' ');
return 0;
}
实测:AC 1~9, 12, 14(6 号耗时 2.4s、14 号 2.9s 接近时限边缘);10/11/13/15~20 超时。约 64 分。
4.4 性质档 A:不含第 2 类指令(只有加法)(specialA.cpp,实测 AC 5, 12)
思路(通俗版):没有乘法时,加法可交换——先加谁后加谁无所谓。于是问题变成
"每个函数被执行了多少次":调用序列给顶层函数各 +1,再沿 DAG 从调用者向被调用者传递次数。
最后 a[P] += V * cnt[u]。
// specialA.cpp — 特殊性质 A:不含第 2 类指令(只有加法与组合,实测 AC 5,12)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD=998244353;
const int N=100005;
int n,m,Q;
int T[N],P[N];
ll V[N];
vector<int> g[N];
int deg[N]; // 出边意义上的"儿子"计数,用于拓扑
ll cnt[N]; // 每条指令被执行的总次数(模意义)
ll a[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
cin>>m;
for(int j=1;j<=m;j++)
{
cin>>T[j];
if(T[j]==1) cin>>P[j]>>V[j];
else if(T[j]==2) cin>>V[j]; // 本档不出现
else
{
int c;
cin>>c;
while(c--){int x;cin>>x;g[j].push_back(x);deg[x]++;}
}
}
cin>>Q;
for(int i=0;i<Q;i++){int f;cin>>f;cnt[f]=(cnt[f]+1)%MOD;}
// 反向拓扑序(先执行儿子再统计父亲):儿子即 g[u] 中的被调用者
queue<int> que;
for(int u=1;u<=m;u++) if(deg[u]==0) que.push(u);
while(!que.empty())
{
int u=que.front();que.pop();
if(T[u]==1) a[P[u]]=(a[P[u]]+V[u]%MOD*cnt[u])%MOD;
for(int v:g[u])
{
cnt[v]=(cnt[v]+cnt[u])%MOD;
if(--deg[v]==0) que.push(v);
}
}
for(int i=1;i<=n;i++) cout<<a[i]<<(i==n?'\n':' ');
return 0;
}
实测:AC 5, 12(正是"不含第 2 类"的那一半),其余含乘法的点全部 WA(符合预期,真部分分做法)。
4.5 性质档 B:不含第 1 类指令(只有乘法)(specialB.cpp,实测 AC 6, 13)
思路(通俗版):只有乘法时全体可交换,只需一个总乘积。自底向上求
mul[u] = 执行一次 \(u\) 产生的总乘法系数:type2 为 \(V\),type3 为调用表所有子节点 mul 之积。
答案 = \(a_i \times \prod_{f\in序列} mul[f]\)。
(注意:不能直接数执行次数——嵌套下执行次数是指数级的,必须以"乘积"为单位 DP。)
// specialB.cpp — 特殊性质 B:不含第 1 类指令(只有乘法与组合,实测 AC 6,13)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD=998244353;
const int N=100005;
int n,m,Q;
int T[N];
ll V[N];
vector<int> g[N],par[N]; // par[v]:谁调用 v
int rest[N]; // 尚未算出 mul 的儿子个数
ll mul[N],a[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
cin>>m;
for(int j=1;j<=m;j++)
{
cin>>T[j];
if(T[j]==1){int p;cin>>p>>V[j];} // 本档不出现
else if(T[j]==2) cin>>V[j];
else
{
int c;
cin>>c;
while(c--){int x;cin>>x;g[j].push_back(x);par[x].push_back(j);}
rest[j]=(int)g[j].size();
}
}
cin>>Q;
vector<int> fs(Q);
for(int i=0;i<Q;i++) cin>>fs[i];
// 自底向上(先儿子后父亲):叶子入队,弹出时向调用者汇报
queue<int> que;
for(int u=1;u<=m;u++)
{
mul[u]=(T[u]==2)?V[u]%MOD:1;
if(T[u]!=3||g[u].empty()) que.push(u);
}
while(!que.empty())
{
int u=que.front();que.pop();
if(T[u]==3)
for(int v:g[u]) mul[u]=mul[u]*mul[v]%MOD;
for(int f:par[u]) if(--rest[f]==0) que.push(f);
}
ll M=1;
for(int i=0;i<Q;i++) M=M*mul[fs[i]]%MOD;
for(int i=1;i<=n;i++) cout<<a[i]*M%MOD<<(i==n?'\n':' ');
return 0;
}
实测:AC 6, 13("不含第 1 类"的那一半),其余 WA(含加法)。
4.6 树结构档(tree.cpp,递归 DFS,实测 AC 1~20)
思路(通俗版):\(\sum C=m-1\) 且调用关系为树时,DAG 变成森林,直接用递归 DFS
实现正解的两个 DP 即可,代码比显式栈更短、更易读:
- 后序递归求
mul[u](每个节点只算一次,用doneMul标记防重复); - 调用序列从后往前维护后缀积 \(S\),
W[f] += S; - 前序递归传播
W:type3 的 \(u\) 从调用表末尾往前扫,维护兄弟后缀积suf,
W[g_k] += W[u] * suf,随后suf *= mul[g_k]; - 传播用入度计数:某节点的所有父节点都传完 \(W\) 才递归处理它(
--indeg[v]==0才dfsProp(v)),
这样在 DAG 上同样正确——实测本档代码 AC 了全部 20 个测试点; - type1:
add[P] += V * W[u];最终a_i = a_i * M + add_i。
编译注意:递归深度可达 \(10^5\),不用手写显式栈,编译时把栈开大即可:
g++ -O2 -std=c++17 -Wl,--stack,268435456 -o tree tree.cpp(MinGW/Linux 均支持该参数)。
// tree.cpp — 树结构档:递归 DFS 版(实测 AC 1~20;与 40% 暴力对拍 300 组随机树一致)
// 做法:后序递归求 mul[u](每个节点只算一次),前序递归传播加权次数 W[u];
// 传播用入度计数:某节点的所有父节点都传完 W 才递归处理它(DAG 上同样正确)。
// 编译时请开大栈空间,例如:g++ -O2 -std=c++17 -Wl,--stack,268435456 -o tree tree.cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD=998244353;
const int N=100005;
int n,m,Q;
int T[N],P[N];
ll V[N];
vector<int> g[N];
int indeg[N]; // 作为被调用者出现的次数(入度)
ll mul[N],W[N],a[N],add[N];
vector<char> doneMul;
void dfsMul(int u) // 后序:先递归孩子,再算自己的总乘法系数
{
doneMul[u]=1;
mul[u]=1; // 关键:type1 节点的 mul 保持 1(无乘法效果)
if(T[u]==2) mul[u]=V[u]%MOD;
else if(T[u]==3)
{
mul[u]=1;
for(int v:g[u])
{
if(!doneMul[v]) dfsMul(v);
mul[u]=mul[u]*mul[v]%MOD;
}
}
}
void dfsProp(int u) // 前序:u 的所有父节点都传完 W 才会被调用一次
{
if(T[u]==1)
add[P[u]]=(add[P[u]]+V[u]%MOD*W[u])%MOD;
else if(T[u]==3)
{
ll suf=1;
for(int k=(int)g[u].size()-1;k>=0;k--)
{
int v=g[u][k];
W[v]=(W[v]+W[u]*suf)%MOD;
suf=suf*mul[v]%MOD;
if(--indeg[v]==0) dfsProp(v); // 所有父的 W 都传完才处理 v
}
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
cin>>m;
for(int j=1;j<=m;j++)
{
cin>>T[j];
if(T[j]==1) cin>>P[j]>>V[j];
else if(T[j]==2) cin>>V[j];
else
{
int c;
cin>>c;
while(c--){int x;cin>>x;g[j].push_back(x);indeg[x]++;}
}
}
cin>>Q;
vector<int> fs(Q);
for(int i=0;i<Q;i++) cin>>fs[i];
vector<int> indeg0(indeg, indeg+m+1); // 初始入度:只用于识别根节点(dfsProp 会修改 indeg)
doneMul.assign(m+1,0);
// 1) 后序求 mul:从所有尚未访问的节点开始(森林/多根皆可)
for(int u=1;u<=m;u++)
if(!doneMul[u]) dfsMul(u);
// 2) 调用序列从后往前:W[f] += 当前后缀乘积 S;同时累加总乘积 M
ll M=1,S=1;
for(int i=Q-1;i>=0;i--)
{
W[fs[i]]=(W[fs[i]]+S)%MOD;
S=S*mul[fs[i]]%MOD;
}
M=S;
// 3) 前序(自顶向下)传播 W:只从初始入度为 0 的根启动
for(int u=1;u<=m;u++)
if(indeg0[u]==0) dfsProp(u);
for(int i=1;i<=n;i++) cout<<(a[i]*M+add[i])%MOD<<(i==n?'\n':' ');
return 0;
}
实测:AC 1~20 全部(最大 0.12s);另与正解对拍 300 组随机树数据全部一致。
原因是"入度计数 + 只处理一次"的传播在 DAG 上同样正确,本档代码的实际覆盖超过了树结构承诺档位。
4.7 正解:DAG 双向拓扑 DP(full.cpp,实测 AC 1~20)
思路(通俗版,含完整推导):
定义两个量:
mul[u]:执行一次 \(u\) 的调用闭包中所有第 2 类指令系数之积(自底向上算:儿子全部就绪才处理父亲);W[u]:\(u\) 的加权执行次数——每次执行 \(u\),记权为"这次执行之后发生的所有乘法系数之积"。
它恰好等于该次执行里 \(u\) 直接做的加法应被放大的倍数。
算法三步:
- 自底向上(被调用者先处理)求
mul[u]。 - 调用序列从后往前:维护全局后缀积 \(S\)(初值 1),
W[f_i] += S; S *= mul[f_i]。
走完后 \(S=M\) 就是总乘积。 - 自顶向下(调用者先处理,Kahn 拓扑)传播
W:- type1:
add[P] += V * W[u]; - type3 的 \(u\) 执行 \(W[u]\) 次,其第 \(k\) 个被调用者 \(g_k\) 的权 =
W[u] × (mul[g_{k+1}] × … × mul[g_C]),
即从调用表末尾往前扫、维护运行后缀积;type2 的W无用(乘法已隐含在权里)。
- type1:
最终 a_i = a_i(初值) × M + add_i。注意:add 已经带全了后缀乘法权,最后不能再乘 \(M\);只有初值乘 \(M\)。
这是本题最常见的实现错误,务必在输出处单独验证。
复杂度 \(O(n+m+\sum C+Q)\),实测最大点约 0.15s。
// full.cpp — 正解:DAG + 双向拓扑 DP(实测 AC 1~20)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD=998244353;
const int N=100005;
int n,m,Q;
int T[N],P[N];
ll V[N];
vector<int> g[N];
int indeg[N]; // 作为被调用者出现的次数(入度)
ll mul[N]; // 执行一次 u 的调用闭包中所有乘法系数之积
ll W[N]; // u 的加权执行次数:该次执行之后所有乘法系数之积
ll a[N],add[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
cin>>m;
for(int j=1;j<=m;j++)
{
cin>>T[j];
if(T[j]==1) cin>>P[j]>>V[j];
else if(T[j]==2) cin>>V[j];
else
{
int c;
cin>>c;
while(c--){int x;cin>>x;g[j].push_back(x);indeg[x]++;}
}
}
cin>>Q;
vector<int> fs(Q);
for(int i=0;i<Q;i++) cin>>fs[i];
// 1) 自底向上(被调用者先处理)求 mul[u]:儿子全部完成才处理父亲
{
vector<int> rem(m+1,0);
for(int u=1;u<=m;u++){mul[u]=1;rem[u]=(int)g[u].size();}
queue<int> que;
for(int u=1;u<=m;u++) if(rem[u]==0) que.push(u);
vector<vector<int>> par(m+1);
for(int u=1;u<=m;u++) for(int v:g[u]) par[v].push_back(u);
while(!que.empty())
{
int u=que.front();que.pop();
if(T[u]==2) mul[u]=V[u]%MOD;
else if(T[u]==3) for(int v:g[u]) mul[u]=mul[u]*mul[v]%MOD;
for(int f:par[u]) if(--rem[f]==0) que.push(f);
}
}
// 2) 调用序列从后往前:W[f] += 后缀乘积 S;M = S 走完即总乘积
ll S=1;
for(int i=Q-1;i>=0;i--)
{
W[fs[i]]=(W[fs[i]]+S)%MOD;
S=S*mul[fs[i]]%MOD;
}
ll M=S;
// 3) 自顶向下(调用者先处理)传播 W
{
queue<int> que;
for(int u=1;u<=m;u++) if(indeg[u]==0) que.push(u);
while(!que.empty())
{
int u=que.front();que.pop();
if(T[u]==1)
add[P[u]]=(add[P[u]]+V[u]%MOD*W[u])%MOD;
else if(T[u]==3)
{
ll suf=1;
for(int k=(int)g[u].size()-1;k>=0;k--)
{
int v=g[u][k];
W[v]=(W[v]+W[u]*suf)%MOD;
suf=suf*mul[v]%MOD;
if(--indeg[v]==0) que.push(v);
}
}
}
}
for(int i=1;i<=n;i++) cout<<(a[i]*M+add[i])%MOD<<(i==n?'\n':' ');
return 0;
}
实测:AC 1~20 全部,最大点约 0.15s。
4.8 T3 实测通过汇总
| 代码 | 通过测试点 | 得分(估) |
|---|---|---|
brute.cpp |
1~9, 12, 14 | ~64 |
specialA.cpp |
5, 12 | 8 |
specialB.cpp |
6, 13 | 8 |
tree.cpp |
1~20(全部) | 100(递归 DFS + 入度计数,实际覆盖全部点) |
full.cpp |
1~20(全部) | 100 |
(暴力/树档能"超额"通过是数据特性;按题面承诺档位它们分别保底 1~4 与 12/89。)
4.9 T3 可复用技巧
- 拓扑 DP 的两个方向:依赖"儿子信息"(如 mul)用被调用者先处理的 Kahn;依赖"父亲信息"
(如加权次数 W)用入度 = 被调用次数的正向 Kahn。DAG 上正反两次扫描是此类"仿射变换合成"题的定式。 - 贡献法 / 乘法标记的滞后处理:不模拟运算顺序,而是对每个加法单独问"我最终被放大多少倍"。
把"之后"分解为"全局序列后缀 × 函数内兄弟后缀"两层,分别用一次倒扫与一次拓扑传播解决。
同类技巧见"线段树 2"(加乘标记下推顺序)、洛谷 P2023 维护序列。 - 执行次数可能是指数级:任何涉及"数次数"的部分分(纯乘法档)必须直接在乘法半群意义下
DP 乘积,而不是数次数再快速幂。 - 自底向上求"执行一次的效果":
mul[u] = prod mul[son],本质是 DAG 上的闭包统计,与树上子树和同构。 - 工程细节:深度可达 \(10^5\) 的递归用显式栈;取模运算
V % MOD * W % MOD防溢出;
本地 Windows 注意 CRLF 与官方答案行末空格。
第五部分 · T4 商业兼并(原题:贪吃蛇)— 100 分详解
5.1 题目本质(通俗版)
\(n\) 条蛇,体力 \(a_i\)(输入保证不降)。强度按 (体力, 编号) 比较(体力相等时编号大者强)。
每轮最强蛇可以选择吃/不吃最弱蛇:
- 吃:最强蛇体力减去最弱蛇体力,最弱蛇出局,进入下一轮;
- 不吃:决斗立刻结束。
每条蛇在自己不被吃的前提下尽量多吃。求最终存活几条。多组数据(\(T\le10\)),
后续组只修改部分蛇的体力。
关键直觉:这是博弈题,但只有一个决策点(最强蛇吃不吃)。"尽量多吃"意味着:
如果能保证自己活到最后,就吃;如果吃了会死,就不吃。推理链:
-
安全吃:最强蛇吃完后如果不是最弱,则它必然吃——因为之后每轮的最大值不增、
被吃掉的永远是最小值,它再也不会成为最弱,吃是纯赚。 -
强制链:若吃完后变成最弱,那么下一轮它会被新最强蛇吃——但新最强蛇又面临同样问题……
于是进入一条"被迫连吃"的链,直到某次吃完后不再是最弱(安全)或只剩一条蛇。
设链中"吃完变最弱"的次数为 \(L\)(含第一次):- \(L\) 为奇数 → 链首蛇不吃,决斗结束,答案 = 链开始前的蛇数;
- \(L\) 为偶数 → 链首蛇吃一口,下一条蛇(此时链长为奇)不吃,答案 = 链开始前的蛇数 \(-1\)。
奇偶性直觉:链末"吃完安全"的蛇必吃;倒推回去,第 \(i\) 条蛇吃 ⟺ 下一条蛇不吃
(吃了下轮会被吃),逐层取反就是奇偶判定。
实现关键:安全吃可能进行很多轮,每轮找最强/最弱要用数据结构。发现新蛇的体力
\(d=max-min\) 随轮数单调不增,于是用两个单调队列即可 \(O(1)\) 取最值。
5.2 档位 1:n=3 手推公式(20 分,brute20.cpp,实测过 1~4)
三条蛇按强度 \(w<m<s\)。\(s\) 吃 \(w\) 后新值 \(d=a_s-a_w\):
- 若 \((d,id_s)\) 仍强于 \(m\):\(s\) 安全吃 \(w\),下一轮再吃 \(m\),答案 1;
- 否则 \(s\) 吃后变最弱,下一轮 \(m\) 必吃 \(s\)(\(m\) 安全),\(s\) 会死 → \(s\) 不吃,答案 3。
// brute20.cpp — 20% 档:n=3 手推公式(实测 AC 1~4)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> P;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T,n;
cin>>T;
cin>>n;
vector<ll> a(n+1);
for(int i=1;i<=n;i++) cin>>a[i];
for(int tc=1;tc<=T;tc++)
{
if(tc>1) // 第二组起才有修改信息
{
int k;
cin>>k;
for(int j=0;j<k;j++){int x;ll y;cin>>x>>y;a[x]=y;}
}
vector<P> v={{a[1],1},{a[2],2},{a[3],3}};
sort(v.begin(),v.end());
P s=v[2],m=v[1],w=v[0];
P ns={s.first-w.first,s.second};
cout<<(ns>m?1:3)<<"\n";
}
return 0;
}
5.3 档位 2:精确博弈递归(40 分,brute40.cpp,实测过 1~11)
不猜任何结论,直接按题意递归:每轮找最强 \(s\) 与最弱 \(w\),递归求出"若 \(s\) 吃"后的最终存活集合;
若 \(s\) 在该集合里则它吃(活着且多吃了一口严格更优),否则决斗立即结束。每层复制状态 \(O(n)\),
深度 ≤ n,单组 \(O(n^2)\)。这份代码是全文对拍的标尺。
// brute40.cpp — 40% 档:n<=10 精确博弈搜索(也是对拍标尺,实测过 11 个点)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> P; // (体力, 编号),编号大者强
vector<P> play(vector<P> v)
{
if(v.size()==1) return v;
P mx=v[0],mn=v[0];
for(auto &p:v){if(p>mx) mx=p;if(p<mn) mn=p;}
vector<P> w;
bool usedMx=false,usedMn=false;
for(auto &p:v)
{
if(!usedMn&&p==mn){usedMn=true;continue;}
if(!usedMx&&p==mx){usedMx=true;w.push_back({mx.first-mn.first,mx.second});continue;}
w.push_back(p);
}
vector<P> r=play(w);
for(auto &p:r) if(p.second==mx.second) return r; // 吃后存活 -> 吃
return v; // 吃了会死 -> 停
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T,n;
cin>>T;
cin>>n;
vector<ll> a(n+1);
for(int i=1;i<=n;i++) cin>>a[i];
for(int tc=1;tc<=T;tc++)
{
if(tc>1)
{
int k;
cin>>k;
for(int j=0;j<k;j++){int x;ll y;cin>>x>>y;a[x]=y;}
}
vector<P> v;
for(int i=1;i<=n;i++) v.push_back({a[i],i});
cout<<(int)play(v).size()<<"\n";
}
return 0;
}
实测:1~8 档内全对(<0.1s),9~11(n=2000)也顺带通过(约 0.3s)→ 实得 55%;12+ 递归爆内存。
5.4 档位 3:O(n²) 直接模拟(55 分,brute55.cpp,实测过 1~11)
决策逻辑与正解相同(已被 40% 暴力对拍验证),但每轮用 \(O(n)\) 线性扫描找最强/最弱并维护存活数组。
// brute55.cpp — 55% 档:每轮线性扫描模拟(同正解决策逻辑,实测过 11 个点)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> P;
int n;
vector<ll> a;
bool stronger(const P &x,const P &y) // x 比 y 强?
{
return x.first>y.first||(x.first==y.first&&x.second>y.second);
}
int play()
{
vector<P> cur;
for(int i=1;i<=n;i++) cur.push_back({a[i],i});
int cnt=n;
ll chainStart=-1,L=0;
while(cnt>1)
{
int mx=0,mn=0;
for(int i=1;i<(int)cur.size();i++)
{
if(stronger(cur[i],cur[mx])) mx=i;
if(stronger(cur[mn],cur[i])) mn=i;
}
P ns={cur[mx].first-cur[mn].first,cur[mx].second};
cur[mx]=ns;
cur.erase(cur.begin()+mn);
cnt--;
// ns 是最弱 <=> 其余蛇都严格强于 ns;只剩 ns 自己时安全
bool eaterIsWeakest=cur.size()>1;
for(auto &p:cur)
if(p!=ns&&!stronger(p,ns)){eaterIsWeakest=false;break;}
if(eaterIsWeakest)
{
if(chainStart<0) chainStart=cnt+1;
L++;
}
else if(chainStart>=0)
return (int)(L%2==1?chainStart:chainStart-1);
}
if(chainStart>=0) return (int)(L%2==1?chainStart:chainStart-1);
return cnt;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T;
cin>>T;
cin>>n;
a.resize(n+1);
for(int i=1;i<=n;i++) cin>>a[i];
for(int tc=1;tc<=T;tc++)
{
if(tc>1)
{
int k;
cin>>k;
for(int j=0;j<k;j++){int x;ll y;cin>>x>>y;a[x]=y;}
}
cout<<play()<<"\n";
}
return 0;
}
实测:1~11 时限内通过(<0.2s);12~14(n=5e4)答案正确但单点约 15s 超时;15+ 超时。
5.5 正解:双端队列(100 分,snakes.cpp,实测过 1~20)
核心结论回顾(已用暴力对拍 3000+ 组验证):
- 最强蛇吃掉最弱蛇后若不是最弱 → 安全,必然吃;
- 若吃完变成最弱 → 进入强制连吃链,链中"吃完变最弱"次数 \(L\) 为奇数则链首不吃
(答案 = 链开始前蛇数),\(L\) 为偶数则链首吃一口即停(答案 = 链开始前蛇数 − 1)。
双队列实现:原数组升序当队列 A;新蛇依次压入队列 B(\(d=max-min\) 单调不增,B 值单调不降)。
每轮:
- 当前最强 = max(A 尾, B 头),当前最弱 = min(A 头, B 尾)(按 (值,编号) 比较);
- 吃掉后把新蛇 \((d, id_{max})\) 压入 B;
- 判定"吃完是否变最弱"要用压入之前的其余蛇最弱者比较(
eaterIsWeakest = has && !stronger(ns, mn),
其中 mn 取 A[ai] 与 B.back() 中较弱者); - 链触发时记
chainStart(链首吃之前的蛇数),L++;某次吃完不是最弱即判定奇偶返回。
每条蛇进出各一次,单组 \(O(n\log n)\)(重排)+ \(O(n)\)(模拟)。\(n\le10^6,T\le10\) 轻松通过。
// snakes.cpp — 正解:双端队列 O(n)/组(实测 AC 1~20)
// 结论(对拍验证):
// 1) 最强蛇吃掉最弱蛇后若不是最弱 => 安全,必然吃,继续。
// 2) 若吃完变成最弱 => 进入"强制连吃链",链中"吃完变最弱"次数为 L(含第一次):
// L 为奇数 -> 链首不吃,答案 = 链开始前的蛇数;
// L 为偶数 -> 链首吃一口即停,答案 = 链开始前的蛇数 - 1。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> P;
int n;
vector<P> A; // 原数组,升序(值, 编号)
deque<P> B; // 新蛇队列,值单调不升
bool stronger(const P &x,const P &y) // x 比 y 强?
{
return x.first>y.first||(x.first==y.first&&x.second>y.second);
}
int solve()
{
B.clear();
size_t ai=0,aj=A.size(); // A 的有效区间 [ai, aj)
int cnt=n;
ll chainStart=-1; // 链开始前(即链首吃之前)的蛇数
ll L=0; // 链中"吃完变最弱"的次数
while(cnt>1)
{
// 取最强
P s;
if(ai<aj&&!B.empty())
{
if(stronger(A[aj-1],B.front())){s=A[aj-1];aj--;}
else {s=B.front();B.pop_front();}
}
else if(ai<aj){s=A[aj-1];aj--;}
else {s=B.front();B.pop_front();}
// 取最弱
P w;
if(ai<aj&&!B.empty())
{
if(stronger(A[ai],B.back())){w=B.back();B.pop_back();}
else {w=A[ai];ai++;}
}
else if(ai<aj){w=A[ai];ai++;}
else {w=B.back();B.pop_back();}
// 其余存活蛇中的最弱者(ns 尚未入 B):取 A[ai] 与 B.back() 中较弱者
P mn;
bool has;
if(ai<aj&&(B.empty()||!stronger(A[ai],B.back()))){mn=A[ai];has=true;}
else if(!B.empty()){mn=B.back();has=true;}
else has=false;
P ns={s.first-w.first,s.second};
B.push_back(ns);
cnt--;
// ns 是最弱 <=> 其余蛇都严格强于 ns(等强度不可能出现)
bool eaterIsWeakest=has&&!stronger(ns,mn);
if(eaterIsWeakest)
{
if(chainStart<0) chainStart=cnt+1; // 链首这次吃之前的蛇数
L++;
}
else if(chainStart>=0) // 链在此解决(本次吃的蛇安全)
return (int)(L%2==1?chainStart:chainStart-1);
}
if(chainStart>=0) return (int)(L%2==1?chainStart:chainStart-1);
return cnt;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int T;
cin>>T;
cin>>n;
vector<ll> a(n+1);
for(int i=1;i<=n;i++) cin>>a[i];
A.resize(n);
for(int tc=1;tc<=T;tc++)
{
if(tc>1) // 第二组起才有修改信息
{
int k;
cin>>k;
for(int j=0;j<k;j++){int x;ll y;cin>>x>>y;a[x]=y;}
}
for(int i=1;i<=n;i++) A[i-1]={a[i],i};
sort(A.begin(),A.end());
cout<<solve()<<"\n";
}
return 0;
}
验证过程:正解与 55% 档均与精确博弈暴力对拍(n≤16、值域 0..40 制造大量并列与 0,
共约 3800 组随机多组数据)完全一致;期间抓出并修复两个模拟 bug(只剩一条蛇时"是否最弱"的
空真判定、判定循环误含新蛇自身)。奇偶结论未依赖记忆,纯靠对拍确认。
5.6 T4 实测通过汇总
| 程序 | 通过测试点 | 未通过 | 说明 |
|---|---|---|---|
| brute20.cpp | 1, 2, 3, 4(各 <0.1s) | 5+(n=3 保证之外) | 20% 档 |
| brute40.cpp | 1–8(<0.1s),9–11 也对(n=2000,约 0.3s) | 12+(递归爆内存/超时) | 实得 55% |
| brute55.cpp | 1–11(<0.2s);12–14 结果正确但单点约 15s(超真实时限) | 15+(n=1e6 超时) | 时限内 55%,正确性覆盖 70% |
| snakes.cpp | 全部 1–20(n=1e6 单点约 0.3s) | 无 | 100% |
5.7 T4 可复用技巧
- 博弈题部分分:先写"无脑精确暴力"再对拍。把"每条蛇在不被吃前提下尽量多吃"翻译成递归规则
"最强蛇吃 ⟺ 吃完后自己能存活",每层只有一个决策点,递归天然无分支、\(O(n^2)\)。
对拍时生成器要压小值域制造并列值与 0——这些才是结论翻车的高发区(本题强度含编号的字典序就是坑)。 - 单调性 + 双队列替代平衡树/堆:需要"删最小、删最大、插入新值"且新值单调时(本题 \(d=max-min\) 不增),
两个双端队列各管一头即可 \(O(1)\) 每轮,比 multiset 快一个 \(\log\) 且代码更短。 - 链式博弈的奇偶判定:"吃或不吃取决于下一层是否吃"的递推往往化归为链长的奇偶性;
想不清楚时枚举链末两条蛇的行为倒推。记住本题结构:安全吃若干次后,第一次"吃完变最弱"触发链,
链判定后决斗立刻结束。 - 多组数据只改部分值的复用:数组跨组保留、按 \((x,y)\) 就地覆盖(同一位置多次修改自动以最后一次为准);
需要有序结构时可用"未修改的有序段 + 修改的点"归并,避免每组 \(O(n\log n)\) 重排(本题 \(T\le10\) 直接重排已足够)。 - Windows/MinGW 下 printf 输出 CRLF,与官方 .ans 做 diff 时用
diff --strip-trailing-cr。
第六部分 · 全卷可复用技巧总表
| # | 技巧 | 出处 | 一句话 |
|---|---|---|---|
| 1 | 暴力先行 + 对拍 | 全部四题 | 先写"照题意直接做"的暴力当保底分和对拍标尺,再升级 |
| 2 | 位运算集合统计 | T2 | has |= a_i、need |= 1<<p、forbidden = need & ~has,判定 O(1) |
| 3 | 找"无关变量"先化简 | T2 | 饲料编号 q 与答案无关,直接丢弃,模型立刻变简单 |
| 4 | 64 位溢出防御 | T2 | 1ULL<<64 未定义;unsigned __int128 + 手写十进制输出 |
| 5 | 离线化换复杂度 | T1 | 无强制在线时"排序 + 共享一次扫描",30 分 → 80 分只改 15 行 |
| 6 | 周期跳跃替代逐日 | T1 | 历法问题用 4 年/400 年周期或"年首公式 + 二分"批量跳年 |
| 7 | 天文年编号消解"无 0 年" | T1 | \(a=1-y\) 把不连续纪年映射成连续整数轴,只在输出换回 |
| 8 | 自造 fdiv(向下取整除法) | T1 | C++ 负数除法向零取整,\(\lfloor a/b\rfloor\) 要手动修正 |
| 9 | 二分模板 while(l<=r)+ans |
T1/T2 | 求"最大可行值"时:if(check(mid)) ans=mid,l=mid+1; else r=mid-1 |
| 10 | 贡献法 / 延迟标记滞后处理 | T3 | 把"之后的乘法"变成每条加法指令的权重,避免逐元素模拟 |
| 11 | DAG 双向拓扑 DP | T3 | 依赖儿子的信息(mul)与依赖父亲的信息(W)各做一遍 Kahn |
| 12 | 执行次数指数级 → 在乘法半群上 DP | T3 | "数次数"不可行时直接算乘积 |
| 13 | 单调性双队列替代平衡树 | T4 | 新值单调时双端队列 \(O(1)\) 取最值,省一个 \(\log\) |
| 14 | 链式博弈奇偶判定 | T4 | "吃 ⟺ 下一层不吃"的递推化归为链长奇偶性,链末倒推验证 |
| 15 | 多组数据增量复用 | T4 | 数组跨组保留、就地覆盖修改,避免每组建全量结构 |
| 16 | 工程四件套 | 全部 | ios::sync_with_stdio(false)+cin、显式栈防爆栈、long long 按数据范围开、% 防溢出 |
| 17 | Windows 换行坑 | 全部 | diff 前 tr -d '\r' 或 diff --strip-trailing-cr |
附:文件清单
solution/
├── 题解.md 本文档(总览 + 四题全档位详解 + 完整代码)
├── julian/ (T1 星历换算 / 儒略日,官方 10 个测试点)
│ ├── sol1_brute_30pts.cpp 档位1:逐天模拟,实测 AC 1~4
│ ├── sol2_offline_80pts.cpp 档位2:离线单趟扫描,实测 AC 1~9
│ ├── sol3_full_100pts.cpp 正解:分段 + 年首公式 + 二分,实测 AC 1~10
│ └── 题解_julian.md 分题解(旧版,代码风格已同步为本文档所示版本)
├── zoo/ (T2 补丁管理 / 动物园,20 个测试点)
│ ├── zoo20.cpp 20% 暴力枚举,实测过 8 点
│ ├── zoo40.cpp 40% 枚举 + 按位,实测过 12 点
│ ├── zoo60.cpp 60% 按位 ull,实测过 19 点
│ ├── zoo100.cpp 正解(__int128),实测过 20 点
│ └── 题解_zoo.md 分题解
├── call/ (T3 指令流水线 / 函数调用,20 个测试点)
│ ├── brute.cpp 暴力递归展开,实测过 11 点
│ ├── specialA.cpp 无乘法档,实测 AC 5,12
│ ├── specialB.cpp 无加法档,实测 AC 6,13
│ ├── tree.cpp 树结构档(递归 DFS),实测 AC 1~20
│ ├── full.cpp 正解(双向拓扑 DP),实测 AC 1~20
│ └── 题解_call.md 分题解
└── snakes/ (T4 商业兼并 / 贪吃蛇,20 个测试点)
├── brute20.cpp 20% n=3 公式,实测 AC 1~4
├── brute40.cpp 40% 博弈递归(对拍标尺),实测过 11 点
├── brute55.cpp 55% O(n²) 模拟,实测过 11 点
├── snakes.cpp 正解(双队列),实测 AC 1~20
├── gen.cpp 对拍用数据生成器
└── 题解_snakes.md 分题解
浙公网安备 33010602011771号