1. 题目理解与思路选择
先聊清楚这道题到底在做什么。LeetCode 885 "Spiral Matrix III 螺旋矩阵 III",属于典型的模拟题,面试中出现的频率不算特别高,但一旦出现,很容易让人卡住。因为它的遍历起点不在矩阵的左上角,而是矩阵内部的任意一点,甚至可能在矩阵外(虽然原题保证了起点的行列坐标一定在矩阵范围内)。这就意味着你没法用常规的"按圈收缩边界"或者"按层遍历"的思路去处理,老老实实模拟每一步行走,反而是最直接、最不容易出错的方案。
题目的实际要求是这样的:给定 rows 和 cols 表示矩阵的行数和列数,再给定 (rStart, cStart) 作为起始坐标,要求从起点出发,按照顺时针螺旋的顺序走遍矩阵中所有格子,返回按访问顺序排列的所有格子坐标。注意,螺旋路径本身是无限延伸的,但我们只把落在矩阵范围内的坐标收集起来,直到收集满 rows * cols 个坐标为止。
我最初做这道题的时候,第一反应是"能不能像 LeetCode 54 螺旋矩阵那样,用上下左右四个边界不断收缩来处理"。但仔细一想就发现这条路走不通:常规螺旋遍历要求从左上角开始,把矩阵一层层剥开,边界收缩的逻辑非常清晰;但这里起点是任意的,如果还按照"先走最外圈、再走内圈"的思路,你会发现根本没法定义"第一圈"的边界在哪里。起点在中间,螺旋的圈是一层一层向外扩的,这和从外向内剥是完全相反的遍历逻辑。
所以这道题的核心思路只有一个:模拟行走。不要试图找什么数学公式直接计算每个坐标,而是老老实实按照螺旋的路线一步步走,每走一步判断当前位置是否在矩阵内,如果在就记录,走满全部格子就停止。好在这种模拟的规律性很强,方向变化和步长变化都有一套固定的节奏,代码写起来并不复杂。
这道题适合谁来练?我觉得有三类人值得做一遍:第一类是刚开始刷 LeetCode 数组模拟类题目的新手,这道题能帮你加深对方向数组的理解;第二类是准备面试、想在白板上快速写出无 Bug 代码的同学,这道题的边界条件非常典型,很容易暴露粗心的问题;第三类是已经刷过 54、59 题,想系统总结螺旋遍历模板的选手,做这道题能把整个螺旋遍历的体系串起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 螺旋规律拆解与核心原理
2.1 步长的变化规律
模拟行走的第一件事,就是搞清楚螺旋的每一步到底该怎么走。从起点出发,按照顺时针方向,行走的顺序是:向东走 1 步,向南走 1 步,向西走 2 步,向北走 2 步,再向东走 3 步,向南走 3 步,向西走 4 步,向北走 4 步……以此类推。
注意这里的关键规律:方向按照"东南西北"的顺序循环变化,步长则是每走两个方向增加 1。也就是说,1 步使用两次(东、南),2 步使用两次(西、北),3 步使用两次(东、南),4 步使用两次(西、北)。为什么是这个规律?因为标准的顺时针螺旋线,每转半圈(即两个方向),半径就会扩大 1。你可以想象一只蜗牛顺时针爬行,每转 180 度,离起点的距离就会增加一格。
我第一次写的时候差点搞错,下意识以为"每走一个方向就步长加 1",结果螺旋半径涨得太快,路径全乱了。所以要刻在脑子里:步长是每经过两个方向才加 1,而不是每个方向加 1。在代码实现里,最优雅的处理方式是外层循环控制步长,中间层循环让每个步长走两个方向,内层循环真正走步。
还有一个容易困惑的点:起始方向到底是哪个方向?题目要求顺时针螺旋,很多教程默认从"向右"开始。这其实和螺旋的定义有关。当你从任意一点出发画一个顺时针向外扩张的螺旋时,第一条边一定是向右的。这一点不用死记,你可以自己在纸上画一画:从中心点出发,如果先向右走 1 步,再向下走 1 步,再向左走 2 步,再向上走 2 步,画出来就是一个标准的顺时针外扩螺旋。如果你从向下开始,画出来虽然也是螺旋,但整体旋转了 90 度,遍历顺序就完全不同了。题目要求的是固定顺序,所以方向数组的排列必须是"右、下、左、上"。
2.2 方向数组的设计
方向数组是这类题的核心工具。所谓方向数组,就是用两个数组分别存储行坐标和列坐标的偏移量,配合一个方向索引 dir 来表示当前前进的方向。对于"右、下、左、上"四个方向,行偏移分别是 0, 1, 0, -1,列偏移分别是 1, 0, -1, 0。组合起来看:
- 右:行不变,列 +1
- 下:行 +1,列不变
- 左:行不变,列 -1
- 上:行 -1,列不变
每次需要转向时,执行 dir = (dir + 1) % 4 就能循环切换方向。这是所有方向类模拟题共同的基础,不仅 LeetCode 885 用得到,54 题、59 题,甚至机器人模拟、贪吃蛇类的题目都会用到。建议把这四行偏移量当成"肌肉记忆"来写,不要每次现推。
很多初学者写方向数组时会写成 dr = {1, 0, -1, 0}、dc = {0, 1, 0, -1},这是"下、右、上、左"的顺序。这个顺序本身没错,但对应的是逆时针从下开始的螺旋,和本题要求的顺时针顺序完全不同。写代码前一定先确定方向顺序,再写偏移量,否则调半天都查不出问题。
2.3 为什么任意起点都会覆盖所有格子
一个直觉上的疑问是:如果起点不在矩阵中心,螺旋向外扩散的路径会不会漏掉某些格子?答案是不会,但原因值得说清楚。
螺旋路径是无限延伸的,随着步长不断增加,它覆盖的半径范围越来越大。而矩阵是一个有限的矩形区域,只要螺旋的半径足够大,就能把整个矩形都覆盖在螺旋路径的范围内。哪怕起点在矩阵的左下角,螺旋需要向外绕很多圈才能覆盖到右上角,但最终一定会覆盖到。把矩阵想象成一个靶子,螺旋线就像一支从任意位置开始画圈的笔,虽然前几圈可能歪在靶子外面,但圈越画越大,总会把整个靶子圈进去。
代码实现上也印证了这一点:我们从起点出发,每走一步就判断当前坐标是否在矩阵范围内。在矩阵外的点直接跳过不记录,但不影响继续走。当收集到的坐标数量等于矩阵总格子数时,说明已经全部覆盖,循环结束。这个思路无论起点在哪、矩阵是长方形还是正方形,都能正确处理。
3. 完整代码实现与逐段解析
3.1 C++ 实现
cpp复制class Solution {
public:
vector<vector<int>> spiralMatrixIII(int rows, int cols, int rStart, int cStart) {
vector<vector<int>> result;
int total = rows * cols;
// 方向:右、下、左、上(顺时针)
int dr[4] = {0, 1, 0, -1};
int dc[4] = {1, 0, -1, 0};
int r = rStart, c = cStart;
result.push_back({r, c}); // 起点一定在矩阵内,先收集
if (result.size() == total) return result;
int step = 1; // 当前步长
int dir = 0; // 当前方向索引
while (result.size() < total) {
// 每个步长连续走两个方向
for (int i = 0; i < 2; ++i) {
// 沿当前方向走 step 步
for (int j = 0; j < step; ++j) {
r += dr[dir];
c += dc[dir];
// 判断是否在矩阵内
if (r >= 0 && r < rows && c >= 0 && c < cols) {
result.push_back({r, c});
}
}
// 转向:右->下->左->上
dir = (dir + 1) % 4;
}
// 每走完两个方向,步长加 1
++step;
}
return result;
}
};
3.2 Python 实现
python复制class Solution:
def spiralMatrixIII(self, rows: int, cols: int, rStart: int, cStart: int) -> List[List[int]]:
result = [[rStart, cStart]]
total = rows * cols
if total == 1:
return result
# 方向:右、下、左、上
dr = [0, 1, 0, -1]
dc = [1, 0, -1, 0]
r, c = rStart, cStart
step = 1
dir_idx = 0
while len(result) < total:
for _ in range(2):
for _ in range(step):
r += dr[dir_idx]
c += dc[dir_idx]
if 0 <= r < rows and 0 <= c < cols:
result.append([r, c])
dir_idx = (dir_idx + 1) % 4
step += 1
return result
3.3 关键代码点解析
先说第一步为什么要把起点单独加入结果集。因为题目保证起点的坐标一定在矩阵范围内,而我们的循环逻辑是"先走再判断",如果把起点也放进循环里统一处理,需要在每次移动前先判断当前位置,逻辑会变得绕。直接先把起点收集进去,再进入循环,更简单直接。当然,如果你希望循环能够统一处理,也可以把判断放在移动之前,但那样就需要处理"初始状态"和"移动后状态"两套逻辑,代码会更啰嗦。我建议的写法是:结果集中始终保存已经访问过的坐标,循环条件用 result.size() < total 判断是否收集完毕。
第二个关键是步长的双层循环设计。外层 for (int i = 0; i < 2; ++i) 保证每个步长使用两次,每次走完一个方向后就转向。步长什么时候自增?必须是在两个方向都走完之后再 ++step。这个顺序一旦颠倒,螺旋的形状就会塌掉。
第三个关键点是边界检查。因为螺旋会频繁走到矩阵外面,所以每次移动后都要判断 r 和 c 是否在合法范围内。这里用 r >= 0 && r < rows && c >= 0 && c < cols 四个条件同时成立才收集结果。注意不要写成 r <= rows,这是新手最容易犯的边界错误,等于把最后一行和最后一列全都漏掉了。
4. 复杂度、边界情况与常见 Bug 排查
4.1 时间复杂度与空间复杂度
时间复杂度方面,很多同学会误以为代码里套了三层循环,复杂度一定很高。实际上三层循环对应的是"步长 × 方向数 × 每一步",总的行走步数取决于螺旋要扩大到多大才能覆盖整个矩阵。具体来说,螺旋的扩张半径需要达到矩阵中心到最远角落的距离级别,而这个距离是 O(max(rows, cols)) 的量级,所以螺旋走过的总路径长度是 O(max(rows, cols)^2) 量级。在 rows 和 cols 都不超过 100 的题目约束下,这个复杂度完全没问题,甚至可以说是最优的——因为每个矩阵格子至少要被检查一次,而检查的次数和行走步数成正比。
空间复杂度是 O(rows * cols),这个很好理解,结果数组本身就要存储矩阵中的所有坐标,这部分空间是无法避免的。除了结果数组,我们只用到了常数级别的额外空间,所以整体空间复杂度就是 O(rows * cols)。
4.2 最容易踩的 4 个坑
坑一:方向顺序写错。 方向数组的顺序必须是"右、下、左、上",对应偏移量 {0,1}, {1,0}, {0,-1}, {-1,0}。如果你顺手写成了"下、右、上、左",走的就不是顺时针而是逆时针,最终收集到的坐标顺序会和标准答案不一致。这个 Bug 非常隐蔽,因为只看代码很难看出问题,必须手动模拟几个步骤才能发现。
坑二:步长递增时机错误。 步长必须每两个方向递增一次。很多人写着写着就变成 for (int i = 0; i < 4; ++i) { ... step++; },也就是每个方向都加步长。这样会导致螺旋半径增长过快,像是跳着走而不是连续画圈,最终会漏掉大量格子。我调试这类问题时最喜欢的办法是:用小矩阵(比如 3×3)手动模拟,在纸上画出实际路径,马上就能看出螺旋变形了。
坑三:边界判断漏写等号。 判断坐标是否在矩阵内时,必须用 r < rows 而不是 r <= rows。数组下标从 0 开始,最后一行的下标是 rows - 1。少了等号会漏掉最后一行和最后一列;多了等号会数组越界或错误收集。建议把边界判断统一成 0 <= r && r < rows && 0 <= c && c < cols 这个模板,不要每次现想。
坑四:忘记处理 total == 1 的情况。 如果矩阵只有一个格子 rows = 1, cols = 1,起点就是唯一的格子。直接返回起点即可,不用进入循环。虽然代码中 result.size() == total 的判断能兜住这个情况,但有些同学会把起点初始化和这个判断写漏,导致死循环。我建议在刚拿到 total 之后就加一行 if (total == 1) return result;,逻辑更清晰。
4.3 一个扩展:通用螺旋遍历模板
做完这道题,强烈建议把螺旋遍历的通用模板总结出来。这类题的核心就三件事:方向数组、边界判断、循环条件。不同题目的差异只在于"如何定义边界"和"何时停止"。
- LeetCode 54:给定完整矩阵,按顺时针螺旋输出所有元素。可以用四个边界变量
top/bottom/left/right逐步收缩,每遍历完一条边就更新对应边界,当边界交叉时停止。 - LeetCode 59:给定 n,生成一个 n×n 的螺旋矩阵。思路和 54 题完全一样,只不过一个是读,一个是写。
- LeetCode 2326:螺旋矩阵 IV,用链表的值填充螺旋矩阵。本质还是 59 题的框架,多维护一个链表指针。
而 885 题因为起点任意,没法用边界收缩法,只能用方向数组模拟。所以这几道题放在一起看,其实是一个完整的螺旋遍历体系:固定起点用边界收缩,任意起点用方向模拟。把这两种手法都练熟了,遇到任何螺旋矩阵变种题都能在五分钟内写出正确代码。
5. 相关题目与刷题建议
5.1 同类题型对比分析
为了帮你更好地理解 885 题在整个螺旋矩阵考点中的位置,我用一个表格来对比几道核心题目:
| 题目 | 起点 | 遍历方式 | 核心技巧 |
|---|---|---|---|
| LeetCode 54 螺旋矩阵 | 左上角固定 | 由外向内逐层遍历 | 四边界收缩 |
| LeetCode 59 螺旋矩阵 II | 左上角固定 | 由外向内填充 | 四边界收缩 + 方向数组 |
| LeetCode 885 螺旋矩阵 III | 矩阵内任意点 | 由内向外螺旋行走 | 方向数组 + 步长控制 |
| LeetCode 2326 螺旋矩阵 IV | 左上角固定 | 由外向内填充 | 边界收缩 + 链表遍历 |
表格能看得很清楚:54、59、2326 都是固定起点,可以用边界收缩法;只有 885 是任意起点,必须用方向数组模拟。掌握了 885 的做法,相当于补齐了螺旋遍历的另一块拼图——从内向外扩的模拟。以后再遇到类似"从某个点出发按某种轨迹遍历"的题目,你就有经验了。
我记得自己刷题那会儿,一开始对方向数组特别不敏感,每次都要在纸上画半天。后来总结出一套练习方法:先不急着做题,把"右、下、左、上"四个方向的偏移量默写十遍,再把"步长每两个方向加一"这个规律默写十遍,然后去 LeetCode 上找 885、54、59 三道题连刷,刷完基本上就形成条件反射了。这个方法也分享给你们。
5.2 一题多解与优化空间
说实话,这道题想不出比模拟行走更简洁的算法。你可能会想:能不能用公式直接算出第 k 个访问的坐标?理论上可以,因为螺旋路径的坐标可以写成关于步数的分段函数,但分段逻辑非常复杂,写出来完全是脱离实际的炫技,面试官也不一定想听。工程上,模拟行走是这类题最稳妥的做法,代码可读性高,Bug 率低,调试方便。
如果一定要说优化空间,那就是省掉一部分不必要的坐标检查。比如我们提前知道矩阵的最小行、最大行、最小列、最大列,当螺旋的行坐标完全超出这个范围很远时,可以判断这段时间内不会收集到任何坐标,从而跳过整段循环。但这种优化的前提是你的步长已经足够大,实际场景中收益有限,反而增加了代码复杂度,我建议不需要做这个优化。保持代码简洁,才是面试时最重要的。
另外一个小细节:在 C++ 实现中,result.push_back({r, c}) 在 LeetCode 的编译环境下可以正常工作;如果你更习惯显式一点,也可以写成 result.push_back(vector<int>{r, c}),效果一样。Python 的实现则要注意 List[List[int]] 的类型标注,LeetCode 的环境已经自动导入了 List,直接使用即可。
6. 个人实操体会
这道题刷完之后给我最大的感受是:方向数组这种东西,真的是用一次记一辈子。以前看别人写的题解,觉得方向数组不过是个技巧,好像懂了又好像没懂。直到自己亲手把步长规律搞错、把方向顺序写反,调试了将近一个小时才意识到问题出在哪,那一刻才真正把"方向数组 + 步长控制"这套组合拳理解透了。
做模拟题最忌讳的就是"觉得差不多就写"。像 885 这种题,代码量不大,但每一步都有讲究:起点单独处理、步长两方向一增、边界判断用严格小于、循环条件用收集数量判断。这些细节任何一个出错,结果都是错的,而且不容易一眼看出来。
最后分享一个小技巧:遇到这种带方向变化的模拟题,强烈建议在纸上画一个 4×4 的小矩阵,手动走一遍螺旋路径,把你画出来的坐标和代码跑出来的结果做对比。只要这一步对了,代码基本就稳了。不要嫌麻烦,LeetCode 这种模拟题,画图比纯想代码快得多。
