1. 从二维数组经典题说起:鞍点到底是什么
第一次在翁恺老师的C语言练习题里看到“鞍点”这两个字时,我其实愣了一下。刚把二维数组的语法磕磕绊绊学完,突然来一道要同时比较行和列元素的题目,很多人就在这一步栽了跟头。这道题几乎每个C语言学习者都会碰到,也常出现在计算机二级、期末考试的试卷里,原因很简单:它考的不是某个冷门语法,而是二维数组里最核心的“按行操作”和“按列操作”,再加上一点逻辑判断的组合能力。
鞍点的定义不复杂:在一个 m 行 n 列的矩阵里,如果某个元素 a[i][j] 在它所在的第 i 行上是最大值,同时在它所在的第 j 列上是最小值,那这个位置就叫鞍点。之所以叫鞍点,可以想象马鞍的形状——在一个方向上它是“高”的,在另一个方向上它是“低”的,刚好卡在中间。题目通常要求你写程序找出矩阵里所有的鞍点,如果不存在,就输出类似 NONE 的提示。
举个例子。假设有这样一个 3 行 3 列的矩阵:
1 2 3 4 5 6 7 8 9逐行看,第 0 行的最大值是 3,在第 2 列;而第 2 列的最小值是 3,在第 0 行。所以 a[0][2] = 3 就是一个鞍点。第 1 行的最大值 6 在第 2 列,但第 2 列的最小值是 3 而不是 6,所以 6 不是鞍点。第 2 行的最大值 9 同理也不是。
这道题适合谁来练?如果你刚把二维数组的下标、双重循环、scanf 输入这些基础弄明白,正好用它来检验自己是不是真的会了。如果你是被老师布置了作业、被考试提纲逼到这里的,那这篇内容也能帮你把代码写对、把思路理顺。甚至你已经工作了,偶尔翻到这道题,回顾一下二维数组和指针的关系,同样会有收获。
很多人觉得这道题“看懂了但写不出来”,问题往往不在于代码语法,而在于没有把思路拆成清晰的步骤。下面我就从最直觉的写法开始,一步步把它拆开。
2. 先动手:暴力法的实现与陷阱
2.1 暴力法的完整思路
拿到鞍点题,最直接的思路是“逐行检查”:遍历每一行,先找出这一行的最大值,以及它所在的列号,然后去检查这一列上该元素是不是最小值。如果是,就找到了一个鞍点。
这个思路分成三步:
- 外层循环遍历每一行 i。
- 在内层循环找到第 i 行的最大值 maxVal 和它对应的列号 maxCol。初始默认 a[i][0] 是最大值,然后从第 1 列开始逐个比较,遇到更大的就更新。
- 固定在第 maxCol 列,从上到下遍历每一行 k,检查是否所有元素都大于等于这个 maxVal。如果这一列里存在比 maxVal 更小的元素,说明它不满足“列最小”的条件,就排除。
这里的关键点在于:两个条件必须同时成立,缺一不可。很多人只做了第一步“找到行最大”,就直接输出结果,完全没检查列方向,这就把题做错了。
2.2 完整代码一:逐行找最大再验列
#include <stdio.h> #define MAXN 10 int main() { int m, n; int a[MAXN][MAXN]; int i, j, k; int found = 0; scanf("%d %d", &m, &n); for (i = 0; i < m; i++) { for (j = 0; j < n; j++) { scanf("%d", &a[i][j]); } } for (i = 0; i < m; i++) { int maxVal = a[i][0]; int maxCol = 0; for (j = 1; j < n; j++) { if (a[i][j] > maxVal) { maxVal = a[i][j]; maxCol = j; } } int isMin = 1; for (k = 0; k < m; k++) { if (a[k][maxCol] < maxVal) { isMin = 0; break; } } if (isMin) { printf("鞍点: a[%d][%d] = %d\n", i, maxCol, maxVal); found = 1; } } if (!found) { printf("NONE\n"); } return 0; }代码写完后,先用简单的 3x3 矩阵测一下,再把矩阵换成一个没有鞍点的例子,比如对角线为 1、其余为 0 的矩阵,确认它确实会输出 NONE。这里我建议一开始就把“找到鞍点”和“没找到”两种情况都测试一遍,因为只测一种情况,很容易漏掉逻辑分支上的错误。
2.3 这个版本有个隐患:一行的最大值可能不止一个
上面这段代码有一个值得注意的细节:如果某一行里有多个元素都等于最大值,程序只会取第一个最大值对应的列去验证,后面的最大值被直接忽略了。这在某些题目下没问题,因为题目可能只说“输出任意一个鞍点”或“输出第一个鞍点”。但如果题目要求的是“输出所有鞍点”,这种写法就漏答案了。
举个例子,矩阵某一行是[5, 1, 5],最大值是 5,出现在第 0 列和第 2 列。程序只验证了第 0 列,如果第 0 列不满足列最小,但第 2 列满足,那么这里会漏掉一个鞍点。
解决这个隐患有两条路。一条是使用我的推荐做法:遍历该行所有等于 maxVal 的列,逐一验证;另一条是改用下面这种预处理法,逻辑上就绕开了“最大值有多个”这个问题。我现在遇到这道题,更愿意用预处理法,因为它把找最大值和找最小值拆成了两个独立的步骤,不容易犯糊涂。
3. 更稳的解法:预处理行最大值与列最小值
3.1 思路转变:先算两张“小表”
鞍点问题的核心是同时判断两个条件。如果我们先把每一行的最大值和每一列的最小值都算出来,存到两个一维数组里,再逐个遍历矩阵中的每个元素,判断它是不是“同时等于行最大值和列最小值”,问题就简单多了。
这个思路的转化过程很关键:
- 定义一个 rowMax 数组,rowMax[i] 表示第 i 行的最大值。
- 定义一个 colMin 数组,colMin[j] 表示第 j 列的最小值。
- 遍历整个矩阵,如果 a[i][j] 等于 rowMax[i] 且等于 colMin[j],那这个位置就是鞍点。
这种做法的好处是,它不依赖某个元素在行内是“第几个最大值”,而是用“全局视角”同时判断两个条件。前面提到的“一行有多个最大值”的隐患,在这里自然就消失了。
3.2 完整代码二:预处理版本
#include <stdio.h> #define MAXN 10 int main() { int m, n; int a[MAXN][MAXN]; int rowMax[MAXN], colMin[MAXN]; int i, j; int found = 0; scanf("%d %d", &m, &n); for (i = 0; i < m; i++) { for (j = 0; j < n; j++) { scanf("%d", &a[i][j]); } } for (i = 0; i < m; i++) { rowMax[i] = a[i][0]; for (j = 1; j < n; j++) { if (a[i][j] > rowMax[i]) { rowMax[i] = a[i][j]; } } } for (j = 0; j < n; j++) { colMin[j] = a[0][j]; for (i = 1; i < m; i++) { if (a[i][j] < colMin[j]) { colMin[j] = a[i][j]; } } } for (i = 0; i < m; i++) { for (j = 0; j < n; j++) { if (a[i][j] == rowMax[i] && a[i][j] == colMin[j]) { printf("鞍点: a[%d][%d] = %d\n", i, j, a[i][j]); found = 1; } } } if (!found) { printf("NONE\n"); } return 0; }这个版本读起来明显比暴力法清晰。预处理阶段是两个互相独立的一维数组,判断阶段是简单的双重循环加一个 if。即使过了一个月再回头看这段代码,也能很快明白它在干什么。代码的“可读性”和“可维护性”是很重要的一件事,尤其在练习阶段就应该养成这个习惯。
3.3 为什么说这个思路更接近工程实践
我在带初学C语言的学生时经常说一句话:不要满足于“能跑通”,要想想“怎么改起来不容易出错”。暴力法能跑通,但它把“找行最大值”和“验证列最小值”挤在一个循环块里,一旦题目变化,比如要改成求“行最小且列最大”,你得重新梳理逻辑。预处理法把问题拆解成三个独立模块:输入、统计、匹配,每个模块都可以单独测试、单独修改,这和实际工程项目里的“解耦”思路是一致的。
复杂度方面,暴力法的时间复杂度是 O(mn) 的量级,因为每一行找最大值需要 O(n),验证一列需要 O(m),总复杂度约为 O(mn)。预处理法同样是 O(mn),空间上额外用 O(m+n) 的两个一维数组。换句话说,两种算法在性能上差距不大,但预处理法在思维清晰度和扩展性上明显更好。做题的时候适当考虑这类复杂度分析,对你后面学数据结构和算法是很有帮助的。
4. 从“会写”到“写好”:函数封装与指针基础
4.1 把程序拆成函数,逻辑立刻清爽
只写一个 main 函数里的代码,哪怕只有几十行,读起来也会觉得挤。把不同功能拆到函数里,是我个人非常推荐的做法,尤其当你开始写比“鞍点”更复杂的程序时,这个习惯会帮你省下大量时间。
以鞍点题为例,可以拆成下面几个函数:
void inputMatrix(int a[][MAXN], int m, int n); void calcRowMax(int a[][MAXN], int rowMax[], int m, int n); void calcColMin(int a[][MAXN], int colMin[], int m, int n); void findSaddlePoints(int a[][MAXN], int rowMax[], int colMin[], int m, int n);每个函数只负责一件事:输入矩阵就只管读数据,算行最大值就只管行方向,算列最小值就只管列方向。这样就算某个函数写错了,你单独测试它就行了,不用在 main 函数里翻来翻去找问题。
4.2 二维数组传参:三种写法的适用场景
C语言里二维数组作为函数参数,是个容易把人绕晕的点。最常见的有三种写法:
第一种,固定列数的二维数组形参,例如:
void inputMatrix(int a[][MAXN], int m, int n);这种写法的前提是列数必须是编译期常量,所以 MAXN 必须用宏定义好。它适用于你已经确定矩阵最大尺寸的情况,代码最简单。
第二种,用数组指针:
void inputMatrix(int (*a)[MAXN], int m, int n);它和第一种本质上是同一个意思。a 是一个指向“含有 MAXN 个 int 的数组”的指针,也就是指向一行的指针。很多人在书上看到int (*a)[N]觉得别扭,其实把它理解成“a 指向每一行这个整体”就行了。
第三种,动态分配的二维数组用int **a:
void inputMatrix(int **a, int m, int n);这种方式适用于程序运行时才决定矩阵大小、用 malloc 动态分配内存的场景。但要注意,int **a和int a[][N]在内存布局上并不一样,不能混用。动态分配的二维数组要先用 malloc 分配 m 个 int 指针,再给每个指针分配 n 个 int 的空间,操作起来比静态数组麻烦一些。
4.3 指针与二维数组:a[i][j] 到底是怎么算出来的
二维数组名在表达式里会退化成指向第一个一维子数组的指针,也就是“行指针”。a[i][j] 在编译器眼里其实是*(*(a + i) + j)。这句话看起来复杂,拆开理解就清楚了:
- a 指向第 0 行的首地址。
- a + i 指向第 i 行的首地址。
- *(a + i) 得到第 i 行的首元素地址,等价于 a[i]。
- *(a + i) + j 指向第 i 行第 j 列的元素地址。
- ((a + i) + j) 取出这个元素的值。
这也是为什么二维数组传参时必须告诉编译器“每行有几个元素”。如果没有列数信息,编译器无法根据 a[i][j] 计算出应该跳过多少个元素才能定位到第 i 行。所以函数参数写成int a[][MAXN]时,第一维可以不写,第二维必须写,原因就在这里。
理解了这一点,再看int a[][MAXN]和int (*a)[MAXN]就能明白为什么它们等价,因为数组名作为参数时本来就退化为指针。这个知识点不只是为了应付鞍点题,后面你写图像处理、矩阵运算之类的代码时都用得上。
5. 一道题带出调试能力:发现问题的方法
5.1 先学会用 printf 当探针
初学者遇到代码结果不对时,常见的反应是盯着屏幕发呆,或者从头到尾读一遍代码。但更有效的方法,是在关键位置“埋探针”,用 printf 把中间结果打出来看看。鞍点这个问题,建议在下面几个位置加输出:
- 输入结束后,把整个矩阵打印一遍,确认数据读对了。
- 计算完 rowMax 和 colMin 后,把这两个数组打印出来,确认行最大值和列最小值的统计没有错。
- 每当找到一个鞍点,先打印 i 和 j,再打印对应的元素值。
比如 rowMax 和 colMin 的结果不对,那问题多半在统计循环里,根本不用去看后面的判断逻辑。这一步一步缩小排查范围的方法,远比你对着整个程序瞎猜要快得多。
5.2 用 VSCode 边断点边看二维数组
如果你已经用 VSCode 配好了 C/C++ 开发环境,那么利用调试器看二维数组会非常直观。在 VSCode 里打开项目,在代码左侧行号附近点击,就能打上断点。运行调试后,程序会在断点处停住,你可以在“监视”面板里输入变量名,比如a、rowMax、i、j,实时查看它们的值。
对于二维数组,VSCode 的调试器一般会以嵌套结构展示每一行的内容。把断点打在第 26 行左右(预处理循环结束的位置),就能清楚看到 rowMax 是否正确、colMin 是否正确,以及接下来要遍历判断的矩阵元素是什么。比起 printf 大法,这种图形化方式更直观,尤其适合观察循环中变量一步步变化的过程。
配置环境这一步,我在刚入门时折腾过挺久。简单说,需要安装一个编译器(Windows 上用 MinGW-w64 或者 Visual Studio 的 C++ 工具链都行),然后在 VSCode 里装 C/C++ 扩展,写一个简单的 hello world 确认能编译、能运行,再配好 launch.json 和 tasks.json 就能调试了。配置完成后,写代码、做练习的效率会提高很多。
6. 鞍点题最容易踩的坑:错误清单与排查思路
6.1 高频错误速查表
我在帮人看代码的过程中,发现鞍点这道题的错误反复出现在几个固定的地方。这里整理成一张表,按“错误现象、常见原因、解决思路”列出:
| 错误现象 | 常见原因 | 解决思路 |
|---|---|---|
| 永远输出 NONE | 把“行最大、列最小”写反成“行最小、列最大”;或者 rowMax / colMin 初始化次序不对 | 用 3x3 递增矩阵手算一遍,再打印 rowMax 和 colMin 对照 |
| 一行有多个最大值时漏掉鞍点 | 只取第一个最大值列验证,其余最大值被忽略 | 遍历所有等于最大值的列逐项验证,或改用预处理法 |
| 程序卡住不往下走 | 输入数据少于 scanf 的读取个数;循环里 scanf 格式写错 | 检查 scanf 的读取数量和格式串,必要时加输入提示 |
| 输出重复鞍点 | 判断循环 i、j 写反,导致同一个位置被检查多次 | 用 printf 打印当前 i、j 的值,检查是否有重复 |
| 数组越界崩溃 | 循环条件写成 <=m 或 <=n;行列变量搞混 | 统一约定 m 为行数、n 为列数,循环里都用 i<m、j<n |
| 函数传参报错 | 形参写成int a[][];二维数组名传给int * | 形参写成int a[][MAXN]或int (*a)[MAXN] |
这些错误聊起来好像都不复杂,但几乎每个人都犯过。尤其“行列变量搞混”这一点,越是写到后面越容易翻车,因为代码一长,i 和 j 指代的对象很容易记混。
6.2 如何用测试数据逼出隐藏 Bug
光靠看代码找错误是很费力的。我的习惯是准备几组边界数据,专门用来“逼”程序暴露问题。鞍点题建议至少准备以下四组:
- 一行一列的矩阵,比如
1,它自己既是行最大也是列最小,应该作为鞍点输出。 - 所有元素都相等的矩阵,比如 3x3 全 5,每个位置都同时是行最大和列最小,按定义都应该输出。
- 一行内出现多个最大值,但只有一个位置满足列最小的矩阵。
- 完全没有鞍点的矩阵,比如
[[1, 2], [3, 4]],确认程序输出 NONE。
把这四组数据跑一遍,代码里的逻辑分支基本就能覆盖全了。尤其是“全相等矩阵”这一组,很多人会发现自己的程序要么少输出、要么多输出,这就是没有正确理解“行最大和列最小同时满足”的含义。测试时把 expected 和 actual 对照写下来,可以避免自己在调试中越改越乱。
7. 做完鞍点题之后,下一步练什么
7.1 值得继续刷的二维数组题
鞍点题是二维数组练习的一个起点,不是终点。做完这道题,你对“按行遍历”“按列遍历”“二维数组传参”这几个基础操作应该已经有了手感。接下来可以按下面的顺序继续练:
- 矩阵转置:把一个 m 行 n 列的矩阵变成 n 行 m 列,关键是理解交换下标。
- 杨辉三角:用二维数组逐行生成,核心是每行首尾为 1、中间元素等于上一行相邻两数之和。
- 螺旋矩阵 / 蛇形填数:难度立刻上一个台阶,需要你对上下左右四个方向的边界切换有清晰认识。
- 杨氏矩阵查找:在每行递增、每列也递增的矩阵里查找某个数,这个题目会教你怎么利用“行列有序”这个条件把复杂度降到 O(m+n)。
这些题目每做一道,你对二维数组的理解都会更深一层。我当时练完序列题之后,再去看图像处理里常见的卷积、滤波操作,脑子里就有了很具体的内存布局图,学起来轻松很多。
7.2 鞍点题背后真正锻炼的能力
回过头看,鞍点题本身在现实中并不会被单独拿出来用,它更像是一道“思维体操”。它训练的核心能力有两个:
第一,二维条件组合判断。现实中的很多问题,都是“既要满足 A,又要满足 B”,你在代码里能否把两个条件都实现出来、能否准确表达“且”的关系,靠的就是这类题目积累的逻辑能力。
第二,从“会写”到“写得清晰”。同样的功能,有人写出来别人看不懂,有人写出来逻辑分明。鞍点题虽然小,但已经足够让你体会“函数拆分”“中间数据结构”“边界条件”这些概念。如果你能在这种十几行的题目里养成好习惯,后面写几百行的课程设计、几千行的项目时就会受益明显。
我在实际带新人的时候,还发现一个规律:能把鞍点这类基础题干净利落写出来的人,后面写代码时很少会犯“变量名混乱”“函数过长”之类的毛病。基础题练的从来不只是基础,它练的是你脑子里的组织方式。
最后再分享一个我个人的小技巧:写完鞍点这类题目后,不要把代码删了,也别急着提交了事。试着在原来的基础上改一两个条件,比如把“行最大”改成“次大”、把“列最小”改成“列第二大”,看看自己能不能快速改对。能灵活改条件,说明你是真的理解了,而不是背了一版答案。这个习惯我一直保留到现在,碰上一个新算法、新写法,我都会刻意在基础版本上做一次变形,效果比反复抄代码好得多。