1. 题目说了什么
LeetCode 885 这道题,我最初刷到的时候愣了一下,因为它的名字叫 Spiral Matrix III,但和前面两道螺旋矩阵的玩法完全不同。前两题是给你一个完整矩阵,让你把它按螺旋顺序“读”出来;这一题是反过来——给你矩阵的行列数 rows 和 cols,再给你一个起始坐标 (rStart, cStart),然后模拟一个人站在这个位置,按顺时针螺旋的方式走,每经过一个格子就记下坐标,直到把整个矩阵的所有格子都走完。
这个题最反直觉的地方在于:矩阵外的区域也要“走”,只是不记录。因为螺旋线是连续的,从矩阵内部出发后,会不断向外扩张绕圈,中途必然路过矩阵外的空白区域,但只有落在矩阵范围内的坐标才被收入答案。换句话说,你要模拟的是一个不存在的“无限矩阵”上的螺旋路径,然后把落在给定矩形内的点摘出来。
适合谁来看这道题?如果你刚刷完数组、矩阵类的模拟题,或者正在准备面试中常见的“方向行走”类题目,这道题是很好的进阶训练。它不像 BFS/DFS 那样需要复杂数据结构,核心就是“规律 + 模拟”,但非常考验你对循环边界和方向控制的敏感度,写不好就容易出现索引越界、死循环、重复添加这一类问题。
我个人刷题时有个习惯:遇到模拟题,一定先动手画一遍小矩阵,把行走轨迹画出来,再抽象成代码。这道题尤其适合这么干,因为它的步长规律非常规整,画一遍基本就通透了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路拆解
2.1 为什么不能用传统螺旋矩阵的“边界收缩”法
做过第 54 题 Spiral Matrix 的同学应该很熟悉“边界收缩”的思路:维护上、下、左、右四个边界,每次沿着边界走,走完一圈就把某条边界往里缩一格,直到上边界大于下边界或左边界大于右边界。
但这个思路套到 885 题上会很别扭。原因是第 54 题的螺旋中心一定在矩阵内部,且矩阵本身是完整的矩形;而 885 题的起点 (rStart, cStart) 可能在矩阵内任意位置,螺旋的“中心”并不是矩阵中心,甚至起点可能在矩阵边缘附近。如果你强行用边界收缩法,你会发现“当前这一圈”和矩阵的实际边界完全对不上,收缩逻辑很难写。
更关键的一点:885 题要求我们在矩阵外部也继续走,但边界收缩法的前提是“当前圈完全在矩阵内”,一旦走到矩阵外的区域,这个前提就被打破了。所以不用惦记边界收缩,直接换思路。
2.2 抓住螺旋行走的步长规律
仔细看螺旋路径的步长变化:从起点出发,先向右走 1 步,然后转向向下走 1 步,再向左走 2 步,向上走 2 步,向右走 3 步,向下走 3 步……规律非常清晰:
- 方向顺序固定为:右、下、左、上,四个方向循环。
- 步长是 1、1、2、2、3、3、4、4……也就是每个步长连续使用两次后,步长加 1。
为什么要这样?因为螺旋是一圈一圈向外扩的:第一圈从中心出发,走到右、下时各走 1 步;然后向左、上各走 2 步回到中心附近的上方,接着向右走 3 步、向下走 3 步,就完成了第二圈……每绕一圈,半径增加 1,所以步长会在每两个方向后增加 1。
这个规律是整个算法的基石。你不需要关心当前走到了第几圈,只需要维护一个变量 step,表示当前这一段的步长;再用一个变量 count,记录当前步长已经使用了几次。每走完一段(即 step 步之后),count 加 1;当 count 等于 2 时,step 加 1,count 清零,同时方向切换到下一个。
坐标更新就更直接了:用方向数组 dr = [0, 1, 0, -1] 和 dc = [1, 0, -1, 0],分别对应右、下、左、上。每一小步执行 r += dr[dirIndex]; c += dc[dirIndex],然后检查 (r, c) 是否在行号 0 到 rows-1、列号 0 到 cols-1 的范围内,如果在,就加入答案。
2.3 终止条件与复杂度分析
因为矩阵的格子总数固定是 rows * cols,所以终止条件是答案数组长度达到 rows * cols。这个条件必须放在最前面判断,避免多做无效移动。
时间复杂度方面,很多人会误以为只走 rows * cols 步就够了,但实际上,螺旋路径扩展到能覆盖整个矩阵时,需要走的步数取决于矩阵的外接半径。最坏情况下,比如起点在矩阵角落,螺旋需要向外扩展 O(max(rows, cols)) 这么远,总移动步数大约是 O((max(rows, cols))^2)。每个点只做常数次判断和添加,所以时间复杂度是 O((max(rows, cols))^2)。
空间复杂度呢?除了答案数组本身占用 O(rows * cols) 空间外,只用了常数个变量,所以额外空间是 O(1)。有些同学会担心“模拟太多步会不会超时”,实际上 max(rows, cols) 一般在题目范围内不会太大,这个平方复杂度完全能承受。
3. 实操过程与代码实现
3.1 先画图,再写代码
我的习惯是拿到这类题先手工推演一遍。假设 rows = 5,cols = 6,起点在 (1, 4)。也就是说,矩阵是一个 5 行 6 列的表格,起始位置在第二行第五列。我们按步长规律走一遍:
- 向右走 1 步:从 (1,4) 到 (1,5)。(1,5) 在矩阵内,记录。
- 向下走 1 步:从 (1,5) 到 (2,5)。(2,5) 在矩阵内,记录。
- 向左走 2 步:先到 (2,4),再到 (2,3)。两个都在矩阵内,记录。
- 向上走 2 步:先到 (1,3),再到 (0,3)。都在矩阵内,记录。
- 向右走 3 步:先到 (0,4),再到 (0,5),接着到 (0,6)。注意 (0,6) 列号 6 已经超出 cols-1 = 5,不在矩阵内,跳过不记录,但位置仍然有效,下一步继续从 (0,6) 出发。
- 向下走 3 步:从 (0,6) 到 (1,6),再到 (2,6),再到 (3,6)。这里 (1,6)、(2,6)、(3,6) 的列号都越界了,全部不记录,但继续走。
- 向左走 4 步:从 (3,6) 到 (3,5),这是合法的,之后 (3,4)、(3,3)、(3,2) 也都是合法的……
这个推演用不了两分钟,但能让你对“出界但继续走”这件事有深刻体会。你会发现,很多格子是经过好几圈之后才被记录的,甚至有些坐标要走完一整圈才落到矩阵里。
所以我强烈建议你,在写代码前,先拿一个小矩阵把路径画出来,或者至少画前二十个点。这个步骤能帮你省下大量调试时间。
3.2 C++ 实现
cpp复制class Solution {
public:
vector<vector<int>> spiralMatrixIII(int rows, int cols, int rStart, int cStart) {
vector<vector<int>> res;
res.push_back({rStart, cStart});
// 方向: 右, 下, 左, 上
vector<int> dr = {0, 1, 0, -1};
vector<int> dc = {1, 0, -1, 0};
int total = rows * cols;
int r = rStart;
int c = cStart;
int dirIndex = 0; // 当前方向,从右开始
int step = 1; // 当前步长
int count = 0; // 当前步长已使用次数
while (res.size() < total) {
for (int i = 0; i < step; i++) {
r += dr[dirIndex];
c += dc[dirIndex];
if (r >= 0 && r < rows && c >= 0 && c < cols) {
res.push_back({r, c});
}
}
dirIndex = (dirIndex + 1) % 4;
count++;
if (count == 2) {
step++;
count = 0;
}
}
return res;
}
};
这里有几个细节要说清楚。第一个是步长的更新时机:每次走完一段(for 循环结束)后切换方向,然后 count 加 1,只有当 count 等于 2 时才把 step 加 1。这样正好对应“1,1,2,2,3,3……”的规律。第二个是方向的切换,用模 4 循环,保证四个方向来回转。
注意,我把起点直接放进了 res,因为起点一定在矩阵内,题目的约束保证了 rStart 在 [0, rows-1]、cStart 在 [0, cols-1] 之间。这个初始化可以避免 while 循环里重复判断起点。
3.3 Python 简洁版
如果你用 Python 刷题,可以写得更简洁一些:
python复制class Solution:
def spiralMatrixIII(self, rows: int, cols: int, rStart: int, cStart: int) -> List[List[int]]:
res = [[rStart, cStart]]
dr = [0, 1, 0, -1]
dc = [1, 0, -1, 0]
r, c = rStart, cStart
total = rows * cols
step = 1
count = 0
dir_index = 0
while len(res) < total:
for _ in range(step):
r += dr[dir_index]
c += dc[dir_index]
if 0 <= r < rows and 0 <= c < cols:
res.append([r, c])
dir_index = (dir_index + 1) % 4
count += 1
if count == 2:
step += 1
count = 0
return res
Python 版和 C++ 版逻辑完全一致,只是去掉了类型声明的冗余。这里要注意 Python 的边界比较可以连续写,比如 0 <= r < rows 就是合法的,别写成 0 <= r and r < rows,虽然也对,但不简洁。
3.4 一个隐藏的计算过程
为什么步长变化要“连续两个方向后加 1”,而不是“每走一步加 1”?我们可以从圈数的角度来理解。
把起点看作圆心,第一圈:向右走 1、向下走 1,这只是“半圈”,要回到起点上方,还得向左走 2、向上走 2,这样才算完整一圈。也就是说,从第 1 步到第 6 步,分别走了 1、1、2、2,这时形成了围绕起点的第一层“环”。接下来从第 7 步开始向右走 3,才进入第二层环。
如果你把 step 的更新时机改成“每个方向走完就加 1”,那么步长序列会变成 1、2、3、4……这就不是螺旋,而是一个不断扩大的正方形路径,每个边都比上一边长 1,会明显偏离螺旋的轨迹。所以我们才需要每隔两个方向才增加一次步长,保证每一圈的每一侧都有对称的边长。
这个细节在面试时很容易被追问,如果你能直接说出“这个规律来自每圈闭合时半径加 1,所以同一圈的两个相邻方向共享同一个步长”,会显得理解很透。
4. 常见问题与排查技巧
4.1 结果缺少格子,或者顺序错乱
这是最典型的报错信号。出现这种情况,先别急着查边界,而是要先看步长是否更新错了。
我用调试工具打印过很多次路径,发现最常见的错误是:在 for 循环里就把 count 加了 1,导致 step 过早增加。比如有人会把代码写成:
cpp复制for (int i = 0; i < step; i++) {
// 走一步
count++;
if (count == 2) { step++; count = 0; }
}
这样写,第一段还没走完,步长就可能已经变成 2 了,螺旋形状直接乱掉。步长的更新必须放在当前整段走完之后,而不是每一步都检查。
排查方法也简单:打印每一步之后的 (r, c) 和当前 step、dirIndex,和手动画的路径对比。如果步长序列变成 1,2,3,4……那一定就是更新时机错了。
4.2 索引越界却忘了跳过
我在初写这道题时犯过一个低级错误:只检查了行索引 r 是否在范围内,忘了检查列索引 c。结果在 (0,6) 这种坐标上,r 合法但 c 越界,仍然被加进了答案,导致结果里出现非法坐标。
这个问题的本质是边界检查不完整。必须同时满足 r >= 0 && r < rows && c >= 0 && c < cols 四个条件,才能把当前坐标加入结果。用 C++ 写的时候,别把 && 写成 ||,这种笔误在紧张的时候很容易发生。
还有个隐蔽的坑:如果起点本身就在矩阵外,题目其实保证了不会出现这种情况,但如果你想扩展成通用算法,需要额外处理。不过 LeetCode 的原题一定有解,所以不担心这个问题。
4.3 死循环或运行超时
如果你发现代码跑不完,很可能是终止条件没写对。很多人写 while 循环时习惯用 while (true),然后在循环内部判断 res.size() == total 再 break。这样也能跑通,但多了一层分支,不够优雅。
还有一小部分情况是方向数组的模运算写错了,导致方向永远不变,人一直朝一个方向走,永远到不了 total 个格子。我建议把 dirIndex = (dirIndex + 1) % 4 固定写在每段路径结束后,不要放到 if 分支里,减少出错概率。
另外,total 的计算一定要用 rows * cols,小心别写成 rows + cols,这种手滑错误在面试高压下真的会出现。
4.4 一个可视化调试的小技巧
调试这种“方向行走”类题目,最有效的方式不是打印一堆原始坐标,而是把矩阵路径可视化。你可以在本地写一个辅助函数,把答案数组渲染成矩阵:
python复制def show(rows, cols, path):
grid = [['.' for _ in range(cols)] for _ in range(rows)]
for idx, (r, c) in enumerate(path):
grid[r][c] = str(idx % 10)
for row in grid:
print(' '.join(row))
把每位数字取个位数填进去,能直观看到螺旋的访问顺序是否正确。我第一次用这个函数的时候,立刻发现自己在某个方向上是逆时针在走,因为数字排列的绕向和预期相反,方向数组的初始值写错了。这种可视化工具比打印坐标直观得多,我现在刷所有矩阵类题目都会顺手写一个。
5. 如果题目变种,你还会写吗
5.1 逆时针螺旋
把方向数组的顺序从“右、下、左、上”改成“右、上、左、下”,也就是 dr 和 dc 的排列变化,同时步长规律不变,就完成了逆时针螺旋。这个变种在一些公司的笔试题里出现过,本质上是同一个思路。
有个小坑要注意:方向数组变了之后,draw 出来的路径绕向会改变,步长仍然要“连续两个方向后加 1”,因为这是由“每圈半径增加 1”决定的,和顺时针、逆时针无关。
5.2 只访问矩阵边界或某个子区域
如果题目改成“按螺旋顺序输出矩阵边界坐标”,那么你不能简单用上面的全量模拟,因为螺旋会经过内部点。这时候需要额外加一个过滤条件,比如要求坐标满足 r == 0 || r == rows-1 || c == 0 || c == cols-1 才加入结果。但这种方法效率不高,因为还是模拟了所有路径。更好的做法是直接用边界收缩法遍历边界。
这类变种题考的其实是你对算法灵活迁移的能力。我的建议是:先搞懂“步长 + 方向”这套模拟框架,再额外学一个“边界收缩”框架,两道题融会贯通后,遇到任何螺旋类题目都能快速定位解法。
5.3 和前面两道螺旋矩阵题的横向对比
第 54 题是“读矩阵”,第 59 题是“填矩阵”,第 885 题是“走矩阵”。这三道题的共同点是都考察二维坐标的行列变化,但核心策略完全不同:
- 第 54 题偏向“边界收缩”,每次走完一条边就收缩边界,适合处理完整矩形。
- 第 59 题也是“边界收缩”,但需要按顺序填数,和 54 题是镜像操作。
- 第 885 题的核心是“步长递增 + 出界继续走”,更接近模拟题。
我见过很多同学刷完 54 和 59 之后,看到 885 直接套边界收缩,结果越写越乱。所以我想专门强调一句:看到“螺旋”两个字,别急着用老方法,先判断起点是否在矩阵中心、是否需要考虑矩阵外区域。这就是刷题经验的价值所在——不是背题,而是建立对题目类型的敏感度。
拿这道题当跳板,你还能顺便练熟“方向数组 + 步长控制”这套模板。这套模板不光用于螺旋矩阵,像机器人行走、贪吃蛇、迷宫路径之类的模拟题,底层思路都是同一个东西。
6. 最后的实操体会
我刷这道题的时候踩过两个坑,第一个是把步长更新的位置写错了,导致结果里出现了重复坐标;第二个是列越界没检查,混进来一堆非法值。这两个错误用可视化工具一眼就能发现,但靠肉眼盯坐标真的费劲。
所以如果你想高效掌握这道题,我的建议是:先画图推演一遍小矩阵,再写代码,再用可视化工具验证。整个过程大概二十分钟,但效果比直接看题解强得多。这道题一旦理解透彻,对它背后的“步长 + 方向”模型会有肌肉记忆,以后遇到类似模拟题,写代码的速度会快很多。
最后再分享一个小技巧:写螺旋矩阵这类题目,可以把方向数组定义成全局变量或者类的成员变量,这样在多个函数里复用,避免反复复制。面试的时候,我还喜欢顺手注释一下 step 的更新规则,比如“每两个方向后步长加一,因为每圈半径增加 1”,这样面试官一眼就能看出你理解了这个规律,而不是背模板。
