印象很深刻,一次模拟面试对面直接给出LeetCode 59这道题:把数字1到 n² 顺时针螺旋填进一个 n x n 的二维数组。我看到题的第一反应是“这有什么难的,不就是一层层剥洋葱吗?”可真在共享编辑器里写起来,才意识到问题出在“剥完一层之后,下一层的起点和终点到底该怎么同步变化”。当时写得磕磕绊绊,四个 for 循环的区间差一点就全乱了。
面试结束后我专门把这道题吃透,发现它考察的其实是二维数组下标管理和循环边界的建模能力,非常能反映一个人写代码时有没有“先把坐标想清楚”的习惯。如果你正在刷数组相关的题目,或者面试前想快速过一遍二维矩阵的边界处理套路,这篇文章应该能帮你省下不少时间。我会把两种主流解法都拆开讲,并附上我实际写错、调错、补救的全过程。
1. 螺旋填数问题的本质:把“绕圈”翻译成坐标移动
1.1 题目在说什么
这道题的输入是一个正整数 n,要求返回一个 n x n 的矩阵,里面按顺时针螺旋顺序填入 1 到 n²。比如 n = 3 时,输出是:
code复制1 2 3
8 9 4
7 6 5
也就是说,数字从左上角出发,先向右走到头,再向下走到头,再向左走到头,再向上走到头,如此反复,直到把所有格子都填满。
这类题之所以被归到数组分类里,是因为它的载体是二维数组,而它考察的真正核心不是某个算法公式,而是下标控制。别看它看起来像模拟题,LeetCode 官方对它给出的标签就是“数组”“矩阵”“模拟”这类,刷题初期把它当成二维数组的边界练习题再合适不过。
1.2 难点不在算法,而在坐标习惯
如果只让你口述思路,几乎每个人都能说出来:从左到右、从上到下、从右到左、从下到上,四个方向循环。但一旦写成代码,很多人第一版都会遇到同一个问题——四个边的长度不是永远一样的。
外层是 n,往里缩一圈后,四条边的长度就变成 n-2,再往里一圈又继续缩。也就是说,我们需要一个能表达“当前圈层位置”的东西,可以是四个边界变量,也可以是一组方向和状态判断。
把思路翻译成坐标,通常有两套认知模型:
- 圈层模型:把矩阵看成一层一层的“回”字,每次处理最外面一圈,处理完就往里收缩。每圈拆成四条边,每条边用
for循环填一遍。 - 贪吃蛇模型:手里拿着一个坐标和一个方向,每一步先按当前方向前进一格,如果下一步会撞到边界或已经填过的格子,就把方向右转 90 度,再继续走。
这两种模型没有对错之分,只是对应不同的代码风格。后面我会分别用它们实现一遍,你就能明显感觉到差异。在此基础上,还需要注意一个容易被忽略的点:奇数阶矩阵最中间会剩下一个单独格子,偶数阶则不会。这个细节处理不好,输出的中间位置就容易重复赋值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:逐圈收缩,用四个区间端点控制边界
2.1 代码骨架
先看我个人最常用的实现思路:维护 top、bottom、left、right 四个变量,分别表示当前圈的上边、下边、左边、右边。每次填完一圈,四个变量分别向中心收缩一位。
cpp复制class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> ans(n, vector<int>(n, 0));
int top = 0, bottom = n - 1;
int left = 0, right = n - 1;
int num = 1;
while (top <= bottom && left <= right) {
// 1. 向右:填当前圈的上边
for (int j = left; j <= right; ++j) {
ans[top][j] = num++;
}
++top;
// 2. 向下:填当前圈的右边
for (int i = top; i <= bottom; ++i) {
ans[i][right] = num++;
}
--right;
// 防止只剩一行或一列时重复填充
if (top > bottom || left > right) {
break;
}
// 3. 向左:填当前圈的下边
for (int j = right; j >= left; --j) {
ans[bottom][j] = num++;
}
--bottom;
// 4. 向上:填当前圈的左边
for (int i = bottom; i >= top; --i) {
ans[i][left] = num++;
}
++left;
}
return ans;
}
};
这段代码的核心逻辑是“边填边缩”。
第一步做完,top 已经向下移了一行;第二步做完,right 已经向左移了一列。此时如果剩下的矩形只有一行或者只有一列,再执行第三步和第四步就会重复覆盖已经填过的格子,所以我在第三步之前加了一个退出判断。
2.2 以 n = 4 完整走一遍外圈
空跑一遍代码会更清楚。假设 n = 4,初始值 top = 0, bottom = 3, left = 0, right = 3。
第一步向右填上边,填的是第 0 行的第 0 到 3 列:
code复制1 2 3 4
0 0 0 0
0 0 0 0
0 0 0 0
填完后 top 变成 1。
第二步向下填右边,填的是第 1 到 3 行的第 3 列:
code复制1 2 3 4
0 0 0 5
0 0 0 6
0 0 0 7
填完后 right 变成 2。
第三步向左填下边,这里负责的是第 3 行的第 2 到 0 列:
code复制1 2 3 4
0 0 0 5
0 0 0 6
10 9 8 7
填完后 bottom 变成 2。
第四步向上填左边,填的是第 2 到 1 行的第 0 列:
code复制1 2 3 4
11 0 0 5
12 0 0 6
10 9 8 7
填完后 left 变成 1。第二圈开始前,四个边界分别是 top = 1, bottom = 2, left = 1, right = 2,正好对应到内圈的 2x2 矩阵。第二轮照样先向右,再向下,再向左,再向上,把 13 到 16 四个数填完。
2.3 奇数阶矩阵中心格的处理
如果你偷懒把第三步和第四步前面的 if 判断去掉,奇数阶矩阵就会出问题。以 n = 3 为例,第一圈结束后,内圈只剩中间一个格子:
code复制top = 1, bottom = 1, left = 1, right = 1
第二次进入 while 循环后:
第一步向上边赋值,把中心格填上 9,此时 top 变成 2。
第二步向下,由于 top = 2 大于 bottom = 1,循环体不会执行,但 right 还是会被 --,变成 0。
如果第三步没有 if 判断,它会继续执行向左填充,把 bottom 对应的格子再填一遍,数字直接变成 10,然后 bottom 变成 0。第四步向上时,又会因为边界错乱继续操作。
所以简单记一句话:当剩余结构退化成一行或一列时,第三边和第四边不需要处理。 这个特判不是可有可无的,是这类题最容易出错的地方。
3. 解法二:方向数组轮转,像贪吃蛇一样自动拐弯
3.1 核心代码
如果你觉得一圈圈收缩的边界变量太多,可以试试方向数组的写法。这种思路更贴合人类直觉:拿着笔在矩阵里走,遇到墙就右转。
cpp复制class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> ans(n, vector<int>(n, 0));
// 方向顺序:右 -> 下 -> 左 -> 上
int dx[4] = {0, 1, 0, -1};
int dy[4] = {1, 0, -1, 0};
int x = 0, y = 0;
int dir = 0;
for (int num = 1; num <= n * n; ++num) {
ans[x][y] = num;
// 预判下一步位置
int nx = x + dx[dir];
int ny = y + dy[dir];
// 如果下一步越界或已经填过,就转向
if (nx < 0 || nx >= n || ny < 0 || ny >= n
|| ans[nx][ny] != 0) {
dir = (dir + 1) % 4;
nx = x + dx[dir];
ny = y + dy[dir];
}
// 真正走过去
x = nx;
y = ny;
}
return ans;
}
};
这里的巧妙之处在于,二维数组初始化时默认值全是 0,而我们要填的数字从 1 开始。所以“格子是否已经填过”可以不加额外的 visited 数组,直接判断 ans[nx][ny] != 0 就够了。
3.2 为什么方向顺序必须是“右下左上”
方向数组的顺序不是乱写的,它要对应顺时针转向。我们定义方向为:
| 方向 | dx | dy |
|---|---|---|
| 右 | 0 | 1 |
| 下 | 1 | 0 |
| 左 | 0 | -1 |
| 上 | -1 | 0 |
每次转弯时执行 dir = (dir + 1) % 4,就能从右转到下、再从左转到上,始终保持顺时针。这个顺序一旦写反,比如先右再上,填充出来的形状会变成反向绕圈,甚至在某些场景下提前卡死。
在代码里,我每次不是直接移动,而是先算出下一个位置。如果下一步会撞到边界或者已经填过的格子,就转向,用转向后的方向再算一次。这样一来,即使当前格子周围只有一个合法方向,代码也能正确落到那个格子上。
3.3 方向法的适用场景明显更宽
方向数组法有个天然优势:它对“当前在第几圈”没有概念,只关心当前位置和当前方向。
比如改成长方形矩阵 m x n,它仍然只需要判断越界条件,不需要重新设计圈层;再比如要从矩阵中心开始逆时针螺旋,只需要换一组方向顺序和起点即可。逐圈收缩法在方阵上很优雅,但一旦变成非方形矩阵,每层边的长度不对称,构造起来就麻烦很多。
另外,方向数组法的代码量非常稳定,四条边的逻辑被抽象成“探测 + 转向”,不会出现一个方向写一个 for 导致代码重复的问题。就我个人刷题体验来说,遇到不确定是方形还是长方形的螺旋类题目时,方向数组是更优先选择的方案。
3.4 换成 Python 写需要注意什么
很多 Python 用户会在这道题上踩一个特别经典的初始化坑。
错误写法:
python复制matrix = [[0] * n] * n
这样写得到的每一行,其实是同一个列表对象的引用。当你修改 matrix[0][0] 时,所有行都会跟着变。正确写法是用列表推导式:
python复制class Solution:
def generateMatrix(self, n: int) -> List[List[int]]:
matrix = [[0] * n for _ in range(n)]
dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)]
d = 0
x = y = 0
for num in range(1, n * n + 1):
matrix[x][y] = num
nx, ny = x + dirs[d][0], y + dirs[d][1]
if nx < 0 or nx >= n or ny < 0 or ny >= n or matrix[nx][ny] != 0:
d = (d + 1) % 4
nx, ny = x + dirs[d][0], y + dirs[d][1]
x, y = nx, ny
return matrix
C++ 里 vector 初始化正确写法是:
cpp复制vector<vector<int>> matrix(n, vector<int>(n, 0));
C 语言如果面试官允许直接用 VLA(变长数组),倒是可以 int matrix[n][n],但不少编译器默认不支持,刷题环境下建议还是用 vector 更安全。
4. 两套写法的本质对比:边界收缩与状态判断
4.1 一套直观流程对比
| 对比维度 | 逐圈收缩法 | 方向数组法 |
|---|---|---|
| 控制变量 | top / bottom / left / right | 当前坐标 + 方向 |
| 代码结构 | 四个 for 循环轮流执行 | 一个循环处理所有格子 |
| 核心难点 | 边界更新时机 | 转向条件判断 |
| 非方形矩阵 | 需要额外调整边长度逻辑 | 改动较小 |
| 终止条件 | while 检查边界是否交叉 | for 固定执行 n² 次 |
| 可读性 | 新手较容易理解 | 代码更短,抽象程度更高 |
两种写法的时间复杂度都是 O(n²),因为无论采用什么策略,都必须把 n² 个格子全部填满。空间复杂度方面,最终返回的矩阵本身就占用 O(n²),如果只统计额外辅助空间,两种方法都可以做到 O(1),毕竟方向数组法直接复用矩阵中“0 表示未填”的特性,不需要单独建标记数组。
4.2 为什么我推荐你先学逐圈法
逐圈收缩法的 for 循环写得非常直白,每一步对应一条边,特别适合用来理解螺旋过程的本质。面试里如果时间紧张,逐圈法更容易让你一边写一边解释自己正在做什么。
但方向数组法在扩展性上更优。LeetCode 54“螺旋矩阵”要求的是读出一个已经存在的矩阵,如果仍然用逐圈法,就必须处理 m 和 n 不相等时边长的变化;方向数组法则只要把终止条件从“循环 n² 次”改成“收集 m * n 个数”,主体逻辑几乎不用动。
我的建议是:如果刚开始刷数组题,先把逐圈法写到熟练,因为你能从中彻底理解矩阵边界;如果已经有一定题量,建议把方向数组法也背下来,因为它在很多矩阵遍历题里都能复用。
4.3 “0”的双重身份:未访问标记与结果占位
方向数组法看起来没有额外的标记数组,但它的正确性其实依赖一个前提:初始矩阵每个位置都是 0,而我们要填的数字全是从 1 开始的正整数。因此,只要某个位置的值不是 0,就说明它已经被填过了。
这个思路很常见,但要注意它的适用范围。
如果题目改成了“从 0 开始填”,那判断条件就不能再用 ans[nx][ny] != 0,否则第一步就会把未填过的格子误判为障碍。遇到这种情况,要么额外建一个 visited 布尔矩阵,要么把初始矩阵全部初始化成 -1。这也是我在代码注释里专门提醒自己“预填 0 作为哨兵”的原因。
5. 现场写这题的翻车记录:边界问题与命名陷阱
5.1 行列坐标写反,矩阵会变成什么样
我第一次用逐圈法写这个题的时候,犯过一个很低级的错误:把第一个 for 循环的索引写成了 ans[i][top],而不是 ans[top][j]。看起来只是把 i 和 j 的位置换了一下,实际运行结果会直接越界。
更危险的是,如果矩阵恰好是方阵,行列写反在某些位置不会立刻崩溃,它会先填出一部分看起来还算合理的数,然后才在某一步访问到非法下标。这种“半错半不错”的状态最让人头疼,因为它不像越界那样立刻报错,你会不由自主地怀疑是边界判断的问题,而不是最开始的坐标方向。
所以我现在写二维矩阵遍历时,有个固定的自查习惯:凡是填 ans[x][y],先问自己,x 是行还是列,这一行的 for 循环到底在横向移动还是纵向移动。横向移动必须固定行,变化列;纵向移动必须固定列,变化行。
5.2 缩圈后继续用旧边界,坐标会飞出去
另一个很常见的错误是边界变量已经更新,但后面的 for 循环还在用旧值。
比如先执行了 ++top,紧接着下边界的 for 循环却写成了:
cpp复制for (int i = 0; i <= bottom; ++i) {
ans[i][right] = num++;
}
这样会把已经填过的最上面一行重新覆盖。要记住,在同一个循环里,前面更新过的变量,后面的第三步和第四步必须使用更新后的结果。
建议在写代码前先在草稿纸上明确圈出四个边界值的更新顺序:
- 填完上边后,
top++ - 填完右边后,
right-- - 填完下边后,
bottom-- - 填完左边后,
left++
顺序反过来会导致相邻两圈的边界互相重叠,表现就是数字错位或覆盖。
5.3 方向法在最后一格容易踩的“越界却没事”的坑
方向数组有一个容易被忽略的细节:当填入最后一个数 n² 时,代码依然会去计算下一步的 nx, ny,并可能把方向改成朝下或朝外。此时虽然 x 和 y 已经被更新成越界位置,但因为 for 循环已经结束,后续不再访问矩阵,所以不会触发运行错误。
这在单个测试用例里没问题,但如果你把这段生成函数嵌入到其他逻辑中,比如生成完矩阵后立刻基于末尾坐标做额外处理,就有隐患。我的习惯是在循环内部直接判断:
cpp复制if (num == n * n) {
break;
}
这样最后一格填完后就不再计算下一步,也不会把坐标指向矩阵外部。
5.4 默认值“0”带来的隐藏假设
方向数组法依赖矩阵初始值为 0,但并不是所有语言、所有初始化方式都默认全 0。
C++ 的 vector<vector<int>> ans(n, vector<int>(n)) 会默认初始化成 0;但如果用 new int[n][n] 分配的数组,系统不会自动清零。Python 如果写 [[0] * n for _ in range(n)] 也会全 0,但写 [[0] * n] * n 就会踩引用的坑。
所以刷 LeetCode 时用 C++ 和 Python 都没问题,可一旦换到其他平台或语言,最好自己确认一下初始值,别想当然认为所有格子都是 0。
6. 刷完一题能带走什么:螺旋类题型的泛化套路
6.1 从方阵改成长方阵,解法变化极小
LeetCode 54 是这道题的姐妹题,输入是一个 m x n 的矩阵,要求按螺旋顺序读出来。方向数组法只需要把填充逻辑改成收集逻辑。
cpp复制vector<int> spiralOrder(vector<vector<int>>& matrix) {
vector<int> res;
int m = matrix.size(), n = matrix[0].size();
int dx[4] = {0, 1, 0, -1};
int dy[4] = {1, 0, -1, 0};
int x = 0, y = 0, dir = 0;
for (int i = 0; i < m * n; ++i) {
res.push_back(matrix[x][y]);
matrix[x][y] = INT_MIN; // 标记为已访问
int nx = x + dx[dir];
int ny = y + dy[dir];
if (nx < 0 || nx >= m || ny < 0 || ny >= n
|| matrix[nx][ny] == INT_MIN) {
dir = (dir + 1) % 4;
nx = x + dx[dir];
ny = y + dy[dir];
}
x = nx;
y = ny;
}
return res;
}
这里不能再用 matrix[nx][ny] != 0 判断了,因为原始矩阵里可能包含 0。所以我改成 INT_MIN 作为已读标记,避免和原始数据冲突。
6.2 从 1 到 n² 改成自定义序列,问题就变成另一道题
如果把要填入的数字从“1 到 n²”换成一个外部输入的链表或队列,你会发现代码结构几乎不变,只是数据来源不同。LeetCode 2326 就是这样一道延伸题:给定链表节点,按螺旋规则填进一个 m x n 矩阵,链表不够长的地方用 -1 填充。
解法就是把你手头的数据按顺序读取,方向逻辑完全复用。这类变体题考的不是你会不会背模板,而是能不能识别出“螺旋填充”这个核心动作没有变,变的只是数据的供给方式。
6.3 本地校验小工具:比肉眼靠谱的经验做法
最后分享一个我自己调试这类题时非常管用的经验。写完生成函数后,不要只盯着输出矩阵肉眼看,写一个校验函数,把矩阵里的所有数收集出来,确认它正好覆盖 1 到 n² 且每个数只出现一次,再顺带按螺旋顺序读一遍,确认顺序递增。
cpp复制bool checkMatrix(const vector<vector<int>>& matrix) {
int n = matrix.size();
vector<int> cnt(n * n + 1, 0);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
int val = matrix[i][j];
if (val < 1 || val > n * n) return false;
if (cnt[val] != 0) return false;
cnt[val] = 1;
}
}
return true;
}
这个函数虽然不能完全验证螺旋顺序,但能快速抓住大部分越界、覆盖、漏填问题。我会在函数里从 1 到 n² 循环检查 cnt[i] == 1,一旦发现空缺就能立刻定位到第几个数开始出错。
如果还想验证具体顺序,可以直接调用题目要求的螺旋读取逻辑,读取结果和 1 到 n² 比较一遍。这个组合做法在我排查自己写的“逆时针版本”“非方形版本”时帮了很大忙。毕竟这种题代码不长,真正的价值在于你能不能稳定地一次写对,而稳定的前提就是有一套能快速自查的工具链。
