做USACO训练的时候,我在1.2章节撞上P1205这道“方块转换 Transformations”,第一次提交就被打回一个WA。当时很不服气,觉得这不就是把矩阵转一转、翻一翻,有什么难的?后来静下心排查才发现,这道题卡人的根本不是算法复杂度,而是坐标映射方向、组合变换顺序、输出优先级这三层细节。如果你也在刷USACO题单或者洛谷的入门题,这道题几乎是绕不开的,它非常适合用来把“图形变换”落实到数组坐标操作上,把最容易想当然的几个坑一次性踩平。
这篇文章我就把这题从题面到代码、从公式推导到实测翻车经验完整过一遍。我会先拆解七种变换之间的逻辑关系,再手把手推坐标映射公式,给出一份能在洛谷直接AC的C++代码,最后把最容易出错的地方和延伸训练价值都聊透,保证你下次遇到同类矩阵变换题不会再犯迷糊。
1. 题目到底在考什么:七种变换的几何逻辑
P1205的题面其实很短:给定两个N×N的方阵,一个是原始方阵,一个是目标方阵,方阵里只有两种字符,比如黑块和白块、‘@’和‘-’。要求判断原始方阵能否通过七种变换中的某一种变成目标方阵,然后输出编号最小的那一种。
七种变换分别是:
- 顺时针旋转90度
- 顺时针旋转180度
- 顺时针旋转270度
- 水平镜像,也就是左右翻转
- 先做水平镜像,再顺时针旋转90度、180度或270度中的某一种
- 不做任何变换,原始方阵与目标方阵完全相同
- 以上六种方案都不满足,输出7
从应试角度,这题的三个关键认知点必须一开始就建立起来。
第一,第5种方案到底是什么顺序。英文原题写的是Combination: mirror then rotate,意思非常明确:先镜像,后旋转。不是“先旋转再镜像”,更不是“旋转和镜像随便组合”。有些同学想当然地把两种顺序都算进去,结果把题目语义扩大了。除非你仔细读过原题,否则很容易在这里栽跟头。
第二,输出编号最小的方案。这句话的杀伤力比大多数人想象得大。如果原始方阵同时满足第1种和第6种怎么办?最典型的就是N=1的情况:只有一个格子,旋转90度后还是自己,原始和目标也一样,这时候必须输出1而不是6,因为第1种编号更小。而且不仅仅是N=1,如果方阵旋转后保持原样,且原始恰好和目标相同,那么1、2、3、6这几种方案可能同时成立,你必须按顺序从1检查到6,命中哪个就立刻输出哪个,不能把所有方案都算出来再排序。
第三,变换后必须逐字符完全相等才算匹配。这不是图形题,是数组题。你脑子里觉得“转过来看着差不多”没用,程序里比的是每一个位置上的字符是否一致。也就是说,所有变换最终都要落到“生成一个新矩阵,然后和目标矩阵逐格比较”这条逻辑上。
1.1 七种变换的分类记忆法
别把七种方案当七个孤立函数去背,分类理解会轻松很多。
- 纯旋转类:第1、2、3种,本质是绕中心旋转,只是角度不同。
- 纯镜像类:第4种,只做一次左右翻转。
- 组合类:第5种,内部其实有三个候选角度,镜像后的方阵分别转90、180、270度,任何一个匹配都算命中。
- 恒等类:第6种,原样不变。
这样一整理,真正需要写的核心变换函数只有四个:rotate90、rotate180、rotate270、mirror。第5种就是mirror结果分别和三个旋转组合,第6种就是直接比较原始和目标。
1.2 N的范围为什么决定了解题思路
这题的N最大值是10,也就是说方阵最多100个格子。这个规模意味着任何暴力做法都不会超时,你根本不需要在时间复杂度上绞尽脑汁。
很多人刷题有个误区,一看到“USACO”就觉得是不是要上什么高端算法,其实完全不是。N=10的条件下,你就算把每种变换都单独写一个函数,每个函数扫描一遍矩阵,总共七种判断加起来的操作量也只有几百次,连性能优化的边都摸不到。这道题真正的训练价值在“准确地把规则转换成代码”,在“代码组织的清晰度”,而不是算法构思。
2. 坐标映射公式从哪里来:N=3小方阵手推全过程
我一直认为,矩阵变换题如果只靠图形想象硬做,迟早会在某个角度上翻车。最稳妥的思路是把“图形怎么动”翻译成“坐标怎么变”。这一章我就用3×3的小方阵,把旋转和镜像的坐标公式完整推一遍。
先约定坐标:行号i、列号j都从0开始,也就是C/C++数组下标。方阵有N行N列,所以i和j的取值范围都是0到N-1。原始矩阵叫a,目标矩阵叫b。
2.1 顺时针旋转90度:最核心的一条公式
顺时针旋转90度的几何直觉是:最上面一行变成最右边一列,最左边一列变成最上面一行。我们来看一个3×3方阵每个格子的原始坐标:
(0,0) (0,1) (0,2) (1,0) (1,1) (1,2) (2,0) (2,1) (2,2)顺时针旋转90度之后,效果应该是这样:
(2,0) (1,0) (0,0) (2,1) (1,1) (0,1) (2,2) (1,2) (0,2)观察几个关键点。左上角(0,0)旋转后跑到了右上角,新坐标是(0,2)。上边中间(0,1)旋转后跑到了右边中间,新坐标是(1,2)。左下角(2,0)旋转后跑到了左上角,新坐标是(0,0)。
找规律:原始坐标(i,j)顺时针旋转90度后,新坐标变成(j, N-1-i)。验证一下,原(0,0)代入得(0, 2),正确;原(0,1)代入得(1, 2),正确;原(2,0)代入得(0, 0),正确。
这条公式是整道题最重要的一个点,很多人写错旋转代码,就是把N-1-i写成了i或者N-i。为什么是N-1-i而不是N-i?因为下标从0开始,最大行号是N-1。N=3时第0行旋转后会落到第N-1-0=2列,刚好是最后一列;如果你用N-i,第0行会落到第3列,数组直接越界。
用同样的方式可以推出180度和270度的公式,我把四个核心映射统一列成一张表,写代码时直接查表就行。
| 变换 | 新坐标(i', j') |
|---|---|
| 顺时针90° | (j, N-1-i) |
| 顺时针180° | (N-1-i, N-1-j) |
| 顺时针270° | (N-1-j, i) |
| 水平镜像(左右翻转) | (i, N-1-j) |
2.2 180度和270度:套公式还是函数嵌套?
180度旋转就是把方阵上下左右同时反转,左上角的(0,0)会到右下角(N-1,N-1)。套公式(N-1-i, N-1-j),N=3时(0,0)变成(2,2),正确。270度等价于逆时针90度,但题目只认顺时针270度,所以新坐标是(N-1-j, i),验证(0,0)跑到左下角(2,0),正确。
这里有个实现上的取舍:270度既可以直接套公式,也可以通过调用三次rotate90来实现。两者都能过,因为N=10的时候性能完全可以忽略。但从代码可读性来说,我建议单独实现rotate270函数,公式直接写在里面。我见过有人把旋转写成rotate90(rotate90(rotate90(a))),虽然逻辑正确,但看着绕,而且容易让初学者混淆“旋转三次”和“旋转270度”的关系。
2.3 水平镜像:行不变、列对调
水平镜像是以竖直中线为轴,把左右两边对调。所以行号i完全不变,列号j变成N-1-j,公式就是(i, N-1-j)。
这里必须强调一个中文翻译容易引起的歧义:“水平镜像”到底是左右翻转还是上下翻转?USACO原题里的mirror指的是左右翻转,也就是以竖直中线为轴的镜像。洛谷翻译也叫“水平镜像”。但汉语里“水平”这个词有时候会让人联想到水平方向的轴,也就是上下翻转,这就是个陷阱。
我用USACO的经典样例说明。假设原始矩阵是这样的:
@-@ --- @@-水平镜像的结果应该是第一行@-@还是@-@,第三行@@-变成-@@:
@-@ --- -@@注意第三行的变化:最右边的‘-’跑到了最左边,这正是左右对调。如果你把它理解成上下翻转,第一行和第三行会直接互换,结果完全不一样。所以做题前一定先拿这个样例验证自己脑子里的“水平镜像”方向。
2.4 组合变换:为什么建议用临时矩阵
第5种“先镜像后旋转”,在实现上有两种思路。
第一种是直接推导组合公式,比如“镜像后顺时针90度”等价于(i,j) -> (N-1-j, N-1-i)。这种思路看起来很酷,但三个旋转角度各自推导一套组合公式,太容易出错,而且代码可读性很差,你过两个月回头看根本不知道自己在算什么。
第二种是用临时矩阵:先调用mirror函数得到临时矩阵tmp,再分别把tmp旋转90、180、270度,和目标矩阵比较。只要有一个相等,就算命中第5种。
我强烈推荐第二种,原因很简单:每个函数只做一件事,逻辑链路短,出错概率小。而且N=10的规模,多拷贝几次矩阵完全无所谓。为了省那点根本不存在的时间,去写一堆容易出错的组合公式,非常不划算。
3. 能直接跑通的代码:C++实现与判等细节
这是我在洛谷提交过、能过全部测试点的C++17代码。我故意把函数写得看起来有点啰嗦,目的是让逻辑一步到位,初学者也能一眼看懂。
#include <bits/stdc++.h> using namespace std; int n; vector<string> a, b; vector<string> rotate90(const vector<string>& s) { vector<string> res(n, string(n, ' ')); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) res[j][n - 1 - i] = s[i][j]; return res; } vector<string> rotate180(const vector<string>& s) { vector<string> res(n, string(n, ' ')); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) res[n - 1 - i][n - 1 - j] = s[i][j]; return res; } vector<string> rotate270(const vector<string>& s) { vector<string> res(n, string(n, ' ')); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) res[n - 1 - j][i] = s[i][j]; return res; } vector<string> reflect(const vector<string>& s) { vector<string> res(n, string(n, ' ')); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) res[i][n - 1 - j] = s[i][j]; return res; } bool same(const vector<string>& x, const vector<string>& y) { for (int i = 0; i < n; ++i) if (x[i] != y[i]) return false; return true; } int main() { cin >> n; a.resize(n); b.resize(n); for (int i = 0; i < n; ++i) cin >> a[i]; for (int i = 0; i < n; ++i) cin >> b[i]; if (same(rotate90(a), b)) cout << 1 << '\n'; else if (same(rotate180(a), b)) cout << 2 << '\n'; else if (same(rotate270(a), b)) cout << 3 << '\n'; else if (same(reflect(a), b)) cout << 4 << '\n'; else if (same(rotate90(reflect(a)), b) || same(rotate180(reflect(a)), b) || same(rotate270(reflect(a)), b)) cout << 5 << '\n'; else if (same(a, b)) cout << 6 << '\n'; else cout << 7 << '\n'; return 0; }这段代码的核心都是四个变换函数加一个判等函数,main函数里的逻辑简单得不能再简单。下面我拆几个关键点说明。
3.1 为什么变换函数必须返回新矩阵
如果你在旋转函数里直接改原始数组,边遍历边覆盖,会出大问题。举一个最简单的例子:把a[0][0]移动到a[0][2]之后,如果继续遍历到a[0][2],读到的已经是移动后的新值,不是原始数据了,整个矩阵会变得乱七八糟。
所以每个变换函数都必须先在函数体里创建新的矩阵res,把所有值算完再整体返回。vector 按值返回不会拷贝失败,因为STL容器天然支持深拷贝,你只需要确保res的每一行都被正确初始化成固定长度的字符串。
3.2 same函数的比较逻辑
same函数我用了逐行字符串比较,因为vector 的每一行就是一个string,直接x[i] != y[i]就能判断整行是否相等。这样写比二重循环逐字符比较简洁得多。
如果你用的是char a[10][10],那就要老老实实两层循环逐个字符比。另外要注意:用cin >> s读字符串时,会自动跳过换行符,不会把空行读进来,所以用vector 的方案从输入环节就规避了一半的格式坑。
3.3 第5种分支的三种调用为什么必须完整
第5种是“镜像后旋转”,但镜像后的旋转有90、180、270三个角度,对应三种候选结果。我在else if里用三个same调用做逻辑或,只要其中一个和目标相等就输出5。
这个位置是我见过翻车频率最高的地方。很多人觉得“镜像后旋转”只写一个rotate90就够了,漏掉了180和270。如果你也这么干,凡是需要“镜像后转180度”或“镜像后转270度”才能匹配的测试数据,你的程序就会直接跳过第5种,落到第6种或者第7种,白白丢分。
还有一点,这三个条件必须用||连接,不能写成三个if。因为一旦命中最前面的条件,就应该立刻输出5,而不是继续判断后面的if,否则你后面可能又命中第6种导致输出顺序错乱。我用的是else if链,天然保证了顺序。
3.4 主函数里的判断顺序为什么是铁的
main函数从第1种开始,依次检查到第6种,命中就输出并且结束,否则输出7。这个顺序不是随便写的,它直接对应题目“输出编号最小的变换”的要求。
如果你先判断第6种,再判断第1种,一旦遇到“同时满足1和6”的数据,输出就会是6而不是1,直接WA。N=1的情况就是这个规则的完美测试点:原始和目标都是单个字符,旋转后还是它自己,1、2、3、6同时成立,必须输出1。所以判断顺序就是铁律,千万别调整。
4. 实测最容易踩的四个坑:翻车记录与排查方案
这章我把自己实际做题时踩过、以及在讨论区看到别人踩过的坑整理出来,每一个都是真实导致WA的原因。
4.1 旋转方向的认定不一致
USACO原题明确写了clockwise,也就是顺时针。但不少中文题解或者教学视频在画示意图时画的是逆时针,导致你对坐标公式的理解和题目要求直接岔开。
我建议写完后一定用一个非对称的3×3矩阵自测。怎么构造非对称矩阵?让矩阵里的字符呈“L”形分布,比如:
@-- @-- @@@这个形态旋转后特征非常明显,不会出现转完跟没转一样的错觉。你把原始矩阵和目标矩阵都手动画出来,先自己按顺时针推一遍预期结果,再跑程序验证。如果你用一个全是‘@’或全是‘-’的矩阵自测,那么所有变换结果都一样,根本测不出方向问题,这就是很多人自我感觉良好结果提交WA的隐藏原因。
4.2 临时二维数组的初始化残留
如果你用vector<vector >,初始化res时写成vector<vector > res(n, vector (n, ' ')),没问题。但如果你图省事用char res[10][10],那就必须在每次调用变换函数时先清零。
这个坑很阴险。残留值如果恰好和输入相同,会掩盖bug;如果不同,又会导致莫名的WA。而且因为是数组局部变量,栈里的旧数据是不确定的,你本地跑可能碰巧正常,OJ上就随机出错。这也是为什么我在代码里坚持用vector 而不是裸数组的原因之一。
4.3 输入换行符残留
如果你用cin >> s读字符串,不存在这个问题,它会自动跳过空白。但如果你用scanf配合gets,就要注意上一行读N时把换行符残留在缓冲区,gets可能会先读到一个空行,导致所有矩阵整体错位。
解决方法很粗暴:别用gets,统一用cin或者scanf的%s按字符串读取。C++选手直接cin >> a[i]最省心,Python选手用input()也没这问题。这道题输入简单,没必要踩老式C语言的坑。
4.4 调试信息污染输出
不少初学者会在判等之前打印中间矩阵,用来排查问题。这本身是好习惯,但如果你用cout打印,调试信息和最终答案混在一起,提交上去必WA。
我的习惯是调试输出一律走cerr,比如:
void printM(const vector<string>& s) { for (int i = 0; i < n; ++i) cerr << s[i] << '\n'; }cerr的内容不会进入OJ的答案输出,本地终端又能正常查看,两全其美。
5. 这题的训练价值不止AC:延伸思考与拓展方向
如果AC完就翻篇,我觉得有点暴殄天物。P1205虽然是一道“入门题里的水题”,但它的结构其实很适合做几个方向的延伸思考,对后续刷题帮助很大。
5.1 把变换函数抽象成“函数族”
你有没有发现,这题的7种情况本质上是同一件事:把一个矩阵通过某种函数变换成另一个矩阵,然后判断是否相等。你可以把所有变换函数放进一个数组,用函数指针或者std::function循环调用,代码会短很多,扩展性也更好。
vector<vector<string> (*)(const vector<string>&)> transforms = { rotate90, rotate180, rotate270, reflect };这样第5种就可以写成循环,枚举transforms里的三种旋转,套在reflect结果上。这种“变换函数族”的思路,在以后遇到更复杂的矩阵操作题时非常有用。你可以只维护一份函数列表,而不用在main函数里堆一大串else if。
5.2 旋转公式和图像处理的联系
旋转90度、水平镜像这些操作,在图像处理、计算机图形学里非常常见。N=10的时候你可以随便暴力拷贝矩阵,但如果处理百万像素级的图像,就必须用坐标映射直接计算目标像素位置,避免反复拷贝大块内存。
这道题的坐标公式正好是图像旋转的雏形。你在这里把(i,j) -> (j, N-1-i)推导清楚了,以后看图形学里的仿射变换、旋转矩阵,接受起来会快很多。
5.3 如果N变到1000甚至更大呢
把N从10扩到1000,上面这份代码的复杂度仍然是O(N²),每种变换扫一次矩阵,7种变换加起来也就几百万次操作,现代CPU几毫秒就跑完了。所以这道题本质上不卡性能。
但如果N到10^5,方阵就没法用二维数组存了。这时候需要换思路,比如只用坐标集合表示图形里的特殊点,做哈希匹配;或者用稀疏矩阵的存储方式。这类“压缩表示”的技巧在USACO后面的章节里会反复出现,现在先有个印象,之后遇到不会懵。
5.4 再加一个小技巧:用样例验证前先手推
我实际调试这题时最有效的习惯是:先手推一遍样例的中间矩阵,再跑程序对照。很多WA不是你代码逻辑写错,而是你脑子里的预期结果本身就是错的。先手动把旋转后的矩阵画出来,等于给程序设了一个“正确基线”,程序输出和你预期不一致时,你立刻知道是哪个环节出了问题。
总的来说,P1205这道“方块转换 Transformations”题,难度确实不大,但它把USACO入门阶段最需要的几项基本功凑齐了:坐标系理解、变换抽象、顺序判断、输入处理。我更愿意把它看成一个“矩阵变换的练功房”,而不是一道简简单单的水题。
我自己在这道题上的经历比较曲折,第一次把水平镜像理解成上下翻转,样例直接不对;改过来之后又漏了第5种里的180度和270度,WA了一发;最后加上组合角度的完整枚举才终于AC。如果你也在某个测试点卡住,建议按这个顺序排查:坐标系方向、镜像轴方向、第5种三角度是否枚举完整、输出顺序是不是从1逐个判断。把这四关都过了,这题想错都难。后面刷题遇到任何矩阵变换类问题,我建议你也先按“公式推导、临时矩阵、顺序判断”这三板斧来,基本能避开大多数隐蔽的坑。