P14972『GTOI - 2C』Fliping 这题,光看题号就知道是 GTOI 第二场比赛的 C 题。看到 Fliping 这个单词,我第一反应是出题人把 Flipping 拼错了;读完题发现其实没拼错,因为题面真的就是在考区间翻转(flip),而且正解简单到让人想摔键盘。赛场上我先写了个线段树维护连续 01 段,写完跑样例发现过是能过,但时间卡死在 1e6 的数据上。后来冷静下来把操作映射到差分数组,整个人就清醒了:这题本质就是问一串差分位里有多少个 1,答案永远是一半。这篇文章把整个思考过程写出来,包括题意建模、差分推导、参考代码、对拍技巧和几个我亲手踩过的坑,给还卡在这道题上的同学做参考。
1. 先把题意翻译成能手算的形式
1.1 原题里的翻转到底翻转了什么
题目名字叫 Fliping,很多人下意识会想到字符串反转(reverse),但实际的 flip 操作是:选择一段连续区间,把区间内每个字符的 0 变成 1、1 变成 0。也就是说,这是按位的异或 1,不是把子串倒过来。
为什么要强调这个区别?因为如果真按 reverse 去写,样例里的对称数据可能也能过,但一到构造数据就会全盘 WA。比如当前串是 01001,目标是 00110,如果操作是反转子串,无论怎么反转都没法一步到位;但如果操作是 01 取反,直接翻转原串的区间 [2, 5],就能从 01001 变成 00110:
code复制01001
翻转 [2,5],也就是把第 2 到第 5 位取反:
0 0 1 1 0
所以先把操作定义搞清楚:flip 是取反,不是 reverse。这个点放在最前面,是因为我身边至少有两个人第一眼都栽在这里。
1.2 当前状态和目标状态之间只有一个差异串
假设当前状态是字符串 s,目标状态是 t,长度都是 n。一次区间翻转把 s 的某一段取反,如果只看 s 和 t 的差异,会发现翻转操作其实作用在差异上。
定义差异串 e,长度为 n:e[i] = s[i] ^ t[i],也就是第 i 位当前状态和目标状态不同时为 1,否则为 0。当 e 的某一位是 1,就说明这个位置需要被翻转一次;当 e 全部为 0,说明 s 已经等于 t。
这时候你会发现,s 和 t 具体长什么样已经不重要了。比如 s = 01001,t = 00110,差异串:
code复制s: 0 1 0 0 1
t: 0 0 1 1 0
e: 0 1 1 1 1
e 中有四个 1,分布在 [2,5]。对原串翻转一次 [2,5],其实等价于把 e 的 [2,5] 全部取反,让 e 变成全 0。因此问题被完整地转化成了:给定 01 串 e,每次可以选择一段区间取反,问最少多少次能让 e 变成全 0。
这个转化很重要,它把“两个串之间的距离”变成了“一个串上的清零操作”,后面所有推导都基于这个差异串。
1.3 手玩样例:差异串的连续 1 段等于答案
先看最简单的例子:e = 01111。有一个连续 1 段 [2,5],一次翻转就能清零,答案 1。
再看 e = 010110。连续 1 段有两个:[2,2] 和 [4,5]。手玩一下,分别翻转这两个区间,两次操作能把 e 清零:
code复制初始: 0 1 0 1 1 0
翻转 [2,2]: 0 0 0 1 1 0
翻转 [4,5]: 0 0 0 0 0 0
所以答案 2。
到这里很容易猜到一个结论:答案就是差异串 e 中连续 1 的段数。 这个结论对本题来说是对的,而且非常直观:每一段连续的 1,都必须至少被翻转一次;翻转一整段,恰好能把这段全部变成 0。中间隔着的 0 不会受这一段翻转的影响。
但要注意,这里说的是 e 的连续 1 段数,不是目标串 t 的连续 1 段数。为什么?后面第三节会用一个反例专门说明。现在先把这个直观结论挂在心里,接下来用差分数组把它严格化,并且能顺便给出构造方案。
1.4 1e7 的数据范围意味着什么
原题 n 给到了很大,我记得赛场上看到的是最多 1e7,时间限制也只有 2 秒。这意味着什么?O(n log n) 的线段树、珂朵莉树、平衡树维护连续段,理论上可行,但常数一大就很难顶;O(n^2) 更是想都别想。
我们需要一个只扫描常数遍的算法。差分数组正好满足这个需求:它把“区间翻转”变成“两个单点翻转”,把原问题变成了一个纯计数问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从差分数组看区间翻转的本质
2.1 为什么区间操作要写成差分
很多同学学差分都只用来处理区间加法,遇到 01 翻转就忘了它。其实异或也有差分:对于 01 串 e,补上哨兵 e[0] = 0、e[n+1] = 0,定义差分数组 f:
code复制f[i] = e[i] ^ e[i-1] (1 <= i <= n+1)
这个定义的意思是:差分数组记录了相邻两位之间是否发生了变化。f 中 1 的个数永远是偶数,因为从 e[0]=0 出发,每一位的变化最终要回到 e[n+1]=0,异或和必须为 0。
举个例子,e = 010110,补上哨兵后:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| e | 0 | 0 | 1 | 0 | 1 | 1 | 0 | 0 |
| f | - | 0 | 1 | 1 | 1 | 0 | 1 | 0 |
注意哨兵是 e[0]=0 和 e[7]=0,所以 f[1]=e[1]^e[0]=0,f[7]=e[7]^e[6]=0。f 中 1 的位置是 2,3,4,6,刚好四个 1,对应 e 的两段连续 1:每段连续 1 的开头会让 f 出现一个 1,结尾的下一个位置又会让 f 出现一个 1。连续段数和 f 中 1 的个数一半,是同一件事。
2.2 一次翻转只会动差分数组的两个位置
假设对 e 翻转区间 [l, r]。区间内部所有位取反,差分数组会发生什么?
f[l]会取反,因为e[l-1]没变,e[l]变了;f[r+1]会取反,因为e[r]变了,e[r+1]没变;- 其他所有
f[i]都不变,因为区间内部的相邻关系一个都没变,区间外也没变。
这就是区间取反的差分本质:一次区间操作,等价于在差分数组上选两个位置,分别异或 1。
用刚才的 e 来验证。e = 000100(只有第 4 位是 1),差分 f 中 1 的位置是 4 和 5。现在翻转 e 的 [1,4],e 变成 111000,再看差分,1 的位置变成了 1 和 5。原来 4,5 两个 1,经过一次操作后变成 1,5 两个 1,总量还是两个。这很好地说明了翻转区间只是“移动”了差分中的两个 1,而不改变总数。
2.3 答案等于差分 1 的个数除以 2
有了上一节的结论,目标就变清晰了:我们希望所有 f 都变成 0。每次操作可以任选两个位置,把它们分别异或 1。要把 f 里的 1 全部消掉,一次操作最多消掉两个 1,所以下界是:
code复制至少需要 f 中 1 的个数 / 2 次
下界能不能达到?能。每次取任意两个值为 1 的位置,假设是 f[x] 和 f[y],其中 x < y。翻转 e 的区间 [x, y-1],根据上一节的结论,正好让 f[x] 和 f[y] 同时变成 0,而其他所有 f 都不变。重复这个过程,每次都能稳定消掉两个 1,所以最小操作次数就是:
code复制ans = f 中 1 的个数 / 2
注意,f 中 1 的个数一定是偶数,所以这里不会出现小数。这也是为什么后续代码里可以直接整除。
这种配对方式也给出了构造方案:从左到右扫 f,遇到第一个 1 记下来作为左端点,再遇到第二个 1 作为右端点,把这两个位置配对,翻转区间就是 [第一个位置, 第二个位置-1]。每两个 1 配对一次,次数正好是总数的一半。
2.4 从全 0 到任意目标的推广
很多人第一次学区间翻转,看到的模型是“初始全 0,给出目标串,求最少翻转次数”。这个特殊情形下,差异串 e 就是目标串本身,所以可以直接数目标串的连续 1 段,答案等于段数。
但本题里 s 和 t 都是给定的,所以要先算 e。好消息是,无论 s 是不是全 0,只要我们对 e 做差分,结论完全不变。因为翻转 s 的某个区间,等价于翻转 e 的同一个区间,差分模型只依赖 e,不关心 s 和 t 里各自有什么。
所以整道题的思考路径可以总结成一句话:先看差异,再写差分,答案就是差分 1 的个数除以 2。
3. 赛场上容易走的弯路:贪心匹配与奇偶性陷阱
3.1 线段树维护连续段:能过但没必要
我赛场上第一版写的是线段树。思路是:从左往右找第一个需要翻转的 1,然后尽量把这一段连续 1 翻转成 0,再用并查集跳过已经变成 0 的位置。这种做法能过小数据,但在 1e7 的数据范围下,build 一棵线段树的时间和内存都很痛,而且代码量完全对不起这道题的难度。
后来我意识到,这题的难点根本不在数据结构,而在建模。一旦把 e 转换成差分数组,问题就从“维护动态 01 串”变成了“数一数数组里有多少个 1”。这说明一个经验:遇到区间取反、区间翻转、区间异或这类操作,先想差分能不能简化,再想用什么数据结构。 差分是“数学上的简化”,数据结构是“工程上的维护”,能不用工具就不要先掏锤子。
3.2 不能直接数 t 的连续 1 段
有同学把结论记成了“答案等于目标串里连续 1 的段数”,这在 s 全 0 时是对的,但本题不保证。给你一个反例:
code复制s = 1010
t = 0101
差异串 e = 1111,连续 1 段只有 1 段,所以答案应该是 1。如果数 t 的连续 1 段,t 的 1 在位置 2 和 4,段数是 2,就会算成 2。实际上,只需要翻转一次 [1,4]:
code复制1 0 1 0
翻转 [1,4]:
0 1 0 1
一步到位。所以结论要记对:先算差异串 e,再数 e 的连续 1 段数,而不是数 t 的。 这对任何“从当前状态到目标状态”的题目都适用,不是只有这一题。
3.3 跨过 0 合并两段不会省次数
还有一种常见直觉:e = 10001,有两段 1,能不能先翻转 [1,5],让两段一起变 0?试一下:
code复制初始: 1 0 0 0 1
翻转 [1,5]:
0 1 1 1 0
一次操作之后,两端的 1 确实没了,但中间的三个 0 全变成了 1。还需要再翻转一次 [2,4] 才能清零。总共还是两次,和分别翻转 [1,1]、[5,5] 的次数一样,并没有省。
差分模型更清楚地说明了这一点:e = 10001 的差分 f 中 1 在 1,2,5,6 四个位置。一次操作能消掉两个 1,所以至少需要两次。你选择把 f[1] 和 f[6] 配对,就会把区间拉得很长,结果中间的 f[2] 和 f[5] 还在,等于没省。跨过 0 合并两段看似聪明,实际上只是换了一种配对方式,总次数不变。
3.4 警惕题目把限制条件藏在 flip 的定义里
我后来在群里看到有人问,如果题目改成“每次翻转的区间长度必须是偶数”,这题还能做吗?这个问法其实是好的,因为竞赛题最容易在定义上埋变体。
用差分模型很快能分析:一次翻转区间 [l, r],会把 f[l] 和 f[r+1] 同时翻转。如果要求区间长度是偶数,也就是说 r - l + 1 是偶数,等价于 r - l 是奇数,那么 (r+1) - l 是偶数。也就是说,被配对的两个差分位置的下标奇偶性相同。这时候就不是任意配对都行了,需要把差分位置按奇偶分组,再各自配对;如果某组的 1 的个数是奇数,那就是无解。
本题没有这个限制,所以不展开。但你可以看到,差分模型不是只能解这一题,它能作为分析工具,帮你快速判断一个变体到底难在哪里。
4. 完整解法与参考实现
4.1 从输入到答案的四步流程
把整道题的算法流程固定下来:
- 读入 n、当前串 s、目标串 t。
- 计算差异 e:
e[i] = s[i] != t[i]。 - 补上哨兵,计算差分 f:
code复制f[1] = e[1] ^ 0 f[i] = e[i] ^ e[i-1] (2 <= i <= n) f[n+1] = 0 ^ e[n] - 统计 f 中 1 的个数 cnt,答案就是
cnt / 2。如果要求方案,从左到右每遇到两个 1 就输出一个区间。
这里要特别提醒:f[n+1] 必须统计进去。很多人只循环到 n,导致最后一位的边界漏掉,答案会差 1。看第五节的反例。
4.2 只求次数的最短写法
如果题目只要求输出最少操作次数,空间可以做到 O(1)。只需要在读入两个串之后,边扫边维护上一个 e 值:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
string s, t;
cin >> n >> s >> t;
int cnt = 0;
int prev = 0; // e[0] = 0
for (int i = 1; i <= n; i++) {
int cur = (s[i - 1] != t[i - 1]); // e[i]
int fi = cur ^ prev; // f[i] = e[i] ^ e[i-1]
if (fi) cnt++;
prev = cur;
}
// f[n+1] = e[n+
