[USACO05NOV] Asteroids G(洛谷-P7368)

题目描述

贝茜想在 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个发射器。暴力枚举的时间复杂度高达 。结合数据范围 , 的运算量必然会导致严重的超时。

如何一步步推导到正解?

  1. 建立网格图模型 :每一个小行星坐标(x, y)就像是一个十字路口的交汇点。要消灭这颗小行星,我们要么发射第x行的武器,要么发射第y列的武器,两者至少选其一。

  2. 抽象为二分图:我们将所有的"行"看作左部的N个点,所有的"列"看作右部的N个点。每一个小行星(x, y)就是连接左部第x个点和右部第y个点的一条边。

  3. 触及核心概念:现在问题转化为------我们要选择最少的点(行或列),使得图中的每一条边(小行星),都至少有一个端点被选中。这在图论中被严谨地定义为"最小顶点覆盖"。

  4. 召唤柯尼希定理(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 匹配 :看中右 1,单身,直接匹配!linked[1] = 1。当前匹配:1对。

  2. 左 2 匹配 :看中右 2,单身,直接匹配!linked[2] = 2。当前匹配:2对。

  3. 左 3 匹配 :看中右 2,但右 2 已被左 2 占据。触发协商 dfs(linked[2])(让左 2 去换个目标)。

  4. 左 2 找备胎 :左 2 发现自己只连接了右 2,无路可退,返回 false。

  5. 匹配结束 :左 3 匹配失败。最终最大匹配数为 2。

    (最小代价为 2,具体方案为消灭第一行和第二列)。

4. 时空复杂度分析

  • 时间复杂度 :。外层循环遍历 N 个左部点。最坏情况下,每一次 DFS 会遍历图中的所有边(小行星数 K)。由于本题 ,匈牙利算法实际运行时的上界极少能跑满,且代码中加入了"空节点跳过"的常数优化,C++ 在 1 秒内跑完完全无压力。

  • 空间复杂度 :O(N + K)。邻接表需要存储 K 条边,used 和 linked 数组大小仅为 N 级别(510),整体内存开销约几 MB,远低于题目的 256MB 的常规限制,不会产生 MLE。

5. 坑点与易错总结

  1. 数据范围混淆 :许多初学者习惯性地把所有数组都开到K的大小(250000)。实际上,图的顶点数(行数和列数)只有。used 和 linked 数组开到 510 才是最精准、最省内存的做法。

  2. 清空标记数组的时机 :匈牙利算法中,每一次外层进入新的 DFS 寻找增广路前,必须清空 used 数组。千万不能放到 DFS 内部去清空,否则会导致无限死循环。

  3. 单向边建立即可 :二分图匹配中,如果统一从左向右搜索,建立单向边 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;
}
相关推荐
AI技术新视界2 小时前
Harness^2:递归自进化智能体Harnessing框架
人工智能·深度学习·算法·harness·rsi
这个DBA有点耶2 小时前
MySQL B-tree索引并发控制优化:写密集场景的锁瓶颈与内核级解法
java·数据库·程序人生·算法·database·dba
爱读书的小胖2 小时前
Deaf Grandma:C 语言三版代码分析
算法
信奥卷王2 小时前
[GESP202503 六级] 环线(含视频)
算法
CQU_JIAKE3 小时前
10.15【A】
算法
晴天的雨.9923 小时前
【C++】模板进阶
开发语言·c++·算法
木井巳3 小时前
【动态规划】三步问题
java·算法·leetcode·动态规划·推荐算法
楚识科技3 小时前
手写 OCR 识别技术深度解析:从算法原理到政企级落地实现
算法·ocr
leobertlan12 小时前
痛苦系列 | DSP-03 跨越单位圆:Z变换与零极点分析
算法·程序员