图论—— 2-SAT 学习笔记

说在前面

本文大多数参照作者的理解思考,可能存在偏差,欢迎讨论/纠正。

Pt.1 问题引入:满汉全席

现在你正在参加一个厨艺大赛。你面前有 $ n $ 种材料,每种必须选择采用满式做法还是汉式做法。

评委席上坐着 $ m $ 位呆萌可爱的奶龙。每个奶龙会有两个偏好,具体表现为某种材料必须采用满式/汉式(如材料3必须采用汉式,材料7必须采用满式等),只要满足其中的任意一个偏好你就可以获得奶龙的赞赏并得到一个通过。

只有全部通过才能当上厨神(?),所以我们需要求出是否有一个方案使得所有评审都通过。

实际上,这就是一个经典的 2-SAT 模型:

每个变量有两种取值,每个约束都关联两个变量,且要求「至少一个成立」。

Pt.2 如何解决

有一个很显而易见的暴力做法: 01DFS ,将每个材料做满式/汉式的情况都枚举一遍。

总方案数为 $ 2 ^ n $ ,当 $ n $ 稍微大一点的时候这个做法的效率劣势就会变得非常明显。

所以我们不能暴力,而是应当采用一个更高效的方法。

我们可以对比一下。其实,暴力的本质就是在所有方案中搜索,而 2-SAT 则是将问题转化为一个图,用图来描述一种强制关系(也就是一个选择会强制哪些其它选择)并用图论的工具来检查是否矛盾以及构造方案。

Pt.3 如何 2-SAT

上面也说了,其实 2-SAT 本质就是将选择之间的关系转化为图。

具体地,我们发现如果我们要满足所有评审,假如说一个选择是 $ A $,另一个选择是 $ B $,那么如果我们不选 $ A $ 的话为了满足所有评审就一定要选 $ B $ 。

所以我们可以对所有的这种强制关系建边,具体地,对于所有 $ A $ 与 $ B $ ,我们建两条边即 \(\neg A \to B\)\(\neg B \to A\)

建模完了,然后捏?

烧烤一下,什么情况会导致我们无法满足所有评审?而且这样建图不会让 $\neg A $ 与 $ A $ 互相可达么?那这个图不就飞了吗?

发现了吗?其实这种 $\neg A $ 与 $ A $ 互相可达的情况就是无解。因为这意味着你要是不选 $ A $ 你就必须选 $ A $ ,如果你选了 $ A $ 你就必须不选 $ A $ ,这不就无解了么。

但是如何判断呢?

前面我们介绍了,2-SAT是用图来描述一种强制关系并用图论的工具来检查是否矛盾以及构造方案。现在我们先思考是否矛盾。

回忆一下什么东西是有互相可达/连通的关键词的。于是我们考虑 Tarjan 求个强连通分量。

具体的,我们求出所有点所在的强连通分量,然后针对每个选择判断选/不选是否在同一个强连通分量里,如果是的话说明两者连通,即无解。

但是实现又是一个问题。我在和小蓝鱼对话的时候了解到了一个 trick :异或。

具体的,对于每一个 $ i $ 有:

  • $ 2i \oplus 1 = 2i + 1 $
  • $ 2i + 1 \oplus 1 = 2i $

这样就可以轻松实现对于选/不选的转化。

所以我们可以写一个 get_node 函数,用来获取每个选择所对应的图的点的编号,同时实现上面那个东东。

好了,现在判断的实现问题也解决了,但是如何构造具体方案呢?

我们可以用 Tarjan 缩点,并进行拓扑排序。在拥有合法方案的情况下,每个 $\neg A $ 与 $ A $ 一定是不在同一个强连通分量里的。那么如果我们选拓扑序小的点,它可能会走到拓扑序大的点从而引发矛盾。所以我们对于每个 $\neg A $ 与 $ A $ 取拓扑序更大的点就可以构造出一个合法方案了。

Pt.4 实战

Problem 1

题面

原题链接

题目描述

满汉全席是中国最丰盛的宴客菜肴,有许多种不同的材料透过满族或是汉族的料理方式,呈现在数量繁多的菜色之中。由于菜色众多而繁杂,只有极少数博学多闻技艺高超的厨师能够做出满汉全席,而能够烹饪出经过专家认证的满汉全席,也是中国厨师最大的荣誉之一。世界满汉全席协会是由能够料理满汉全席的专家厨师们所组成,而他们之间还细分为许多不同等级的厨师。

为了招收新进的厨师进入世界满汉全席协会,将于近日举办满汉全席大赛,协会派遣许多会员当作评审员,为的就是要在参赛的厨师之中,找到满汉界的明日之星。

大会的规则如下:每位参赛的选手可以得到 \(n\) 种材料,选手可以自由选择用满式或是汉式料理将材料做成菜肴。

大会的评审制度是:共有 \(m\) 位评审员分别把关。每一位评审员对于满汉全席有各自独特的见解,但基本见解是,要有两样菜色作为满汉全席的标志。如某评审认为,如果没有汉式东坡肉跟满式的涮羊肉锅,就不能算是满汉全席。但避免过于有主见的审核,大会规定一个评审员除非是在认为必备的两样菜色都没有做出来的状况下,才能淘汰一位选手,否则不能淘汰一位选手。

换句话说,只要参赛者能在这两种材料的做法中,其中一个符合评审的喜好即可通过该评审的审查。如材料有猪肉,羊肉和牛肉时,有四位评审员的喜好如下表:

评审一 评审二 评审三 评审四 
满式牛肉 满式猪肉 汉式牛肉 汉式牛肉 
汉式猪肉 满式羊肉 汉式猪肉 满式羊肉 

如参赛者甲做出满式猪肉,满式羊肉和满式牛肉料理,他将无法满足评审三的要求,无法通过评审。而参赛者乙做出汉式猪肉,满式羊肉和满式牛肉料理,就可以满足所有评审的要求。

但大会后来发现,在这样的制度下如果材料选择跟派出的评审员没有特别安排好的话,所有的参赛者最多只能通过部分评审员的审查而不是全部,所以可能会发生没有人通过考核的情形。

如有四个评审员喜好如下表时,则不论参赛者采取什么样的做法,都不可能通过所有评审的考核:

评审一 评审二 评审三 评审四 
满式羊肉 满式猪肉 汉式羊肉 汉式羊肉 
汉式猪肉 满式羊肉 汉式猪肉 满式猪肉 

所以大会希望有人能写一个程序来判断,所选出的 \(m\) 位评审,会不会发生没有人能通过考核的窘境,以便协会组织合适的评审团。

输入格式

第一行包含一个数字 \(K\)\(1\le K \le 50\)),代表测试文件包含了 \(K\) 组数据。

每一组测试数据的第一行包含两个数字 \(n\)\(m\)\(n≤100\)\(m≤1000\)),代表有 \(n\) 种材料,\(m\) 位评审员。

为方便起见,舍弃做法的中文名称而给予编号,编号分别从 \(1\)\(n\)

接下来的 \(m\) 行,每行都代表对应的评审员所拥有的两个喜好,每个喜好由一个英文字母跟一个数字代表,如 \(m1\) 代表这个评审喜欢第 \(1\) 个材料透过满式料理做出来的菜,而 \(h2\) 代表这个评审员喜欢第 \(2\) 个材料透过汉式料理做出来的菜。

输出格式

每组测试数据输出一行,如果不会发生没有人能通过考核的窘境,输出 GOOD;否则输出 BAD(均为大写字母)。

输入输出样例 #1

输入 #1

2
3 4
m3 h1
m1 m2
h1 h3
h3 m2
2 4
h1 m2
m2 m1
h1 h2
m1 h2

输出 #1

GOOD
BAD

solution

也许细心的你已经注意到了,这实际上就是我们在 Pt.1 举出的例子。

直接按照 Pt.3 里面讲的东东来写即可。具体有不明白的可看代码。

AC code

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=105,M=1005;
int T;
int n,m,uu[M],vv[M],dfn[2*N+1],low[2*N+1],idx,id[2*N+1],sccs;
char x[M],y[M];
vector<vector<int> >g;
stack<int>st;
inline int get_id(char c,int u){//获取图上节点编号
	if(c=='h')return 2ll*u;
	return 2ll*u+1;
}
inline void dfs(int u){//Tarjan
	low[u]=dfn[u]=++idx;
	st.push(u);
	for(int i=0;i<g[u].size();i++){
		int v=g[u][i];
		if(!dfn[v]){
			dfs(v);
			low[u]=min(low[u],low[v]);
		}
		else if(!id[v]){
			low[u]=min(low[u],dfn[v]);
		}
	}
	if(low[u]==dfn[u]){
		sccs++;
		while(st.top()!=u)id[st.top()]=sccs,st.pop();
		st.pop();
		id[u]=sccs;
	}
}
void sol(void){
    g.clear();
	cin>>n>>m;
	memset(dfn,0,sizeof dfn);
	memset(low,0,sizeof low);
	memset(id,0,sizeof id);
	sccs=0;
	g.resize(n*2+2);
	for(int i=1;i<=m;i++){
		cin>>x[i]>>uu[i]>>y[i]>>vv[i];
		int u=get_id(x[i],uu[i]),v=get_id(y[i],vv[i]);
		g[u^1].push_back(v);
		g[v^1].push_back(u);
	}
	for(int i=2;i<=2*n+1;i++)if(!dfn[i])dfs(i);
	for(int i=1;i<=n;i++){
		if(id[get_id('h',i)]==id[get_id('m',i)]){
			cout<<"BAD\n";
			return ;
		}
	}
	cout<<"GOOD\n";
}
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0);cout.tie(0);
    cin>>T;
    while(T--)sol();
    return 0;
}
Problem 2

题面

原题链接

题目描述

Katu Puzzle 以一个有向图 \(G(V, E)\) 的形式给出,其中每条边 \(e(a, b)\) 都被标记为一个布尔运算符 \(\text{op}\)(AND, OR, XOR 之一)以及一个整数 \(c\)\(0 \leq c \leq 1\))。如果可以为每个顶点 \(V_i\) 找到一个值 \(X_i\)\(0 \leq X_i \leq 1\)),使得对于每条边 \(e(a, b)\)\(\text{op}\)\(c\) 标记的情况下,以下公式成立:

\[X_a \ \text{op} \ X_b = c \]

那么这个 Katu 是可解的。

给定一个 Katu Puzzle,你的任务是确定它是否可解。

输入格式

第一行包含两个整数 \(N\)\(1 \leq N \leq 100\))和 \(M\)\(0 \leq M \leq 10,000\)),分别表示顶点的数量和边的数量。

接下来的 \(M\) 行中,每行包含三个整数 \(a\)\(0 \leq a < N\)),\(b\)\(0 \leq b < N\)),\(c\) 以及一个操作符 \(\text{op}\),描述这条边。

输出格式

输出一行,包含 \(\texttt{YES}\)\(\texttt{NO}\)

输入输出样例 #1

输入 #1

4 4
0 1 1 AND
1 2 1 OR
3 2 0 AND
3 0 0 XOR

输出 #1

YES

solution

写的时候恶心到我了。

这道题的建模还是有点复杂的。对于每个运算我们都要分类讨论以判断如何建模。

细细品尝完题目之后,我们可以做一个大分讨:

  • \(X_a \text{ AND } X_b = 1\)
    含义:\(X_a=1\)\(X_b=1\)
    连边:

    \[\neg a \to a, \quad \neg b \to b \]

  • \(X_a \text{ AND } X_b = 0\)
    含义:\(\neg X_a \lor \neg X_b\)
    连边:

    \[a \to \neg b, \quad b \to \neg a \]

  • \(X_a \text{ OR } X_b = 1\)
    含义:\(X_a \lor X_b\)
    连边:

    \[\neg a \to b, \quad \neg b \to a \]

  • \(X_a \text{ OR } X_b = 0\)
    含义:\(X_a=0\)\(X_b=0\)
    连边:

    \[a \to \neg a, \quad b \to \neg b \]

  • \(X_a \text{ XOR } X_b = 1\)
    含义:\(X_a \neq X_b\)
    连边:

    \[a \to \neg b, \quad \neg b \to a, \quad \neg a \to b, \quad b \to \neg a \]

  • \(X_a \text{ XOR } X_b = 0\)
    含义:\(X_a = X_b\)
    连边:

    \[a \to b, \quad b \to a, \quad \neg a \to \neg b, \quad \neg b \to \neg a \]

做完建模之后这道题就差不多了。直接按照正常 2-SAT 跑判断即可。

AC code

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=105;
int n,m,dfn[2*N],low[2*N],idx,id[2*N],sccs;
vector<int>g[2*N];
stack<int>st;
inline void add(int u,int v){g[u].push_back(v);}
inline void dfs(int u){
	low[u]=dfn[u]=++idx;
	st.push(u);
	for(int i=0;i<g[u].size();i++){
		int v=g[u][i];
		if(!dfn[v]){dfs(v);low[u]=min(low[u],low[v]);}
		else if(!id[v])low[u]=min(low[u],dfn[v]);
	}
	if(low[u]==dfn[u]){
		sccs++;
		while(st.top()!=u)id[st.top()]=sccs,st.pop();
		st.pop();id[u]=sccs;
	}
}
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n>>m;
	while(m--){
		int a,b,c;string opt;
		cin>>a>>b>>c>>opt;
		a*=2;b*=2;
		if(opt=="AND"){
			if(c)add(a^1,a),add(b^1,b);
			else add(a,b^1),add(b,a^1);
		}else if(opt=="OR"){
			if(c)add(a^1,b),add(b^1,a);
			else add(a,a^1),add(b,b^1);
		}else{
			if(c)add(a,b^1),add(a^1,b),add(b,a^1),add(b^1,a);
			else add(a,b),add(a^1,b^1),add(b,a),add(b^1,a^1);
		}
	}
	for(int i=0;i<2*n;i++)if(!dfn[i])dfs(i);
	for(int i=0;i<n;i++)if(id[2*i]==id[2*i+1]){cout<<"NO";return 0;}
	cout<<"YES";
	return 0;
}

THE END

感谢您的阅读!

posted @ 2026-07-16 20:59  fireclouds_11  阅读(8)  评论(0)    收藏  举报