☰
C++二维数组鞍点:行最小列最大与三种解法
2026/9/29 2:04:47 网站建设 项目流程

鞍点这个题目,我在带新人刷题的时候见过太多翻车现场——思路不是不会,代码也不是写不出来,问题往往出在定义没抠清楚、边界没想周全。样例一跑就过,提交上去直接 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 建议的测试用例

写完代码别急着交,先用下面的用例过一遍。这些是我平时调试时固定会跑的几组:

编号输入矩阵期望行为考察点
13×3 递增矩阵输出 (3,1) 值 7基础流程
23×3 全为 5输出 9 个鞍点重复元素多解
31×1 矩阵值 42输出 (1,1) 值 42最小规模
42×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 都过不了,就别指望它在更大的数据上能对了。

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

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

立即咨询