1. 程式人生 > 實用技巧 >2-SAT問題,一個神奇的東西

2-SAT問題,一個神奇的東西

目錄

參考資料與前話

luogu題解

伍昱奆佬的PPT Orz

順便說一下,如果看這篇文章的人有一些較高深的2-sat姿勢如果可以的話發個評論,感覺我這篇文章比較淺。

由於證明總是很難一塊講完講另一塊,都是互相息息相關的,所以有的證明有時候是有誤在後面再根據已經講的漏洞修改,希望大家習慣。

sat問題簡介

以前,你媽總是問你哪個是對的,哪個是錯的,現在,老師也總是問你,哪個是對的,哪個是錯的。

不過不再是1+1=2是不是對的這種幼稚的問題,而是對於一些不知道具體內容的命題,只知道他們之間存在一些邏輯問題,讓你判斷他們的真假。

現在有很多命題\(q_1,q_2,q_3...,q_n\)

給你一些他們的邏輯關係:

\(q_i\) \(or\) \(q_j=0\)

\(q_k\) \(and\) \(q_k=1\)
.......

然後判斷每個命題的真假,這種問題就叫sat問題。

特別的,如果對於所有的邏輯關係的式子中如果都是\(k\)個元的,那麼就是\(k-sat\)問題。

當然,已經有人證明了\(k-sat(k>2)\)的問題是NPC問題。

不過\(k=2\)反倒是個特例,我們把他們叫做\(2-sat\)問題。 這個問題是可以線性解決的。

曾經我看到一個奆佬說過:含有一定結構的 n-SAT 問題可以被新的 SAT solver 挺快的解決,但是完全隨機的 n-SAT 問題就很難解決。

這個問題,我們討論的就是\(2-sat\)問題。

解法

例題

例題

萬能的luogu

解法以及一點點證明

基本的轉換思路

這裡,我們僅僅討論例題裡面涉及的邏輯關係,其他的都自己想吧(╯‵□′)╯︵┻━┻。

現在題目基本上就是給你\(a\) \(or\) \(b=1\),然後進行判斷。

但是需要注意一個事情,就是是可以\(a\) \(or\) \(a=1/0\),也就是指定了\(a\)一定是\(0/1\)了。

\(2-sat\)和差分約束一樣都是利用構圖來完成的,\(2−sat\)基本思路是拆點,我們先把第\(i\)個命題拆成\(x_{i}\)\(!x_{i}\) (一下規定,如果帶有\(x_i\)

表示的是\(1\)\(!x_i\)\(0\),但如果是對於一個點\(a\)\(!a\)則是\(a\)所屬命題的另外一個值),如果我們選了\(x_i\)就是這個命題為\(1\),選\(!x_{i}\)就表示這個命題為\(0\),我們現在規定如果存在一條邊,\(a->b\),那麼就表示如果我們選了\(a\),那麼也必須一起把\(b\)選了。

那麼什麼情況下某種方案不成立呢?簡單明瞭,如果\(x\)\(!x\)同時被選中不就是這種方案不成立了嗎。

基本的構圖

也許許多人看到這個定義是蒙的,這個要怎麼構圖?

舉個例子其他自己推吧。

\(q_x\) \(or\) \(q_y=1\)

那麼就表示\(x,y\)裡面至少要有一個選\(1\)號點,也就是說如果我的\(x\)命題選了\(0\)\(y\)必須選\(1\),反之也是如此。

也就是\(!x_{x}->x_y,!x_y->x_x\)

其他的自己推,當然也有一個特例,就是\(q_i\) \(or\) \(qi=1\),也就是必須為\(1\)

我們從建圖的角度講,我們就是要讓圖裡面每次選到\(!x_{i}\)都不成立,不成立的條件是什麼呀?

不就是兩個點都被選到嗎,那麼只要\(!x_i->x_i\),不是就可以保證\(!x_i\)絕對不成立了嗎?

至此,建圖講完了。

基礎解法1

這個解法其實就是暴力,超級的暴力,對於每個點,如果\(0\)或者\(1\)沒被選到,就選它\(0/1\),然後讓他跑一邊DFS傳遞,如果沒問題,就讓他當\(0/1\)(經過的點不用重置),有問題,就將這次DFS所修改的點全部重置,然後跑另外一個值,都不成立,無解。

時間複雜度個人認為是\(O(n^2)\),但是常數小,思路簡單。

證明1

如果你對解法1心存疑慮,那麼多半是擔心如果選\(1\)成立,就選\(1\),而不考慮\(0\),這個操作有沒有可能有後效性。

這裡畫個圖幫助大家理解一下這種衝突

這個圖一坨人會說,有解啊,\(0,1\)\(0,0\),反正你開始選\(1\)就是個錯誤的選擇,有後效性懂不懂!

但是對於\(2-sat\)問題,目前我所知道的操作都是對稱的,也就是說若\(a->b\),則有\(!b->!a\),當然除了自己連自己以外(這個情況也是無傷大雅,不用理他),所以這個圖要麼不成立,要麼少了兩條邊。

所以我們對於這個方法是錯的的疑慮只是因為說怕選了\(1\)但是後面的點都是指向\(0\)的,但是你不曾考慮過如果後面有點指向\(!x_i\)\(x_i\)也會指回那個點的反點(即\(!a\)\(a\)的關係)。

考慮\(a->!x_i\),那麼\(x_i->!a\),所以不需要擔心有後效性,因為我只要選了\(1\)\(!a\)點也會被選,怎麼可能輪得到\(a\)來說話呢?

程式碼1

注:這份程式碼是把\(x_i\)當成\(0\)了。

#include<cstdio>
#include<cstring>
#define  N  2100000
#define  M  4100000
using  namespace  std;
struct  node
{
	int  x,y,next;
}a[M];int  len,last[N];
inline  void  ins(int  x,int  y)
{
	len++;
	a[len].x=x;a[len].y=y;a[len].next=last[x];last[x]=len;
}
int  n,m,col[N]/*1表示被選,2表示等待選擇*/;
int  list[N],tail;
int  fan(int  x){return  x>n?x-n:x+n;}
bool  dfs(int  x)//從x開始遞迴 
{
	if(col[x]==1)return  true;
	else  if(col[fan(x)]==1)return  false;
	col[x]=1;list[++tail]=x;//以後方便回溯
	for(int  k=last[x];k;k=a[k].next)
	{
		int  y=a[k].y;
		if(dfs(y)==false)return  false;
	}
	return  true;
}
int  ans[N];
int  main()
{
//	freopen("std.in","r",stdin);
//	freopen("vio.out","w",stdout);我就是個SB東西,這都要對拍
	scanf("%d%d",&n,&m);
	for(int  i=1;i<=m;i++)
	{
		int  x,y,a,b;scanf("%d%d%d%d",&x,&a,&y,&b);
		int  xx=fan(x),yy=fan(y);
		if(a==1)x^=xx^=x^=xx;
		if(b==1)y^=yy^=y^=yy;
		if(x==y)
		{
			if(a==b)ins(xx,x);
		} 
		else  ins(xx,y),ins(yy,x);//這個構圖可以過,但是十分亂七八糟,如果你有更好的還是用自己的吧
	}
	for(int  i=1;i<=n;i++)
	{
		if(col[i]==0  &&  col[fan(i)]==0)
		{
			tail=0;
			if(dfs(i)==0)
			{
				while(tail)col[list[tail--]]=0;
				if(dfs(i+n)==0)
				{
					printf("IMPOSSIBLE\n");
					return  0;
				}
				else  ans[i]=1;
			}
		} 
		else  ans[i]=(col[i]==0);
	}
	printf("POSSIBLE\n");
	for(int  i=1;i<n;i++)printf("%d ",ans[i]);
	printf("%d\n",ans[n]);
	return  0;
}

基礎解法2

這個做法可就是線性\(O(n)\)的了。

觀察到一個強連通分量裡面選了一個就全部都被選,那麼我只需要作個tarjan縮點求出強連通不就簡單很多了嗎。

這裡科普一下強連通的聯通塊編號其實就是拓撲序的反序,即入度為\(0\)的編號最大。

這裡隨便亂說一通,說錯了證明供上

如果\(a\)\(!a\)在一個聯通塊,當場無解, 然後對於\(a\)\(!a\),哪個所在的拓撲序編號大選哪個(程式碼表現為所在連通塊編號越小選哪個)。

基礎證明2

首先說明,對於\(a->!a\)這條邊不影響我們考慮對稱性是因為如果他影響我們考慮對稱性即他有影響強連通的個數的話,那麼就代表\(a,!a\)在一個聯通塊裡面,就代表無解,而我們討論的都是有解情況,直接忽視。

這裡無聊的證明一個東西,就是在有解情況下,一個點的聯通塊及其反點的聯通塊的點的個數相同,且裡面每個點都能在對面集合找到自己的反點。

這個運用對稱性好好想想就知道了。

這裡上個圖:

那聯通塊編號是拓撲序反序又是怎麼理解啊。

脫開2-sat,放到一般圖中:

拓撲序滿足的是什麼,對於一個點\(a\),指向\(a\)的點\(b\)的拓撲序肯定在\(a\)之前,但是\(tarjan\)剛好滿足的是若\(b->a\)\(a,b\)不同聯通塊,則\(a\)的聯通塊編號肯定比\(b\)小,所以剛好相反。

好了,現在證明最後一句話,為什麼選拓撲序大的,也就是為什麼選這個點所在聯通塊TJ(tarjan)序小的。

我們先假設\(x\)的拓撲序大於\(!x\),這樣\(x\)是會被選擇的,但是我們需要證明\(x\)能到達的點都被選了。

開始證明:
假設\(x\)的拓撲序小於\(!x\)(反過來,方便打符號),這樣選的是\(!x\),設能走到\(x\)點的點為\(y\),那麼需要證明,\(y\)也不會被選。

因為\(y\)的拓撲序小於\(x\)小於\(!x\)小於\(!y\),所以只會選擇\(!y\),不會選擇\(y\)

至於拓撲序相等的情況,隨便選一個就行了,\(x->!x\)的情況,無傷大雅,只要不影響對稱性即可。

程式碼2

#include<cstdio>
#include<cstring>
#define  N  2000050
using  namespace  std;
inline  void  myre(int  &x)
{
	x=0;char  c=getchar();
	while(c>'9'  ||  c<'0')c=getchar();
	while(c>='0'  &&  c<='9')x=(x<<3)+(x<<1)+(c^48),c=getchar();
 } 
struct  node
{
	int  y,next;
}a[N];int  len,last[N];
void  ins(int  x,int  y)
{
	len++;
	a[len].y=y;a[len].next=last[x];last[x]=len; 
}
int  n,m,vis[N],sta[N],num,scc[N]/*所在的聯通塊*/,tim,list[N],top;
inline  int  mymin(int  x,int  y){return  x<y?x:y;}
void  dfs(int  x)
{
	vis[x]=sta[x]=++tim;list[++top]=x;
	for(int  k=last[x];k;k=a[k].next)
	{
		int  y=a[k].y;
		if(!vis[y])
		{
			dfs(y);
			vis[x]=mymin(vis[x],vis[y]);
		}
		else  if(!scc[y])vis[x]=mymin(vis[x],sta[y]);
	}
	if(vis[x]==sta[x])
	{
		num++;scc[x]=num;
		while(list[top]!=x)scc[list[top]]=num,top--;
		top--;//把自己除出去 
	}
}
inline  int  fan(int  x){return  x>n?x-n:x+n;}
int  main()
{
	myre(n);myre(m); 
	for(int  i=1;i<=m;i++)
	{
		int  x,y,a,b;myre(x);myre(a);myre(y);myre(b);
		int  xx=fan(x),yy=fan(y);
		if(a==1)xx^=x^=xx^=x;
		if(b==1)yy^=y^=yy^=y;
		if(x==y)
		{
			if(a==b)ins(xx,x);
		}
		else  ins(xx,y),ins(yy,x);
	}
	for(int  i=1;i<=2*n;i++)
	{
		if(!scc[i]/*沒有陣營*/)dfs(i);
	}
	//全部都分好了陣營。
	for(int  i=1;i<=n;i++) 
	{
		if(scc[i]==scc[fan(i)])
		{
			printf("IMPOSSIBLE\n");
			return  0;
		}
	}
	printf("POSSIBLE\n");
	for(int  i=1;i<=n;i++)
	{
		if(scc[i]<scc[fan(i)])//拓撲序小的先
		{
			printf("0 ");
		} 
		else  printf("1 ");
	}
	return  0;
}

小結

沒做什麼題目,溜了溜了。