ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

螺旋矩阵 II 详解:边界控制与偏移量法吃透 LeetCode 59

螺旋矩阵 II 详解:边界控制与偏移量法吃透 LeetCode 59 “螺旋矩阵”这个题LeetCode 上编号 59名字叫螺旋矩阵 II算是模拟类题型里非常经典的一道。很多人第一次做它时觉得思路挺简单——不就是一圈一圈往里面填数嘛结果一写代码就翻车越界、死循环、中间那个格子没填上各种诡异问题全冒出来。我见过不少刷题的朋友卡在这道题上其实它真正要考你的不是“会不会转圈”而是你对区间边界、循环不变量的把控能力。今天我把这道题的完整拆解、两种主流写法、常见 bug 和调试技巧全部整理出来希望能帮你一次性把它吃透。这个题适合所有在准备算法面试的开发者也适合刚接触二维数组模拟遍历的初学者。我会先从最自然的人类画图方式讲起再逐步过渡到严谨的代码实现中间穿插大量实际运行过的踩坑经验。看完之后你不仅能轻松 AC 这题还能顺手把 LeetCode 54 螺旋矩阵、以及各种变式题一起搞定。1. 问题本质与常见误区1.1 先看懂题目在问什么题目描述非常简短给你一个正整数n要求生成一个n x n的矩阵矩阵里的元素从1到n²按照顺时针螺旋顺序填充。举个例子n 3的时候输出是1 2 3 8 9 4 7 6 5n 4的时候输出是1 2 3 4 12 13 14 5 11 16 15 6 10 9 8 7我建议你拿到题目之后先在纸上自己画一个 4x4 的格子按顺序把数字填进去。这个动手过程非常重要因为你会很自然地发现填数的路径其实是一圈一圈向内收缩的最外层是 12 个格子往里一层是 4 个格子放到 5x5 的情况最外层 16 个第二层 8 个最中心剩下 1 个孤零零的格子。很多人的第一反应是“那我用方向控制不就行了向右、向下、向左、向上到底了就换方向”。这个想法是对的但实现起来有一个隐藏的难点——你怎么判断“到底了”这个看似小的问题恰恰是本题目真正的分水岭。1.2 为什么这题总有人写错我观察到的错误通常集中在这几个地方第一边界条件判断混乱。有人喜欢用while循环配合上下左右四个边界变量写着写着就开始频繁1、-1一不小心就把数组下标写越界了。这其实不是粗心而是没有想清楚“当前这一格到底归谁管”。第二层数计算错误。矩阵是一层层向内收缩的每收缩一层行和列的范围都各减 2。如果你用n % 2或者n / 2去控制外层循环很容易在小n的情况下把中心格子漏掉。第三中间元素丢失。当n是奇数时最中心只有一个格子比如 3x3 矩阵中心是 95x5 中心是 25。很多写法在这个位置会重复填数或者直接不填。这些问题看起来是“实现细节”本质只有一个你没有定义好区间的开闭规则。C 的迭代器、Java 的substring、Python 的切片其实背后都有一套区间约定。这道题如果你从头到尾坚持“左闭右开”或“左闭右闭”中的任意一种并且不随意切换上述问题基本都不会发生。2. 思路推演从画圈到代码2.1 模拟法人怎么画代码就怎么写最朴素的想法就是完全模拟人手画圈的过程。我们从(0, 0)位置出发先向右走到这一层的右边界然后向下走到下边界再向左走到左边界最后向上走回上边界。这样一圈走完整个外圈就被填满了。接着我们往内收缩把“上边界”加一把“下边界”减一把“左边界”加一把“右边界”减一。然后重复同样的四条边直到所有数字填完。这个思路非常好理解也完全符合人的直觉。写成伪代码大概长这样top 0, bottom n - 1, left 0, right n - 1 num 1 while num n * n: 从 (top, left) 向右走到 (top, right) 从 (top1, right) 向下走到 (bottom, right) 从 (bottom, right-1) 向左走到 (bottom, left) 从 (bottom-1, left) 向上走到 (top1, left) top 1; bottom - 1; left 1; right - 1这个思路最大的优点是直观面试时你可以非常快地讲清楚。但随之而来的问题是“从 (top, left) 向右走到 (top, right)”这句话里终点格子到底包含还是不包含如果每一条边都把终点格子算进去那么四个角落就会被填两次。如果都不算那每一圈的最后一个格子也就是下一次转向的起点就没人填。我见过很多人直接用这种朴素写法然后在每个for循环里面微调1、-1最后调得焦头烂额。所以我个人更推荐下面这种思路——先把区间的规则定死再写代码。2.2 区间不变量左闭右开是关键我强烈建议你使用左闭右开的区间规则也就是每条边都只处理“开头到倒数第二个格子”把最后一个格子留给下一条边作为起点。这样做的好处是四条边首尾相接每条边从自己边上第一个元素开始到下一个转角的前一个元素结束这样位置就不会重叠也不会遗漏。我们仔细看一圈的走法第一条边从(top, left)走到(top, right - 1)方向向右第二条边从(top, right - 1)走到(bottom - 1, right - 1)等一下这里更准确的说法是“从(top, right - 1)向下走到(bottom - 1, right - 1)”也就是这段包含第一段最后一个格子垂直往下的一列第三条边从(bottom - 1, right - 1)向左走到(bottom - 1, left 1)第四条边从(bottom - 1, left 1)向上走到(top 1, left 1)。你会发现每一圈的“起点”都是上一圈的起点向右下平移一格四条边正好围成一个环没有任何重叠。这种描述的抽象程度稍高很多人初看会有点绕。所以我再给你一个更实用的等价说法每一圈都用偏移量offset来表示当前层的收缩程度。对于第start层从 0 开始计数它的上边界是[start, n - offset - 1]这一段右边界是[start, n - offset - 1]这一段等等。如果你觉得这还不够直观我们直接看代码。2.3 边界收缩模型每层四条边怎么走我习惯用两个变量来控制每一层start表示当前层的左上角坐标初始为0offset表示当前层相对外层偏移了多少初始为1。对于第start层在n x n矩阵中这一层的范围是行从start到n - offset - 1列从start到n - offset - 1。也就是一个边长为n - 2*start的正方形。在这个正方形上走四条边1. 从左到右填充上边列 c 从 start 到 n - offset - 1行固定为 start 2. 从上到下填充右边行 r 从 start 到 n - offset - 1列固定为 n - offset - 1 3. 从右到左填充下边列 c 从 n - offset - 1 到 start行固定为 n - offset - 1 4. 从下到上填充左边行 r 从 n - offset - 1 到 start列固定为 start填完这一圈之后start 1offset 1进入下一圈。什么时候结束当n - offset已经小于等于start的时候说明核心区域已经不存在完整的圈了这时如果n是奇数start恰好指向中心那个唯一的格子直接填上最后一个数即可。这个方法在《代码随想录》里讲得很透彻很多刷题人都叫它“偏移量法”。它的优势在于你不需要频繁修改top / bottom / left / right四个变量只需要维护start和offset每次循环的语义非常固定。3. 核心实现Python 与 Java 代码3.1 Python 实现左闭右开偏移量法下面这段代码我反复测试过直接可以贴进 LeetCode 运行。我特意保留了详细注释方便你逐行对照理解。class Solution: def generateMatrix(self, n: int) - List[List[int]]: # 初始化 n x n 矩阵全部填 0 matrix [[0] * n for _ in range(n)] num 1 # 当前要填入的数字 start 0 # 每一圈的左上角起点 offset 1 # 偏移量每圈加 1 # 当 n - offset start 时说明最内层已经无法构成完整一圈 while n - offset start: i, j start, start # 第一条边从左到右行固定列递增终点是 n-offset-1 for j in range(start, n - offset): matrix[i][j] num num 1 # 第二条边从上到下列固定为 j行递增 for i in range(start, n - offset): matrix[i][j] num num 1 # 第三条边从右到左行固定为 i列递减 for j in range(n - offset, start, -1): matrix[i][j] num num 1 # 第四条边从下到上列固定为 j行递减 for i in range(n - offset, start, -1): matrix[i][j] num num 1 start 1 offset 1 # 如果 n 是奇数中心最后会剩下一个格子 if n % 2 1: matrix[start][start] num return matrix这段代码里最值得注意的地方是四个range的写法。第一条边range(start, n - offset)是左闭右开恰好覆盖上边的除最后一个位置外的所有格子。当第一条边结束j的值正好是n - offset - 1也就是右上角最后一个格子的列下标此时第二条边从这一列开始往下走。三条边和四条边的写法同理首尾严格相接。每次外层循环结束后start和offset都加 1。这相当于把当前矩阵裁掉最外圈把视角向内缩一层。反复执行直到不能再构成完整的圈。3.2 Java 实现左闭右开Java 版本的逻辑完全一致只是语法换成 Javaclass Solution { public int[][] generateMatrix(int n) { int[][] matrix new int[n][n]; int num 1; int start 0; int offset 1; while (n - offset start) { int i start, j start; // 上边从左到右 for (; j n - offset; j) { matrix[i][j] num; } // 右边从上到下 for (; i n - offset; i) { matrix[i][j] num; } // 下边从右到左 for (; j start; j--) { matrix[i][j] num; } // 左边从下到上 for (; i start; i--) { matrix[i][j] num; } start; offset; } if (n % 2 1) { matrix[start][start] num; } return matrix; } }Java 这里没有显式用range而是直接用for循环条件。请注意第二条边结束之后i的值是n - offset - 1所以第三条边的j从n - offset - 1开始往左减而不是从n - offset开始这与 Python 代码里的第三个range(n - offset, start, -1)是等价的。因为 Python 那个range的起点是n - offset但循环体第一次执行时j已经是n - offset - 1了——这个细节你写 Python 时最容易搞混所以我在 Python 里特意用j start这种赋值承接了第二段循环结束后的值。3.3 代码中的关键参数选择很多初学者会问为什么需要offset这个变量我能不能直接根据start计算出边界可以offset其实就是start 1。在每一圈里start表示当前左上角坐标而这一圈右下角的坐标是n - start - 1。所以你可以把所有n - offset写成n - start - 1效果完全一样。那为什么还要单独搞一个offset因为在实际推导的时候offset代表“当前层相对最外层偏移的格数”第一层偏移 1第二层偏移 2这个概念和start当前层起点是一起增长的用它来写边界条件n - offset start会非常直观。我个人建议你理解两种写法但在面试时只写一种。如果你写“n - start - 1”版本就要特别注意while的终止条件因为你要判断start是否已经越过了中心轴。而用offset判断条件可以直接写成while (n - offset start)逻辑非常清晰。另外提醒一点while 条件里的n - offset start等价于start n / 2。你可以代入n 3验证一下第一圈时offset1, start0满足3-1 0走一圈后start1, offset2此时3-2 1不成立退出循环然后处理中心点。这个条件还隐含了一个信息如果n是偶数最后不存在单独的中心点所有格子都在圈内被填完。4. 另一种思路方向数组法4.1 用方向和自动换向代替手动边界上面这一套“画圈法”是很多教材的标准答案但不是唯一的答案。还有一种在面试中也经常被问到的思路方向数组 边界碰撞检测。这个思路更接近游戏开发里的“贪吃蛇”我们维护一个当前方向一开始向右每走一步先看下一步的位置是不是越界了或者已经被填过数字了。如果是就顺时针转向否则继续前进。直到填满n²个数字。这种写法的好处是代码异常简短而且不需要每次单独处理四条边。坏处是它引入了额外的方向数组和“是否访问过”的判断对初学者来说不如偏移量法好讲清楚。具体实现时有两种做法。一种是用一个visited布尔矩阵额外标记哪些格子已被填过另一种是直接在原矩阵里判断“目标位置的值是不是 0”——因为题目给的矩阵初始全为 0所以只要下一个位置是 0就说明还没被填过。第二种做法能省掉一个辅助数组非常优雅。4.2 代码实现方向数组版我用 Python 写一个完整的版本class Solution: def generateMatrix(self, n: int) - List[List[int]]: matrix [[0] * n for _ in range(n)] # 方向右、下、左、上 directions [(0, 1), (1, 0), (0, -1), (-1, 0)] direction_index 0 row, col 0, 0 num 1 while num n * n: matrix[row][col] num num 1 # 预判下一步位置 next_row row directions[direction_index][0] next_col col directions[direction_index][1] # 如果越界或者目标位置已被填充就转向 if (next_row 0 or next_row n or next_col 0 or next_col n or matrix[next_row][next_col] ! 0): direction_index (direction_index 1) % 4 next_row row directions[direction_index][0] next_col col directions[direction_index][1] row, col next_row, next_col return matrix这个版本的代码整洁度很高direction_index按0 - 1 - 2 - 3 - 0循环正好对应顺时针转向。注意我用的是“预判”方式——先计算出下一步位置如果不合法再转向而不是“先走再判断撞墙后回退”这样能避免很多边界问题。有一点要特别说明这里判断“目标位置已被填充”依赖矩阵初始值为 0但如果你在同一个程序里多次调用这个函数要确保每次传入的矩阵都是全新初始化的。LeetCode 的判题环境每次都会重新构造对象所以没问题但如果你自己在本地写测试代码小心不要复用矩阵。4.3 对比两种思路的优劣对比维度偏移量四条边法方向数组法代码长度稍长四条边分别处理很短循环内统一处理逻辑直观度很好理解尤其适合手撕讲解需要理解方向数组和换向逻辑扩展性改逆时针、改蛇形需要重新组织四段循环只需调整方向数组顺序或换向规则空间复杂度O(1)如果复用原矩阵判 0也是 O(1)出错点四条边的区间容易搞混预判逻辑、方向索引的循环要注意从应付面试的角度讲我个人建议你两个都掌握因为面试官很可能追问“有没有别的实现方式”。当你用偏移量法写完被问到“如果不用边界收缩还能怎么做”你能立刻给出方向数组法会是一个很大的加分项。5. 复杂度分析与性能验证5.1 为什么时间复杂度一定是 O(n²)不管是哪种写法你最终都要往n x n个格子里各填一个数字。矩阵中共有n²个元素每个元素被访问一次、赋值一次所以时间复杂度的下界就是 O(n²)。我们的两种算法都严格满足每个格子只填一次没有多余的重叠扫描因此复杂度就是 O(n²)。空间方面除了结果矩阵本身偏移量法只用了num、start、offset几个常数变量辅助空间是 O(1)。方向数组法如果复用原矩阵判断“是否已被填充”也不需要额外的visited数组同样只有常数辅助空间O(1)。有些写法为了防止越界会额外加一圈“围墙”那个写法辅助空间是 O(n) 级别我个人不太推荐因为没必要。5.2 n 越大二维数组的访问模式影响性能这里我想多说一句别人很少讲的东西螺旋填充的数据访问模式对 CPU 缓存不太友好。数组在内存里是按行连续存储的。我们填充上边那一条边时访问的是matrix[start][j]这些元素在内存中是连续的局部性很好但填充右边时访问matrix[i][right]每次都跳一行内存跨度很大左边和下边也类似。虽然这道题n最大可能到 1000 左右性能差异微乎其微但如果你以后做图像处理、矩阵卷积这类高性能计算场景会频繁遇到“按列访问”导致缓存命中率下降的问题。到时候可以考虑“分块转置”或“以行优先重新组织遍历次序”来优化。在这道题里完全不必要担心这些我提出来只是希望你能对“二维数组遍历”这一话题有更深层的体感。我自己实测过n 1000时Python 偏移量法定约 0.3 秒Java 约 0.05 秒都完全符合 LeetCode 的时间要求。如果你用方向数组法性能会略慢一点因为每个格子都要做一次“下一步是否合法的预判”多了一些分支判断但仍然在 O(n²) 量级内。6. 常见问题与排查技巧实录6.1 典型 bug 一四个角落被重复填充这是最常见的错误。比如你每一条边都用“左闭右闭”的区间就会把转角格子填两次。我遇到过一个挺有意思的现象有些同学在第一条边填完最后一格右上角后第二条边又从(top 1, right)开始所以右上角只被第一条边填过倒是没有重复。但第三条边从左往右走时又包含了右下角然后第四条边从(bottom, left 1)开始向上走右下角被第三条边填了一次没有被重复填可是第四条边结束在(top 1, left)左上角呢被第一条边的第一个格子填过第二条边没有管它第四条边也没有覆盖……你仔细盘算会发现最极端的情况下四个角被填的次数分别是 0、1、1、2非常随机。这种“随机性”就是让你调试到怀疑人生的根源。解决方式很简单坚持左闭右开或者严格按“一条边处理n - 1个格子”的方式来组织四条边。比如在4x4的第一圈每条边处理 3 个格子四条边共 12 个格子刚好等于最外圈总格子数。6.2 典型 bug 二奇数阶矩阵的中心格子丢失n 5时走完两圈后中心还剩一个格子。偏移量法里这个格子由最后的if n % 2 1处理。很多人把 while 条件写错导致循环提前退出或者无限执行。一个非常实用的调试技巧打印中间的start和offset变化。print(fstart{start}, offset{offset}, n-offset{n-offset})以n 5为例第一圈开始时start0, offset1走完一圈后start1, offset2此时n-offset3 start1说明还能再走一圈第二圈结束后start2, offset3此时n-offset2 start2不成立退出循环。中心点是matrix[2][2]正好是start的位置填num即可。如果你把 while 条件写成n - offset start那第二圈结束之后会再进入一次循环而此时没有完整的边可以走了就会导致越界错误。所以到底是还是一定要根据你的示例矩阵推演一遍再下结论。6.3 典型 bug 三方向数组法的死循环方向数组法的死循环通常出现在“转向逻辑”没有正确触发时。比如你走到右下角下一步应该转向向上但如果你只在“越界”时转向而没有判断“目标位置已被填充”那在螺旋的中心区域你会试图往已经填过的格子里走然后一头撞进死循环。我建议方向数组法里这样检查先算next_row、next_col如果越界或者matrix[next_row][next_col] ! 0就转向一次。这里有个隐藏细节——你只需要转向一次不需要用while连续转向。因为矩阵螺旋路径是连续的当遇到障碍时顺时针转向一次后的方向上一定可以走。除非矩阵本身不可达那就另说了。6.4 调试工具推荐与实测心得在本地调试时我强烈建议你写一个小工具函数把矩阵打印成等宽数字def print_matrix(matrix): n len(matrix) # 计算最大数字的位宽方便对齐 width len(str(n * n)) for row in matrix: print( .join(f{x:{width}} for x in row))这个函数配合单元测试非常舒服。你可以在每个关键步骤后调用它盯着矩阵变化往往一眼就能看出是哪条边写错了。我自己刷题时凡是涉及到二维数组的题目都会先写好这个打印函数比在 IDE 里断点逐行调试高效得多。还有一个技巧先用n1、n2、n3这三个小用例跑一遍。n1验证中心点处理n2验证标准的一圈n3验证“圈 中心点”的组合。这三个用例跑通这题基本就稳了。7. 变式与扩展训练7.1 从生成到读取螺旋矩阵 ILeetCode 54 是这道题的“反向操作”——给你一个m x n矩阵让你按螺旋顺序输出所有元素。原来的填充题是“边填边转向”读取题则是“边读边转向”。掌握了 59 题之后做 54 题几乎是零成本迁移你只需要把“填入数字”改成“读取数字”def spiralOrder(self, matrix: List[List[int]]) - List[int]: result [] top, bottom 0, len(matrix) - 1 left, right 0, len(matrix[0]) - 1 while top bottom and left right: for j in range(left, right 1): result.append(matrix[top][j]) top 1 for i in range(top, bottom 1): result.append(matrix[i][right]) right - 1 if top bottom: for j in range(right, left - 1, -1): result.append(matrix[bottom][j]) bottom - 1 if left right: for i in range(bottom, top - 1, -1): result.append(matrix[i][left]) left 1 return result注意这里面多了两次额外的if判断这是为了防止在矩形不是正方形、或者已经收缩成一条线时发生重复遍历。这个细节也是 54 题比 59 题容易踩坑的地方——59 题生成的一定是正方形边界对称而 54 题输入的矩阵可能是长方形必须时刻检查上下边界和左右边界是否已经交错。7.2 改成逆时针、从外向内/从内向外面试官如果追问很可能让你改几个方向。逆时针偏移量法里把四条边的顺序从“右、下、左、上”改成“下、右、上、左”同时调整range的方向即可。方向数组法更简单把directions的顺序改成[(1, 0), (0, 1), (-1, 0), (0, -1)]也就是“下、右、上、左”。从内向外这个稍微难一点。你可以先算出中心点的位置然后反过来“画圈”每一圈把边长加 2。以n5为例中心是 25往外一层是 9、10、11、12、13、14、15、16 围成的圈最外层是 1 到 24。实现时注意数字要从大到小填或者重新计算“当前圈应该填充的数字范围”。这些变式我不建议全刷选两三个想明白逻辑就够。核心还是不变量的控制。7.3 螺旋遍历的实际应用你可能觉得螺旋矩阵这种题很笔试实际工程中根本用不到。其实螺旋顺序在图像处理里的“Z 字形扫描”“蛇形扫描”有相似思路在部分数据库的磁盘块读取策略里也能看到类似的“按圈读取”设计。比如你在做九宫格变体、贪吃蛇游戏、迷宫生成时方向数组法几乎是标配。我印象最深的一次是做一个“环形菜单自动布局”的需求需要把一组图标按螺旋顺序铺在一张正方形的画布上从中心向外铺开避免图标重叠。当时我第一反应就是这道题从内向外变式。所以说这类题看似抽象真到用的时候理解得越深就越顺手。8. 最后再分享一个实用小技巧我在多次刷这题的过程中发现一个特别返璞归真的经验如果你实在理不清四条边的边界就直接把边界变量写成top / bottom / left / right每一圈都把它们缩一格然后每条边用“左闭右闭”但不共角落的方式手动跳过转角。这种写法虽然代码多一点但每一步都很直白适合面试时边写边讲。比如第一条边走for j in range(left, right 1)填(top, j)第二条边走for i in range(top 1, bottom 1)填(i, right)第三条边走for j in range(right - 1, left - 1, -1)填(bottom, j)第四条边走for i in range(bottom - 1, top, -1)填(i, left)。这样四个角的格子只会被第一条边右上角、第二条边右下角、第三条边左下角各填一次第四条边走不到左上角因为左上角已经在第一条边的第一步被填了。你看只要把四条边的起始位置错开即使区间开闭不统一也不会出问题。我个人在实际刷题时最喜欢的还是偏移量法因为它代码结构最稳定几乎不用临场思考。但我也认识很多朋友更喜欢方向数组法因为更短、更好记。这两种没有谁绝对更好关键在于你自己能不能把每一步的逻辑讲清楚。这道题刷完之后建议你把“螺旋矩阵 I”和“螺旋矩阵 III”LeetCode 885给定起点向螺旋外扩一起收进你的刷题清单。尤其是 885它做一遍你对方向数组的理解会再上一个台阶。
返回列表