十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

螺旋矩阵LeetCode 54详解:模拟法与C++边界控制实战

螺旋矩阵LeetCode 54详解:模拟法与C++边界控制实战 1. 螺旋矩阵这道题到底在考什么LeetCode 54这题我愿称之为“模拟类题目的教科书”。题目描述很简单给你一个m行n列的矩阵按照顺时针螺旋顺序返回矩阵中的所有元素。看起来就是个遍历真正动手写的时候才发现边界处理能把人绕晕。这题能被收录进 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 bottom或left right时结束。两种方法的时间复杂度都是O(m*n)因为每个格子恰好访问一次。空间复杂度上转向法额外需要O(m*n)的visited数组分层法只需要几个整型变量是O(1)额外空间。对比维度转向法分层法核心思想状态机方向切换边界收缩按边遍历额外空间O(m*n) visited数组O(1) 四个边界变量代码量稍短但易错点隐蔽稍长但结构清晰出错概率方向判断、visited条件容易写混边界更新时机容易写错推荐场景快速AC、追求简洁面试讲解、强调可读性我个人在刷题时两种都会写但面试时更推荐分层法。原因很简单它的每一步都在“做事”而不是在“判断”代码读起来更像是在描述“怎么螺旋”而不是在描述“怎么防止出错”。对于需要边写边讲思路的面试场景分层法更容易让对方跟上你的节奏。2.3 为什么说这题是“模拟类”的敲门砖模拟题在算法竞赛和面试题里的地位很特殊。它不考高深的算法设计考的是把现实规则精确翻译成代码的能力。螺旋矩阵就是这类题目的典型代表——规则极其简单没有任何公式需要背但写起来却能暴露很多基本功问题数组下标有没有越界、循环不变式有没有想清楚、边界条件有没有遗漏。把这道题吃透之后再去做像“旋转图像”“之字形打印矩阵”“岛屿数量”这类二维数组遍历的题目会有一种打通任督二脉的感觉。因为它们底层共享同一套能力在二维坐标系里安全地移动、准确地判断边界。我见过有些同学把一道题刷完就扔感觉“会了”下次换个马甲又不认识了。我的建议是刷完螺旋矩阵之后可以在草稿纸上把二维数组的四个角坐标写一遍把“右移、下移、左移、上移”对应的行列变化规律自己推一遍。这个基本功打牢了后面遇到任何矩阵遍历题都不慌。3. C实现细解从方向数组到边界收缩3.1 转向法用一个方向数组控制“走路”先给出转向法的完整代码我用的是vectorvectorint存储矩阵这也是LeetCode C题目的标准输入格式。class Solution { public: vectorint spiralOrder(vectorvectorint matrix) { if (matrix.empty() || matrix[0].empty()) return {}; int m matrix.size(); int n matrix[0].size(); vectorint res; res.reserve(m * n); vectorvectorbool visited(m, vectorbool(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之间循环。这里有一个细节值得注意为什么不是先判断再走而是先走一步再判断其实两种写法等价但“先算下一步、再判断、再更新”的方式更直观也更不容易漏掉边界情况。我在代码里用nextRow和nextCol暂存下一步坐标就是为了避免在判断和更新之间出现“用了旧坐标”的bug。visited数组是转向法必须的。没有它程序在走到第二圈时会把已经收录过的格子当作“可走的空格”然后一头撞进死胡同。有些朋友觉得visited数组浪费空间想用“走过的格子就标记为特殊值”的办法但那样会修改原始输入面试时可能被追问不太推荐。3.2 分层法四个边界变量“剥洋葱”分层法的思路更像是在“切蛋糕”。每一轮操作都固定处理当前最外圈的四条边处理完一圈就往里缩一层。class Solution { public: vectorint spiralOrder(vectorvectorint 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; vectorint 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大于bottom0第三、第四条边就不应该执行。如果不加检查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题一模一样唯一的区别是输入格式可能是一个vectorvectorint也可能是一个C风格的二维数组指针。刷面试题时会频繁遇到。还有一个有趣的变种是LeetCode 885题“螺旋矩阵III”它从矩阵外的某个点开始螺旋走需要在“越界时仍然继续走只在回到矩阵内时记录元素”。这个题的“边界条件”更反直觉你不能因为当前坐标越界就停下反而要等它绕回来。解法依然可以套用方向数组模拟但终止条件变成了“已经收录了r*c个元素”。LeetCode 2326题“螺旋矩阵IV”则把链表和螺旋矩阵结合需要先把链表节点逐个填入螺旋矩阵。这类复合题考察的是“把不同数据结构拼在一起”的能力从侧面说明螺旋遍历是个非常基础的构造模块。我刷题的一个心得是遇到一个核心题型就把它的变种集中吃掉。螺旋矩阵这个系列54、59、885、2326四道题一起刷比单独刷十道不相关的题更能建立体系感。5.2 面试中关于这道题的高频追问面试考螺旋矩阵通常不是只让写代码后面往往跟一串追问考察你对代码的理解深度。第一个追问大概率是“你的代码空间复杂度是多少能不能优化”。这就是在给分层法递话。如果你上来就用转向法就得解释visited数组的存在意义然后说“如果希望节省空间可以改成边界收缩的写法”。能主动给出优化方案在面试官眼里是加分项。第二个追问是“如果矩阵不是矩形而是锯齿状的每行长度不一样你的代码会怎么处理”。这个场景在C里其实就是vectorvectorint的每行长度可以不同。解法需要改成逐行确认右边界不能直接取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则更强调步骤清晰、变量命名表意。我建议变量名不要用i和j一把梭。top、bottom、left、right这种名字读代码的人一眼就能看到“边界在哪”比a、b、c好理解十倍。在LeetCode评论区看别人代码时我也更青睐那些“变量名即注释”的写法。另一个建议是用res.reserve(m * n)提前分配空间。LeetCode的判题环境里一个大型矩阵动辄几十万个元素vector反复扩容拷贝的耗时不是零。虽然跑测试用例可能看不出差别但在真实项目中处理大数据量时这行代码能带来肉眼可见的性能提升。最后C11及以后的版本里vectorvectorint的遍历尽量用范围for循环或者把matrix.size()存到局部变量里避免在循环条件里反复调用。这算是C性能优化里的入门习惯了。
返回列表