news 2026/10/1 3:25:08

螺旋矩阵II详解:边界收缩法破解LeetCode 59题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
螺旋矩阵II详解:边界收缩法破解LeetCode 59题

LeetCode第59题,螺旋矩阵II,是数组专题里非常有代表性的一道题。给定正整数n,要求生成一个n×n矩阵,把1到n²按顺时针螺旋顺序填进去。这道题不涉及复杂的数学技巧,也不依赖高级数据结构,核心考察的就是对二维数组的遍历控制能力:方向怎么切换、边界怎么收缩、循环什么时候终止。很多刷LeetCode的人会把它归类到“模拟题”,但它在面试里出现频率相当高,尤其是要求手写代码的环节,能在几分钟内检验一个人对数组索引和循环边界的敏感度。这篇博文就把我从刷这道题到彻底吃透它的完整过程写出来,包括核心思路、手写代码、常见坑点、变体迁移,最后聊几个面试实战的建议。

1. 题目拆解:先搞清楚它在考什么

1.1 题目要求与一个完整的n=3推演

题目描述很简单:给定正整数n,生成一个包含1到n²所有元素的n×n矩阵,元素按顺时针螺旋顺序排列。

n=3时输出是:

[[1, 2, 3], [8, 9, 4], [7, 6, 5]]

n=4时输出是:

[[1, 2, 3, 4], [12, 13, 14, 5], [11, 16, 15, 6], [10, 9, 8, 7]]

我第一次看到样例时心想这不就是一圈一圈往里填嘛,但真动手写才发现没那么简单。手动推演n=3的填充路径:从左上角(0,0)开始向右走,依次填1、2、3,碰到右边界;转向向下填4、5,到达右下角(2,2);转向向左填6、7,到达左下角(2,0);转向向上填8,到达(1,0);最后剩下正中间(1,1)填9。整个过程走了一个完整的“回”字形轨迹。

仔细观察会发现一个规律:每一圈的填充可以拆成四条边,分别是顶边从左到右、右边从上到下、底边从右到左、左边从下到上。而每一圈走完之后,下一次走的范围会比上一次小一圈。这个“范围缩小”就是整个算法的核心操作,想明白了它,代码就只剩下落实的问题。

1.2 三个比较容易卡壳的点

这道题看起来简单,大家普遍会在三个地方卡壳。

第一个是方向控制。填充方向按照“右、下、左、上”循环切换,每次碰到边界就要转向。很多人用一堆if else判断当前方向,写着写着就晕了。其实方向切换是有固定顺序的,完全可以配合边界收缩来统一处理。

第二个是边界收缩。走完顶边之后,下次再走顶边时范围要缩小一行;走完右边之后,右边界要左移一格。如果只改坐标不收缩边界,填充路径就会重复覆盖已经填过的格子,或者冲出数组范围。边界收缩是区分“真懂”和“背模板”的分水岭。

第三个是终止条件。什么时候停止循环?最直观的想法是“填完n²个数为止”,但落到代码里用while还是for、在哪里判断退出,写法不对就会出现死循环或者漏填。这三个点拆开看都不难,合在一起就容易乱。

针对这类矩阵遍历问题,主流解法有两派:一派是方向偏移量法,定义方向数组dx/dy,配合访问标记数组判断是否转向;另一派是边界收缩法,用四个变量维护当前可填充的边界。我个人强烈推荐后者,变量少、逻辑直白、不需要额外空间,下面详细展开。

2. 核心解法:边界收缩法的完整实现

2.1 四个指针怎么圈定当前环

把矩阵想象成被四堵墙围起来的区域:顶墙top、底墙bottom、左墙left、右墙right。初始状态下,top=0,bottom=n-1,left=0,right=n-1,正好圈住整个矩阵的外围。

每一轮填充,目标就是当前四堵墙围成的矩形区域的“外圈”,也就是最外环。走完一圈之后,四堵墙同时向中心收缩一格,得到一个新的、更小的矩形区域。不断重复这个过程,直到所有格子被填满。

这个过程很像剥洋葱,从外到内一层一层处理。剥洋葱的时候你不会乱剥,而是沿着每一层的轮廓绕一圈再往里;边界收缩法也是这样,每一轮处理一个完整的环,处理完就缩小范围进入下一环。

用生活化的比喻:你拿着喷漆在操场外圈画跑道,画完一圈后站到新的起跑线,再画里面一圈。这里的“新的起跑线”就是收缩后的边界。思路就是如此朴素,它的好处是不需要额外记录每个格子是否访问过,只看边界就能确定下一步往哪走。

2.2 循环不变量:每条边怎么走才不出错

这是整道题最关键的设计决策:每条边上的格子究竟属于哪一段,也就是四个顶角的归属问题。

我采用的写法是每一条边都走“完整的一段”,但四个拐角只让每条边包含起点、不包含终点,用代码表达就是:

  • 顶边:从左到右遍历[left, right],包含两个端点,填充第top行的所有格子。
  • 右边:从上到下遍历[top+1, bottom],不包含top行(因为右上角已经在顶边填过),包含bottom行。
  • 底边:从右到左遍历[right-1, left],不包含right列(因为右下角已经在右边填过),包含left列。
  • 左边:从下到上遍历[bottom-1, top+1],不包含bottom行(因为左下角已经在底边填过),也不包含top行(因为左上角已经在顶边填过)。

这样四个角分别由四条边各填一次,不会重复。这个“角落归属”的约定就是这道题的循环不变量,只要每条边都严格遵守,整个填充过程就不会出错。

还有一个很多人争议的细节:底边和左边到底要不要走满。有些写法走的是“每条边左闭右开”,也就是每条边都不包含该边的最后一个格子,把转角留给下一条边。两种约定都能通过测试,但你必须选定一种并且贯彻到底。我更喜欢上面这版“每条边包含自己的起点”的写法,因为循环条件写起来对称,不容易漏填最后一个格子。

循环的退出条件我直接用while (count <= n * n)。这个条件配合内层四条边的填充,天然就能处理所有情况。count最终会停在n²+1,恰好说明全部格子已经填完。这个写法不需要额外的if判断,也不会出现中心格子无法填充的问题,细节在3.1节单独说明。

2.3 参考代码:JavaScript版与到其他语言的平移

下面给出一份可以AC的JavaScript实现,逐行注释:

function generateMatrix(n) { // 初始化 n x n 矩阵,全部填充 0 const matrix = Array.from({ length: n }, () => new Array(n).fill(0)); // 四个边界指针 let top = 0, bottom = n - 1; let left = 0, right = n - 1; // 当前要填入的值 let count = 1; while (count <= n * n) { // 顶边:左 -> 右 for (let col = left; col <= right; col++) { matrix[top][col] = count++; } top++; // 顶边界收缩 // 右边:上 -> 下 for (let row = top; row <= bottom; row++) { matrix[row][right] = count++; } right--; // 右边界收缩 // 底边:右 -> 左 for (let col = right; col >= left; col--) { matrix[bottom][col] = count++; } bottom--; // 底边界收缩 // 左边:下 -> 上 for (let row = bottom; row >= top; row--) { matrix[row][left] = count++; } left++; // 左边界收缩 } return matrix; }

这份代码提交LeetCode可以顺利通过,时间复杂度和空间复杂度后面再讲。平移到C++时用vector<vector<int>> matrix(n, vector<int>(n, 0)),平移成Python时用[[0] * n for _ in range(n)],核心逻辑完全一致,只是语法不同。唯一的注意点是不同语言的二维数组初始化方式有差异,这个坑点很大,我在3.2节专门讲。

如果你更喜欢方向偏移量法,思路是定义dx = [0, 1, 0, -1]、dy = [1, 0, -1, 0],配合一个visited二维数组记录是否访问过,碰到边界或者已访问格子就转向。这种写法可以处理更复杂的“蛇形填充”等变体,但它多用了O(n²)的额外空间,而且面对螺旋矩阵这种“每走完一整条直线边界就要收缩”的场景,反而没有边界法直观。所以面试时我建议优先讲边界收缩法。

3. 实操细节与常见坑

3.1 奇偶n的处理:中间格子是怎么落到正确位置的

n为奇数时,最内层只剩下一个单独的中心格子,比如n=3的9、n=5的13。很多人担心这个格子会被漏掉,于是额外写一个if (n % 2 === 1) matrix[mid][mid] = n * n;的特判。

实际上,我刚才给出的代码不需要特判。原因在于while (count <= n * n)这个条件:当四堵墙收敛到同一个位置时,即top === bottom且left === right,此时count恰好等于n²,循环条件仍然成立,进入循环体。第一段顶边的for循环从左到右只有一个格子,fill填入n²,count变成n²+1。随后右边的for循环条件是top到bottom,此时row = top = bottom,按理说会执行一次,但注意right已经在上一轮循环末尾收缩过,而当前轮顶边执行完后top又加了一次,实际上right已经是中心格子的列坐标,不过由于循环体末尾还有right--这步还没执行,右边这一段执行时row从新的top到bottom,如果top===bottom,会以matrix[row][right]再填一次同一个位置——等等,这里需要仔细推演。

我实际推演n=3的最后一轮:进入最后一轮前,经过了第一圈的收缩,top=1, bottom=1, left=1, right=1,count=9。while条件9 <= 9成立。顶边:for col=1到1,matrix[1][1]=9,count变成10。top++变成2。右边:for row=2到1,条件不成立,跳过。right--变成0。底边:for col=0到1,条件不成立,跳过。bottom--变成0。左边:for row=0到2,条件不成立,跳过。left++变成2。此时count=10,while条件10 <= 9不成立,退出循环。输出正确。

所以这个写法在中心点上完全正确:顶边负责中心格子的填充,后面的边由于边界收缩后条件不满足而自动跳过。这也是我偏爱while (count <= n * n)的原因,它比while (top <= bottom && left <= right)更不容易漏掉中心位置。后者那套写法就必须在循环外补一个中心点特判,很多题解里能看到这种写法,两套方案都没有错,但你必须在提交前明确自己用的是哪套,别混着用。

3.2 数组初始化的坑:引用共享与逐行创建

这个坑主要出现在JavaScript和Python这类带引用语义的语言里。JavaScript最常见的错误写法是:

const matrix = new Array(n).fill(new Array(n).fill(0));

看起来没问题,实际上fill方法传入的是一个数组对象,所有行都指向同一个数组。你给matrix[0][0]赋值为1,matrix[1][0]的值也会变成1,因为matrix[0]和matrix[1]根本就是同一个数组。找bug的时候会非常崩溃,因为你看到的输出像是“整列一起变了”,完全不知道问题出在初始化上。

正确的做法是逐行创建新数组:

const matrix = Array.from({ length: n }, () => new Array(n).fill(0));

Array.from的第二个参数是映射函数,每一行都会调用一次new Array(n).fill(0),得到独立的数组对象。Python里也有一模一样的坑:[[0] * n] * n会生成n个指向同一行的引用,正确写法是[[0] * n for _ in range(n)]。C++的vector不存在这个问题,因为vector<vector<int>> matrix(n, vector<int>(n, 0))在构造时会为每个内层vector独立分配内存。

这个细节看起来小,但在LeetCode的JavaScript提交里是非常常见的报错源。很多人代码逻辑写对了,就因为初始化方式不对,提交后输出一团乱麻,还以为自己的螺旋遍历思路有问题。排查了半天才知道是引用共享。记住:二维数组初始化,每一行都要单独创建。

3.3 死循环与越界的经典错误自查

再总结几个我见过的典型错误,你可以拿来自查。

死循环方面,最常见的场景是while (true)循环里靠break退出,但break条件写成了类似if (count > n * n) break;并且放在了错误的位置,比如放在四条边的for循环之后,而不是每填充完一个数都检查。这样如果最后一次循环多填了格子,count超出n²,循环可能不会被正确终止,导致无限循环。用我推荐的while (count <= n * n)写法可以避免整个问题。

越界方面,最容易出错的是底边和左边。底边从right - 1开始向左走,如果上一轮结束后right已经小于left,for循环条件col >= left就为假,自动跳过。左边从bottom - 1开始向上走,同理会自动跳过。这些边界条件在代码里体现为for循环的条件判断,只要变量名逻辑正确,一般不会越界。真正会越界的写法是手动管理坐标的while循环,比如while (col <= right) { fill; col++; },一旦col在赋值后没有即时检查边界,下一次循环就可能越界访问。

我给自己的自查清单是:写完代码后立刻用n=1、2、3、4、5逐个跑一遍。n=1验证最简情形,n=2验证只有一圈的偶数,n=3验证奇数中心的落点,n=4验证多圈收缩,n=5验证奇数多圈。重点检查四个角和中心位置的值是否符合预期。这五个用例能覆盖几乎所有边界问题。

4. 从螺旋矩阵到一类题:数组遍历的通用方法论

4.1 高频变体:读矩阵、蛇形矩阵、对角线遍历

很多人刷完59题就接着刷下一题,其实这一题可以延伸出一整片题型。理解核心的“边界收缩+方向切换”之后,以下几类题都会容易很多。

最直接的变体是LeetCode 54题(螺旋矩阵I),输入一个m×n矩阵,要求按螺旋顺序读出所有元素。它和59题互为镜像:59题是“填”,54题是“读”。实现54题时,同样的四指针边界法依然适用,区别在于travse的方向和退出条件要同时考虑left > right和top > bottom,因为矩阵不一定是正方形,可能出现中间只剩一行或一列的情况。我实测过,如果直接用59题的代码套54题,会在“只剩一行”和“只剩一列”这两种情况里踩坑,因为四条边的执行顺序会让某些格子被重复读取。正确做法是在每一条边填充后检查边界是否相交,一旦相交立即退出。

另一个高频变体是蛇形填数,也叫之字形遍历。方向顺序不是“右、下、左、上”,而是“左下、下、右上、右……”这种斜向路径。它就不能简单用四指针了,得用方向偏移量法,配合边界判断。但熟悉了“方向数组+边界判断”这个组合套路后,切过去也不难。LeetCode 498题对角线遍历也是同一个套路,方向数组换成四个对角线方向而已。

还有一个我面试中见过两次的变体:从矩阵左上角开始,要求按螺旋顺序打印矩阵的外围一圈、然后打印去掉外围后的内层一圈的对角线。这类组合题本质就是把59题的“按圈处理”拆成两个阶段分别输出,边界收缩的思路是完全一致的。

4.2 数组类题目的通用套路与热词对照

把LeetCode里常见的数组题做个分类,你会发现几乎所有题目都能归入几类套路:

  • 遍历模拟类:螺旋矩阵、杨辉三角、机器人扫地、旋转图像,核心是方向控制和状态更新。
  • 双指针类:数组去重、三数之和、接雨水(双指针版),核心是左右指针的移动策略。
  • 滑动窗口类:最长无重复子串、最小覆盖子串、长度最小的子数组,核心是窗口边界维护。
  • 前缀和类:区域检索、连续子数组和、树状数组相关题,核心是预处理累计信息。
  • 筛选重组类:按条件筛选数组元素并重新组织,比如“提取数组对象的一部分字段”,这类在真实业务和JS编程里用得最多。

螺旋矩阵属于遍历模拟类的典型代表。它用到的四个指针,本质上就是双指针思路在二维场景下的扩展:一维用两个指针维护区间,二维用四个指针维护矩形区域。理解了这层关系,热词里“指针数组”、“二维数组指针”这些概念就串起来了——它们描述的都是用指针/索引管理数组边界这件事。

至于“数组去重”“数组排序”这类题,它们和螺旋矩阵的共通点是都在“原地操作+索引管理”这个框架下,但具体思维模型不同。我刷题时会把题目先归类再动手,而不是拿到题就硬写。这个习惯让我在LeetCode周赛和模拟面试里都能更快定位解法方向。

5. 面试与刷题实战建议

5.1 手写这题的时间与节奏控制

螺旋矩阵在面试里经常作为手写题出现,因为它的题面短、不需要额外知识点、又足够考验代码功底。我建议的节奏是这样:先花1到2分钟确认题目细节,比如n的范围、是否要求原地操作;然后花30秒到1分钟说思路,跟面试官讲清楚“用四个边界指针维护当前环,每走完一条边就收缩对应的边界,while循环条件是count <= n*n”;最后用8到12分钟写代码,剩下几分钟自查。

说思路这一步非常关键。很多候选人拿到题就低头写,写到一半发现边界处理不过来,又停下来想,面试官看着会扣分。而先讲思路有两个好处:第一,强迫自己在动手前把方案想完整;第二,即使写的时候出bug,面试官也知道你已经掌握了核心方法,评分会宽容很多。

变量命名也值得注意。有人习惯用a、b、c、d表示四条边界,省几个字符却让代码可读性骤降。我建议直接用top、bottom、left、right,面试官扫一眼就能跟上逻辑。这属于代码风格的细节,但在手写环节里很加分。

5.2 刷题到内化的复盘方法

这道题入选了LeetCode热门100题,也是很多“必刷清单”里的常客。但刷过不等于掌握,我自己见过不少人能背出正确答案,换个n值手动推演就不行了。

我复盘这道题的方法是脱离编译器,拿纸笔手动推演n=5的完整填充过程,每填一步更新一次四个指针的值。推演完后再默写一遍代码,不看任何参考答案。第一次默写大概率会在某个for循环的起点上卡壳,比如记不清右边是从top还是top+1开始,这时候再去对照代码,印象会深刻得多。过两周再默写一次,能写出来才算真正掌握。

还可以做一些趣味改造来巩固记忆:比如用这个算法在终端里螺旋打印一个矩阵,或者用canvas把数字换成彩色方块,生成一张螺旋渐变的图片。把算法和可视化结合起来之后,你对边界收缩的感知会从“记代码”变成“理解运动过程”,这也是我后来能在变体题上快速迁移的原因。

最后分享一点实操体会

这道题刷完有一段时间后,我在处理图像处理里的区域扫描时,发现完全一样的“从外圈向中心收缩”的思路被用在了真实项目里。那一刻才真正体会到,LeetCode的很多题训练的不是“背题能力”,而是识别规律、抽象模型的能力。螺旋矩阵的边界收缩模型看似简单,却能迁移到不少实际场景。所以如果你正在刷这题,别急着过掉,花点时间把四个指针每一步的变化画一遍,把“为什么右边从top+1开始”彻底想通,比急着刷完一百题更有价值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 3:24:23

基于Qt和C++的植物大战僵尸课程设计源码解析与避坑指南

简介&#xff1a;这是一份基于Qt与C框架编写的简易植物大战僵尸游戏课程设计源码&#xff0c;面向计算机相关专业的在校学生、教师及企业员工&#xff0c;可直接用于课程设计、毕业设计或项目初期演示&#xff0c;也适合入门者学习Qt游戏开发。压缩包共37个文件&#xff0c;包含…

作者头像 李华
网站建设 2026/10/1 3:24:22

医疗视角学Linux命令:从症状到排查的实战手册

很多人都问过我同一个问题&#xff1a;Linux 命令那么多&#xff0c;到底该怎么记&#xff1f;我的回答通常是——先别急着背&#xff0c;你试着把面前那台服务器当成一位等着看病的患者&#xff0c;命令就是你的听诊器、血常规报告单和手术刀。今天这篇是“医疗视角下的 Linux…

作者头像 李华
网站建设 2026/10/1 3:24:15

SpringBoot+Vue在线考试系统:架构设计、组卷逻辑与部署排坑全解析

从培训机构到企业内部&#xff0c;在线考试早就是刚需了&#xff0c;但真正把一套在线考试系统从零搭到能上线用&#xff0c;中间的细节远比想象中多。这套基于SpringBootVueMyBatisMySQL的在线考试系统源码&#xff0c;不是一个demo级别的玩具&#xff0c;而是一套贴近真实业务…

作者头像 李华
网站建设 2026/10/1 3:24:14

1702张西瓜照片训练目标检测模型:VOC格式与YOLO实战解析

简介&#xff1a;面向目标检测入门与实战的Pascal VOC格式数据集&#xff0c;包含1702张西瓜图片及对应XML标注文件&#xff0c;是一个干净、规范的单一类别检测样本集。数据集仅保留jpg图像与xml标注&#xff0c;不包含分割路径和YOLO格式内容&#xff0c;共3405个文件&#x…

作者头像 李华
网站建设 2026/10/1 3:23:23

TSN与反射内存融合:硬实时与高带宽兼得的工业通信方案

一提起工业实时通信&#xff0c;很多人第一反应就是反射内存卡那一套专有方案&#xff1b;这几年TSN&#xff08;时间敏感网络&#xff09;热度很高&#xff0c;带宽高、标准开放&#xff0c;但要说硬实时和微秒级延迟&#xff0c;它又有点“绷不住”。于是“TSN与反射内存的融…

作者头像 李华
网站建设 2026/10/1 3:22:54

Agent开发与Agent算法:分水岭、能力栈与实操路径全解析

1. 分水岭到底分的是什么&#xff1a;Agent 开发与 Agent 算法的本质差异先把结论摆在最前面&#xff1a;Agent 开发和 Agent 算法&#xff0c;是两条完全不同的职业路径&#xff0c;混在一起学&#xff0c;大概率两头都抓不住。我见过太多人一上来就问“Agent 怎么学”&#x…

作者头像 李华