☰
C语言二维数组找鞍点:从朴素思路到O(mn)高效解法
2026/10/9 3:13:12 网站建设 项目流程

4月22号那天,我在刷题列表里又翻到了这道经典的二维数组题——找鞍点。如果你正在学C语言或数据结构的数组章节,大概率也见过它:给定一个m行n列的矩阵,找出矩阵中的鞍点。所谓鞍点,就是一个元素在同一行上是最大值,同时在同一列上是最小值。听起来很简单对吧?我第一次做的时候也觉得简单,十几行代码写完就交了,结果被一个看起来毫无问题的测试用例打脸。后来我把这道题的边界情况和解法彻底捋了一遍,才发现它真正的考点藏在"重复值"和"统计方式"里。这篇文章不打算只贴一份能跑的代码,而是把我从踩坑到修正、从朴素思路到O(mn)解法的完整过程写下来,给正在学数组和算法的同学做个参考。

1. 先搞清楚题目到底在问什么

1.1 鞍点的准确定义

教材里的表述通常只有一句话:如果某个元素a[i][j]同时在“第i行的最大值”和“第j列的最小值”上,那它就是鞍点。注意,是“同时”。
我在一些参考书上见过反向定义(行最小、列最大),但本质是一样的,相当于把大小比较互换,解题思路完全一致。所以做题前先瞄一眼题面,别默认一定是“行最大列最小”。

理解这个名字有个很直观的角度:马鞍的形状。骑手坐在马鞍上,前后方向是向上翘的,左右方向是向下弯的。一个点如果在两个互相垂直的方向上分别处于“最高”和“最低”,就正好对应这种形状。这也是它被叫做saddle point的原因。矩阵里的鞍点虽然没有那么形象,但逻辑完全相同。

1.2 为什么教科书和OJ都爱出这题

从教学角度讲,找鞍点几乎把二维数组的常见操作串起来了:遍历矩阵、按行统计、按列统计、标志判断、边界处理。它不像排序那样需要完整算法思想,也不像递归那样绕脑子,但很考验一个人能不能把“行列两个维度的极值”处理得干净。
说句实在话,课程作业里比它难的数组题多的是,但比它容易写错的却不多。很多同学的第一版代码都能过样例,一提交就挂,原因往往不是语法问题,而是思路里埋着逻辑漏洞。

我在刷到的版本里,常见有几种变体:有的要求输出所有鞍点坐标,有的改成“输出一个鞍点,没有则输出none”,还有的要求坐标从1开始计数。这些细节直接影响得分。我的代码里习惯用数组下标从0开始,但打印的时候会根据题目要求统一加1。

1.3 先手算一个标准例子

比如这个3×3矩阵:

1 2 3 4 5 6 7 8 9

一眼看过去,右上角的3是第一行的最大值,同时是第三列的最小值(第三列是3、6、9),所以它是鞍点。
再看第二行最大值6,第三列最小值是3,不是6,不满足。第三行最大值9,第三列最小值还是3,不满足。
所以这个矩阵只有一个鞍点:位置在(0, 2),值是3。

这个例子比较简单,容易给人造成一种错觉:先找每行最大,再看它是不是列最小,不就行了吗?问题恰恰出在这个“先找每行最大”上。

2. 第一次动手:朴素思路为什么翻车

2.1 很多人都会写的“找位置”写法

我第一次想到的方法十分直接:外层循环遍历每一行,先找这一行的最大值,记下它所在的列;然后检查这个元素是不是它所在列的最小值。是就输出,不是就继续下一行。
逻辑清晰,代码不长,很多参考书上的示例就是这么写的。
但这个方案隐含了一个前提:每行的最大值必须唯一。只要不唯一,就会漏判。

2.2 一个让我当场沉默的反例

看这个矩阵:

1 3 3 2 2 5 1 8 6

第一行的最大值是3,但它同时出现在第2列和第3列两个位置。
如果按“找每行第一个最大值”的逻辑,我会先检查(0, 1)这个位置:它所在的列是3、2、8,最小值是2,不是3,所以我会认为第一行没有鞍点,直接跳到第二行。
但真正的鞍点躲在(0, 2):3在第三列,而第三列是3、5、6,最小值恰好是3。
也就是说,朴素写法会漏掉这个矩阵里唯一的鞍点。

我当时看到这个数据的时候人都愣了。问题不是“比较大小写错”,而是“用位置当候选”本身不可靠。当最大值出现重复时,你选到的那个位置不一定是鞍点,没选到的反而是。

2.3 不只是“第一个位置”一个问题

有的同学说:那我记下每行所有最大值的位置,逐个检查总行了吧?
思路没错,但代码会变得很绕。更隐蔽的坑是:如果一行有两个相同最大值,一个恰好是鞍点,一个不是,你在标记候选时很容易因为下标处理失误而把两个都漏掉。
还有一种常见毛病是:循环里一旦找到鞍点就break。如果题设要求输出所有鞍点,这个break会丢数据;如果题目只要求输出一个,那还能凑合。所以写代码前一定要先确认输出要求。

后来我把这个坑想透了:与其纠结“最大值出现在哪些位置”,不如彻底放弃位置候选,改成从“值”的层面直接做双重验证。

3. 正确解法:把“位置候选”改成“值双重验证”

3.1 核心思路

不记位置,只记“每行最大值是多少”和“每列最小值是多少”。
具体分两步走:

  • 预处理两个数组:
    • maxRow[i]:第i行的最大值
    • minCol[j]:第j列的最小值
  • 再扫描一次矩阵,对每个元素a[i][j]做判断:
    • 如果 a[i][j] == maxRow[i] 且 a[i][j] == minCol[j],那它一定是鞍点。

为什么这样判断一定对?因为它等于行最大值,满足“行最大”;同时等于列最小值,满足“列最小”。我完全不需要关心这个最大值出现在哪一列、这个最小值出现在哪一行,重复值也天然覆盖到了。

3.2 手动模拟一遍反例

还是用刚才那个矩阵:

1 3 3 2 2 5 1 8 6

先求每行最大值:

  • 第0行:3
  • 第1行:5
  • 第2行:8

再求每列最小值:

  • 第0列:1、2、1,最小值1
  • 第1列:3、2、8,最小值2
  • 第2列:3、5、6,最小值3

然后逐个扫描:

  • (0,0)=1:不等于maxRow[0]=3,跳过
  • (0,1)=3:等于maxRow[0],但minCol[1]=2,3不等于2,跳过
  • (0,2)=3:等于maxRow[0],也等于minCol[2]=3,输出
  • 其余位置都不满足,最终只输出(0,2)

和朴素写法漏掉的结果完全对上了,而且逻辑更简单。

3.3 复杂度对比

朴素写法里,每个元素都可能要重新比较整行和整列,最坏情况复杂度是O(m×n×(m+n))。
改成预处理数组之后,求每行最大值是O(m×n),求每列最小值是O(m×n),扫描判断是O(m×n),总复杂度就是O(m×n)。
课程作业里两个版本都能跑,但如果OJ把范围放到1000×1000,效率差别就很明显了。这也是很多题目会放心把矩阵规模开大的原因——它默认你用的是线性扫描。

3.4 如果题目反向定义怎么办

如果题目要求的是“行最小、列最大”,把maxRow换成minRow,把minCol换成maxCol,判断条件改成 a[i][j]==minRow[i] && a[i][j]==maxCol[j],其余代码一行都不用动。
我建议把逻辑封装成一个函数,比如FindSaddle(int a[][MAXN], int m, int n, int mode),用mode参数切换两种定义,应对变体时会更从容。

4. C语言实现与容易扣分的细节

4.1 可以直接跑起来的代码

#include <stdio.h> #define MAXN 100 int main() { int m, n; int a[MAXN][MAXN]; int maxRow[MAXN], minCol[MAXN]; printf("请输入行数 m 和列数 n:"); scanf("%d %d", &m, &n); printf("请输入 %d x %d 的矩阵:\n", m, n); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { scanf("%d", &a[i][j]); } } // 初始化行最大和列最小数组,必须在读入矩阵之后 for (int i = 0; i < m; i++) { maxRow[i] = a[i][0]; } for (int j = 0; j < n; j++) { minCol[j] = a[0][j]; } // 求每行最大值 for (int i = 0; i < m; i++) { for (int j = 1; j < n; j++) { if (a[i][j] > maxRow[i]) { maxRow[i] = a[i][j]; } } } // 求每列最小值 for (int j = 0; j < n; j++) { for (int i = 1; i < m; i++) { if (a[i][j] < minCol[j]) { minCol[j] = a[i][j]; } } } // 扫描所有元素,找鞍点 int count = 0; printf("鞍点如下(行,列,值):\n"); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (a[i][j] == maxRow[i] && a[i][j] == minCol[j]) { printf("(%d, %d) = %d\n", i, j, a[i][j]); count++; } } } if (count == 0) { printf("该矩阵不存在鞍点\n"); } else { printf("共找到 %d 个鞍点\n", count); } return 0; }

4.2 几个容易被扣分的细节

  • 读入顺序:一定要先读入矩阵,再初始化maxRow和minCol。我见过不少同学把初始化放在scanf之前,结果用垃圾值初始化,代码时对时错,非常玄学。
  • MAXN大小:题目如果给了范围,比如m、n是100,就留点余量定义成105。不确定就用动态内存,课程作业里固定数组基本够用。
  • scanf的返回值:严谨起见可以检查返回值是否为期望的个数。OJ环境一般不强制,但工程代码里这是好习惯。
  • 坐标打印:教科书喜欢让输出“第i行第j列”时用i+1、j+1,因为给人看是从1开始更自然。但一切以题面为准,有些OJ就是要求从0开始。
  • 无鞍点的输出:题目通常会明确要求输出“NO”或“NONE”,大小写和换行都要照抄,别自由发挥。

4.3 一组标准输入输出

请输入行数 m 和列数 n:3 3 请输入 3 x 3 的矩阵: 1 2 3 4 5 6 7 8 9 鞍点如下(行,列,值): (0, 2) = 3 共找到 1 个鞍点

这里的坐标是从0开始计的。如果你的题目要求从1开始,打印时把i和j都加1就行。

5. 我实测中遇到的边界情况与调试心得

5.1 这些边界情况一定要自测

光看样例远远不够,我后来整理了一张边界测试表,每换一种写法都会跑一遍:

测试矩阵预期结果说明
5(1×1)1个鞍点唯一元素天然满足行列双条件
3 1 3(一行)2个鞍点两个3都是行最大,且各自列的唯一元素就是自己
[[2],[1],[2]](一列)1个鞍点列最小是1,且1所在行的唯一元素就是自己
3×3全为79个鞍点每个元素都同时等于行最大和列最小
1 3 3 / 2 2 5 / 1 8 61个鞍点(0,2)检验重复值处理的关键用例
2 7 1 / 9 4 3 / 6 8 50个鞍点每行最大值都不是所在列最小值

尤其要提一下全等矩阵,乍看反直觉,但按定义推就是所有元素都满足条件。很多同学在这上面翻车,以为鞍点最多只能有一个,其实题目没有这么约定。

5.2 一次真实的调试过程

我调试时遇到过一种很诡异的现象:同一个矩阵,第一次跑输出一个鞍点,第二次跑输出两个。
查了半天,发现minCol数组没有在scanf之后重新初始化,第一次处理时残留了一些旧数据。后来我养成了一个习惯:凡是用来做统计汇总的数组,在使用前一律显式赋值,绝不依赖系统初始化的0。

另外一个容易踩的点是行列数不相等的时候。初始化maxRow用a[i][0],初始化minCol用a[0][j],两步只要有一个放在了读入前,或者数组越界,结果就不可预测。这种错通常编译器不报,运行时也“看起来正常”,但一换数据就露馅。

5.3 给新手的自检建议

写完代码后,别只用题目给的那一个样例,建议自己构造下面这些输入:

  1. 全相等矩阵,比如3行3列全为7,预期输出9个鞍点。
  2. 递增矩阵,行和列都递增,预期右上角有1个鞍点。
  3. 递增矩阵转置,预期左下角有1个鞍点。
  4. 无鞍点矩阵,预期输出none。
  5. 只有一行的矩阵,预期输出所有等于行最大值的元素。
  6. 本文那个重复最大值的反例,预期必须输出(0,2)这一个鞍点。

这些用例全跑通,代码基本就稳了。

5.4 浮点数版本的一个提醒

这题通常用int,不会有什么溢出问题。但如果哪天遇到浮点数鞍点,判断相等千万别用==,要用fabs(x - y) < 1e-9这类精度比较。课程作业极少涉及,但提前知道能省一次debug。

6. 从“找鞍点”到“鞍点规划”:一点延伸思考

6.1 数学里的鞍点是什么

在微积分和优化里,鞍点指的是“沿一个方向是极小值、沿另一个方向是极大值”的临界点。
对二元函数f(x, y)来说,如果它的一阶偏导数都为0,但驻点既不是极大也不是极小,而是一个方向二阶导为正、另一个方向二阶导为负,那这个点就叫鞍点。
矩阵里的鞍点和它同源,都强调了“两个互相垂直的方向上极值性质相反”。

6.2 机器学习为什么会怕鞍点

训练神经网络时,很多人以为梯度为0就代表找到了局部最优。但高维非凸优化里,大量梯度为0的点其实是鞍点,而不是极小值。
在鞍点附近,梯度非常小,梯度下降更新速度极慢,看起来像收敛了,实际上只是被卡住。这也是为什么后来的优化算法要加入动量、自适应学习率,甚至用SGD的随机噪声来帮助逃离鞍点区域。
一个小小的矩阵题,背后的思想居然能和现代深度学习的优化难题连上,这是我当初完全没想到的。

6.3 “鞍点规划”这个热词想表达什么

“鞍点规划”在运筹学和博弈论里对应的是“极小极大”一类问题,比如二人零和博弈的均衡点就被称为鞍点,鲁棒优化里的最坏情况决策也经常落到鞍点规划上。
作为初学者,不需要深挖这些,但至少可以知道:鞍点不只是一个二维数组练习题,它在很多领域里都是正经的研究对象。以后如果再遇到别人提“鞍点规划”,你不至于完全陌生。


最后说回程序本身。我做这道题最大的收获是:不要急着写双重循环,先想清楚我要比较的是“值”还是“位置”。如果一开始就走“每行最大值集合+每列最小值集合”的路子,后面那些边界问题都会少很多。
另外那句话想再说一遍:样例过,不等于代码对。把那6个边界测试用例存下来,以后凡是遇到矩阵极值类题目都能复用。你照着上面的代码跑一遍反例矩阵,看到它只输出唯一的(0,2)鞍点,才算真正理解这个解法了。

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

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

立即咨询