NeFut Logo NeFut
EN 管理员登录

深入解析2-SAT问题:基于拓扑排序与强连通分量的高效解法

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#C++ #Tutorial

核心逻辑与数学原理

拓扑排序在解决2-SAT(2-满足性问题)的构造解问题中,扮演着核心的代数构建角色。

2-SAT问题的目标是:给定 $N$ 个布尔变量 $x_1, x_2, \dots, x_n$,以及 $M$ 个限定条件,每个条件都形如 “$x_i$ 为真或 $x_j$ 为真”($x_i \lor x_j$)。我们需要为每个变量指派真值(0 或 1),使得所有条件同时得到满足。

1. 命题逻辑向有向图(蕴含图)的代数同构

根据逻辑代数的等价变换,一个析取条件 $A \lor B$ 在数学上等价于两条蕴含式(Implication)

$$\neg A \implies B \quad \text{且} \quad \neg B \implies A$$

2. 强连通分量(SCC)的合法性判据

利用Tarjan算法或Kosaraju算法对蕴含图进行强连通分量缩点:

3. 逆拓扑序驱动的贪心自洽指派

当判定有解后,如何构造出一组具体的合法解?


算法推导与状态设计

1. 蕴含图状态设计

对于变量 $i \in [1, n]$:

常见约束转换代数式:

  1. $x_i \lor x_j$ (至少有一个为真):$\neg x_i \implies x_j$ 且 $\neg x_j \implies x_i$。连边:add_edge(i + n, j)add_edge(j + n, i)
  2. $x_i \implies x_j$ (若 $i$ 为真则 $j$ 必为真):连边:add_edge(i, j)add_edge(j + n, i + n)
  3. $x_i$ 必须为真:相当于 $x_i \lor x_i$。连边:add_edge(i + n, i)

C++ 标准源码(2-SAT 构造解高精模板)

#include <iostream>
#include <vector>
#include <algorithm>
#include <stack>

const int MAXN = 200005; // 2 * N 空间基座

std::vector<int> adj[MAXN * 2];
int dfn[MAXN * 2], low[MAXN * 2], scc[MAXN * 2];
bool in_stack[MAXN * 2];
std::stack<int> stk;
int timer = 0, scc_cnt = 0;

void tarjan(int u) {
    dfn[u] = low[u] = ++timer;
    stk.push(u);
    in_stack[u] = true;

    for (size_t i = 0; i < adj[u].size(); ++i) {
        int v = adj[u][i];
        if (!dfn[v]) {
            tarjan(v);
            low[u] = std::min(low[u], low[v]);
        } else if (in_stack[v]) {
            low[u] = std::min(low[u], dfn[v]);
        }
    }

    if (low[u] == dfn[u]) {
        scc_cnt++;
        while (true) {
            int v = stk.top();
            stk.pop();
            in_stack[v] = false;
            scc[v] = scc_cnt; // Tarjan 标记出来的 scc 编号大小天然构成逆拓扑序
            if (u == v) break;
        }
    }
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m;
    if (!(std::cin >> n >> m)) return 0;

    for (int k = 0; k < m; ++k) {
        int i, a, j, b;
        // 读入格式:i 号变量取值为 a(0/1) 或 j 号变量取值为 b(0/1)
        std::cin >> i >> a >> j >> b;

        // 状态平移映射:a == 0 代表假(i+n),a == 1 代表真(i)
        int u_true = i, u_false = i + n;
        int v_true = j, v_false = j + n;

        // 根据具体输入的 0/1 判定其代表的是正状态还是反状态
        int pos_u = (a == 1) ? u_true : u_false;
        int neg_u = (a == 1) ? u_false : u_true;
        int pos_v = (b == 1) ? v_true : v_false;
        int neg_v = (b == 1) ? v_false : v_true;

        // 核心条件转化:pos_u V pos_v  <=>  (neg_u -> pos_v) AND (neg_v -> pos_u)
        adj[neg_u].push_back(pos_v);
        adj[neg_v].push_back(pos_u);
    }

    // 1. 跑 Tarjan 划分强连通拓扑空间
    for (int i = 1; i <= 2 * n; ++i) {
        if (!dfn[i]) {
            tarjan(i);
        }
    }

    // 2. 完备性合规校验
    for (int i = 1; i <= n; ++i) {
        if (scc[i] == scc[i + n]) {
            std::cout << "IMPOSSIBLE\n"; // 正反状态同属一个 SCC,逻辑崩溃
            return 0;
        }
    }

    std::cout << "POSSIBLE\n";
    // 3. 利用强连通分量编号的逆拓扑性质构造贪心自洽解
    for (int i = 1; i <= n; ++i) {
        // scc 编号越小,拓扑序越靠后。
        // 若 scc[i] < scc[i+n],说明正状态 i 靠后,故选择正状态(指派为 1)
        if (scc[i] < scc[i + n]) {
            std::cout << 1 << (i == n ? "" : " ");
        } else {
            std::cout << 0 << (i == n ? "" : " ");
        }
    }
    std::cout << "\n";

    return 0;
}

NOIP 实战避坑指南

  1. 强连通编号与拓扑序方向记反: 这是2-SAT输出方案时最高频的爆零点。Tarjan算法生成的 scc 缩点编号,在本质上是“逆拓扑序”(即最先被切断弹出的叶子子树其 scc 编号为1)。因此,scc[i] < scc[i+n] 意味着节点 $i$ 在有向图拓扑网络中处于更加靠后的位置。为了避免逻辑污染,我们要选靠后的点,所以此时应该选 $i$,即方案输出 1。如果选手误记为“编号小的拓扑靠前”,将输出逻辑反置,就会导致满盘皆输。
  2. 数组空间未开 2 倍(或 4 倍): 2-SAT伴随着变量拆点,点的总数直接翻倍变为 $2N$。而由于每个析取约束会对应两条蕴含边,边的总数也会翻倍变为 $2M$。选手的 adj 邻接表基座、Tarjan相关的 dfnlowscc 数组必须雷打不动地开辟2倍(点数)和4倍(边数)空间,否则在建边和递归松弛时会引发严重的越界。

经典 NOIP/洛谷 真题

1. 洛谷 P4782 【模板】2-SAT 问题

2. 洛谷 P3201 [HNOI2009] 梦幻布丁

原文链接: local://12.4

[h] 返回首页