问题描述
给你一个 m 行 n 列的矩阵 matrix
,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。
示例 1:
1 | 输入:matrix = [[1,2,3],[4,5,6],[7,8,9]] |
上面这个示例中,螺旋顺序遍历的过程如下:
1 | → → → |
示例 2:
1 | 输入:matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]] |
这个例子中,螺旋遍历的路径是:
1 | → → → → |
约束条件:
- m == matrix.length
- n == matrix[i].length
- 1 <= m, n <= 10
- -100 <= matrix[i][j] <= 100
解题思路
螺旋矩阵是一个典型的数组遍历问题,核心在于如何控制遍历的方向和边界。我们需要按照顺时针螺旋的顺序(右 → 下 → 左 → 上)依次访问矩阵中的所有元素。
方法一:按层模拟(边界法)
这个方法的核心思想是:将整个矩阵看作由多个"环"(层)组成,从外到内一层一层地剥离遍历。
步骤详解:
-
初始化四个边界:
left
= 0:左边界right
= 列数-1:右边界top
= 0:上边界bottom
= 行数-1:下边界
-
循环遍历各层,直到
left > right
或top > bottom
:a. 从左到右遍历上边界上的元素:
- 遍历
matrix[top][j]
,其中j
从left
到right
- 遍历完后,上边界下移:
top++
b. 从上到下遍历右边界上的元素:
- 遍历
matrix[i][right]
,其中i
从top
到bottom
- 遍历完后,右边界左移:
right--
c. 检查是否还有行需要遍历(防止单行情况重复遍历):
- 如果
top <= bottom
,从右到左遍历下边界上的元素: - 遍历
matrix[bottom][j]
,其中j
从right
到left
- 遍历完后,下边界上移:
bottom--
d. 检查是否还有列需要遍历(防止单列情况重复遍历):
- 如果
left <= right
,从下到上遍历左边界上的元素: - 遍历
matrix[i][left]
,其中i
从bottom
到top
- 遍历完后,左边界右移:
left++
- 遍历
-
重复第 2 步,直到所有元素都被遍历完。
关键注意点:
- 在每一轮遍历中,边界会收缩,形成一个逐渐缩小的矩形
- 需要特别注意单行或单列的情况,避免重复访问元素
方法二:方向数组法
这种方法使用方向数组来模拟螺旋过程,关键在于知道何时改变方向。
步骤详解:
-
定义方向数组:表示四种移动方向(右、下、左、上)
1
directions := [][]int{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}
-
创建访问标记数组:避免重复访问
1
2
3
4visited := make([][]bool, rows)
for i := range visited {
visited[i] = make([]bool, cols)
} -
遍历矩阵:
- 从(0,0)开始,按当前方向移动
- 记录已访问的位置
- 当需要转向时(即下一步会越界或已访问过),顺时针旋转方向
我的错误实现及分析
我最初的实现采用了方向数组的方法,代码如下:
1 | func spiralOrder(matrix [][]int) []int { |
错误分析:
让我们通过一个简单的例子来分析这段代码的问题:
1 | [ |
按照代码的执行逻辑:
- 首先访问 (0,0):值为 1,添加到结果
- 按照右方向移动到 (0,1):值为 2,添加到结果
- 继续按照右方向移动到 (0,2):值为 3,添加到结果
- 继续按照右方向尝试移动到 (0,3),但这超出了边界
- 改变方向为下,移动到 (1,2):值为 6,添加到结果
- 继续按照下方向移动到 (2,2):值为 9,添加到结果
- 继续按照下方向尝试移动到 (3,2),但这超出了边界
- 改变方向为左,移动到 (2,1):值为 8,添加到结果
- 继续按照左方向移动到 (2,0):值为 7,添加到结果
- 继续按照左方向尝试移动到 (2,-1),但这超出了边界
- 改变方向为上,移动到 (1,0):值为 4,添加到结果 - 这里出现了问题!
我们再次访问了 (0,0),造成了死循环!因为代码只检查了边界条件,没有检查是否已经访问过该位置。正确的做法应该是使用一个额外的数组来标记已访问的位置。
我的另一个错误实现及分析
在尝试实现边界法的过程中,我还犯了另一个常见错误:
1 | func spiralOrder(matrix [][]int) []int { |
错误分析:
这段代码的主要问题是缺少边界检查。以矩阵 [[1,2,3],[4,5,6]]
(2 行 3 列)为例,分析执行过程:
- 初始状态:
left=0, right=2, top=0, bottom=1, cnt=0
- 向右遍历:添加
[1,2,3]
,cnt=3, top=1
- 向下遍历:添加
[6]
,cnt=4, right=1
- 向左遍历:添加
[5,4]
,cnt=6, bottom=0
- 问题出现:此时
top=1, bottom=0
,已经交叉,但代码没有检查就继续执行向上遍历
当 top > bottom
或 left > right
时,说明当前层已经遍历完毕,应该结束循环。但这个实现中缺少了这一关键检查,导致在单行或单列等特殊情况下会重复访问元素,甚至可能发生数组越界。
另一个问题是,当矩阵只有一行或一列时,执行完一个方向的遍历后,立即执行下一个方向的遍历可能会重复处理元素。例如,对于一个单行矩阵 [[1,2,3]]
:
- 向右遍历后
top
增加,此时top > bottom
- 但代码仍会执行向下、向左和向上遍历,导致越界或重复访问
修复方法:
在每个方向遍历后,需要检查更新后的边界条件是否仍然有效:
1 | func spiralOrderFixed(matrix [][]int) []int { |
错误的根本原因:
这个错误的根本原因是没有考虑边界条件。在处理矩阵类问题时,尤其需要注意:
- 边界交叉检查:确保索引边界(如
left <= right
和top <= bottom
)始终有效 - 特殊情况处理:矩阵可能是单行、单列、甚至空矩阵
- 方向转换条件:每次改变遍历方向前需要验证还有元素需要遍历
这是矩阵遍历问题中最容易被忽视的细节,也是导致代码出错的常见原因。
正确实现
方法一:按层模拟(推荐)
1 | func spiralOrder(matrix [][]int) []int { |
执行过程分析:
对于示例矩阵 [[1,2,3],[4,5,6],[7,8,9]]
:
第一轮循环:
- 初始边界:
left=0, right=2, top=0, bottom=2
- 从左到右:添加
[1,2,3]
,top
变为 1 - 从上到下:添加
[6,9]
,right
变为 1 - 从右到左:添加
[8,7]
,bottom
变为 1 - 从下到上:添加
[4]
,left
变为 1
第二轮循环:
- 边界:
left=1, right=1, top=1, bottom=1
- 从左到右:添加
[5]
- 结束循环,因为所有元素都已访问
最终结果:[1,2,3,6,9,8,7,4,5]
方法二:改进的方向数组方法
1 | func spiralOrder(matrix [][]int) []int { |
执行过程分析:
对于同样的示例,方法二的执行过程:
- 从 (0,0) 开始,标记为已访问,添加值 1
- 当前方向为右,下一步是 (0,1),有效且未访问,移动并添加值 2
- 继续向右到 (0,2),添加值 3
- 尝试继续向右到 (0,3),超出边界,方向改为向下,移动到 (1,2),添加值 6
- 继续向下到 (2,2),添加值 9
- 尝试继续向下,超出边界,方向改为向左,移动到 (2,1),添加值 8
- 继续向左到 (2,0),添加值 7
- 尝试继续向左,超出边界,方向改为向上,移动到 (1,0),添加值 4
- 尝试继续向上到 (0,0),已经访问过,方向改为向右,移动到 (1,1),添加值 5
- 所有单元格都已访问,结束循环
最终结果:[1,2,3,6,9,8,7,4,5]
方法比较
方面 | 按层模拟(边界法) | 方向数组法 |
---|---|---|
思路 | 将矩阵分为多层,由外向内遍历 | 使用方向数组控制移动,遇到边界或已访问位置时转向 |
空间复杂度 | O(1)(不计算结果数组) | O(m×n)(需要访问标记数组) |
易理解性 | 更直观,易于可视化 | 需要理解方向变化逻辑 |
实现复杂度 | 需要处理边界条件和单行/单列情况 | 需要维护访问标记 |
推荐度 | ★★★★★ | ★★★☆☆ |
复杂度分析
对于两种方法:
- 时间复杂度:O(m×n),其中 m 和 n 分别是矩阵的行数和列数。矩阵中的每个元素都被访问一次。
- 空间复杂度:
- 按层模拟:O(1),只需要常数级别的额外空间(不计算结果数组)。
- 方向数组方法:O(m×n),需要一个与矩阵大小相同的访问标记数组。
总结
螺旋矩阵问题是一个经典的矩阵遍历问题,有两种主要解法:
- 按层模拟(边界法):通过控制四个边界(左、右、上、下)来模拟螺旋遍历过程,空间效率高,思路直观。
- 方向数组法:使用方向数组和已访问标记来控制遍历方向,需要额外空间但思路也较为清晰。
在实际应用中,按层模拟的方法通常是更好的选择,因为它不需要额外的访问标记数组,空间复杂度更低,并且逻辑也相对直观。
这道题目的核心在于理解如何控制边界和方向,以及如何处理遍历过程中的边界情况。解决此类问题的关键是清晰地定义状态变量并正确地更新它们。