题目描述
贝茜想在 N×N 的网格中驾驶她的宇宙飞船。网格中有 K 个小行星。要使驾驶过程愉快,就必须把这些小行星全部消除。
贝茜有一个武器,可以以一个单位代价消除一行或一列的全部小行星。贝茜想问你,要把所有小行星都消除的最小代价是多少。
输入格式
第一行两个整数 N,K。
接下来 K 行,每行输入 xi,yi,表示第 i 个小行星在网格的坐标。
输出格式
一行一个整数,表示把所有小行星消除的最小代价。
输入输出样例
输入 #1复制运行
3 4
1 1
1 3
2 2
3 2
输出 #1复制运行
2
说明/提示
样例解释:
样例的图为(X 为小行星):
X.X
.X.
.X.
贝茜可以分别消除第一行和第二列的小行星。
数据范围:
对于 100% 的数据,1≤N≤500,1≤K≤N×N。
1. 题意转换
本题本质上是在求:在一个以"网格行号"为左部节点、"网格列号"为右部节点、坐标位置为边的二分图中,寻找能够覆盖所有边的最小顶点覆盖数。
2. 思考过程与解题思路
直觉的暴力解法为什么不行?
初见此题,很多同学的第一直觉是 DFS 暴搜或状态压缩 DP:对于网格的每一行和每一列,枚举它到底"开不开炮"。由于网格大小,总共有2N个发射器。暴力枚举的时间复杂度高达
。结合数据范围
,
的运算量必然会导致严重的超时。
如何一步步推导到正解?
-
建立网格图模型 :每一个小行星坐标(x, y)就像是一个十字路口的交汇点。要消灭这颗小行星,我们要么发射第x行的武器,要么发射第y列的武器,两者至少选其一。
-
抽象为二分图:我们将所有的"行"看作左部的N个点,所有的"列"看作右部的N个点。每一个小行星(x, y)就是连接左部第x个点和右部第y个点的一条边。
-
触及核心概念:现在问题转化为------我们要选择最少的点(行或列),使得图中的每一条边(小行星),都至少有一个端点被选中。这在图论中被严谨地定义为"最小顶点覆盖"。
-
召唤柯尼希定理(König's theorem) :图论中有一个极其优美的神级定理指出:在二分图中,最小顶点覆盖数 = 最大匹配数!
(正如代码开头的灵感记录:同行或同列的小行星在二分图中会共享同一个端点,它们无法形成新的独立匹配对;只有既不同行也不同列的"独立"小行星才会增加最大匹配数,而消灭这些相互独立的行星,恰好需要增加同等数量的炮弹代价。)
综上,这道题瞬间降维成了使用匈牙利算法求二分图最大匹配的模板题!
3. 算法设计与样例推演
核心算法与数据结构:
-
匈牙利算法(基于 DFS):通过"寻找增广路"的核心逻辑,求出最多能构成多少对互不相交的匹配线。
-
邻接表
vector<int> vec:用于存储稀疏图,左侧代表行号,内部存储该行连接的所有列号。 -
匹配数组
linked:记录右部节点(列)当前被哪个左部节点(行)覆盖。
样例手玩推演:
带入题目样例 1:N=3, K=4。小行星在 (1,1), (1,3), (2,2), (3,2)。
-
左点 1(第1行)连向 右点 1、3。
-
左点 2(第2行)连向 右点 2。
-
左点 3(第3行)连向 右点 2。
执行过程:
-
左 1 匹配 :看中右 1,单身,直接匹配!
linked[1] = 1。当前匹配:1对。 -
左 2 匹配 :看中右 2,单身,直接匹配!
linked[2] = 2。当前匹配:2对。 -
左 3 匹配 :看中右 2,但右 2 已被左 2 占据。触发协商
dfs(linked[2])(让左 2 去换个目标)。 -
左 2 找备胎 :左 2 发现自己只连接了右 2,无路可退,返回
false。 -
匹配结束 :左 3 匹配失败。最终最大匹配数为 2。
(最小代价为 2,具体方案为消灭第一行和第二列)。
4. 时空复杂度分析
-
时间复杂度 :
。外层循环遍历 N 个左部点。最坏情况下,每一次 DFS 会遍历图中的所有边(小行星数 K)。由于本题
,匈牙利算法实际运行时的上界极少能跑满,且代码中加入了"空节点跳过"的常数优化,C++ 在 1 秒内跑完完全无压力。
-
空间复杂度 :O(N + K)。邻接表需要存储 K 条边,
used和linked数组大小仅为 N 级别(510),整体内存开销约几 MB,远低于题目的 256MB 的常规限制,不会产生 MLE。
5. 坑点与易错总结
-
数据范围混淆 :许多初学者习惯性地把所有数组都开到K的大小(250000)。实际上,图的顶点数(行数和列数)只有
。
used和linked数组开到510才是最精准、最省内存的做法。 -
清空标记数组的时机 :匈牙利算法中,每一次外层进入新的 DFS 寻找增广路前,必须清空
used数组。千万不能放到 DFS 内部去清空,否则会导致无限死循环。 -
单向边建立即可 :二分图匹配中,如果统一从左向右搜索,建立单向边
vec[x].push_back(y)即可,无需建立双向边,不仅节省内存还能加速遍历。
6. 代码进阶与优化剖析(核心教学点)
审视下方提供的代码,基础扎实,逻辑严密,且包含了值得学习的高阶编码习惯:
-
高光技巧:空节点跳过优化
代码在
hungary()函数中写道:if(vec[u].size() != 0)。这是一个极具实战价值的常数优化 。在稀疏网格中,如果某一行根本没有小行星,跳过它可以省去大量无意义的memset擦除和 DFS 调用开销。 -
进阶优化方向:时间戳替代
memset即便有上述剪枝,每次触发 DFS 仍需执行
memset(used, 0, sizeof(used))。对于极度卡常数的题目,这一步会累积不小的开销。优化思路 :把外层循环的行号
u作为"时间戳"传进dfs(int x, int timestamp)。将判断条件!used[v]升级为used[v] != timestamp,并在内部执行used[v] = timestamp。通过这种方式,旧轮次的标记天然不会干扰新轮次,memset操作可被整行删除,从而实现常数时间的断崖式下降。
7. 完整代码
cpp
//这道题在草稿纸上画一画
//把每个小行星的横坐标当成左边点 纵坐标当成右边点
//就会发现这道题的最小代价就是最大匹配数量(最小点覆盖)
//我们思考一下为什么
//能想到如果是同一行不同列的行星
//等于是从左边一个点连接右边不同的点 最大匹配就是1
//一个单位代价就可以消灭这一行所有的小行星
//如果是同一列不同行的行星
//等于是从左边不同点连接右边一个点 最大匹配就是1
//一个单位代价就可以消灭这一列所有的小行星
//当不同行 不同列时候 确实增加了一个最大匹配
//但也确实需要额外的一个单位代价
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
int n,k;
vector<int> vec[510];//邻接表存边(图)
int used[510];//标记右边的点这一轮循环是否被使用过
int linked[510];//标记右边的点所匹配的左边的点
//x是这一轮循环的左点
bool dfs(int x){
//遍历所有与x有边直接相连的左点v
for(int v:vec[x]){
//如果v在这一轮循环中还没有被使用过
//【优化】:进阶写法可改为if(used[v]!=外层时间戳),从而彻底淘汰外层的memset
if(!used[v]){
//标记v被使用
used[v]=1;
//如果右点v还没匹配左点 或者原本匹配它的行 linked[v]可以退让并去匹配别的右点(列)
if(linked[v]==-1 || dfs(linked[v])){
linked[v]=x;
return true;
}
}
}
return false;
}
//匈牙利算法计算最大匹配
int hungary(){
int idx=0;//把所有小行星消除的最小代价
//初始化所有右边的点没有匹配任何左点(-1)
memset(linked,-1,sizeof(linked));
//遍历所有左点
for(int u=1;u<=n;u++){
//如果是真实存在的左点(某个小行星的横坐标)
if(vec[u].size()!=0){
//每一轮循环初始化所有右点都没有被使用过
//【优化】:若使用时间戳判断,此处的 memset 即可直接删除
memset(used,0,sizeof(used));
if(dfs(u)) idx++;
}
}
return idx;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>k;
//存图
for(int i=1;i<=k;i++){
int x,y;//一个小行星的横纵坐标
cin>>x>>y;
//横纵坐标之间进行连线
//从左边点(横坐标)发出向右边点(纵坐标)的线
vec[x].push_back(y);
}
cout<<hungary();
return 0;
}