ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言经典习题:多维矩阵鞍点查找的三种解法与避坑指南

C语言经典习题:多维矩阵鞍点查找的三种解法与避坑指南 刚开始刷菜鸟教程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是按行更新的它只看外层循环的icolMin是按列更新的它只看内层循环的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这种题目恰恰说明看懂答案和写出正确代码之间隔着一整条“边界情况处理”的鸿沟。你在测试时多换几组数据多想想“如果这里并列最大值怎么办”远比把代码背下来有价值。希望这篇记录能帮你把这道题彻底拿下也顺带建立起处理二维数组综合问题的自信。
返回列表