做洛谷的题,P5731几乎是每个刷题人都绕不开的一个名字。蛇形矩阵,n最大只有9,看起来人畜无害,但第一次动手写的时候,方向一多、边界一乱,代码就跟着失控。我最早是在“数组与循环”那个专题里碰到它的,当时刚学会二维数组,觉得这不就是把数字一圈圈填进方阵里嘛,结果真坐下来写才发现,填到后半程,上下左右四个边界的管理特别容易出幺蛾子。这篇就基于我自己反复改写的经历,把P5731的几种写法和背后的思路完整梳理一遍,也把那些题单里不会明说的细节放进来,希望对正在刷这道题的人有点帮助。
1. 初见P5731:先弄清楚这道题到底要你做什么
1.1 题面到底说了什么
P5731要求输入一个正整数n,输出一个n乘n的蛇形方阵。数字从1开始,按顺时针方向一圈一圈往内填充,从左上角起步。比如n=4的时候,输出长这样:
code复制 1 2 3 4
12 13 14 5
11 16 15 6
10 9 8 7
这个例子很关键,能顺出来一层之后,整个矩阵的规律就清楚了:先往右走到底,再往下走到底,再往左走到底,再往上走到底,然后往里缩一圈,重复这个过程。所谓的“蛇形”,其实就是一条从外圈到内圈、不停顺时针转向的路径。
很多人第一次看到这个题,脑子里第一个反应是“模拟”。这个方向是对的,因为n很小,直接模拟每一步的移动完全可行。但这道题真正想训练的不是模拟本身,而是“当方向改变时,边界条件如何被准确追踪”的能力。正是这一点,让一些人在简单题目上意外翻车。
1.2 为什么它是很多题单里的分水岭
洛谷的入门题单里,P5731一般被放在“数组”这一节的中间偏后位置。前面的题目多半是简单的数组读写、排序、枚举,到P5731这里突然开始要求“在同一次遍历中同时管理多个边界条件”。它不考任何高级算法,但非常考代码的组织能力。
我见过不少同学,在这道题之前写得顺风顺水,到这里就开始卡壳。卡壳的原因通常不是不知道思路,而是不知道代码怎么写才不容易乱。这就是为什么我说它是分水岭——跨过去,后面进入DFS、BFS、动态规划这些更抽象的内容时,你会对“状态”和“边界”更敏感;跨不过去,后面每一道带方向枚举的题都会让你难受。
所以,认真对待P5731,不是因为它本身难,而是因为它帮你在基础阶段建立“模拟路径”的直觉。这种直觉一旦有了,后面刷别的题会省很多时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方向数组四个循环:为什么“贪吃蛇”思路最省脑子
2.1 核心思路:把移动方式抽象成方向向量
我见过很多人一开始直接尝试写四个独立的循环,分别处理“向右填”“向下填”“向左填”“向上填”,然后在一个外层while里轮流执行。这个写法能过,但代码偏长,而且很容易在某个边界上多填一个或少填一个格子。
我自己更推荐“方向数组”做法。把填数的过程当成一个“小蛇”在矩阵里爬,小蛇有一个当前方向,向右就是纵坐标加1,向下就是横坐标加1,向左就是纵坐标减1,向上就是横坐标减1。每到一个新格子,先看“如果继续沿着当前方向走,下一个格子是不是还合法”,如果不合法,就转向,然后走到转向后的格子里。
这种做法最大的好处是逻辑集中。你不需要关心“现在是第几圈”“还剩几行几列”,只需要维护四个东西:当前横坐标x、当前纵坐标y、当前方向dir、以及已经填过的格子标记。判断“下一步能不能走”也统一成一句:下一个格子是否越界,或者是否已经被填过。
2.2 参考代码:方向数组加循环填数
给出一版我认为最小且清晰的C++写法:
cpp复制#include <cstdio>
int a[10][10];
int main() {
int n;
scanf("%d", &n);
int dx[] = {0, 1, 0, -1};
int dy[] = {1, 0, -1, 0};
int x = 0, y = 0, dir = 0;
for (int i = 1; i <= n * n; i++) {
a[x][y] = i;
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++) {
printf("%3d", a[i][j]);
}
printf("\n");
}
return 0;
}
这里dx[0]=0、dy[0]=1代表向右;dx[1]=1、dy[1]=0代表向下;dx[2]=0、dy[2]=-1代表向左;dx[3]=-1、dy[3]=0代表向上。方向顺序是固定的顺时针顺序,所以转向时只要dir = (dir + 1) % 4,就能依次在右、下、左、上之间循环。
下标从0开始,x和y直接初始化为0,对应左上角。循环次数是n*n,因为总共要填n的平方个数。每次填完当前格子之后,就用当前方向去探测下一个位置。如果探测的位置越界,或者已经不再是0(也就是已经被填过数字),说明这条路径到头了,必须转向。
2.3 为什么“先探路再转向”比“撞墙再转向”更好
有的写法是:先按当前方向往前走一格,如果发现下一步非法,再回退并转向。这种写法也能工作,但需要多做一次“回退”操作,代码里容易留下多余的移动指令。而“先探路再转向再移动”的思路,每轮循环只在最后统一更新x和y,逻辑上一气呵成,不容易出现“多走一步”或“少走一步”的问题。
还有一个细节值得注意:转向后,新的nx和ny一定是合法的。为什么?因为当你在某一圈走到拐点时,转弯后的方向所指向的格子,要么还没填过,要么就是下一圈的第一个格子。在n为正整数的前提下,这个性质始终成立。理解这一点,就不会去写多余的合法性检查。
从变量管理的角度看,这种写法只需要关心两个坐标和三个常量(dx、dy、dir),整个矩阵的填充被抽象成“带着方向走路”。如果你玩过贪吃蛇,这个思路几乎不用额外解释——每走一步前先看看前方有没有障碍,有障碍就转个弯,然后继续走。
3. 我当初的翻车经历:非要用DFS写蛇形矩阵
3.1 思路没毛病,代码却越写越乱
我得实话实说,第一次做P5731的时候,我没有用方向数组。当时我刚接触深搜不久,脑子里全是“递归”“回溯”这些词,看什么题都想套DFS。于是我就想:能不能写一个递归函数,每次填一个格子,然后尝试朝四个方向继续填?
想法听上去挺合理,但写起来问题很快暴露。递归解决这类问题需要维护“当前路径的状态”,而蛇形矩阵要求方向是单调轮转的,不是自由探索。我必须额外记录“上一次朝哪个方向走的”“已经转弯几次了”“当前在第几层圈”,这些状态全堆在一起之后,代码很快就变得很难读。
我印象很深的一个Bug是:在没有设置“已经访问过”标记的情况下,递归在第二圈往左走的时候,撞上了第一圈已经填过的数字,导致下层填数提前终止,矩阵右下角空出一块。当时我在递归里加回溯标记,结果把已经填过的数字又重置成了0,等于自己把自己走过的路擦掉了。
3.2 完整的翻车排查过程
为了给同样思路卡住的人一个参考,我把当时的排查链路还原出来:
- 发现输出矩阵左下角有一片0,说明有格子没有被填到。
- 我以为是循环次数不够,于是把终止条件从“填满n*n个数”改成“位置越界才停止”,结果递归在某些分支里死循环,程序直接运行超时。
- 随后我在填数的递归函数里加了printf,逐个打印每一步走到哪个坐标、填入什么数字,发现递归在“往左走”那一段提前进入了终态。
- 打印显示,当递归尝试往左走时,它检查前一个格子是否“可走”,发现前一个格子在上一圈已经被填过,于是判定无路可走,回溯。
- 这个现象让我意识到:DFS的“随便选一个方向”和蛇形矩阵的“必须按固定顺序转向”本质上是不匹配的。自由搜索要的是全空间探索,而这道题要求的是沿着固定路径走一遍。
这其实是两种思路的根本差异。P5731不是一道搜索题,它不需要尝试多种可能路径,而是只有一条路径。DFS的“回溯”“试探”在这里不但没有帮助,反而制造了大量不必要的状态管理。
3.3 把这个教训沉淀下来
这次翻车让我明白一个道理:选算法和数据结构,不是越高级越好,而是越匹配越好。方向数组用循环就可以解决的问题,非要用递归去做,等于用坦克去拧螺丝,不是不行,但难受。
后来我写题的时候,会先问自己一句:“这道题的最简模型是什么?”P5731的最简模型就是“一条有方向的线在方阵里走”,所以用循环加方向数组最自然。只有遇到真正的搜索问题,比如迷宫寻路、岛屿数量、八皇后,才值得上DFS、BFS。这个判断习惯,比记住这一道题本身更有价值。
4. 从“按层收缩”到“一维模拟”:其他能过的写法怎么选
4.1 方向数组之外的经典写法:按层收缩
除了方向数组,还有一种也很常见的写法:不用方向概念,直接用四个变量top、bottom、left、right围住当前还没填的矩形区域,每次填完一条边,就收缩对应的边界。伪代码逻辑如下:
cpp复制int top = 0, bottom = n - 1, left = 0, right = n - 1;
int cnt = 1;
while (cnt <= n * n) {
for (int j = left; j <= right; j++) a[top][j] = cnt++;
top++;
for (int i = top; i <= bottom; i++) a[i][right] = cnt++;
right--;
for (int j = right; j >= left; j--) a[bottom][j] = cnt++;
bottom--;
for (int i = bottom; i >= top; i--) a[i][left] = cnt++;
left++;
}
这种写法更贴近“一圈一圈填”的直觉,很好理解。写的时候只要在每一条边填完后记得把对应边界往里缩一格,基本就不会乱。
4.2 按层收缩的真正难点:什么时候该补一个if
上面的代码里有一个隐藏条件:当n是奇数的时候,最后一圈只剩一个格子;当n是偶数的时候,最后会收缩到top > bottom 或 left > right。如果不做任何检查,遇到奇数n的时候,最后那个单格会被漏掉,或者在某些写法里被重复填一遍。
很多人在这个写法上交过WA,原因不是思路错,而是“在循环内部没有检查区间是否仍然合法”。解决方式是在后面两条边的循环前加if判断:
cpp复制if (top <= bottom) {
for (int j = right; j >= left; j--) a[bottom][j] = cnt++;
}
bottom--;
if (left <= right) {
for (int i = bottom; i >= top; i--) a[i][left] = cnt++;
}
left++;
这样就保证了在矩阵退化到只剩一行或一列时,不会出现越界或重复填充。这里建议自己拿n=3和n=4分别推演一遍,推完你就知道这两个if存在的意义了。
4.3 两种主流写法对比
我把方向数组法和按层收缩法放在一起,做了一个对比:
| 对比维度 | 方向数组法 | 按层收缩法 |
|---|---|---|
| 核心状态 | 当前坐标 + 方向编号 | 四个边界变量 |
| 代码量 | 更短,逻辑集中 | 稍长,每条边一个循环 |
| 理解难度 | 初学可能不适应方向数组 | 更贴近“一圈圈”的直觉 |
| 出错点 | 越界判断和转向条件 | 边界收缩时漏判退化情况 |
| 适用题目 | 迷宫、路径模拟、螺旋类题 | 螺旋矩阵、按层处理类题 |
如果是从零刷题,我建议方向数组法优先掌握。它更通用,遇到“在矩阵里按方向移动”的题时可以直接复用思路。按层收缩法也值得会写,因为某些题目明确要求按层处理,比如输入二维数组的某类区间查询预处理,用收缩思路反而更自然。
还有一种一维模拟的写法,就是把二维坐标映射成线性下标,但本质上还是方向数组那一套,而且可读性更差,我不推荐在入门阶段使用。
5. 格式与边界:这道题上白给次数最多的两个地方
5.1 输出格式:%3d是你最该信任的东西
洛谷对P5731的输出格式要求是:每行输出n个数,每个数占3个字符宽度,右对齐。这个要求对应C语言的printf("%3d", a[i][j])。
最容易犯的错误有两个:
第一个,自己在数字之间拼填空格,比如printf("%d ", a[i][j])。这样在个位数和两位数混在一起的时候,矩阵列对不齐,不符合“每个数占3位”的要求。虽然洛谷在部分题目上允许行末多空格,但占位宽度不满足要求会导致Presentation Error。
第二个,把%3d写成%d。n比较小的时候,肉眼看起来好像也差不多,但一旦n=9,矩阵里有两位数、三位数交错,输出列宽就会乱。格式错误在洛谷上经常被判为PE而不是WA,心态会受影响。
如果你用C++的cout输出,可以用setw(3)来控制宽度,记得包含<iomanip>头文件。不过个人建议这道题直接用printf,简洁明了。
5.2 边界判断:到底是“越界”还是“已走过”
方向数组法里,判断能不能走的条件需要同时检查两点:
- 新位置是否在矩阵范围内,也就是
nx >= 0 && nx < n && ny >= 0 && ny < n; - 新位置是否还没有被填过,即
a[nx][ny] == 0。
这两个条件缺一不可。有些初学者只检查越界,不检查“已走过”,结果在填完第一圈转向之后,小蛇直接穿进了已经填好的区域,把之前填的数字覆盖掉。还有些人只判断“已走过”,但忘记判断越界,当n为偶数时,走到最后一圈的出口位置,x或y可能变成负数,导致数组越界读取。
一个调试技巧:在写判断条件时,不要急着写位运算或简写,先用一个直观的条件组合。如果当时我也先老老实实写出完整判断,后面调试能省半小时。
另外一个容易被忽略的坑是:数组初始值。要把a定义成全局变量,这样默认值全是0。如果你在main函数里定义局部二维数组,一定要初始化成int a[10][10] = {}或者memset(a, 0, sizeof(a))。没有清零的话,a[nx][ny] != 0 的判断会受到随机值影响,行为完全不可控。
5.3 变量命名的坑
建议不要用单个字母的x、y以外再叠加太多含义模糊的变量名。比如有些人喜欢用t来表示“临时变量”,结果一个t在前后两段代码里分别代表“当前填入的数字”和“临时坐标”,调试的时候就非常痛苦。
我自己习惯的命名方式是:
x、y:当前坐标,名副其实;nx、ny:下一个坐标;dir:方向编号;cnt或者直接使用循环变量i:当前填入的数字。
清晰命名配合上面的方向数组法,代码几乎可以不写注释就读得懂。
6. 刷完这道题之后,紧接着值得做的几件事
6.1 同类型题目练手清单
P5731不是孤立题,刷完之后应该趁热打铁做几道类似题,巩固“方向模拟”和“边界处理”的手感:
- 洛谷P5731的邻题P5732,是同一个专题里的同类练习,可以先做;
- 力扣 54 螺旋矩阵,是P5731的“输出版”,矩阵不需要自己填,而是按顺序取元素,重点考察方向切换的准确度;
- 力扣 59 螺旋矩阵 II,是P5731的“力扣版”,和P5731几乎一样,可以用来交叉验证自己的写法;
- 洛谷P1067多项式输出,讲的是更复杂情况下的格式化输出,能帮助你建立“输出细节也是算法题的一部分”的意识。
6.2 把“方向数组”当作工具箱里常备的一件工具
方向数组这个技巧,在你之后刷算法的时候会反复用到。经典的网格DFS、BFS,比如岛屿数量、迷宫最短路径、扫雷游戏,都需要用方向数组来描述“往四个方向走”。所以在P5731这里把方向数组用熟练,等于给后面这些题提前打了个底子。
我建议你做完P5731之后,有一个巩固动作:把方向数组法的代码重新默写一遍,不看参考,不看题解。默写到“边界条件”“转向条件”“填数终止条件”这三处都一次写对,说明你对这道题的理解已经到位了。
根据我个人经验,P5731这个题,方向数组法理解和默写各一遍之后,再遇到任何“按方向在矩阵中移动”的题,你都不会再发怵。它的价值,远不止一道入门题那么浅。
