菜鸟到大厂面试,最怕碰到的就是这类“看着简单,一写就废”的题。顺时针打印矩阵,剑指 Offer 29,C语言版本,几乎每家公司在考察数组和边界控制时都会拿它当试金石。题目本身抽象,一句话概括就是:给你一个 m 行 n 列的矩阵,按顺时针顺序从外到内把每个元素输出一遍。但真上手写,你会发现最难的不是遍历本身,而是怎么控制四个方向的转向时机,以及矩阵不是正方形时那些奇奇怪怪的退化情况。
这篇文章我从零开始拆解这道题:先讲清楚核心思路,再给你一份可以直接运行的 C 代码,最后把我踩过的边界坑全部列出来。无论你是第一次刷题的新手,还是想快速捡起 C 语言实现的老手,照着下面的思路走,十分钟内能搞出一版稳定的解法。
1. 题目分析与核心解题思路
1.1 题目到底在问什么
输入是一个二维数组,比如:
1 2 3 4 5 6 7 8 9输出应该是1 2 3 6 9 8 7 4 5。注意方向变化:先向右走到底,再向下走到底,再向左走到底,再向上走到底,然后收缩范围,继续下一个内圈。整个过程像是一圈一圈剥洋葱,每一圈走四条边,直到所有元素都被访问。
关键点在于:矩阵不一定是正方形,可能是3x4,可能是1x5,也可能是一列。如果直接用“按层模拟”的写法,必须处理好只剩一行或只剩一列时的重复输出问题。
C 语言里没有现成的二维数组边界判断,所有越界检查都要自己写,这也是这题在 C 语言下的难度所在。C 语言考察的是你对指针、下标、循环条件的掌控力,而不是像 Python 里可以直接用pop作弊。
1.2 两种常见解法的取舍
这道题主流解法有两种:模拟螺旋路径和维护四个边界。
模拟螺旋路径的思路是:从(0,0)出发,用dir表示当前方向,走到下一个位置如果越界或者已经访问过,就右转 90 度。这个思路直观,但必须额外申请一个m x n的二维 visited 数组,空间复杂度 O(mn)。对 C 语言来说,如果矩阵特别大,动态申请 visited 本身就是个麻烦事,而且容易在释放内存时出幺蛾子。
四个边界法就优雅得多。定义top, bottom, left, right四个变量,初始分别指向矩阵的上边界、下边界、左边界、右边界。然后循环执行四步:
- 从左到右遍历当前 top 行,之后 top++;
- 从上到下遍历当前 right 列,之后 right--;
- 从右到左遍历当前 bottom 行,之后 bottom--;
- 从下到上遍历当前 left 列,之后 left++。
每次循环都检查top <= bottom && left <= right,循环退出条件就是边界交错。
我推荐用四边界法,因为空间复杂度 O(1),而且边界条件更可控,不会漏元素也不会重复输出。面试时写这版,面试官很难挑出毛病。
1.3 时间复杂度与空间复杂度先说清楚
时间复杂度 O(mn),因为每个元素恰好访问一次,没有额外重复遍历。空间复杂度 O(1),只用了四个整型变量,没有申请额外数组。这个复杂度是这题的“标准答案”,面试时主动说出来,属于加分项。
2. 四边界模拟法的关键细节
2.1 四个方向访问的边界控制
把矩阵看作一个平面直角坐标系,行是row,列是col。四个方向分别是:
| 方向 | 行变化 | 列变化 | 终止条件 |
|---|---|---|---|
| 向右 | row 不变 | col++ | col == right |
| 向下 | row++ | col 不变 | row == bottom |
| 向左 | row 不变 | col-- | col == left |
| 向上 | row-- | col 不变 | row == top |
每个方向走完后,对应边界收窄。这里特别容易出错的是第三、第四步:如果这一圈已经只剩一行,第三步向左遍历会再次访问这一行,如果只剩一列,第四步向上遍历会再次访问这一列。所以要加上额外的“防重复”判断。
具体判断逻辑:
- 在做第3步(从右到左遍历 bottom 行)之前,要先确认
top <= bottom。因为第1步结束后top已经加过了,如果此时top > bottom,说明这一圈只有一行,第1步已经把所有元素输出完了,第3步不需要执行。 - 在做第4步(从下到上遍历 left 列)之前,要先确认
left <= right。因为第2步结束后right已经减过了,如果此时left > right,说明这一圈只有一列,第4步不需要执行。
这个细节是无数人踩坑的地方。我见过太多人写完代码,测试3x3没问题,一测3x4或者1x5就输出重复元素,问题就出在这个防重复判断上。
2.2 循环条件的两种写法对比
你可以用while (top <= bottom && left <= right)作为外层循环,也可以用while (count < total)计数来控制。两者各有优劣。
边界法常用边界判断:
while (top <= bottom && left <= right) { // 四步遍历 }这个写法干净利落,循环次数正好是“圈数”。但要注意,如果矩阵是0x0(理论上可能),初始条件就不满足,直接跳过循环,返回空结果,这样也正确。
计数法常用count统计已输出元素数量,当count == total时退出。这种方法更直观,想漏一个都难,但每次循环都要判断count,逻辑上略显冗余。
我个人推荐边界法,因为面试时更好解释,而且代码更精简。核心是要把四个边界的更新顺序想明白。
2.3 一维矩阵的特殊处理
如果m == 1或n == 1,其实就是一条线。四边界法依然适用,只是第三步和第四步会被防重复判断直接跳过。例如1x5的矩阵{1,2,3,4,5},初始top=0, bottom=0, left=0, right=4,第一步从左到右输出全部,top变 1,此时top > bottom,外层循环结束。整个过程只输出了 5 个元素,没有重复。
如果是单列5x1的矩阵,第一步输出matrix[0][0],top变 1,第二步从上到下输出matrix[1][0]到matrix[4][0],right变 0,此时left > right,外层循环结束。没毛病。
3. C 语言完整实现与逐段解读
3.1 函数签名设计
在 C 语言环境中,我们通常把输出逻辑封装成一个函数。返回值用void,直接打印到标准输出。如果要返回数组,需要额外用指针参数传递,但这道题核心是考察思路,打印即可。
#include <stdio.h> void spiralOrder(int matrix[][100], int m, int n) { // 实参传递时,第二维需要指定大小 }不过,matrix[][100]这种写法限制了列数必须固定为 100,不够通用。更灵活的做法是使用动态二维数组,或者直接用int**指针。但 C 语言笔试常见格式是给定matrix数组以及matrixSize,用变长数组(VLA)的特性也可以。
这里为了方便演示,我用int matrix[][4]这类固定列数的方式测试。实际面试时,建议这样处理:
void spiralOrder(int** matrix, int matrixSize, int* matrixColSize) { // matrixSize 表示行数 m,matrixColSize 指向每行列数 n }这个签名符合力扣风格的 C 接口。下面我用这种接口写完整代码。
3.2 完整代码与注释
#include <stdio.h> #include <stdlib.h> void spiralOrder(int** matrix, int matrixSize, int* matrixColSize) { if (matrixSize == 0 || matrixColSize[0] == 0) { return; } int top = 0; int bottom = matrixSize - 1; int left = 0; int right = matrixColSize[0] - 1; while (top <= bottom && left <= right) { // 第一步:左到右遍历 top 行 for (int j = left; j <= right; j++) { printf("%d ", matrix[top][j]); } top++; // 第二步:上到下遍历 right 列 for (int i = top; i <= bottom; i++) { printf("%d ", matrix[i][right]); } right--; // 第三步:右到左遍历 bottom 行(防重复判断) if (top <= bottom) { for (int j = right; j >= left; j--) { printf("%d ", matrix[bottom][j]); } bottom--; } // 第四步:下到上遍历 left 列(防重复判断) if (left <= right) { for (int i = bottom; i >= top; i--) { printf("%d ", matrix[i][left]); } left++; } } } int main() { int row1[] = {1, 2, 3, 4}; int row2[] = {5, 6, 7, 8}; int row3[] = {9, 10, 11, 12}; int* matrix[] = {row1, row2, row3}; int matrixSize = 3; int matrixColSize[] = {4, 4, 4}; spiralOrder(matrix, matrixSize, matrixColSize); return 0; }输出结果:
1 2 3 4 8 12 11 10 9 5 6 7这是 3 行 4 列的矩阵,输出顺序完全正确。
3.3 逐段代码逻辑拆解
第一步遍历top行时,行下标固定为top,列从left到right递增。完成后,上边界向下一行移动,即top++,这样下一圈就不会再访问这一行。
第二步遍历right列时,列下标固定为right,行从top到bottom递增。注意这里的top已经是更新后的新值,也就是说第一步访问过的元素不会被重复访问。完成后right--。
第三步遍历bottom行时,行下标固定为bottom,列从right到left递减。这里的right已经是更新后的值,所以最右侧那列不会重复。但行bottom在第二步中已经被访问过最右下角那个元素了吗?仔细看:第二步是按行从top到bottom遍历right列,确实访问了matrix[bottom][right]。第三步这里我们从right-1开始递减,因为right已经减过 1,所以起点是matrix[bottom][right-1],没有重复。但前提是top <= bottom,如果top已经在第一步后超过bottom,说明矩阵只有一行,第一步已经搞定,第三步就没必要执行了。
第四步同理,left列从bottom到top递减,此时left还是旧值,因为还没更新。需要判断left <= right,防止单列重复。
这四个步骤的顺序不能变。如果你先做第四步再做第三步,整个输出顺序就乱了。
3.4 测试不同形状的矩阵
我专门测试了几种常见边界情况,大家可以对照验证自己写的代码。
3 行 3 列:
1 2 3 4 5 6 7 8 9输出应为1 2 3 6 9 8 7 4 5。我的代码运行结果一致。
1 行 4 列:
1 2 3 4初始top=0, bottom=0, left=0, right=3,第一步输出 1 2 3 4,top变 1,外层循环结束。不会执行第二三四步。输出1 2 3 4,正确。
4 行 1 列:
1 2 3 4初始top=0, bottom=3, left=0, right=0,第一步输出 1,top=1;第二步输出 2 3 4(从 i=1 到 3),right=-1;第三步判断top<=bottom为真(1<=3),进入循环for (int j=right; j>=left; j--),此时right=-1, left=0,循环条件j>=left即-1>=0为假,直接跳过,但注意bottom--是在循环外,所以 bottom 从 3 变 2;第四步判断left <= right,0 <= -1为假,跳过。外层循环继续,此时top=1, bottom=2, left=0, right=-1,条件top<=bottom && left<=right因为left<=right为假而结束。没有重复输出。正确。
空数组:
matrixSize = 0;直接 return,不输出任何内容。正确。
4. 常见 Bug 与调试心得
4.1 经典的“多输出一个元素”问题
如果你把第三步的防重复判断写成if (top <= bottom)没错,但把第四步的防重复判断漏了,对于 3 行 3 列矩阵不会出错,因为第三步和第四步都满足条件。但如果矩阵是 3 行 2 列:
1 2 3 4 5 6正确输出是1 2 4 6 5 3。流程如下:第一步输出 1 2,top=1;第二步输出 4 6,right=0;第三步 top<=bottom(1<=2),输出 5,bottom=1;第四步 left<=right(0<=0),输出 3,left=1。外层循环条件top<=bottom && left<=right变成1<=1 && 1<=0,不满足,退出。共输出 6 个元素,正确。
如果漏了第四步的判断,第一步输出 1 2,第二步输出 4 6,第三步输出 5,第四步无条件执行,从 bottom=1 向上到 top=1 只输出一次 3,也不会重复,因为此时 bottom 已经是 1,只有一个元素。所以单看这个例子不一定会漏。但如果是 1 行 5 列,漏了第三步判断,就会重复输出整行。
调试这类问题最好的方法不是靠肉眼,而是用一个小的矩阵(比如 3x3、2x3、3x2)走一遍,用笔在纸上画出每个边界的变化。我刷题这么多年,遇到边界类题目永远先画表,不要高估自己的脑内 debug 能力。
4.2 变长数组与指针传递的坑
在 C 语言中,matrix[i][j]这种写法要求编译器知道第二维的大小。如果你拿到的是int** matrix,它本身是不带行列信息的,必须配合matrixSize和matrixColSize使用。最常见的错误是:
int matrix[3][4] = {...}; int** p = (int**)matrix; // 错误!这种强制转换会导致后面访问p[i][j]时地址计算完全错误,因为二维数组在内存中是连续排布的,而int**期望的是“指针数组”。正确的传递方式应该直接传int matrix[][4],或者在调用时用数组指针int (*matrix)[col]。
如果接口限定为int**,那调用方必须真正构造一个指针数组,每个指针指向一行。我在上面的代码里就是这么干的:用row1, row2, row3三个数组,然后int* matrix[] = {row1, row2, row3}。这样matrix[i]就是第 i 行的地址,matrix[i][j]才能正确访问。
4.3 打印格式与字节流问题
这道题在力扣中要求返回 int 数组,但在 C 语言本地测试时,大家习惯直接打印。打印时要注意最后一个元素后面不要带多余的空格。如果你用printf("%d ", matrix[top][j]),最后会多一个空格,判题系统一般忽略空白,但有些平台会严格比较字符串,那样会判错。
更严谨的写法是判断是否已输出第一个元素,或者先输出一个标记。当然,如果只是刷题,多一个空格没什么大问题,但如果是输出到文件、做精确比对,就要处理一下。
我个人在本地测试时喜欢这样:
int first = 1; // 每个 printf 前判断 if (first) { printf("%d", val); first = 0; } else { printf(" %d", val); }这样输出整洁,也更符合实际项目的需求。
4.4 性能分析与优化空间
这个算法已经是 O(mn) 时间、O(1) 空间,理论上没有更优解,因为每个元素至少要访问一次。但你可以做一些微小的优化,比如把matrixColSize[0]存到局部变量n里,减少多次间接寻址。编译器一般也会帮你做这件事,但显式写出来代码更清晰。
如果矩阵是稀疏的、不需要全部输出,那就另当别论。但就这道题而言,没有优化空间,面试时不用画蛇添足。
一个可以提的优化点是:把打印改成回调函数或者通过输出数组返回,这样函数可以被复用到不同场景,比如你后续要做图像处理中的像素螺旋采样。把“输出”这个动作抽象出去是代码设计层面的加分项。
5. 从这道题延伸出的面试考点
5.1 逆时针打印与贪吃蛇遍历
面试官常会在你答完顺时针后追问:“如果改成逆时针呢?”其实只要把四步方向反转即可:先从上到下,再从左到右,再从下到上,再从右到左,同时边界更新逻辑不变。核心思想还是控制四个边界,换方向只是换遍历顺序。会了顺时针,逆时针只是三分钟的改动。
另外,有些变体会要求你从任意起点开始螺旋遍历,或者给定步长、跳跃式访问。处理手段都是维护方向数组,配合越界判断。面试时如果能引申出这个方向数组方案,会显得你举一反三的能力很强。
5.2 二维矩阵的原地旋转
剑指 Offer 里还有一道矩阵旋转的题,和螺旋打印有相似之处:都需要精确定位行列变换关系。顺时针螺旋打印本质上是在二维坐标上做线性遍历,遍历顺序是(0,0) -> (0,n-1) -> (m-1,n-1) -> (m-1,0) -> (1,0) ...。它和一维数组下标转换有关,但比旋转简单,因为不需要交换元素。
面试官问螺旋打印,大概率会接着问旋转矩阵或之字形打印。建议你花半天时间把这三道题一起刷掉,效果事半功倍。
5.3 边界条件在真实开发中的意义
你可能觉得这种题只在面试中出现,实际工程里用不到。其实图形学里扫描线填充、地图切片处理、神经网络的卷积核滑动,都要用到类似的边界控制。比如图像处理中常见的“环形滤波”,就是按照螺旋顺序遍历像素邻域。能把这道题的边界想清楚,写那些代码时会少很多年轻时的幼稚错误。
我就是当年因为没处理边界条件,导致某个图像处理模块在边缘像素上出现越界访问,最后查了一天才发现是螺旋遍历少了一条判断。从那以后,凡是写遍历逻辑,先画图、列边界条件,再写循环体。
6. 最后的实操建议
这里给大家一个“考场三步法”:
第一步,拿到题先写四个边界变量,不要急着写循环。先在草稿纸上画一个3x4的矩阵,标出top/bottom/left/right,模拟走一圈,把每一步输出的元素列出来。
第二步,确定外层循环条件。while (top <= bottom && left <= right)作为模板,背下来即可。
第三步,写四个for循环,注意每一步对应的边界变量是旧值还是新值。写完检查第三步和第四步的防重复判断是否齐全。
我当初刷这道题刷了至少五遍,每一遍都能发现新的理解死角。第一遍漏了边界,第二遍漏了指针传递,第三遍搞混了左闭右开区间,第四遍终于代码跑通了,但一测1xN就重复。第五遍才算彻底掌握。所以如果你第一次没写对,太正常了。关键是记住这么一句话:螺旋矩阵的核心不是四个循环,而是两个 if。
今天这篇文章以 C 语言为例,把四边界法的代码逐段拆开,希望你能动手在本地编译器里跑一遍,试着把矩阵改成5x1、1x5、5x3各种形状,看看输出是否符合预期。跑了这几个用例之后,你会彻底理解防重复判断的意义。这道题不吃天赋,吃细心。