
跟着代码随想录刷到 day03螺旋矩阵ⅡLeetCode 59是第一个让我真正停下脚步的题目。题面很短、思路也很好说——按顺时针方向一圈一圈填数字就行——但只要自己动手写十个有八个都会在边界条件上翻车。这篇文章不谈“背模板”只讲我实际写这道题时怎么拆解循环不变量、怎么调试边界错误以及它和 LeetCode 54 题之间如何举一反三。说它难肯定谈不上力扣标个中等都有点客气可它是一道特别典型的“模拟题”考察的是你对循环不变量的掌握程度而不是什么高深的算法。以前刷题你可能觉得模拟题就是照着题目描述写代码没什么含金量螺旋矩阵Ⅱ会把这个想法彻底纠正过来细节写错了思路再对也白搭。我自己第一遍写的时候就在第三条边的区间上栽了为此专门把调试过程整理成了这篇记录希望能帮你少走一点弯路。1. 螺旋矩阵Ⅱ到底在考什么1.1 从暴力直觉到“模拟”的本质题面我再念一遍给定一个正整数 n生成一个 n 乘 n 的矩阵矩阵元素从 1 到 n² 按顺时针螺旋顺序填充。听起来像什么像小时候玩的那种绕圈游戏。于是大多数人的第一版代码长这样开一个二维数组然后用四个 for 循环分别填充上边、右边、下边、左边填完一圈再把上下左右边界各缩进去一格重复。这个直觉没有任何问题问题出在四个 for 循环的区间到底怎么写。你可以把这道题想象成打扫一个正方形的房间你拿着吸尘器贴着墙根走一圈先走上面那面墙再走右边那面墙再走下面、左边。问题来了——墙角你擦了几遍四个墙角如果每个 for 循环都写成包含首尾的闭区间那么每个墙角会被重复擦两遍如果每个循环都写成不包含首尾那四个墙角全部没人擦。只有制定一个统一规则比如“每条边负责擦自己那面墙但不包括终点墙角终点墙角交给下一条边的起点”才能保证每个格子恰好被处理一次且没有漏掉的格子。所谓“模拟题的本质”就是在这种细致的规则下把过程复现出来。螺旋矩阵Ⅱ不是让你发明什么算法而是看你能不能把一个朴素想法用代码精确地表达出来。这比某些需要灵光一现的题目更考验基本功因为它考的是你是否真的具备把脑内过程“翻译”成代码的能力——这种能力在工程中比背一百个算法模板都实用。1.2 为什么大家都会在这道题上翻车我观察过一个很有意思的现象很多人看题解时都觉得自己懂了因为题解里把四条边画得很清楚每一条边从哪里走到哪里一眼就能看明白。但合上题解自己写立刻暴露问题。原因在于这个题的复杂度不在理解材料上而在从“看图说话”到“代码表达”的这个转化过程。图是静态的代码是动态的动态过程中索引的每一步变化全靠你自己盯住。最容易翻车的三个点我列一下。第一区间开闭混乱上边写成左闭右闭右边又写成左闭右开第二圈开始边界就错位了。第二圈数和起点的关系搞不清楚外层循环到底循环多少次每次循环起点在哪第三奇偶性处理n 是偶数时正好可以分成若干完整圈n 是奇数时中间还会剩下一个孤零零的格子这个格子什么时候填以上三个点如果不提前想清楚调试时间可能比写代码时间还长。所以先把规则定下来再动手写才是做这道题的正确姿势。我也见过有人硬背代码背的时候没感觉第二天重写照样错——因为背下来的东西没有在脑子里形成规则自然无法应对任何一行变动。2. 左闭右开这道题的循环不变量才是核心2.1 边界划分的本质代码随想录里反复出现一个词叫循环不变量我以前看到这四个字总觉得是废话循环变量不变但螺旋矩阵Ⅱ恰恰是理解这个词最好的教材。所谓循环不变量指的是在每一轮循环中你都坚持同一个区间规则。在这道题里我的规则是每一条边都采用左闭右开区间也就是包含起点、不包含终点。为什么选左闭右开我们回到打扫房间的类比如果你规定每面墙都擦到一半就停那墙角谁来擦左闭右开的意思是每条边的起点由自己擦终点交给下一条边作为起点来擦。这样四个墙角分别是谁擦呢左上角由上边这条边的起点擦右上角由右边这条边的起点擦右下角由下边这条边的起点擦左下角由左边这条边的起点擦。一个不多一个不少。用 n5 的第一圈来验证一下。上边行是 0列从 0 到 3填充 (0,0)、(0,1)、(0,2)、(0,3)。右边列是 4行从 0 到 3填充 (0,4)、(1,4)、(2,4)、(3,4)。下边行是 4列从 4 到 1填充 (4,4)、(4,3)、(4,2)、(4,1)。左边列是 0行从 4 到 1填充 (4,0)、(3,0)、(2,0)、(1,0)。四个角分别由四条边依次负责没有任何一个角被重复读或漏掉。这就是一个稳定的不变量。很多人会问那我用左闭右闭只要我每一圈都记住哪些角填过了行不行行但代价是你在每一圈都要额外维护“哪些角不能填”的状态代码里会多出好几个 if 分支。对于这种重复性极强的过程规则越统一出错的概率越低。左闭右开的价值就在于“无状态”你不需要记住上一轮填到哪了因为每条边的终点天然就是下一条边的起点这个衔接关系是规则自动保证的。2.2 圈数、起始点、偏移量到底怎么算定完规则接下来就是三个数值loop、startx/starty、offset。很多人在这一步开始懵其实公式非常简单。先看圈数。一个 n 阶方阵从外往里能分出多少圈可以画几个例子n4 时是两圈n5 时也是两圈再加中间一个点n6 时三圈。所以完整圈的数目是 n/2也就是整除。这个结论信不过的话你把它当成每走一圈上下左右四条边各向内收缩一格总共能收缩多少次就是 n/2 次。从另一个角度理解每圈消耗掉两行两列所以 n 行 n 列能剥离出 n/2 层。再看每圈的起始点。第一圈的起点是 (0,0)第二圈是 (1,1)第三圈是 (2,2)。这个很直观起点永远在对角线上。于是我们用 startx、starty 记录当前圈的左上角坐标每处理完一圈就把它们各自加 1。这两个变量有时候会被叫做 sx、sy但作用完全一样别被不同写法搞混。最后是 offset这是最容易糊涂的地方。它表示当前圈往内缩了多少格等于 startx 1。写循环时上边那条边的终止条件是 j n - offset也就是 j 走到 n-offset-1 就停下。第一圈 offset1所以 j 最大到 n-2右边那列 n-1 被空出来留给右边这条边从 n-1 开始往下走。第二圈 offset2可操作区域的内边界就是 n-2右边列从 n-2 开始往下依此类推。每走一圈右边真正能碰到的最大列下标就往左缩一格offset 正是用来描述这个“缩进量”的。把三者串起来外层的 while 就是每进去一圈起点加一、offset 加一直到 loop 用尽。如果 n 是奇数循环结束后中心点还没填直接单独赋值 res[mid][mid] count 即可。此时 count 刚好等于 n²整个矩阵也就填完了。这里有一个小细节值得注意while (loop--) 会把 loop 一路减到 -1但由于 loop 是函数内部的局部变量减没了也无所谓不会影响任何外部逻辑所以你可以放心这么写。关于每圈实际填充元素的数量我也顺手算过第 k1 圈k 从 0 开始的边长是 n - 2k - 1四条边加起来填 4 × (n - 2k - 1) 个格子。n5 时第一圈填 16 个第二圈填 8 个剩中心 1 个总 25 个吻合n4 时第一圈 12 个第二圈 4 个总 16 个也吻合。这个式子虽然写代码时用不上但能帮你确认自己的边界是否算对。3. 完整实现与逐段拆解3.1 C 实现我最后提交的 C 版本长这样注释都写在关键位置上class Solution { public: vectorvectorint generateMatrix(int n) { vectorvectorint res(n, vectorint(n, 0)); int startx 0, starty 0; // 当前圈的左上角起点 int loop n / 2; // 完整圈数 int mid n / 2; // 奇数时中心点坐标 int count 1; // 待填充的数字 int offset 1; // 右边界的收缩量 int i, j; while (loop--) { i startx; j starty; // 上边左闭右开行 fix列增 for (j starty; j n - offset; j) { res[startx][j] count; } // 右边上闭下开列 fix行增 for (i startx; i n - offset; i) { res[i][j] count; } // 下边右闭左开行 fix列减 for (; j starty; j--) { res[i][j] count; } // 左边下闭上开列 fix行减 for (; i startx; i--) { res[i][j] count; } startx; starty; offset; } // 奇数阶矩阵中心点单独填 if (n % 2) { res[mid][mid] count; } return res; } };这段代码是跟着代码随想录那套思路走的典型写法不额外维护 left/right/top/bottom 四个边界指针而是用 startx、starty 配合 offset 来刻画“当前圈”。两种思路本质一样但我个人觉得这种写法更贴合“一圈一圈往内缩”的直觉变量也少不容易在四个指针的更新顺序上打架。3.2 代码逐段解释四个 for 循环顺序是上、右、下、左这是很顺手的螺旋方向。上边j 从 starty 开始到 n-offset 之前停止。此时 j 走到 n-offset-1正好停在上边的最右端同时把右上角让给下一条边。右边i 从 startx 开始到 n-offset 之前停止注意此时 j 已经停在上一条边结束的位置也就是列 n-offset-1所以右边循环里直接复用 j不用再给 j 赋值起点就是右上角。下边行 i 此时是 n-offset-1列 j 从当前值递减条件是 j starty意味着列 starty 这个左边界不在这里处理留给最后一条边。这一步很容易被写成 j starty一旦这么写左下角就会被提前填掉最后一条边马上再次覆盖整个矩阵的数列就崩了。左边列 j 此时停在 starty行 i 从当前值递减条件是 i startx起点 startx 这一行也不在这里处理。此时 i 减到 startx1上一圈的最内层格子正好全部填充完毕。每一条边循环结束后那个顶点恰好是下一条边的起点这是左闭右开规则最自然的结果。末尾的三句更新不可省略startx 和 starty 分别加一表示进入内圈offset 加一表示右边可到达位置向左让一格。如果你只写了循环体而忘了这三句矩阵会停留在永远填外圈的状态——我见过很多人说“死循环”其实不是死循环是循环变量根本没推进外圈被反复覆盖。3.3 Python 版本Python 写法在思路上完全一致只是 range 的右侧同样不包含所以天然契合左闭右开class Solution: def generateMatrix(self, n: int) - List[List[int]]: res [[0] * n for _ in range(n)] startx starty 0 loop n // 2 mid n // 2 count 1 offset 1 while loop: i, j startx, starty for j in range(starty, n - offset): res[startx][j] count count 1 for i in range(startx, n - offset): res[i][j] count count 1 for j in range(j, starty, -1): res[i][j] count count 1 for i in range(i, startx, -1): res[i][j] count count 1 startx 1 starty 1 offset 1 loop - 1 if n % 2: res[mid][mid] count return res注意第三条边和第四条边用的是 range(j, starty, -1) 和 range(i, startx, -1)终止下标 starty 和 startx 是不包含的正好和 C 的 j starty、i startx 对应。这段代码可以直接用 LeetCode 59 验证。另外提醒一个 Python 特有的坑初始化二维数组千万别写 [[0] * n] * n外层乘出来的每一行都是同一个对象的引用改一个全变必须用列表推导式 [[0] * n for _ in range(n)]。这种坑在别的题目里不常见但在矩阵题里非常典型顺手记一下。顺带提一句很多题解会把 for 循环写成 while用 left、right、top、bottom 四个指针来收边界。那种写法思路也清晰尤其适合矩形矩阵但和这里的左闭右开写法有一个细节差异指针写法是在整圈处理完后再统一收缩而点位加 offset 写法是每圈天然形成新的区域。两种风格没有高下之分选一种你更不容易写错的就行。我自己是两种都练过后期反而更喜欢四指针版因为它在读取版螺旋矩阵里更通用这一点下面第五节会展开说。4. 典型翻车现场我从 n3 开始调试的三个小时4.1 高频 bug 清单把我在各种讨论区看到的、以及自己踩过的高频 bug 整理成一张表方便你自查错误写法具体表现根因上边写成 for (j starty; j n - offset; j)数组越界或右上角被重复填上边界写成闭区间且 offset 边界本身算错第三条边写成 for (; j starty; j--)左下角被重复填充count 溢出矩阵出现大于 n² 的数没遵守“每条边让出一个角”的规则只写循环体忘记更新 startx、starty、offset程序能跑完但外圈被反复覆盖结果全错破坏循环不变量内圈永远进不去n 为奇数时忘了单独处理中心点中心位置残留初始值 0没处理奇偶性分支C 初始化二维数组时没有赋初值 0未填充的格子可能是垃圾值初始化不完整或错误地用了默认构造表格里每一行都是我见过至少三次以上的问题其中最隐蔽的是第二条它不会报错也不会越界甚至看起来挺“有规律”但结果就是不对。下面我用 n3 完整复盘一遍。4.2 一个完整的调试复盘我第一次写这道题时四条边都想当然用了闭区间跑 n3输出矩阵直接乱了。于是我在代码里临时加了一个打印函数每走完一条边就把矩阵打出来这一打问题就清楚了。以 n3 为例正确结果应该是1 2 3 8 9 4 7 6 5我第一版因为上边用了闭区间导致右上角 3 被上边和右边同时填充右边又用闭区间导致右下角 4 被右边和下边同时填充。打印出来每个角落都出现了两次数值矩阵中间反而有空洞。这个现象其实不用看矩阵都能猜出来——闭区间加四条边四个角至少有一半会“撞车”。问题还不是简单的覆盖因为 count 一直在自增覆盖后矩阵里会有一些数字凭空消失另一些数字出现两次。后来我改成左闭右开上边、右边都对但第三条边仍然写了 j starty。我当时的心理活动是我已经让出左边这个角了呀实际上并没有。上边让出了右上角右边让出了右下角但下边如果一直减到 j starty它会把左下角也填掉紧接着左边那条边又从同一个左下角开始填直接覆盖。这么一来n3 时矩阵里会出现超过 9 的值因为 count 多跳了好几步。解决的办法不是去改某一条边的边界条件而是回到左闭右开这个不变量上既然上边是左闭右开右边是左闭右开那下边和左边也应该统一成“包含起点、不包含终点”的形式。for (; j starty; j--) 和 for (; i startx; i--) 才是和前面一致的写法。调整完再跑一次 n3外圈 8 个数字完全正确中心 9 因为奇数分支单独填上。之后 n4、n5、n6 都一次通过。整个过程大约花了三小时但通过这次调试我才真正理解什么叫“循环不变量”——不是某个高深术语而是你写每一行循环条件时都在问自己这条边的终点到底由谁来填想清楚了代码自然就稳了。4.3 特殊输入n1 和 n0n1 是最容易被忽略的特例。按照公式 loop 0整个 while 直接跳过此时如果忘了 if (n % 2)输出就是 [[0]]。一定要记住奇数判断不仅是为了处理 n5 的中心更是为了兜住 n1 这种极端情况。力扣的用例里 n 是正整数但万一面试官让你处理非法输入最好在函数开头加一句 if (n 0) return {}; 这种防御性写法在任何语言里都是加分项。还有一个非常实用的调试技巧当你怀疑边界有问题时不要只盯着 n3 这种小规模看直接测 n4 和 n5。n4 可以完整展示两圈的分界n5 则额外验证了中心点的填法。这两个样例足够覆盖所有逻辑分支。如果你在本地调试可以把打印矩阵封装成一个函数每个循环结束都调用一次中间状态一目了然。另外关于时间复杂度矩阵一共有 n² 个格子每个格子恰好填一次所以时间是 O(n²)额外空间只有结果矩阵和几个变量所以空间是 O(n²)结果矩阵本身不计的话就是 O(1)。这个复杂度在面试中基本会被追问提前准备好解释就好。5. 举一反三从生成螺旋矩阵到读取螺旋矩阵5.1 LeetCode 54 的差异刷完生成版很多人会顺手去刷同系列的第 54 题螺旋矩阵也就是读取版给你一个 m 行 n 列的矩阵按顺时针螺旋顺序返回所有元素。两题长得像亲兄弟但有一个本质差异59 是“填满一个正方形”54 是“把一个矩形读空”。正方形天然对称可以用 n/2 圈来处理矩形则不一定对称甚至可能发生“只剩一行”或“只剩一列”的情况。如果直接用生成版的思路去套 54最常见的 bug 是读到中间那条线上重复取数。比如一个 3 行 4 列的矩阵螺旋读到最内层时只剩一行两个元素上边读取已经把这两个都读走下边如果还按老逻辑再读一次就会重复。同理只剩一列时左边也可能重复。这就是为什么读取版不能简单套用“每圈四条边”的模板它需要更灵活地判断当前还剩几行几列。另一个差异是终止条件。生成版填满 n² 个格子后自然结束读取版需要一个显式的计数器或者通过 top、bottom、left、right 四个指针是否交错来判断。很多新手在用四个指针时容易把条件写成 while (top bottom left right)进循环之后又忘了收缩后的边界可能已经交错结果在最后一圈重复取值后就越界。这个细节需要反复模拟几遍才能彻底吃透。5.2 通用化改造思路解法其实更直白干脆放弃“圈数”的概念改用四个指针框住当前可读区域top、bottom、left、right。每次读四条边读完收缩。关键只有两个 if读下边之前先确认 top bottom读左边之前先确认 left right。vectorint spiralOrder(vectorvectorint matrix) { vectorint res; if (matrix.empty() || matrix[0].empty()) return res; int top 0, bottom matrix.size() - 1; int left 0, right matrix[0].size() - 1; while (top bottom left right) { // 上边从左到右整行读 for (int j left; j right; j) { res.push_back(matrix[top][j]); } // 右边从上到下从 top1 开始避免重复右上角 for (int i top 1; i bottom; i) { res.push_back(matrix[i][right]); } // 下边从右到左前提是还没只剩一行 if (top bottom) { for (int j right - 1; j left; --j) { res.push_back(matrix[bottom][j]); } } // 左边从下到上前提是还没只剩一列 if (left right) { for (int i bottom - 1; i top; --i) { res.push_back(matrix[i][left]); } } top; bottom--; left; right--; } return res; }这段代码里前两个 for 是无论如何都要做的因为当前区域只要非空就一定还有 top 行和 right 列。但第三四个循环只在前两个循环没有把整个区域读完时才有意义所以条件判断不能省。这两个 if 是 54 题和 59 题最核心的区别也是“循环不变量”在不同场景下的灵活运用。建议把 59 和 54 放在同一天做。做 54 时你甚至会想如果早一点把“每条边的区间规则”想明白59 根本不需要调那么久。这两题互为正反面一写一读边界处理的基本功就直接成型了。后面如果遇到类似“旋转图像”“对角线遍历”这种矩阵题你会发现全都共用同一套思维先把区域边界定义清楚再让每一步操作都严格落在边界规则内。地基打牢了上面盖什么都顺手。6. 刷完这道题之后的几点体会最后说说我个人的感受。这次刷题对我来说最大的收获不是背下来螺旋矩阵Ⅱ的代码而是真的理解了模拟题为什么强调细节。面试时候如果考到这道题面试官大概率不是要看你会不会 n/2 和 offset而是看你在边界条件上是否有一套自洽的规则。你哪怕不用左闭右开只要规则统一、解释得清楚也是能过的。怕的是写着写着规则变了自己都圆不回来。给准备刷这道题的朋友几个建议第一先拿 n3 在纸上手写一遍填充过程写下每一步落子的坐标再动手写代码这比看十篇题解都有效。第二每写完一圈可以用临时 printf 输出矩阵把中间状态打出来错误一目了然。第三把 54 题紧接着刷掉你会发现两题背后的同一套边界思维完全打通了。代码随想录这个系列到了 day03难度其实还不高但我个人的态度是别赶进度前面这些“简单题”恰恰是把基础习惯养好的时候。边界处理和循环不变量这种能力后面处理链表、二叉树时一直在用。这篇记录如果能帮你少调半小时的 bug我觉得就值了。下一道题见。