2-sat
1.算法分析
有 n 个变量x[1...n],每个变量的可能取值为1或0(或称i和~i必取到其中1个)。
给定 m 个约束条件,每个约束条件形如:
若 x[i] 取 i(或者~i),则 x[j] 必取 j(或者~j)
判定是否存在对每个变量的合法赋值,使所有约束都被满足
判定方法:
- 建立 2N 个点有向图,i 和 ~i 一般设为 i 和 i+N
- 对于每个约束条件连2条有向边(原命题以及其逆否命题),例如 i->j,则同时连 (~j) -> (~i)
- Tarjan算法求出有向图的scc
对于存在某个变量x[i],i 和 ~i 属于同一个scc(即 i 和 ~i 可以相互导出)则必然无解,否则有解。 - 如果要求出路径,那么把每个点的scc[i]和scc[opp(i)]进行比较,如果scc[i] < scc[opp(i)], 那么输出1; 否则,输出0
本质:
2-sat的本质就是判断一个xi的属性,因为xi只能是0或1,因此如果xi既是0也是1那么就是非法状态(scc[xi] == scc[~xi])。2-sat和扩展域并查集的本质相同,不同的在于适用条件不同,2-sat只需要一个条件能够推导出2个命题即可(原命题、逆否命题),而扩展域并查集则需要一个条件能够推导出4个命题(原命题、逆否命题、否命题、逆命题)。异或能够导出4个命题,与/或能导出2个命题。2-sat建立的是有向边,所以使用tarjan算法处理;扩展域并查集使用无向边,所以使用并查集处理。
建边技巧:
建边的原则就是建边时一定要考虑原命题和它的逆否命题,建边分两种情况:
- 第一种:已经告诉i和j有连边,且已知他们之间的关系,&、|、 ^ , 然后按照给定的关系进行建边,&建2条边,|建2条边,^建4条边。
- 没有告诉i和j连边,那么只能N^2去枚举i和j,判断它两之间是否能够建边,然后根据判断的结果建边。比如i和j之间不能建边,那就说明i->~j, j->~i。同时,可能存在特殊情况,需要去枚举M^2,那么一般来说M都必须规约到N的数量级才行。注意枚举的时候要判断i==j->continue
2. 版子
// 属于已知边关系建边
#include<bits/stdc++.h>
using namespace std;
int const N = 2e6 + 10, M = 2e6 + 10;
// dfn记录每个点的时间戳,low记录每个点的回溯值,scc[i]=x表示i在标号为x的强连通分量里,stk维护一个栈,sccnum记录强连通分量的个数
int dfn[N], low[N], scc[N], stk[N], sccnum, top, timestamp;
int h[N], e[M], ne[M], idx;
int n, m;
// a->b有一条边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
// tarjan算法求强连通分量
void tarjan(int root)
{
if (dfn[root]) return; // 时间戳不为0,返回
dfn[root] = low[root] = ++timestamp; // 记录当前点的时间戳和回溯值,初始化二者相同,而后dfn[root]>=low[root]
stk[++top] = root; // 把根放入栈内
for (int i = h[root]; i != -1; i = ne[i]) // 遍历每一个与根节点相邻的点
{
int j = e[i]; // 与i相邻的点为j
if (!dfn[j]) // j点没有访问过
{
tarjan(j); // 继续dfs,得到所有以j点为根的子树内所有的low和dfn
low[root] = min(low[root], low[j]); // 根的low是其子树中low最小的那个
}
else if (!scc[j]) // 如果j这个点还在栈内(在栈内的话不属于任何一个scc),同时一个栈内的点在一个scc内
{
low[root] = min(low[root], dfn[j]); // low代表所能到达的最小的时间戳
}
}
// 如果root的后代不能找到更浅的节点(更小的时间戳)
if (low[root] == dfn[root]) // 只有某个强连通分量的根节点的low和dfn才会相同
{
sccnum++;
while (1) // 出栈直到等于root
{
int x = stk[top--];
scc[x] = sccnum;
if (x == root) break;
}
}
}
int opp(int x) {
if (x > n) return x - n;
else return x + n;
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
for (int i = 1, a, b, c, d; i <= m; ++i) {
scanf("%d %d %d %d", &a, &b, &c, &d);
if (!b) a = opp(a);
if (!d) c = opp(c);
// a|c=1 => ~a->c, ~c->a
add(opp(a), c), add(opp(c), a);
}
// tarjan求scc
for (int i = 1; i <= n * 2; ++i)
if (!dfn[i]) tarjan(i);
// 判断是否满足条件
for (int i = 1; i <= n; ++i) {
if (scc[i] == scc[opp(i)]) {
cout << "IMPOSSIBLE
";
return 0;
}
}
// 打印路径
cout << "POSSIBLE
";
for (int i = 1; i <= n; ++i) {
if (scc[i] < scc[opp(i)]) cout << "1 ";
else cout << "0 ";
}
return 0;
}
3. 典型例题
3.1 已知点与点关系
acwing370卡图难题
有N个变量X0~XN−1,每个变量的可能取值为0或1。
给定M个算式,每个算式形如 Xa op Xb=c,其中 a,b 是变量编号,c 是数字0或1,op 是 and,or,xor 三个位运算之一。求是否存在对每个变量的合法赋值,使所有算式都成立。
/* 本题的建边需要好好牢记,&、|能够推出2个命题,^能够推出4个命题 */
#include<bits/stdc++.h>
using namespace std;
int const N = 1e3 + 10, M = 4e6 + 10;
// dfn记录每个点的时间戳,low记录每个点的回溯值,scc[i]=x表示i在标号为x的强连通分量里,stk维护一个栈,sccnum记录强连通分量的个数。该题属于已知边关系建边。
int dfn[N], low[N], scc[N], stk[N], sccnum, top, timestamp;
int h[N], e[M], ne[M], idx;
int n, m;
char op[10];
// a->b有一条边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
// tarjan算法求强连通分量
void tarjan(int root)
{
if (dfn[root]) return; // 时间戳不为0,返回
dfn[root] = low[root] = ++timestamp; // 记录当前点的时间戳和回溯值,初始化二者相同,而后dfn[root]>=low[root]
stk[++top] = root; // 把根放入栈内
for (int i = h[root]; i != -1; i = ne[i]) // 遍历每一个与根节点相邻的点
{
int j = e[i]; // 与i相邻的点为j
if (!dfn[j]) // j点没有访问过
{
tarjan(j); // 继续dfs,得到所有以j点为根的子树内所有的low和dfn
low[root] = min(low[root], low[j]); // 根的low是其子树中low最小的那个
}
else if (!scc[j]) // 如果j这个点还在栈内(在栈内的话不属于任何一个scc),同时一个栈内的点在一个scc内
{
low[root] = min(low[root], dfn[j]); // low代表所能到达的最小的时间戳
}
}
// 如果root的后代不能找到更浅的节点(更小的时间戳)
if (low[root] == dfn[root]) // 只有某个强连通分量的根节点的low和dfn才会相同
{
sccnum++;
while (1) // 出栈直到等于root
{
int x = stk[top--];
scc[x] = sccnum;
if (x == root) break;
}
}
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
for (int i = 1, a, b, c; i <= m; ++i)
{
scanf("%d%d%d%s", &a, &b, &c, op);
getchar();
if(op[0]=='A' && c==0){//a&b = 0, a->~b, b->~a
add(a, b + n);
add(b, a + n);
}
if(op[0]=='A' && c==1){//a&b = 1, ~a->a, -b->b
add(a + n, a);
add(b + n, b);
}
if(op[0]=='O' && c==0){//a|b = 0, a->~a, b->~b
add(a, a + n);
add(b, b + n);
}
if(op[0]=='O' && c==1){//a|b = 1, ~a->b, ~b->a
add(a + n, b);
add(b + n, a);
}
if(op[0]=='X' && c==0){//a^b = 0, a->b, ~a->~b, b->a, ~b->-a
add(a, b);
add(a + n, b + n);
add(b, a);
add(b + n, a + n);
}
if(op[0]=='X' && c==1){//a^b = 1, a->~b, ~a->b, b->~a, ~b->a
add(a, b + n);
add(a + n, b);
add(b, a + n);
add(b + n, a);
}
}
// tarjan求scc
for (int i = 1; i <= n * 2; ++i)
if (!dfn[i]) tarjan(i);
// 判断是否满足条件
for (int i = 1; i <= n; ++i) {
if (scc[i] == scc[i + n]) {
cout << "NO";
return 0;
}
}
cout << "YES";
return 0;
}
2.2 未知点与点关系
acwing371牧师约翰最忙碌的一天
9月1日这天牧师需要忙碌婚礼的事情,有 N 对情侣在这天准备结婚,每对情侣都预先计划好了婚礼举办的时间,其中第 i 对情侣的婚礼从时刻 Si 开始,到时刻 Ti 结束。第 i 对情侣需要 Di 分钟完成这个仪式,即必须选择 Si~Si+Di 或 Ti−Di~Ti 两个时间段之一。现在给定时间可选时间段,求出是否存在合法方案,并打印路径
// 本题没有给出边的关系,但点的数目很少,直接枚举建边
// 属于边未知关系,枚举建边
#include<bits/stdc++.h>
using namespace std;
int const N = 2e3 + 10, M = N * N;
// dfn记录每个点的时间戳,low记录每个点的回溯值,scc[i]=x表示i在标号为x的强连通分量里,stk维护一个栈,sccnum记录强连通分量的个数
int dfn[N], low[N], scc[N], stk[N], sccnum, top, timestamp;
int h[N], e[M], ne[M], idx, d[N], n, m, t[N][2];
// a->b有一条边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
// tarjan算法求强连通分量
void tarjan(int root)
{
if (dfn[root]) return; // 时间戳不为0,返回
dfn[root] = low[root] = ++timestamp; // 记录当前点的时间戳和回溯值,初始化二者相同,而后dfn[root]>=low[root]
stk[++top] = root; // 把根放入栈内
for (int i = h[root]; i != -1; i = ne[i]) // 遍历每一个与根节点相邻的点
{
int j = e[i]; // 与i相邻的点为j
if (!dfn[j]) // j点没有访问过
{
tarjan(j); // 继续dfs,得到所有以j点为根的子树内所有的low和dfn
low[root] = min(low[root], low[j]); // 根的low是其子树中low最小的那个
}
else if (!scc[j]) // 如果j这个点还在栈内(在栈内的话不属于任何一个scc),同时一个栈内的点在一个scc内
{
low[root] = min(low[root], dfn[j]); // low代表所能到达的最小的时间戳
}
}
// 如果root的后代不能找到更浅的节点(更小的时间戳)
if (low[root] == dfn[root]) // 只有某个强连通分量的根节点的low和dfn才会相同
{
sccnum++;
while (1) // 出栈直到等于root
{
int x = stk[top--];
scc[x] = sccnum;
if (x == root) break;
}
}
}
// 判断是否矛盾
bool isx(int i, int fi, int j, int fj){
if(t[i][fi] >= t[j][fj]+d[j]) return 0;
if(t[i][fi]+d[i] <= t[j][fj]) return 0;
return 1;
}
int str2int(string s){
int x = 0;
x = (s[0]-'0')*10 + (s[1]-'0');
x *= 60;
x += (s[3]-'0')*10 + (s[4]-'0');
return x;
}
string int2str(int x){
string s = "00:00";
int h = x/60, m = x%60;
s[0] = '0' + h/10; s[1] = '0' + h%10;
s[3] = '0' + m/10; s[4] = '0' + m%10;
return s;
}
int main()
{
cin >> n;
memset(h, -1, sizeof h);
// 读入并进行时间转换
for (int i = 1; i <= n; ++i) {
string start, end;
cin >> start >> end >> d[i];
t[i][0] = str2int(start);
t[i][1] = str2int(end) - d[i];
}
// 枚举建边
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
if (i == j) continue;
// isx函数判断是否矛盾
if (isx(i, 0, j, 0)) add(i, j + n), add(j, i + n);
if (isx(i, 1, j, 0)) add(i + n, j + n), add(j, i);
if (isx(i, 0, j, 1)) add(i, j), add(j + n, i + n);
if (isx(i, 1, j, 1)) add(i + n, j), add(j + n, i);
}
}
// tarjan求scc
for (int i = 1; i <= n * 2; ++i)
if (!dfn[i]) tarjan(i);
// 判断是否满足条件
for (int i = 1; i <= n; ++i) {
if (scc[i] == scc[i + n]) {
cout << "NO
";
return 0;
}
}
// 打印路径
cout << "YES
";
for (int i = 1; i <= n; ++i) {
if (scc[i] < scc[i + n]) cout << int2str(t[i][0]) << " " << int2str(t[i][0] + d[i]) << endl;
else cout << int2str(t[i][1]) << " " << int2str(t[i][1] + d[i]) << endl;
}
return 0;
}