螺旋矩阵LeetCode 54详解:模拟法与C++边界控制实战
2026/9/11 14:15:07 网站建设 项目流程

1. 螺旋矩阵这道题,到底在考什么

LeetCode 54这题,我愿称之为“模拟类题目的教科书”。题目描述很简单:给你一个mn列的矩阵,按照顺时针螺旋顺序,返回矩阵中的所有元素。看起来就是个遍历,真正动手写的时候才发现,边界处理能把人绕晕。

这题能被收录进 Hot 100,不是因为它难,而是因为它背后考察的东西非常基础且重要:对二维数组索引的敏感度、边界条件的把控能力,以及最基本的模拟思想——把“沿着螺旋路径走”这件事,用代码准确地表达出来。

我在刷题群里见过不少朋友,一上来就试图找数学规律、推导坐标公式,结果越推越复杂。实际上这题的正解就是老老实实地“走迷宫”,每一步都明确知道自己在哪、要去哪、什么时候该拐弯。用C++写这道题,还能顺带巩固vector的二维操作、方向数组的定义、循环不变量的维护这些基本功。

这篇文章会从思路选型讲起,然后把两种主流写法的代码逐行拆开,最后聊聊我实际提交过程中踩过的坑和总结的调试技巧。不管你是刚开始刷Hot 100的新手,还是准备面试想快速复习的老手,这篇应该都能给你一点参考。

2. 为什么“模拟法”是这道题的正解,而不是数学公式

2.1 螺旋遍历的本质是一个“状态机”

先想一个问题:如果让你在纸上手动按螺旋顺序圈出一个矩阵,你的大脑执行的是什么指令?

其实就两条:

  • 沿着当前方向一直走;
  • 走到头了(越界或者遇到已经走过的格子),就顺时针转90度,继续走。

直到所有格子都被访问过为止。这就是一个典型的有限状态机:状态是“当前位置+当前方向”,转移条件是“下一步是否合法”。所谓模拟法,就是把大脑里这套规则原封不动地翻译成代码。

有人会想,能不能用数学公式直接算出第k个位置的行列坐标?对于某些特殊矩阵可以,但通用性很差,而且推导过程容易出错。模拟法的优势在于它的逻辑与人类直觉完全一致,写出来之后正确性一目了然,调试也方便。在面试或笔试场景下,“能快速写出正确代码”远比“写出炫技的数学解法”更实际。

2.2 两条技术路线的对比:转向法 vs 分层法

模拟螺旋遍历,社区里最常见的写法有两种,我分别称为“转向法”和“分层法”。

转向法维护一个方向数组dirs = {{0,1},{1,0},{0,-1},{-1,0}},分别对应右、下、左、上四个方向。每次尝试往前走一步,如果下一步越界或者撞上已经访问过的格子,就切换方向。为了知道哪些格子访问过,需要一个同样大小的visited二维数组。

分层法则像是“剥洋葱”。维护四个边界变量top, bottom, left, right,每一轮按“从左到右、从上到下、从右到左、从下到上”遍历当前最外层的一条边,遍历完一条边就收缩对应的边界。当top > bottomleft > right时结束。

两种方法的时间复杂度都是O(m*n),因为每个格子恰好访问一次。空间复杂度上,转向法额外需要O(m*n)的visited数组,分层法只需要几个整型变量,是O(1)额外空间。

对比维度转向法分层法
核心思想状态机+方向切换边界收缩+按边遍历
额外空间O(m*n) visited数组O(1) 四个边界变量
代码量稍短,但易错点隐蔽稍长,但结构清晰
出错概率方向判断、visited条件容易写混边界更新时机容易写错
推荐场景快速AC、追求简洁面试讲解、强调可读性

我个人在刷题时两种都会写,但面试时更推荐分层法。原因很简单:它的每一步都在“做事”,而不是在“判断”,代码读起来更像是在描述“怎么螺旋”,而不是在描述“怎么防止出错”。对于需要边写边讲思路的面试场景,分层法更容易让对方跟上你的节奏。

2.3 为什么说这题是“模拟类”的敲门砖

模拟题在算法竞赛和面试题里的地位很特殊。它不考高深的算法设计,考的是把现实规则精确翻译成代码的能力。螺旋矩阵就是这类题目的典型代表——规则极其简单,没有任何公式需要背,但写起来却能暴露很多基本功问题:数组下标有没有越界、循环不变式有没有想清楚、边界条件有没有遗漏。

把这道题吃透之后,再去做像“旋转图像”“之字形打印矩阵”“岛屿数量”这类二维数组遍历的题目,会有一种打通任督二脉的感觉。因为它们底层共享同一套能力:在二维坐标系里安全地移动、准确地判断边界

我见过有些同学把一道题刷完就扔,感觉“会了”,下次换个马甲又不认识了。我的建议是,刷完螺旋矩阵之后,可以在草稿纸上把二维数组的四个角坐标写一遍,把“右移、下移、左移、上移”对应的行列变化规律自己推一遍。这个基本功打牢了,后面遇到任何矩阵遍历题都不慌。

3. C++实现细解:从方向数组到边界收缩

3.1 转向法:用一个方向数组控制“走路”

先给出转向法的完整代码,我用的是vector<vector<int>>存储矩阵,这也是LeetCode C++题目的标准输入格式。

class Solution { public: vector<int> spiralOrder(vector<vector<int>>& matrix) { if (matrix.empty() || matrix[0].empty()) return {}; int m = matrix.size(); int n = matrix[0].size(); vector<int> res; res.reserve(m * n); vector<vector<bool>> visited(m, vector<bool>(n, false)); // 方向数组:右、下、左、上 int dirs[4][2] = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; int dir = 0; // 当前方向索引,0右 1下 2左 3上 int row = 0, col = 0; for (int i = 0; i < m * n; i++) { res.push_back(matrix[row][col]); visited[row][col] = true; // 计算下一步的位置 int nextRow = row + dirs[dir][0]; int nextCol = col + dirs[dir][1]; // 如果下一步越界,或者已经访问过,就需要转向 if (nextRow < 0 || nextRow >= m || nextCol < 0 || nextCol >= n || visited[nextRow][nextCol]) { dir = (dir + 1) % 4; nextRow = row + dirs[dir][0]; nextCol = col + dirs[dir][1]; } row = nextRow; col = nextCol; } return res; } };

这段代码的核心逻辑就一句话:每次先假设“继续往前走”,如果发现走不通就转弯dir = (dir + 1) % 4是方向切换的标准写法,% 4保证索引在0到3之间循环。

这里有一个细节值得注意:为什么不是先判断再走,而是先走一步再判断?其实两种写法等价,但“先算下一步、再判断、再更新”的方式更直观,也更不容易漏掉边界情况。我在代码里用nextRownextCol暂存下一步坐标,就是为了避免在判断和更新之间出现“用了旧坐标”的bug。

visited数组是转向法必须的。没有它,程序在走到第二圈时会把已经收录过的格子当作“可走的空格”,然后一头撞进死胡同。有些朋友觉得visited数组浪费空间,想用“走过的格子就标记为特殊值”的办法,但那样会修改原始输入,面试时可能被追问,不太推荐。

3.2 分层法:四个边界变量“剥洋葱”

分层法的思路更像是在“切蛋糕”。每一轮操作都固定处理当前最外圈的四条边,处理完一圈就往里缩一层。

class Solution { public: vector<int> spiralOrder(vector<vector<int>>& matrix) { if (matrix.empty() || matrix[0].empty()) return {}; int top = 0; int bottom = matrix.size() - 1; int left = 0; int right = matrix[0].size() - 1; vector<int> res; res.reserve(matrix.size() * matrix[0].size()); while (top <= bottom && left <= right) { // 1. 从左到右遍历上边界 for (int j = left; j <= right; j++) { res.push_back(matrix[top][j]); } top++; // 2. 从上到下遍历右边界 for (int i = top; i <= bottom; i++) { res.push_back(matrix[i][right]); } right--; // 3. 从右到左遍历下边界(注意要检查是否还有有效行) if (top <= bottom) { for (int j = right; j >= left; j--) { res.push_back(matrix[bottom][j]); } bottom--; } // 4. 从下到上遍历左边界(同理检查是否还有有效列) if (left <= right) { for (int i = bottom; i >= top; i--) { res.push_back(matrix[i][left]); } left++; } } return res; } };

分层法的关键在最后两条边。为什么需要if (top <= bottom)if (left <= right)这两种检查?因为当矩阵只有一行时,第一条边“从左到右”就把这一行遍历完了,此时top变成了1,大于bottom(0),第三、第四条边就不应该执行。如果不加检查,matrix[bottom][j]访问到的其实是已经被遍历过的那一行元素,会导致重复收录。

这个bug藏得挺深,因为它在m = n的方阵上完全不会触发,只有矩阵退化成一维的时候才暴露。我一开始刷的时候,就是在测试[[1,2,3,4]]这个用例时发现结果里出现了重复元素。

3.3 两种方法,我推荐你重点掌握哪一种

如果你只能记住一种写法,我的建议是记住分层法

原因有三条:

  • 它不需要额外的visited数组,空间更省;
  • 它不需要方向数组和取模运算,代码里的“魔法成分”更少;
  • 它的每一步都对应一个明确的物理动作(走完一条边、收缩一个边界),讲解起来非常自然。

转向法可以作为进阶理解,因为它更接近“状态机”的思维方式,在一些更复杂的模拟题(比如“机器人模拟行走”那类)里,方向数组是标配。两种写法都值得动手敲一遍,体会一下各自的思维模式差异。

我在实际刷题时会刻意训练自己:拿到题目先想清楚“用什么数据结构维护状态”,再想“循环终止条件是什么”,最后才动手写。螺旋矩阵这道题,转向法的状态是“坐标+方向”,终止条件是“走了m*n步”;分层法的状态是“四个边界值”,终止条件是“上下边界或左右边界交叉”。想清楚这两件事,写代码就只是翻译问题了。

4. 边界条件、复杂度分析与测试用例

4.1 容易被忽视的三个边界场景

螺旋矩阵的恶心之处不在于主流程,而在于几个边界场景。

第一个是空矩阵,也就是matrix.empty()或者matrix[0].empty()的情况。不判空直接访问matrix[0]会触发未定义行为,轻则运行时错误,重则在面试官面前出洋相。标准写法就是在函数开头统一处理。

第二个是单行或单列矩阵。比如[[1,2,3,4]][[1],[2],[3]]。分层法里需要靠那两个if守卫来防止重复遍历;转向法里由于每个格子只入队一次,天然不会重复,但仍要关注方向切换是否会把索引带出界。

第三个是已经走过的格子“堵路”的情形。这在转向法中通过visited数组解决,在分层法中则通过收缩边界解决。两者哲学不同:一个是“标记已访问”,一个是“让已访问区域从地图上消失”。

我整理了一张自测用例表,每次写完螺旋矩阵的代码都会跑一遍:

测试用例预期输出考察点
[][]空矩阵判空
[[]][]空行判断
[[1,2,3,4]][1,2,3,4]单行,防止重复
[[1],[2],[3]][1,2,3]单列,防止越界
[[1,2],[3,4]][1,2,4,3]最小方阵
[[1,2,3],[4,5,6],[7,8,9]][1,2,3,6,9,8,7,4,5]标准3x3
[[1,2,3,4],[5,6,7,8],[9,10,11,12]][1,2,3,4,8,12,11,10,9,5,6,7]3x4矩形

4.2 复杂度分析:为什么这是最优解

时间复杂度和空间复杂度是面试必问题。

时间复杂度很显然是O(m*n),因为无论哪种写法,每个矩阵元素恰好被访问一次。这里无法优化到更低,因为输出本身就有m*n个元素,下界就是O(m*n)

空间复杂度要分情况说。输出数组res本身不算在额外空间里的话,转向法需要O(m*n)的visited数组,分层法只需要O(1)的边界变量。如果面试官追问“能不能把空间压到O(1)”,分层法就是答案。这也是我推荐它的另一个理由。

这里有个小优化很多人忽略:res.reserve(m * n)。提前分配好容量,可以避免vector在push_back过程中反复扩容搬运元素,在矩阵很大的时候能省下不少时间。虽然LeetCode上跑测试用例不一定感觉得到,但这是个好习惯。

4.3 我调试时必用的一个技巧:打印机模式

写这类矩阵遍历题,最怕的就是“脑子里的索引”和“代码里的索引”对不上。我的习惯是先在本地加一行调试输出,把每一步访问的坐标打出来:

// 调试代码,提交前记得删除 cout << "(" << row << ", " << col << ") -> " << matrix[row][col] << endl;

然后配合一个小矩阵,比如3x3或3x4,手动在草稿纸上模拟一遍,把每一步应该访问的坐标写出来,再和程序输出对比。只要坐标序列能对得上,结果就一定是对的。

这个方法听起来原始,但真的能救急。有次我调一个旋转矩阵的题,就是靠打印坐标发现自己在“向下走”时多走了一格,导致整个序列错位。坐标一错,看起来就是“访问了不该访问的格子”,很快就锁定了问题。

5. 从螺旋矩阵延伸出去:相关题目与面试考点

5.1 一道题带出一类题:常见的变种

螺旋矩阵不是孤立的一道题,它身上挂着一串兄弟姐妹。

LeetCode 59题“螺旋矩阵II”正好是逆向操作:给定正整数n,生成一个包含1到n²的螺旋矩阵。输入和输出对调,核心逻辑几乎一样,区别只是把“遍历读取”换成“遍历写入”。刷完54题再去做59题,体感会很轻松。

剑指Offer 29题“顺时针打印矩阵”和54题一模一样,唯一的区别是输入格式可能是一个vector<vector<int>>,也可能是一个C风格的二维数组指针。刷面试题时会频繁遇到。

还有一个有趣的变种是LeetCode 885题“螺旋矩阵III”,它从矩阵外的某个点开始螺旋走,需要在“越界时仍然继续走,只在回到矩阵内时记录元素”。这个题的“边界条件”更反直觉:你不能因为当前坐标越界就停下,反而要等它绕回来。解法依然可以套用方向数组模拟,但终止条件变成了“已经收录了r*c个元素”。

LeetCode 2326题“螺旋矩阵IV”则把链表和螺旋矩阵结合,需要先把链表节点逐个填入螺旋矩阵。这类复合题考察的是“把不同数据结构拼在一起”的能力,从侧面说明螺旋遍历是个非常基础的构造模块。

我刷题的一个心得是:遇到一个核心题型,就把它的变种集中吃掉。螺旋矩阵这个系列,54、59、885、2326四道题一起刷,比单独刷十道不相关的题更能建立体系感。

5.2 面试中关于这道题的高频追问

面试考螺旋矩阵,通常不是只让写代码,后面往往跟一串追问,考察你对代码的理解深度。

第一个追问大概率是“你的代码空间复杂度是多少?能不能优化”。这就是在给分层法递话。如果你上来就用转向法,就得解释visited数组的存在意义,然后说“如果希望节省空间,可以改成边界收缩的写法”。能主动给出优化方案,在面试官眼里是加分项。

第二个追问是“如果矩阵不是矩形,而是锯齿状的(每行长度不一样),你的代码会怎么处理”。这个场景在C++里其实就是vector<vector<int>>的每行长度可以不同。解法需要改成逐行确认右边界,不能直接取matrix[0].size()当作通用列数。这题考察的是“你的代码是死板的还是健壮的”。

第三个追问是“螺旋遍历和深度优先搜索有什么关系”。其实转向法的visited数组加方向数组,本质就是一个DFS:从左上角出发,优先往右走,走不通就顺时针转向。理解了这层关系,遇到“迷宫寻路”类问题会容易上手很多,因为它们共享同一套“坐标+方向+visited”的框架。

5.3 这道题背后的“模拟法”能迁移到哪些场景

模拟法作为一类算法思想,应用范围远不止矩阵遍历。

操作系统里页面置换算法的时钟(Clock)算法,就是用一个循环指针和一个标志位数组模拟“扫描一圈找到替换页面”,和螺旋矩阵的转向法有异曲同工之妙。图形学里的多边形扫描填充、游戏里的贪吃蛇移动、机器人路径规划里的沿墙走(Wall Following),底层都涉及“方向状态+边界判断+路径记录”这套逻辑。

所以不要觉得“我刷了一道二维数组题而已”。你真正练的是把现实世界的规则抽象成循环和分支的能力,这个能力在C++开发中的价值,远高于记住某个具体的API。我自己在做图像处理项目时,就经常用到“按照一定顺序遍历像素邻域”的代码,写起来轻车熟路,就是刷这类矩阵题打下的底子。

6. 实战心得:我从这道题里提炼的刷题方法论

6.1 画图是解矩阵题的第一步,永远不要省

很多同学打开LeetCode,看完题目就埋头写代码,写一半发现索引搞错了,又回来读题。这种“先写后想”的方式,对于螺旋矩阵这种规约复杂的题,大概率会浪费时间。

我的习惯是:在草稿纸上画一个3x3的方阵,手动按螺旋顺序标记1到9,然后观察行和列的变化规律。这个过程看似笨拙,但能帮你提前发现“行号变化还是列号变化”“从第几列开始到第几列结束”之类的关键信息。用程序员的话说,这叫“先建立心智模型,再翻译成代码”。

我曾经直接把一个4x4矩阵的螺旋路径画出来,然后用箭头标出每一步的坐标变化,写着写着就发现规律了:右走n步、下走m-1步、左走n-1步、上走m-2步……照这个规律也能写出一种解法,而且不容易出错。这就是画图的威力。

6.2 刷题不要只求AC,要对拍和复盘

这道题我第一次AC用的是转向法,提交通过后我很得意。后来我看到题解区有人提到分层法,才意识到“我的解法虽然对,但不是最优”。如果是在面试现场,被追问“空间能否O(1)”,我可能会卡壳。

所以我现在刷题有个习惯:拿到一道题至少看三种解法,自己写一遍题解区最高票解法,再对比一下和自己的思路差在哪。对拍测试也很有用,写一个随机矩阵生成器,把两种解法的输出塞给同一个校验函数对比,能发现很多“恰好通过只是运气好”的隐藏bug。

螺旋矩阵这题,转向法和分层法我都写过,代码风格完全不同。每次重写都会发现细节在变好——比如reserve、比如边界变量的初始化顺序、比如空矩阵的判空位置。这些点滴的改进,才是刷题真正的积累。

6.3 用C++写这类题的一些风格建议

C++刷题和Python刷题的习惯很不一样。Python写起来行数少,很多人喜欢把多个逻辑塞进一行;C++则更强调步骤清晰、变量命名表意。

我建议变量名不要用ij一把梭。topbottomleftright这种名字,读代码的人一眼就能看到“边界在哪”,比abc好理解十倍。在LeetCode评论区看别人代码时,我也更青睐那些“变量名即注释”的写法。

另一个建议是用res.reserve(m * n)提前分配空间。LeetCode的判题环境里,一个大型矩阵动辄几十万个元素,vector反复扩容拷贝的耗时不是零。虽然跑测试用例可能看不出差别,但在真实项目中处理大数据量时,这行代码能带来肉眼可见的性能提升。

最后,C++11及以后的版本里,vector<vector<int>>的遍历尽量用范围for循环或者把matrix.size()存到局部变量里,避免在循环条件里反复调用。这算是C++性能优化里的入门习惯了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询