题目
给一个
M x N 的矩阵,从
1 开始
逆时针螺旋填入 / 打印这
M x N 个数,要求时间复杂度尽可能低。
面试现场通常是画个九宫格,让你从 1 开始往里填。思路一句话:
向右、向下、向左、向上循环,每撞到未填区域的边缘就变向。
在写任何代码之前,有一件事必须先对齐 —— 口径。
一、核心洞察:先把"逆时针"的口径对齐
这是本题最大的坑,而且它不在代码里,在
名词定义里。
从左上角出发,第一行只能向右走完(顶边没有向上的空间),所以第二个方向必然是向下。于是「右、下、左、上」这个顺序,
几何上画出来是顺时针(1→2→3 沿顶边、4→5 沿右边、6→7 沿底边、8 沿左边)。但大量面经和教材把这一套叫作「逆时针螺旋」—— LeetCode 54(螺旋矩阵)和 59(螺旋矩阵 II)用的都是它。
真正的逆时针是「
下、右、上、左」:从左上角先往下走,再沿底边向右,再沿右边向上,最后沿顶边向左。
两个顺序在
方阵下恰好互为转置(3×3 成立,非方阵不成立 —— 这点我验证过,见第四节)。所以它们不是"对错"关系,是"两套都合法、命名混乱"的关系。
面试里的正确动作:先问一句"方向顺序是右下上左还是下右上左",或者直接声明"我按右下上左,LC 54 那一套"。 把口径钉死再写码,比写完再返工省一半时间。这篇文章两个顺序都实现、都做成了动画,你可以直接对照着看。
二、拆解:四边界收缩
最稳的写法是维护四条边界,逐圈向内收缩:
def spiral(rows, cols):
grid = [[0] * cols for _ in range(rows)]
top, bottom, left, right = 0, rows - 1, 0, cols - 1
num = 1
while top <= bottom and left <= right:
# 1) 顶边:向右
for c in range(left, right + 1):
grid[top][c] = num
num += 1
top += 1
# 2) 右边:向下
for r in range(top, bottom + 1):
grid[r][right] = num
num += 1
right -= 1
# 3) 底边:向左 —— 守卫:只剩一行时不能再扫
if top <= bottom:
for c in range(right, left - 1, -1):
grid[bottom][c] = num
num += 1
bottom -= 1
# 4) 左边:向上 —— 守卫:只剩一列时不能再扫
if left <= right:
for r in range(bottom, top - 1, -1):
grid[r][left] = num
num += 1
left += 1
return grid
两个
if 守卫是全部难点所在。它们防的不是"越界"(
range 自己会处理),而是
重复写入:当剩余区域退化成一整行或一整整列时,第三条边和第一条边是同一批格子,第四条边和第二条边也是。
漏掉守卫的确切症状(脚本实测,不是印象):
注意这个规律:
方阵和"矮胖/瘦高但每边至少 2"的形状全都正常,只有退化到单行或单列时才炸 —— 而且炸的方式是"后来的数字覆盖先前的",1 和 2 直接消失。很多面经笼统说"非方阵会错",不准确;
2 x 3 就好好的。要测就测
1 x N 和
N x 1。
三、动画怎么看
动画用主例
3 x 3、方向顺序「右、下、左、上」,11 帧走完:
- 网格:已填格显示数字,正在填的格子橙框,未填格虚线空壳;
- 方向箭头:画在当前格的外侧,指向本帧的推进方向 —— 撞到未填区域边缘就转向;
- 绿色虚线框:本圈的边界(动画里四边界按圈固定,不会中途跳动);
- 右侧面板:四个方向小方块(当前方向高亮)、已填
n / 9、四个边界值。
教科书帧是第 4 帧:
1 2 3 填完、
top 内推成 1,箭头从"向右"转成"向下",绿框还是本圈的
上0 / 下2 / 左0 / 右2。第 9 帧是第二圈,边界收缩成
上1 / 下1 / 左1 / 右1,只剩中心一格。
四、完整推演表
3 x 3,方向顺序「右、下、左、上」(对应动画的 9 个填数帧):
终局就是九宫格的标准答案
[[1,2,3],[8,9,4],[7,6,5]]。
换成「下、右、上、左」(真逆时针):
它恰好是上面那个矩阵的转置。 这个关系在方阵下恒成立(我用 3×3 验证过),但
非方阵不成立 ——
2 x 3 时前者是
[[1,2,3],[6,5,4]]、后者是
[[1,6,5],[2,3,4]],完全不同的两个矩阵。所以"逆时针就是顺时针转个置"这个说法,只在方阵面试题里碰巧对。
第二个动画就是「下、右、上、左」这一套,同一个状态机换了个方向顺序 —— 你可以直接对照两套动画,确认"口径"到底差在哪。
五、进阶:撞墙变向法与真逆时针
另一条实现路线是
方向向量 + 撞墙变向:维护一个方向序列,每步先试探下一格,撞到边界或已填格子就换下一个方向。
def spiral(rows, cols):
dirs = [(0, 1), (1, 0), (0, -1), (-1, 0)] # 右下上左
seen = [[False] * cols for _ in range(rows)]
grid = [[0] * cols for _ in range(rows)]
r = c = d = 0
for num in range(1, rows * cols + 1):
grid[r][c] = num
seen[r][c] = True
nr, nc = r + dirs[d][0], c + dirs[d][1]
if not (0 <= nr < rows and 0 <= nc < cols and not seen[nr][nc]):
d = (d + 1) % 4
nr, nc = r + dirs[d][0], c + dirs[d][1]
r, c = nr, nc
return grid
换方向顺序只要改
dirs 一行 —— 这也是本文两个动画能共用一套状态机的原因。代价是
seen 数组:
额外空间 O(M x N),而四边界收缩法只要 O(1)。想省空间就用边界法,想要代码短、方向好换就用撞墙法。
"打印"是同一件事:如果题目只要求按螺旋顺序打印、不要求返回矩阵,把
grid[r][c] = num 换成
print(num) 就行,遍历顺序一字不改。别为"打印"单独写一套逻辑。
六、Java 版
public int[][] spiral(int rows, int cols) {
int[][] grid = new int[rows][cols];
int top = 0, bottom = rows - 1, left = 0, right = cols - 1;
int num = 1;
while (top <= bottom && left <= right) {
for (int c = left; c <= right; c++) grid[top][c] = num++;
top++;
for (int r = top; r <= bottom; r++) grid[r][right] = num++;
right--;
// 守卫:只剩一行时不能再向左扫,否则覆盖已填的值
if (top <= bottom) {
for (int c = right; c >= left; c--) grid[bottom][c] = num++;
bottom--;
}
// 守卫:只剩一列时不能再向上扫
if (left <= right) {
for (int r = bottom; r >= top; r--) grid[r][left] = num++;
left++;
}
}
return grid;
}
想换成真逆时针,把四个循环块按「下、右、上、左」重排即可,两个守卫的位置跟着一起换。撞墙变向的 Java 版就是把
dirs 写成
int[][] dirs = {{1,0},{0,1},{-1,0},{0,-1}} 再配一个
boolean[][] seen。
七、边界情况
1 x 1:输出 [[1]],循环跑一轮就结束,两个守卫都不触发。
1 x N / N x 1:守卫的唯一用武之地。漏守卫时 1 x 3 输出 [[5,4,3]](1、2 被 4、5 覆盖),3 x 1 输出 [[1],[4],[3]]。
M != N 但都 ≥ 2:2 x 3、5 x 2 这类形状漏守卫也正常,别被"非方阵必错"的说法带偏。
M 或 N 为 0:题目一般保证正整数;真要防御,while 条件 top <= bottom && left <= right 会直接跳过,返回空矩阵。
- 从 0 开始编号:面经里两种都有,问清楚。本文全程从 1 开始(和 LeetCode 59 一致)。
八、复杂度
"时间复杂度尽可能低"的答案是:O(M x N) 已经是最优,没有优化空间。 理由很硬:输出本身有
M x N 个数,每个数至少要被写一次,所以
Ω(M x N) 是下界;而两种写法都恰好
M x N 次写入,上下界贴平。
真正能省的是
空间:四边界收缩法
O(1) 额外空间,撞墙变向法要一个
seen。如果面试官追问"能不能不用
seen",这就是切入点。
顺带一句:别用递归写这题。每圈一层递归,圈数
min(M, N) / 2 虽然不大,但递归没有任何优势,还会引入栈深和边界传递的额外出错点。
九、常见坑
9.1 口径没对齐就开写(本题头号坑)
你说"逆时针"、面试官想"右下上左",或者反过来 —— 写完全场对不上。
动手前先声明方向顺序,或者画个 3×3 的九宫格跟面试官对一遍。这也是本篇花一整节讲口径的原因:它比代码更难补救。
9.2 漏掉第三、第四条边的守卫
症状是
后来的数字覆盖先前的(不是越界崩溃,所以特别隐蔽)。但要注意:它只在剩余区域退化成
单行或单列时触发,
2 x 3、
5 x 2 全都正常。回归测试请直接钉
1 x N 和
N x 1 两组。
9.3 边界更新的时机
四条边的顺序必须是「先填满这一边,再把这条边界内推一格」。把
top++ 写到循环前面,第一行就会漏掉一个格子;把
right-- 写在整个圈结束时,右边就会多填一行。
填满 → 收缩,一一对应。
9.4 起始编号 0 还是 1
面经两种都有。本文从 1 开始(对齐 LeetCode 59)。如果面试官说"从 0 开始",把
num = 1 改成
num = 0、循环条件改成
< rows * cols 即可,其他一字不动。
9.5 用 seen 判断转向,却忘了判断边界
撞墙变向法的试探条件是
0 <= nr < rows and 0 <= nc < cols and not seen[nr][nc] —— 三个条件缺一不可。只判
seen 不判边界,会在最后一格直接数组越界。
9.6 把"填入"和"打印"当成两道题
遍历顺序完全一样,差别只在"要不要存"。面试时如果只要求打印,直接输出即可;如果先问打印、再问返回矩阵,就是把
print 换成赋值,别重写算法。
十、面试怎么答
- 先对齐口径:"我按『右、下、左、上』写,也就是 LeetCode 54 那一套;如果您要的是先往下走的真逆时针,方向序列换成下右上左就行。"
- 说清核心思路:"维护四条边界,每圈先填顶边、再填右边、再填底边、再填左边,每填完一条把对应边界内推一格。"
- 主动点出守卫:"第三、第四条边要加
if top <= bottom 和 if left <= right —— 剩余区域退化成单行/单列时会重复写入。1 x 3 漏守卫会输出 [[5,4,3]]。"
- 报复杂度并给出理由:"时间
O(M x N),这已经是最优 —— 输出本身有 M x N 个数,下界贴平。额外空间 O(1)。"
- 提另一条路线:"也可以用方向向量 +
seen 撞墙变向,代码更短、换方向只改一行,但要多 O(M x N) 空间。"
- 给变体:LC 54 螺旋矩阵(给定矩阵按此顺序读取)、LC 59 螺旋矩阵 II(从 1 填到 n²)、LC 885 螺旋矩阵 III(从某点出发按螺旋走,考验坐标推进)、以及"逆时针"口径本身。
结语
螺旋填数是那种"代码十分钟、口径半小时"的题:算法本身简单到不值一提 —— 四边界收缩,
O(M x N) 时间、
O(1) 额外空间,而且
O(M x N) 有硬下界(输出就有
M x N 个数),没有优化余地。真正的知识点有两个:
方向顺序的口径要先对齐("右下上左"几何上是顺时针,却被大量面经叫作逆时针;方阵下"真逆时针"恰好是它的转置,非方阵不成立),以及
两个守卫防的不是越界而是覆盖(只在单行/单列时触发,
1 x 3 会输出
[[5,4,3]])。这两点都是"不写就不会知道"的类型 —— 所以回归测试请直接钉
1 x N 和
N x 1。