鞍点这个题目,我在带新人刷题的时候见过太多翻车现场——思路不是不会,代码也不是写不出来,问题往往出在定义没抠清楚、边界没想周全。样例一跑就过,提交上去直接 WA 掉一半测试点,回头一看是重复元素没处理,或者把“行最大列最小”和“行最小列最大”给搞反了。这篇就来把 C++ 计算鞍点这件事从头到尾捋一遍,给出三种不同层次的解法:从最朴素的暴力验证,到用数组预处理的线性扫描,再到用指针遍历、把额外空间压到常数的写法。三种方法的代码我都会贴全,复杂度会算清楚,坑也会一个个标出来。不管你刚学完二维数组想找道题练手,还是已经写过几遍但总在某些测试点上栽跟头,都能从这里挑到对自己有用的部分。
1. 鞍点问题到底在考什么
1.1 定义先钉死:行最小加列最大
先把话说死,不然后面代码写得再漂亮都是白搭。所谓鞍点,指的是矩阵里某个元素,它同时满足两个条件:它是自己所在这一行的最小值,又是自己所在这一列的最大值。写成数学表达就是,存在 a[i][j],使得对任意 k 都有 a[i][j] <= a[i][k],并且对任意 k 都有 a[i][j] >= a[k][j]。这两条必须同时成立,缺一个都不算。
拿个具体的 3×3 矩阵感受一下:
1 2 3 4 5 6 7 8 9第一行的最小值是 1,它落在第 1 列,而第 1 列的最大值是 7,1 不等于 7,所以 (1,1) 不是鞍点。第三行的最小值是 7,它在第 1 列,第 1 列最大值正好也是 7,两边对上了,所以 (3,1) 是鞍点。这里有个特别容易被忽略的点:鞍点判断的是“值”,不是“位置”。哪怕一行里有好几个元素都等于最小值,只要其中某一个同时是该列最大值,它就算数。
1.2 别把“行最大列最小”当成默认
网上流传的鞍点定义其实有两个方向。一种是上面说的“行最小、列最大”,另一种是“行最大、列最小”。这两个是完全不同的题,把方向搞反,答案会离谱到姥姥家。我一般建议:拿到题目先看样例,用一个手算得出的小矩阵验证一下自己理解的方向,再动手写代码。如果题目里给的是“在矩阵中,一个数在所在行中是最大值,在所在列中是最小值”,那就要把判断条件整个反过来,代码逻辑一样,只是把小于号和大于号调个个儿。养成先确认定义再写代码的习惯,能省掉后面大量的调试时间。
另外还要确认几个输入输出约定:矩阵是几行几列、行列下标从 1 开始还是从 0 开始、如果存在多个鞍点要不要全部输出、如果不存在鞍点输出什么。这些看起来是小事,但直接决定了你输出的格式对不对。很多在线评测的题,逻辑全对,就因为少输出一句话或者格式差一个空格,判成错误。
1.3 边界情况必须先想清楚
在动键盘之前,我习惯先把下面几种情况在纸上过一遍:
- 1×1 的矩阵:唯一那个元素既是行最小又是列最大,直接就是鞍点。
- 整行元素相同:比如某一行全是 5,那么这一行每个元素都“是行最小值”,都可能成为候选,不能只抓一个。
- 整个矩阵元素全相同:比如全 5,那么每个位置都满足条件,是多重鞍点。
- 存在负数:求最小值用
INT_MAX初始化是对的,但如果初始值随手写 0,遇到全是负数的行就会算错。 - 真的没有鞍点:要能正常输出“无鞍点”这类的提示,而不是什么都不打印。
这几条里,重复元素导致的“多重候选”是最容易翻车的地方,后面讲三种方法时会反复提到。
2. 三种解法的总体设计与选型
2.1 三种方法的定位差异
同一个问题,解法可以差出好几个数量级。我要介绍的三种方法分别是:暴力验证法、预计算数组法、逐行候选加指针验证法。它们不是互相替代,而是代表了三种不同的思维层次:第一种是“能跑就行”,第二种是“拿空间换时间”,第三种是“用问题自身的性质剪枝”。理解这三种的思路演进,比记住某一个具体写法更重要,因为你以后遇到别的矩阵类问题,同样会用到这些套路。
从算法下界上讲,任何解法都至少要把矩阵里的每个元素看一遍,所以时间复杂度的理论下限是 O(m×n)。谁离这个下界最近,谁就是最优的。方法一远远够不着,方法二刚刚好压在线上,方法三在理想情况下也能压到线上,但遇到极端输入会退化。
2.2 复杂度对照表
先把账算清楚,后面写代码时心里有数。设矩阵是 m 行 n 列:
| 方法 | 时间复杂度 | 额外空间 | 核心手段 | 适用场景 |
|---|---|---|---|---|
| 方法一 暴力验证 | O(m×n×(m+n)) | O(1) | 每个元素都扫一行一列 | 初学练手、规模很小 |
| 方法二 预计算数组 | O(m×n) | O(m+n) | 先存行最小值与列最大值 | 通用推荐、大规模数据 |
| 方法三 逐行候选 | 最好 O(m×n),最坏 O(m²×n) | O(1) | 用“鞍点必为行最小”剪枝 | 每行元素互不相同的场合 |
表格里方法三那一栏的“最坏”是个关键信息,很多人写完了以为自己是线性的,其实没有。这点后面细说。
2.3 为什么方法一注定是慢的
方法一的逻辑是:对矩阵里的每一个位置,都去检查它是不是行最小、是不是列最大。光看逻辑没毛病,问题是它做了大量重复劳动。求同一行的最小值,你在这个元素上扫了一遍,挪到下一个元素又扫一遍,一行扫了 n 次,每次 n 个元素,白白多出来一个 n 倍。列方向同理。这就是为什么它的复杂度里带着一个 (m+n) 的因子。而事实上,每一行的最小值只需要求一次就够了,求完存下来反复用。想通这一点,方法二就自然浮现出来了。
3. 方法一:暴力三重循环,能跑就对
3.1 思路拆解
思路特别直白:两层循环遍历每个位置 (i, j),对每个位置再做两组验证。第一组,扫一遍第 i 行,如果发现比 a[i][j] 更小的元素,说明它不是行最小,直接淘汰;第二组,扫一遍第 j 列,如果发现比 a[i][j] 更大的元素,说明它不是列最大,同样淘汰。两组都通过,那它就是一个鞍点。
这里我用了“提前退出”的写法,也就是一旦确定不满足就立刻 break,不再继续扫完剩下的。这个细节在数据量大的时候能省不少时间,写循环时顺手就加上了,成本几乎为零。
#include <iostream> using namespace std; const int MAXN = 105; int a[MAXN][MAXN]; int main() { int m, n; // m 行 n 列 if (!(cin >> m >> n)) return 0; for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) cin >> a[i][j]; bool found = false; for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { // 第一关:检查是不是本行最小 bool isRowMin = true; for (int k = 0; k < n; ++k) { if (a[i][k] < a[i][j]) { // 有更小的,淘汰 isRowMin = false; break; } } if (!isRowMin) continue; // 第二关:检查是不是本列最大 bool isColMax = true; for (int k = 0; k < m; ++k) { if (a[k][j] > a[i][j]) { // 有更大的,淘汰 isColMax = false; break; } } if (isColMax) { cout << "鞍点: (" << i + 1 << ", " << j + 1 << ") 值 = " << a[i][j] << "\n"; found = true; } } } if (!found) cout << "无鞍点\n"; return 0; }3.2 几个必须注意的细节
第一个,循环变量别写混。第一关扫的是同一行的不同列,所以内层变量是列下标 k,范围是 0 到 n;第二关扫的是同一列的不同行,变量是行下标 k,范围是 0 到 m。我见过有人两处都写成k < n,结果在非方阵(m 不等于 n)上直接越界或者漏查。这种错误在方阵测试数据上完全看不出来,一换成长方形矩阵立刻暴雷。
第二个,比较符号的方向。第一关找的是“有没有比它还小的”,所以是a[i][k] < a[i][j];第二关找的是“有没有比它还大的”,所以是a[k][j] > a[i][j]。两个符号方向相反,写的时候容易顺手写成一个方向,那样逻辑就彻底错了。
第三个,found 标记的作用。如果你不加这个标记,那么“无鞍点”的提示可能会在循环里被打印很多次。正确做法是把提示放到所有循环结束之后,用标记判断。
注意:方法一虽然慢,但它是唯一不需要任何额外思考就能写对的版本。如果你在考场上时间紧张,或者数据规模明确很小(比如 10×10 以内),直接上方法一,稳。
4. 方法二:预计算行最小值与列最大值
4.1 空间换时间的核心思路
方法一的浪费在于“反复求同一行的最小值”。那解决办法就很简单:把每一行的最小值和每一列的最大值提前算一次,存进两个数组,最后拿这两个数组去比对。这两个数组分别是rowMin[i](第 i 行的最小值)和colMax[j](第 j 列的最大值)。
预处理好之后,再遍历一遍矩阵,只要a[i][j]既等于rowMin[i],又等于colMax[j],它就同时是行最小和列最大,是鞍点。整个过程矩阵被完整扫描了常数次,时间复杂度降到了 O(m×n),代价是多开 m+n 个整数的空间。
4.2 完整实现
#include <iostream> #include <climits> // INT_MAX / INT_MIN 在这 using namespace std; const int MAXN = 105; int a[MAXN][MAXN]; int rowMin[MAXN]; int colMax[MAXN]; int main() { int m, n; if (!(cin >> m >> n)) return 0; for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) cin >> a[i][j]; // 第一步:求每一行的最小值 for (int i = 0; i < m; ++i) { int mn = INT_MAX; // 用最大整数打底,负数也能正确处理 for (int j = 0; j < n; ++j) if (a[i][j] < mn) mn = a[i][j]; rowMin[i] = mn; } // 第二步:求每一列的最大值 for (int j = 0; j < n; ++j) { int mx = INT_MIN; // 用最小整数打底 for (int i = 0; i < m; ++i) if (a[i][j] > mx) mx = a[i][j]; colMax[j] = mx; } // 第三步:比对 bool found = false; for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { if (a[i][j] == rowMin[i] && a[i][j] == colMax[j]) { cout << "鞍点: (" << i + 1 << ", " << j + 1 << ") 值 = " << a[i][j] << "\n"; found = true; } } } if (!found) cout << "无鞍点\n"; return 0; }4.3 用“值比较”为什么是对的
有人会犯嘀咕:用值相等来判断,会不会误判?比如某一行的最小值是 5 出现在位置 (2,3),而 (2,7) 处恰好也有个 5,它并不是“那个”最小值,会不会被误当成鞍点?
答案是不会,而且这正是我们想要的。因为鞍点的定义本身只约束值,不约束位置。“(2,7) 处的 5 是第 2 行的最小值”这句话是成立的,因为它确实等于该行最小。如果它同时又等于所在列的最大值,那它就是一个合法的鞍点。所以用a[i][j] == rowMin[i]这种值比较,天然就处理了重复元素的多解情况——所有符合条件的元素都会被输出。
真正需要小心的是另一种情况:如果题目额外要求“行内最小值必须唯一”,那用值比较就会多输出。这时候得先统计每行最小值的出现次数,只有出现一次时才纳入候选。这个差异要在读题时确认清楚。
4.4 初始化值的坑
INT_MAX和INT_MIN定义在<climits>头文件里,是标准库给的常量。有些同学图省事,求最小值时写成int mn = 0;,在正数矩阵上跑着没问题,一旦输入全是负数,0 永远不会被更新,最后rowMin[i]全是 0,结果全错。这种 bug 特别隐蔽,因为你的样例可能全是正数,根本测不出来。养成习惯,求最小值用INT_MAX打底,求最大值用INT_MIN打底,一行代码的事,能避免一类错误。
提示:如果你的编译器比较老,
<climits>里没有宏定义,也可以直接写0x7fffffff和0x80000000,效果一样,但不推荐,可读性差。
5. 方法三:逐行候选加指针验证
5.1 剪枝的核心依据
方法二虽然快,但它开了两个额外数组。能不能在不开数组的情况下也做到接近线性?可以,靠的是鞍点定义给我们的一个硬性前提:鞍点一定是某一行里的最小值。换句话说,一行里除最小值之外的所有元素,压根儿不用考虑,它们连入场资格都没有。
于是思路变成:逐行处理,先把这一行的最小值找出来,然后只让这一行里等于最小值的那些位置去参加“列最大”的验证。其余位置直接跳过。这个剪枝砍掉了大量的无效验证,同时不需要任何额外数组,额外空间是 O(1)。
5.2 二维数组的内存布局与指针访问
要讲指针写法,先把底层布局说清楚。C++ 里的二维数组int a[105][105],在内存里其实是一整块连续空间按行排列,第 0 行接第 1 行,第 1 行接第 2 行,依此类推。所以a[i]这个写法本身就代表“第 i 行的首地址”,它的类型是int*。a[i][j]等价于*(a[i] + j),也等价于*(*(a + i) + j)。
再往外一层,a这个名字在作为函数参数传递时会退化成“指向数组的指针”,类型是int (*)[105],也就是“指向长度为 105 的 int 数组的指针”。这个类型很关键,因为它决定了指针加一跳过多少字节——p + 1会跳过整整一行(105 个 int),而不是一个 int。
再补一个内存角度的细节:假设矩阵只有 3 行 4 列,但数组声明成int a[105][105],那么元素 (i, j) 的地址偏移是i * 105 + j个 int,而不是i * 4 + j。行与行之间那些没用到的空间仍然真实存在于内存里。这一点在手动算地址或者做内存布局转化时千万别算错,你按“有效列数”去乘就会得到完全错误的地址。
函数参数一旦写成int (*p)[MAXN],p[i]和*(p + i)就是同一行的两个等价写法,p[i][j]和*(*(p + i) + j)也是等价的。理解了这一层,指针版代码读起来就毫无障碍了。
两种写法的对照关系,我整理成一张表,方便随时翻:
| 下标写法 | 指针写法 | 含义 |
|---|---|---|
a[i][j] | *(*(a + i) + j) | 第 i 行第 j 列的元素 |
a[i] | *(a + i) | 第 i 行的首地址,类型 int* |
&a[i][j] | *(a + i) + j | 第 i 行第 j 列元素的地址 |
a[i] + j | *(a + i) + j | 同上 |
| 行指针声明 | int (*p)[MAXN] = a; | 指向一整行的指针 |
5.3 指针版代码实现
#include <iostream> using namespace std; const int MAXN = 105; int a[MAXN][MAXN]; // 参数写成指向数组的指针,接收二维数组 void solve(int (*p)[MAXN], int m, int n) { bool found = false; for (int i = 0; i < m; ++i) { int *row = *(p + i); // 拿到第 i 行首地址 // 第一趟:找出这一行的最小值 int mn = *row; for (int j = 1; j < n; ++j) if (*(row + j) < mn) mn = *(row + j); // 第二趟:本行等于 mn 的位置才有资格做列验证 for (int j = 0; j < n; ++j) { if (*(row + j) != mn) continue; bool isColMax = true; for (int k = 0; k < m; ++k) { if (*(*(p + k) + j) > mn) { // 该列有更大的,淘汰 isColMax = false; break; } } if (isColMax) { cout << "鞍点: (" << i + 1 << ", " << j + 1 << ") 值 = " << mn << "\n"; found = true; } } } if (!found) cout << "无鞍点\n"; } int main() { int m, n; if (!(cin >> m >> n)) return 0; for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) cin >> a[i][j]; solve(a, m, n); // 数组名传进去,自动退化成 int(*)[MAXN] return 0; }5.4 这个方法什么时候会退化
前面表格里提到,方法三最坏的复杂度是 O(m²×n),得解释一下为什么。它的开销取决于“每行里等于最小值的元素有几个”。如果每行最小值只出现一次(比如每行元素互不相同),那每行只产生一个候选,每个候选做一次 O(m) 的列验证,总共 m 次验证,加上找最小值的 O(m×n),整体就是 O(m×n)。
但如果输入很“坏”,比如整个矩阵全是同一个数字,那每一行的 n 个元素全都是候选,每个候选都要扫一遍 m 行,于是变成 m 行 × n 个候选 × m 次扫描 = O(m²×n)。前面说过,全相同矩阵每个位置都是鞍点,这个结果在答案上是对的,只是算得慢。
所以结论很明确:如果题目保证每行元素互不相同,方法三是又快又省空间的最优解;如果数据里重复元素很多,老老实实用方法二更稳妥。这是一个典型的“用更强的前提换更好的性能”的取舍。
注意:指针写法里
*(p + k) + j这种连续的指针运算,务必自己画一遍地址图确认。我调试时最常犯的错是漏掉一层解引用,写成*(p + k + j),那就跑到别的行去了,而且不报错,只是结果错,特别难查。
6. 完整工程:从读入到输出
6.1 输入解析与规模约定
把三种方法放到一个工程里,通常的做法是先用cin >> m >> n读行列数,再用双重循环读矩阵。这里我建议大家用if (!(cin >> m >> n)) return 0;这种写法,万一输入为空或者格式不对,程序直接干净退出,不会拿随机值当规模用,避免出现诡异的越界。数组大小用常量MAXN控制,105 足够应付绝大多数教学题和中小规模数据。如果要处理更大规模,把常量改大即可,但要注意 C++ 里全局的大数组是放在静态区的,不会爆栈,这点比在函数里声明局部大数组安全得多。局部int a[1000][1000]在某些平台上可能直接栈溢出,这是个很多人踩过的坑,超过几万个元素的大数组,一律放全局或者用动态分配。
6.2 输出格式需与题目对齐
输出这块,逻辑上只有三种情况:找到若干鞍点、找到但是从 0 开始的下标、找不到。下标从 0 还是从 1 开始,完全看题目要求。我上面所有代码都在输出时写了i + 1和j + 1,因为大多数题目用 1-based 坐标。如果你的题目要求 0-based,把加一去掉就行,但千万别一半加一半不加。
多解的情况也要想清楚:有的题目要求输出第一个鞍点就结束,那就在第一次命中后return 0;有的要求全部输出,那就让循环跑完。这两种输出完全不同,选错了必然错。
6.3 建议的测试用例
写完代码别急着交,先用下面的用例过一遍。这些是我平时调试时固定会跑的几组:
| 编号 | 输入矩阵 | 期望行为 | 考察点 |
|---|---|---|---|
| 1 | 3×3 递增矩阵 | 输出 (3,1) 值 7 | 基础流程 |
| 2 | 3×3 全为 5 | 输出 9 个鞍点 | 重复元素多解 |
| 3 | 1×1 矩阵值 42 | 输出 (1,1) 值 42 | 最小规模 |
| 4 | 2×3 存在鞍点 | 输出对应坐标 | 非方阵 |
| 5 | 全负数矩阵 | 正常判断 | 初始化打底值 |
| 6 | 清晰无鞍点 | 输出无鞍点 | 标记逻辑 |
尤其第 2 组和第 5 组,能分别戳中重复元素和初始化两个高频错误。第 4 组则是专门用来抓“行列表述混用”的,方阵上测不出来的 bug,在 2×3 上一测就现形。
7. 常见问题与排查实录
7.1 高频错误速查表
下面这些是我和身边人实际踩过的坑,按出现频率从高到低排列:
| 现象 | 可能原因 | 修复方式 |
|---|---|---|
| 样例全过,提交部分 WA | 未处理重复元素多解 | 用值比较,别只找第一个 |
| 换非方阵就错 | 行列循环范围写混 | 行循环用 m,列循环用 n |
| 负数据全错 | 初始化写成 0 | 改成 INT_MAX / INT_MIN |
| 编译报错找不到常量 | 漏了<climits> | 补上头文件 |
| 程序随机崩溃 | 数组开太小越界 | 调大 MAXN 或改全局数组 |
| 无鞍点时啥都不打印 | 缺标记逻辑 | 加 found 变量统一判断 |
| 指针版结果全乱 | 指针层级少解引用 | 对照地址表逐层核对 |
7.2 调试手法:把中间量打出来
算法出错的时候,最有效的办法不是盯着代码看,而是把关键中间量打印出来。方法二里,把rowMin和colMax两个数组打完,一眼就能看出预处理有没有算对。如果这两个数组是对的,那问题必然在最后的比对环节,范围一下子就缩小了。方法三里,先把每行找出的最小值打出来,确认找最小值的逻辑没问题,再去查列验证。这个“分段隔离”的调试思路,比漫无目的地改代码高效得多。
另外一个强烈推荐的做法是暴力对拍。把方法一(虽然慢但直观)和方法二(快但可能有细节疏漏)都写出来,用一个随机数生成器造几百组小规模数据,两个程序分别跑,比对输出。只要出现不一致,就把那组数据单独拎出来手工分析。对拍能在几分钟内发现你手动设计用例时想不到的情况,尤其是边界组合。这个方法我从学算法一直用到工作里,屡试不爽。
7.3 关于规模与数组的两个提醒
第一,如果你要处理的是几千乘几千的矩阵,int a[3000][3000]就是九百万个整数,占约 36MB,全局声明通常还扛得住,但再大就得考虑动态分配或者滚动处理了。第二,不要用vector<vector<int>>去硬套方法三的指针写法,因为vector的每一行不保证在内存里连续,指针运算会失效。真要用容器,就老老实实用下标访问,指针优化留给原生数组。这个坑我在一次重构里踩过,指针在上面跑出了完全无法解释的地址偏移,排查了半天才发现是底层存储不连续。
8. 我在实战里的几点体会
把三种方法都写过一遍之后,我对这类题最大的感受是:先确认定义和边界,再谈优化。方法一那种暴力写法看起来笨,但它在逻辑上几乎没有出错的空间,非常适合作为“正确答案的参照物”。我个人的习惯是,遇到矩阵类题目,先用最直白的写法求出正确答案,再拿它去校验优化版本的输出,而不是一上来就追求最优解。
关于指针,我建议新手不必强行一步到位。二维数组的指针退化规则(数组名退化成指向数组的指针)是个理解门槛,很多人第一遍学就卡在这里。可以先把下标写法练熟,等哪天你的性能分析真的显示指针遍历更划算,再回头补这一课。就做题而言,方法二的下标写法已经把复杂度压到理论下界,指针写法带来的性能收益在中小规模上基本看不出来;它真正的价值,是让你彻底搞懂二维数组在内存里长什么样,这个认知在以后处理图像数据、做矩阵运算时都会用到。
最后分享一个我常用的验证小技巧:任何矩阵类的程序,都拿一个 1×1 的输入跑一遍。这个用例小到极致,但能同时检验读取、判断和输出三个环节,而且结果唯一、易于心算。很多越界和初始化问题,在 1×1 上会暴露得特别明显——因为它把所有循环边界都推到了极限位置。如果连 1×1 都过不了,就别指望它在更大的数据上能对了。