题目背景
公元二零一四年四月十七日,小明参加了省赛,在一路上,他遇到了许多问题,请你帮他解决。
题目描述
已知车上有 N 排座位,有 2N 个人参加省赛,每排座位只能坐两人,且每个人都有两个自己想坐的排数,问最多使多少人坐到自己想坐的位置。
输入格式
第一行,一个正整数 N。
第二行至第 2N+1 行,每行两个正整数 Si,1,Si,2,为第 i 个人想坐的排数。
输出格式
一个非负整数,为最多使得多少人满意。
输入输出样例
输入 #1复制运行
4
1 2
1 3
1 2
1 3
1 3
2 4
1 3
2 3
输出 #1复制运行
7
说明/提示
对于 10% 的数据,n≤10;
对于 30% 的数据,n≤50;
对于 60% 的数据,n≤200;
对于 100% 的数据,n≤2000。
1. 题意转换
这道题本质上是在求:在一个左部节点数(人)为 2N、右部节点数(排数)为 N 且右部节点容量为 2 的二分图中,寻找最大匹配数。
2. 思考过程与解题思路
第一直觉的暴力解法是什么? 面对这种分配问题,初学者的第一直觉往往是 DFS 深度优先搜索,枚举每个人到底去他喜欢的两个座位中的哪一个。由于有 2N 个人,每个人有 2 种大方向选择,整体的时间复杂度会呈现指数级 O(2^2N)。当 N=2000 时,2^4000 的复杂度必定会导致严重的超时。
如何推导到正解? 我们将视角切换到图论模型:把"人"看作左边的点,把"座位"看作右边的点。每个人与他想坐的排数连一条边,问题就变成了经典的"匹配问题"。
但这里有一个极其棘手的冲突:传统的二分图匹配(如标准的匈牙利算法)是严格的"一对一"模型,也就是"一夫一妻制"。而这道题里,一排座位可以坐两个人,相当于右部的点"容量为 2"。 怎么办?
破局点:"拆点"思想。 既然算法只认"一对一",那我们就从物理层面把"容量为 2 的点"变成"两个容量为 1 的点"! 一共有 N 排座位,我们把第 i 排座位分裂成两个独立的座位:编号 i 和编号 n+i。 这样一来,右部节点的总数也变成了 2N(编号从 1 到 2N),每个座位刚好只能坐 1 个人。原题目瞬间降维,变成了一个完美的 2N×2N 的标准二分图最大匹配问题!
3. 算法设计与样例推演
核心算法与数据结构:
-
匈牙利算法(DFS 实现):利用"协商让位(寻找增广路)"的核心逻辑来撮合匹配。
-
拆点映射:左侧点为 1...2N(代表 2N 个人),右侧点为 1...2N(代表 2N 个独立座位)。第 x 排座位对应右侧的 x 和 x+n。
-
邻接表
vector<int> vec:记录每个人可以坐的 4 个具体座位编号。
手玩推演核心逻辑(协商让位): 假设当前排到了第 u 个人,他想坐在第 1 排(拆分为座位 1 和座位 n+1)。
-
他去查看座位 1,如果单空着,直接坐下(匹配成功)。
-
假如座位 1 已经坐了张三,座位 n+1 坐了李四,怎么办?
-
u 会对张三发出灵魂拷问(触发
dfs(linked[1])):"兄弟,你除了第 1 排,是不是还想坐第 3 排?你去第 3 排坐行不行,把第 1 排让给我?" -
张三如果能在其他排找到空位,或者张三又能去把别人挤走......经过一条链式的"让位",只要最后有一个人找到了空位,整个链路上的所有人顺势交接,成功让 u 也坐下,总满意人数 +1。
4. 时空复杂度分析
-
时间复杂度:O(V×E)。对于匈牙利算法,外层循环遍历左部点 V=2N。每次 DFS 最坏情况下遍历所有的边。由于每个人最多只有 2 个意向排数(拆点后连出 4 条边),所以边数 E=8N。总时间复杂度为 O(N^2)。当 N=2000 时,最大运算量在 4×10^6 左右,C++ 1秒内轻松跑完,非常合理。
-
空间复杂度 :O(V+E)。邻接表存图空间为 8N 级别,
used和linked数组大小为 2N 级别。对于 N=2000,内存占用远不到 1MB,绝对不会 MLE。
5. 坑点与易错总结
-
数组越界(致命坑点) :题目给出的是 N,但实际的人数和拆分后的座位数都是 2N。如果习惯性地按照题目的变量 N 开数组(比如开 2050),必然会因为 4000 的下标访问导致 RE。务必将数组开到 4000 以上。
-
多条出边 :每次读取两个志愿排数 a 和 b,拆点后每个人必须连续
push_back4个右部节点(a, n+a, b, n+b),少连任何一条边都会导致答案偏小。
6. 代码进阶与优化剖析(核心教学点)
审视一下我的代码,逻辑严密,"拆点"的思想落地得非常漂亮,这是一份可以AC的高质量代码!但为了让你在省选甚至更高阶的比赛中拥有更强的竞争力,我们需要探讨以下优化空间:
进阶优化 1:消除 memset 的常数开销(时间戳优化) 在我的 hungary() 函数中,每次处理一个新人物,都会执行一遍 memset(used, 0, sizeof(used));。虽然本题 2N=4000 不算大,但这就带来了 O(V^2) 的基础常数。 优化思路 :我们可以用"时间戳"替代布尔值。不要在每次循环都清空数组,而是把当前的轮次 u 当作标记传进去。判断条件从 !used[v] 改为 used[v] != u。这样,就能彻底抹除 memset,让算法常数呈数量级下降!
7.完整代码
cpp
//很明显的二分匹配问题 求最大匹配数(即最多能让多少人满意)每匹配一对就等于是成功一排
//每一排等于是两个点 左边和右边
#include <iostream>
#include <vector>
#include <cstring>//对应memset
using namespace std;
int n;
int ln;//左边的点个数 即每个人
int rn;//右边的点个数 即排数(一共有n排)但每一排我们用两个数来表示
int used[4020];//代表右边的点在当前那一轮的循环中是否已经被使用
int linked[4020];//代表右边的座位所匹配的左边的人
vector<int> vec[4020];//邻接表存图
//x是本轮循环左边的点(人)
bool dfs(int x){
//对于左边的每个点x(人),去遍历它能匹配到的(有线连接)的每个右点v
//即对于左边的每个人(x)去遍历它能坐的所有右边的座位v
for(int v:vec[x]){
//如果右边的座位v这一轮还没有被坐
//【优化】:可将此处改为if(used[v]!=x),配合外层传入时间戳x 即可彻底省略外层的memset
if(!used[v]){
//标记这一个座位已经被坐了
used[v]=1;
//如果v点之前没有匹配的左点 (座位空着)
//或v点之前匹配的左点可以找到新的匹配的右点从而把v释放出来 (强迫原主人换座)
if(linked[v]==-1 || dfs(linked[v])){
//就让v匹配的左点变成x(上位接手座位)
linked[v]=x;
//并返回成功匹配
return true;
}
}
}
//如果一直到这里都没有成功匹配返回false (没空位,别人也不肯让)
return false;
}
int hungary(){
int idx=0;//idx代表最多使得多少人满意
//初始化所有座位都没有匹配人(值为-1)
memset(linked,-1,sizeof(linked));
//遍历所有左边的人
for(int u=1;u<=2*n;u++){
//每一轮循环初始化右边的点(座位)都没有被使用过
//【优化】:若dfs使用时间戳判断,这里的memset就完全不需要了,极大降低运行常数
memset(used,0,sizeof(used));
if(dfs(u)) idx++;
}
return idx;
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n;
ln=2*n;//左边点数为2n(2n个人)
//右边点数为2n
//(因为每一排有两个座位,所以第一排两个座位可以设置为1 n+1)
//第二排两个座位2 n+2;第三排两个座位3 n+3......以此类推
rn=2*n;
//邻接表存图-边(每个人想坐的座位)
for(int i=1;i<=2*n;i++){
int a,b;
//第i个人想坐第a排和第b排
cin>>a>>b;
//第i个人想坐第a排,第a排为a和n+a
vec[i].push_back(a);
vec[i].push_back(n+a);
//第i个人想坐第b排,第b排为b和n+b
vec[i].push_back(b);
vec[i].push_back(n+b);
}
//匈牙利算法
cout<<hungary();
return 0;
}