AcWing 257. 关押罪犯
\(AcWing\) \(257\). 关押罪犯
一、题目描述
\(S\) 城现有两座监狱,一共关押着 \(N\) 名罪犯,编号分别为 \(1\)∼\(N\)。
他们之间的关系自然也极不和谐。
很多罪犯之间甚至积怨已久,如果客观条件具备则随时可能爆发冲突。
我们用 怨气值(一个正整数值)来表示某两名罪犯之间的仇恨程度,怨气值越大,则这两名罪犯之间的积怨越多。
如果两名怨气值为 \(c\) 的罪犯被关押在同一监狱,他们俩之间会发生摩擦,并造成影响力为 \(c\) 的冲突事件。
每年年末,警察局会将本年内监狱中的所有冲突事件按影响力从大到小排成一个列表,然后上报到 \(S\) 城 \(Z\) 市长那里。
公务繁忙的 \(Z\) 市长只会去看列表中的第一个事件的影响力,如果影响很坏,他就会考虑撤换警察局长。
在详细考察了 \(N\) 名罪犯间的矛盾关系后,警察局长觉得压力巨大。
他准备将罪犯们在两座监狱内重新分配,以求产生的冲突事件影响力都较小,从而保住自己的乌纱帽。
假设只要处于同一监狱内的某两个罪犯间有仇恨,那么他们一定会在每年的某个时候发生摩擦。
那么,应如何分配罪犯,才能使 \(Z\) 市长看到的那个冲突事件的 影响力最小 ?这个最小值是多少?
输入格式
第一行为两个正整数 \(N\) 和 \(M\),分别表示罪犯的数目以及存在仇恨的罪犯对数。
接下来的 \(M\) 行每行为三个正整数 \(a_j,b_j,c_j\),表示 \(a_j\) 号和 \(b_j\) 号罪犯之间存在仇恨,其怨气值为 \(c_j\)。
数据保证 \(1≤a_j<b_j<N,0<c_j≤10^9\) 且每对罪犯组合只出现一次。
输出格式
输出共 \(1\) 行,为 \(Z\) 市长看到的那个冲突事件的影响力。
如果本年内监狱中未发生任何冲突事件,请输出 \(0\)。
二、题目解析
其实对于二分图判定的做法还是比较好想的,也易于实现
由于本题要求把罪犯划分到两个监狱中(我理解为划分到两个不同的集合中)那么我不禁想到图论的二分图
首先,抛来一个 二分图 的定义:
如果一张无向图的\(n\)个节点(\(n>=2\))可以分为\(A\),\(B\)两个集合,
且满足$A ∩ B = ∅ \(,而且在**同一集合内的点之间都没有边相连**,那么这张无向图被称为**二分图**,其中\)A\(和\)B$分别叫做二分图的左部和右部
-
说人话的定义:图中点通过移动能分成左右两部分,左侧的点只和右侧的点相连,右侧的点只和左侧的点相连。
-
下图就是个二分图:
- 下图不是个二分图:
如果判断一个图是不是二分图?
-
开始对任意一未染色的顶点染色
-
判断其相邻的顶点中,若未染色则将其染上和相邻顶点不同的颜色
-
若已经染色且颜色和相邻顶点的颜色相同则说明不是二分图,若颜色不同则继续判断
-
\(bfs\)和\(dfs\)可以搞定!
三个等价关系
那么对于本题,我们就是要把所有人分为两个部分,其间不出现矛盾,显然很符合二分图的要求
别太高兴,问题来了:如何判定这个 矛盾图 是不是二分图
在这里,抛出二分图判定定理:
一张无向图是二分图:
当且仅当图中不存在奇环(奇环是指长度为奇数的环)
既然有了判定定理,我们就可以使用染色法进行二分图判定
染色法实现
\(1\).大多数情况基于\(dfs\) 或者 \(bfs\)
\(2\).我们尝试用黑和白两种颜色标记图中的点,当一个节点被标记了,那么所有与它相连的点应全部标记为相反的颜色
如果在标记过程中出现冲突,那么算法结束,证明本图中存在奇环,即本图不为二分图;反之,如果算法正常结束,那么证明本图是二分图
那好啦,现在有了染色法判定二分图,那么我们还需要考虑一件事:这个最小矛盾值怎么求?
首先,我们考虑这样一个判定问题:是否存在一种分配方案,使得最小的矛盾值不超过\(mid\)。显然,当\(mid\)较小时可行的方案对于\(mid\)较大时依然可行。换言之,本题的答案具有 单调性,可以采用二分的方法求解,将求最小值问题转换为判定问题
策略如下:
将罪犯当做点,罪犯之间的仇恨关系当做点与点之间的无向边,边的权重是罪犯之间的仇恨值。
那么原问题变成:将所有点分成两组,使得各组内边的权重的最大值尽可能小。
我们在 \([0,10^9]\) 之间枚举最大边权 \(limit\),当 \(limit\) 固定之后,剩下的问题就是:
- 判断能否将所有点分成两组,使得所有权值大于 \(limit\) 的边都在组间,而不在组内。也就是判断由所有点以及所有权值大于 \(limit\) 的边构成的新图是否是二分图。
判断二分图可以用染色法,时间复杂度是 \(O(N+M)\),其中 \(N\) 是点数,\(M\) 是边数
可以参考\(AcWing\) \(860\). 染色法判定二分图
为了加速算法,我们来考虑是否可以用二分枚举 \(limit\), 假定最终最大边权的最小值是 \(Ans\):
-
当 \(limit∈[Ans,10^9]\) 时,所有边权大于 \(limit\) 的边,必然是所有边权大于 \(Ans\) 的边的子集,因此由此构成的新图也是二分图。
-
当 \(limit∈[0,Ans−1]\) 时,由于 \(Ans\) 是新图可以构成二分图的最小值,因此由大于 \(limit\) 的边构成的新图一定不是二分图。
-
所以整个区间具有二段性,可以二分出分界点 \(Ans\) 的值。二分算法模板可以参考这篇。
时间复杂度分析
总共二分 \(logC\) 次,其中 \(C\) 是边权的最大值,每次二分使用染色法判断二分图,时间复杂度是 \(O(N+M)\),其中 \(N\) 是点数,\(M\) 是边数。因此总时间复杂度是 \(O((N+M)logC)\)。
四、\(dfs\)染色法
#include <bits/stdc++.h>
using namespace std;
const int N = 20010, M = 200010;
int n, m;
int color[N]; //二分图的标记数组
//邻接表
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
//是不是一个合法的二分图
// u:节点号 c:颜色,1:黑,2:白 3-c:互转, limit:最小冲突值
bool dfs(int u, int c, int limit) {
color[u] = c; //染色为c
for (int i = h[u]; ~i; i = ne[i]) { //枚举每条出边
if (w[i] <= limit) continue; //不关心小于limit冲突值的关系
int j = e[i];
if (color[j]) { //如果j染过色
if (color[j] == c) return false; //并且与u一样,那就冲突了
} else if (!dfs(j, 3 - c, limit)) //如果没有染过色,并且在后续的染色过程中出现冲突,也就是无法成为合法二分图
return false; // 染色失败
}
return true; //染色成功
}
//使用limit做为最小的冲突值,这样划分的两个图是不是二分图
bool check(int limit) {
memset(color, 0, sizeof color); //清空二分图的标记数组
for (int i = 1; i <= n; i++)
if (color[i] == 0) //如果没有标记过,将i这个点标识为黑色:1
if (!dfs(i, 1, limit)) //存在冲突,不是二分图,返回false
return false;
return true; //没有冲突,是二分图
}
int main() {
scanf("%d %d", &n, &m);
memset(h, -1, sizeof h);
while (m--) {
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
add(a, b, c), add(b, a, c);
}
//二分答案
int l = 0, r = 1e9;
while (l < r) {
int mid = l + r >> 1;
if (check(mid))
r = mid;
else
l = mid + 1;
}
//输出最小的匹配值
printf("%d\n", l);
return 0;
}
五、\(bfs\)染色法
#include <bits/stdc++.h>
using namespace std;
const int N = 20010, M = 200010;
typedef pair<int, int> PII;
int n, m;
int color[N]; //二分图的标记数组
//邻接表
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
// bfs实现
bool bfs(int u, int limit) {
//假设 1:黑,2:白,这样方便理解一些
color[u] = 1;
queue<PII> q; //两个属性:节点号,颜色
q.push({u, 1}); //将节点u入队列,颜色为黑
while (q.size()) {
PII t = q.front();
q.pop();
int u = t.first, c = t.second;
//找到这个节点关联的其它节点
for (int i = h[u]; ~i; i = ne[i]) {
if (w[i] <= limit) continue; //不关心小于limit冲突值的关系
int j = e[i];
//没染色就染成相反的颜色
if (!color[j]) {
color[j] = 3 - c;
//并且把这个新的节点入队列,再探索其它的相邻节点
q.push({j, 3 - c});
} else if (color[j] == c)
return false;
//染过的,有两种情况,一种是与本次要求的染色一样,一种是不一样,
//不一样就是矛盾
}
}
return true;
}
//使用limit做为最小的冲突值,这样划分的两个图是不是二分图
bool check(int limit) {
//清空二分图的标记数组
memset(color, 0, sizeof color);
//这里一般都是枚举每个节点,然后找突破口
for (int i = 1; i <= n; i++)
if (color[i] == 0) //如果没有标记过
//将i这个点标识为黑色:1
if (!bfs(i, limit)) //存在冲突,不是二分图,返回false
return false;
return true; //没有冲突,是二分图
}
int main() {
scanf("%d %d", &n, &m);
memset(h, -1, sizeof h);
while (m--) {
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
add(a, b, c), add(b, a, c);
}
//二分答案
int l = 0, r = 1e9;
while (l < r) {
int mid = l + r >> 1;
if (check(mid))
r = mid;
else
l = mid + 1;
}
//输出最小的匹配值
printf("%d\n", l);
return 0;
}
六、朴素并查集
枚举 当前最大的仇恨值 的边,让当前仇恨值最大的罪犯在两个不同的监狱中,根据 朋友的朋友是朋友,敌人的敌人是朋友 的原则,将这两名罪犯划分入不同的监狱中,如果当前仇恨值最大的罪犯已经在同一个监狱中,那么当前最大仇恨值就是答案。
#include <bits/stdc++.h>
using namespace std;
const int N = 20010, M = 100010;
struct Node {
int x, y, v;
const bool operator<(const Node &t) const {
return v > t.v;
}
} a[M];
int n, m;
int p[N]; // 并查集
int q[N]; // q[i] 表示 i 的敌人,是一个辅助数组,辅助并查集使用
int find(int x) {
if (x == p[x]) return x;
return p[x] = find(p[x]);
}
bool join(int a, int b) {
if (find(a) == find(b)) return false;
p[find(a)] = find(b);
return true;
}
//两个罪犯是否向两个监狱安排时出现冲突
bool check(int x, int y) {
x = find(x), y = find(y);
return x == y; //只有不在一个并查集时才是不冲突,如果出现在了一个集合中就是有了冲突
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) p[i] = i, q[i] = n + i;
for (int i = 1; i <= m; i++) scanf("%d%d%d", &a[i].x, &a[i].y, &a[i].v);
sort(a + 1, a + m + 1);
//从冲突值由大到小枚举,直到找出冲突时停止,此时,就是冲突的最小值
for (int i = 1; i <= m; i++) {
int x = a[i].x, y = a[i].y, v = a[i].v;
//本着把冲突大的两方尽量安排到两个监狱去,将冲突值由大向小排序,如果这样安排下,出现无法安排的情况,就是最小冲突值
if (check(x, y)) {
printf("%d", v);
exit(0);
}
if (q[x] == x + n)
q[x] = y; // x的敌人团伙q[x],现在团伙中只有1人,是y
else
join(q[x], y); // x的敌人团伙q[x],y申请加入此团伙q[x]
if (q[y] == y + n)
q[y] = x; // y的敌人团伙q[y],现在团伙中只有1人,是x
else
join(q[y], x); // y的敌人团伙q[y],x申请加入此团伙q[y]
//其实,说是简单的并查集,其实通过引入q的对手数组,就是一个扩展域并查集
}
//输出无解
printf("0");
return 0;
}
七、扩展域并查集
贪心+排序+并查集
很容易发现这道题目具有明显的贪心痕迹,以及并查集的归属关系,那么既然如此,我们就利用 贪心+排序+并查集 来处理这道题目.
首先我们 将权值从大到小排序,然后对于每一对权值,如果说可以避免开,那么必须避免开,因为我们的目的是让最大的影响力变得最小(这也是为什么,这道题目可以二分答案的原因,不过我们不需要).
想要使最后的最大怨气值最小,就是 尽量让怨气值大的两名罪犯分在两个监狱里 。所以每次加入一对罪犯,就需要判断其在不在一个并查集里,若不在,那么将两名罪犯加入到补集之中。然后若在一个并查集里,那么我们可以直接输出,因为最大权值,已经被贪心锁定好了。
现在假设 \(i\) 与 \(j\) 敌对,那么根据上述定义,令原域的 \(i\) 与扩展域的 \(j\) 连边,再令原域的 \(j\) 与扩展域的 \(i\) 连边,表达 敌对 关系。若此时有另一 敌对关系 \((j,k)\) ,那么完成连边后,可以得到下图:
这时我们可以发现,如果我们在原域查询 \(i,k\) ,可以得到友好关系,完成了 敌人的敌人是朋友 的表达。
由此可知:种类并查集可以维护对立关系,而且可以维护多对等价双向的对立关系,可以维护 敌人的敌人是朋友 这一原则。
#include <bits/stdc++.h>
using namespace std;
const int N = 20010, M = 100010;
int n, m, x, y;
// p[i]表示i所属的集合,用p[i+n]表示i所属集合的补集,实现的很巧妙,可以当成一个使用并查集的巧妙应用;
int p[N << 1]; //并查集数组
int find(int x) {
if (x == p[x]) return x;
return p[x] = find(p[x]);
}
//合并集合
bool join(int a, int b) {
if (find(a) == find(b)) return false;
p[find(a)] = find(b);
return true;
}
//关系
struct Node {
int x, y, v; //冲突值
//排序函数,按冲突值大小排序,大的在前
const bool operator<(const Node &t) const {
return v > t.v;
}
} a[M];
int main() {
//读入
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) scanf("%d %d %d", &a[i].x, &a[i].y, &a[i].v);
//排序,按冲突值由大到小排序
sort(a + 1, a + m + 1);
//初始化扩展域并查集,每个人都是自己的祖先
for (int i = 1; i <= 2 * n; i++) p[i] = i;
//处理m组关系
for (int i = 1; i <= m; i++) {
int px = find(a[i].x), py = find(a[i].y);
if (px == py) {
printf("%d\n", a[i].v);
exit(0);
} else
join(a[i].y + n, px), join(a[i].x + n, py);
}
printf("%d\n", 0);
return 0;
}
八、带权并查集
我们可以考虑 只用一倍空间的并查集 ,但是运用 完全不一样的思路。
对于 敌人的敌人是朋友 这一原则,我们考虑用模\(2\)运算表达,那么每次连边合并,我们就要赋予边权。
现在考虑实现以下要求:
- 敌人之间的路径权值模 \(2\) 为 \(1\)
- 朋友之间的路径权值模 \(2\) 为 \(0\)
容易想到以下构造:
黑色标号为加边的顺序,红色标号为边的权值,那么这个关系是 \(rt\) 与 \(f_x\)敌对,\(f_x\) 与 \(x\) 敌对,而 \(rt\) 与 \(x\) 友好,同时它们之间的路径权值和模 \(2\) 为 \(0\).
现在我们定义 \(val(x)\) 表示 \(x\) 到父亲结点的路径上的权值和。
考虑能否在带权并查集上实现路径压缩
可以实现路径压缩,但是路径压缩的过程中,被压缩掉的边的权值和要记录下来,并与结点到父结点的边权一同组成新边权,这条新边从这个结点到根结点。
其中 \(S\)可以在递归的过程中计算出,并最后赋值在 \(val'(f_x)\)
代码如下:
//带权并查集路径压缩
int find(int x) {
if (x == p[x]) return x;
//!!!这里要一拆三!!!
int px = find(p[x]);
d[x] += d[p[x]];
return p[x] = px;
}
//普通并查集路径压缩
int find(int x) {
if (x == p[x]) return x;
return p[x] = find(p[x]);
}
考虑如何合并两点所在的两个不同的集合
合并两点所在的两个不同集合,关键在于 修改这两个集合的根之间的边的权值,即知道 \(x\) 与 \(f_x\)之间的关系,知道 \(y\) 与 \(f_y\)之间的关系,知道 \(x\) 与 \(y\) 的关系,求 \(f_x\) 与 \(f_y\)之间的关系。
现在我们压缩路径之后,得到 \(val(x)\) 表示 \(x\)到根结点的边权和。
所以在合并两个结合的时候,要修改 \(x\) 的根结点 \(f_x\) 到 \(f_y\) 的权值,令权值
int px = find(x), py = find(y);
if (px != py) {
d[px] = d[y] - d[x] + 1; //更新距离
p[px] = py; //合并并查集
}
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100010, M = 20000;
struct Node {
int x, y, w;
const bool operator<(const Node &t) {
return w > t.w;
}
} a[N];
int n, m, p[M], d[M];
int find(int x) {
if (x == p[x]) return x;
int px = find(p[x]);
d[x] += d[p[x]];
return p[x] = px;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++)
scanf("%d %d %d", &a[i].x, &a[i].y, &a[i].w);
sort(a + 1, a + m + 1);
for (int i = 1; i <= n; i++) p[i] = i; //初始化并查集
for (int i = 1; i <= m; i++) {
int x = a[i].x, y = a[i].y;
int px = find(x), py = find(y);
if (px != py) {
d[px] = d[y] - d[x] + 1; //更新距离
p[px] = py; //合并并查集
} else if ((d[y] - d[x]) % 2 == 0) {
printf("%d\n", a[i].w);
return 0;
}
}
printf("%d\n", 0);
return 0;
}