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 通用于四题的工作流

  1. 先建对拍环境:每道题先写"照题意直接做"的暴力(哪怕超时),再与升级版对拍。
    T4 的博弈结论、T1 的换历边界都是"猜错/看漏就全错"的地方,对拍是唯一可靠的验证手段。
  2. 读入优化:n,m,Q 可达 \(10^5\sim 10^6\),T3/T4 必须手写快读或用 ios::sync_with_stdio(false) + cin。
  3. 按数据范围读出类型:"年份 ≤ 1e9" → 中间量要到 \(3.6\times10^{11}\),必须 long long。

第二部分 · T1 星历换算— 100 分详解

2.1 题目本质(通俗版)

给你一个整数 \(r\),表示"从公元前 4713 年 1 月 1 日正午算起,过了多少整天",请输出 \(r\) 对应的公历日期。
规矩很绕,拆成四条:

  1. 公元 1582 年 10 月 15 日之后(含):用格里高利历——普通年份 365 天;闰年规则是
    "400 的倍数,或 4 的倍数但不是 100 的倍数"。
  2. 公元 1582 年 10 月 5 日 ~ 10 月 14 日:不存在,10 月 4 日的下一天直接是 10 月 15 日。
  3. 1582 年 10 月 4 日之前(含):用儒略历——只要年份是 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 应试策略

  1. 先想清楚"公元无 0 年"怎么表示(见 2.3 天文年编号),这一步错了,公元前所有测试点全错。
  2. 暴力也要完整实现换历(10-04 → 10-15)和公元前,否则 1~3 号点都过不了。
    暴力档的分数同样是"完整规则"换来的,不能偷工。
  3. 所有手推常数必须用暴力验证(1721424 / 2299160 / 2299239 这几个分界值),不要靠"感觉"。
  4. 升级路径明确:逐天模拟(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. 日期加 1,若超过当月天数,进位到月、年;
  2. 换历特判:当走到 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. 题面样例 1/2 全过(三档都过)。
  2. 构造 1762 个询问的边界对拍集:随机 \(r\in[0,10^5]\) + 六个分界点前后 ±25 天 +
    年份 4/100/300/1500/1580/1584/1600/1700/1900/2000/2400 的 2 月末/年末窗口,正解与暴力输出完全一致。
  3. 官方 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 可复用技巧

  1. 历法/时间类题的万能骨架:锚点日期 + "年首序号函数 \(f(年)\)" + 年内 doy + 月长表拆分。
    查询 = "定位年份(二分,\(f\) 单调即可)+ 拆月日"两步,不必手推周期循环的边界特判。
  2. 周期化:儒略历 4 年 = 1461 天,格里历 400 年 = 146097 天。凡"跨很多年"的计数,先找周期再数余数。
  3. 公元前无 0 年 → 天文年编号 \(a=1-y\):把不连续的纪年映射成连续整数轴,加法、取模、周期全部自然成立;只在输出时换回 BC。
  4. 负数取模:C++ 的 /、% 向零取整,写 \(\lfloor a/b\rfloor\) 必须自造 fdiv(见正解),判整除用 ((a%k)+k)%k==0。
  5. 换历/跳日这类"日历空洞":在逐天模拟里是一次特殊跳转(10-04→10-15),在公式法里是一段手写小区间;两种表示互相验证。
  6. 离线化换复杂度:无强制在线时,"逐询问各算各"→"排序后共享一次扫描"是零成本大优化(本题 30 分 → 80 分只改 15 行)。
  7. 暴力即对拍器:先花 20 分钟把规则完整的暴力写稳,它既是保底分又是正解的随机 + 边界对拍基准。
  8. 读数据范围读出类型:"年份 ≤ 1e9" 直接翻译成"中间量可能到 4e11,开 long long"。
  9. 输出格式细节清单: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,其余位任意。所以:

\[\text{自由位}=k-\text{popcount}(forbidden),\qquad \text{答案}=2^{\text{自由位}}-n \]

第四步:处理 \(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 可复用技巧

  1. 位运算统计集合:\(n,m\) 可达 \(10^6\),逐位逐动物检查是 \(O(nm)\) 必爆;把 \(n\) 个编号 OR 成
    一个 has、\(m\) 个 \(p\) OR 成一个 need,一切判定变成常数时间位运算——位运算题通用套路。
  2. 先找"无关变量":\(q\)(饲料编号)只决定"会不会多买饲料",而答案要的正是"会不会多买",所以 \(q\) 可整体丢弃。做题先找无关量,模型立刻变简单。
  3. 64 位溢出处理:1ULL << 64 是未定义行为,\(2^{64}\) 放不进 unsigned long long。
    对策:用 unsigned __int128 承载 (1 << freeBits) - n;输出不能 printf("%llu"),须手写逐位取模输出。
  4. 移位防御:1ULL << p 要求 \(0\le p\le63\);题面保证 \(p<k\le64\),故 k=64 时 \(p\) 最大 63,按位做法依然合法。
  5. 组合数学收尾:约束只限制若干位取值(其余任意)时,总数就是 \(2^{\text{自由位}}\),记得减掉已占用的 \(n\) 个。

第四部分 · T3 指令流水线— 100 分详解

4.1 题目本质(通俗版)

维护 \(n\) 个数(对 998244353 取模),有 \(m\) 条指令,三类:

  1. 1 P V:把 \(a_P\) 加上 \(V\);
  2. 2 V:所有数乘 \(V\);
  3. 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 应试策略

  1. 先写暴力(递归展开执行):30 分钟内可写完调完,稳拿 1~4;\(\sum C=0\) 的 7、14 天然能过;
    实测还顺带过了 12,共 11 个点(约 64 分)。
  2. 性质档必须拿:5~6 与 12~13 是送分——"只有加法"用拓扑 DP 数执行次数、"只有乘法"
    用总乘积 DP,两份代码都极短。
  3. 树结构档(12、89):把正解的 DP 用递归 DFS 实现(编译时 -Wl,--stack,268435456 开大栈),
    是本卷"正解思路但更简单实现"的典型,也是通向正解的跳板。
  4. 正解核心一句:"每个加法的贡献 = 加的值 × 该次加法之后所有乘法系数之积"。
    把"之后"拆成两层:调用序列里的之后(全局后缀积) 与 函数内部调用表里的之后(兄弟后缀积),
    在 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\) 直接做的加法应被放大的倍数。

算法三步:

  1. 自底向上(被调用者先处理)求 mul[u]。
  2. 调用序列从后往前:维护全局后缀积 \(S\)(初值 1),W[f_i] += S; S *= mul[f_i]。
    走完后 \(S=M\) 就是总乘积。
  3. 自顶向下(调用者先处理,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 无用(乘法已隐含在权里)。

最终 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 可复用技巧

  1. 拓扑 DP 的两个方向:依赖"儿子信息"(如 mul)用被调用者先处理的 Kahn;依赖"父亲信息"
    (如加权次数 W)用入度 = 被调用次数的正向 Kahn。DAG 上正反两次扫描是此类"仿射变换合成"题的定式。
  2. 贡献法 / 乘法标记的滞后处理:不模拟运算顺序,而是对每个加法单独问"我最终被放大多少倍"。
    把"之后"分解为"全局序列后缀 × 函数内兄弟后缀"两层,分别用一次倒扫与一次拓扑传播解决。
    同类技巧见"线段树 2"(加乘标记下推顺序)、洛谷 P2023 维护序列。
  3. 执行次数可能是指数级:任何涉及"数次数"的部分分(纯乘法档)必须直接在乘法半群意义下
    DP 乘积,而不是数次数再快速幂。
  4. 自底向上求"执行一次的效果":mul[u] = prod mul[son],本质是 DAG 上的闭包统计,与树上子树和同构。
  5. 工程细节:深度可达 \(10^5\) 的递归用显式栈;取模运算 V % MOD * W % MOD 防溢出;
    本地 Windows 注意 CRLF 与官方答案行末空格。

第五部分 · T4 商业兼并(原题:贪吃蛇)— 100 分详解

5.1 题目本质(通俗版)

\(n\) 条蛇,体力 \(a_i\)(输入保证不降)。强度按 (体力, 编号) 比较(体力相等时编号大者强)。
每轮最强蛇可以选择吃/不吃最弱蛇:

  • 吃:最强蛇体力减去最弱蛇体力,最弱蛇出局,进入下一轮;
  • 不吃:决斗立刻结束。

每条蛇在自己不被吃的前提下尽量多吃。求最终存活几条。多组数据(\(T\le10\)),
后续组只修改部分蛇的体力。

关键直觉:这是博弈题,但只有一个决策点(最强蛇吃不吃)。"尽量多吃"意味着:
如果能保证自己活到最后,就吃;如果吃了会死,就不吃。推理链:

  1. 安全吃:最强蛇吃完后如果不是最弱,则它必然吃——因为之后每轮的最大值不增、
    被吃掉的永远是最小值,它再也不会成为最弱,吃是纯赚。

  2. 强制链:若吃完后变成最弱,那么下一轮它会被新最强蛇吃——但新最强蛇又面临同样问题……
    于是进入一条"被迫连吃"的链,直到某次吃完后不再是最弱(安全)或只剩一条蛇。
    设链中"吃完变最弱"的次数为 \(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+ 组验证):

  1. 最强蛇吃掉最弱蛇后若不是最弱 → 安全,必然吃;
  2. 若吃完变成最弱 → 进入强制连吃链,链中"吃完变最弱"次数 \(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 可复用技巧

  1. 博弈题部分分:先写"无脑精确暴力"再对拍。把"每条蛇在不被吃前提下尽量多吃"翻译成递归规则
    "最强蛇吃 ⟺ 吃完后自己能存活",每层只有一个决策点,递归天然无分支、\(O(n^2)\)。
    对拍时生成器要压小值域制造并列值与 0——这些才是结论翻车的高发区(本题强度含编号的字典序就是坑)。
  2. 单调性 + 双队列替代平衡树/堆:需要"删最小、删最大、插入新值"且新值单调时(本题 \(d=max-min\) 不增),
    两个双端队列各管一头即可 \(O(1)\) 每轮,比 multiset 快一个 \(\log\) 且代码更短。
  3. 链式博弈的奇偶判定:"吃或不吃取决于下一层是否吃"的递推往往化归为链长的奇偶性;
    想不清楚时枚举链末两条蛇的行为倒推。记住本题结构:安全吃若干次后,第一次"吃完变最弱"触发链,
    链判定后决斗立刻结束。
  4. 多组数据只改部分值的复用:数组跨组保留、按 \((x,y)\) 就地覆盖(同一位置多次修改自动以最后一次为准);
    需要有序结构时可用"未修改的有序段 + 修改的点"归并,避免每组 \(O(n\log n)\) 重排(本题 \(T\le10\) 直接重排已足够)。
  5. 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           分题解
posted @ 2026-08-17 17:17  surprise_ying  阅读(80)  评论(0)    收藏  举报