2-SAT问题浅显入门指北


\(0.\) 写在前面

这只菜菜的黄鸭最近自学了 \(2-SAT\) 问题,便自己整理了一下,希望对大家有所帮助(

另外,\(2-SAT\) 主要部分是 \(Tarjan\) 算法,不会的自行 bdfs(bushi


\(1. \, 2-SAT\) 问题的大概形式(定义)

我们把 \(2-SAT\) 拆成 \(SAT \, + \, 2\),分别来看:

\(SAT\)\(Satisfiability\) 的缩写,意思是“可满足性”。\(2\) 指一共有两种类型变量(也可以理解成只有 \(0\)\(1\) 的布尔变量)

综合来看,\(2-SAT\) 问题 大概 就是给定一串布尔变量,每个变量只可以为真 \((1)\) 或假 \((0)\),并给出一系列约束变量的条件,要求回答关于满足以上条件的布尔序列的各种信息(如序列本身、方案数等)

例题也可以看这里:P4782 【模板】2-SAT 问题

我们举个例子来看一下这个问题:(这个是 \(3-SAT\) 问题,真正的 \(2-SAT\) 问题是只有两个约束条件)

Tops里,LCJ老师正在为一个模板写代码,写的代码要符合所有同学的码风要求。假设课堂有四位学生:wht,sx,hjl,wjx,他们的要求如下:

wht:我要求代码满足下列条件之一:

  1. 要加 ios::sync_with_stdio(0);
  2. 要开 O2
  3. 大括号换行

sx:我要求代码满足下列条件之一:

  1. 要加 ios::sync_with_stdio(0);
  2. 不开 O2
  3. 大括号不换行

hjl:我要求代码满足下列条件之一:

  1. 不加 ios::sync_with_stdio(0);
  2. 不开 O2
  3. 大括号不换行

wjx:我要求代码满足下列条件之一:

  1. 不加 ios::sync_with_stdio(0);
  2. 开 O2
  3. 大括号换行

我们不妨把“加 ios::sync_with_stdio(0)”定义为 \(a\),“开 O2” 定义为 \(b\),“大括号换行”定义为 \(c\),前面加 \(\neg\) 表示相反的意义

则四位同学的要求如下:

同学名称 条件 \(a\) 条件 \(b\) 条件 \(c\)
wht \(a\) \(b\) \(c\)
sx \(a\) \(\neg b\) \(\neg c\)
hjl \(\neg a\) \(\neg b\) \(\neg c\)
wjx \(\neg a\) \(b\) \(c\)

满足的布尔方程即为 \((a \vee b \vee c)\land(a \vee \neg b \vee \neg c)\land(\neg a \vee \neg b \vee \neg c)\land(\neg a \vee b \vee c)\)

而LCJ老师所要做的就是为 \(a \, , \, b \, , \, c\) 三个变量赋值,以满足四位学生的交流

这就是 \(3-SAT\) 问题的一个例子


\(2. \, 2-SAT\) 问题的解法

\(2.1\) 朴素思想(暴力)

那这怎么赋值呢?

我们首先来考虑最简单的方法:暴力

枚举每一个变量的每一种可能,如果符合要求LCJ老师就会按这种方案写

很显然,对于每个变量只有两种可能的话,时间复杂度为 \(O(2^n)\),若每个变量有 \(m\) 种可能,时间复杂度就会为 \(O(m^n)\)

如果我们就这样写,那妥妥会 \(\text{TLE}\)

那怎么优化呢?

……

没错,对于 常规 \(SAT\) 问题我们 只有暴力(因为 \(SAT\) 问题目前已经证明是 NP完全问题,只能暴力,具体证明戳 这里这里

但是,我们说了,对于 常规 只可以暴力,但是 \(2-SAT\) 问题有它自己独特的优化方法,请见下文


\(2.2\) 针对 \(2-SAT\) 的特殊解法

因为此类问题涉及变量之间的关系,考虑用图论来解决

首先假设有 \(n\) 个布尔变量 \(x_1 \sim x_n\)\(m\) 个约束条件 \((i_1,a_1,j_1,b_1) \sim (i_m,a_m,j_m,b_m)\),对于每个约束条件 \((i,a,j,b)\)\(x_i=a \vee x_j =b\)\(a,b \in \{0,1 \}\)

我们考虑建这样一个图:对于每一个布尔变量 \(x_i\) 我们建两个节点,第一个节点表示 \(x_i\) 时的状态,存储下标为 \(i\);第二个节点表示 \(\neg x_i\),的状态,存储下标为 \(i+n\)

接下来我们考虑如何建边:

首先我们考虑一个关系:\(x_i \land x_j = 0\)

那这说明 \(x_i\)\(x_j\)必然至少 有一个是 \(0\)

所以我们由 \(x_i=1\) 可以推出 \(x_j = 0\)\(x_j=1\) 可以推出 \(x_i =0\)

那可不可以由 \(x_i=0\) 推出 \(x_j=1\) 呢?

不行,因为 \(x_i =0\) 的话 \(x_i \land x_j\) 必然为 \(0\),与 \(x_j\) 无关

\(x_j=0\) 不可推出 \(x_i=1\) 同理

所以我们就可以由 \(i\) 连向 \(j+n\),由 \(j\) 连向 \(i+n\)

图被洛谷吃了

要注意只有关系确切才可以建边

通过上述推理,我们总结出一般的建边规则:

若满足 \(x_i = a \vee x_j = b\),那么我们建两条 有向边:(很重要,这是建边的 原则

  1. \(x_i[\neg a] \rightarrow x_j[b]\)
  2. \(x_j[\neg b] \rightarrow x_i[a]\)

也可以总结为 若其中一个变量不成立,则另一个变量必须成立

附:若 \(x_p=1\),则我们从 \(p+n \rightarrow p\),意味我们即使走到了 \(x_p=0\),也会走到 \(x_p=1\),也就是 保证 \(x_p=1\)

图建好了,那接下来我们首先应该判断有没有解。

首先我们考虑 无解:对于一个 \(x_i\),若其既能由 \(i\) 走到 \(i+n\),又能由 \(i+n\) 走到 \(i\),那就证明此数据无解

因为既要 \(x_i=1\),又要 \(x_i=0\),这办不到

也就是说,如果 \(i\)\(i+n\)​ 在 同一个强连通分量中,那么此问题无解

而判断强连通分量的算法常用 \(Tarjan\)

例如 ,此问题就无解

否则就是有解。对于有解的情况,题目一般会叫你输出一组合法的解

那怎么对每一个 \(x_i\) 赋值呢?

我们继续来看一个例子(画图技术很差,轻喷)

我们推理一下:

因为由 \(j\) 可以走到 \(j+n\),所以 \(x_j=0\)(如果不理解可以看上文如何连边)

因为由 \(i+n\) 可以走到 \(i\),所以 \(x_i=1\)(同上)

如果再看不懂请看下面详细(口胡)解释:

反证法:

如果我们把 \(x_i =0\),会发生什么呢?

由图知若 \(x_i=0\),则 \(x_j=1\),但又因 \(x_j=1\) 可以推出 \(x_i=1\),与 \(x_i=0\) 矛盾,所以 $x_i =1 $

证毕。

由此我们可以推出一个推论:在有解的情况下,一个布尔变量的两种取值是具有 前后推导关系 的,也就是一个值间接影响了另一个取值。

而我们所要赋值的那个值,并不会产生矛盾

在Tarjan缩点的拓扑上,表现为我们要在两种取值中选择拓扑序较大的那个值(因为它后遍历到,不会产生矛盾)

所以我们接下来要三部曲:缩点、拓扑、染色(bushi

只不过我们在 \(Tarjan\) 缩点中就已经求出强连通分量的拓扑序了,只不过是反序

所以就转变为求拓扑序较小的那个值了

其实以我的理解是拓扑序越大的点在一棵树上是越靠近叶节点的,然后越靠近叶节点的那些节点在 Tarjan 的时候是越早被缩点的,所以拓扑序越大的点其所在强联通分量编号越小,那么我们只要取两个取值中强联通分量编号较小的所对应的值就可以了(这是保证不会错的,因为有时候两个值取哪个都行)


\(3. \, 2-SAT\) 的代码实现

哇哈哈哈哈哈,代码来喽

本小节将分部分讨论,以 这道题 为例(请先仔细读题,不要当 \(\texttt{ctjer}\)

\(3.1\) 输入

cin>>n>>m;//输入变量数与约束关系数量
for(int i=1;i<=m;i++)
{
    int x,xv,y,yv;
	cin>>x>>xv>>y>>yv;//输入对应约束条件
    //接下来按2.2特殊解法的建边规则进行建边,x表示第x个变量,x+n表示第x个变量的相反值
	if(!xv&&!yv) gra[x].push_back(y+n),gra[y].push_back(x+n);
	else if(xv&&!yv) gra[x+n].push_back(y+n),gra[y].push_back(x);
	else if(!xv&&yv) gra[y+n].push_back(x+n),gra[x].push_back(y);
	else if(xv&&yv) gra[x+n].push_back(y),gra[y+n].push_back(x);
}

当然,这是笔者的写法。由于笔者太菜,分分钟被大佬吊打, 便发现了另一种使用 位运算 建边的方法,代码如下:

cin>>n>>m;
for(int i=1;i<=m;i++)
{
    int x,xv,y,yv;
    cin>>x>>xv>>y>>yv;
    gra[x+n*(xv&1)].push_back(y+n*(yv^1));
    gra[y+n*(yv&1)].push_back(x+n*(xv^1));
}

这里用分类讨论的方法解释一下这样建边正确的原因:(其实不难,自己手推也可以)


\[1. \text{当} xv=0,yv=0 \text{时} \\ \because x+ n \times (xv \land 1) = x+n \times 0=x \\ \, \, \, y+ n \times (yv \land 1) = y+n \times 0 =y \\ \, \, \, \text{本人解法中该部分部分也为} x,y ;\\ \quad \quad \, y+n \times (yv \oplus 1) = y+n \times 1 =y+n\\ \quad \quad \, x+n \times (xv \oplus 1) = x+n \times 1 =x+n \\ \, \, \, \text{本人解法中该部分也为} y+n,x+n \\ \therefore \text{该解法在} xv=0,yv=0 \text{时成立} \]


\[2. \text{当} xv=0,yv=1 \text{时} \\ \because x+ n \times (xv \land 1) = x+n \times 0=x \\ \quad \quad \, \, y+ n \times (yv \land 1) = y+n \times 1 =y+n \\ \, \, \, \text{本人解法中该部分部分也为} x,y+n ;\\ \, \, y+n \times (yv \oplus 1) = y+n \times 0 =y\\ \quad \quad \, \, x+n \times (xv \oplus 1) = x+n \times 1 =x+n \\ \, \, \, \text{本人解法中该部分也为} y,x+n \\ \therefore \text{该解法在} xv=0,yv=1 \text{时成立} \]


\[3. \text{当} xv=1,yv=0 \text{时} \\ \quad \, \, \, \, \because x+ n \times (xv \land 1) = x+n \times 1=x+n \\ \, \, \, y+ n \times (yv \land 1) = y+n \times 0 =y \\ \, \, \, \text{本人解法中该部分部分也为} x+n,y ;\\ \quad \quad \, y+n \times (yv \oplus 1) = y+n \times 1 =y+n\\ \quad \quad \, x+n \times (xv \oplus 1) = x+n \times 0 =x \\ \, \, \, \text{本人解法中该部分也为} y+n,x \\ \therefore \text{该解法在} xv=1,yv=0 \text{时成立} \]


\[4. \text{当} xv=1,yv=1 \text{时} \\ \quad \, \, \, \, \because x+ n \times (xv \land 1) = x+n \times 1=x+n \\ \, \, \, y+ n \times (yv \land 1) = y+n \times 1 =y+n \\ \, \, \, \text{本人解法中该部分部分也为} x+n,y+n ;\\ \quad \quad \, y+n \times (yv \oplus 1) = y+n \times 0 =y\\ \quad \quad \, x+n \times (xv \oplus 1) = x+n \times 0 =x \\ \, \, \, \text{本人解法中该部分也为} y,x \\ \therefore \text{该解法在} xv=1,yv=1 \text{时成立} \]


综上,四种情况全部考虑完,所以该做法成立


\(3.2\) Tarjan求强连通分量

这个板子不用多讲了吧(bushi

int dfn[2000009],low[2000009],col[2000009],cnt,colc;
int st[2000009],top;
bool vis[2000009];//数组空间一定要开够
inline void tarjan(int u)//Tanjan板子,不用多讲了,一模一样
{
	dfn[u]=low[u]=++cnt,st[++top]=u,vis[u]=1;
	for(int i=0;i<gra[u].size();i++)
	{
		int v=gra[u][i];
		if(!dfn[v]) tarjan(v),low[u]=min(low[u],low[v]);
		else if(vis[v]) low[u]=min(low[u],dfn[v]);
	}
	if(dfn[u]==low[u])
	{
		colc++;
		while(st[top]!=u) col[st[top]]=colc,vis[st[top--]]=0;
		col[st[top]]=colc,vis[st[top--]]=0;
	}
	return;
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);//玄学读入优化
    //do something...
    //以下是Tarjan板子,不会的自行百度
	for(int i=1;i<=n*2;i++)
		if(!dfn[i]) tarjan(i);
    //do something...
	return 0;
} 

此部分还算比较简单,记住板子就可以


\(3.3\) 判定无解与有解的方案输出

无解的判定(见上文):如果一个变量的两个值都在同一个强连通分量内,那此题无解

除无解以外都是有解(这个不用多说了吧)

关于有解的输出也请见上问,懒得搬运

int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
    //do something
	for(int i=1;i<=n;i++)
		if(col[i]==col[i+n])
		{
			cout<<"IMPOSSIBLE";//如果一个变量的两个值都在同一个强连通分量内,那此题无解
			return 0;
		}
	cout<<"POSSIBLE"<<endl;//先输出有解
	for(int i=1;i<=n;i++)
		if(col[i]>col[i+n]) cout<<"1 ";//详见2.2,如果不是Tarjan求强连通分量要改成小于号
		else cout<<"0 ";
	return 0;
} 

这样,一道 \(\text{紫}\) 题就被我们写出来了 \(\sim\sim\sim\sim\sim\)

附上 高清无注释 代码:

//#pragma GCC optimize(3)
//#pragma GCC optimize(2)
#include<bits/stdc++.h>
using namespace std;
int n,m;
vector<int>gra[2000009];
int dfn[2000009],low[2000009],col[2000009],cnt,colc;
int st[2000009],top;
bool vis[2000009];
inline void tarjan(int u)
{
	dfn[u]=low[u]=++cnt,st[++top]=u,vis[u]=1;
	for(int i=0;i<gra[u].size();i++)
	{
		int v=gra[u][i];
		if(!dfn[v]) tarjan(v),low[u]=min(low[u],low[v]);
		else if(vis[v]) low[u]=min(low[u],dfn[v]);
	}
	if(dfn[u]==low[u])
	{
		colc++;
		while(st[top]!=u) col[st[top]]=colc,vis[st[top--]]=0;
		col[st[top]]=colc,vis[st[top--]]=0;
	}
	return;
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=m;i++)
	{
		int x,xv,y,yv;
		cin>>x>>xv>>y>>yv;
		if(!xv&&!yv) gra[x].push_back(y+n),gra[y].push_back(x+n);
		else if(xv&&!yv) gra[x+n].push_back(y+n),gra[y].push_back(x);
		else if(!xv&&yv) gra[y+n].push_back(x+n),gra[x].push_back(y);
		else if(xv&&yv) gra[x+n].push_back(y),gra[y+n].push_back(x);
	}
	for(int i=1;i<=n*2;i++)
		if(!dfn[i]) tarjan(i);
	for(int i=1;i<=n;i++)
		if(col[i]==col[i+n])
		{
			cout<<"IMPOSSIBLE";
			return 0;
		}
	cout<<"POSSIBLE"<<endl;
	for(int i=1;i<=n;i++)
		if(col[i]>col[i+n]) cout<<"1 ";
		else cout<<"0 ";
	return 0;
} 

\(4.\) 总结

此题重点就在建边与输出方案上,要理解边与边、点与点之间的练习,加以推理就可以写出了

预祝各位同学:

\[RP++ \]


posted @ 2022-08-03 14:57  DreamerX  阅读(653)  评论(4)    收藏  举报