ARTICLE DETAIL

资讯详情

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

螺旋矩阵从边界收缩到方向模拟:LeetCode 54/59 算法详解与变体

螺旋矩阵从边界收缩到方向模拟:LeetCode 54/59 算法详解与变体 螺旋矩阵这个题目说难不难说简单也不简单。我在刷题群里见过不少人一上来就试图用“一圈一圈剥”的方式硬写结果不是越界就是死循环最后直接背答案。其实这个题的核心不在于记住代码而在于理解二维数组的遍历节奏和边界控制。这篇总结就把螺旋矩阵从思路、实现到变体一次讲透包括我踩过的坑和面试时被追问过的细节给正在刷题或者准备面试的朋友一个参考。1. 螺旋矩阵破题先想清楚先剥洋葱还是先认方向1.1 螺旋矩阵到底考的是什么螺旋矩阵这个题LeetCode上对应的是第54题输入一个 m 行 n 列的矩阵要求按顺时针螺旋顺序返回矩阵中的所有元素。比如一个 3x3 的矩阵[[1,2,3],[4,5,6],[7,8,9]]输出就是[1,2,3,6,9,8,7,4,5]。这个题表面上看是遍历实际考察的是三个东西第一能否把二维坐标抽象成四个边界第二能否用循环不变量守住“每次遍历一条边”的规则第三遇到非方阵或者行列为0的边界情况时能不能稳住。很多人在 2x2、3x3 这类方阵上测得很欢一换成 3x4 或者 1x5 就直接崩就是因为没有建立边界收缩的思维模型。在动手写代码之前我建议先在纸上画一个矩阵模拟一遍顺时针螺旋的过程。你会发现整个遍历的规律是右、下、左、上每走完一条边那条边就被“消费”掉了下次不会再走。把这个规律抽象成代码就两种主流思路一种是我个人最推荐的边界收缩法把矩阵想象成不断缩小的洋葱另一种是方向模拟法用方向数组和状态机控制每一步的走向。1.2 边界收缩法最推荐的第一直觉边界收缩法的本质是维护四个边界变量top、bottom、left、right分别表示当前还未遍历区域的上、下、左、右边界。每一次循环按右、下、左、上的顺序把四条边依次遍历掉每遍历完一条边对应的边界就往内收缩一格。为什么我推荐这个方法因为它的思维负担最小代码可读性好也最不容易在面试时翻车。你不需要额外开一个 visited 数组时间复杂度是 O(mn)空间复杂度 O(1)。更重要的是边界收缩法非常容易扩展到“生成螺旋矩阵”这类变体题后面我会专门讲。这里有一个关键点必须说清楚四条边的遍历顺序是固定的右、下、左、上缺一不可。每执行完一次“向右遍历”top要自增执行完“向下遍历”right要自减执行完“向左遍历”bottom要自减执行完“向上遍历”left要自增。循环条件是top bottom left right一旦边界交叉就说明矩阵已经被整体遍历完了。1.3 方向模拟法换条路也能走通方向模拟法是另一种思路它不收缩边界而是让“指针”自己在矩阵里移动。我们把方向定义成四个增量右、下、左、上分别对应(0,1)、(1,0)、(0,-1)、(-1,0)。每走一步先计算下一个位置如果下一个位置越界了或者已经访问过了就顺时针切换方向再继续走。这种方法的好处是逻辑统一不管矩阵是方的还是扁的代码都不用改。代价是需要额外开一个visited布尔数组来标记访问状态空间复杂度是 O(mn)。虽然多占了空间但在一些需要“蛇形访问”或者“按路径走”的题目里方向模拟法会更通用因为只要改动方向数组就能适配逆时针、之字形等不同需求。我个人的建议是如果你刷题时间有限先彻底吃透边界收缩法等面试前再把方向模拟法也写熟练。两道题对比着看你会对“坐标移动”这件事有更深的感觉。接下来我分别把这两种方法的完整实现和细节拆开讲。2. 边界收缩法的完整实现与边界细节2.1 基本代码框架先用 C 给出一个可以直接运行的标准版本class Solution { public: 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]); top; // 向下遍历 for (int i top; i bottom; i) res.push_back(matrix[i][right]); right--; // 向左遍历 for (int j right; j left; j--) res.push_back(matrix[bottom][j]); bottom--; // 向上遍历 for (int i bottom; i top; i--) res.push_back(matrix[i][left]); left; } return res; } };这个版本看起来没什么问题但如果你直接拿去跑一些非方阵用例很可能会出问题。问题出在最后两个for循环上。2.2 四个边界更新的逻辑顺序为什么是固定的我来逐步拆解一下。假设矩阵是 3 行 4 列即m3、n4。初始top0、bottom2、left0、right3。第一轮向右遍历第 0 行的 4 个元素随后top变成 1再向下遍历第 3 列的 2 个元素随后right变成 2再向左遍历第 2 行的 2 个元素随后bottom变成 1再向上遍历第 1 行、第 0 列的 2 个元素随后left变成 1。第二轮开始时top1、bottom1、left1、right2四个边界围住的是正中间一个 1x2 的横向缝隙区域。向右遍历 2 个元素后top变成 2此时top bottom整个遍历结束。这个过程中四个循环的走向和边界收缩顺序是严格配对的谁先谁后都不能乱。注意一个细节上面代码里向右、向下、向左、向上这四步之间没有加任何if判断。这种方式在某些情况下会出错。以 2 行 3 列矩阵为例初始top0、bottom1、left0、right2。第一轮“向右遍历”加入 3 个元素top变成 1然后“向下遍历”加入 1 个元素right变成 1再“向左遍历”加入 1 个元素bottom变成 0最后“向上遍历”时top1、bottom0越界了于是错误地把已经遍历过的一个元素再次加入结果。解决这个问题的标准做法是在向左和向上两个方向前加边界条件判断比如这样while (top bottom left right) { for (int j left; j right; j) res.push_back(matrix[top][j]); top; for (int i top; i bottom; i) res.push_back(matrix[i][right]); right--; if (top bottom) { for (int j right; j left; j--) res.push_back(matrix[bottom][j]); bottom--; } if (left right) { for (int i bottom; i top; i--) res.push_back(matrix[i][left]); left; } }为什么只加这两处因为向右和向下两个方向是必然存在的只要进入 while 循环就说明这两条边还没有交叉而向左和向上则可能在单行或者单列矩阵中被跳过。这个细节是螺旋矩阵题最容易翻车的地方我建议你记在笔记里。2.3 针对 m*n 与 mn 两种情况的注意点方阵m n的情况下每一步边界收缩都很对称即使不加判断也能顺利跑完。但一旦出现 m 远大于 n或者 n 远大于 m就特别容易出问题。比如 5 行 1 列这种极端长条矩阵初始top0、bottom4、left0、right0。第一轮向右遍历只加入 1 个元素top变 1向下遍历加入bottom-top1也就是 4 个元素right变 -1此时 while 条件left right已经不满足循环直接退出。如果不加判断向左遍历会尝试向一个right-1的区域访问直接越界崩溃。再比如 1 行 5 列这种极端横条矩阵初始top0、bottom0、left0、right4。第一轮向右遍历加入 5 个元素top变 1此时 while 条件top bottom已经不满足循环退出后面三个方向都不会执行。这也是安全的。所以核心结论是判断加在向左和向上两个方向前而不是加在循环开头。循环开头的条件只负责判断是否还有未遍历的区域至于这个区域是横条还是竖条由内部的方向判断来兜底。提示如果你用的是 Python写法和这个完全一致只是在索引数组和列表的方法名上略有差异。Python 里可以用matrix[top][j]直接读取也可以用res.extend(...)批量扩展结果列表。3. 方向模拟法状态机思路与代码落地3.1 方向数组的设计思路方向模拟法的核心是一个方向数组和方向索引。方向数组按顺序存放四个移动向量方向索引从 0 到 3 循环切换。以顺时针为例方向为右、下、左、上增量分别为(0,1)、(1,0)、(0,-1)、(-1,0)。我用dir表示当前方向步进时先将下一步的坐标计算出来再判断这个坐标是否合法。合法性判断有两个维度一是数值坐标本身是否越界二是该位置是否已经被访问过。越界判断很简单nr 0 nr m nc 0 nc n已访问判断需要一个visited数组。如果下一步合法就直接移动指针如果不合法就切换方向再重新计算下一步坐标。这里有一个容易出错的地方切换方向后必须重新计算坐标而不是在原坐标上直接加两次方向增量。否则你会跳过一格。3.2 访问标记数组的配合用 visited 数组标记已经访问过的位置是方向模拟法的核心。数组大小和原矩阵保持一致默认值全部为false每访问一格就置为true。class Solution { public: vectorint spiralOrder(vectorvectorint matrix) { vectorint res; int m matrix.size(); if (m 0) return res; int n matrix[0].size(); vectorvectorbool visited(m, vectorbool(n, false)); int dirs[4][2] {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; int r 0, c 0, dir 0; for (int k 0; k m * n; k) { res.push_back(matrix[r][c]); visited[r][c] true; int nr r dirs[dir][0]; int nc c dirs[dir][1]; if (nr 0 || nr m || nc 0 || nc n || visited[nr][nc]) { dir (dir 1) % 4; nr r dirs[dir][0]; nc c dirs[dir][1]; } r nr; c nc; } return res; } };这段代码的逻辑非常规整一共循环m * n次保证每个元素恰好被访问一次不会多也不会少。即使遇到 1 行或者 1 列的情况方向切换也能正常工作因为每次走不通就切换方向而走到尽头必然会被nr 0 || nr m等条件拦住。3.3 两种解法对比表我把两种方法放在同一个表格里方便你根据场景选择对比维度边界收缩法方向模拟法空间复杂度O(1)O(mn)需要 visited 数组思维难度较低适合初级和中级稍高需要理解状态切换代码量中等较少逻辑统一变体适配性适合方阵生成类题目适合之字形、蛇形、自定义路径常见翻车点单行/单列时向左向上越界方向切换后坐标未重新计算如果你的目标是快速解决 LeetCode 54并且希望代码在面试中容易被解释清楚边界收缩法是首选。如果你想在类似“螺旋填充”“蛇形遍历”的题里也能快速复用方向模拟法的价值就体现出来了。我个人建议两个都写熟练互补性很强。4. 变体题目从“读”到“写”再到多层螺旋4.1 螺旋矩阵 II生成 n x n 的螺旋矩阵螺旋矩阵 II 是 LeetCode 第 59 题要求给定一个正整数 n生成一个包含 1 到 n^2 所有元素的 n x n 矩阵并按顺时针螺旋排列。比如n3时输出1 2 3 8 9 4 7 6 5这个题和螺旋矩阵 I 正好互逆。前者是遍历矩阵取出元素后者是往矩阵里填入元素。边界收缩法在这个场景下非常好用思路完全一致只是把res.push_back(matrix[top][j])换成了res[top][j] cur。class Solution { public: vectorvectorint generateMatrix(int n) { vectorvectorint res(n, vectorint(n, 0)); int top 0, bottom n - 1, left 0, right n - 1; int cur 1; while (top bottom left right) { for (int j left; j right; j) res[top][j] cur; top; for (int i top; i bottom; i) res[i][right] cur; right--; if (top bottom) { for (int j right; j left; j--) res[bottom][j] cur; bottom--; } if (left right) { for (int i bottom; i top; i--) res[i][left] cur; left; } } return res; } };这里有一个和遍历题不同的细节生成题里 while 循环内部的边界更新方式和遍历题一模一样但如果不加向左和向上的判断在 n 为偶数时也能跑通只是会有一次无意义的空循环n 为奇数时中心位置会由向上方向写入最后一个数字。所以这里判断条件的作用是避免重复写入和越界而不是为了 “防错”。我在刷这个题时最喜欢的一个自测样例是n4。它内部会经历两轮完整收缩可以顺便验证边界的收缩是否正确。试着手动画一下 4x4 的填充过程你会发现每轮循环写入的数字个数是 4、4、3、3下一轮是 2、2、1、1。这个不对称的个数恰恰反映了螺旋的节奏。4.2 逆时针螺旋与自定义旋转方向有些面试官不按套路出牌会把要求改成逆时针遍历或者从某个角开始。这种时候只要你理解方向数组的本质就不需要背新的模板。逆时针螺旋的顺序是“上、右、下、左”对应方向向量可以定义为(-1,0)、(0,1)、(1,0)、(0,-1)。起点如果换到右上角、左下角或者右下角你就把初始坐标和方向向量顺序整体调整即可。边界收缩法要改的话就是把四条边的遍历顺序改成上、右、下、左同时对应地更新top、right、bottom、left的顺序。仔细推一遍就会发现本质上还是同一个模板。注意逆时针变体在面试时往往会作为附加题出现或者与“从左上角开始”这种默认条件区分开。我建议你在准备时手动把顺时针的代码改成逆时针跑一遍。改起来只要 5 分钟但对理解边界收缩的核心逻辑帮助非常大。4.3 多层螺旋的递归思路如果你听到了“螺旋返回所有层”之类的描述其实这和“遍历所有元素”是同一个问题。但有些题会要求你单独输出来每一层的元素列表比如把 4x4 矩阵的每一层变成一组列表。这种情况下可以按边界收缩的层数来做。每一轮循环对应一层把当前层四个方向的元素按顺序收集然后递归处理向内收缩后的子矩阵。递归终止条件仍然是top bottom || left right。唯一的额外处理是当只剩一行或者一列时要避免把这一行或者这一列重复收集两次。这里我给你一个判断模板如果只剩一条横线那只需要收集向右方向的那一行如果只剩一条竖线那只需要收集向下方向的那一列。这个规则同样适用于“分层”版本的题目。5. 实操中的高频坑调试记录与排查心得5.1 最常见的边界错误模式写螺旋矩阵题几乎所有人都会在调试时遇到下面三类错误。我按从高到低的频率列出第一类单行或者单列矩阵时向左或者向上的循环多跑了一次结果把已经访问过的元素重复加入。这种错误在本地调试时不容易被看出因为你打印结果时可能只盯着结果个数而重复元素会让长度变长一眼能发现但如果你没有打印结果长度只看中间状态就会察觉不到。第二类把top和left写反了。比如循环里用的边界变量名字只差一个字母复制粘贴时很容易就错了。我踩过的一次就是在向下遍历时写成了i right结果把整列当成整行处理直接越界。第三类忘记处理输入为空的情况。matrix.empty()和matrix[0].empty()是两个不同的判断。前者表示没有任何行后者表示第一行是空数组。如果不做判断matrix[0].size()就会越界访问。5.2 死循环的典型成因与克制方法螺旋矩阵出现死循环通常是因为边界更新的时机不对。边界收缩法里如果某一条边遍历后没有即时更新对应的边界变量下一轮循环就会再走一遍同样的边形成死循环。方向模拟法里死循环的典型成因是方向切换时机过晚。比如你只判断了越界但没有判断 visited 数组结果指针走到已访问区域时不知道要转向只是在原地和已访问区域之间来回试探。克制死循环的一个办法是每走一步打印当前指针位置、方向和下一步目标。你能直观看到指针在什么地方打转就能立刻定位是哪个判断条件没有生效。我调试这类代码时通常会准备一个 4x4 测试用例然后分步打印中间状态比直接打印最终结果高效得多。5.3 调试建议用三个固定用例快速定位问题长时间刷题之后我总结出一个快速定位螺旋矩阵问题的三用例法2x3 矩阵能测出“向左重复访问”的问题。3x2 矩阵能测出“向上重复访问”的问题。1x5 或者 5x1 矩阵能测出边界立即交叉时是否会越界。只要这三个用例都跑过并且结果的长度等于元素总数这个实现基本就是稳的。一般我不会在所有用例都通过前就去碰那些复杂的 4x4 手动推导因为输入输出容易看花眼不如先用上述三种极端形状把最常见的边界问题直接暴露出来。6. 面试和工程视角怎么回答才能显得你真正懂6.1 复杂度分析怎么说才足够严谨面试官一问复杂度你不能只说一个 O(mn) 就算完。要说清楚因为我们访问了矩阵中的每个元素恰好一次所以时间复杂度是 O(mn)边界收缩法的额外空间只用了四个整数变量空间复杂度为 O(1)方向模拟法因为额外使用了和矩阵同尺寸的 visited 数组空间复杂度为 O(mn)。在面试里指出空间复杂度的差异是加分的。最好还能顺带补充如果矩阵本身就是只读的方向模拟法可以改成在原矩阵上做特殊标记比如将访问过的位置置为一个特殊值这样可以把空间压缩到 O(1)代价是修改了原矩阵。这个观点能体现你对工程中“接口不可变”原则的理解。6.2 代码可读性的三个习惯一个严格的技术面试官不会只满足于你写出了能跑的代码。他会看你写代码时是否注意可读性。我建议养成三个习惯第一边界变量命名统一。top、bottom、left、right是最直觉的命名不要用a、b、c、d这种。面试时临时抱佛脚改名很容易改漏。第二方向向量用常量命名。方向模拟法里方向数组做成局部变量并配合注释比int dirs[4][2] {{0,1},{1,0},{0,-1},{-1,0}}裸写在代码里清晰得多。如果你用的是 Python可以考虑用元组和命名常量比如RIGHT (0, 1)之类。第三注释不写废话。真正有价值的注释是“为什么”而不是“做什么”。比如“防止单行或单列时重复访问左侧边”这种注释能给面试官传递你对边界情况的理解远好过“这里遍历左列”这种描述性无意义注释。6.3 延伸思考从螺旋矩阵还能引出什么螺旋矩阵虽然本身是一个题目但它背后牵扯到的能力点非常丰富。如果你准备面试可以从这几个角度深化矩阵的坐标变换与增量设计其实和“图像旋转 90 度”、“矩阵转置”这类题相通。方向状态机的思想可以迁移到 BFS、DFS 里“四个方向遍历”的写法。边界收缩的思想在处理“矩阵中的连通区域”“查找有序矩阵中是否包含目标值”等题目时同样有用。我在实际写代码时最喜欢的做法是把螺旋矩阵当做一个练手工具每过一段时间就重新默写一遍不参考任何资料看看自己会不会在某个边界判断上卡住。如果卡住了说明那部分理解还不到位需要回去重新推演一遍 3x4 的过程直到形成肌肉记忆。这个题在多年以后的面试里还可能以各种变体出现但核心永远是“边界”和“方向”。把这两件事想透彻螺旋矩阵这个系列就算彻底拿下了。
返回列表