ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PTA找鞍点算法详解:C语言与Python实现,避开常见坑

PTA找鞍点算法详解:C语言与Python实现,避开常见坑

PTA 实验7-2-8 找鞍点:一个二维数组题,到底藏了多少坑

看到“找鞍点”这三个字,很多初学C语言的同学第一反应是:这题简单,不就是遍历一下矩阵找最大最小值嘛。可真要动手写,十个里有五六个会踩坑——不是输出格式不对,就是判断条件写反,要么就是死活输出NONE。我帮人调试这道题的时候,反反复复看到同样的错误模式,今天干脆把这道题从头到尾拆开讲清楚。

这篇文章写给正在学二维数组、刷PTA基础题、或者想搞清楚“双重条件判断”怎么组织的读者。我会从题目定义开始,给出C和Python两套完整代码,再把常见的错误一张表列出来,最后聊聊这个“找鞍点”的思路还能用到什么地方去。全文都是实际跑过的代码和调试心得,你直接照抄代码、照着思路走,基本不会再被这道题卡住。

1. 题目到底在考什么:一个被低估的二维数组模型

1.1 鞍点的定义:行上最大、列上最小

先看题目本身的定义。所谓鞍点,是指矩阵中某个位置上的元素,在它所在的那一行里是最大值,同时在它所在的那一列里是最小值。也就是说,这个点要同时满足“横向称王”和“纵向垫底”两个条件。注意这个“同时”,它是这道题全部的难点所在。

举个例子,矩阵:

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

从第0行开始看,第0行最大值是7,在第1列;第1列从上到下是7、8、6、7,最小值是6。7不等于6,所以(0,1)不是鞍点。再看第2行,元素是1、6、1、2,最大值是6,也在第1列;第1列的最小值还是6,刚好等于这个元素。因此(2,1)这个位置就是鞍点,输出格式是“2 1”。

如果你自己找过鞍点,会发现手工找的过程天然就是“先看行、再验列”的。算法也应当如此:先从每一行里挑出最大值的位置,然后检查这个位置是不是它所在列的最小值。这个逻辑理清了,写代码就顺了。

很多同学一开始会把题目理解成“找到矩阵最大的数”或者“找到每行每列都满足什么规律的数”,那方向就完全跑偏了。鞍点不是全局最大,也不是全局最小,它只是一种“局部极值”的特殊组合。这也是这个题目真正想训练的点:二维数组里的条件组合判断。

1.2 输入输出格式是第一个隐藏考点

这道题的输入很直白:第一行给一个正整数n,表示n阶方阵;后面n行,每行给n个整数,用空格分开。输出则要求“输出鞍点所在的行下标和列下标,其间以空格分隔”,如果矩阵中不存在鞍点,输出“NONE”。

这里有两处容易丢分。第一,输出的是从0开始计数的下标,不是从1开始。很多同学本地测试数的是第几行,结果输出了1 2这种,范围直接错掉。第二,不存在鞍点时要输出大写的“NONE”,不是“None”,也不是“NO”,更不是什么都不输出。判题系统对输出格式的匹配是严格且不认人情的,少一个字母、多一个空格都可能整题0分。

另外,题目给出的n范围通常很小,常见版本是1≤n≤6。这个范围意味着你开一个a[6][6]或者a[100][100]的数组都绰绰有余,甚至用最暴力的三重循环也不会超时。但我的建议是别因为数据小就放飞自我,认真做一次复杂度分析和代码优化,这对后面学数据结构有实打实的好处。

2. 从暴力到高效:三种解法逐个拆

2.1 暴力枚举三重循环:最直白,但能帮你理解题意

最容易想到的办法:遍历矩阵里的每一个元素,逐个检查它是不是所在行的最大值,同时是不是所在列的最小值。写成伪代码就是:

遍历每一行i 遍历每一列j 遍历当前行的所有列,看a[i][j]是否最大 遍历当前列的所有行,看a[i][j]是否最小 如果都是,输出并结束

这种方法的时间复杂度是O(n^3)。n等于6的时候,最多跑216次比较,对电脑来说连眨眼都算不上。但它有两个明显的缺点:第一,代码嵌套了三层循环,缩进一多,初学者很容易糊;第二,每次判断都重复扫描了很多次相同的数据,浪费计算。作为初学阶段理解题意的写法,它可以跑通;但如果你想把这题的价值吃透,我建议往下看第二种做法。

2.2 先找行候选,再验列最小:能过题的正确姿势

真正高效的思路是这样的:既然一个元素要当鞍点,它首先必须是它所在那一行的最大值。那么每一行至多只需要检查一个候选位置——该行最大值所在的那一列。如果这一行的最大值都不是列最小,那这一行其他元素更不可能是鞍点。

于是流程变成两段:

  • 第一段:对于第i行,遍历这一行所有列,找出最大值所在的列下标max_j。
  • 第二段:对于列max_j,遍历所有行,检查a[i][max_j]是否是该列最小值。

如果第二段检查通过,直接输出“i max_j”并结束程序;如果没通过,换下一行继续。所有行都检查完还没有结果,输出NONE。

这个算法的复杂度是O(n^2):外层循环n行,每一行先花O(n)找最大值、再花O(n)做列验证,加起来就是O(n^2)。比暴力枚举少了一个数量级,而且代码结构清晰,每一段任务独立,不容易出错。

如果你再仔细一点,会发现两个细节可以优化:第一,如果某行最大值有多个(并列),max_j只记录其中一个;第二,找到鞍点后立即用return结束程序,避免后续循环干扰结果。这些细节我会在代码讲解里专门展开。

2.3 预处理思路:行最大表加列最小表,一次遍历收尾

另一种思路更直观:先一次性把每一行的最大值分别算出来,存进一个数组rowMax;再把每一列的最小值算出来,存进另一个数组colMin。然后重新遍历整个矩阵,只要某个元素同时等于它所在行的最大值、又等于它所在列的最小值,那它一定是鞍点。

rowMax[i] 记录第i行的最大值 colMin[j] 记录第j列的最小值 遍历 i, j: 如果 a[i][j] == rowMax[i] && a[i][j] == colMin[j],输出

这种方法的时间复杂度同样是O(n^2),但多用了两个长度为n的辅助数组,空间复杂度是O(n)。它的好处是语义特别清晰:最大值表、最小值表一眼就能看懂,也不用担心“并列最大值取哪一列”的问题——只要元素值满足双重条件,不管有几个都会被抓到。

我个人在带新人时,会建议先理解第2.2节的“行候选验证法”,因为它更贴近手工找鞍点的思维过程,代码也短;但笔试面试写代码时,我反而常写预处理法,因为它不容易漏判。两种方法各有优势,下面我给出两种的C语言实现,你可以对照着看。

3. C语言代码实现与逐行讲解

3.1 存储与输入:固定数组为什么够用

先说存储。题目说是n阶方阵,n的范围一般很小,用静态二维数组最省事:

int a[6][6];

如果你不确定n的上限,开成int a[100][100]也完全可以,内存毫无压力。有人喜欢用动态分配或者C99的变长数组,在这个题里没有必要,反而容易引入malloc忘记free之类的麻烦。输入用scanf就够了,注意%d能自动跳过空白字符,所以矩阵元素无论怎么换行排列都能正确读入。

int n; scanf("%d", &n); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { scanf("%d", &a[i][j]); } }

这里说个小技巧:读入完成后,建议顺手用两层循环把矩阵打印出来检查一遍,尤其是从PTA样例直接复制粘贴的时候,经常会出现漏读、多读的情况。写题不丢人,丢分才亏。

3.2 找每行最大值的列下标:并列极值的坑

核心代码第一段,找第i行的最大值所在列:

int max_j = 0; for (int j = 1; j < n; j++) { if (a[i][j] > a[i][max_j]) { max_j = j; } }

注意这里用的是“>”,不是“>=”。用“>”意味着当存在多个并列最大值时,max_j保留的是列下标最小的那一个。为什么要这样?因为如果题目要求“行下标最小的优先,列下标最小的优先”,默认保留最靠左的最大值,恰好能满足这一要求,输出也稳定可预期。

反过来,如果用了“>=”,遇到并列时max_j会变成列下标最大的那一个,在某些变种题目里就会出错。所以这一个字符的选择,背后就是输出唯一性的保证。很多老手不看代码靠肉眼检查这一行,就是在看这个符号。

3.3 列最小值验证与标志位:程序如何“记住”结果

接下来验证列最小:

int flag = 1; for (int k = 0; k < n; k++) { if (a[k][max_j] < a[i][max_j]) { flag = 0; break; } }

flag在这里是一个“标志位”。先假设它是鞍点(flag=1),然后遍历整个第max_j列,一旦发现有任何一行的元素比当前候选元素小,说明它根本不算该列最小值,把flag置为0并退出循环。只有当整列都扫完、flag仍然为1时,这个位置才满足“列最小”的条件。

这里判断条件是“<”,不是“<=”。如果写成“<=”,那么在出现并列最小值的情况下,即使a[i][max_j]已经是列最小值,也会因为相等元素而误判失败。题目原义中的“最小”并没有要求唯一,所以用“<”才是严谨的。

3.4 完整代码与提交要点

把上面几段串起来,得到标准解法:

#include <stdio.h> int main() { int n; int a[6][6]; scanf("%d", &n); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { scanf("%d", &a[i][j]); } } for (int i = 0; i < n; i++) { int max_j = 0; for (int j = 1; j < n; j++) { if (a[i][j] > a[i][max_j]) { max_j = j; } } int flag = 1; for (int k = 0; k < n; k++) { if (a[k][max_j] < a[i][max_j]) { flag = 0; break; } } if (flag) { printf("%d %d\n", i, max_j); return 0; } } printf("NONE\n"); return 0; }

提交时注意三点:第一,选对编译器版本,GCC的C99模式完全够用;第二,printf里%d和%d之间是空格,末尾有换行,分行列下标之间不要加逗号或者其他字符;第三,整个程序只允许输出一次结果,所以找到鞍点后要用return 0直接退出,否则后面的循环可能覆盖前面的输出。

如果你偏好预处理写法,代码也不长:

#include <stdio.h> int main() { int n; int a[6][6]; int rowMax[6], colMin[6]; scanf("%d", &n); for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) scanf("%d", &a[i][j]); for (int i = 0; i < n; i++) { rowMax[i] = a[i][0]; for (int j = 1; j < n; j++) if (a[i][j] > rowMax[i]) rowMax[i] = a[i][j]; } for (int j = 0; j < n; j++) { colMin[j] = a[0][j]; for (int i = 1; i < n; i++) if (a[i][j] < colMin[j]) colMin[j] = a[i][j]; } for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (a[i][j] == rowMax[i] && a[i][j] == colMin[j]) { printf("%d %d\n", i, j); return 0; } } } printf("NONE\n"); return 0; }

这两种代码我都提交过,两种都能稳过。第一种代码短,只需要两个额外变量;第二种逻辑结构更清晰,适合作为“算法学习”阶段的范本。你自己选一种写法吃透就好。

4. Python版本的实现:换一种语言,思路还通不通

PTA同样支持Python提交,而且Python的for-else语法在这道题里非常应景。完整代码:

n = int(input()) matrix = [] for _ in range(n): row = list(map(int, input().split())) matrix.append(row) for i in range(n): max_j = 0 for j in range(1, n): if matrix[i][j] > matrix[i][max_j]: max_j = j is_saddle = True for k in range(n): if matrix[k][max_j] < matrix[i][max_j]: is_saddle = False break if is_saddle: print(i, max_j) break else: print("NONE")

这里的for-else是Python一个不太常见但很好用的语法:如果for循环没有被break打断,循环结束后会执行else块;如果循环中途break了,else块不会执行。放在这个场景里,意味着“所有行都扫完都没找到鞍点,才输出NONE”。这个写法比C语言里靠flag判断要优雅一截,也省得额外维护一个found标志。

读入部分也提一句:矩阵元素在一行内以空格分隔时,input().split()是最省心的;如果题目给的输入是每行若干整数,那就按行读,append到matrix里。不要尝试一次性把整个矩阵读进来再拆,那样容易在换行细节上出错。print(i, max_j)会自动在两者之间加空格,输出格式天然符合要求。

Python和C在这道题的差异主要在语法层面,核心逻辑完全一致。如果你先写了C版本,再转过Python,会特别快,因为算法思想是通用的。反过来也一样——Python跑通以后再回去写C,只是把for-else换回flag而已。

5. 常见错误与排查技巧实录

5.1 判断条件写反:鞍点变“波谷”

最常见的错误是把列最小写成了列最大,也就是验证循环里用了大于号而不是小于号:

if (a[k][max_j] > a[i][max_j]) { flag = 0; break; }

这么一改,程序找出来的点变成“行最大、列也最大”,那就不是鞍点了,而是矩阵里某个局部的“山尖”。表现就是:你的代码在某些样例上正确,换一个矩阵就输出NONE或者输出奇怪的坐标。排查方法很简单,找到一个已知鞍点的矩阵逐行对照,在验证循环里加一句printf打印当前比较的k和值,马上就能看出来。

5.2 并列最大值时选错了列

前面提过,找行最大值时用“>”和“>=”会导致不同的列下标。如果题目不保证鞍点唯一,或者要求输出行下标最小、列下标最小的那个鞍点,而你用了“>=”,就可能输出一个“理论上也是鞍点但顺序不对”的坐标,被判题系统判错。

这是所有错误里最隐蔽的一种,因为代码逻辑看起来完全合理,甚至在本地跑样例都能通过。只有当你构造了一个每行有两个相同最大值、并且两个位置都能成为鞍点的矩阵时,问题才会暴露出来。对策就一条:统一用“>”,别给测试数据留下钻空子的机会。

5.3 输出格式和NONE写错

输出格式相关的错误,零基础同学特别容易踩。常见问题包括:

错误写法正确写法错误原因
printf("%d%d\n", i, max_j)printf("%d %d\n", i, max_j)缺少空格,输出粘连
printf("%d,%d\n", i, max_j)printf("%d %d\n", i, max_j)多了一个逗号
printf("None\n")printf("NONE\n")大小写不对
找到鞍点后不return找到后马上return 0后续循环可能再次触发输出
printf("NO\n")printf("NONE\n")单词不对

这些错误不会导致逻辑不对,但会导致0分。我的习惯是:写完代码后,把输出语句单独拎出来盯一遍,再对着题目原文比对一次格式,别凭记忆写。

5.4 n=1的边界情况:最容易忽略但必须想清楚

当n=1时,矩阵只有一个元素。它既是自己所在行的最大值,也是自己所在列的最小值,所以必须输出“0 0”。你拿上面的代码跑一下,会发现在第0行,max_j初始化为0,验证循环只有k=0一个比较,flag保持1,输出“0 0”,完全正确。

不要小看这个边界。很多面试和机试的隐藏测试点就是n=1或者全矩阵只有一个数的情况。如果你在找最大值时把max_j初始化为-1,或者把初始最大值设成了a[i][0]外面的什么值,很容易在这里翻车。

5.5 调试方法:手算矩阵加printf逐步定位

遇到代码输出不对时,别急着改代码。我的做法是三步走:

第一步,拿一个自己手算过鞍点的小矩阵(比如上面那个4阶的例子),把期望输出的坐标写下来。第二部,在代码的关键节点加printf,例如每次找到max_j后打印“i=… max_j=…”,每次验证失败后打印“k=… a[k][max_j]=…”,看循环实际走了哪条路。第三步,对比手算过程和代码打印结果,找出是在“找行最大”阶段就错了,还是在“验列最小”阶段错了。

这个分段排查的方法,能帮你把“为什么错”缩小到几行代码里。所有调试完成后,记得把临时加的printf全部删掉再提交,不然输出会多出额外内容,直接判错。

6. 题目之外的思考:这个思路能带去哪儿

写到这里,相信你已经能完整跑通“找鞍点”这道题了。但如果你只把这题当成一个二维数组作业,那有点可惜。鞍点本质上是一个“双条件筛选”模型:一个元素要同时满足横向极值和纵向极值。这种模型在现实里很常见。

举一个直观的类比:你在招聘网站上筛简历,期望薪资是整个候选人池子里的最低档(列最小),同时技术评分是同一批候选人里的最高档(行最大),那这个人就是某种意义上的“鞍点候选人”。又比如推荐系统里,希望某件商品价格在同类里最低、销量在同类里最高——双重条件同时成立的对象就是我们要的“焦点”。这些场景的代码逻辑,和找鞍点是同构的。

回到算法本身,如果矩阵特别大,比如n是十万级别,O(n^2)的算法也就到了极限。这时候要么利用并行计算把不同行的任务拆开分头处理,要么对稀疏矩阵做特殊存储,要么改用近似算法接受“大概率找到”的结果。当然,这些内容已经超出基础题的范围,但思路的起点都是你今天写的这几行代码。

我个人带新人的体会是,这道题最值得记住的其实不是“鞍点”本身,而是“先定候选、再验证条件”的解题模式。很多看似复杂的算法题,如果先想一想“什么元素有资格成为答案”,把候选范围缩小,再逐一对候选做条件验证,思路会一下子清晰很多。找最大值、找中位数、找Top K,本质上都在重复这个模式。

最后再分享一个小技巧:考试或刷题时,遇到这种“找到一个就输出”的题目,拿到题先别急着写循环,先用纸笔把题目给的样例矩阵手算一遍,得到答案后再写代码,最后用代码跑样例比对。别嫌这一步慢,它能帮你省下至少三倍于它的调试时间。

鞍点这道题,说难不难,说简单也绝不是一道“看一眼就秒”的送分题。把它代码里的每一行为什么这么写搞明白,你得到的远不止一个AC。希望这篇拆解能让你少踩几个坑,也让你下次遇到类似的“双条件判断”,能直接条件反射地把思路理清楚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进