综合实战

综合建模与赛场调试

复杂度估算、部分分与对拍

9个章节
查看本篇目录一、别急着翻模板:对象的物理状态与“灵魂数组”1. 抓本质变化:静态批处理 vs 动态交替2. 状态维度的物理意义:“到了这里之后,还能做的事一样吗?”二、数据范围是密码本:复杂度与内存的极限算命1. 常见数据规模与预期算法2. 警惕内层膨胀与空间杀手三、暴力不是退路,而是破题降维的第一步1. 经典破题场景:和不超过 $S$ 的连续子段计数2. 寻找冗余:非负性与单调性带来的 $O(n)$ 降维3. 警惕陷阱:单调性被破坏的反例四、赛场核武器:单文件对拍机制与边界数据1. 完整对拍自检程序2. 随机数之外:手工炮制“极难伺候”的魔鬼边界3. 榨取“最小出错数据”五、部分分策略:把能拿的分数焊死在代码里1. 拆解子任务:多路分流的组织结构六、拒绝玄学改符号:用 cerr 与 assert 顺藤摸瓜1. 使用 cerr 追踪关键灵魂变量2. 使用 assert 构筑安全防波堤七、终场交卷体检清单(考前 15 分钟保命必查)八、构造题的破局思路——从必要条件到可行方案(选学)1. 黄金路径:寻找不变量与必要条件2. 具体推导:硬币消除的降维打击九、渐进式实战练习题单

很多同学在日常训练中刷了几百道题,线段树、Dijkstra、树形背包、KMP 背得滚瓜烂熟。然而一进考场,面对新题却依然两眼发黑。

痛点在哪里?赛场上的真题从来不会在标题下方贴上“本题是分层图最短路”或“本题是单调队列优化 DP”的标签。 考题包裹在长篇大论的背景故事中,甚至会用复杂的交互规则掩盖算法本质。

这节课我们不讲新的数据结构,专门修炼一名竞赛选手最核心的三项实战内功:

  1. 状态抽象与模型翻译:如何扒掉花哨的题面外衣,看透底层对象与操作;
  2. 从暴力出发破题降维:如何先用朴素算法抓住物理规律,再用单调性砍掉多余维度;
  3. 赛场对拍与自检调试:如何在没有评测反馈的环境下,尽可能找出代码里藏着的漏洞。

一、别急着翻模板:对象的物理状态与“灵魂数组”

拿到题,最忌讳的做法就是像查字典一样硬套关键词——看到“区间”就上线段树,看到“图”就跑 SPFA。在动键盘写代码之前,先在草稿纸上回答三个根本问题:

  1. 我当前到底在维护哪些对象?
  2. 数据的变化是离线积累的,还是即时交叉的?
  3. 走到这一步之后,未来的选择会不会因“过去的历史细节”而截然不同?

1. 抓本质变化:静态批处理 vs 动态交替

场景描述 表面特征 核心灵魂追问 正确工具模型
给定若干区间加上一个值,所有操作结束后求数组终态 频繁出现“区间加” 中途需要随时回答查询吗?不需要。 属于纯离线。 差分数组 O(N)O(N),千万别搬线段树大炮打蚊子
区间加与区间查询交替穿插发生 频繁出现“区间加”与“区间和” 每次修改后答案立刻会被使用吗?是的。 必须动态维护。 带懒标记的线段树或树状数组
滑动窗口内求最值 连续固定长度子段 窗口右移时,哪些元素已“不可能成为答案”? 单调队列,果断淘汰劣质过期元素
求一种方案,使得最大瓶颈最小化 题目出现“最大值最小” 猜一个上限值,能否以多项式时间判定可行性? 二分答案转化为判定性问题

识别模型的关键,从来不在于文字表述有多花哨,而在于操作与查询的时序关系。

2. 状态维度的物理意义:“到了这里之后,还能做的事一样吗?”

设计 DP 状态或图论状态时,初学者最常犯的错误就是漏掉关键维度。

经典实战陷阱:在带权有向图上求从起点到终点的最短路径,但题目给了一个特权——“你在途中最多可以将 1 条边的花费直接变为 0”。

很多同学立刻写出 dist[u],贪心地想:“我先跑一遍常规最短路,然后挑一条最贵的边改成 0 不就行了?”——这完全是假的。 因为选了一条贵边可能会迫使你绕一个巨大的远路,常规最短路根本不会经过那条边。

那开 dist[u] 记录到达 uu 的最小花费行不行?

我们来做一次“物理状态审问”:

假设选手 A 和选手 B 都走到了节点 uu,两人的累计花费都是 100100 元。 但选手 A 手里的“免单特权”还没有使用;而选手 B 已经把免单特权花掉了。 此时,uu 后面连接着一条费用高达 9999999999 的边通往终点。他们未来能做出的决策和所需花费是一样的吗?

显然截然不同!选手 A 可以把这条天价边免费抹平,而选手 B 只能咬牙支付。既然后续选择完全不同,把他们都压缩在同一个 dist[u] 里就必然发生信息丢失与状态混乱。

因此,状态必须吸收历史特权信息,扩充为二维:

dist[u][k](k∈{0,1})dist[u][k] \quad (k \in \{0, 1\})

  • 物理意义:到达节点 uu,且当前已经使用了 kk 次免单机会时的最小花费。
  • 状态转移:
    • 走一条正常边 (u,v,w)(u, v, w):dist[v][k] = min(dist[v][k], dist[u][k] + w)
    • 使用特权走这条边 (u,v,w)(u, v, w)(需 k=0k=0):dist[v][1] = min(dist[v][1], dist[u][0] + 0)

铁律:状态维度不是拍脑袋凑出来的。只要过去发生的某件事会直接制约未来的选择,这层制约就必须化作数组的一维下标。 分层建图的转移写法见《最短路》第十节,本篇重点练习从题意中识别这层状态。


二、数据范围是密码本:复杂度与内存的极限算命

很多同学看题只看描述,把最下方的数据范围当成附件。实际上,数据范围是出题人给你写在卷面上的明示信号。

1. 常见数据规模与预期算法

数据规模 NN 标算时间复杂度期望 常见解题方向
N≤12N \le 12 O(N!)O(N!) 或 O(N22N)O(N^2 2^N) 全排列爆搜、复杂旅行商状压 DP
N≤20∼22N \le 20 \sim 22 常考虑 O(2N⋅N)O(2^N \cdot N);O(3N)O(3^N) 需另估算 状压 DP、折半搜索 (Meet in the middle)
N≤100N \le 100 O(N4)O(N^4) 或 O(N3)O(N^3) Floyd、区间 DP、高斯消元、费用流
N≤1000∼2000N \le 1000 \sim 2000 O(N2)O(N^2) 二维 DP、树形背包、平方级贪心
N≤105∼2×105N \le 10^5 \sim 2 \times 10^5 O(Nlog⁡N)O(N \log N) 或 O(NN)O(N \sqrt{N}) 线段树、树状数组、分治、莫队、单调栈/队列
N≤106N \le 10^6 O(N)O(N) 或 O(Nlog⁡N)O(N \log N) 线性筛、双指针、KMP、差分、极低常数贪心
NN 达到 101810^{18} 级 O(log⁡N)O(\log N) 或 O(1)O(1) 矩阵快速幂加速推导、可快速计算的数论公式

2. 警惕内层膨胀与空间杀手

但是,“范围对上了”绝对不等于“写完就能过”。必须把内层操作数与空间全部算清:

  1. 状压的内层陷阱: 同样是 N=20N=20:
    • 如果每个状态只需枚举一个未加入元素,总运算次数是 N⋅2N≈20×106=2×107N \cdot 2^N \approx 20 \times 10^6 = 2 \times 10^7,C++ 在 1 秒内可以轻松跑过;
    • 但若每个状态还要枚举它的所有子集,总运算次数是 ∑k=0n(nk)2k=3N≈320≈3.48×109\sum_{k=0}^n \binom{n}{k} 2^k = 3^N \approx 3^{20} \approx 3.48 \times 10^9 次运算,直接超时 30 倍!
  2. 内存算命(防爆 MLE): 如果定义 int dp[1<<20][20],空间占用是 220×20×4 B≈80 MB2^{20} \times 20 \times 4 \text{ B} \approx 80 \text{ MB},在常规 128 MB128 \text{ MB} 限制下安全。 但如果在头文件无脑加上 #define int long long,空间直接翻倍到 160 MB160 \text{ MB},考场当场爆零。
  3. 多组测试数据清空开销: 若题目明确给出“所有测试点的 ∑N≤2×105\sum N \le 2 \times 10^5”,但单组测试数据有 T≤104T \le 10^4 组。如果每一组开头你都习惯性写 memset(head, -1, sizeof(head)),你的复杂度实际上是 O(T×MAXN)O(T \times \text{MAXN}),直接因清空超额暴毙。务必做到“用多少、清多少”。

三、暴力不是退路,而是破题降维的第一步

本节可与《双指针与滑动窗口》对照:那里讲窗口怎么维护,这里练习怎样从暴力中发现它。

面对毫无头绪的压轴题,切忌坐在考场上双手抱头死憋。最科学的思考路线是:先写出一个绝不可能出错的暴力算法,用它来观察计算过程中的冗余,进而寻找单调性降维。

1. 经典破题场景:和不超过 SS 的连续子段计数

问题场景:给定一个长度为 nn 的数组 aa,求有多少个非空连续子段 [l,r][l, r],满足其区间和 ∑i=lra[i]≤S\sum_{i=l}^r a[i] \le S。

我们先完全不考虑任何高深技巧,写一个两重循环暴力算法:

C++
long long brute(const vector<int>& a, long long s){
    long long ans=0;
    for(int l=0;l<(int)a.size();l++){
        long long sum=0;
        for(int r=l;r<(int)a.size();r++){
            sum+=a[r];
            if(sum<=s) ans++;
        }
    }
    return ans;
}
  • 物理意义:固定左端点 ll,把右端点 rr 从 ll 一路扫到末尾。每一对区间都被完整检验了一次,复杂度显然是 O(n2)O(n^2)。

2. 寻找冗余:非负性与单调性带来的 O(n)O(n) 降维

我们要问:内层循环的重复计算到底在哪里? 如果题目给出了一个强力前提:数组元素全是非负数(ai≥0a_i \ge 0),且 S≥0S \ge 0。

我们立刻捕捉到关键的物理单调性:

  • 数组元素都是非负数,意味着随着右端点 rr 向右移动,区间和必然单调不减;
  • 换句话说,对于一个固定的右端点 rr,如果区间和已经膨胀到大于 SS 了,左端点 ll 就必须向右缩减;
  • 更绝妙的是:当右端点从 rr 移动到 r+1r+1 时,左端点 ll 绝不需要向左倒退!

既然左端点只进不退,两重循环的平方级枚举就可以彻底降维为 滑动窗口(双指针): 每次让右端点 rr 进一位,若当前窗口累加和超过 SS,就把左端点 ll 持续右移并从和中减去 a[l]a[l],直到窗口和重新 ≤S\le S。 此时,以当前 rr 为右端点的合法子段,左端点可以取 l,l+1,…,rl, l+1, \dots, r 中的任意一个,合法子段数正好是极其简洁的 r−l+1r - l + 1!

每个元素至多进出窗口各一次,算法复杂度瞬间从 O(n2)O(n^2) 降维到 O(n)O(n)。

3. 警惕陷阱:单调性被破坏的反例

但是,单调性是有边界的。如果数组中存在负数呢?

我们随手手推一个极小反例:

  • 数组为 [5, -5],目标阈值 S=0S = 0。

如果你把上面的滑动窗口套进去:

  1. 考察第一个元素 5:因为 5>05 > 0,滑动窗口判定超标,左指针会被迫一直向右走,把 5 踢出;
  2. 然而,如果把后面的 -5 吸收进来,整个区间 [5, -5] 的和是 5+(−5)=0≤05 + (-5) = 0 \le 0,它本身就是一个完全合法的子段!

负数的加入,使得“加入新元素和必增加”的单调性彻底粉碎,左指针不能再贪心右移。面对带负数的情况,我们必须立刻放弃滑动窗口,转向“前缀和 + 离散化树状数组/归并排序”的逆序对模型求解。

这就是为什么必须先理解暴力的物理意义:优化不是死记代码模板,而是搞清楚是哪一条单调性规则支撑了你的指针单向推进。

统计和不超过S的非空连续子段,从O(n²)枚举转为非负数组且S≥0时的O(n)窗口,再做小数据对拍;(5,-5)、S=0说明负数会破坏该窗口的单调性。


四、赛场核武器:单文件对拍机制与边界数据

在考场上,你没有洛谷的在线判题机,也没有实时的测试结果反馈。你怎么敢笃定自己绞尽脑汁想出的优化解法是 100% 正确的?

一件能帮你主动找漏洞的核武器是:对拍(Stress Testing);通过测试能增加信心,但不能代替对算法的推理。

很多同学嫌对拍麻烦,觉得要写生成器、写两个 cpp、再写批处理脚本。其实在赛场上,最敏捷高效的对拍是单文件内嵌自检:把绝对可靠的朴素暴力和待验证的高效算法写进同一个程序,利用伪随机数快速跑上万组小规模数据进行相互印证。

1. 完整对拍自检程序

【输入输出协议与数据范围】:

  • 测试环境:程序内部通过固定随机种子(rng(20261008))生成 10,000 组测试数据。
  • 数据范围:每组测试数据的数组长度 n∈[1,12]n \in [1, 12],阈值 S∈[0,30]S \in [0, 30],数组元素非负且 ai∈[0,10]a_i \in [0, 10]。
  • 输出格式:若两份程序答案一致,最后输出 10000 tests passed\n;若在第 tctc 组测试发现分歧,立即输出 Mismatch at [tc],并打印出该组的输入参数 n,Sn, S、数组内容,以及两份算法的不同输出,随后退出程序。
  • 运行效率:每组同时运行 O(n2)O(n^2) 暴力与 O(n)O(n) 滑窗,总时间复杂度为 O(T⋅n2)O(T \cdot n^2);这里 n≤12n \le 12,适合快速自检。
C++
#include<bits/stdc++.h>
#define int long long
using namespace std;

// 1. 绝对可靠的 O(n^2) 暴力基准
int brute(const vector<int>& a, int s){
	int ans=0;
	for(int l=0;l<(int)a.size();l++){
		int sum=0;
		for(int r=l;r<(int)a.size();r++){
			sum+=a[r];
			if(sum<=s) ans++;
		}
	}
	return ans;
}

// 2. 待检验的 O(n) 滑动窗口算法
int fast(const vector<int>& a, int s){
	int ans=0,sum=0;
	int l=0;
	for(int r=0;r<(int)a.size();r++){
		sum+=a[r];
		while(l<=r && sum>s) sum-=a[l++];
		ans+=r-l+1;
	}
	return ans;
}

signed main(){
	ios::sync_with_stdio(0),cin.tie(0);
	mt19937 rng(20261008); // 固定随机种子,保证错误可随时 100% 复现
	
	for(int tc=1;tc<=10000;tc++){
		int n=1+rng()%12;
		int s=rng()%31;
		vector<int> a(n);
		for(int &x:a) x=rng()%11;
		
		int x=brute(a,s);
		int y=fast(a,s);
		
		if(x!=y){
			cout<<"Mismatch at "<<tc<<"\n";
			cout<<n<<" "<<s<<"\n";
			for(int v:a) cout<<v<<" ";
			cout<<"\n"<<x<<" "<<y<<"\n";
			return 1;
		}
	}
	cout<<"10000 tests passed\n";
	return 0;
}

自测通过后,记得换回按题目读入数据的 main 再提交,别把随机自检程序交上去。

2. 随机数之外:手工炮制“极难伺候”的魔鬼边界

随机数虽然好用,但它是均匀分布的,经常会“完美避开”那些最阴险的边界分支。除了随机对拍,你必须手动制造几组刁钻数据投喂给程序:

  1. 绝对孤立点:n=1n=1,答案为 00 或 11 的临界态;
  2. 全零黑洞:所有 ai=0a_i=0 且 S=0S=0,任何子段和都是 0,答案应当恰好是 n(n+1)/2n(n+1)/2;
  3. 全线崩塌:每个元素都极其巨大(ai>Sa_i > S),窗口必须能够正常缩减为空且不产生负数越界;
  4. 等号分歧:区间和恰好卡在 sum == s,检查代码中的循环退出条件到底该写 > 还是 >=;
  5. 图论极端形态:树退化为一条长链(递归爆栈测试)、菊花图(单点度数极大测试)、图包含重边和自环。

抓 Bug 心法:边界条件不是考前死背的备忘录,而是专门去撞你代码里的每一个 if 分支与循环临界点。

3. 榨取“最小出错数据”

当你的对拍程序报错时,它往往会吐出一个充满随机数字的庞大数组。人工手推十几个毫无规律的数据极其痛苦,必须进行反例降维:

  • 物理截断:把报错的数组长度直接砍掉一半,手动输入给程序。如果依然报错,说明导致错误的“基因”就在这半截里,你成功剔除了另一半的干扰。
  • 极限剔除:尝试把数组里的某些值人为改为 0,或者删除几个元素。如果程序仍然输出错误答案,说明这些元素只是掩人耳目的干扰项。 反复削减,直到你得到一组便于手推的小反例。如果能压到两三个元素,只需在草稿纸上推一分钟,代码逻辑的断裂点就可能原形毕露。

五、部分分策略:把能拿的分数焊死在代码里

竞赛的计分规则不是“全对才给分”。在省选与 NOIP 中,一个能稳稳拿到 60 分暴力的选手,往往能击败一大批正解写假拿 0 分的选手。

1. 拆解子任务:多路分流的组织结构

打开题目的子任务表格,你会发现出题人留下的台阶:

  • 子任务 1(20%20\% 分数):n≤15n \le 15;
  • 子任务 2(30%30\% 分数):保证树退化为一条链;
  • 子任务 3(50%50\% 分数):无特殊限制。

把各子任务的分数切切实实拿到手,代码最规范的组织形式是分流执行:

C++
if(n<=15){
    solve_brute(); // 纯暴力搜索,稳稳装袋 20 分
}else if(is_chain){
    solve_chain(); // 树退化为区间,直接差分/双指针搞定 30 分
}else{
    solve_general(); // 考场全力冲击满分做法
}

实战避坑要点:

  • 互斥出口:每个分支必须独立完成所有的输入与最终输出,千万别让 solve_brute() 输出完后,外层又顺手跑了一次 solve_general() 造成重复输出;
  • 链不是顺序编号:在树退化为链的特殊性质中,“树是一条链”绝对不等于节点的编号就是 1,2,3,…,n1, 2, 3, \dots, n!必须从度数为 1 的端点出发,用一次简单的 DFS 重新求出整条链的物理遍历序列。

六、拒绝玄学改符号:用 cerr 与 assert 顺藤摸瓜

很多同学在调试时,只要大样例没过,就开始像摸彩票一样: 把 < 改成 <=,把 +1 改成 -1,或者在数组下标外层胡乱套取模。这种盲目修改不仅修不好程序,反而会把原先正确的逻辑彻底搅浑。

科学的调试应当是沿着数据的流动轨迹定位真相。

1. 使用 cerr 追踪关键灵魂变量

C++
cerr<<"[DEBUG] step="<<i<<" left="<<l<<" right="<<r<<" sum="<<sum<<"\n";
  • 物理优势:cerr 走的是标准错误流(stderr),而评测机和重定向文件只收集标准输出(stdout)。这意味着在本地终端上能清晰看到调试日志,却不会干扰标准输出。
  • 注意:在正式提交代码前,务必注释掉密集的调试输出,避免因海量控制台 I/O 导致赛场超时(TLE)。

2. 使用 assert 构筑安全防波堤

C++
assert(1<=u && u<=n); // 严禁数组越界访问
assert(l<=r);          // 仅在当前模型要求区间非空时使用;滑窗允许 l=r+1 表示空窗

如果在运行大样例时程序中途发生断言崩溃(Assertion failed),不要把它注释掉装作看不见!这说明上游逻辑已经产生非法数据。立刻顺着这个断言向上追查:这个非法值是在哪一次计算中被制造出来的?

答案界限与不变量自检 (Bound Check):

在复杂的 DP 或图论转移中,为了防止写假,你需要建立两道逻辑防线:

  • 不变量自检:如同能量守恒,区间 DP 中合并后的区间长度必定单调递增;两个不同连通块合并后,总块数减一。如果你的转移破坏了这些底层守恒律,立刻推翻重查。
  • 答案界限检查:转移结束后,在输出前代入极值审问。求二分图最大匹配时,答案绝不可能超过总节点数的一半;求全正边权的最短路,可达点的距离绝不可能小于 0。在代码中加上极简的 assert(ans <= n / 2);(这里 nn 是左右两侧的总节点数),这能在程序输出荒谬结果前将其强行拦下,暴露漏洞。

七、终场交卷体检清单(考前 15 分钟保命必查)

离考试结束还有 15 分钟时,必须停下所有编写新题的念头,专注执行以下物理体检:

  1. 文件与重定向检查:
    • 题目是否要求文件读写?若需要,核对 freopen("problem.in","r",stdin) 和 freopen("problem.out","w",stdout) 的文件名大小写;
    • 离开时绝不能将本地绝对路径(如 D:\test\data.in)残留在提交代码中。
  2. 数值防爆检查:
    • 是否存在两数相乘可能突破 2×1092 \times 10^9?两个 int 相乘必须显式转换为大整数:1LL * a * b;
    • 计数题在加法、乘法后是否无死角取模?ans = (ans + x) % MOD。
  3. 空间与清空检查:
    • 无向图的边数组大小必须开到 2×M2 \times M;线段树节点数组必须开到 4×N4 \times N;
    • 多测环境必须精准重置全局下标计数器(如 timer=0, tot=0);清空开销大时做到“用多少、清多少”,小数组或总开销可控时仍可用全量 memset。
  4. 递归栈空间(防爆栈):
    • 树形 DP 或深度优先搜索(DFS)在面对 10510^5 规模的“一条长链”时,递归深度会达到十万层,极其容易撑爆系统的默认栈空间引发运行时错误(RE)。
    • 自检动作:考场上不仅要测大样例,必须手工造一条长度为 NN 的单链喂给程序。若本地发生 Segmentation Fault,先排查越界、死递归,再核对本地与评测环境的栈限制;确认是深链超栈后,再考虑显式栈或按 BFS 序逆序递推,不要一见段错误就重写正确算法。
  5. 局部变量的“薛定谔状态”:
    • 全局数组默认初始化为 0,但写在函数内部的局部变量(如 int sum;)如果不赋初值,就不能直接拿来累加。在多测环境下,这会引发时有时无的“幽灵 Bug”。
    • 自检动作:扫描核心逻辑,确保累加器、计数器和指针在使用前有明确的合法初值;这个初值不一定都是 00。
  6. 终极编译复测:
    • 最后一次修改保存后,必须在终端重新敲一遍编译命令,并用最基础的样例跑一遍,确认没有留下未闭合的大括号或编译警告。

八、构造题的破局思路——从必要条件到可行方案(选学)

(先修要求:掌握基础贪心、奇偶性分析与图论基础)

近年来,要求“输出任意一种方案”或判断“是否无解”的构造题频繁出现。这类题目没有固定模板,极其考验对对象本质的观察。

1. 黄金路径:寻找不变量与必要条件

面对构造题,最科学的思维不是凭空想象,而是先寻找绝对不可能跨越的界限:

  1. 找必要条件(下限与不变量):如果方案存在,它必须满足什么基本事实?(例如:总和是否一定为偶数?某两个元素的差值是否永远不变?)
  2. 构造性验证(操作方案):在满足必要条件的基础上,能不能给出一套“傻瓜式”的固定操作规则,使得任何局面都能被这套规则强行还原到目标状态?

2. 具体推导:硬币消除的降维打击

问题场景:给定 nn 枚硬币排成一排,每次操作只能同时翻转相邻的两个硬币。问能否通过若干次操作,把所有硬币都翻成正面(用 1 表示正面,0 表示反面)?如果能,给出一个方案。

第一步:抓不变量,卡死无解的必要条件

  • 每次翻转两枚硬币,它们可能有三种情况:正正变反反,反反变正正,正反变反正。
  • 无论哪种情况,反面硬币的总数,要么增加 2,要么减少 2,要么不变。
  • 这意味着:反面硬币总数的奇偶性是一个永恒的不变量。如果初始状态有奇数个反面硬币,绝对不可能变成 0 个(偶数)。此时直接输出无解。

第二步:基于必要条件,设计无脑的“推平”方案 既然奇数个反面无解,那如果反面是偶数个,就一定有解吗?我们尝试构造一套规则:

  • 从左向右扫描,遇到正面直接跳过。
  • 遇到反面,无脑将它和它右边相邻的硬币翻转。
  • 这样做,我们必定能把当前硬币变成正面,代价是把影响转移到了下一枚硬币。因为反面总数是偶数,当我们一路把反面“推”到最后一个硬币时,最后两枚硬币必定恰好凑成一对反面,一次翻转即可完美消除!

这就是构造题的核心物理意义:通过不变量筛掉无解,通过固定方向的推平操作消化掉所有中间状态。

C++
// 验证硬币翻转构造,输入: 硬币数 n (n <= 10^5),接着 n 个 0 或 1 (1: 正面, 0: 反面)
// 输出: 翻转操作的左端点下标集合,若无解则输出 -1
#include<bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100005;
int a[N];

void solve(){
    int n;
    if(!(cin>>n)) return;
    int count0 = 0;
    for(int i=1;i<=n;i++){
        cin>>a[i];
        if(a[i]==0) count0++;
    }
    
    // 必要条件拦截:奇数个反面必定无解
    if(count0 % 2 != 0){
        cout<<"-1\n";
        return;
    }
    
    // 方案构造:从左到右推平
    vector<int> ops;
    for(int i=1;i<n;i++){
        if(a[i]==0){
            a[i] = 1;              // 当前硬币翻为正面
            a[i+1] = 1 - a[i+1];   // 右侧硬币随之翻转
            ops.push_back(i);      // 记录操作左端点
        }
    }
    
    cout<<ops.size()<<"\n";
    for(int i=0;i<(int)ops.size();i++){
        cout<<ops[i]<<(i==(int)ops.size()-1?"":" ");
    }
    cout<<"\n";
}

signed main(){
    ios::sync_with_stdio(0),cin.tie(0);
    solve();
    return 0;
}
/*
独立验证数据:
输入:
6
1 0 1 0 1 1
输出:
2
2 3
*/

九、渐进式实战练习题单

第一阶段:单调性破题与双指针降维

  1. 洛谷 P1638 逛画展
    • 训练指引:在包含多种元素的序列中寻找覆盖所有画家的最短区间。严格体验“右指针扩张吸收画家,左指针紧缩排除冗余”的单调性窗口控制。

第二阶段:状态扩维与物理意义落地

  1. 洛谷 P4568 [JLOI2011] 飞行路线(复习)
    • 训练指引:分层图最短路的典中典。给定 kk 次免费乘坐航班的机会,深刻体会为什么单开 dist[u] 会破坏决策独立性,理解灵魂状态 dist[u][k] 的物理转移。

第三阶段:对拍与树上特殊性质拆解

  1. 洛谷 P1099 [NOIP2007 提高组] 树网的核
    • 训练指引:先从树直径的端点枚举暴力写起,接着设计数据生成器进行对拍验证,最后利用偏心距的单调性降维至双指针求解。极佳的全流程建模综合实践题。
搜索全部54篇讲义的标题、目录与正文
点击结果进入讲义Esc 关闭