☰
C语言经典习题:多维矩阵鞍点查找的三种解法与避坑指南
2026/10/3 14:48:06 网站建设 项目流程

刚开始刷菜鸟教程C经典100例的时候,我一直觉得“把题目做出来、能跑通”就算完成任务了。直到做到第55题——用C语言查找一个5×5矩阵中的鞍点,我才发现这类“条件叠加”的题目远比想象中容易出问题。它考察的知识点非常基础:二维数组、嵌套循环、比较运算和标志位,但把“行最大”和“列最小”两个条件同时摆到同一个元素身上之后,很多隐藏的边界情况就会冒出来。这篇文章就围绕这道题,把我自己从读题、设计思路、写代码,到调试踩坑、再扩展成通用实现的全过程整理出来,希望能帮正在刷这一系列题的朋友少走弯路。

1. 练习55到底在考什么:鞍点定义与题面里容易被忽略的两个细节

先说题目本身。经典的描述是:输入一个5×5的矩阵,找出其中的鞍点,如果存在则输出它的位置和值,如果不存在则给出提示。所谓鞍点,指的是这样一个元素:它在自己所在的行上是最大值,同时在自己所在的列上是最小值。

“鞍点”这个名字其实挺形象,想象一下马鞍的曲面:沿着一个方向看它是隆起的最高处,沿着另一个方向看它又是凹陷的最低处。矩阵里的鞍点就是这个道理,所以判断条件必须同时满足两个不等式,缺一不可。

这道题难吗?单纯从语法难度来说,它连指针、结构体都没用到,是所有C语言教材前几章就该掌握的二维数组内容。但实际写起来,很多人的问题出在下面两个细节上。

第一个细节:题面没有保证“每行的最大值唯一”或“每列的最小值唯一”。也就是说,同一行里可能出现多个相同的最大值,同一列里可能出现多个相同的最小值。这种情况下,鞍点可能不止一个,也可能因为并列极值而出现“同一个行最大里只有某个位置同时满足列最小”的情况。很多参考答案为了简单,统一定义为“找第一个”,这在严格意义上损失了对题意的完整覆盖。

第二个细节:很多初学者理解成“先找每行最大值,再看这个最大值在不在该列最小”,这个思考方向本身没错,但实现时容易犯一个毛病——只记住“最大值是多少”,却不记住“最大值出现在哪一列”,或者反过来只记住下标却丢掉了值。一旦遇到并列极值,判断就会出错。

从学习角度说,这道题真正的考点不是“会不会写for循环”,而是“能不能把两个维度的条件有条理地组合起来”。这正好是后面学习矩阵运算、查找算法、动态规划等内容的底层思维训练,所以值得认真对待。

2. 三种解法的取舍:为什么我最后选了“预计算数组”

针对这个题目,我第一次想到的解法很直接:遍历每一个元素,对这个元素所在的行重新扫描一遍找最大值,再对这个元素所在的列重新扫描一遍找最小值,如果当前元素同时等于行最大值和列最小值,就认定它是鞍点。每个元素都要做一次行列扫描,所以时间复杂度是O(n³),这里n是5,规模小,完全跑得动。

为了方便说明,我把这种解法称为“暴力检查法”。它的代码结构大概长这样:

for (int i = 0; i < 5; i++) { for (int j = 0; j < 5; j++) { int rowMax = matrix[i][0]; int colMin = matrix[0][j]; for (int k = 0; k < 5; k++) { if (matrix[i][k] > rowMax) rowMax = matrix[i][k]; } for (int k = 0; k < 5; k++) { if (matrix[k][j] < colMin) colMin = matrix[k][j]; } if (matrix[i][j] == rowMax && matrix[i][j] == colMin) { printf("鞍点: matrix[%d][%d] = %d\n", i, j, matrix[i][j]); } } }

这个写法虽然能跑,但有一个很明显的问题:重复扫描太多了。每一个元素都要把整行、整列各遍历一遍,5×5矩阵不觉得慢,可如果哪天把题目改成50×50,性能立刻会变得难看。更麻烦的是,代码里一旦夹杂着多个循环变量,初学者很容易把行列下标搞混,写错一个括号,排查半天。

第二种解法是“预计算法”。既然判断鞍点只需要知道“该元素是否等于行最大值且等于列最小值”,那我可以先把每一行的最大值存到一个数组里,把每一列的最小值存到另一个数组里,然后再做一次双重循环,直接比较当前元素和这两个数组里对应位置的值。这样整体只需要两轮双重循环,时间复杂度降到O(n²),代码逻辑也更清楚。

第三种解法是“下标记录法”。很多人会写:先找每行的最大值,同时记录这个最大值所在的列下标,然后再去验证这一列是不是最小值。这种思路看着高效,实际上藏着很大隐患,因为当一行里出现多个并列最大值时,你记录的下标只能是其中某一个位置,通常是最后一个,那么前面同样满足“行最大”的元素就被直接跳过了。我稍后会用一个具体例子说明这是怎么漏掉鞍点的。

权衡之后,我选择了预计算法。原因有两点:第一,它用“值相等比较”代替了“下标回溯验证”,天然能够处理并列极值的情况,不容易漏判;第二,行最大数组和列最小数组的概念很清晰,代码的可读性高,对初学者来说更容易理解和维护。

下面这张表是我当时对三种方法的简单对比,方便你直观感受差异:

方法时间复杂度代码复杂度处理并列极值能力推荐程度
暴力检查法O(n³)中等,循环嵌套多可以,但容易写乱不推荐
预计算数组法O(n²)较低,逻辑清晰强推荐
下标记录法O(n²)低弱,容易漏判不推荐

3. 用stdio.h和limits.h实现的完整代码与逐行解读

选定了预计算数组法之后,就要动手写代码。这里先说一个容易被忽略的问题:初始化行最大值和列最小值时,到底用什么初值合适?很多初学者习惯用矩阵的第一个元素,也就是rowMax[i] = matrix[i][0],这样在大多数情况下没问题,可一旦矩阵里全是负数,或者你想要一个更通用的解法时,这种写法就显得不够稳健。

更好的做法是利用limits.h头文件里定义的INT_MIN和INT_MAX。INT_MIN是int类型能表示的最小值,用给行最大值做初始值,那么读入第一个元素时它必然会被更新;同理,INT_MAX是int类型能表示的最大值,用给列最小值做初始值也一定合理。这两个宏就是专门为这种情况准备的,用起来干净利落。

下面是完整的代码,我加了比较详细的注释:

#include <stdio.h> #include <limits.h> #define ROWS 5 #define COLS 5 int main(void) { int matrix[ROWS][COLS]; int rowMax[ROWS]; int colMin[COLS]; // 初始化:行最大取int最小可能值,列最小取int最大可能值 for (int i = 0; i < ROWS; i++) { rowMax[i] = INT_MIN; } for (int j = 0; j < COLS; j++) { colMin[j] = INT_MAX; } printf("请输入%d*%d矩阵的元素:\n", ROWS, COLS); // 读入矩阵数据,scanf会按空白字符自动分隔数字 for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { scanf("%d", &matrix[i][j]); } } // 一次双重循环同时完成行最大、列最小的统计 for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { if (matrix[i][j] > rowMax[i]) { rowMax[i] = matrix[i][j]; } if (matrix[i][j] < colMin[j]) { colMin[j] = matrix[i][j]; } } } // 遍历每一个元素,同时满足两个相等条件即为鞍点 int found = 0; for (int i = 0; i < ROWS; i++) { for (int j = 0; j < COLS; j++) { if (matrix[i][j] == rowMax[i] && matrix[i][j] == colMin[j]) { printf("找到鞍点:matrix[%d][%d] = %d\n", i, j, matrix[i][j]); found = 1; } } } if (!found) { printf("该矩阵不存在鞍点\n"); } return 0; }

这段代码里最核心的地方在于统计行最大和列最小的循环。乍一看可能会疑惑:为什么只用了一个双重循环,就能同时更新两个数组?因为rowMax是按行更新的,它只看外层循环的i;colMin是按列更新的,它只看内层循环的j。遍历顺序是从左到右、从上到下,无论先遇到哪一列,colMin[j]的更新逻辑都不受影响,所以完全可以在同一轮循环里完成两种统计。

判定部分则更直接:只要当前位置的值同时等于它所在行的最大值和所在列的最小值,它就是鞍点。注意这里用的是==比较而不是>或<,因为我们已经把最优值算出来了,现在只需要判断是否命中。

关于found这个标志位,看起来很简单,但很多人第一次写会忘记它。如果没有这个标志,矩阵不存在鞍点时,程序就什么也不输出,用户根本不知道是程序跑完了还是出了bug。加上found,程序的行为就非常明确:要么输出至少一个鞍点,要么明确告诉你“不存在”。

我放两个测试样例在下面,你可以直接复制运行验证。

有鞍点的样例:

9 1 2 3 4 10 1 1 1 1 11 1 1 1 1 12 1 1 1 1 13 1 1 1 1

这个矩阵中,matrix[0][0] = 9是第0行的最大值,同时是第0列的最小值,所以程序会输出找到鞍点。

无鞍点的样例:

1 2 3 4 5 2 3 4 5 6 3 4 5 6 7 4 5 6 7 8 5 6 7 8 9

每一行的最大值分别是5、6、7、8、9,每一列的最小值分别是1、2、3、4、5,没有任何元素同时满足两个条件,程序会输出“该矩阵不存在鞍点”。

4. 调试过程中踩过的坑:并列极值、矩阵读入与无鞍点输出

代码看起来简单,但真正运行起来、换着花样测试的时候,我先后踩过好几个坑。有些坑是题目本身设下的陷阱,有些则是C语言基本功不扎实导致的,这里全部记录下来。

第一个坑就是前面提到的并列极值问题。如果我采用“先记录每行最大值的下标,再回头验证列最小”的写法,就很可能出问题。比如下面这个3×3矩阵:

3 3 4 5 1 2 6 7 8

第0行的最大值是3,这一行里有两个3,分别在第0列和第1列。如果我用一个变量pos记录“最后一个等于行最大值的列下标”,那pos会等于1,也就是指向matrix[0][1]这个位置。然后我去验证第1列的最小值,发现matrix[0][1] = 3,但第1列的元素是3、1、7,最小值是1,所以这个位置不是鞍点,程序就会输出“不存在鞍点”。

可实际上,matrix[0][0] = 3是第0列的最小值(第0列元素是3、5、6),同时又是第0行的最大值,它才是真正的鞍点。换句话说,下标记录法因为只记住了最后一个并列最大值的位置,活活把正确答案漏掉了。

这个例子给我的教训很深:当题目没有明确规定“极值唯一”时,最稳妥的判断方式永远是“直接用值进行比较”,而不是“记住一个下标再回头验证”。这也是我最终选择预计算数组法的根本原因。

第二个坑是关于scanf读入的。C语言的scanf按空白字符自动分隔输入,所以用户在输入矩阵时,可以换行输入,也可以空格隔开,甚至混着来都行。但如果你在测试时少打了一个数字,程序并不会立刻报错,而是会把后面的数字错位读入,导致整个矩阵数据乱掉。这种错误非常隐蔽,因为程序能正常跑完,输出的结果却莫名其妙。

解决办法有两个。一是输入时多留个心眼,每次检查scanf的返回值,如果返回值不等于1就说明读入失败,可以给出提示;二是调试阶段在读入完成后,先把整个矩阵打印一遍,确认数据没有错位再继续执行。我在实际调试中每次都会加一段临时的打印循环,确认无误后再写后续逻辑,这个习惯帮我省下了很多排查时间。

第三个坑是初始化值选错。如果用0作为行最大值的初始值,一旦矩阵里所有元素都是负数,那么每一行的最大值都会被错误地算成0,因为负数永远不大于0,这会导致行最大值数组全部错误。用INT_MIN和INT_MAX就完全避免了这个问题,因为它们分别是int类型能表达的最小值和最大值,任何合法的int输入都能正确更新初始状态。

第四个坑其实不算坑,更多是输出格式的细节。题目里的“第几行第几列”通常默认从1开始数,而数组下标是从0开始的。如果直接输出matrix[0][0],用户会觉得这是“第0行”,看起来很别扭。我习惯在输出时把下标加1,变成matrix[1][1],这样和日常说法一致,别人看输出信息时会更舒服。

还有一个不能忽略的问题:如果矩阵里存在多个鞍点,程序应该全部输出还是只输出第一个?这取决于题目要求。菜鸟教程的经典版本一般没有明确说“只输出一个”,所以我倾向于全部输出,并在代码注释里说明这一点。这样不管测试数据里有没有多个鞍点,结果都不会遗漏。

5. 把5x5限定改成任意行列:一版可复用的通用实现

练习55的题面把矩阵固定为5×5,这简化了数组定义,但作为练手,我建议你把它改造成可以处理任意行数和列数的版本。这样不仅能加深理解,以后遇到类似题目还能直接复用。

最简单的改造方式是使用C99标准支持的变长数组(VLA)。所谓变长数组,就是数组的长度在程序运行时才确定,由变量指定。比如:

int rows, cols; printf("请输入矩阵行数和列数:"); scanf("%d%d", &rows, &cols); int matrix[rows][cols]; int rowMax[rows]; int colMin[cols];

C99之后很多编译器都支持这种写法,包括GCC。不过要注意,一些老的编译器或者某些OJ平台可能不开放变长数组,更通用的做法是用malloc动态分配内存。关键代码如下:

#include <stdio.h> #include <stdlib.h> #include <limits.h> int main(void) { int rows, cols; printf("请输入矩阵行数和列数:"); scanf("%d%d", &rows, &cols); int **matrix = malloc(rows * sizeof(int *)); int *rowMax = malloc(rows * sizeof(int)); int *colMin = malloc(cols * sizeof(int)); for (int i = 0; i < rows; i++) { matrix[i] = malloc(cols * sizeof(int)); } // 初始化 for (int i = 0; i < rows; i++) { rowMax[i] = INT_MIN; } for (int j = 0; j < cols; j++) { colMin[j] = INT_MAX; } // 读入 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { scanf("%d", &matrix[i][j]); } } // 统计 for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (matrix[i][j] > rowMax[i]) { rowMax[i] = matrix[i][j]; } if (matrix[i][j] < colMin[j]) { colMin[j] = matrix[i][j]; } } } // 查找 int found = 0; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (matrix[i][j] == rowMax[i] && matrix[i][j] == colMin[j]) { printf("找到鞍点:matrix[%d][%d] = %d\n", i + 1, j + 1, matrix[i][j]); found = 1; } } } if (!found) { printf("该矩阵不存在鞍点\n"); } // 释放内存 for (int i = 0; i < rows; i++) { free(matrix[i]); } free(matrix); free(rowMax); free(colMin); return 0; }

动态内存版本看起来繁琐,但思路其实和固定数组完全一样,只是把int matrix[5][5]换成了二级指针结构,并且记得在程序结束前释放内存。很多初学者会忽略释放这一步,导致程序在循环使用时会内存泄漏,虽然5×5这种小型程序跑一次就退出,影响不大,但养成释放内存的好习惯非常重要。

如果你想把这段逻辑封装成函数,需要特别注意二维数组作为函数参数时的写法。如果使用固定数组,函数原型里必须带上列数,比如void findSaddle(int matrix[5][5]),其中第二维大小不能省略。如果使用动态分配,则可以写成void findSaddle(int **matrix, int rows, int cols),因为二级指针本身就携带了行列维度需要额外参数传入。

从练习的角度看,我建议你把“输入矩阵并计算鞍点”这一个完整问题拆分成三个函数:读入矩阵、计算行最大列最小、查找并输出鞍点。这样既符合模块化编程的思想,也为之后学习多文件项目打下基础。

6. 顺着这道题再往前想一步:极值判断模式还能用在哪儿

练完练习55之后,我最大的收获其实不是会做一道鞍点题,而是发现“行最大、列最小”这种极值组合模式在现实场景里很常见,只是换了一层外衣。

举个例子,在图像处理里有一种“局部极值”的检测思路。一张图片可以看成像素矩阵,某个像素如果比它周围一圈的所有像素都亮或者都暗,这个点往往对应着图像中的特征点。虽然具体算法和鞍点不完全相同,但核心思维方式一致:你需要在两个不同的方向上做极值判断,然后把两个条件结合起来得出结论。理解了练习55,再去读非极大值抑制相关代码时,至少不会对“同时满足两个极值条件”这个套路感到陌生。

再比如,在你以后学到算法设计时,这种“先预处理、再查询”的两阶段思路可以说是最常用的优化手段之一。暴力法遍历每个元素时都要重新计算行列极值,而预计算法把极值提前算好,查询阶段只需要常数时间。这不只是鞍点题能用,很多需要频繁查询区间最值、二维前缀和的题目,本质上都是这个思路的变体。

我从这道题里总结出了一个做题套路,分享给你:拿到题目先别急着写代码,先把限制条件拆开,问自己三个问题。第一,条件之间是“并且”还是“或者”的关系?第二,极值是否唯一?不唯一时会不会产生多个答案?第三,如果数据规模变大,当前方案还能不能跑?想清楚这三个问题,代码的结构基本上就定下来了。

顺着练习55还可以展开很多变式练习,这里列几个我觉得值得动手写的方向:

  • 把“行最大、列最小”改成“行最小、列最大”,程序只改两个比较符号,但你能借此理解对称逻辑。
  • 改成“严格鞍点”,也就是行最大值必须唯一且列最小值也必须唯一,此时并列极值出现时不算鞍点。
  • 输入一个10×10矩阵,把所有鞍点及其位置都输出,并统计总数。
  • 把矩阵数据从标准输入改成从一个文本文件读取,这需要用到fopen、fscanf等文件操作函数,又引出了新的知识点。

这些变式看似只是换条件,实际上每换一个条件,都可能出现新的边界情况。只有自己亲手改过、跑过、踩过坑,才算真正把这道题吃透了。

最后说一点我自己的体会。我见过很多初学者刷题时喜欢快速看答案,看完觉得自己懂了,合上书本又写不出来。练习55这种题目恰恰说明:看懂答案和写出正确代码之间,隔着一整条“边界情况处理”的鸿沟。你在测试时多换几组数据,多想想“如果这里并列最大值怎么办”,远比把代码背下来有价值。希望这篇记录能帮你把这道题彻底拿下,也顺带建立起处理二维数组综合问题的自信。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询