2026年3月15日,顺丰春招的笔试里出了一道《黑白纸片》,题目本身不算难,但很有代表性。它表面上是棋盘翻面问题,实际上考查的却是位运算、状态压缩和贪心这三板斧,很多同学一看满屏黑白格子就条件反射地往DFS或者暴力模拟上想,结果白白浪费了时间。这篇文章我按笔试现场的常见版本把题意补全,把思考过程拆开讲清楚,并给出Java、C++、Python三套可以直接提交的代码,最后还会附上本地自测和在线测试的完整方式。不管你是正在刷春招笔试题的应届生,还是想把位运算用得更顺手的人,这篇内容都能帮上忙。
1. 题目还原与考察点分析
春招笔试里的第二题,通常承担的是“稳定拿分”的功能,不会出得像竞赛那样刁钻,但它特别爱考察“你能不能把一个看似二维的问题压缩成简单模型”。《黑白纸片》就是典型:题目包装成一个棋盘翻转,核心却是一个二进制枚举问题。
1.1 完整题面与输入输出
先说题面。我按常见笔试版本复述如下:
小C有一个 n 行 m 列的棋盘,每个格子上都贴着一张纸片,纸片一面是白色,一面是黑色。初始时每张纸片的状态已知,0 表示白色朝上,1 表示黑色朝上。小C每次可以选择任意一行,或者任意一列,把这一整行或一整列的所有纸片翻面,黑色变白色,白色变黑色。操作次数不限,也可以不操作,问最终棋盘上最多能有多少张黑色纸片。
输入格式:
第一行两个整数 n, m 接下来 n 行,每行一个长度为 m 的 01 字符串输出格式:
一行一个整数,表示最多黑色纸片数量数据范围这块,我按笔试时比较常见的规模补充:1 ≤ n ≤ 1000,1 ≤ m ≤ 15。如果你实际拿到的题面范围略有不同,解法框架完全不变,只需要把枚举的上限相应调整一下。
有一点要提前确认:这类题面的输入通常没有空格分隔字符,是一整串01,读的时候千万不要按
char数组加到二维矩阵里再慢慢判断,后面你会发现有更快的处理方式。
1.2 这道题到底在考什么
拆开看,这道题的核心考点有四个:
- 观察力:能否看出“操作次数无限”这句话背后的限制,并及时做状态压缩。
- 位运算:能否用一行二进制整数表示棋盘的一行,用一个异或完成整行翻转。
- 状态压缩枚举:能否枚举列翻转的所有情况,而不是枚举整个棋盘。
- 贪心:能否在列翻转条件固定后,对每一行独立取最大值,并证明这种取法不会互相影响。
这四点放在一起,就已经把题目从“棋盘模拟”拉到了“二进制枚举”的层次。下面我从常规思路开始讲,先说清楚为什么不能直接模拟。
2. 常规思路为什么不可行:组合爆炸
很多人的第一反应是:既然每次可以翻一行或者一列,那我用DFS搜索每一步,翻到最后找一个最大值。这个思路理论上没错,但实际上一算复杂度就会被劝退。
2.1 行列独立但组合多到爆炸
如果老老实实考虑“每一行翻不翻、每一列翻不翻”,那么行方向有 2^n 种选择,列方向有 2^m 种选择,总共是 2^(n+m) 种组合。n 取 1000,m 取 15 的时候,这个数字已经不是程序能跑完的量级了。
而且,DFS 盲目搜索还要加上操作步数这个维度,真正模拟出来的搜索空间更大。所以第一步要做的是压缩状态。
2.2 翻转次数只有奇偶性重要
这里有个很关键的观察:每张纸片被翻转奇数次,最终状态才发生变化;被翻转偶数次,等于没翻。而行和列的操作本质上都是全体取反,所以操作顺序不影响最终结果。
也就是说,任意多次操作之后,最终状态只取决于“哪些行被翻转了奇数次”和“哪些列被翻转了奇数次”。其他行、其他列翻多少次都没用。于是,问题收缩为:选一个行翻转集合和一个列翻转集合,求黑色纸片的最大数量。
2.3 为什么选列来枚举,而不是选行
状态虽然从 2^(n+m) 缩小到了 2^n × 2^m,但 n 和 m 仍然很大。不过题目给了 m ≤ 15 这样一个明显暗示:列的规模很小,列的翻转方案最多只有 2^15 = 32768 种。
反观 n 可能有 1000,行方向根本不能枚举。所以天然的方案是:枚举列翻转的全部状态,然后对每一行做贪心决策。这是本题最核心的算法骨架。
3. 巧解:状态压缩 + 逐行贪心
一旦确定枚举列翻转,剩下的问题就是:在某个列翻转方案下,每一行应该怎么处理?这里用到的技巧是把整行看成一个二进制整数。
3.1 固定列翻转后,每行收益只取决于本行
假设我选好了一个列翻转方案,用 mask 表示,mask 二进制第 k 位为 1,表示第 k 列要翻转。那么对于第 i 行,它收到的列翻转效果是固定的:哪些列被翻面,哪些列不变,完全一样。
此时这一行有两种选择:
- 不翻转这一行,黑色数量就是当前状态下的 black;
- 翻转这一行,整行颜色取反,黑色数量变成 m - black。
因为行与行之间没有任何制约关系,所以每一行都可以独立选择对于自己更有利的那个方案,取 max(black, m - black)。最后把所有行的贡献加起来,就是当前列翻转方案对应的最优答案。
3.2 为什么贪心是对的
有人可能会担心:一行翻多了会不会影响别的行?不会。行翻转只改变本行,列翻转已经被 mask 固定了,行与行之间没有任何交互。于是:
每一行的局部最优解互不影响,全局最优就等于每一行局部最优之和。
这就像你给每个人发固定金额的优惠券,每个人都可以独立决定自己用不用,谁的选择都不会影响别人,那么总收益自然是每个人单独最优收益的总和。这里也是一样的道理。
3.3 位运算落地三步
讲完了贪心,再把它落到具体代码上,其实就是三步:
第一步:把一行01字符串压缩成一个整数。从左到右读字符串,不断执行row = (row << 1) | (s[j] - '0')。这样一行纸片就变成了一个整数,黑纸片对应二进制 1,白纸片对应二进制 0。
第二步:用异或实现整行翻转。如果 mask 的第 k 位为 1,表示第 k 列需要翻转,那么row ^ mask的结果就是这一行在列翻转之后的新状态。因为异或运算中,1 和 0 异或等于 1,1 和 1 异或等于 0,天然就是翻转。
举个例子:某一行状态是二进制101,列翻转 mask 是010,只翻中间那一列,异或结果是111。二进制里的 0 变成了 1,1 保持不变,操作完全正确。
第三步:用 popcount 数出黑纸片数量。一个整数二进制中有多少个 1,就是这一行当前有多少张黑色纸片。Java 可以用Integer.bitCount,C++ 可以用__builtin_popcount,Python 3.10+ 可以直接用int.bit_count()。
这三步做完,时间复杂度就是 O(2^m × n),m 取 15、n 取 1000 时,运算量大约是 32768 × 1000 = 3.3 × 10^7,三个语言都能轻松跑完。
4. Java/C++/Python 三版代码与细节
思路确认后,代码写起来就很快了。我直接给出三份完整可提交的代码,同时把每份代码里容易踩的坑标出来。
4.1 Java 版本(推荐)
import java.io.*; import java.util.StringTokenizer; 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[] rows = new int[n]; for (int i = 0; i < n; i++) { String line = br.readLine(); int row = 0; for (int j = 0; j < m; j++) { row = (row << 1) | (line.charAt(j) - '0'); } rows[i] = row; } int ans = 0; int totalMasks = 1 << m; for (int mask = 0; mask < totalMasks; mask++) { int blackCount = 0; for (int i = 0; i < n; i++) { int flipped = rows[i] ^ mask; int black = Integer.bitCount(flipped); blackCount += Math.max(black, m - black); } ans = Math.max(ans, blackCount); } System.out.println(ans); } }这里我用了BufferedReader而不是Scanner,因为笔试数据量可能很大,Scanner的 parse 开销在极限数据下会拖慢程序。另外要注意line.charAt(j) - '0'这一步,比Integer.parseInt(line.substring(j, j + 1))快得多。
4.2 C++ 版本(跑得最稳)
#include <iostream> #include <vector> #include <string> #include <algorithm> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; vector<int> rows(n, 0); for (int i = 0; i < n; i++) { string s; cin >> s; for (int j = 0; j < m; j++) { rows[i] = (rows[i] << 1) | (s[j] - '0'); } } int ans = 0; int totalMasks = 1 << m; for (int mask = 0; mask < totalMasks; mask++) { int cur = 0; for (int i = 0; i < n; i++) { int black = __builtin_popcount(rows[i] ^ mask); cur += max(black, m - black); } ans = max(ans, cur); } cout << ans << '\n'; return 0; }C++ 版本里__builtin_popcount是 GCC 编译环境下的内置函数,很多 OJ 都支持。如果你遇到不支持bits/stdc++.h的环境,用上面这几个标准头文件就够了。ios::sync_with_stdio(false);和cin.tie(nullptr);这两行是处理大量输入的关键,忘了写很容易被卡 IO。
4.3 Python 版本(简洁但注意性能)
import sys def main(): data = sys.stdin.read().strip().split() if not data: return n, m = map(int, data[:2]) rows = [] idx = 2 for _ in range(n): s = data[idx] idx += 1 row = 0 for ch in s: row = (row << 1) | (ord(ch) - 48) rows.append(row) ans = 0 for mask in range(1 << m): total = 0 for row in rows: black = (row ^ mask).bit_count() total += max(black, m - black) ans = max(ans, total) print(ans) main()Python 版本有两个细节要注意。第一是int.bit_count()需要 Python 3.10 及以上版本,如果评测环境版本低,可以改成bin(row ^ mask).count("1"),但速度会慢一些。第二是读取方式,我直接用sys.stdin.read().split()一次性读入,比循环调用input().strip()更快,在 n 到达 1000 时差距还能接受,但养成用整段读入的习惯总没错。
4.4 三版代码对比
| 语言 | 时间复杂度 | 空间复杂度 | 注意点 |
|---|---|---|---|
| Java | O(2^m × n) | O(n) | 必须用 BufferedReader;位计数用 Integer.bitCount |
| C++ | O(2^m × n) | O(n) | 开启 IO 同步关闭;用 __builtin_popcount |
| Python | O(2^m × n) | O(n) | 用 bit_count();用 sys.stdin.read() 整段读取 |
从稳定性来说,C++ 在极限数据下最不容易超时;Java 只要 IO 处理得当,同样没问题;Python 在 m=15、n=1000 这个数据范围下也能跑完,但如果你发现边界数据已经到 m=20 附近,Python 就会比较吃力,建议优先做优化而不是硬跑。
5. 在线测试与本地自测方式
笔试里最怕的不是不会写,是写完了不知道自己到底对不对。我习惯在提交前用几个样例在本地跑一遍,确认逻辑和边界都正确后,再上测评系统。
5.1 用手写样例验证答案
我准备了一个简单样例,很适合快速验证:
输入:
2 3 101 010输出:
6简单验证一下:第一行101有 2 个黑色,若翻转整行,最多可以变成 2 个黑色,即 max(2, 1) = 2;第二行010只有 1 个黑色,不翻行是 1,翻行是 2,取 2;所以列翻转 mask 全 0 时,总贡献是 2 + 2 = 4。如果选择列翻转 mask =010,第一行变成111,取 3;第二行变成000,不翻行是 0,翻行是 3,取 3;总贡献是 6。这就是最优答案。
再给一个能覆盖更多行情况的样例:
输入:
4 3 101 010 111 000输出:
10这个例子可以用来测试边界:全黑行111取 3,全白行000翻转后取 3,普通行取 2 或 2,最后能凑到 10。你可以在本地把这组输入跑一遍,看看三个版本是不是都输出 10。
5.2 本地运行三版代码
本地验证时,我习惯把所有输入放到input.txt文件里,然后分别用下面的命令执行:
# C++ 编译运行 g++ -O2 -std=c++17 black_white.cpp -o solve ./solve < input.txt # Java 编译运行 javac Main.java java Main < input.txt # Python 运行 python3 main.py < input.txt这里有个小习惯:把输入数据存成文件再重定向,可以避免反复手动敲样例。尤其是笔试时,时间很紧,写一个input.txt比一次一次粘贴方便得多。
5.3 在线测试与提交平台的注意点
在线测试一般是你笔试时用的那个评测系统,或者牛客网、LeetCode 之类的在线题库。提交时要注意几个点:
- Java 的主类名必须是
Main,否则会编译失败。 - C++ 不要输出多余提示信息,比如
请输入n:这种东西,OJ 只认标准结果。 - Python 代码里不要写
if __name__ == "__main__":之外的顶层冗余代码,保持main()入口清晰。
如果你是在本地跑通过,再提交到在线平台,基本上除了 IO 方式不同,其余逻辑不会变。
6. 排坑与个人心得
每次写完位运算相关的题,我都会整理一份自己的“踩坑清单”,这道题也不例外。
6.1 几个必踩的坑
第一个坑:忘了重置计数变量。在枚举 mask 的循环里,每一轮都要把blackCount重置为 0,否则会把上一轮的答案叠加进去,导致输出离谱地大。这个错误很隐蔽,因为在小样例上可能看不出问题,数据一大就全错。
第二个坑:行列翻转对象搞反。mask 枚举的是列翻转,所以行数组是rows[i] ^ mask,如果你不小心写成rows[i] & mask或者其他运算,整道题就废了。位运算里异或就是“对应位不同则结果为 1”,这正是翻面的语义。
第三个坑:位运算优先级。在 Java 和 C++ 中,^的优先级低于==和算术运算符,高于&&,但低于+、<<这种。所以如果你写rows[i] ^ mask == 0,实际运行结果会跟预期完全不同。稳妥的做法是给异或运算加括号,例如(rows[i] ^ mask)。
第四个坑:输入字符串里可能有空格或换行残留。用BufferedReader或cin >> s处理 01 字符串时,问题不大;但如果你用Scanner或input()读取,就要小心换行符带来的空串问题。我的建议是统一用整段读取再切分的方式,避免这类问题。
6.2 时间分配与心态
这道题放在春招第二题的位置上,理想状态下应该在 20 到 30 分钟内完成。我个人的建议是:看到棋盘题不要急着写搜索,先观察数据范围。一旦发现 m 很小而 n 很大,就要立刻联想到状态压缩枚举。反过来,如果 m 和 n 都很大,那就不能用这个思路,需要找别的性质。
我第一次做这道题时,也走了弯路,先写了一个 DFS 版本,结果自己用 20 行的样例一跑就卡死,后来才想到枚举列翻转。所以强烈建议大家在平时刷题时就养成一个习惯:题目读完后,先把数据范围写在草稿纸上,再决定算法方向。
6.3 扩展:如果 m 更大怎么办
如果题目把 m 改成 20 以上,比如 m=25,那么 2^25 约等于 3300 万,再乘 n=1000 就是 300 亿次,三版代码都会超时。这时候就需要更高级的思路,比如按行的等价类分组,或者用类似 meet-in-the-middle 的技术。但如果是春招题,m 通常不会给到这么大,所以这道题的“标准答案”就是枚举列状态。
我在实际笔试中还有一个体会:代码写完不要急着提交,先用极小的边界数据测试一下,比如1 1、全 0 棋盘、全 1 棋盘。全 0 棋盘应该输出 n×m,因为每一行翻转一次就能全变黑;全 1 棋盘同样应该输出 n×m,因为不翻就是全黑。这种边界测试花不了 30 秒,但能救回很多不该丢的分。
如果你能把这道题的思路讲清楚,代码写完还能顺手验证几个边界,那么顺丰春招笔试的这个环节基本就稳了。