在哈工大的SSE编程练习平台上,第33题“求5×5矩阵的鞍点”是很多学C语言的同学绕不过去的一道题。SSE这个平台提交代码后只会返回一个干巴巴的结果,不告诉你哪个用例挂了,也没有网络OJ那么丰富的讨论区,所以刷这种练习,光会写代码还不够,还得能自己定位问题。这篇文章我打算以练习33为例子,把题目拆解、算法设计、完整C代码、常见WA原因和SSE评测的特点一次说清楚。刚学二维数组的新手可以直接照着思路写;已经能AC的同学,建议重点看第4节和第5节,里面有不少我在这个平台上踩过的坑,能帮你省下几十分钟的无谓调试。
1. 先把SSE平台的“脾气”摸清楚
1.1 练习33在SSE上的一般流程
SSE是哈工大C语言课程里常用的在线练习评测环境,跟校外那些公开在线测评网站相比,它更像是一个校内训练场。通常的操作流程是:进入网页后找到对应的作业列表,看到题目编号、截止时间、题目描述,在答题框里粘贴代码,提交后等结果。
这里有一个很实用的建议:不要直接在网页文本框里写完整代码。SSE的编辑框一般没有语法高亮,也不方便查看错误提醒,万一页面刷新一下,写了一半的代码可能就没了。我在实际刷题时都是先在本地编辑器里把代码写好、调好,再整段粘贴进去提交。本地用VS Code、CLion、Dev-C++都行,关键是提前把编译错误处理干净。
SSE每道题是按编号排的,练习33在我的题库里正好就是“求5×5矩阵的鞍点”。不过不同年份、不同老师开的题册顺序可能不一样,如果你那边的33不是这道题,也没关系,后面提到的二维数组处理思路、调试方法和平台提交注意点,对SSE上的绝大多数C语言练习题都适用。
1.2 这道题要考的前置知识
鞍点题出现在二维数组这一章,通常是在讲完数组、循环、分支之后,还没系统讲指针和结构体之前。做这道题之前,建议先确认自己掌握了下面几个点:
- 二维数组的定义和初始化:
int a[5][5]; - 用嵌套for循环遍历二维数组元素
scanf读入二维数组元素的基本写法- 用
>、<比较大小,并用变量记录最大值、最小值
这些点单独说都不难,但组合在一起,很容易在细节上翻车。比如数组下标从0开始,第1行其实是a[0];又比如嵌套循环的量词i和j分别控制行和列,一旦写反,整个矩阵的行列就颠倒了。第33题真正考的,其实是对“二维数组索引”的敏感度。
1.3 为什么偏要选这道题当练习
把鞍点问题放在二维数组练习里是有道理的。它不像九九乘法表那样只靠两个循环的简单拼接,也不像字符串逆序那样只要倒着输出。鞍点问题要求你在两个方向上分别做极值判断:先在行方向找到最大值,再在列方向验证最小值。这种“先固定一个维度,再到另一个维度验证”的思路,在后面的矩阵类题目里会反复出现,比如矩阵旋转、行列式、生命游戏,本质上都是对行和列索引的交叉使用。所以练习33不是单纯为了让你会做这一道题,而是帮你建立一种空间遍历的感觉。
2. 理解鞍点,以及这个题目里容易忽略的“歧义”
2.1 鞍点到底是什么
鞍点的名字来自马鞍的形状:在一个方向上是隆起的最高点,在另一个方向上又是下凹的最低点。放到矩阵里,一个元素被称为鞍点,需要同时满足两个条件:
- 在它所在的那一行里,它是最大值;
- 在它所在的那一列里,它是最小值。
我用一个3×3的小矩阵演示一下:
1 2 3 4 5 6 7 8 9这里第一行的最大值是3,它在第3列,第三列的值是3、6、9,最小值正好是3,所以a[0][2]这个位置的3就是鞍点。你可以把它理解成“行内最突出,列内最不起眼”的一个元素。
有些课本把鞍点定义成“行最小列最大”,方向正好反一下。你在动手写代码之前,必须确认你们题面里说的是哪一种。我刷到的是“行最大、列最小”,后面给出的完整代码也是按这个口径来写的。
2.2 最大的坑:一行里出现多个最大值怎么办
很多新手第一次写这题,思路是“每一行找出最大值,记录下标,然后比较它所在列的最小值”。这个思路在数据没有重复时没问题,但一旦某一行最大值出现了两次,就是灾难。
举个例子,某一行是:
3 3 1 1 1按“只记录第一个最大值下标”的方式,程序只会检查第一个3对应列的最小值,如果这一列最小值恰好是2,检查结果是不满足,于是程序认为这一行没有鞍点。但第二个3所在的列,可能最小值就是3,那它其实是鞍点。这就是经典的漏判问题。
正确做法是:确定某一行最大值后,再扫描这一行的所有元素,凡是等于这个最大值的下标,都去对应列做一次最小值的校验。这样做虽然多了几次比较,但能保证不会漏掉并列最大值的情况。在第3节的完整代码里,我就是这么处理的。
2.3 输出格式的不同版本
鞍点题在输出要求上特别不统一,不同题库版本给出的格式可能完全不一样。我见过至少这几种:
| 输出描述 | 示例输出 |
|---|---|
| 输出行号、列号和值,空格隔开 | 1 5 5 |
| 输出类似数组元素写法 | a[0][4]=5 |
| 带中文说明 | 鞍点位置:1行5列,值:5 |
| 找不到时的英文提示 | No saddle point |
| 找不到时的中文提示 | 不存在鞍点 |
这里给一个非常重要的提醒:不要凭记忆或网上的代码猜输出格式,一定要以你自己SSE题面上给出的输出样例为准。很多人在本地运行完全正确,一提交就WA,十有八九是输出里多了一个冒号、少了一个空格,或者提示语跟题面不一致。后面第4节我会专门讲怎么排查这种问题。
3. 从读题到AC:完整代码与实现细节
3.1 一份可以直接用的完整C代码
我用stdio.h负责输入输出,用limits.h里的INT_MAX来初始化“列最小值”,这样做的好处是不用单独把某一行或某一列的第一个元素单独拎出来当初始值。
#include <stdio.h> #include <limits.h> int main(void) { int a[5][5]; int i, j, k; int row_max; int col_min; int found = 0; // 读入5x5矩阵 for (i = 0; i < 5; i++) { for (j = 0; j < 5; j++) { scanf("%d", &a[i][j]); } } // 逐行处理 for (i = 0; i < 5; i++) { // 第一步:找出第i行的最大值 row_max = a[i][0]; for (j = 1; j < 5; j++) { if (a[i][j] > row_max) { row_max = a[i][j]; } } // 第二步:遍历第i行所有等于row_max的位置 for (j = 0; j < 5; j++) { if (a[i][j] != row_max) { continue; } // 第三步:找第j列的最小值 col_min = INT_MAX; for (k = 0; k < 5; k++) { if (a[k][j] < col_min) { col_min = a[k][j]; } } // 第四步:如果列最小值就是这个元素,就是鞍点 if (col_min == a[i][j]) { printf("%d %d %d\n", i + 1, j + 1, a[i][j]); found = 1; break; } } if (found) { break; } } if (!found) { printf("No saddle point\n"); } return 0; }这份代码的思路非常简单:一层一层剥,先处理行,再处理列。最后输出时我让行号、列号从1开始,这样更符合普通人读题时的习惯。如果你的题面要求下标从0开始,把i + 1改成i,j + 1改成j就可以。
3.2 关键变量和循环为什么要这么写
先看row_max。它的作用是在一行内部“打擂台”,从第一个元素开始,依次跟后面的元素比较,遇到更大的就更新。这个写法是求最大值的标准模板,几乎在每道C语言题里都会用到。注意循环变量j在这里是从1开始的,因为a[i][0]已经被当作初始值了,没必要再跟自己比一遍。
再看col_min。我用INT_MAX作为初始值,来自limits.h。这相当于把“列最小值”赛跑的起跑线拉得很远,任何正常整数元素都比它小,所以第一次比较就能把当前元素收进来。如果你不用INT_MAX,就需要额外写一句“先把a[0][j]当作最小值”,逻辑上多一行,也不算难,但用INT_MAX让代码语义更干净。
found这个标志变量是控制程序结束的。题目如果只要找一个鞍点,找到一个就可以直接退出外层循环,避免后面的行继续做无用功。我设置了两个break:内层判断确认后先跳出“遍历该行所有候选位置”的循环,再通过if (found) break;跳出“逐行处理”的循环。
还有一处容易被忽略的细节:内层循环里我先判断a[i][j] != row_max,不相等就continue。这一步就是把“一行里多个最大值都要验证”落实到位。只要有某个位置的元素等于行最大值,就进入列校验,不会漏掉并列最大值的情况。
3.3 输入和数组边界要注意的事
scanf读取整数时会自动跳过空格和换行,所以你不需要在输入排版上做额外处理。格子之间不管是用空格隔开,还是用回车换行,scanf("%d", &a[i][j])都能正确按顺序读到25个整数。
数组下标从0到4,这是新手最常犯迷糊的地方。很多人写着写着,循环条件就变成i <= 5,结果数组越界,读进来一个莫名其妙的垃圾值。如果担心下标混乱,我建议先明确概念:5×5矩阵一共有5行、5列,行的索引是0、1、2、3、4,列的索引也是0、1、2、3、4,所有循环写< 5而不是<= 5。
如果题目不是固定5×5,而是让你先输入n和m,再输入一个n×m矩阵,代码就需要做两处改动:一是把数组定义成更大的大小,比如int a[100][100];二是把所有循环界限从5改成n和m。更通用的做法是用#define N 5定义常量,算出一个固定的最大容量,再根据实际行列数控制循环。这种做法在参加课程设计或者后续刷更多题目时更常用。
4. 实测调试:那些WA和“玄学”问题怎么排查
4.1 本地能跑,提交到SSE却不对
这是SSE练习里最高频的问题,没有之一。我的经验里,大部分原因集中在下面几类:
| 现象 | 主要检查方向 |
|---|---|
| 输出结果对但被判WA | 多输出了一行提示、少了一个换行、提示语拼写不一样 |
| 程序本地正常但提交后编译失败 | void main、遗漏头文件、中文全角符号、注释里的中文编码问题 |
| 提交后显示答案错误 | 检查是否只找一个鞍点而题目要求找全部鞍点 |
| 运行时无响应或超时 | 循环条件错误导致死循环、scanf读取条件不匹配 |
先说编译失败。SSE的编译环境一般是Linux下的GCC,对C语言标准的支持比较严格。你在Windows本地写了void main(),本地编辑器可能不报错,但Linux下一行就会报警或直接编译失败。正确写法是int main(void),最后return 0;。另外system("pause")、getch()这类Windows专有的调用,提交前一定要删掉,否则评测机跑起来会卡住或正名报错。
再说答案错误。这道题如果题目要求输出所有鞍点,而代码找到一个就退出,必然丢解。怎么判断题目是否需要全部?看题面描述:如果说“输出所有鞍点”,就说明可能有多个;如果说“找出该矩阵的鞍点”,多半找一个即可。输出之前,先读一遍题。
4.2 构造自测数据,反向验证算法
我做OJ类题目有个习惯:先不急着提交,自己构造几组有代表性的数据,手算一遍结果,再让程序跑一遍。鞍点问题我建议准备三类测试数据。
第一类:确定有鞍点。最简单的就是递增矩阵:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25第一行最大值5,第五列最小值也是5,鞍点是1 5 5。
第二类:确定没有鞍点。比如用下面这组:
4 1 1 1 1 -1 5 1 1 1 -2 1 6 1 1 -3 1 1 7 1 -4 1 1 1 8逐行看,每行最大值分别在4、5、6、7、8所在的位置,而它们各自所在列的最小值都比它们小,所以没有任何一个位置同时满足“行最大、列最小”,应该输出无鞍点提示。
第三类:多个鞍点。比如:
3 3 3 3 3 2 2 2 2 2 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3第三行全是1,每列最小值也是1,所以这一行的每一个位置都是鞍点,一共5个。如果你的代码要求输出全部,这一步能立刻暴露“找到一个就退出”的问题。
测试时不需要手动一遍遍敲输入。在本地把测试输入存成test.txt,然后在命令行里用输入重定向运行程序:./a.out < test.txt,Windows下是a.exe < test.txt。这样反复测试不费劲,还能避免粘贴错数据。
4.3 多组数据与EOF处理
有些版本的鞍点题,输入不是单独一组5×5矩阵,而是连续给很多组,直到文件结束。如果你的题面写了“输入数据包含多个测试用例”之类的话,就必须用EOF循环。一个常见写法:
while (scanf("%d", &a[0][0]) != EOF) { for (i = 0; i < 5; i++) { for (j = 0; j < 5; j++) { if (i == 0 && j == 0) { continue; } scanf("%d", &a[i][j]); } } // 处理当前矩阵 }当然这个代码看起来有点绕,更常见的做法是读入&temp判断EOF后再填进数组。不过练习33大多数版本都只用一组数据,你只需要花一分钟扫一眼题面,确认输入描述里有没有“多组”关键词就行。
4.4 SSE不会告诉你具体错误,只能自己排查
SSE这类校内平台,反馈通常很粗糙:要么“通过”,要么“不通过”。它不会像很多公开OJ那样给出“格式错误”“答案错误”“运行超时”的分类,所以你必须养成一个习惯:把“不通过”当成一个综合故障来排查。
我一般按这个顺序查:先确认编译无警告,再确认输出格式跟题面样例一模一样,然后跑一遍自测数据,最后检查有没有漏掉并列最大值、多个鞍点这类边界情况。如果还是找不到问题,就把代码里关键的中间变量用printf打印出来看。比如在找到row_max之后打印一行:
printf("debug: row=%d row_max=%d\n", i, row_max);确认行最大值的计算没问题,再继续排查列校验部分。调试完记得把这些printf删掉,不然SSE会把你额外输出的调试信息当成答案内容的一部分,必然WA。
5. 把练习题变成自己的算法资产
5.1 把单题拆成可复用函数
练习33的代码可以写成一个main函数从头到尾,但我更推荐你提前感受一下模块化。把“求某行最大值”和“求某列最小值”分别写成函数,主函数逻辑会清晰很多。比如:
int row_max_value(int a[][5], int row) { int max = a[row][0]; int j; for (j = 1; j < 5; j++) { if (a[row][j] > max) { max = a[row][j]; } } return max; } int col_min_value(int a[][5], int col) { int min = INT_MAX; int i; for (i = 0; i < 5; i++) { if (a[i][col] < min) { min = a[i][col]; } } return min; }写函数时要特别注意:二维数组作为函数参数时,第二维的大小必须写明,比如int a[][5]。不然编译器无法计算a[i][j]的地址偏移。这个知识点后面讲数组传参和指针时还会反复遇到,你提前在练习33里用一次,印象会深很多。
5.2 从固定5×5到任意n×m矩阵
如果题目扩展成n×m,固定数组就没那么优雅了。处理方法有两种:一种是像前面说的,定义一个大数组,比如int a[100][100],然后按实际输入的n、m控制循环;另一种是学完动态内存分配后,用malloc申请二维数组空间,不过那是后话,不用在练习33里硬上。
从这道题延伸出去,你还可以试着自己改造一下:把“行最大列最小”改成“行最小列最大”,看看输出结果有什么变化;再把“只要一个鞍点”改成“输出所有鞍点”,比较两种需求对代码结构的影响。这些变形练习能很好地训练你写代码的灵活性,而不是只记住一个模板。
5.3 让调试技巧成为你刷题的本能
刷SSE练习,最不值钱的是“知道自己错了”,最值钱的其实是“能快速定位哪里错了”。我在练习33之后,养成了一套固定调试流程:先制造一个最小规模的复现案例,比如3×3甚至2×2矩阵,手算一遍看输出;再用随机数生成多组矩阵,跑完看会不会异常;最后才提交。尤其是边界情况,比如矩阵全是同一个数、全是递增数、某一行全相等,至少要过一遍。
还有一个很土但很好用的方法:把题目要求的输出样例原样复制到代码注释里,写完代码后对照注释里的样例,逐字符检查自己的printf字符串。这能解决一大半“莫名其妙WA”的问题。
5.4 我在SSE练习33上的一次实际教训
说一个我自己的真实经历。第一次做这题,我在本地输出的是SaddlePoint: 1 5 5,还带了一个冒号,自以为很人性化,结果提交上去WA。我盯着代码看了半天也没发现问题,后来重新翻开题面,才发现它要求的输出格式是1 5 5,没有任何前缀。多写一个冒号都不行。从那以后我养成了一个习惯:动手写代码前,先把题面里的“输出样例”抄在注释最上方,写完代码提交前逐字符比对。这个习惯帮我避开了至少十次类似的无效提交。
如果你现在也卡在SSE的练习33,先别急着怀疑自己的算法,把输出那一行放大看一遍。大部分时候,答案就藏在格式里。