0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P5461,日期见页头。两边不一致时信原站。
题目背景:借助反作弊系统,一些在月赛有抄袭作弊行为的选手被抓出来了!
题目描述
现有 2^n × 2^n(n ≤ 10)名作弊者站成一个正方形方阵等候发落。他将正方形矩阵均分为 4 个
更小的正方形矩阵,每个更小的矩阵的边长是原矩阵的一半。其中左上角那一个矩阵的所有作弊者都将得到赦免,
剩下 3 个小矩阵中,每一个矩阵继续分为 4 个更小的矩阵,然后通过同样的方式赦免作弊者……
直到矩阵无法再分下去为止。所有没有被赦免的作弊者都将被处以棕名处罚。
给出 n,请输出每名作弊者的命运,其中 0 代表被赦免,1 代表不被赦免。
输入格式:一个整数 n。
输出格式:2^n × 2^n 的 01 矩阵,代表每个人是否被赦免。数字之间有一个空格。
1先看清楚:分治的形状是什么
题面已经把递归写好了,几乎不用自己想:
处理一个边长 len 的正方形:
len == 1 -> 这一个人不被赦免(填 1)
否则 -> 切成四块,左上那块整块赦免(填 0),另外三块各自重复这个过程
这就是第 1 章说的「信任函数」:你只要写清楚「处理一个正方形」这件事, 它自己会把整张图铺满。
输入
3
输出
0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 1 1 0 0 0 1 0 0 0 1 0 0 1 1 0 0 1 1 0 1 0 1 0 1 0 1 1 1 1 1 1 1 1 1
n = 3,也就是 8×8。上面那段输出是仓库里的 p5461.cpp 真跑出来的,
和原题样例逐字节相同。注意最后一行全是 1,第一行只有最右边是 1 —— 记住这个形状,第 ⑤ 步会用到。
2第 ① 版:递归到哪儿就打到哪儿(这一版是错的)
思路对了之后,最顺手的动作是:算到哪个格子就把它打出来。
// 洛谷 P5461 赦免战俘 —— 第 ① 版:递归的时候顺手就把答案打出来(**这一版是错的**)//// 输入:n(n <= 10)// 输出:本该是 2^n × 2^n 的 01 矩阵//// 这份为什么存在:分治的思路完全对 —— 切成四块,左上角整块赦免,另外三块继续切。// 而人写到这一步,最顺手的动作就是「算到哪儿就打到哪儿」。//// ⚠ 但它错得很彻底,而且错在一个和算法无关的地方:**递归是按「块」走的,输出是按「行」走的。**// 递归先把左上那一整块处理完,才轮到右上 —— 可这两块在同一批行里。// 于是打出来的东西,每一行都是从别的块拼过来的碎片,**根本对不上位置**。//// ★ 这是初学分治最常见的一种翻车,值得单独记住一句话:// **递归的顺序 ≠ 输出的顺序。** 只要两者不一致,就必须先把结果存下来,最后统一输出。
#include <bits/stdc++.h>using namespace std;
// 处理左上角在 (r, c)、边长为 len 的正方形void solve(int r, int c, int len) { if (len == 1) { cout << 1 << " "; return; } // 分不下去了,这一个不赦免
int h = len / 2; for (int i = 0; i < h; i++) // 左上角整块赦免 for (int j = 0; j < h; j++) cout << 0 << " ";
solve(r, c + h, h); // 右上 solve(r + h, c, h); // 左下 solve(r + h, c + h, h); // 右下}
int main() { int n; if (!(cin >> n)) return 0; solve(0, 0, 1 << n); cout << "\n"; return 0;}点「运行 ▶」看结果
分治本身一点毛病都没有。错的是这一句:
递归是按「块」走的,输出是按「行」走的。
递归会先把左上那一整块处理完,才轮到右上 —— 可这两块在同一批行里。 于是打出来的每一行,都是从不同的块里拼过来的碎片。
★ 这是初学分治最常见的一种翻车,值得单独记一句话:
递归的顺序 ≠ 输出的顺序。只要两者不一致,就必须先把结果存下来,最后统一输出。
⚠ 这个错很难自查,因为输出的数字个数、0 和 1 的比例全都是对的 —— 只有逐格对照样例才看得出来。
3第 ② 版:先填数组,最后统一输出(答案对了)
治法只有一个:递归函数从此不负责输出,只负责「往格子里写数」; 等递归全部结束,再一行一行打出来。
// 洛谷 P5461 赦免战俘 —— 第 ② 版:先填进数组,最后统一输出(答案对了)//// 输入:n// 输出:2^n × 2^n 的 01 矩阵//// 这份为什么存在:第 ① 版的病根是「递归按块走、输出按行走」。// 治法只有一个:**先把结果填进一个二维数组,等递归全部结束,再一行一行打出来。**// 递归函数从此不负责输出,只负责「往格子里写数」。//// ★ 答案这下全对了。但这一版还有一个和算法完全无关的问题,第 ③ 版收拾它:// n = 10 时要打出 1024 × 1024 = 1 048 576 个数。// 这一版用的是没关同步的 cout,还每行 endl 刷一次缓冲 —— 光是输出就可能把时限吃光。// ⇒ **「算得快」和「打得快」是两件事**,这道题两件都要管。
#include <bits/stdc++.h>using namespace std;
int a[1030][1030];
void solve(int r, int c, int len) { if (len == 1) { a[r][c] = 1; return; } // 分不下去了,这一个不赦免
int h = len / 2; for (int i = r; i < r + h; i++) // 左上角整块赦免(填 0,本来就是 0) for (int j = c; j < c + h; j++) a[i][j] = 0;
solve(r, c + h, h); solve(r + h, c, h); solve(r + h, c + h, h);}
int main() { int n; if (!(cin >> n)) return 0; int m = 1 << n; solve(0, 0, m);
for (int i = 0; i < m; i++) { for (int j = 0; j < m; j++) cout << a[i][j] << (j + 1 < m ? " " : ""); cout << endl; // ⚠ 每行刷一次缓冲,1024 行就是 1024 次 } return 0;}点「运行 ▶」看结果
n = 10 时要打出 1024 × 1024 = 1 048 576 个数。
这一版用的是没关同步的 cout,而且每行 endl 刷一次缓冲 ——
光是输出就可能把时限吃光。
⇒ 「算得快」和「打得快」是两件事,这道题两件都要管。 这一版属于「算法对了、输出拖后腿」,是一种很值得单独认一次的失败。
4第 ③ 版:把输出也弄快(正解)
算法一个字都不改,只动输出那几行:
ios::sync_with_stdio(false); // 断开 cout 和 C 的 stdio 同步
cin.tie(nullptr);
...
cout << a[i][j] << ' '; // 不用 endl —— 它会强制刷缓冲
cout << '\n';
// 洛谷 P5461 赦免战俘 —— 第 ③ 版:把输出也弄快(正解)//// 输入:n(n <= 10)// 输出:2^n × 2^n 的 01 矩阵,数字之间一个空格//// 和第 ② 版的差别**只有输出那几行**,算法一个字没改:// · ios::sync_with_stdio(false) —— 断开 cout 和 C 的 stdio 同步(这份代码不用 printf,所以安全)// · endl 换成 '\n' —— endl 会**强制刷缓冲**,1024 行就是 1024 次系统调用//// ⚠ 这两条只在「输出量很大」的题里才要紧,而这道题正是:// n = 10 时要打 1 048 576 个数。平时的题目输出几行,加不加毫无区别,别当成万能咒语。//// ⚠⚠ 关同步有个**必须记住的前提**:这份代码里不能再出现 printf。// cout 和 printf 各自缓冲,混用又关了同步,打印顺序会乱掉(这本书在第 26 章踩过)。
#include <bits/stdc++.h>using namespace std;
int a[1030][1030];
// 处理左上角在 (r, c)、边长为 len 的正方形:左上那一小块整块赦免,另外三块继续分void solve(int r, int c, int len) { if (len == 1) { a[r][c] = 1; return; }
int h = len / 2; solve(r, c + h, h); // 右上 solve(r + h, c, h); // 左下 solve(r + h, c + h, h); // 右下 // 左上那一块什么都不用做 —— 数组本来就是 0,而 0 正是「被赦免」}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; int m = 1 << n; solve(0, 0, m);
for (int i = 0; i < m; i++) { for (int j = 0; j < m; j++) cout << a[i][j] << (j + 1 < m ? " " : ""); cout << '\n'; } return 0;}点「运行 ▶」看结果
只在输出量真的很大时才要紧。 平时的题目输出几行,加不加毫无区别 —— 别把它当成「写了就变快」的口诀到处贴。
⚠⚠ 关同步有一个必须记住的前提:这份代码里不能再出现 printf。
cout 和 printf 各自缓冲,混用又关了同步,打印顺序会乱掉
(这本书在第 26 章踩过一次,表格跑到结语后面去了)。
⇒ 规矩:要么全 cout + 关同步,要么全 printf + 不关。别混。
5第 ④ 版:写完之后回头看,它其实有个闭式
递归写对了不是终点。 把它写对之后回头盯着那张图看,常常能看出一条一句话就能说清的规律。
跟着推一遍,三十秒:
一个人最终是 1(不被赦免)
<=> 他每一层都没落进左上角那一块
<=> 每一层里,行号和列号的那一个二进制位不同时为 0
<=> (行号 | 列号) 的每一位都是 1
<=> (i | j) == 2^n - 1
拿 n = 3 的样例验(下标从 0 数):
最后一行 i = 7 = 111 -> i | j == 7 恒成立 -> 整行全是 1 ✓
第一行 i = 0 = 000 -> 只有 j = 7 才成立 -> 只有最右是 1 ✓
第二行 i = 1 = 001 -> j 要含 110 -> j = 6, 7 ✓
// 洛谷 P5461 赦免战俘 —— 第 ④ 版:写完之后回头看,它其实有个闭式(不用递归)//// 输入:n// 输出:和第 ③ 版逐字节相同//// 这份为什么存在:**递归写对了不是终点**。把它写对之后回头盯着那张图看,// 常常能看出一条一句话就能说清的规律 —— 而看出来的过程本身就是收获。//// 推导(跟着走一遍,三十秒):// 一个人最终是 1(不被赦免),当且仅当**他每一层都没落进左上角那一块**。// 而「落进左上块」= 这一层里,行号和列号的那一个二进制位**同时是 0**。// ⇒ 他是 1 ⟺ 行号和列号的**每一个二进制位都不同时为 0**// ⇒ 也就是 (i | j) 的每一位都是 1// ⇒ i | j == 2^n - 1//// 拿 n = 3 验一下(下标从 0 数):// 最后一行 i = 7 = 111,那么 i | j == 7 恒成立 ⇒ 整行全是 1 ✓(对照样例)// 第一行 i = 0,要 i | j == 7 只能 j = 7 ⇒ 只有最右边是 1 ✓//// ★ 这一版不是「更优解」—— 第 ③ 版早就够快了。它的价值在于:// **一个分治过程,常常等价于一句关于二进制位的话。**// 第 28 章状压、第 38 章树状数组都会再见到这种「下标的二进制形状」。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; int m = 1 << n, full = m - 1;
for (int i = 0; i < m; i++) { for (int j = 0; j < m; j++) cout << ((i | j) == full ? 1 : 0) << (j + 1 < m ? " " : ""); cout << '\n'; } return 0;}点「运行 ▶」看结果
第 ③ 版早就够快了,这一版不会让你多拿一分。它值钱的地方在于那个发现本身:
一个分治过程,常常等价于一句关于二进制位的话。
第 28 章状压 DP、第 38 章树状数组都会再见到这种「下标的二进制形状」。
⚠ 但顺序不能反:先把递归写对,再回头找规律。 上来就想凑公式,凑错了你连一个能对拍的正确版本都没有。
6回头看:这道题在教什么
- 递归的顺序 ≠ 输出的顺序。 不一致就先存后打 —— 这条以后写树、写图都会用到。
- 算得快和打得快是两件事。 输出量上百万时,
endl和同步会单独把你卡掉。 - 写对之后回头看一眼。 分治常常藏着一句关于二进制位的规律, 看出来的那一下比多做一道题值钱。