【知识】图论 2-SAT

P4782 【模板】2-SAT

Code
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cstdio>

using namespace std;

const int N = 2000010, M = 2000010;

int n, m;
int h[N], e[M], ne[M], idx;
int dfn[N], low[N], ts, stk[N], top;
int id[N], cnt;
bool ins[N];

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

void tarjan(int u)
{
    dfn[u] = low[u] = ++ts;
    stk[++top] = u, ins[u] = true;
    for (int i = h[u]; ~i; i = ne[i])
    {
        int j = e[i];
        if (!dfn[j])
        {
            tarjan(j);
            low[u] = min(low[u], low[j]);
        }
        else if (ins[j])
            low[u] = min(low[u], dfn[j]);
    }

    if (low[u] == dfn[u])
    {
        int y;
        cnt++;
        do
        {
            y = stk[top--], ins[y] = false, id[y] = cnt;
        } while (y != u);
    }
}

int main()
{
    scanf("%d%d", &n, &m);
    memset(h, -1, sizeof h);

    while (m--)
    {
        int i, a, j, b;
        scanf("%d%d%d%d", &i, &a, &j, &b);
        i--, j--;
        add(2 * i + !a, 2 * j + b);
        add(2 * j + !b, 2 * i + a);
    }

    for (int i = 0; i < n * 2; i++)
        if (!dfn[i])
            tarjan(i);

    for (int i = 0; i < n; i++)
        if (id[i * 2] == id[i * 2 + 1])
        {
            puts("IMPOSSIBLE");
            return 0;
        }

    puts("POSSIBLE");
    for (int i = 0; i < n; i++)
        if (id[i * 2] < id[i * 2 + 1])
            printf("0 ");
        else
            printf("1 ");

    return 0;
}

SAT 问题: 给定 \(n\) 个命题,\(x_1,x_2,…,x_n\),每个命题有两种取值,可以取值为真或假(\(0\)\(1\)),我们假设 \(x_i\) 表示第 \(i\) 个命题为真,\(-x_i\) 表示第 \(i\) 个命题为假。对于这 \(n\) 个命题,会有若干个条件,如 \(x_1 \wedge x_2 \wedge -x_3\)\(\wedge=\) 或),表示 \(x_1\) 为真、\(x_2\) 为真、\(x_3\) 为假,这三个命题中至少有一个是成立的。我们需要给这 \(n\) 个命题一个取值,使得每一个条件都是成立的。

SAT 问题是一个 NPC 问题(NP 完全问题),如果给我们一个一般的 SAT 问题,我们是无法在多项式的复杂度内把这个问题求解出来的。但是对于 2-SAT 问题,我们是有一些比较效率的解法的,最好的算法可以做到线性的时间复杂度。

2-SAT 问题: 区别于 SAT 问题,2-SAT 问题的每个条件中一定只有两个变量,举例如 \(x_1 \wedge -x_2\),表示 \(x_1\) 为真、\(x_2\) 为假,这两个命题中一定有一个是成立的。相似的,2-SAT 问题中所有的条件都像这样只有两个变量。

2-SAT 问题的一般解法:

给定 \(n\) 个命题,\(x_1,x_2,…,x_n\),我们用 \(x_i\) 表示 \(x_i = 1\),用 \(-x_i\) 表示 \(x_i = 0\)

接下来我们想把这个问题放到图论里面,我们把每一个命题表示成图论里的一个点,然后把它们之间的推导关系看成边。

如何去推导,这里涉及到 离散数学 中的 数理逻辑,假设有两个变量 \(a\)\(b\)\(a\wedge b\) 表示如果 \(a\)\(1\),那么 \(b\) 一定是 \(1\)

对于 \(a\wedge b\) 进行列表。

  1. \(a=1,~b=1\) 得结果为 \(1\)\(a\)\(1\)\(b\) 一定要是 \(1\)
  2. \(a=1,~b=0\) 得结果为 \(0\)
  3. \(a=0,~b=1\) 得结果为 \(1\)\(a\) 不为 \(1\)\(b\) 为几都成立)
  4. \(a=1,~b=1\) 得结果为 \(1\)

可以发现,要么 \(a\)\(0\),要么 \(b\)\(1\),结果才能成立,因此得出 \(a \rightarrow b \Leftrightarrow a \wedge b\),反之得出 \(a \wedge b \Leftrightarrow -a \rightarrow b \Leftrightarrow -b \rightarrow a \Leftrightarrow b \wedge a\)

我们通过 \(a \wedge b\)\(b \wedge a\) 得出两个推导公式 \(-a \rightarrow b\)\(-b \rightarrow a\),我们就可以把这两个关系看作图论中的边,对于 \(a \wedge b\),我们从 \(-a\)\(b\) 连一条边,从 \(-b\)\(a\) 连一条边,这两条边刚好对应两个推导公式。

通过上述方式,我们能将整个问题转化成一张有向图,每个变量的两种取值对应两个节点,每个条件对应两条边。这样对于图中一条路径 \(a \rightarrow b \rightarrow c\),就表示如果 \(a\)\(1\),那么一路上 \(b\)\(c\) 都要取 \(1\)。因此图中任意一条路径都能对应原题的一段对照关系,而且这个对照关系是具有传递性的。

转化完之后,先考虑什么时候会无解,如果从 \(x_1\) 沿着边走,会走到 \(-x_1\),并且从 \(-x_1\) 沿着边走,会走到 \(x_1\),这意味着如果 \(x_1\)\(1\),那么我们会推导出来 \(x_1\)\(0\),反之如果 \(x_1\)\(0\),那么我们会推导出来 \(x_1\)\(1\)。所以能得出 \(x_1\) 不存在一个合理的取值,此时就一定无解。所以得出结论,如果 \(x_i\)\(-x_i\) 能相互到达,即在同一个强连通分量中时,就一定无解。这是无解的一个必要条件。

那么如果规避这个必要条件,如果任意一个变量的真值和假值不在同一个强连通分量中,那么是不是一定有解呢?

这里用构造法,如果任意一个变量的真值和假值在不同的强连通分量里的话,我们用一个特定的方式去构造一组合法的解。首先在求完强连通分量后进行缩点,这样原图就变成一个拓扑图,并求一下拓扑图的拓扑排序,我们可以按照任意顺序枚举所有命题,然后看一下 \(x_i\)\(-x_i\) 所在的强连通分量,我们看一下这两个强连通分量在拓扑排序中哪一个更靠前哪一个更靠后,我们每次都选择所在强连通分量更靠后的一个取值,就能得出一组合法的解。

而在我们求完强连通分量并缩点后,所有强连通分量的编号其实就是拓扑排序的逆序(具体请看强连通分量相关证明),所以对于每个变量,我们要想知道哪个取值在拓扑排序中更靠后,我们只需要看哪个取值所在的强连通分量编号更靠前,因为是倒序,所以编号越小越靠后。

为什么这样构造是正确的呢,首先确定每个变量都只有一种取值,然后对于一个强连通分量,如果选了这个连通分量中的一个点,那么由于推导关系,就需要把该连通分量中的其他点都选上,我们要看一下按照这个构造方法是否能满足这个要求。

对于任意一个命题都存在一个与它等价的逆否命题,即 \(a \rightarrow b \Leftrightarrow -b \rightarrow -a\)。由于这个性质,我们可以发现,如果存在一个强连通分量,其中 \(a\) 能走到 \(b\)\(b\) 能走到 \(a\)。那么一定存在一个与之对应的强连通分量,其中 \(-a\) 能走到 \(-b\)\(-b\) 能走到 \(-a\)。因此我们建的有向图中所有的强连通分量一定都是成对出现的。由于两个强连通分量的关系是等价的,等于是两个方案,我们只需要选其一即可,而这两个强连通分量在拓扑排序中一定会有一个先后顺序,靠后的那个强连通分量中所有变量 \(x_i\) 在拓扑序中的顺序也一定比对应的反变量 \(-x_i\) 靠后,这就能保证我们一定能选到同一个强连通分量中的变量。按照这个方式其实能构造出两组合法解,每次选靠后的取值能得出一组合法解,按照这个原理每次选靠前的取值也一定能得出一组合法解。

最后我们再回归原题,我们证明 \(a \wedge b\) 的合法性,即如果 \(a\)\(0\),则 \(b\) 一定取 \(1\)。如果我们取了 \(-a\),就意味着 \(-a\) 所在的强连通分量比 \(a\) 所在的强连通分量更靠后,根据我们的建图方式,\(-a\) 会连向 \(b\)\(-b\) 会连向 \(a\),所以 \(b\)\(-a\) 在同一个强连通分量中,\(-b\)\(a\) 在同一个强连通分量中,所以 \(b\) 所在的强连通分量也比 \(-b\) 所在的强连通分量更靠后,所以就会取 \(b\),即 \(b\)\(1\)

综上所述,我们证明能用强连通分量求 2-SAT 问题,并且得出构造合法解的方法。由于用 Tarjan 算法能在线性复杂度 \(O(NM)\) 内求强连通分量,所以一般 2-SAT 问题的时间复杂度也是线性的。

注意: 在一般的 2-SAT 问题中,通常不会给出 \(a \wedge b\) 这样直接的关系,这里给出常见的几种条件和转化方式

\[a \wedge b = \begin{cases} a \wedge b \newline a \rightarrow b \Leftrightarrow -a \wedge b \newline a = 1 \Leftrightarrow a \wedge a \newline a = 0 \Leftrightarrow -a \wedge -a \end{cases} \]

P3825 [NOI2017] 游戏

本题有 \(n\) 个地图,每次要求我们设定一辆车来跑,地图分四种 \(a, b, c, x\),车分三种 \(A, B, C\)\(a\) 不能放 \(A\)\(b\) 不能放 \(B\)\(c\) 不能放 \(C\)\(x\) 能放任何车,但是只有 \(d\)\(x\) 图。然后还会有很多要求,\((i, h_i, j, h_j)\),如果第 \(i\) 张图放了 \(h_i\),那么第 \(j\) 张图一定要放 \(h_j\)

可以发现,这若干个要求 \((i, h_i, j, h_i)\) 其实就是若干个推导关系。如果不看 \(x\) 图,光 \(a, b, c\) 三种图每张图都只能从两辆车中选一辆来开,可以看作它们都有两种取值,每次只能选一种,再加上若干个推导关系,这就是一个非常裸的 2-SAT 问题。

但是现在还有一个有三种取值的 \(x\) 图,因此我们还需要进行一些转化。可以发现 \(x\) 图并不多,最多只有 \(8\) 张,虽然 \(x\) 图有三种取值,但是最终它一定只能选固定的一种,因此我们可以暴力枚举,分别枚举每张 \(x\) 图的两种情况,一种是不选 \(A\),一种是不选 \(B\)。这样能保证每张 \(x\) 图的所有情况都被枚举到。选 \(A\) 在不选 \(B\) 的情况中,选 \(B\) 在不选 \(A\) 的情况中,选 \(C\) 在两种情况中都有。

通过以上操作,我们将 \(x\) 图进行了转化,如果是不选 \(A\) 的情况,等价于是 \(a\) 图,如果是不选 \(B\) 的情况,等价于是 \(b\) 图。这样就能将 \(x\) 图转化成只有两种取值,这样本题就变成了一个一般的 2-SAT 问题,\(x\) 图有 \(d\) 张,需要枚举 \(2^d\) 种情况,如果 \(2^d\) 种情况都无解,说明原问题无解。

时间复杂度为 \(\mathcal{O}(2^d \cdot (M+N))\)

Code
#include <iostream>
#include <cstring>

using namespace std;

const int N = 100010, M = 200010;

int n, d, m;
char s[N];
int h[N], e[M], ne[M], idx; //邻接表
int dfn[N], low[N], timestamp; //Tarjan 算法的数组
int stk[N], top; //栈
bool in_stk[N]; //记录每个点是否在栈中
int id[N], cnt; //记录每个点所在的强连通分量
int pos[10]; //记录所有 x 的位置

struct Op
{
    int x, y;
    char a, b;
}op[M]; //记录所有条件

void add(int a, int b) //添加边
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

int get(int x, char b, int t) //返回 x 选 b 时的编号,t 为 1 表示选 -b,t 为 0 表示选 b
{
    char a = s[x] - 'a';
    b -= 'A';
    /*
    图 a 能选 B 和 C,图 b 能选 C 和 A,图 c 能选 A 和 B,设选前一个表示取 0,选后一个表示取 1,
    若 A/a = 0, B/b = 1, C/c = 2,则 (a + 1) % 3 != b 表示取 0(不是 b 图才能选 b),(a - 1) % 3 != b 表示取 1,
    设最开始默认取 1,若 (a + 1) % 3 != b,则取 0,即为取反一次,t 为 1 又表示一次取反,
    有个规律,(a + 1) % 3 != b 和 t 都为 1,则取 1,(a + 1) % 3 != b 和 t 都为 0,也取 1,
    (a + 1) % 3 != b 和 t 不同,则只取反一次,取 0
    */
    if(((a + 1) % 3 != b) ^ t) return x + n; //取 1
    return x; //取 0
}

char put(int x, int t) //返回 x 取 t 时对应的赛车字母
{
    int y = s[x] - 'a';
    return 'A' + (y + 3 + (t ? -1 : 1)) % 3;
}

void tarjan(int u) //求强连通分量
{
    dfn[u] = low[u] = ++timestamp;
    stk[++top] = u, in_stk[u] = true;

    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];

        if(!dfn[j])
        {
            tarjan(j);
            low[u] = min(low[u], low[j]);
        }
        else if(in_stk[j]) low[u] = min(low[u], dfn[j]);
    }

    if(dfn[u] == low[u])
    {
        cnt++;
        int y;
        do
        {
            y = stk[top--];
            in_stk[y] = false;
            id[y] = cnt;
        } while(y != u);
    }
}

bool check() //判断当前情况是否有解,有解并输出方案
{
    //初始化
    memset(h, -1, sizeof h);
    memset(dfn, 0, sizeof dfn);
    idx = timestamp = cnt = 0;

    //根据所有条件建立推导公式
    //设 i 取 1 编号为 i + n, i 取 0 编号为 i
    for(int i = 0; i < m; i++)
    {
        int x = op[i].x - 1, y = op[i].y - 1; //规定下标从 0 开始
        char a = op[i].a, b = op[i].b;

        if(s[x] != a - 'A' + 'a') //只有第 x 张图能取 a 时,才需要限制条件
        {
            //第 y 张图能取 b 时,即 x 选 a 时 y 必须选 b,得出推导公式 a -> b, -b -> a
            if(s[y] != b - 'A' + 'a') add(get(x, a, 0), get(y, b, 0)), add(get(y, b, 1), get(x, a, 1));
            //第 y 张图不能取 b 时,即 x 选 a 时 y 无法选 b,则 x 不能选 a,得出推导公式 a -> -a
            else add(get(x, a, 0), get(x, a, 1));
        }
    }

    //求强连通分量
    for(int i = 0; i < n * 2; i++)
        if(!dfn[i])
            tarjan(i);

    //如果某一张图的两个取值在同一个强连通分量,说明无解
    for(int i = 0; i < n; i++)
        if(id[i] == id[i + n])
            return false;

    //输出方案
    for(int i = 0; i < n; i++)
        if(id[i] < id[i + n]) printf("%c", put(i, 0)); //取 0
        else printf("%c", put(i, 1)); //取 1

    return true;
}

int main()
{
    scanf("%d%d%s", &n, &d, s);

    //找出所有的 x 并记录位置
    for(int i = 0, j = 0; i < n; i++)
        if(s[i] == 'x')
            pos[j++] = i;

    scanf("%d", &m);
    for(int i = 0; i < m; i++) scanf("%d %c %d %c", &op[i].x, &op[i].a, &op[i].y, &op[i].b);

    //用一个长度为 d 的二进制数枚举 x 的所有情况,共 2^d 种
    for(int k = 0; k < 1 << d; k++)
    {
        for(int i = 0; i < d; i++) //枚举所有 x 的情况
            if(k >> i & 1) s[pos[i]] = 'a'; //如果第 i 位取 1,则第 i 个 x 变成 a 图
            else s[pos[i]] = 'b'; //如果第 i 位取 0,则第 i 个 x 变成 b 图

        if(check()) return 0; //如果有解直接结束
    }

    //到这说明无解
    puts("-1");
    return 0;
}
posted @ 2024-12-01 17:21  Star_F  阅读(6)  评论(0编辑  收藏  举报