资讯详情

资讯详情

C语言实现顺时针打印矩阵:三种解法与边界陷阱详解

不铺垫了直接说正事。剑指 Offer 29 这道“顺时针打印矩阵”放在《剑指Offer》里算是看着简单、写起来容易翻车的一道经典题。我在给同学做模拟面试时亲眼见过很多人在白板上把矩阵画得清清楚楚一到代码就开始纠结“下边这条边要不要打印”“左边这条边会不会重复”最后涂涂改改乱了阵脚。今天这篇就用 C 语言把它彻底讲透从题目拆解到三种解法的取舍再到完整代码、边界用例、C 语言内存细节一次聊完。网上题库里这道题要么叫“顺时针打印矩阵”要么叫“螺旋矩阵”本质上是一模一样的题目给定一个 m 行 n 列的矩阵要求从最外层开始按顺时针方向一圈一圈地从外向内把每个数字都访问一遍。你可能觉得这不就是四个 for 循环吗真上手写的时候就会发现真正值钱的部分不是那四个 for而是怎么定义和保持“循环不变量”。1. 面试官抛出这道题时到底在考什么1.1 题目原型与期望输出先把题目规定死。假设输入一个矩阵1 2 3 4 5 6 7 8 9顺时针从外到内输出顺序应该是1, 2, 3, 6, 9, 8, 7, 4, 5你可以把整个过程理解为先沿着上边从左往右走再沿着右边从上往下走再沿着下边从右往左走最后沿着左边从下往上走走完这一圈之后把边界向里收缩一圈重复同样的动作直到所有元素都被访问过。这个过程既适用于正方形矩阵也适用于长方形矩阵甚至单行、单列、只有一个元素、以及空矩阵这些极端情况都要能正确处理。输出形式一般有两种要求第一种是直接打印到控制台第二种是要求返回一个动态分配的数组把结果按顺序放到数组里同时通过一个传出参数告诉调用方生成了多少个元素。后面我会把这两种形式都给出可运行的 C 代码。1.2 考核点拆解不止是“四个 for 循环”这道题被面试官反复使用是因为它至少有四个层次能考察候选人。第一考察循环不变量。你准备采用左闭右开区间还是全闭区间每一圈的“上、右、下、左”四条边分别负责哪些元素这些区间定义如果前后不一致代码就会在某个角落多打印一个数或者漏掉一个数。第二考察边界条件意识。单行矩阵、单列矩阵、空矩阵这些 case 是否能第一时间想到并且主动验证第三考察 C 语言基本功。int**和int[][N]到底是什么关系返回值数组由谁分配、谁释放returnSize这个传出参数怎么用第四考察沟通与复杂度意识。面试官更希望听到你先说思路、分析时间和空间复杂度再动手写代码。所以如果你只是背了一个解法就去面试一旦面试官把矩阵从 3×3 改成 3×4或让你改成逆时针很容易露馅。真正稳的做法是吃透边界收缩的本质再把它落实到 C 代码里。2. 手写代码前先把三种主流思路在脑中过一遍2.1 分层收缩法最好理解也最推荐这是我个人最推荐的做法。维护四个变量left最左边还没被访问的列下标right最右边还没被访问的列下标top最上边还没被访问的行下标bottom最下边还没被访问的行下标。每一轮循环打印当前这一圈。打印完上边之后top向下收缩一行打印完右边之后right向左收缩一列打印完下边之后bottom向上收缩一行打印完左边之后left向右收缩一列。当left right或者top bottom时说明没有新的完整圈可打了循环结束。我拿刚才的 3×3 矩阵推演一遍。初始left0, right2, top0, bottom2。上边打印 1、2、3然后top1右边从上到下打印 6、9然后right1下边从右到左打印 8、7然后bottom1左边从下到上打印 4然后left1。此时left1, right1, top1, bottom1下一轮循环条件不满足退出正好打印完。这个思路的优势在于它把“一圈”这个概念落成了非常具体的边界变量而不是靠脑子想象“走到哪了”。代码写起来也不容易迷路。2.2 原书起点法每圈从左上角开始《剑指Offer》原书给出的经典解法是另一个思路定义一个起点start每圈从(start, start)这个左上角开始同时算出这一圈的右下角(endRow, endCol)其中endRow rows - 1 - startendCol cols - 1 - start。主循环条件用start * 2 rows start * 2 cols这样保证当前至少还有一圈可打。打印的时候四条边分别处理上边从start到endCol固定行start右边从start 1到endRow固定列endCol下边从endCol - 1到start固定行endRow左边从endRow - 1到start 1固定列start。但这里有个非常容易错的点不是每一圈都完整具备四条边所以下边、左边的打印都带条件原书写法大概是// 打印下边仅在 start endRow 时执行 // 打印左边仅在 start endRow 且 start endCol 时执行很多人在这个版本的四个条件上栽跟头。因为它每一圈的边界判断更微妙写起来不如分层收缩法直觉。我的建议是如果你已经对原书写法很熟可以用如果只是刚开始练优先掌握分层收缩法考试时不容易出低级错误。2.3 模拟转向法适合初学但不是最优解第三种思路是模拟一个人拿着笔在矩阵里走路。规则很简单初始方向向右碰到矩阵边界或者已经访问过的格子就右转 90 度继续走直到走完所有格子。实现上需要方向数组dirs[4][2]例如{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}分别代表右、下、左、上一个visited二维数组大小和原矩阵一样用 0/1 标记是否访问过每次准备移动时先看下一个位置是否越界或已访问如果是就把方向切换到下一个方向再移动。int dirs[4][2] {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; int dir 0; int x 0, y 0; for (int k 0; k rows * cols; k) { ans[k] matrix[x][y]; visited[x][y] 1; int nx x dirs[dir][0]; int ny y dirs[dir][1]; if (nx 0 || nx rows || ny 0 || ny cols || visited[nx][ny]) { dir (dir 1) % 4; } x dirs[dir][0]; y dirs[dir][1]; }这个方案逻辑很简单写的时候不容易错非常适合初学者用来理解整个顺时针过程。但它需要额外的visited空间空间复杂度是 O(mn)。面试时如果面试官要求最优空间复杂度你就要能切换到分层收缩法。三种思路对比如下方法核心思想时间复杂度额外空间适用场景分层收缩法维护 left/right/top/bottom逐圈收缩O(mn)O(1)面试最优解推荐重点掌握原书起点法每圈从左上角 start 开始圈圈推进O(mn)O(1)经典教材实现代码短但条件稍绕模拟转向法方向数组 visited 标记O(mn)O(mn)初学理解过程工程上不够省3. C 语言实现左闭右开区间怎么写不出 bug3.1 为什么推荐左闭右开区间写这类题最容易引发争议的地方就是区间定义。常见的两种写法是左闭右闭[left, right]left right时区间有效左闭右开[left, right)left right时区间有效。我强烈建议在 C 语言里优先选择左闭右开。原因很简单C 语言的数组下标本身就是 0 到n-1我们用[0, n)来代表整个数组既符合语言习惯又能避免很多1、-1的混乱。比如矩阵有cols列右边界的初始值直接写right cols访问最后一列时用right - 1收缩时写--right整个过程不会出现“到底要不要加 1”的犹豫。另外左闭右开配合“打印完上边就收缩上边界”这种操作时退化判断非常自然。如果打印完上边之后top和bottom相等说明整个矩阵只有这一行当前已经打完直接结束。后面我会在代码里具体演示。3.2 打印到 stdout 的完整实现先给出直接打印版本方便你在本地编译器里立刻跑起来验证。#include stdio.h static int needComma 0; static void outputValue(int value) { if (needComma) { printf(, ); } printf(%d, value); needComma 1; } void printSpiral(int rows, int cols, int **matrix) { if (matrix NULL || rows 0 || cols 0) { return; } needComma 0; int left 0, right cols; // 列区间 [left, right) int top 0, bottom rows; // 行区间 [top, bottom) while (left right top bottom) { // 上边从左到右 for (int j left; j right; j) { outputValue(matrix[top][j]); } top; // 打印完上边后如果上下边界相遇说明只剩一行已经处理完毕 if (top bottom) { break; } // 右边从上到下 for (int i top; i bottom; i) { outputValue(matrix[i][right - 1]); } --right; // 打印完右边后如果左右边界相遇说明只剩一列已经处理完毕 if (left right) { break; } // 下边从右到左 for (int j right - 1; j left; --j) { outputValue(matrix[bottom - 1][j]); } --bottom; // 左边从下到上 for (int i bottom - 1; i top; --i) { outputValue(matrix[i][left]); } left; } printf(\n); } int main(void) { int row0[] {1, 2, 3}; int row1[] {4, 5, 6}; int row2[] {7, 8, 9}; int *matrix[] {row0, row1, row2}; printSpiral(3, 3, matrix); return 0; }这段代码我实际编译运行过输出就是1, 2, 3, 6, 9, 8, 7, 4, 5。你注意观察那两个break它们是整个代码的“安全阀”。第一个break发生在打印完上边并top之后。如果此时top bottom意味着所有剩余元素都已经在上边这一行打印完了再继续往下执行就会访问到不存在的行。典型场景就是单行矩阵1 2 3上边打印完top变成 1bottom原本就是 1直接退出完美。第二个break发生在打印完右边并--right之后。如果此时left right意味着剩余元素已经在右边这一列打印完了典型场景就是单列矩阵。这两个判断缺一不可删掉任何一个边界用例立刻翻车。3.3 返回数组版实现malloc、returnSize 这些坑更多时候在线笔试会给出一个函数签名让你把结果存到数组里返回。剑指 Offer 29 在 C 语言环境下常见的签名是这样的int *spiralOrder(int **matrix, int matrixSize, int *matrixColSize, int *returnSize)其中matrixSize是行数matrixColSize是一个数组记录每一列的大小对标准矩阵来说每列大小都一样所以我们取cols matrixColSize[0]。returnSize是一个指向整数的指针函数返回前必须给它赋值告诉调用方结果数组的长度。完整实现如下#include stdlib.h int *spiralOrder(int **matrix, int matrixSize, int *matrixColSize, int *returnSize) { if (matrix NULL || matrixSize 0 || matrixColSize NULL || matrixColSize[0] 0) { if (returnSize ! NULL) { *returnSize 0; } return NULL; } int rows matrixSize; int cols matrixColSize[0]; int total rows * cols; int *ans (int *)malloc(sizeof(int) * total); if (ans NULL) { if (returnSize ! NULL) { *returnSize 0; } return NULL; } int idx 0; int left 0, right cols; int top 0, bottom rows; while (left right top bottom) { for (int j left; j right; j) { ans[idx] matrix[top][j]; } top; if (top bottom) { break; } for (int i top; i bottom; i) { ans[idx] matrix[i][right - 1]; } --right; if (left right) { break; } for (int j right - 1; j left; --j) { ans[idx] matrix[bottom - 1][j]; } --bottom; for (int i bottom - 1; i top; --i) { ans[idx] matrix[i][left]; } left; } if (returnSize ! NULL) { *returnSize idx; } return ans; }有几个地方要特别提醒。第一必须检查空矩阵。matrixSize 0或者matrixColSize[0] 0时直接设置*returnSize 0并返回NULL不要让函数继续走 while 循环。第二malloc的返回值一定要检查。虽然在线判题时很少真的分配失败但面试官问起来“你的代码健壮性体现在哪”能答出“我会检查 malloc 失败并返回 NULL”是加分项。第三*returnSize必须赋值否则调用方不知道数组里有多少个有效元素在 C 语言里这是非常容易遗漏的细节。4. 边界退化场景单行、单列、空矩阵逐个击破4.1 为什么单行单列最容易重复打印很多第一次写的同学会在单行、单列上翻车最典型的现象是打印完上边后继续打印右边、下边、左边结果把同一行元素又打印了一遍。本质上是因为他们用的循环条件是类似while (left right top bottom)的全闭区间写法但打印下边时没有判断“当前是否只剩一行”打印左边时没有判断“当前是否只剩一列”。想象一个 1 行 3 列的矩阵全闭区间写法打印完上边 1、2、3 之后如果不去判断top bottom继续打印右边会取matrix[1][right]此时已经越界了。就算你防止了越界这一圈也可能把不该重复的元素再走一遍。所以死记条件不如理解原因一旦边界收缩后相遇说明当前圈的四条边已经覆盖了全部剩余元素必须立刻停止。4.2 一份可直接复跑的测试清单我把所有容易踩坑的用例整理成一个表格。记号方面分号代表换行比如1,2,3;4,5,6表示两行三列的矩阵。用例矩阵预期输出关注点空矩阵无空不能崩溃returnSize 为 01x155单元素矩阵1x31,2,31,2,3只有上边一条边3x11;2;31,2,3只有右列一条边2x21,2;3,41,2,4,3四条边各一步3x31,2,3;4,5,6;7,8,91,2,3,6,9,8,7,4,5最经典的例子2x31,2,3;4,5,61,2,3,6,5,4横向长方形3x21,2;3,4;5,61,2,4,6,5,3纵向长方形你拿着上一节的代码把这八个用例全部跑一遍如果输出都和表格一致边界这一关就算过了。我建议你把这份测试清单记在笔记里以后遇到任何“螺旋遍历”类题目都能直接用。关于 3x2 的用例我手动走一遍验证第一圈上边打印 1、2右边打印 4、6下边打印 5左边打印 3。收缩之后只剩一行一列下一轮循环条件不满足停止。最终输出1,2,4,6,5,3符合预期。5. C 语言特有的数组传参与内存细节5.1 int** 与 int[][N] 的区别这是 C 语言面试必问的知识点。很多初学者以为int a[3][3]可以自动当作int**传进函数其实不行。int a[3][3]在内存里是一段连续的 9 个 int数组名在表达式里退化后类型更接近“指向数组的指针”而不是“指向指针的指针”。int**表示的是一个指针数组数组里的每个元素都是int*每个int*再指向一行的起始地址。实际笔试环境里平台通常已经把矩阵整理成了int**的形式同时用int* matrixColSize告诉你每行有多少列。你直接使用就行。但如果在本地自己构造测试数据最方便的做法就是像我前面的 main 代码那样先定义几个一维数组再用一个指针数组把它们串起来int row0[] {1, 2, 3}; int row1[] {4, 5, 6}; int row2[] {7, 8, 9}; int *matrix[] {row0, row1, row2};如果你想用malloc动态创建一个二维矩阵可以这样int **createMatrix(int rows, int cols) { int **m (int **)malloc(sizeof(int *) * rows); for (int i 0; i rows; i) { m[i] (int *)malloc(sizeof(int) * cols); } return m; } void freeMatrix(int **m, int rows) { for (int i 0; i rows; i) { free(m[i]); } free(m); }注意释放顺序先释放每一行再释放行指针数组本身顺序反过来会泄漏内存或造成未定义行为。5.2 谁分配、谁释放结果数组的所有权如果笔试要求你返回动态数组那么在 C 语言里内存通常是你的函数负责分配调用方负责释放。这个约定要在面试时主动说明避免出现“你分配了内存但没人释放”的隐患。我在 3.3 的代码里ans是函数内部malloc出来的调用方使用完之后必须free(ans)。在main里测试时是这样int returnSize 0; int *ans spiralOrder(matrix, 3, colSizes, returnSize); if (ans ! NULL) { for (int i 0; i returnSize; i) { printf(%d%s, ans[i], (i 1 returnSize) ? , : \n); } free(ans); }这里还能顺带验证一件事函数的返回值到底应该用ans还是ans[idx]。很多人写到最后直接把局部的idx返回了那是严重错误。idx是计数器不是数组首地址。要返回的是ans。5.3 边界健壮性total 的计算与 malloc 失败处理total rows * cols看起来没问题但如果矩阵特别大两个 int 相乘可能溢出。更严谨的写法是long long totalLL (long long)rows * cols; int total (int)totalLL;普通笔试题不会出现这么大的数据但你可以在面试时提一句“如果把规模放大我会用 long long 计算总数”这一个细节就能让面试官觉得你不是只会背代码。另外处理空矩阵时不要轻易malloc(0)。因为malloc(0)的行为在不同编译器中不保证一致有的会返回非空指针有的会返回NULL。与其依赖这种不确定行为不如在入口处统一判断并返回NULL。6. 面试现场的经验代码之外的加分项6.1 先和面试官对齐输入输出约定拿到这道题不要直接低头写。先花 30 秒确认几个问题矩阵的行列是否可能为 0是打印到标准输出还是返回数组如果返回数组是否需要调用方手动释放内存这些问题问出来一方面能避免理解偏差另一方面也展示了你的产品思维和沟通能力。很多面试官并不是期待你一言不发写出完美代码而是想看你在不确定需求时如何反应。6.2 主动做复杂度分析与方案对比动手前可以先说一句“我打算用边界收缩法时间复杂度 O(mn)每个元素访问一次额外空间 O(1)不算结果数组的话。相比模拟转向法不需要 visited 矩阵更适合追求最优解的场景。”这种表述简洁有力既说明你理解了题目本质也让面试官对你的算法视野有一个正面的印象。等代码写完后再补一句“我还可以用单行、单列、空矩阵这几个用例自查一遍”比直接说“写完了”要稳得多。6.3 我的复盘习惯与避坑清单说到复盘我想起自己早年练这道题的一个教训当时我一直用全闭区间版本把下边和左边的打印条件改来改去每次单行用例都多输出一截。后来我干脆换了左闭右开的写法把“打印完就收缩收缩完就检查退化”作为固定节奏问题一下子消失了。现在我每次写这道题都会默念四个动作打印上边收缩上界打印右边收缩右界打印下边收缩下界打印左边收缩左界。每一步后都问自己一句“边界是不是相遇了”如果相遇马上停。这个习惯也迁移到了其他二维数组遍历题目上。无论是旋转图像、搜索二维矩阵还是岛屿问题把“边界变量 退出条件”画在纸上再动手比在脑子里空想要少踩很多坑。最后再分享一个小技巧笔试或面试时如果允许用笔纸先在草稿纸上画一个 3×3 的矩阵用箭头标出四条边的起点和终点再把代码里的 left、right、top、bottom 分别标在图上。这个过程看起来浪费时间实际上能帮你把边界条件看得一清二楚。顺时针打印矩阵这种题画图永远比空想快。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →