作为一个常年把洛谷当成“单机小游戏”刷的人,我对B类题向来是有点偏见的:B开头,一般就是语法题、模拟题,随手写写就能过。但洛谷B3866给我上了一课。那天晚上我打开题单,看到B3866,心想“十分钟搞定”,结果写了一个小时,错了三次,最后还差点被Java的输入输出坑到不想说话。这篇不是标准题解,更像是我自己的一次复盘:从暴力模拟到标记优化,从C++切到Java,再到把洛谷提交页面那些不起眼的小细节,全部捋一遍。如果你是刚入坑洛谷、经常在B类题上“样例过了但提交全红”的选手,这篇应该能帮到你。
1. 先把B3866的题面翻译成人话
1.1 题目到底让我干什么
我凭记忆复述一下这道题的大致形态。它给了一个n行m列的0/1矩阵,初始矩阵里的元素不是全0,而是由输入给定,可能是0也可能是1。接下来有q次操作,每次操作给一个op和一个x,op等于1就表示把第x行整体翻转,也就是这一行里所有0变成1、所有1变成0;op等于2就表示把第x列整体翻转。所有操作执行完之后,要求把最终的矩阵原样输出。
听起来是不是特别像“扫雷小游戏”里那个翻地雷的棋盘?我第一次看到这题的时候也有点这种错觉,以为要写什么搜索。其实不用,它本质上就是一道“标记状态然后统一结算”的模拟题。
1.2 关键不是算法,而是读题
这种题最大的坑在于读题。题目里说的“翻转第x行”和“翻转第x列”,用的是同一个x变量。也就是说,输入给你一行两个数,第二个数有时代表行号,有时代表列号,取决于第一个数op是1还是2。我第一遍看题的时候,脑补成了“第一个操作翻转行,第二个操作翻转列,第三个操作问询子矩阵”,结果自己给自己加戏,多写了一堆前缀和的代码,最后看样例才发现根本不是这么回事。
所以拿到题目第一步,不要急着想算法,先把操作类型、数据范围、输入格式画出来。我习惯在草稿纸上写三行:
- 输入:n m q
- 初始矩阵:n行m列,0或1
- q次操作:op x,op=1翻转行,op=2翻转列
这种“翻译成人话”的过程,能过滤掉至少一半的审题失误。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力模拟为什么不对:复杂度翻车现场
2.1 最直觉的做法
如果完全按照题目描述去写,代码非常简单:开一个二维数组存矩阵,遇到flip row就遍历这一行的所有列,把每个格子异或1;遇到flip col就遍历这一列的所有行,把每个格子异或1。操作全部做完之后,再二重循环输出整个矩阵。
伪代码长这样:
text复制for each operation:
if op == 1:
for j in 0..m-1:
a[x][j] ^= 1
else:
for i in 0..n-1:
a[i][x] ^= 1
这个做法有什么问题?我们算一下复杂度。假设n和m都是1000,q是200000。那么一次行翻转需要遍历1000个格子,一次列翻转也需要遍历1000个格子,q次操作下来,就是200000 × 1000 = 2×10^8次格子修改。再叠加最后输出矩阵的10^6次操作,总操作量在2亿左右。
2亿次数组异或,在C++里开了O2优化后可能勉强能跑,但在Java里几乎必挂。因为Java的数组边界检查、JIT预热等因素,2亿次随机读写很容易把时间拖到两三秒甚至更多,而洛谷对多数题目给的时间限制通常是1秒到2秒。用暴力交上去,结果只有一个:Time Limit Exceeded。
2.2 翻转的本质是什么
要优化,就必须想明白“翻转”到底在干什么。一个格子只有0和1两种状态,翻转一次变一次,翻转两次又变回原样,翻转三次等于翻转一次。所以关键不在于翻了多少次,而在于“翻的次数是奇数还是偶数”。
这个结论看起来非常简单,但它是整道题的核心。一个格子最终是不是和初始状态相反,只取决于它所在的行被翻了多少次、它所在的列被翻了多少次,两者加起来是奇数还是偶数。
用二进制的话说,就是做异或运算。行翻转次数为奇数记作1,偶数记作0;列翻转次数同样记作0或1。这个格子最终是否需要取反,等于“行翻转奇偶性”异或“列翻转奇偶性”。如果异或结果是1,就取反;如果是0,就保持不变。
这一步想明白了,优化方案就呼之欲出了。
3. 用翻转标记代替真的翻转:核心优化思路
3.1 空间换时间的标记法
与其真的去修改矩阵里的每一个值,不如先记下每一行被翻转的奇偶性、每一列被翻转的奇偶性。操作结束后,再根据标记统一计算最终矩阵。
C++代码我最后写出来是这样的:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, q;
cin >> n >> m >> q;
vector<vector<int>> a(n, vector<int>(m));
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
cin >> a[i][j];
}
}
vector<int> row(n, 0), col(m, 0);
while (q--) {
int op, x;
cin >> op >> x;
--x; // 把输入的下标从 1 起始改成 0 起始
if (op == 1) {
row[x] ^= 1;
} else {
col[x] ^= 1;
}
}
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
int val = a[i][j];
if (row[i] ^ col[j]) {
val ^= 1;
}
cout << val;
if (j == m - 1) {
cout << '\n';
} else {
cout << ' ';
}
}
}
return 0;
}
核心就三件事:
- 用row[i]记录第i行是否被翻了奇数次。
- 用col[j]记录第j列是否被翻了奇数次。
- 输出时判断row[i] ^ col[j]是否为1。
这里面的“ ^= 1 ”是一个非常实用的小技巧。因为0异或1等于1,1异或1等于0,正好对应“翻转一次从偶变奇、再翻一次从奇变偶”。不需要去数到底翻了多少次,只需要维护0和1的奇偶性。
3.2 为什么不用二维差分
可能有同学会想到二维差分或二维前缀和。这里我解释一下为什么不用。二维差分适合的场景是“对子矩阵整体加一个值,最后求每个位置的值”,它维护的是二维增量。但这道题的操作是整行或整列翻转,不是子矩阵加值。如果强行用二维差分,每次行翻转需要更新一整行,列翻转需要更新一整列,复杂度和暴力差不多,并没有本质提升。
二维差分真正好使的地方是“若干次局部修改后统一还原”,而本题的特殊性在于:行和列是独立的。任何一个格子的最终状态只由行状态和列状态组合决定,不存在“左上角到右下角的矩形区域”这种依赖,所以用两个一维数组就够了。这也是很多矩阵模拟题的通用套路:如果能分解成“行方向”和“列方向”两个独立维度,就一定不要真的去操作二维数组。
3.3 与线段树懒标记是同一个道理
如果你接触过线段树的懒标记,再看这个优化思路,会觉得特别亲切。线段树在做区间加法时,不会真的把区间内每个叶子节点都更新一遍,而是先给整个区间打一个标记,等到查询或需要继续下传时才把标记逐层散下去。这里也是一样,翻转操作本身不是目标,最终状态才是目标,所以先欠着,不打到每个格子身上。
这启发我们在做题时养成一个习惯:当操作对象是“一个区间”“一整行”“一整列”时,先停下来想一下能不能用标记延迟处理,而不是立刻机械地循环。很多时候,一个“状态标记”就能把O(nq)甚至O(mnq)降到一个可接受的范围。
4. 从C++切到Java:洛谷语言选择的那些细节
4.1 为什么要用Java再交一遍
可能有朋友会问:C++已经写完了,为什么还要用Java写一遍?因为我平时有些训练环境是Java为主,而且我注意到洛谷上有个很常见的搜索热词叫“java洛谷”,说明不少人在用Java刷题。B3866这道题用C++写标记优化后,代码非常短,但换成Java提交时,问题就来了:同样的逻辑,Java跑得更慢,输入输出处理也更讲究。如果你在洛谷提交页面没有选对语言,或者不知道在哪里切换,分分钟被编译错误和超时折磨。
4.2 洛谷如何切换编程语言
在洛谷的题目提交页面,代码框下方或旁边的“提交语言”下拉框里,可以切换不同语言。默认通常是C++,你要选Java的话,点开下拉框找到“Java”或“OpenJDK”之类的选项。如果你是在题库页面直接打开题目再点“提交”,也是一样的位置。
有个新手特别容易踩的坑:本地写好了Java代码,类名写了HelloWorld,提交到洛谷后编译错误。洛谷的Java评测要求主类名必须是Main,文件里不能带package声明,也就是说你最多只能有import语句和public class Main。如果类名不是Main,评测机根本找不到入口,直接给你一个编译错误。我第一次用Java提交洛谷题的时候,就死在过这个类名上。
4.3 用Scanner真的会超时
B3866虽然最终只输出一个矩阵,但输入规模可能很大。Java里最常用的Scanner,在数据量大的时候性能确实不行。它的底层用了正则匹配和缓冲,每读一个整数都要做很多额外操作。如果你用Scanner去读n、m、q,再读整个矩阵和所有操作,一旦数据量到了几十万甚至上百万,光是读入就可能把时间耗掉一大半。
我试过用Scanner写这道题的Java版本,样例能过,但自己造了一组n=1000、m=1000、q=200000的数据,直接超时。换成BufferedReader和StringTokenizer之后,速度立刻快了很多。核心写法是:
java复制import java.io.*;
import java.util.*;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
int n = Integer.parseInt(st.nextToken());
int m = Integer.parseInt(st.nextToken());
int q = Integer.parseInt(st.nextToken());
int[][] a = new int[n][m];
for (int i = 0; i < n; i++) {
st = new StringTokenizer(br.readLine());
for (int j = 0; j < m; j++) {
a[i][j] = Integer.parseInt(st.nextToken());
}
}
int[] row = new int[n];
int[] col = new int[m];
for (int i = 0; i < q; i++) {
st = new StringTokenizer(br.readLine());
int op = Integer.parseInt(st.nextToken());
int x = Integer.parseInt(st.nextToken()) - 1;
if (op == 1) {
row[x] ^= 1;
} else {
col[x] ^= 1;
}
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
int val = a[i][j];
if ((row[i] ^ col[j]) == 1) {
val ^= 1;
}
sb.append(val);
if (j == m - 1) {
sb.append('\n');
} else {
sb.append(' ');
}
}
}
System.out.print(sb);
}
}
注意最后用StringBuilder把所有输出拼起来再一次性System.out.print,这比在循环里一个个System.out.print快很多。System.out是一个被缓冲的输出流,不过频繁调用一样有开销,尤其是要输出几百万个字符的时候。用StringBuilder攒一个大的字符串再输出,是洛谷Java题的基本操作。
5. 提交前的边界测试清单与我的现场翻车记录
5.1 最容易出错的三个地方
这道题我错了三次,分别踩了三个不同的坑。第一个是下标越界,因为题目输入的行号和列号是1-based,数组是0-based,我给忘了减一。第二个是操作类型判断写反了,我写到代码里变成了op==1翻列、op==2翻行,样例刚好第一组数据区分不出来,直到自己造数据才发现。第三个是输出格式,我最后一行多打了一个空格,虽然洛谷对行尾空格一般不判错,但我在本地比对的时候对不上,很烦。
如果你不想像我一样反复提交,建议在写完之后过一遍这个清单:
- 所有从输入读到的行号、列号,有没有统一减一?
- op等于1时对应操作的是行还是列,和题面保持一致?
- row数组和col数组的异或更新,是不是写成row[x] ^= 1而不是row[x] = 1?
- 输出矩阵的时候,是每行末尾都带了一个额外空格,还是只在元素之间加空格?
- 如果n=1或m=1,循环逻辑是否还能正常运行?
5.2 自己造一组能看出问题的样例
不要只拿题目给的样例测。题目样例通常比较小,而且可能覆盖不到翻转两次抵消的情况。我自己造了这样一组数据:
text复制2 3 4
1 0 1
0 1 0
1 1
2 2
1 1
2 2
初始矩阵:
text复制1 0 1
0 1 0
操作分别是:翻第1行,翻第2列,再翻第1行,再翻第2列。按照翻转两次抵消的逻辑,第1行翻了两次,第2列翻了两次,所以最终矩阵应该还是原来的样子。如果你用暴力模拟,结果也应该是原矩阵。但如果你的标记法在更新row或col时用的是赋值而不是异或,这组样例就会暴露问题:你会发现最终矩阵变成了“只翻转一次”的结果。
这种“同一个操作重复偶数次,结果应该等于没操作”的样例,对于所有标记类题目都非常好用。它的本质是测试你的状态合并是否满足幂等性。
5.3 小数据手推一遍,再对拍
在洛谷上做题,如果遇到“样例过了但全红”,我强烈建议不要急着瞎改,先造几组小数据手动推演。比如2行2列,甚至1行3列的矩阵,把每一步操作后的矩阵写在纸上。然后跑一遍代码,对比是不是和你手推的一样。这样做一轮,基本能定位到是读题问题、下标问题还是算法问题。
B3866这类模拟题,逻辑本身不复杂,绝大多数错误都出在“题目要求的操作和我代码里写的操作不是同一个”。对拍小数据是成本最低、见效最快的排查手段。
6. 从B3866延伸出去:标记延迟处理还能用在哪
6.1 类似题型的通用解法
做完这道题之后,我最大的感受是:标记延迟处理的思路并不仅仅适用于这一道题。以后你看到题面里出现“把某一行翻转”“把某一列取反”“把整个区间的值都加上一个数”这类描述时,第一反应不应该是老老实实遍历,而是问自己:能不能把操作先记下来,最后再统一结算?
比如另一个经典场景是图像翻转。给一张n×m的图,连续做若干次水平翻转和垂直翻转,最后输出图片。这个如果用二维数组直接翻转,每次都要O(nm),但如果只记录“水平翻转次数奇偶性”和“垂直翻转次数奇偶性”,最后输出时判断坐标映射关系即可。思路和B3866如出一辙。
再比如一些二维数组的旋转问题。连续旋转90度、180度、270度,虽然旋转是整体性的,但也可以转化为一个坐标变换加上旋转次数的奇偶性,而不必真的四个方向重排数组。
6.2 从“标记”到“懒更新”的进阶
如果你后面接触到树状数组、线段树,会发现延迟标记的思想随处可见。线段树的懒标记本质上也是“先记录,不立刻下放”,等真正需要局部数据时才做真正的更新。B3866是这个思想最简单的版本,因为它的更新只涉及一维状态,合并规则就是异或。
所以我的建议是:刷B类入门题的时候,不要因为“题目简单”就无脑暴力。每一道模拟题,都值得想一想能不能用更少的复杂度完成。很多人觉得洛谷B类题没营养,其实不是题目没营养,而是我们刷题的时候没有带着“可不可以优化”这个问号去写代码。
6.3 关于洛谷平台的小建议
最后聊一点平台使用上的私货。最近还看到有人问“洛谷微信登不了”“vjudge绑定洛谷账号”之类的问题。微信登录偶尔抽风是正常事,如果遇到登录问题,用账号密码登录或者绑定手机号基本能解决。绑定第三方平台这类操作,和写题是两回事,建议先确保洛谷账号本身能正常登录,再去看团队、题单这些功能。洛谷的团队系统用C++刷题时,如果团队内部有私有题目和比赛,提交语言同样可以切换,方法和普通题目一样。不要因为语言切换或登录这些小问题,影响了自己刷题的状态。
复盘完B3866,我个人最大的收获不是那道题本身,而是“先想清楚操作的数学本质,再写代码”这个习惯。行翻转和列翻转,听起来是两个动作,但落到格子上就是异或。抓住这个本质,一行标记更新能顶一百行暴力循环。如果你也被某道“看起来很简单”的B类题卡住,不妨停下来想想:是不是我还在用最笨的方式,替计算机做那些本来可以延后结算的工作?
