
东华OJ刷到基础题第77题螺旋方阵时我本来以为几分钟就能收工结果第一版代码WA了第二版才顺利通过。后来回头看这一题其实特别有代表性代码量不大但方向控制、二维数组下标、边界处理、输出格式这些C新手最容易糊的点它全占了。网上能AC的代码很多但不少人都只是“背下来能过”真让自己从零推一遍很容易在细节上翻车。这篇就把螺旋方阵给彻底讲透。我会先拆题再给两套完全不同思路的C写法一套是逐层剥洋葱一套是方向数组模拟然后专门列一下我在OJ提交时踩过的坑和排查过程最后说说怎么从这道基础题延伸出去练更多矩阵类题目。适合刚开始刷OJ、正在学二维数组和循环控制的朋友也适合那些早已AC但想弄明白“为什么这么写”的人。1. 拆题螺旋方阵到底在考什么1.1 先把题面拉齐你要输出的是一个n×n的顺时针矩阵东华OJ基础题77的题目描述不同的版本可能措辞稍有差异但核心场景很一致输入一个正整数n生成一个n行n列的方阵。从左上角开始先往右填1、2、3……等填到边界就转向下继续填然后向左、向上一圈一圈往内层绕直到把n×n个数全部填完最后按行输出整个方阵。拿n3举例输出长这样1 2 3 8 9 4 7 6 5如果你再手推一下n4应该是1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7很多人一看就说“哦这不就是二维数组填数嘛”。对它表面上确实只是给二维数组按顺序赋值但“按顺序”这三个字背后藏着几个很基础又很容易出错的点。东华OJ把它放在基础题序列里就是因为这道题能同时考察你对循环边界、方向切换和数组下标三个基本功的掌握程度。1.2 这三个考点才是真正的核心第一个考点是二维数组的基本使用。你要定义一个足够大的二维数组比如int a[30][30]或者用vectorvectorint然后通过a[i][j]来访问每个格子。这个大家都会但细节上仍然有坑比如数组内部定义的局部数组如果没有初始化里面存的可能是随机值后面用这些值做判断就会莫名其妙出错。第二个考点是状态方向。螺旋填充可以理解为一个小人站在方阵里手里拿着计数器从1开始数每走一格就填一个数。一开始它面朝右走撞到边界或者走过已经填好的格子就右转90度继续走。这个“方向状态”怎么用代码表达是整道题的分水岭。第三个考点是边界控制。什么时候该转弯转弯之后下一步落在哪个坐标循环什么时候结束很多人的代码在n3时能跑通一换成n5就乱了多半就是边界写错。尤其当n是奇数时最中间会剩下一个单格很多解法在这里会重复填或者漏填。1.3 动手之前先把两件事定下来我在带新手做这题时会让他们先不写代码口头回答两个问题第一填数的方向顺序是什么顺时针方向下永远是“右、下、左、上”循环第二什么时候停填满n×n个格子就停。这两个问题想明白后所有代码都是在为这两条规则服务。如果方向固定是顺时针那这个秩序就不会变。你不需要担心某个格子要不要填只要按规则走下去每一步填的格子一定是唯一确定的。所以接下来两套思路本质上都是在回答同一个问题如何让程序知道“下一步该往哪个方向走”以及“下一步走到哪个格子”。2. 方案一按圈填充法也是最容易手推的思路2.1 一圈一圈往里“剥洋葱”螺旋方阵可以理解成一圈一圈的矩形边框嵌套组成。最外层是第一圈往里缩一圈是第二圈一直缩到中心。如果n是偶数最后会没有中心如果n是奇数最后一圈就退化成单独一个格子。这种“按圈填充”的思路代码里最直观的做法是定义两个坐标start表示当前圈的左上角行号和列号end表示当前圈的右下角行号和列号。每处理完一整圈start加1、end减1也就是把矩阵往里收缩一圈。比如n5最开始start0、end4填最外面一圈然后start1、end3填里面一圈最后start2、end2只剩中心一个格子。这种方式就像剥洋葱一层一层往里走所以我也叫它剥洋葱写法。它最大的优点是好理解你可以在草稿纸上先画出方阵然后用笔模拟每一圈的填充路径再对照着写代码。2.2 四条边怎么填不会重也不会漏每一圈可以拆成四条边上边、右边、下边、左边。最容易写错的地方就在角的处理上。比如最外一圈上边如果从start到end都填那右上角已经被填了右边这一列再填的时候行的起点就必须是start1从上边结束位置的下一格开始才能避免重复填右上角。同理右边这一列结束时会填到右下角所以下边这一行从右往左填的时候列的起点要用end-1跳过右下角下边填完会填左下角所以左边这一列从下往上填的时候行的起点要用end-1行的终点是start1这样既能绕开左下角也不会再把左上角重复填一遍。这个“每条边的起点要让开上一段的终点”的小细节是整个按圈写法的灵魂。如果不做这个处理直接让四条边都从start到end第一圈就会把四个角重复填好几遍后面的数字顺序全是乱的。我第一次WA就是因为这里偷懒了。2.3 剥洋葱写法的完整代码#include iostream using namespace std; int a[30][30]; int main() { int n; cin n; int num 1; for (int start 0; start (n 1) / 2; start) { int end n - start - 1; // 上边从左到右包含左上角和右上角 for (int j start; j end; j) { a[start][j] num; } // 右边从上到下起点避开右上角包含右下角 for (int i start 1; i end; i) { a[i][end] num; } // 下边从右到左起点避开右下角包含左下角 for (int j end - 1; j start; j--) { a[end][j] num; } // 左边从下到上避开左下角和左上角 for (int i end - 1; i start; i--) { a[i][start] num; } } for (int i 0; i n; i) { for (int j 0; j n; j) { if (j ! 0) { cout ; } cout a[i][j]; } cout \n; } return 0; }这段代码里最外层循环的次数是(n 1) / 2。n3时是2次第一圈处理外围第二圈处理中心n4时也是2次两圈刚好处理完。内层四条边的循环范围就是上面讲的“上含两端、右含下端、下含左端、左不含两端”。如果你把n5的情况在纸上推一遍会发现中心格子a[2][2]是由“上边”那一段循环单独填掉的因为当start2、end2时后面三段的循环起点都比终点大根本不会执行。这个天然行为就让奇数中心不必单独写if判断代码反而更简洁。2.4 剥洋葱写法有哪些需要注意的地方按圈写法最容易出问题的场景是n比较小的时候。n1时最外圈循环只执行一次start0、end0上边给a[0][0]填1其余三段循环条件不成立输出正确。n2时上边填a[0][0]1、a[0][1]2右边从i1到end1填a[1][1]3下边从end-10到start0填a[1][0]4左边不会执行。也没问题。如果你要改成逆时针或者改成从右下角开始填这种按圈思路就得重新推理边的起止顺序通用性相对差一些。所以在OJ练习里我更建议你理解它但日常更推荐用下面这一种写法。3. 方案二方向数组模拟法工程上更稳的选择3.1 把“方向”也变成数据按圈写法的核心难点在于每条边的起止范围都要人肉算一遍容易错。方向数组模拟法则换了一个思路不让程序“知道自己在填第几圈”而是让程序每一格都问自己一个问题——下一步能不能继续往前走如果想要表达“先向右走走不动就转向下再走不动就转向左转完继续”最自然的代码就是方向数组。比如定义两个数组dx和dy分别表示横向和纵向的偏移量int dx[4] {0, 1, 0, -1}; int dy[4] {1, 0, -1, 0};这里的下标0、1、2、3分别代表右、下、左、上四个方向。当前位置是(x, y)当前方向是d那么下一步的位置就是int nx x dx[d]; int ny y dy[d];方向数组把“坐标加减”变成了查表操作代码非常统一。你不再需要分别写“向右时y加1、向下时x加1”这样的分支只要查表就行出错的概率低很多。3.2 什么时候转向撞墙或者踩到老格子方向数组写起来不难关键是怎么判断该转向。原则只有一条下一步的位置不在方阵范围内或者这个位置已经填过数了就右转90度。“不在方阵范围内”很好理解就是nx小于0、nx大于等于n、ny小于0、ny大于等于n这四种情况。“已经填过数”的判断依赖于一个初始为0的二维数组。我们填进去的数都是正整数所以只要发现下一个格子a[nx][ny]不等于0说明这个地方早就被填过了这条路走不通必须转向。这里有个前提数组必须是干净的也就是初始值都是0。全局数组默认会清零但如果数组定义在main函数内部一定要手动初始化写成int a[30][30] {};。很多本地跑得好好的代码一提交到OJ就出问题就是因为数组里残留了上一次运行留下的脏数据。3.3 方向数组写法的完整AC代码#include iostream using namespace std; int a[30][30]; int dx[4] {0, 1, 0, -1}; int dy[4] {1, 0, -1, 0}; int main() { int n; cin n; int x 0, y 0; // 当前位置从左上角出发 int d 0; // 当前方向0右 1下 2左 3上 for (int num 1; num n * n; num) { a[x][y] num; // 在当前格子填数 // 先按当前方向往前走一步看看这个位置能不能落 int nx x dx[d]; int ny y dy[d]; // 走不了就顺时针转向 if (nx 0 || nx n || ny 0 || ny n || a[nx][ny] ! 0) { d (d 1) % 4; nx x dx[d]; ny y dy[d]; } // 更新坐标 x nx; y ny; } for (int i 0; i n; i) { for (int j 0; j n; j) { if (j ! 0) { cout ; } cout a[i][j]; } cout \n; } return 0; }循环从num1开始一直执行到numn×n每轮先填当前格再尝试走向下一格。如果当前位置的下一个格子越界或已被填过说明该转向了通过(d 1) % 4把方向从右切到下从下切到左从左切到上从上切回右形成循环。这种写法的好处是不管你当前处在矩阵的哪个角落你都不用去记“这已经是第几圈、圈的边界是什么”程序每次只需要判断下一格能不能走。把“撞墙”和“重复”两种情况统一处理逻辑非常干净。3.4 为什么我更推荐方向数组作为主力解法举一个很实际的例子如果题目改成从右上角出发按顺时针填充按圈写法需要重新推导四条边的起止范围大改一遍方向数组写法只需要把起点改成x 0, y n - 1再把方向数组的顺序调整一下其他部分完全不用动。如果改成逆时针也只需要把方向数组改成上、右、下、左的顺序。这种可维护性在平时练习和笔试手写代码时都很占便宜。另外方向数组不只是螺旋矩阵能用。日后遇到迷宫类DFS、BFS题目时你经常会用到类似的dx/dy数组来表示上下左右四个方向。提前在螺旋方阵这种基础题里把方向数组用熟后面学图遍历会轻松很多。我见过不少人觉得方向数组每次都要“空想走一步再判断回头”没有按圈写法直观。这个担心正常但只要在纸上把n3的流程走一遍就会很快适应。毕竟在代码里“试探–判断–决定”本来就是很常见的模式方向数组把这个模式固定了下来。4. 提交OJ前必查从WA到AC的常见坑4.1 输出格式空格和换行是PE重灾区OJ题和普通练习最大的区别是它比对的是标准答案不是人眼。很多人本地输出看上去和样例一模一样一提交却是Presentation Error问题基本都出在空格和换行上。题目如果要求数字之间用空格隔开那你最好不要在行尾多打一个空格也不要每行前面多余几个空格。我习惯用这样的输出写法for (int j 0; j n; j) { if (j ! 0) cout ; cout a[i][j]; } cout \n;这段代码里第一个数字前面不打空格从第二个数字开始每个数字前补一个空格。这样能保证行尾干干净净不会出现末尾空格干扰判题的情况。换行的话用\n和endl在OJ结果上没有区别但\n更快刷题时更推荐。4.2 最容易踩的数组初始化坑方向数组方案依赖a[nx][ny] ! 0来判断格子是否被填过。如果数组没有清零那么里面存的可能是随机值比如某个没有访问过的格子已经是个非零垃圾值程序走到那里就以为走不通提前转向最后矩阵形状就错了。全局变量数组int a[30][30];是会默认清零的这没问题。可一旦你把数组写进main函数内部比如int a[30][30];放在main里它就不会清零了。这时候务必补上初始化int a[30][30] {};。如果题目是多组输入每组数据之间还需要用memset(a, 0, sizeof(a));重新清理一次否则上一组数据残留的非零数字会影响下一组判断。4.3 本地对但OJ错排查顺序从这四步走如果你遇到本地运行结果很正常、提交却出问题的情况我建议按下面的顺序排查别一上来就怀疑是编译器差异。第一步检查数组初始化尤其是局部数组没有清零这种低级但高频的问题。第二步检查数组边界看看题目数据范围n最大是多少数组有没有开够一般开a[30][30]或a[105][105]。第三步检查输出格式行尾空格和换行最隐蔽。第四步检查是否有额外的提示输出比如cout打印了“答案是”之类的内容这也会被判错。为了帮你快速对照我把典型的WA原因整理成了一个速查表现象可能原因解决方法程序不输出或超时死循环方向一直没转检查转向条件是否覆盖了越界和已填格子输出矩阵中出现0循环少填了格子检查循环结束条件是否写成了num n*n数字顺序乱掉某个格子被重复填充检查每条边或方向数组转向逻辑本地AC一提交WA局部数组未初始化或格式问题使用 {}初始化检查行尾空格输出结果错位数组开小了按题目上限增大数组容量4.4 多准备几组边界测试别只盯着样例东华OJ这类平台的样例通常只给一两个常规输入比如n3。如果你只拿n3测试很容易漏掉边界情况。我个人的习惯是提交前至少要本地跑n1、n2、n4、n5这四组数据。n1时输出就是一个1大多数代码不会有问题但能帮你发现循环条件是否会把数组下标算到-1。n2时只有2×2四个格子最外圈也是最后一圈特别容易测出“下边”和“左边”重复填充的问题。n5时矩阵中心独立存在能测出奇数n时中心格是否被正确填入。这几组都跑对了再去提交一次AC的概率会高很多。4.5 调试小技巧打印每一步的坐标和方向如果程序逻辑有问题光看最终输出很难定位。我调试这类题时会在填数的循环里临时加一行打印看看每个数字填到了哪个坐标cout num num x x y y d d endl;这样能直接观察填充轨迹。比如你发现某个数字填到了预期之外的格子或者方向d没有在应该转弯的位置变化那问题就很明确了。调试完记得把打印注释掉不然OJ会判你有多余输出。用VS Code调试C时也可以直接在循环里打上断点监视x、y、d这几个变量的变化效果一样还不用反复改代码。5. 从螺旋方阵说开去一道基础题能带来的C训练价值5.1 螺旋遍历是很多矩阵题目的隐藏起点螺旋方阵不是东华OJ独有的题目LeetCode上同样有类似的原型比如54题“螺旋矩阵”和59题“螺旋矩阵II”。两者的区别是LeetCode版本不一定要求你输入n输出方阵而是给你一个现成的矩阵让你按螺旋顺序遍历思路和这题完全相通。如果你把方向数组那套代码理解透了以后去写旋转矩阵、蛇形矩阵、矩阵按层遍历就会发现它们的核心逻辑都是“方向控制加边界判断”。这些东西在同一天的OJ练习里可能显得抽象但放到后续算法学习中就是你理解二维数组遍历和状态切换的一块重要跳板。5.2 建议的进阶练习顺序AC这道题后不要急着把代码扔一边可以做几个小小的变体练习来检验自己是不是真的掌握了。第一个变体是改成从左上角开始逆时针填充你只需要调整方向数组的顺序比如按右、上、左、下还是按上、右、下、左自己动手试一次能帮助理解方向数组的排列和实际行走方向之间的对应关系。第二个变体是改成输入两个数m和n输出一个m行n列的螺旋矩阵。这时候方阵变成矩形要求你对行数和列数分别做边界判断比正方形多一道坑。第三个变体是尝试只用一重循环不用方向数组写出另一种模拟解法。这样对比之后你对几种方案的优劣会有更直观的体会。5.3 我个人的实操体会我在实际练习中每次做这种二维数组模拟题都会先干一件事在代码注释里写下三行文字当前坐标、当前方向、结束条件。别小看这个习惯很多写着写着就乱的人都是因为心里只有一个模糊的画面没有把状态拆出来。把这三行状态写清楚代码就有了骨架后面填肉不会偏。另外不管你现在用的是Dev C、VS Code还是在线OJ编辑器都要在本地留一套能直接跑起来的C环境。VS Code配好C/C插件后调试这种二维数组题非常顺手打断点加监视能直接看到数组内容比打印调试直观得多。环境准备好了刷题效率会明显不一样。回到螺旋方阵本身我不建议只背代码。你可以今天用逐层剥洋葱的思路写一遍明天再用方向数组的思路默写一遍后天再尝试逆时针改法每一次都在逼自己理解得更深入一点。等你能闭着眼在白纸上画出n5的螺旋填充路径再把代码流畅写出来这道题才算真正吃透了。那种“二维数组也不过如此”的感觉就是这么一格一格填出来的。