模版
struct Edge {
    int from, to, nxt;
};
int n;  // 变量个数

struct SAT2 {
    // from[u] 代表 u 所在的SCC编号,scc代表scc编号,sz[scc] 代表对应scc的大小
    int dfn[maxn], low[maxn], id, from[maxn], scc = 0, sz[maxn], head[maxn];
    bool in[maxn];  // instack or not
    int st[maxn], tail = -1, ecnt = 1;
    Edge edges[maxm];
    void dfs(int u) {
        in[u] = 1;
        st[++tail] = u;
        dfn[u] = low[u] = ++id;
        for (int e = head[u]; e; e = edges[e].nxt) {
            int to = edges[e].to;
            if (dfn[to] && in[to]) low[u] = min(low[u], dfn[to]);  // 要记得在栈内
            if (!dfn[to]) {
                dfs(to);
                low[u] = min(low[u], low[to]);
            }
        }
        if (dfn[u] == low[u]) {
            from[u] = ++scc;
            sz[scc] = 1;
            while (tail >= 0 && st[tail] != u) {
                int cur = st[tail];
                from[cur] = from[u];
                sz[scc]++;
                tail--;
                in[cur] = 0;  // 记得这里,将在栈中的标记去掉
            }
            tail--;
            in[u] = 0;  // 记得这里,将在栈中的标记去掉
        }
    }
    void addEdge(int u, int v) {
        Edge e = {u, v, head[u]};
        head[u] = ecnt;
        edges[ecnt++] = e;
    }

    void solve() {
        for (int i = 1; i <= 2*n; i++) {
            if (!dfn[i]) dfs(i);
        }
        for (int i = 1; i <= n; i++) {
            if (from[i] == from[i+n]) {
                cout << "-1" << "\n";  // 无解
                return;
            }
        }
        // 否则输出答案
        for (int i = 1; i <= n; i++) {
            if (from[i] < from[i+n]) {
                cout << i << "\n";  // 这个变量选择 True
            } else {
                cout << i+n << "\n";  // 这个变量选择 False
            }
        }
    }
};

int f(int u) {
    if (u > n) return u - n;
    return u + n;
}

int main() {
    int T; cin >> T;
    while (T--) {
        SAT2 sat {};  // 注意这里的 {} 才能正确清空!
        cin >> n >> m;
        for (int i = 1; i <= m; i++) {
            int u, v; cin >> u >> v;  // u,v 冲突
            sat.addEdge(u, f(v));   // u -> not v
            sat.addEdge(v, f(u));  // v -> not u
        }
    }
}

介绍

2-SAT 用于解决:寻找满足一个 二元 boolean 方程集合的解。

例如,我们有 $n$ 个 boolean 变量 $a_1,a_2,…a_n$。

而我们给出一个boolean方程集合,共有 $m$ 个方程:

$$a_1 \wedge a_3 = 1$$

$$a_1 \vee a_4 = 1$$

$$a_2 \wedge a_4 = 0$$

$$…$$

注意到每一个方程都只有两个变量 $a_i, a_j$,所以叫做 2-SAT。如果有超过 $2$ 个变量,那么是 NP 问题,只能暴力。

• 一般在题目中,当一个元素要么取某一个值,要么取另外一个值(或者状态)时,就可以将这个元素看作一个 boolean 变量,而它的两个值/状态就代表了 2-SAT 中的两个点。

原理

2-SAT 本质上是利用了 boolean 表达式之间的推导关系。

比如 $a_1 \vee a_4 = 1$ 可以等效转化为 $(\neg a_1 \rightarrow a_4) \wedge (\neg a_4 \rightarrow a_1)$。

这意味着,如果 $a_1 = 0$,那么 $a_4 = 1$,且,如果 $a_4 = 0$,那么 $a_1 = 1$。

那么我们就可以这么连边:

img

因为是推导关系所以是有向边。

那么 2-SAT 问题有解 $\iff$ 对于所有的 $i$,$a_i$ 和 $\neg a_i$ 并不在同一个强连通分量(SCC)中。

所以跑 tarjan 找SCC即可。

一些例子

考虑如下的情况:

img

在这个图中,如果我们从 $\neq a$ 出发,最终会走到 $a$。这说明 $\neq a$ 是不可能成立的。

由此可知,$a=1$。

所以我们在找 SCC 过后,还需要判断 $a$ 和 $\neq a$ 的拓扑序。哪个拓扑序更靠后,就说明这个变量必须取这个值。

这样就可以输出解的具体值了。

• 注意到在 tarjan 算法中,SCC的序号反过来就是拓扑序,所以板子中我们直接比较了 from[] 的值,哪个小就取哪个。

常用连边模型

表达式 连边方式
$a \vee b = 1$ $\neg a \rightarrow b, \neg b \rightarrow a$
$a \wedge b = 0$ $a \rightarrow \neg b, b \rightarrow \neg a$
$a = 1$ $\neg a \rightarrow a$
$a = 0$ $a \rightarrow \neg a$
$a,b$ 冲突(不能同时出现) $a \rightarrow \neg b, b \rightarrow \neg a$

2-SAT的优化建图

在一些特定的问题中,直接建图的复杂度可能过高,需要优化建图后才能跑 2-SAT。

举个例子:

题意

给定 $n$ 个元素,给定 $m$ 个组,每一组有一些元素,一个元素可能属于多个组。

现在要给一些元素加标记,使得每个组都恰好只有一个元素有标记,问这种方案是否存在?

$n,m \leq 10^5$,每个组的元素数量和不超过 $10^6$。

每个元素就两种状态:有标记/无标记,所以每个元素都是一个boolean变量。

然后看每一组:每组内的元素都互相冲突,所以可以用连边的方式来表示。

在一个组内,当任意一个元素有标记了,则其他元素都没有标记,也就是:

$$x_i \rightarrow \neg x_j, \forall j \neq i$$

img

这样连边是 $O(n^2)$ 的,不能接受。

但是注意到,2-SAT中的边是用于表达逻辑推导关系的,也就是说具有传递性,只要有 $a\rightarrow b, b\rightarrow c$,则有 $a \rightarrow c$。

这意味着,从一个点 $x$ 出发,考虑能够到达的所有点 $y$,本质上都相当于直接连了 $x \rightarrow y$ 这条边。

所以我们建 $2n$ 个辅助点,分别代表前缀和后缀。

img

  1. 对于每一个 $R_i$,连 $R_i \rightarrow R_{i+1}, R_i \rightarrow \neg X_{i+1}$。

  2. 对于每一个 $L_i$,连 $R_i \rightarrow L_{i-1}, L_i \rightarrow \neg X_{i-1}$。

  3. 对于每一个 $X_i$,连 $X_i \rightarrow R_i, X_i \rightarrow L_i$。

$R_i$ 代表后缀:从 $R_i$ 出发,就能到达所有的 $\neg X_j, \forall j > i$。

$L_i$ 代表前缀:从 $L_i$ 出发,就能到达所有的 $\neg X_j, \forall j < i$。

相当于 $X_i$ 连上了 $R_i$,就与它右边的所有 $\neg X_j$ 相连了,而连上了 $L_i$,就与它左边的所有 $\neg X_j$ 相连了。

可以发现这个新图和原图等效,在这个新图上跑 2-SAT 即可。

例题

例1 洛谷P5782 [POI2001] 和平委员会

题意

有 $n$ 个党派,每个党派有两个人(第 $i$ 个党派的人是 $2i-1, 2i$)。

给定 $m$ 个关系,表示 $a,b$ 互相厌恶。

现在每个党派需要派出恰好一个人,并且互相厌恶的人不能同时出现。

其中,$n \leq 8000, m \leq 20000$。

题解

本题可以用二分图染色做。

2-SAT 也是可以的。每个党派看作一个 boolean 变量。

参考表达式中的 $a,b$ 冲突就可以做了。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 4e5+5;
const int maxm = 5e5+505;

struct Edge {
    int from, to, nxt;
};
int n;

struct SAT2 {
    // from[u] 代表 u 所在的SCC编号,scc代表scc编号,sz[scc] 代表对应scc的大小
    int dfn[maxn], low[maxn], id, from[maxn], scc = 0, sz[maxn], head[maxn];
    bool in[maxn];  // instack or not
    int st[maxn], tail = -1, ecnt = 1;
    Edge edges[maxm];
    void dfs(int u) {
        in[u] = 1;
        st[++tail] = u;
        dfn[u] = low[u] = ++id;
        for (int e = head[u]; e; e = edges[e].nxt) {
            int to = edges[e].to;
            if (dfn[to] && in[to]) low[u] = min(low[u], dfn[to]);  // 要记得在栈内
            if (!dfn[to]) {
                dfs(to);
                low[u] = min(low[u], low[to]);
            }
        }
        if (dfn[u] == low[u]) {
            from[u] = ++scc;
            sz[scc] = 1;
            while (tail >= 0 && st[tail] != u) {
                int cur = st[tail];
                from[cur] = from[u];
                sz[scc]++;
                tail--;
                in[cur] = 0;  // 记得这里,将在栈中的标记去掉
            }
            tail--;
            in[u] = 0;  // 记得这里,将在栈中的标记去掉
        }
    }
    void addEdge(int u, int v) {
        Edge e = {u, v, head[u]};
        head[u] = ecnt;
        edges[ecnt++] = e;
    }

    void solve() {
        for (int i = 1; i <= 2*n; i++) {
            if (!dfn[i]) dfs(i);
        }
        for (int i = 1; i <= 2*n; i += 2) {
            if (from[i] == from[i+1]) {
                cout << "NIE" << "\n";  // 无解
                return;
            }
        }
        // 否则输出答案
        for (int i = 1; i <= 2*n; i += 2) {
            if (from[i] < from[i+1]) {
                cout << i << "\n";
            } else {
                cout << i+1 << "\n";
            }
        }
        // cout << "\n";
    }
} sat;

int m;
int f(int u) {
    if (u % 2 == 0) return u - 1;
    return u + 1;
}
int main() {
    fastio;
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v; cin >> u >> v;
        sat.addEdge(u, f(v));
        sat.addEdge(v, f(u));
    }
    sat.solve();
}

例2 CF1250E. The Coronation

题意

给定 $n$ 个 $01$ 串(长度均为 $m$)。

给定一个正整数 $k$, 定义两个 $01$ 串为相似当且仅当它们同位置上相同的字符至少有 $k$ 个。

现在我们可以将一些 $01$ 串反转(从后往前)。

求最少翻转多少个,使得所有串之间两两相似?无解输出 $-1$。

其中,$n \in [2, 50], m \in [1, 50], k \in [1,m]$。

题解

这个题 不能用 2SAT!

我们考虑所有两两不相似的串(这个可以直接预处理出来)。

现在其实就相当于,每个串要么翻转,要么不翻转,使得最后得到的 $n$ 个串不能有冲突。

看起来很像 2-SAT,并且也可以这么做?

但有个问题,2-SAT并 没有办法处理最少翻转几个 的问题!


正解是用权值并查集或者二分图染色。

因为这个题是串之间的 冲突 问题,所以可以这么做,更复杂的情况才需要2-SAT。


考虑二分图染色。

我们将翻转前的所有串作为左边的点,翻转后的作为右边的。

两两冲突的点之间连一条边($i$ 和翻转后的版本 $i'$ 也要连边)。

虽然这样不算严格的二分图?但跑染色还是没问题的。

连完以后跑染色,可以得到若干个联通块。对于每个联通块,看染色是否成功,如果成功,黑白两个颜色中,翻转后的版本 $i'$ 哪个最少,选最少的那个即可。

如果染色失败,输出无解即可。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e4+5;
const int maxm = 1e5+505;
struct Edge {
    int from, to, nxt;
} edges[maxm];
int n, head[maxn], ecnt = 1;

void addEdge(int u, int v) {
    Edge e = {u, v, head[u]};
    head[u] = ecnt;
    edges[ecnt++] = e;
}

int m, k;
int f(int u) {
    if (u > n) return u - n;
    return u + n;
}

string s[maxn];
int similar(int u, int v) {
    int res = 0;
    for (int i = 0; i < m; i++) {
        if (s[u][i] == s[v][i]) res++;
    }
    return res;
}

vector<int> tmp0, tmp1;
int color[maxn], cnt0, cnt1;
bool ok = 1;
void dfs(int u) {
    if (color[u] == 1 && u > n) tmp1.push_back(u - n);
    if (color[u] == 0 && u > n) tmp0.push_back(u - n);
    for (int e = head[u]; e; e = edges[e].nxt) {
        int v = edges[e].to;
        if (color[v] == -1) {
            color[v] = color[u] ^ 1;
            dfs(v);
        } else if (color[v] == color[u]) {
            ok = 0;
            return;
        }
    }
}

int main() {
    fastio;
    int T; cin >> T;
    while (T--) {
        ok = 1;
        cin >> n >> m >> k;
        for (int i = 1; i <= n; i++) {
            cin >> s[i];
            s[i+n] = s[i];
            reverse(s[i+n].begin(), s[i+n].end());
        }
        for (int i = 1; i <= 2*n; i++) {
            for (int j = i+1; j <= 2*n; j++) {
                if (j == i+n) continue; 
                if (similar(i, j) < k) {  // 冲突
                    addEdge(i, j);
                    addEdge(j, i);
                }
            }
        }
        for (int i = 1; i <= n; i++) addEdge(i, i+n), addEdge(i+n, i);
        vector<int> ans;

        memset(color, -1, sizeof(color));
        for (int i = 1; i <= 2*n; i++) {
            if (color[i] == -1) {
                tmp1.clear(); tmp0.clear();
                color[i] = 0;
                dfs(i);

                if (tmp1.size() < tmp0.size()) {
                    for (int j : tmp1) ans.push_back(j);
                } else {
                    for (int j : tmp0) ans.push_back(j);
                }
            }
        }

        if (!ok) {
            cout << -1 << "\n";
        } else {
            cout << ans.size() << "\n";
            for (int j : ans) cout << j << " ";
            cout << "\n";
        }

        ecnt = 1;
        memset(head, 0, sizeof(head));
        ok = 1;
    }
}

例3 ABC210F. Coprime Solitaire

题意

有 $n$ 张卡片,每张卡片正面写了 $A_i$,背面写了 $B_i$。

求是否存在一种方案,使得所有卡片朝上的那一面的所有数字互质?

其中,$n \leq 3 \times 10^4, A_i,B_i \in [1,2\times 10^6]$。

题解

注意到每张卡片要么正面朝上,要么背面朝上,所以每张卡片都是一个boolean变量 $x$,而 正面朝上/背面朝上 则分别代表了 $x$ 和 $\neg x$。

但不能枚举 $O(n^2)$ 种卡片pairs,不过可以对于每一个数字进行质因数分解。

每个质数 $p$ 维护一个组,每一组里面有一些数,代表这个数有 $p$ 这个质因数。而这意味着,每一组数里面 最多只能有一个是朝上的。

每一组里面的数有可能是正面的数,也有可能是背面的数。如果是正面的数,就把 $x$ 这个变量放进去,否则把 $\neg x$ 这个变量放进去,这样每一组里面就维护了 $X_1,X_2, …, X_k$ 这些boolean变量。

而每一组的限制条件意味着,每一组里面如果取了 $X_i = 1$,那么其他的 $X_j = 0$。

所以需要 2SAT的优化建图

对于每一组都进行优化建图即可。由于每一个数最多有 $20$ 个不同的质因子,所以复杂度为 $O(20n)$。

代码
#include <bits/stdc++.h>
using namespace std;
int n, a[maxn], b[maxn];
int f(int u) {
    if (u > n) return u - n;
    return u + n;
}
struct Edge {
    int from, to, nxt;
};

struct SAT2 {
    // from[u] 代表 u 所在的SCC编号,scc代表scc编号,sz[scc] 代表对应scc的大小
    int dfn[maxn], low[maxn], id, from[maxn], scc = 0, sz[maxn], head[maxn];
    bool in[maxn];  // instack or not
    int st[maxn], tail = -1, ecnt = 1;
    Edge edges[maxm];
    void dfs(int u) {
        in[u] = 1;
        st[++tail] = u;
        dfn[u] = low[u] = ++id;
        for (int e = head[u]; e; e = edges[e].nxt) {
            int to = edges[e].to;
            if (dfn[to] && in[to]) low[u] = min(low[u], dfn[to]);  // 要记得在栈内
            if (!dfn[to]) {
                dfs(to);
                low[u] = min(low[u], low[to]);
            }
        }
        if (dfn[u] == low[u]) {
            from[u] = ++scc;
            sz[scc] = 1;
            while (tail >= 0 && st[tail] != u) {
                int cur = st[tail];
                from[cur] = from[u];
                sz[scc]++;
                tail--;
                in[cur] = 0;  // 记得这里,将在栈中的标记去掉
            }
            tail--;
            in[u] = 0;  // 记得这里,将在栈中的标记去掉
        }
    }
    void addEdge(int u, int v) {
        Edge e = {u, v, head[u]};
        head[u] = ecnt;
        edges[ecnt++] = e;
    }

    bool solve() {
        for (int i = 1; i <= 2*n; i++) {
            if (!dfn[i]) dfs(i);
        }
        for (int i = 1; i <= n; i++) {
            if (from[i] == from[i+n]) {
                return 0;
            }
        }
        return 1;
    }
} sat;

const int M = 2e6;
bool isPrime[M+5];
int small[M+5];
vector<int> primes;
int idx[M+5];
vector<int> group[maxn];
void preprocess() {   // 线性筛
    memset(isPrime, 1, sizeof(isPrime));
    small[1] = 1;
    for (int i = 2; i <= M; i++) {
        if (isPrime[i]) primes.push_back(i), small[i] = i;
        for (int j = 0; j < primes.size() && i * primes[j] <= M; j++) {
            int cur = i * primes[j];
            isPrime[cur] = 0;
            small[cur] = primes[j];   // 最小的质因子
            if (i % primes[j] == 0) break;
        }
    }
    for (int i = 0; i < primes.size(); i++) idx[primes[i]] = i;
}

int main() {
    preprocess();
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i] >> b[i];
    for (int i = 1; i <= n; i++) {
        int x = a[i];
        while (x > 1) {
            int sm = small[x];
            int smidx = idx[sm];
            while (x > 1 && x % sm == 0) x /= sm;
            group[smidx].push_back(i);
        }
        x = b[i];
        while (x > 1) {
            int sm = small[x];
            int smidx = idx[sm];
            while (x > 1 && x % sm == 0) x /= sm;
            group[smidx].push_back(i+n);
        }
    }

    int id = 2*n;
    for (int j = 0; j < primes.size(); j++) {
        int k = group[j].size();
        for (int i = id+1; i <= id+k; i++) {
            int u = group[j][i-id-1];
            if (i < id+k) sat.addEdge(i, f(group[j][i-id])), sat.addEdge(i, i+1);
            if (i > id+1) sat.addEdge(i+k, f(group[j][i-id-2])), sat.addEdge(i+k, i+k-1);
            sat.addEdge(u, i);
            sat.addEdge(u, i+k);
        }
        id += 2*k;
    }
    cout << (sat.solve() ? "Yes" : "No") << "\n";
}