题单 · 习题解析

洛谷 P5461 赦免战俘

递归的顺序不等于输出的顺序;写对之后回头看,这个分治其实等价于一句 (i | j) 的话

原题:洛谷 P5461出自 第 1 章 递归入门:函数怎么调用自己 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P5461,日期见页头。两边不一致时信原站。

题目背景:借助反作弊系统,一些在月赛有抄袭作弊行为的选手被抓出来了!

题目描述

现有 2^n × 2^nn ≤ 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第 ① 版:递归到哪儿就打到哪儿(这一版是错的)

思路对了之后,最顺手的动作是:算到哪个格子就把它打出来

p5461Print.cpp第 ① 版(错的)
输入 3。对照上面的样例看 —— 数字的个数是对的,位置全错。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 错在一个和算法完全无关的地方

分治本身一点毛病都没有。错的是这一句:

递归是按「块」走的,输出是按「行」走的。

递归会先把左上那一整块处理完,才轮到右上 —— 可这两块在同一批行里。 于是打出来的每一行,都是从不同的块里拼过来的碎片。

★ 这是初学分治最常见的一种翻车,值得单独记一句话:

递归的顺序 ≠ 输出的顺序。只要两者不一致,就必须先把结果存下来,最后统一输出。

⚠ 这个错很难自查,因为输出的数字个数、0 和 1 的比例全都是对的 —— 只有逐格对照样例才看得出来。

3第 ② 版:先填数组,最后统一输出(答案对了)

治法只有一个:递归函数从此不负责输出,只负责「往格子里写数」; 等递归全部结束,再一行一行打出来。

p5461Slow.cpp第 ② 版(对,但可能超时)
输入 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 但这道题还有第二关,而且和算法无关

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.cpp第 ③ 版(正解)
输入 3,和第 ② 版逐字节相同。改的只是输出方式。试试 10 —— 一百万个数。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 这两条不是万能咒语,而且关同步有前提

只在输出量真的很大时才要紧。 平时的题目输出几行,加不加毫无区别 —— 别把它当成「写了就变快」的口诀到处贴。

⚠⚠ 关同步有一个必须记住的前提:这份代码里不能再出现 printfcoutprintf 各自缓冲,混用又关了同步,打印顺序会乱掉 (这本书在第 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       ✓
p5461Bit.cpp第 ④ 版(一行判断)
输入 3,和第 ③ 版逐字节相同 —— 但它一次递归都没有。
// 洛谷 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 这一版的价值不是「更优」

第 ③ 版早就够快了,这一版不会让你多拿一分。它值钱的地方在于那个发现本身:

一个分治过程,常常等价于一句关于二进制位的话。

第 28 章状压 DP、第 38 章树状数组都会再见到这种「下标的二进制形状」。

⚠ 但顺序不能反:先把递归写对,再回头找规律。 上来就想凑公式,凑错了你连一个能对拍的正确版本都没有。

6回头看:这道题在教什么

✓ 三件带得走的东西
  1. 递归的顺序 ≠ 输出的顺序。 不一致就先存后打 —— 这条以后写树、写图都会用到。
  2. 算得快和打得快是两件事。 输出量上百万时,endl 和同步会单独把你卡掉。
  3. 写对之后回头看一眼。 分治常常藏着一句关于二进制位的规律, 看出来的那一下比多做一道题值钱。