螺旋矩阵边界收缩法详解:力扣54题C++与Python实战
最近集中刷力扣 hot100刷到第 14 题螺旋矩阵。这道题在力扣上的编号是 54属于典型的数组模拟题。评论区经常有人说“这不就是简单题吗”但真让你现场手写一遍翻车概率比想象中高得多。它要求按顺时针方向把一个 m 行 n 列的矩阵所有元素依次取出来。拿 3×3 矩阵举例从左上角开始输出顺序是 1 2 3 6 9 8 7 4 5就像一圈圈往里转直到把全部元素扫完。这篇文章我按照自己刷题时的完整复盘来写包含核心思路、一版能直接用的 C 解法、常见变体以及我实际调试中踩过的坑。无论你是刚开始刷力扣的小白还是准备面试想快速过一遍 hot100 的选手这篇文章应该都能给你一点参考。1. 螺旋矩阵到底在考什么从审题到破题1.1 初见这道题时最容易踩的坑很多人第一次看到螺旋矩阵第一反应是这有什么难的不就是一圈一圈遍历吗结果真正动手写问题全来了。最容易踩的坑是试图用一个坐标不断往前走然后判断下一步“该不该转向”。比如定义方向向量 (0,1)、(1,0)、(0,-1)、(-1,0)每走一步就检查下一个位置是否越界或者已经被访问过如果遇到边界就切换方向。这种思路在方阵里勉强能跑通但遇到 3 行 5 列的矩阵或者反过来 5 行 3 列就很容易写乱。因为你不仅要管理方向还要管理每一步的坐标更新边界条件一多代码就开始失控。还有人是想用递归把最外层剥掉之后对内部子矩阵再递归调用。这个思路本身没毛病但为了拿到内部矩阵你得先构造一个去掉最外圈的新矩阵空间开销一下就上去了。面试时如果你先抛这个方案面试官大概率会追问一句“能不能不用额外空间”。这正是这道题有意思的地方思路好像一看就懂但你要写出一版既简洁、又不会漏元素、还能处理各种畸形矩阵的代码确实需要一点技巧。1.2 从“转圈”到“边界收缩”核心思路的由来我最后用的方法是边界收缩法也常被称为“逐层模拟”或“剥洋葱”。核心想法很简单用一个不断缩小的矩形框住还没遍历的区域每遍历完一条边就把这条边对应的边界往内部缩一格。具体来说我们维护四个变量top当前区域最上方的行索引bottom当前区域最下方的行索引left当前区域最左边的列索引right当前区域最右边的列索引。初始值分别是 0、m-1、0、n-1。每一轮循环按顺时针依次遍历四条边上边、右边、下边、左边。每遍历完一条边就把对应的边界往中间缩一格。当 left 和 right 交错或者 top 和 bottom 交错说明已经没有未遍历的区域了循环结束。这个方法的好处在于它把“方向切换”完全转化成了“边界变化”。你不需要记录哪些格子访问过也不需要每次判断下一个位置越不越界只要盯着四个边界值写循环就行。边界不交错就继续边界交错了就停。你可以在脑子里想象成剥洋葱或者卷纸一层层从外往里最外层剥完里面又是一个更小的矩形继续同样的动作。对于 m 和 n 不相等的情况边界收缩法依然成立因为最终收缩到的可能是一条线也可能是一个点而 while 条件里的两个判断会自动兜住这些情况。1.3 为什么说它是“简单题里的分水岭”力扣官方把螺旋矩阵标为中等难度hot100 里也把它归到数组这类基础题型。说它简单是因为思路直观、代码量也少说它是分水岭是因为边界细节极容易出错一行 if 漏掉结果就整个崩掉。这种题在面试里非常常见因为它能快速考察一个人对下标、边界、循环不变量这些基本功的掌握程度。你有没有认真处理“单行矩阵”和“单列矩阵”你有没有思考过 while 条件为什么是小于等于而不是小于这些细节恰恰是区分“背过题”和“真会写”的关键。hot100 这个列表里有一批这类“模拟题”比如旋转图像、生命游戏本质上考的都是对数组下标的精确控制。把螺旋矩阵吃透后面遇到这类题你会觉得它们很多地方是相通的。2. 边界收缩法一版能直接落地的标准解法2.1 四个边界怎么定义、怎么更新我直接给出这套解法的完整逻辑。假设矩阵 matrix 有 m 行 n 列定义top 0 bottom m - 1 left 0 right n - 1之后的每一轮四条边的遍历顺序是固定的上边行固定为 top列从 left 到 right逐个读入元素。读完 top 向下收缩执行top。右边列固定为 right行从 top 到 bottom逐个读入元素。读完 right 向左收缩执行right--。下边行固定为 bottom列从 right 到 left逐个读入元素。读完 bottom 向上收缩执行bottom--。左边列固定为 left行从 bottom 到 top逐个读入元素。读完 left 向右收缩执行left。肉眼可见这一轮下来最外圈已经被完整读取了。下一轮进入更内层的矩形继续同样的操作。这里有一个非常关键的细节在第三步和第四步之前必须加上判断条件。为什么因为如果矩阵只有一行第一步“上边”会把这一行全部读走此时 top 之后top 已经大于 bottom。如果不再加判断继续执行“下边”的循环就会去读取一个已经不存在的行而且会把这一行重复读一遍。同样的道理如果矩阵只有一列第二步“右边”已经把所有元素读走right-- 之后 left 和 right 交叉不加判断直接遍历“左边”也会重复读。所以第三步前要写 if (top bottom)第四步前要写 if (left right)。这两条 if 是整道题最容易漏掉、也最值得记住的地方。2.2 完整代码实现C 核心版下面是 C 的标准实现我加了比较详细的注释方便直接对照理解class Solution { public: vectorint spiralOrder(vectorvectorint matrix) { vectorint result; // 空矩阵直接返回 if (matrix.empty() || matrix[0].empty()) { return result; } int top 0; int bottom matrix.size() - 1; int left 0; int right matrix[0].size() - 1; while (left right top bottom) { // 1. 上边从左到右 for (int j left; j right; j) { result.push_back(matrix[top][j]); } top; // 2. 右边从上到下 for (int i top; i bottom; i) { result.push_back(matrix[i][right]); } right--; // 3. 下边从右到左必须防止单行重复 if (top bottom) { for (int j right; j left; j--) { result.push_back(matrix[bottom][j]); } bottom--; } // 4. 左边从下到上必须防止单列重复 if (left right) { for (int i bottom; i top; i--) { result.push_back(matrix[i][left]); } left; } } return result; } };这段代码跑起来之后每个元素恰好被访问一次。顺序完全符合题目要求的顺时针螺旋包括当矩阵只有一行、只有一列、或者正好是方阵这些场景。2.3 每个循环条件的边界细节讲解我把几个容易让人迷糊的边界点单独拿出来聊一下。第一while 条件为什么是 left right top bottom而不是 left right因为当矩阵是奇数尺寸方阵时比如 3×3最内层会剩下一个单独的元素。此时 left 和 right 相等top 和 bottom 也相等如果用小于号循环会在这一层提前退出中间那个元素就被漏掉了。等于号保证了最后单独一个点也能被正确处理。第二循环变量的作用域和方向要分清。上边用的是 matrix[top][j]行固定 top列 j 从 left 到 right右边用的是 matrix[i][right]列固定 right行 i 从 top 到 bottom。很多人第一次写容易把行列搞反写成了 matrix[j][top] 或者 matrix[right][i]结果跑出一个乱七八糟的顺序。第三下边和左边两个 for 循环都是反向遍历。下边是 matrix[bottom][j]j 从 right 递减到 left左边是 matrix[i][left]i 从 bottom 递减到 top。注意反向遍历的起始值别顺手就写成了 j 或者 i。我自己调试过 3×4 的矩阵发现只要顺序对输出是先是第一行全部然后最后一列从第二行到底然后最后一行从右到左再是第二列从下到上最中间的两个元素会被下一轮循环正确处理。整个过程不会多读一个、也不会少读一个。我额外写了一个 Python 版本思路完全一样供参考class Solution: def spiralOrder(self, matrix: List[List[int]]) - List[int]: res [] if not matrix or not matrix[0]: return res top, bottom 0, len(matrix) - 1 left, right 0, len(matrix[0]) - 1 while left right and top bottom: for j in range(left, right 1): res.append(matrix[top][j]) top 1 for i in range(top, bottom 1): res.append(matrix[i][right]) right - 1 if top bottom: for j in range(right, left - 1, -1): res.append(matrix[bottom][j]) bottom - 1 if left right: for i in range(bottom, top - 1, -1): res.append(matrix[i][left]) left 1 return resPython 里 range(right, left - 1, -1) 这句是左闭右开区间所以第二个参数要写成 left-1这是很多 Python 新手容易搞错的地方。3. 进阶与变体螺旋矩阵 II 与相关高频变种3.1 从读取到生成螺旋矩阵 II 的对称写法螺旋矩阵这道题还有一个非常对称的姊妹题力扣 59 题螺旋矩阵 II。它和 54 题正好反过来——54 题是给你矩阵让你按螺旋序输出数组59 题是给你一个整数 n让你生成一个 1 到 n² 的 n×n 矩阵数字按螺旋序填入。代码结构几乎一模一样区别只是把“读取元素”换成“写入数字”。C 实现如下class Solution { public: vectorvectorint generateMatrix(int n) { vectorvectorint ans(n, vectorint(n, 0)); int top 0, bottom n - 1, left 0, right n - 1; int num 1; while (left right top bottom) { for (int j left; j right; j) { ans[top][j] num; } top; for (int i top; i bottom; i) { ans[i][right] num; } right--; if (top bottom) { for (int j right; j left; j--) { ans[bottom][j] num; } bottom--; } if (left right) { for (int i bottom; i top; i--) { ans[i][left] num; } left; } } return ans; } };这道题在面试中可能是 54 题的追问方式。面试官先让你写螺旋矩阵读取你写完了他点点头然后又问一句“如果反过来的让你往一个空矩阵里填螺旋数字你写不写”如果你 54 题用的是边界收缩法那 59 题你几乎只要改三行代码结果数组换成二维数组读取换成赋值再加一个计数器 num。hot100 里还有一道进阶变体力扣 2326 题螺旋矩阵 IV给一个链表让你把链表节点按螺旋顺序填入矩阵。思路仍然是边界收缩只是每填入一个数字链表指针要同步向后移动一格。这个变体考的是“模拟题 链表操作”的组合能力本质还是对四个边界的控制。3.2 逆时针螺旋与蛇形遍历的扩展思路面试官如果在螺旋矩阵的基础上加难度最常见的两个变体是逆时针螺旋和蛇形遍历。逆时针螺旋很好处理。你只要把四个方向的顺序调整一下先从上到下读左列再从左到右读底行再从右到左读右列最后从下到上读顶行。对应的边界收缩顺序也要跟着改成读完左列 left读完底行 bottom--读完右列 right--读完顶行 top。整体框架没变变化的是边界的读取顺序。蛇形遍历也叫之字形遍历是另一种常见的矩阵模拟题。它不要求一圈圈转而是按行交替方向第一行从左到右第二行从右到左第三行再从左到右以此类推。这种题比螺旋矩阵简单不少核心就是一个布尔变量控制方向每遍历完一行就翻转一次。把这些变体放在一起看你会发现它们都有一个共同点固定一个维度变化另一个维度。螺旋矩阵每轮固定四条边每条边固定行或列然后循环另一个变量。想清楚这一点你就不会被各种“变着花样转圈”的题目绕晕。3.3 时空复杂度分析与优化空间螺旋矩阵的时间复杂度是 O(m×n)因为每个元素只会被访问一次不存在重复读取。空间复杂度是 O(1)这里的 O(1) 指的是除了存放结果的数组之外额外使用的空间只有 top、bottom、left、right 几个变量。面试中如果追问空间复杂度你可以提一个对比方案如果用方向向量加 visited 数组的写法额外空间至少是 O(m×n)因为你得记录哪些格子已经访问过。边界收缩法最大的优势之一就是省内存不需要任何标记结构。还有一种递归写法也能解决螺旋矩阵问题但每次递归都需要把去掉最外圈之后的内部子矩阵作为参数传下去因为要构造新矩阵空间复杂度会变成 O(m×n) 量级时间上也有额外的拷贝开销。写代码可以面试时如果想展示思路多样性可以提但不建议作为主要解法。另外如果你想挑战一下自己可以想想这个问题能不能在不模拟遍历全部元素的情况下直接根据索引 k 算出螺旋序中第 k 个元素的坐标。这个是可以做到的需要根据当前所在圈层推公式比模拟写法复杂很多我在实际面试中几乎没见过有人要求这么写当作课后思考题就好。4. 刷题实战中的常见问题与避坑心得4.1 死循环与重复读元素的三大典型原因我把实际调试过程中遇过的典型问题整理成了三类每一类都很隐蔽。第一类while 条件写错导致死循环或者漏元素。比如把 left right top bottom 写成 left right top bottom3×3 矩阵里最内层的中间元素会因为条件不满足直接被跳过。又比如只写了 left right没写 top bottom在列数大于行数的矩阵中循环可能会一直转下去输出超出预期的元素数量。第二类漏掉下边和左边的 if 保护。这是最经典的错误。如果你把一个 1×3 的矩阵喂给没有 if 的代码第一轮上边遍历会把 1、2、3 全部读走top 变成 1然后右边遍历因为 top bottom 不会执行但如果不加 if下边遍历会再次执行把已经不存在的“下边”又读一遍。结果就是输出一个长度为 6 的数组元素重复。这个 bug 在方阵里不出现因为方阵最内圈有足够的行和列兜底只有在单行、单列矩阵里才暴露。所以很多人的代码跑 3×3 没问题一测 1×3 就挂了。第三类边界更新顺序写反。比如读完上边之后不是先 top而是先 right-- 或者 bottom--。这会导致下一轮的矩形区域发生变化最终结果顺序混乱。建议每次写完一个 for 循环立刻更新对应的边界不要跳着更新也不要憋到最后统一更新。我在刷题时养成一个习惯写完代码后固定用六个测试用例自查分别是 3×3、3×4、4×3、1×3、3×1、1×1。这六个用例几乎覆盖了所有边界场景只要它们全部通过这道题基本就稳了。4.2 与面试官交流时的答题节奏建议如果你是在面试中遇到这道题答题节奏比代码本身更重要。第一步先确认输入范围。问清楚矩阵是否可能为空行数和列数范围是多少。这一步既是澄清需求也是在给后面的边界条件做铺垫。第二步说思路。一句话讲明白你的方案“我用四个边界表示当前未遍历的矩形区域每遍历完一条边就收缩对应的边界直到边界交错。” 这句话一说面试官就知道你思路清晰。第三步再写代码。写代码的时候可以先写主框架再补边界的 if 判断。第四步手动跑一个用例。拿 3×3 矩阵对着代码走一遍尤其是走到最内层的时候嘴里念一下“top 和 bottom 相等left 和 right 也相等但 while 条件允许进入所以中间这个元素被正确读取”。这个动作对面试官的印象分提升非常明显。最后再补一句复杂度分析时间 O(m×n)空间 O(1)。还有一个小技巧主动提一下“这个解法对单行单列也不会重复读取”。这句话很轻但能直接告诉面试官你已经理解了这个题最深层的边界问题。4.3 力扣 hot100 刷题策略与这类题的记忆技巧hot100 是力扣上标记的 100 道高频面试题覆盖面很广。第 14 题螺旋矩阵出现在这个列表的数组板块它周围的题基本都是数组遍历、双指针、滑动窗口这些基础能力。我的建议是不要按编号顺序刷而是按专题刷。模拟类题目放在一起做效果会更好。你可以把螺旋矩阵和相关变体放在同一天完成54 题、59 题、2326 题再往后可以连着做旋转图像48 题、矩阵置零73 题、生命游戏289 题。这些题都在考同一件事对二维数组行列下标的管理能力。一次吃透这一类比每天刷十道不同题型更有用。关于记忆技巧我总结了一句口诀“四个边界两条 if先缩边再判断。” 四个边界就是 top、bottom、left、right两条 if 就是下边和左边遍历前的保护判断先缩边再判断的意思是每遍历完一条边先更新边界再进入下一个方向。这句话我到现在还记得就是因为这道题我翻过车记忆深刻。另外也想说一句不要因为力扣上有些评论说这是“简单题”就掉以轻心。在实际面试和机试里能稳稳把这题的边界想清楚并一次写对的人确实不算多数。很多题看起来简单但能写对才是本事。我个人在实际操作中的体会是螺旋矩阵这道题我隔一段时间重写一遍每次都会在两条 if 那里顿一下确认自己是不是真的理解了为什么要有它们。它让我对“边界条件”这件事有了一种敬畏感。还有一个小技巧分享给你准备一个固定的自查模板把单行、单列、1×1、奇数和偶数尺寸矩阵都测一遍应对任何矩阵模拟题都够用。搞定这题之后下一道建议去刷旋转图像你会发现两题对着看能更清楚地理解行列下标的变化规律。