P5731 蛇形方阵,是我刷题时见过的特别适合练“模拟”的入门题之一。题面很干脆:给一个正整数 n,要求输出一个 n×n 的方阵,数字从 1 开始,像蛇一样从外圈一圈一圈往里面盘。比如 n=4 时,结果是这样:
1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7我第一次看到这道题,觉得不就是填数吗,结果自己写完之后跑样例全对,一交上去却 WA。后来才发现,蛇形方阵考的不是你有多聪明,而是你能不能把四个方向的转向、边界判断和输出格式一次性处理对。这篇文章就把我从题意理解、核心思路、完整代码到调试踩坑的整个过程都拆开讲清楚,适合刚学数组和模拟的新手,也适合想快速复习的同学。
1. 题目理解:从输出反推行走路径
1.1 输入输出到底长什么样
题目只有一个输入:正整数 n。输出是 n 行,每行 n 个数字,数字之间不是用普通空格分隔,而是每个数字固定占 3 个字符宽度、右对齐。这一点特别重要,因为很多人只顾着填数,忘了格式要求,结果输出数组内容完全正确,却因为每行开头多了一个空格或者少了一个空格被判错误。
以 n=3 为例,正确输出应该是:
1 2 3 8 9 4 7 6 5可以看到,个位数前面都补了两个空格,两位数前面补了一个空格,这样所有数字都顶到右边的第 3 列。如果不按这个格式输出,哪怕方阵本身的数字顺序全对,OJ 也会给你一个红红的 WA。
1.2 数字的行走路线:一圈一圈往里绕
很多同学看到“蛇形”两个字,第一时间想到的是那种一行从左到右、下一行从右到左的 S 形走法。但 P5731 里的蛇形方阵并不是那个东西,它实际是“回字形螺旋”:
- 从左上角出发,先向右走到这一行的最右端;
- 然后向下走到最下端;
- 再向左走到最左端;
- 最后向上走回起点下方的位置;
- 然后继续进入里面一圈,重复“右、下、左、上”的过程。
拿 n=4 的矩阵来说,数字 1 到 4 是第一行,5 到 7 是最右边一列,8 到 10 是最下面一行,11 到 12 是最左边一列,这就算走完外圈。接下来从数字 13 开始,进入内圈的 2×2 区域继续绕。整个填数过程,本质上就是一条“碰壁就右转”的贪吃蛇。
1.3 为什么很多人第一次会写错
这道题看着简单,真正写错的人非常多。最常见的错误是把“蛇形”理解成了左上、右上、右下、左下的斜向走法,或者写成了从上到下、再从下到上交替填列。这两种都和题目要求的回形路径不是一回事。
另一个容易错的地方是起点和方向顺序。题目要求最外圈先向右,然后顺时针绕。如果你把方向顺序写成“右、上、左、下”,出来的矩阵就是逆时针的,虽然看起来也是一种“蛇形”,但和样例对不上。
所以我拿到这类题目,第一件事永远是先在草稿纸上把 n=3 或 n=4 的行走路线画出来,标上箭头,再开始写代码。画清楚之后,代码反而简单很多。
2. 核心思路拆解:模拟法为什么是首选
2.1 用“方向数组”模拟当前前进方向
解决蛇形方阵最直接的思想就是模拟:从第一个格子开始,每次把当前数字填进去,然后往当前方向前进一步。如果发现下一步会越界,或者下一步的格子已经被填过了,就把方向顺时针旋转 90°,再走到新的位置。
那么如何用程序表达“方向”?最标准的方法是方向数组。把四个方向分别表示成坐标的增量:
- 向右:行不变,列加 1,对应
(0, 1); - 向下:行加 1,列不变,对应
(1, 0); - 向左:行不变,列减 1,对应
(0, -1); - 向上:行减 1,列不变,对应
(-1, 0)。
写成数组就是:
int dx[4] = {0, 1, 0, -1}; int dy[4] = {1, 0, -1, 0};再用一个变量dir标记当前方向是第几个。当需要转向时,执行dir = (dir + 1) % 4就能循环切换,非常干净。这个技巧在迷宫、扫雷、冰壶推箱子等一大堆题目里都能用,值得单独记下来。
2.2 边界判断:越界或已填都算“撞墙”
方向数组定下来以后,每次计算下一步的位置:
int nx = x + dx[dir]; int ny = y + dy[dir];然后判断这个(nx, ny)还能不能走。有两种情况必须转向:
- 超出 n×n 的方阵范围:也就是
nx < 0 || nx >= n || ny < 0 || ny >= n; - 这个格子已经被填过数字了:因为蛇形方阵要往里绕,内圈的格子之前可能已经赋值,不能重复走。
判断“被填过”有一个很省事的办法。因为矩阵初始值全部为 0,而填入的数字从 1 开始,所以只要检查a[nx][ny] != 0,就知道这个格子是不是已经走过了。注意这个技巧依赖“数字从 1 开始”这个前提,如果题目改成从 0 开始编号,这个方法就要换成初始化成 -1 或使用独立的访问标记数组。
2.3 填数循环的通用骨架
整体流程可以压缩成这样的伪代码:
设置起点为 (0, 0) 初始方向为向右 for cur = 1 to n*n: 把 cur 填到当前格子 计算下一步位置 如果下一步越界或者已经填过: 方向旋转 90 度 重新计算下一步位置 移动到下一步这个循环次数是固定的n*n次,所以不会出现“不知道该什么时候停”的问题。每填一个数,必然走一个格子,循环结束矩阵也就填满了。
我个人的经验是,这种写法最好理解,也不太容易出逻辑错误。你不需要额外计算每一圈的边界,只需要记住“撞到墙就右转”,剩下的交给方向数组。它唯一的缺点是每次都要算一次下一步可不可走,但这在 n 很小的入门题里完全不是问题。
3. 完整代码实现:C++ 和 Python 各写一遍
3.1 C++ 标准写法
下面这段是我比较推荐的 C++ 写法,配合vector动态开二维数组,避免静态数组大小开错的问题:
#include <iostream> #include <vector> #include <iomanip> using namespace std; int main() { int n; cin >> n; vector<vector<int>> a(n, vector<int>(n, 0)); int dx[4] = {0, 1, 0, -1}; int dy[4] = {1, 0, -1, 0}; int dir = 0; int x = 0, y = 0; for (int cur = 1; cur <= n * n; ++cur) { a[x][y] = cur; int nx = x + dx[dir]; int ny = y + dy[dir]; if (nx < 0 || nx >= n || ny < 0 || ny >= n || a[nx][ny] != 0) { dir = (dir + 1) % 4; nx = x + dx[dir]; ny = y + dy[dir]; } x = nx; y = ny; } for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { cout << setw(3) << a[i][j]; } cout << "\n"; } return 0; }有几个细节要解释一下。
第一,setw(3)来自<iomanip>头文件,作用是让后面的输出占 3 个字符宽度,默认右对齐。所以输出1会变成两个空格加一个1,输出12会变成一个空格加12。这正是题目要求的格式。
第二,最后一次循环里,cur已经等于n*n,填完后还会计算下一步的nx, ny。这时算出来的坐标可能是越界的,也可能指向已经填过的格子,但没关系,因为循环马上结束,x和y虽然被更新了,却再也不会被使用。如果你心里不踏实,也可以在填完立即break,比如:
if (cur == n * n) break;第三,判断越界时用了||短路。C++ 从左往右判断,当nx < 0成立时,后面的nx >= n、a[nx][ny]都不会再执行,所以不用担心数组下标访问越界。
3.2 Python 写法:适合快速验证思路
如果你平时用 Python 刷题,可以写得更紧凑:
def solve(): n = int(input()) a = [[0] * n for _ in range(n)] dx = [0, 1, 0, -1] dy = [1, 0, -1, 0] d = 0 x = y = 0 for cur in range(1, n * n + 1): a[x][y] = cur nx = x + dx[d] ny = y + dy[d] if nx < 0 or nx >= n or ny < 0 or ny >= n or a[nx][ny] != 0: d = (d + 1) % 4 nx = x + dx[d] ny = y + dy[d] x, y = nx, ny for row in a: print("".join(f"{v:3d}" for v in row)) if __name__ == "__main__": solve()Python 里的f"{v:3d}"等价于把整数v格式化成占 3 位的右对齐字符串。比如f"{1:3d}"的结果是" 1",前面有两个空格。因此"".join之后,每一行开头都会有一个或两个空格,这正是题目想要的效果。
用 Python 做题的好处是代码量少、逻辑一眼就能看穿。不过需要注意,如果题目给的 n 比较大,Python 的二维列表会比 C++ 稍微慢一些,但 P5731 这种入门题完全不用纠结性能。
3.3 复杂度分析:时间 O(n²),空间 O(n²)
整个算法最核心的工作是填满 n×n 个格子。填每个格子时,方向判断和坐标更新都是常数时间,所以总时间复杂度是 O(n²)。空间上需要存一个 n×n 的矩阵,所以空间复杂度也是 O(n²)。
这个复杂度在 n 很小的时候没有任何压力。哪怕你以后遇到变体题,把 n 提升到几百甚至上千,O(n²) 的模拟通常也能过,只是要注意输出是否会超时。如果 n 超过几千,反复调用输出函数也可能成为瓶颈,那时候再考虑整行字符串拼接或者快写优化。
3.4 输出格式的隐藏细节
我见过不少人因为输出格式 WA 到怀疑人生。题目说“每个数字占 3 个字符宽度、右对齐”,不等于“数字之间加一个空格”。如果你的代码写成:
cout << a[i][j] << " ";那么每个数字之间是普通空格,宽度完全不对,样例输出会变成:
1 2 3 4 12 13 14 5这样一看就和题目要求不一样。正确的思路是把每个数字看成一个宽度为 3 的字符串,所有数字紧挨着输出,靠前导空格补足宽度。C++ 的setw(3)和 Python 的f"{v:3d}"都是这个意思。
4. 新手最常踩的坑与排查技巧
4.1 数组越界:静态数组开太小
有些人图省事,直接写:
int a[10][10];如果题目数据保证 n <= 9,那当然没问题。但万一你拿到的是 n=10 的数据,a[10][10]就已经越界了。我的建议是直接用vector动态申请,按输入 n 的大小开数组。这样既能避免越界,代码也更通用。
还有一个小坑:很多人用一维数组加下标换算来模拟二维矩阵,结果方向变化时下标算错。新手阶段我还是推荐直接用二维数组,逻辑清晰,调试也方便。
4.2 方向数组写反:顺时针还是逆时针
方向数组的顺序决定了螺旋方向。想要顺时针,顺序必须是“右、下、左、上”,对应:
int dx[4] = {0, 1, 0, -1}; int dy[4] = {1, 0, -1, 0};如果你写成了“右、上、左、下”,也就是:
int dx[4] = {0, -1, 0, 1}; int dy[4] = {1, 0, -1, 0};出来的就是逆时针螺旋。虽然看起来也是螺旋,但样例对不上。记住一个小技巧:把方向数组的“转向”理解为顺时针换方向,即右转 90°,因为当前进方向是右时,右转后是下;是下时,右转后是左;是左时,右转后是上。
4.3 判断已填格子:初始值一定要是 0
如果矩阵初始化时用vector<vector<int>> a(n, vector<int>(n)),默认初始化为 0,这时用a[nx][ny] != 0判断是没问题的。但如果你初始化时给所有格子赋了别的值,或者用memset填了-1,这个判断就失效了。
另外要注意,判断“下一步能不能走”的时候,一定要在计算完nx, ny之后判断,不能在填当前格子之前判断。方向数组的核心是“下一步撞墙才转向”,而不是“预判整条路”。
4.4 死循环和停不下来的原因
方向数组模拟法理论上不可能死循环,因为循环次数固定是n * n。但如果你把循环条件写成while (true),并且忘了在里面判断填满退出,就有可能出现越界后仍然继续填的情况。
还有一种情况是,转向后没有重新计算nx, ny,导致原地打转。很多人写转向时只改了dir,忘了重新算下一步坐标。最后的x = nx; y = ny;仍然是转向之前的坐标,于是下一次继续撞墙,循环就乱了。解决办法是,检测到需要转向后,马上重新计算一次:
dir = (dir + 1) % 4; nx = x + dx[dir]; ny = y + dy[dir];这一步非常关键。
4.5 手动调试:把每一步路径打出来
如果写完代码样例都过了,但提交还是错,我建议你把路径打印出来。比如在填数循环里临时加一句输出:
cout << x << " " << y << " " << cur << endl;然后跑 n=4,看看数字 1 到 16 对应的坐标是不是:
(0,0) -> (0,1) -> (0,2) -> (0,3) (1,3) -> (2,3) -> (3,3) (3,2) -> (3,1) -> (3,0) (2,0) -> (1,0) (1,1) -> (1,2) (2,2)如果坐标路线正确,那就是输出格式的问题;如果坐标路线本身乱了,那就检查方向数组和转向逻辑。这个排查方法比盯着屏幕看矩阵更高效。
5. 更稳的写法:上下左右边界收缩法
5.1 四个边界变量的思路
方向数组虽然好懂,但有些人更喜欢另一种经典写法:维护四个边界变量top, bottom, left, right,表示当前还没填的内圈范围。每填完一条边,就把对应的边界往里缩一格。
伪代码如下:
top = 0, bottom = n-1, left = 0, right = n-1 cur = 1 while cur <= n*n: 从 left 到 right 填 top 这一行 top++ 从 top 到 bottom 填 right 这一列 right-- 从 right 到 left 填 bottom 这一行 bottom-- 从 bottom 到 top 填 left 这一列 left++这个写法不需要方向数组,思路直观,但对 n 为奇数或偶数时容易漏填内层。比如 n=1 时只有一格,填完 top 行之后top++,如果继续执行填 right 列,会重复访问已经填过的格子。所以要特别注意每个 for 循环之后是否已经填满 n² 个数字。
5.2 边界收缩法的完整代码
我给出一版带防越界判断的代码:
#include <iostream> #include <vector> #include <iomanip> using namespace std; int main() { int n; cin >> n; vector<vector<int>> a(n, vector<int>(n, 0)); int top = 0, bottom = n - 1; int left = 0, right = n - 1; int cur = 1; while (cur <= n * n) { for (int j = left; j <= right && cur <= n * n; ++j) { a[top][j] = cur++; } top++; for (int i = top; i <= bottom && cur <= n * n; ++i) { a[i][right] = cur++; } right--; for (int j = right; j >= left && cur <= n * n; --j) { a[bottom][j] = cur++; } bottom--; for (int i = bottom; i >= top && cur <= n * n; --i) { a[i][left] = cur++; } left++; } for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { cout << setw(3) << a[i][j]; } cout << "\n"; } return 0; }这里的cur <= n * n条件是我个人比较喜欢的写法,它保证即使只剩一行或一列,也不会重复填充。比如 n=5 时最内层只有一个格子,填完 top 行后top++,此时cur已经变成 26,循环条件cur <= n*n不成立,后面三个 for 循环根本不会执行,安全退出。
5.3 哪种写法更适合你
方向数组模拟法和边界收缩法的本质是一样的:都是“从外圈往内圈填”。区别在于,方向数组法把“方向”显式化,逻辑统一;边界收缩法把“范围”显式化,代码更对称。
我的建议是新手优先掌握方向数组法,因为它更能锻炼坐标变化的思维,而且后面遇到迷宫、搜索类题目时,方向数组会反复出现。边界收缩法可以作为第二种解法理解,两者互相印证,印象更深。
6. 从这道题延伸出去:变体和通用能力
6.1 不要把“蛇形方阵”和“蛇形矩阵”搞混
在很多题目里,“蛇形矩阵”指的是矩阵按行蛇形填充:
1 2 3 6 5 4 7 8 9也就是奇数行从左到右,偶数行从右到左。它和 P5731 的回形螺旋完全不一样。刷题时一定要看清题目给的样例,不要凭名字判断。P5731 的“蛇形方阵”更准确的说法是“螺旋方阵”或“回形方阵”,只是题目名字叫蛇形而已。
6.2 可以尝试的变体
如果觉得这道题太简单,可以尝试改一改:
- 改成逆时针螺旋,起点还是左上角;
- 改成从中心开始往外绕;
- 改成输出每个位置到中心的距离;
- 改成 m 行 n 列的矩形螺旋,而不是正方形;
- 要求不额外开矩阵,直接在输出时计算每个位置是什么数字。
其中最后一种最考验数学功底。每个位置所在的“圈层”可以用:
int layer = min(min(i, j), min(n - 1 - i, n - 1 - j));计算出来,然后根据当前圈层左上角的起始数字和当前边位置推出该格子的值。感兴趣的同学可以自己推一下,写出来会非常有意思,也能加深对螺旋结构的理解。
6.3 这类题真正锻炼的能力
我始终觉得,P5731 这类题考察的不是某个高深算法,而是三件小事:能不能把自然语言描述的规则转化成程序逻辑,能不能用坐标和方向数组管理状态,能不能在边界条件下保持稳定。这三个能力在后续刷动态规划、图论搜索、模拟题时都是底层能力。
很多人看到“模拟题”就觉得没有技术含量,其实恰恰相反。模拟题最容易暴露一个人对细节的控制力。边界判断多一个等号、方向顺序反了、输出宽度不对,任何一个地方出错,整道题都会变成红色 WA。能把一道模拟题做到一遍过,说明你的代码基本功是扎实的。
7. 我的一点实际体会
最后分享一个我常用的答题习惯:拿到任何“填矩阵”的题目,不管样例多简单,我一定会先手动把 n=3 的完整输出写在草稿纸上,然后对照代码逐格模拟前 9 个数字。这个过程很像在脑子里跑一次程序,能提前发现很多边界问题。
遇到输出格式不确定时,我会故意用 n=1 去测试。n=1 的蛇形方阵只有一个数字1,没有转向、没有内圈,但能快速验证越界和输出宽度是否正确。n=2 则是另一个关键用例,它让程序第一次面临“走完外圈后只剩空”的情况,方向数组法在 n=2 时也能正常收尾。把这两个小用例先跑通,再跑 n=4 的样例,基本就不会出大问题。
如果你是用 C++ 刷题,一定记得加#include <iomanip>,setw函数不在默认的头文件里。如果你是用 Python,记得区分print默认的换行和空格,多用一个join能减少不少麻烦。
做这类题,最忌讳的是“看起来差不多就行了”。填数顺序错一格,整个矩阵都崩;输出宽度错一格,整个答案都不认。把这些细节练成肌肉记忆,以后再遇到任何模拟类题目,你都会比别人稳得多。