螺旋矩阵 II 边界控制详解:从循环不变量到方向数组两种解法

📅 发布时间:2026/10/15 7:03:29
螺旋矩阵 II 边界控制详解:从循环不变量到方向数组两种解法
刷 LeetCode 的同学应该都有过这种体验题目看一遍题解好像完全会了关掉编辑器自己写却怎么都不对尤其一碰到边界控制就开始全乱。LC 59. 螺旋矩阵 II 就是这么一道典型的题给你一个正整数 n让你生成一个包含 1 到 n² 所有元素的 n x n 矩阵元素按顺时针螺旋顺序排列。题不难中等难度但在工作上写业务代码的人不一定能一次写好更别说面试时手写。这道题在代码随想录的数组专题里被放在很关键的位置。它不考什么高级数据结构不考二分、不考 DP考的就是你对“循环不变量”的理解。我见过不少刷题群里的朋友在这道题上翻车有人卡了半小时有人 AC 了但代码里塞满了 if 分支自己都讲不清楚每条边是从哪走到哪。这篇文章我会把题目的核心逻辑拆开讲两种可行解法的思路和完整代码再把我亲眼见过的高频错误整理出来。无论你是刚开始刷题的小白还是准备面试需要快速过一轮的老手这篇都能直接拿去用。1. 这道题看似是模拟题真正考的是边界感1.1 题目在说什么一个 3 阶矩阵就把核心逻辑讲清楚了先看输入输出。n 3 时期望输出是1 2 3 8 9 4 7 6 5规则非常直白从左上角出发数字 1 开始先往右走到头再往下走到头再往左走到头再往上走到头然后继续往内层螺旋直到 1 到 n² 全部填满。很多人的第一反应是“模拟”这没错。但问题恰恰出在“模拟”这两个字上——到底怎么模拟用变量 i、j 表示当前坐标然后写四个 for 循环分别处理四条边还是定义方向数组每次走一步碰壁就拐弯两种思路都能 AC但前一种更贴近这道题的考核重点后一种写起来更短。后面我会把两种都讲一遍因为面试时你可能会被问到“还有没有别的写法”能不能切换过来是很加分的。n 3 的矩阵规模小人肉模拟一遍就能找到规律所谓“一圈”其实是由四条边组成的矩形环。1 到 8 在外圈9 单独在中心。把外圈剥掉之后剩下的就是一个更小一号的矩阵继续走同样的流程。1.2 为什么更多人栽在“看懂了题解却写不对代码”我观察到很多人的刷题方式是看一遍题解觉得“这不就是模拟吗”然后合上题解开始写。结果一写就崩最常见的原因不是不会写代码而是没搞清楚循环变量的边界到底怎么定。比如你写第一条边从 (0,0) 往右填for (; j n; j)这条路没毛病。走到头之后j 已经是 n - 1接下来往下走for (; i n; i)也能走通。问题出现在第三条边走到最下面之后你还要往左走可这时你已经把最右下角的格子填过了如果再用j 0往回走就会把已经填过的格子盖掉。题解里往往只是模棱两可地说“按规则模拟”却没说清楚一个关键约定每条边处理到最后一个元素之前就停手把最后一个格子留给下一条边的起点。一旦你理解了这条约定整个代码就顺了。1.3 循环不变量把四条边的遍历规则统一成一个协议“循环不变量”这个词听起来很高大上但落地到这道题就一句话所有循环区间都用左闭右开。什么叫左闭右开就是在循环里只处理区间内从 start 到 end - 1 的元素不处理 end 这个位置的元素。写代码的时候体现为第一条边for (; j n - offset; j)而不是第二条边for (; i n - offset; i)同样不是第三条边for (; j startY; --j)区间是(startY, 当前j]的逆序到 startY 停第四条边for (; i startX; --i)同理。你可以用分蛋糕来类比四个人轮流切蛋糕每个人都不拿最后一块把最后一块统一留给下一个。这样四个人永远不会抢同一块。螺旋矩阵也是这个道理每条边都故意“让”出最后一个格子这个格子正好是下一条边的出发点于是四条边无缝衔接不会重叠也不会漏。2. 转圈填充法把“一圈一圈往里缩”翻译成代码2.1 一次循环做四件事上、右、下、左先把思路固定下来整个矩阵由若干圈组成每圈称为一层。最外层从左上角 (0,0) 开始处理完之后下一层从 (1,1) 开始再下一层从 (2,2) 开始。在每一层里严格按顺序做四件事上边从左往右填右边从上往下填下边从右往左填左边从下往上填。以 n 4 为例第一圈会依次填上边1 2 3 右边4 5 6 下边7 8 9 左边10 11 12第二圈起点变成 (1,1)只剩一个 2x2 的内层上边13 右边14 下边15 左边16核心代码的骨架是while (loop--) { int i startX; int j startY; for (; j n - offset; j) res[i][j] count; for (; i n - offset; i) res[i][j] count; for (; j startY; --j) res[i][j] count; for (; i startX; --i) res[i][j] count; startX; startY; offset; }这四行 for 是整道题的灵魂。你会发现变量 i 和 j 是“接力”的上边走完时 j 停在右边界的前一个位置第二条边直接拿这个 j 作为起始列右边走完时 i 停在下边界的前一个位置第三条边直接拿这个 i 作为起始行。四个循环通过“让出最后一个位置”完成了无缝交接。2.2 offset、startX、startY这三个变量的变化规律很多初学者不理解为什么需要offset。它的作用是记录从矩阵最外层向内缩了几层。第一圈时右边界和下边界的有效范围是 n 个格子但因为我们采用左闭右开所以要排除最后一个格子所以第一条边写j n - offset此时 offset 1。第二圈时最外圈已经被填掉了内层矩阵的有效范围只剩 n - 2 个格子对应最后一个有效索引是 n - 2所以要把边界继续收缩一位也就是n - offset中 offset 2。startX 和 startY 是每一圈的起始坐标。外圈从 (0,0) 开始内圈从 (1,1) 开始再内圈从 (2,2) 开始。它们每处理完一圈就各自加 1。这几个变量必须同步更新缺一不可。有个朋友写代码时只加了 startX 和 startY忘了offset结果第二圈的循环边界算错直接越界访问程序崩溃。排查了十分钟才意识到问题出在一个自增语句上。另一个细节是循环圈数loop n / 2。这是因为每一轮循环会填掉外圈的两条边上边和左边所以每两层矩阵才需要完整转一圈。n 4 时 loop 2转两圈刚好填满外圈和内圈n 3 时 loop 1只转一圈剩下中心的格子单独处理。2.3 奇数阶矩阵的中心点单独处理才是关键n 为奇数时最中心会剩下一个格子。这个格子不会出现在任何一圈的四条边里必须单独处理if (n % 2 1) { res[mid][mid] count; }其中mid n / 2。比如 n 3loop 1转完一圈填了 1 到 8count 正好变成 9中心格子 res[1][1] 填上 9完美收尾。如果你漏掉这个 ifn 3 时中心格子会一直是初始值 0测试用例直接挂掉。很多第一次写的人都会在这里翻车所以做题时养成习惯只要规模是奇数就单独检查一下中心元素是否需要额外处理。这不是这道题的偶然要求而是所有“按层或按圈遍历”类题目的通用注意事项。2.4 完整可运行的实现与逐行解读下面是完整的 C 实现已经能在 LeetCode 上直接通过class Solution { public: vectorvectorint generateMatrix(int n) { vectorvectorint res(n, vectorint(n, 0)); int startX 0; // 每圈起始行 int startY 0; // 每圈起始列 int offset 1; // 每圈需要收缩的边界大小 int count 1; // 当前要填入的数字 int loop n / 2; // 要处理的圈数 int mid n / 2; // 奇数时中心点坐标 while (loop--) { int i startX; int j startY; // 上边从左到右最后一位留给右边 for (; j n - offset; j) { res[i][j] count; } // 右边从上到下最后一位留给下边 for (; i n - offset; i) { res[i][j] count; } // 下边从右到左最后一位留给左边 for (; j startY; --j) { res[i][j] count; } // 左边从下到上最后一位留给下一圈的上边 for (; i startX; --i) { res[i][j] count; } startX; startY; offset; } if (n % 2 1) { res[mid][mid] count; } return res; } };注意这里的while (loop--)有一个隐性行为loop 取到 0 时会先判断 0 的布尔值为假退出循环之后 loop 会变成 -1。虽然不影响结果但它修改了 loop 本身。如果你介意这种写法可以把while (loop--)改成while (loop 0) { ...; loop--; }不改变逻辑只是可读性更好。我个人习惯用for (int k 0; k loop; k)这样循环变量的变化一目了然不容易引起误解。这段代码的空间复杂度是 O(n²)因为答案矩阵本身就要占这么多空间额外空间除了几个 int 变量之外没有别的所以可以认为是 O(1)。3. 方向数组法让矩阵自己决定往哪走3.1 用“贪吃蛇”的方式理解螺旋如果你觉得上一节的“圈层收缩”写法逻辑虽然清晰但变量有点多那方向数组法会给你另一种视角我们让填充过程像贪吃蛇一样每次走一步方向按“右→下→左→上”循环直到把整个矩阵填满。方向数组是处理二维网格问题的常用工具int dirs[4][2] { {0, 1}, // 右行不变列 1 {1, 0}, // 下行 1列不变 {0, -1}, // 左行不变列 -1 {-1, 0} // 上行 -1列不变 };当前坐标加方向向量就是下一步的位置。每次填完一个格子先尝试按当前方向前进如果下一步会越界或者下一步的位置已经被填过就右转 90°再试一次。通过这种方式蛇会自动沿着矩阵的边界向内收紧。3.2 关键判断何时转向方向数组法的核心只有一个问题什么时候算“走不了”两种情况需要转向下一步的坐标超出了矩阵范围下一步的坐标已经被填过数字。第一种情况好判断nx 0 || nx n || ny 0 || ny n。第二种情况需要借助一个前提我们填入的数字从 1 开始而矩阵初始化时全部是 0。所以res[nx][ny] ! 0就表示这个格子已经填过了。这里有一个隐藏坑如果题目改成一个从 0 开始填充的版本那么用res[nx][ny] ! 0就会失效因为未填充的格子和起点格子都是 0。遇到这种变体要么把初始值改成 -1要么额外开一个visited数组。我在面试前专门想过这个问题后来碰到类似题目时能直接给出改进方案面试官追问“为什么这里不会判断错”时也能解释得清清楚楚。3.3 完整代码与两种解法的取舍class Solution { public: vectorvectorint generateMatrix(int n) { vectorvectorint res(n, vectorint(n, 0)); int dirs[4][2] {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; int x 0, y 0, d 0; for (int i 1; i n * n; i) { res[x][y] i; int nx x dirs[d][0]; int ny y dirs[d][1]; if (nx 0 || nx n || ny 0 || ny n || res[nx][ny] ! 0) { d (d 1) % 4; nx x dirs[d][0]; ny y dirs[d][1]; } x nx; y ny; } return res; } };这个实现比转圈填充法短得多而且不容易出现“某条边的循环边界写错”这种低级错误。你只需要维护一个方向和当前坐标其他一切都交给循环。两种方法的对比如下表对比项转圈填充法循环不变量方向数组法贪吃蛇核心思想把每圈的边拆成四个循环区间统一为左闭右开每次走一步撞墙或撞已填格子就转向代码量四个 for 循环略长主循环很短但需要理解方向数组空间复杂度O(1) 额外空间O(1) 额外空间可原地判断边界易错点区间端点容易写错转向后是否立即更新坐标容易漏面试讲解难度强调“循环不变量”更有说服力思路直观但表达得不好会显得像背题我个人在面试时更推荐讲转圈填充法因为“循环不变量”是一个可以举一反三的概念面试官能看出你在系统思考边界问题但方向数组法作为补充方案提一嘴会显得你的解法储备更全面。4. 高频翻车点盘点这些错我都见过4.1 n 1 和边界条件“最简单的情况”最容易翻车n 1 时矩阵只有一个格子。转圈填充法中 loop 0循环一次都不进中心填充直接写res[0][0] count结果为 1看起来没问题。但如果你把中心处理放在循环之外却在循环里先对 res 进行了某种操作就可能在小规模用例上出错。比如有人写循环时没注意 n 1 的情况导致 res 的维度是 1x1但循环体里访问 res[i][j] 时 i 或 j 变成了 1直接越界。我的建议是写这类题目时先把 n 1、n 2、n 3 三个用例在纸上推一遍。n 1 是最容易被忽略的。很多测试框架会故意塞一个极小值进去而极小值往往能暴露边界处理的所有问题。4.2 区间端点写错和一字之差满盘皆输转圈填充法的第一条边如果写成for (; j n - offset; j)会发生什么以 n 4 第一圈为例上边会多填一个 res[0][3]把右上角那个原本属于右边起点的格子填掉。接着第二条边从 i startX 开始往右列填充但此刻 j 已经在最右列于是直接覆盖掉已经填好的值。这类错误非常隐蔽因为编译和运行都不报错只有打印结果才能看出来而且通常只在第一圈结束时出错。解决方法是把四段循环的边界条件对照检查一遍确保每一段都是左闭右开。写代码前可以在注释里标一句// 每个 for 都不会处理最后一个格子。4.3 第二圈起点更新不全startX、startY、offset 一个都不能少转圈填充法每处理完一圈必须把三个变量同时更新startX、startY、offset。漏掉任何一个都会导致第二圈的行为异常。漏掉offset是最经典的错误。比如 n 5第一圈正常填完第二圈本应从 (1,1) 开始边界排除 2 个格子但如果你还让 offset 1第二圈上边就会一路填到 res[1][3]把本应属于右边的格子占了后面全乱。如果你在调试时看到“第二圈的填充路径明显超出了内层范围”第一反应就该是检查 offset 是否更新。这个教训我是在帮一个同学排查时得到的他那段代码只漏了这一行结果输出矩阵完全不像螺旋反而像几条直线交错。4.4 方向数组法的转向陷阱转完之后忘了走下一步方向数组法最常见的低级错误是判断需要转向之后只更新了方向 d却没有重新计算 nx 和 ny而是继续用原来的 nx、ny 赋值给 x、y导致蛇“走穿墙”。if (需要转向) { d (d 1) % 4; nx x dirs[d][0]; // 这行容易漏 ny y dirs[d][1]; }另一个相关错误是假设“转向一次一定合法”。在正方形的螺旋矩阵里这个假设成立但如果题目改成矩形、或者某个内层已经填满可能会需要连续转向多次。稳妥的做法是用while循环判断直到找到合法位置为止。当然在 LC 59 这道题里转向一次就够了这也是它作为入门题比较友好的原因。5. 从 59 题延伸出去螺旋矩阵簇的复习路线5.1 LC 54 螺旋矩阵从“填”变成“读”思路反过来用LC 59 是生成矩阵LC 54 是读取矩阵给你一个 m x n 的矩阵按顺时针螺旋顺序返回所有元素。这道题和 59 正好是一对核心思路是用四个边界变量 top、bottom、left、right 分别表示当前层的上下左右边界每读完一条边就收缩相应边界。需要特别小心的是m 和 n 不一定相等所以最后可能出现只剩一行或只剩一列的情况。如果在读下边和左边的时候不做判断就会把同一行或同一列重复读一遍。常规做法是在读下边前判断top bottom在读左边前判断left right。这个“收缩边界 防止重复”的模式就是 59 题的进阶版很值得接着刷。5.2 蛇形矩阵一行正着走一行反着走蛇形矩阵是另一种常见变体给定行数和列数按从左到右、从右到左交替的方式填充每一行。这种题不考螺旋考的是用一个方向变量随时翻转。实现起来一般是这样维护一个bool leftToRight每填完一行就取反或者用step * dir的方式控制列方向。如果你只是会做 59 题看到这种变体可能会觉得是全新的题但理解了方向控制的套路之后你会发现它们其实是同一个家族都是在一维方向变换上叠加边界控制。5.3 边界类题目的刷题心法我自己刷完这一系列题目之后总结了一套流程之后遇到任何“按边界遍历二维数组”的题都能快速上手先拿一个极小输入n 3 或 3x4 矩阵在纸上完整走一遍把每个格子的坐标和数字写出来提炼出统一的遍历规则比如“每条边都让出最后一个格子”或“遇到墙就转向”写代码时把规则写在注释最上方再动手写循环提交前手动跑一遍 n 1 的用例再跑一遍最小奇数和最小偶数用例。这套流程看起来麻烦但对边界类题目的收益很高因为这类题最大的风险就是“你觉得逻辑对但实际上边界差了一个位置”。如果只看代码这种错误很难发现但结合手工推演之后就变得一目了然。最后再分享一个小技巧我在做这类题时习惯先把循环不变量用注释写到代码里比如“每条边处理到最后一个元素之前就停手最后一个元素交给下一条边”。有了这行注释写四个 for 循环时就不会因为手滑把写成。后续做 54 题、蛇形矩阵变体时我也保持同样的注释习惯边界题的正确率明显提高。