文章目录

2-SAT 算法

2-SAT 算法

目录


预备知识 A:数理逻辑基础

2-SAT 的本质是一类特殊的布尔可满足性问题,理解它需要先掌握命题逻辑的基本概念。

A.1 布尔变量与布尔代数

布尔变量(Boolean Variable):取值只能为 真(true,记为 1 或 T)假(false,记为 0 或 F) 的变量。本文中常用 \(x, y, z\)\(x_1, x_2, \dots, x_n\) 表示布尔变量。

布尔代数(Boolean Algebra):研究布尔变量之间通过逻辑运算符组合、运算规律的代数系统。由乔治·布尔(George Boole)于 1847 年创立。

布尔代数的基本运算有三种:与(AND)或(OR)非(NOT),其余运算均可由这三种组合表示。


A.2 基本逻辑运算

A.2.2 与(Conjunction, AND)

符号\(a \wedge b\)(读作"a 且 b"),也可写作 \(a \land b\)\(a \cdot b\)a && b(程序中)。

定义:当且仅当 \(a\)\(b\) 同时为真时,结果为真;否则为假。

真值表

\(a\) \(b\) \(a \wedge b\)
0 0 0
0 1 0
1 0 0
1 1 1

性质

  • 交换律:\(a \wedge b = b \wedge a\)
  • 结合律:\((a \wedge b) \wedge c = a \wedge (b \wedge c)\)
  • 幂等律:\(a \wedge a = a\)
  • 同一律:\(a \wedge 1 = a\)
  • 零律:\(a \wedge 0 = 0\)

A.2.3 或(Disjunction, OR)

符号\(a \vee b\)(读作"a 或 b"),也可写作 \(a \lor b\)\(a + b\)a || b(程序中)。

定义:当且仅当 \(a\)\(b\) 至少一个为真时,结果为真;只有当两者都为假时,结果才为假。

注意:这是"可兼或"(Inclusive OR),即 a 和 b 可以同时为真。另有"不可兼或"(异或 XOR),见后文。

真值表

\(a\) \(b\) \(a \vee b\)
0 0 0
0 1 1
1 0 1
1 1 1

性质

  • 交换律:\(a \vee b = b \vee a\)
  • 结合律:\((a \vee b) \vee c = a \vee (b \vee c)\)
  • 幂等律:\(a \vee a = a\)
  • 同一律:\(a \vee 0 = a\)
  • 零律:\(a \vee 1 = 1\)

A.2.4 分配律与吸收律

合取与析取之间满足:

  • 分配律
    • \(a \wedge (b \vee c) = (a \wedge b) \vee (a \wedge c)\)
    • \(a \vee (b \wedge c) = (a \vee b) \wedge (a \vee c)\)
  • 吸收律
    • \(a \wedge (a \vee b) = a\)
    • \(a \vee (a \wedge b) = a\)

A.3 德摩根定律

德摩根定律(De Morgan's Laws)是命题逻辑中最基本、最常用的等价变换,由奥古斯都·德·摩根(Augustus De Morgan)发现:

\[\neg(a \wedge b) \;=\; \neg a \vee \neg b \]

\[\neg(a \vee b) \;=\; \neg a \wedge \neg b \]

口诀:"与"和"或"互换,否定号分配到两边。

在 2-SAT 中的应用:德摩根定律是约束转换的核心工具。例如"不能同时选 a 和 b"(即 \(\neg(a \wedge b)\))等价于"不选 a 或不选 b"(即 \(\neg a \vee \neg b\)),这正是 2-SAT 可以表达的形式。


A.4 蕴含与等价

A.4.1 蕴含(Implication)

符号\(a \rightarrow b\)(读作"a 蕴含 b"或"如果 a,那么 b")。

定义

\[a \rightarrow b \quad \Longleftrightarrow \quad \neg a \vee b \]

真值表

\(a\) \(b\) \(a \rightarrow b\)
0 0 1
0 1 1
1 0 0
1 1 1

理解要点

  • 蕴含只有在"前真后假"时才为假
  • 前为假时,蕴含式自动为真("假前提可以推出任何结论")
  • \(a \rightarrow b\)逆否命题\(\neg b \rightarrow \neg a\),二者逻辑等价

逆否命题等价性\(a \rightarrow b \;\Longleftrightarrow\; \neg b \rightarrow \neg a\)
这是 2-SAT 建图中"每条约束加两条对称边"的理论依据。

A.4.2 等价(Equivalence, Biconditional)

符号\(a \leftrightarrow b\)(读作"a 当且仅当 b"),也可写作 \(a \equiv b\)

定义

\[a \leftrightarrow b \quad \Longleftrightarrow \quad (a \rightarrow b) \wedge (b \rightarrow a) \]

即 a 和 b 同真同假。

真值表

\(a\) \(b\) \(a \leftrightarrow b\)
0 0 1
0 1 0
1 0 0
1 1 1

A.4.3 异或(Exclusive OR, XOR)

符号\(a \oplus b\)(读作"a 异或 b"),也可写作 \(a \dot{\vee} b\)a ^ b(程序中)。

定义:当且仅当 a 和 b 取值不同时为真。

\[a \oplus b \quad \Longleftrightarrow \quad (a \vee b) \wedge (\neg a \vee \neg b) \quad \Longleftrightarrow \quad \neg(a \leftrightarrow b) \]

真值表

\(a\) \(b\) \(a \oplus b\)
0 0 0
0 1 1
1 0 1
1 1 0

A.5 命题逻辑中的重要概念

A.5.1 命题与文字

  • 命题(Proposition):一个非真即假的陈述句。如"今天下雨"、"\(x_1 = 1\)"。
  • 原子命题(Atomic Proposition):不能再分解的命题,对应布尔变量。
  • 文字(Literal):原子命题或它的否定。即每个布尔变量可以贡献两个文字:变量本身和它的否定。
    • 正文字\(x_i\)(变量本身,取 true)
    • 负文字\(\neg x_i\)(变量的否定,取 false)

2-SAT 中的"2"指的是每个子句有恰好 2 个文字,不是 2 个变量。(变量可以出现为正文字或负文字)

A.5.2 子句与范式

  • 子句(Clause):若干个文字的或(OR)。例如:\(x_1 \vee \neg x_3 \vee x_5\) 是一个 3 文字子句。

    • 子句的长度:子句中文字的个数。
  • 合取范式(Conjunctive Normal Form, CNF):若干个子句的与(AND)

    • 形式:\(C_1 \wedge C_2 \wedge \dots \wedge C_m\),其中每个 \(C_i\) 是子句。
    • 例:\((x_1 \vee x_2) \wedge (\neg x_1 \vee x_3) \wedge (x_2 \vee \neg x_3)\)
  • k-CNF:每个子句长度不超过 k 的合取范式。

    • 2-CNF:每个子句恰好或最多有 2 个文字。这是 2-SAT 问题的输入形式。

A.5.3 真值指派与可满足性

  • 真值指派(Truth Assignment):给每个布尔变量赋一个具体的布尔值(0 或 1)。也叫赋值解释
    • 记为 \(\sigma: \{x_1, \dots, x_n\} \to \{0, 1\}\)
  • 满足(Satisfy):给定一个公式 \(\phi\) 和一个指派 \(\sigma\),如果在 \(\sigma\)\(\phi\) 的值为真,则称 \(\sigma\) 满足 \(\phi\),记作 \(\sigma \models \phi\)
  • 可满足的(Satisfiable):如果存在至少一个指派满足公式 \(\phi\),则称 \(\phi\) 是可满足的。
  • 不可满足的(Unsatisfiable)/ 矛盾式:不存在任何指派满足公式。
  • 重言式(Tautology):在任何指派下都为真的公式。如 \(x \vee \neg x\)

A.6 SAT 问题的形式化定义

布尔可满足性问题(Boolean Satisfiability Problem, SAT)

给定一个布尔公式 \(\phi\)(通常表示为 CNF),判断是否存在一个真值指派使得 \(\phi\) 被满足。

k-SAT 问题

给定一个 k-CNF 公式(每个子句至多 k 个文字),判断其是否可满足。

k-SAT 的计算复杂性

  • 2-SAT:存在多项式时间算法(线性时间),即本文主题。
  • 3-SAT:NP 完全问题(详见预备知识 B)。
  • \(k \ge 3\) 时,k-SAT 都是 NP 完全的。

为什么是 2?因为 2 是 SAT 问题"可解"与"不可解(NP 完全)"的分界线。从 2 到 3,问题难度发生了质的飞跃——这正是计算复杂性理论的魅力所在。


预备知识 B:计算复杂度理论

B.3 P 类与 NP 类

B.3.1 P 类

P 类(Polynomial Time):所有可以在多项式时间内求解的判定问题构成的集合。

\[P = \{ L : \text{存在多项式时间算法判定} \; L \} \]

P 类问题举例

  • 排序的判定版本("这个数组是否已经有序?")
  • 最短路问题("从 s 到 t 的距离是否 ≤ k?")
  • 最小生成树判定
  • 2-SAT 问题(本文主题)

B.3.2 NP 类

NP 类(Nondeterministic Polynomial Time):所有可以在多项式时间内验证一个"解"是否正确的判定问题构成的集合。

换句话说,如果你猜出了一个候选解,你能在多项式时间内检验它对不对。

\[NP = \{ L : \text{存在多项式时间验证算法} \} \]

NP 类问题举例

  • SAT 问题:给你一个指派,验证是否满足所有子句,只需要 O(m) 时间
  • 哈密顿回路:给你一条回路,验证它是否经过每个顶点恰好一次,只需要 O(n) 时间
  • 子集和问题:给你一个子集,验证和是否等于目标,只需要 O(n) 时间

P vs NP 问题:P 是否等于 NP?这是计算机科学中最重要的未解问题之一,也是千禧年七大数学难题之一。目前普遍猜想 P ≠ NP,但无人能证明。

显然的关系\(P \subseteq NP\)
(能多项式时间求解的问题,必然能多项式时间验证——直接算一遍就是了)


B.4 多项式时间归约

归约(Reduction):把一个问题转化为另一个问题的过程。如果问题 A 可以归约到问题 B,那么 B 的算法可以用来解决 A。

多项式时间归约(Polynomial-time Reduction):如果归约过程本身可以在多项式时间内完成,则称为多项式时间归约,记作 \(A \le_p B\)

直观含义

  • \(A \le_p B\) 表示"A 不比 B 难"
  • 如果 B 有多项式时间解,那么 A 也有
  • 如果 A 没有多项式时间解,那么 B 也没有

B.5 NP 完全与 NP 难

B.5.1 NP 完全(NP-Complete, NPC)

定义:判定问题 X 是 NP 完全的,当且仅当:

  1. \(X \in NP\)(X 属于 NP 类)
  2. 对所有 \(Y \in NP\),都有 \(Y \le_p X\)(所有 NP 问题都可以多项式时间归约到 X)

通俗理解:NP 完全问题是 NP 类中"最难的"那些问题。只要其中一个找到了多项式时间算法,所有 NP 问题都能在多项式时间内解决(即 P = NP)。

第一个 NP 完全问题SAT 问题(Cook-Levin 定理,1971 年)。

B.5.2 NP 难(NP-Hard)

定义:问题 X 是 NP 难的,当且仅当对所有 \(Y \in NP\),都有 \(Y \le_p X\)

与 NP 完全的区别:NP 难问题不一定属于 NP 类(甚至不一定是判定问题)。NP 完全 = NP ∩ NP难。

关系图

        ┌──────────────────────────────┐
        │           NP难               │
        │  ┌────────────────────────┐  │
        │  │          NP            │  │
        │  │  ┌──────┐  ┌────────┐ │  │
        │  │  │  P   │  │NP完全  │ │  │
        │  │  └──────┘  └────────┘ │  │
        │  └────────────────────────┘  │
        └──────────────────────────────┘

B.5.3 常见的 NP 完全问题

问题 描述
SAT 布尔公式可满足性
3-SAT 每个子句至多 3 个文字的 SAT
团问题(Clique) 图中是否存在大小为 k 的团
顶点覆盖(Vertex Cover) 图中是否存在大小为 k 的顶点覆盖
哈密顿回路(Hamiltonian Cycle) 图中是否存在经过每个顶点恰好一次的回路
子集和(Subset Sum) 是否存在子集和等于目标值
旅行商判定版(TSP) 是否存在总长度不超过 k 的回路

2-SAT vs 3-SAT:从 2 到 3,看似只差了 1,但 2-SAT 在 P 中,3-SAT 是 NP 完全的。这就是为什么我们专门研究 2-SAT——它是 SAT 问题中可高效求解的最大子类。


B.6 为什么 k ≥ 3 时 k-SAT 是 NP 完全的

证明思路

  1. 3-SAT ∈ NP:给一个指派,O(m) 验证所有子句。

  2. 把 SAT 归约到 3-SAT:对于任意一个 CNF 公式中的任意长度 ≥ 3 的子句 \(l_1 \vee l_2 \vee \dots \vee l_k\),引入新变量 \(y_1, y_2, \dots, y_{k-3}\),拆成:

    \[(l_1 \vee l_2 \vee y_1) \wedge (\neg y_1 \vee l_3 \vee y_2) \wedge \dots \wedge (\neg y_{k-3} \vee l_{k-1} \vee l_k) \]

    可以证明原公式可满足当且仅当新公式可满足。

  3. 因此所有 SAT(任意长度子句)都可以多项式时间归约到 3-SAT。

  4. 因为 SAT 是 NP 完全的,所以 3-SAT 也是 NP 完全的。

  5. 对于 k > 3,3-SAT 显然可以归约到 k-SAT(每个 3 文字子句添加 k-3 个哑文字即可),因此 k-SAT 也是 NP 完全的。


预备知识 C:图论基础

2-SAT 的求解依赖于图论中的强连通分量算法。本节复习相关图论概念。

C.1 有向图的基本概念

有向图(Directed Graph, Digraph):由顶点集 V 和有向边集 E 组成的二元组 G = (V, E),其中每条边是顶点的有序对 (u, v),表示从 u 指向 v。

  • 起点(Source / Tail):边 (u, v) 中的 u
  • 终点(Destination / Head):边 (u, v) 中的 v
  • 出度(Out-degree):以 u 为起点的边数,记作 \(deg^+(u)\)
  • 入度(In-degree):以 u 为终点的边数,记作 \(deg^-(u)\)

路径(Path):顶点序列 \(v_0, v_1, \dots, v_k\),满足对每个 \(0 \le i < k\)\((v_i, v_{i+1})\) 是一条边。路径长度为 k(边数)。

简单路径(Simple Path):顶点互不重复的路径。

回路(Cycle / Circuit):起点和终点相同,且中间顶点互不相同的路径(长度 ≥ 1)。

可达(Reachable):如果存在从 u 到 v 的路径,则称 v 是从 u 可达的,记作 \(u \leadsto v\)


C.2 强连通分量

C.2.1 强连通

强连通(Strongly Connected):在有向图中,如果两个顶点 u 和 v 互相可达(\(u \leadsto v\)\(v \leadsto u\)),则称 u 和 v 是强连通的。

强连通图(Strongly Connected Graph):图中任意两个顶点都强连通。

C.2.2 强连通分量

强连通分量(Strongly Connected Component, SCC):有向图的一个极大强连通子图

"极大"的含义:不能再加入更多顶点而仍然保持强连通。

性质

  1. 每个顶点恰好属于一个 SCC(SCC 构成顶点集的一个划分)
  2. 同一 SCC 内的任意两顶点互相可达
  3. 不同 SCC 之间的边只能单向(否则它们会合并为一个 SCC)

C.2.3 缩点(Condensation)

把每个 SCC 缩成一个"超级节点",原图就变成了一个有向无环图(DAG),称为缩点图DAG 图

\[G' = (V', E') \]

\[V' = \{ C_1, C_2, \dots, C_k \} \quad \text{(所有 SCC 的集合)} \]

\[E' = \{ (C_i, C_j) : \exists u \in C_i, v \in C_j, (u,v) \in E, C_i \neq C_j \} \]

缩点后得到 DAG,这是一个极其重要的性质。许多在一般有向图上很难的问题,在 DAG 上可以用动态规划等方法高效求解。


C.3 有向无环图(DAG)

有向无环图(Directed Acyclic Graph, DAG):不含回路的有向图。

C.3.1 拓扑排序

拓扑序(Topological Order):DAG 顶点的一个线性排列 \(v_1, v_2, \dots, v_n\),满足对于每条边 \((v_i, v_j)\),都有 i < j(所有边从前往后指)。

拓扑排序(Topological Sort):求一个 DAG 的拓扑序的过程。

性质

  • DAG 一定存在拓扑序(反之,如果一个有向图有拓扑序,则它一定是 DAG)
  • 拓扑序不一定唯一

求拓扑序的算法

  1. Kahn 算法(BFS 法):不断找入度为 0 的点加入拓扑序,O(V+E)
  2. DFS 法:DFS 回溯时记录节点,逆序即拓扑序,O(V+E)

Tarjan 与拓扑序的关系:Tarjan 算法给 SCC 编号时,先完成(编号小)的 SCC 在缩点 DAG 中更接近"汇点"(出度为 0),后完成(编号大)的更接近"源点"(入度为 0)。因此 Tarjan 的 SCC 编号是反拓扑序


预备知识 D:算法与数据结构

2-SAT 的核心算法是 Tarjan 强连通分量,而 Tarjan 基于 DFS。本节复习相关概念。

D.1 强连通分量算法概览

求有向图强连通分量的经典算法有三种,时间复杂度均为 O(V+E):

算法 发明者 核心思想 特点
Tarjan Robert Tarjan (1972) 一次 DFS,用 dfn/low + 栈 代码紧凑,一次遍历,竞赛最常用
Kosaraju S. Rao Kosaraju (1978) 两次 DFS:正图 + 逆图 思路直观,好理解,不易写错
Gabow Harold Gabow (1985) 一次 DFS,两个栈 常数小,但不如 Tarjan 普及

D.1.1 Kosaraju 算法简介(了解)

算法步骤

  1. 对原图 G 进行 DFS,按完成时间从晚到早记录节点顺序(后序遍历的逆序)
  2. 按上述顺序,对逆图(所有边反向的图)\(G^T\) 进行 DFS
  3. 每次 DFS 访问到的节点构成一个 SCC

直观理解:第一次 DFS 确定了拓扑序(逆后序),第二次按拓扑序从后往前在逆图上搜,每次搜到的就是一个 SCC。


一、问题背景与定义

1.1 SAT 问题

SAT(Satisfiability,布尔可满足性)问题是计算机科学和数理逻辑中的经典判定问题:给定一个布尔公式(通常表示为合取范式 CNF,即若干子句的 AND),判断是否存在一个真值指派(给每个变量赋 0 或 1),使得整个公式的值为真。

SAT 问题的一般形式为 k-SAT:输入是一个 k-CNF 公式(每个子句至多 k 个文字),问是否可满足。

根据计算复杂性理论(详见预备知识 B):

  • \(k \ge 3\) 时,k-SAT 是 NP 完全问题——目前没有已知的多项式时间算法,且普遍相信不存在。
  • \(k = 2\) 时,2-SAT 可以在线性时间 O(n + m) 内求解。

这就是为什么 2-SAT 在信息学竞赛中占有独特地位:它是 SAT 问题族中可高效求解的最大子类。

1.2 2-SAT 问题

2-SAT 是 SAT 问题的特殊情况,每个子句恰好(或至多)包含 2 个文字

形式化定义

给定 n 个布尔变量 \(x_1, x_2, \dots, x_n\),以及 m 个子句,每个子句形如 \(l_1 \vee l_2\),其中每个 \(l\) 是一个文字(即某个变量 \(x_i\) 或它的否定 \(\neg x_i\))。

求一个真值指派 \(\sigma: \{x_1, \dots, x_n\} \to \{0, 1\}\),使得所有 m 个子句同时被满足。

也可以把每个子句理解为一个约束条件:

\[\text{"}x_i \text{ 为真/假 } \textbf{ 或 } x_j \text{ 为真/假"} \]

注意:2-SAT 中的"2"指的是每个子句有 2 个文字,而不是只有 2 个变量。变量数可以是任意的 n。

问题的两种形式

竞赛中 2-SAT 通常有两种问法:

  1. 判定问题:是否存在满足所有约束的方案?(输出 YES/NO)
  2. 构造问题:如果存在,输出任意一组可行解。

有时还会有字典序最小解、必须包含某个变量等变形。


二、核心思想:蕴含图建模

2-SAT 的核心技巧是将逻辑约束转化为图论中的有向边,从而把一个逻辑问题变成图的可达性问题。这张图称为蕴含图(Implication Graph)

2.1 蕴含关系

回忆蕴含(详见预备知识 A.4)的定义:

\[a \rightarrow b \quad \Longleftrightarrow \quad \neg a \vee b \]

读作"如果 a 成立,那么 b 一定成立"。蕴含有一个重要性质:原命题与逆否命题等价,即 \(a \rightarrow b \iff \neg b \rightarrow \neg a\)

对于 2-SAT 的每个子句 \(l_1 \vee l_2\),利用蕴含的定义,可以改写为两个蕴含式:

\[l_1 \vee l_2 \quad \Longleftrightarrow \quad \neg l_1 \rightarrow l_2 \quad \text{且} \quad \neg l_2 \rightarrow l_1 \]

理解:"l₁ 或 l₂ 至少一个为真"等价于:

  • 如果 l₁ 为假,那么 l₂ 必须为真
  • 如果 l₂ 为假,那么 l₁ 必须为真

每条蕴含都是一条推理规则,而推理规则天然对应有向图中的边

2.2 建图方法

我们为每个布尔变量 \(x_i\) 建立两个节点,分别代表这个变量的两种取值状态:

  • 节点 \(i\):表示文字 \(x_i\)(即 \(x_i = \text{true}\)
  • 节点 \(i+n\):表示文字 \(\neg x_i\)(即 \(x_i = \text{false}\)

常见编号约定

  • 约定一\(i\) 表真,\(i+n\) 表假(本文采用,最直观)
  • 约定二\(2i\) 表真,\(2i+1\) 表假(位运算取反:u ^ 1)

两种完全等价,任选一种并保持一致即可。约定二有一个小技巧:用 u ^ 1 可以快速得到 u 的对立面(u 是偶数则加 1,是奇数则减 1)。

对于每个子句 \(l_1 \vee l_2\),我们在蕴含图中添加两条有向边

  • \(\neg l_1 \rightarrow l_2\)(如果 l₁ 不成立,则 l₂ 必须成立)
  • \(\neg l_2 \rightarrow l_1\)(如果 l₂ 不成立,则 l₁ 必须成立)

以下是所有常见子句的建图对照表(使用约定一):

子句形式 等价蕴含式 添加的边
\(x_i \vee x_j\) \(\neg x_i \rightarrow x_j\)\(\neg x_j \rightarrow x_i\) \(i+n \to j\)\(j+n \to i\)
\(x_i \vee \neg x_j\) \(\neg x_i \rightarrow \neg x_j\)\(x_j \rightarrow x_i\) \(i+n \to j+n\)\(j \to i\)
\(\neg x_i \vee \neg x_j\) \(x_i \rightarrow \neg x_j\)\(x_j \rightarrow \neg x_i\) \(i \to j+n\)\(j \to i+n\)
\(x_i\) 必须为真 \(\neg x_i \rightarrow x_i\) \(i+n \to i\)
\(x_i\) 必须为假 \(x_i \rightarrow \neg x_i\) \(i \to i+n\)

直观理解:边 \(u \to v\) 的含义是"如果选择了 u 对应的文字为真,就必须选择 v 对应的文字也为真"。

2.3 蕴含图的意义

这张有向图称为蕴含图(Implication Graph),它的每条边都是一条逻辑推理规则。沿着有向边的路径可以进行链式推理

如果存在路径 \(u \to v_1 \to v_2 \to \dots \to v_k \to v\),则说明 \(u\) 蕴含 \(v\)(如果 u 成立,则 v 必成立)。

关键点:如果从某文字出发,沿着边能到达它的否定,那么这个文字不可能为真(否则会推出矛盾)。这正是后续用 SCC 判定有解性的思想来源。


三、算法原理:强连通分量

3.1 核心定理

建图之后,如何判断 2-SAT 是否有解?

2-SAT 基本定理

2-CNF 公式可满足,当且仅当在蕴含图中,对于任意变量 \(x_i\)\(x_i\)\(\neg x_i\) 不在同一个强连通分量(SCC)中。

即:对所有 \(i \in [1, n]\)\(scc(i) \neq scc(i+n)\)

3.2 正确性证明

必要性("仅当"方向)

假设公式可满足,即存在真值指派使所有子句为真。

反证:如果存在某个 \(x_i\),使得 \(x_i\)\(\neg x_i\) 在同一个 SCC 中,则:

  • 存在路径从 \(x_i\)\(\neg x_i\):若 \(x_i\) 为真,则 \(\neg x_i\) 必为真 → 矛盾
  • 存在路径从 \(\neg x_i\)\(x_i\):若 \(\neg x_i\) 为真,则 \(x_i\) 必为真 → 矛盾

无论 \(x_i\) 取真还是取假,都会导出矛盾。因此不存在满足的指派,与假设矛盾。

故若有解,则同一变量的两个文字必不在同一 SCC 中。

证毕。

充分性("当"方向)

假设对所有 \(i\)\(x_i\)\(\neg x_i\) 不在同一个 SCC 中。

我们可以构造一个满足的指派(这就是方案构造算法,详见第五章):

  • 对缩点后的 DAG,按逆拓扑序处理每个 SCC
  • 对于变量 \(x_i\),如果 \(x_i\) 所在 SCC 的拓扑序在 \(\neg x_i\) 之后,则取 \(x_i = 1\),否则取 \(x_i = 0\)

可以证明这样的赋值不会产生矛盾(详见第五章的论证)。

因此条件是充分的。

证毕。


五、可行性判定与方案构造

5.1 可行性判定

跑完 Tarjan(或其他 SCC 算法)后,对每个变量 \(x_i\) 检查:

若 scc[i] == scc[i + n],则无解

只要有一个变量的两个文字在同一个 SCC 中,整个 2-SAT 问题就不可满足。

bool satisfiable = true;
for (int i = 1; i <= n; i++) {
    if (scc[i] == scc[i + n]) {
        satisfiable = false;
        break;
    }
}

5.2 方案构造

如果有解,如何构造出一组具体的赋值?

核心思想:拓扑序

对缩点后的 DAG,我们按逆拓扑序从汇点到源点的方向)处理 SCC。

对于每个变量 \(x_i\),比较 \(x_i\)\(\neg x_i\) 所在 SCC 的拓扑序:

  • 如果 \(x_i\) 的 SCC 在拓扑序中更靠后(即更接近"汇点"),则取 \(x_i = \text{true}\)
  • 如果 \(\neg x_i\) 的 SCC 在拓扑序中更靠后,则取 \(x_i = \text{false}\)

为什么选拓扑序靠后的?

在缩点 DAG 中,如果有边 A → B,说明 A 蕴含 B(选 A 就必须选 B)。

如果我们选择了某个 SCC S,就必须选择 S 的所有后继 SCC(因为蕴含关系)。

策略:对于变量 \(x_i\),选拓扑序更靠后的那个状态。

正确性论证(数学归纳法)
我们按拓扑序从后往前依次处理每个 SCC,并决定其中文字的取值。归纳假设:已经处理完的 SCC(拓扑序更靠后的)中,取值不存在矛盾。

归纳步骤:考虑下一个 SCC S,设其中包含文字 \(l\)。我们需要决定是否让 \(l\) 为真。

  1. 同一变量的两个文字不在同一个 SCC(可行性已经保证)
  2. \(l\) 的否定 \(\neg l\) 所在的 SCC,记为 \(S'\)
  3. \(S'\)\(S\) 哪个拓扑序更靠后?
    • 如果 \(S'\)\(S\) 后面(拓扑序更靠后),则 \(S'\) 已经被处理过了
    • 如果 \(S'\)\(S\) 前面,则还没处理
  4. 我们的策略是选拓扑序更靠后的那个。这意味着:
    • 若选 \(l\) 为真,则 \(l\) 的所有后继(沿蕴含边能到达的文字)都必须为真
    • 这些后继的 SCC 拓扑序都在 \(l\) 之后(因为边从前往后指)
    • 它们的否定呢?如果某个后继 \(m\) 的否定 \(\neg m\) 也在后面,那在处理 \(\neg m\) 时就会选 \(\neg m\) 而不是 \(m\),矛盾
    • 但可行性判定保证了 \(m\)\(\neg m\) 不在同一个 SCC
    • 且如果 \(m\) 在后面被选了,说明 \(m\) 的拓扑序比 \(\neg m\) 更靠后
    • 因此 \(\neg m\) 一定在前面,不会被选
  5. 由归纳假设,前面的选择都是无矛盾的;当前选择也不会产生新矛盾。

综上,按"选拓扑序更靠后的状态"的策略构造的赋值一定满足所有约束。

直观理解

对于每个变量,我们选择"推导顺序"中后出现的那个取值。因为如果先选了 A,而 A 能推导出 ¬A,就会产生矛盾。所以选后出现的那个更安全——它是"结果"而不是"原因",不会倒逼出它的否定。

5.3 Tarjan 编号与拓扑序的关系

重要结论:Tarjan 算法得到的 SCC 编号是反拓扑序

  • 编号越小 → 越早被标记为 SCC → 在 DFS 中越先"完成" → 在缩点 DAG 中越接近汇点 → 拓扑序越靠后
  • 编号越大 → 越晚被标记 → 越接近源点 → 拓扑序越靠前

原因:Tarjan 是 DFS。越深的节点越早完成 SCC 的检测和编号(因为它们先回溯)。在 DAG 中,"深"意味着拓扑序靠后(接近汇点)。

因此,使用 Tarjan 时:

  • scc[i] < scc[i+n],则 \(x_i\) 所在 SCC 编号更小 → 拓扑序更靠后 → 选 \(x_i = \text{true}\)
  • scc[i] > scc[i+n],则 \(\neg x_i\) 所在 SCC 编号更小 → 拓扑序更靠后 → 选 \(x_i = \text{false}\)
// Tarjan 版本的方案构造
for (int i = 1; i <= n; i++) {
    if (scc[i] < scc[i + n]) {
        ans[i] = 1;  // x_i = true
    } else {
        ans[i] = 0;  // x_i = false
    }
}

注:不同算法的 SCC 编号方向不同!

  • Tarjan:编号 = 反拓扑序 → 编号小的拓扑序靠后 → 选编号小的
  • Kosaraju:编号 = 正拓扑序 → 编号大的拓扑序靠后 → 选编号大的

一定要搞清楚自己用的算法的编号方向,写反了会全错!


六、完整代码模板

6.1 邻接表

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 2000005;  // 2-SAT 节点数为 2n,注意开两倍!

vector<int> g[MAXN];
int dfn[MAXN], low[MAXN], scc[MAXN];
bool in_stack[MAXN];
stack<int> st;
int timestamp, scc_cnt;
int n, m;  // n 个变量,m 个约束

void tarjan(int u) {
    dfn[u] = low[u] = ++timestamp;
    st.push(u);
    in_stack[u] = true;
    for (int v : g[u]) {
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (in_stack[v]) {
            low[u] = min(low[u], dfn[v]);
        }
    }
    if (dfn[u] == low[u]) {
        scc_cnt++;
        int x;
        do {
            x = st.top();
            st.pop();
            in_stack[x] = false;
            scc[x] = scc_cnt;
        } while (x != u);
    }
}

// 变量编号 1 ~ n
// i 表示 x_i 为真,i+n 表示 x_i 为假
// 添加约束:x_i = val_i  OR  x_j = val_j
void add_clause(int i, bool val_i, int j, bool val_j) {
    int u = val_i ? i : i + n;  // 第一个文字
    int v = val_j ? j : j + n;  // 第二个文字
    // ¬u → v :如果 u 不成立,则 v 必须成立
    g[u <= n ? u + n : u - n].push_back(v);
    // ¬v → u :如果 v 不成立,则 u 必须成立
    g[v <= n ? v + n : v - n].push_back(u);
}

bool solve() {
    memset(dfn, 0, sizeof(dfn));
    memset(low, 0, sizeof(low));
    memset(scc, 0, sizeof(scc));
    memset(in_stack, 0, sizeof(in_stack));
    timestamp = scc_cnt = 0;
    while (!st.empty()) st.pop();
    
    for (int i = 1; i <= 2 * n; i++) {
        if (!dfn[i]) tarjan(i);
    }
    
    for (int i = 1; i <= n; i++) {
        if (scc[i] == scc[i + n]) return false;
    }
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int a, b;
        bool va, vb;
        cin >> a >> va >> b >> vb;
        add_clause(a, va, b, vb);
    }
    
    if (!solve()) {
        cout << "IMPOSSIBLE\n";
    } else {
        cout << "POSSIBLE\n";
        for (int i = 1; i <= n; i++) {
            // Tarjan: scc 编号小的拓扑序靠后,选它
            cout << (scc[i] < scc[i + n] ? 1 : 0) << " ";
        }
        cout << "\n";
    }
    return 0;
}

6.2 前向星

当数据量很大(如 n ≥ 1e6)时,前向星比 vector 更快,内存也更紧凑。

struct Edge {
    int to, nxt;
} e[MAXM];  // MAXM 为最大边数,2-SAT 中每个约束对应两条边,故 MAXM ≥ 2m

int head[MAXN], tot;  // MAXN 为最大节点数,2-SAT 中 MAXN ≥ 2n

void add_edge(int u, int v) {
    e[++tot].to = v;
    e[tot].nxt = head[u];
    head[u] = tot;
}

// 遍历方式:
// for (int i = head[u]; i; i = e[i].nxt) {
//     int v = e[i].to;
//     ...
// }

七、经典建图技巧总结

2-SAT 的难点不在算法本身,而在建模——如何把题目中的约束条件转化为蕴含图中的边。以下是常见的建图模型。

7.1 基本约束转换速查表

题意描述 逻辑表达式 建图方式(加两条边)
a 和 b 至少选一个 \(a \vee b\) \(\neg a \to b\)\(\neg b \to a\)
a 和 b 不能同时选 \(\neg a \vee \neg b\) \(a \to \neg b\)\(b \to \neg a\)
a 和 b 必须都选 \(a \wedge b\) \(\neg a \to a\)\(\neg b \to b\)(各加一条强制边,使其对立面推出自身)
选 a 就必须选 b \(a \rightarrow b\) \(a \to b\)\(\neg b \to \neg a\)
a 和 b 必须同真同假 \(a \leftrightarrow b\) \(a \to b\)\(b \to a\)\(\neg a \to \neg b\)\(\neg b \to \neg a\)
a 和 b 必须相反 \(a \oplus b\) \(a \to \neg b\)\(\neg a \to b\)\(b \to \neg a\)\(\neg b \to a\)

7.2 "必须选 a" 的正确建图

如果题目要求变量 a 必须为 true(即"必须选 a"),初学者常困惑:只有一个变量,怎么加两条边?

方法:只加一条边 \(\neg a \rightarrow a\)

为什么有效

  • 这条边的含义是"如果 a 为假,则 a 必须为真"
  • \((\neg a \rightarrow a) \iff a \vee a \iff a\)
  • 在蕴含图中,如果尝试让 a 为假(选 \(\neg a\)),则沿着这条边会推出 a 为真,矛盾
  • 因此 a 只能为真

同理,"必须不选 a"加边 \(a \rightarrow \neg a\)

7.3 集合选择模型

模型描述:有若干个二选一的集合(每个集合恰选一个元素),加上一些"不能同时选"或"必须同时选"的跨集合约束。

典型题:夫妻聚会、和平委员会、二选一任务分配等。

建模方法

  • 每个集合对应一个布尔变量
  • true 表示选第一个元素,false 表示选第二个元素
  • 跨集合约束转化为标准的 2-SAT 子句

7.4 时间冲突 / 区间不相交模型

模型描述:n 个活动,每个活动有两种时间选择(如上午/下午、开始时/结束时),某些活动不能同时进行(时间区间不能重叠)。

典型题:POJ 3683 Priest John's Busiest Day

建模方法

  • 每个活动对应一个变量,两种时间对应 true/false
  • 枚举所有活动对 (i, j),检查四种时间组合:
    • 如果 i 选方案 A 与 j 选方案 A 冲突 → 加约束 \(\neg x_i \vee \neg x_j\)
    • 如果 i 选方案 A 与 j 选方案 B 冲突 → 加约束 \(\neg x_i \vee x_j\)
    • 如果 i 选方案 B 与 j 选方案 A 冲突 → 加约束 \(x_i \vee \neg x_j\)
    • 如果 i 选方案 B 与 j 选方案 B 冲突 → 加约束 \(x_i \vee x_j\)
  • 时间复杂度 O(n²),n 较大时需要优化

7.5 三选二模型

模型描述:每个人有三个猜测/选项,至少要满足两个。

典型题:CF Gym 101987 K - TV Show Game

核心转换

"三个中至少对两个" 等价于 "任意两个中至少对一个"

对于三个猜测 A、B、C,至少对两个等价于:

  • A 错 → B 对 且 C 对
  • B 错 → A 对 且 C 对
  • C 错 → A 对 且 B 对

每个人加 6 条边(每个错的假设推出两个对的结论,共 3 × 2 = 6 条)。

验证:如果只对了 1 个(比如只有 A 对),则 B 错 → A 对且 C 对,但 C 错了,矛盾。因此确实保证了至少对 2 个。


九、常见变形与进阶

9.1 二分答案 + 2-SAT

适用场景:最优化问题,答案具有单调性,且"答案是否 ≤ X(或 ≥ X)"可以用 2-SAT 判定。

典型问题

  • 最小化最大时间/距离/费用
  • 最大化最小值

思路

  1. 二分答案 mid
  2. 加入"第 i 个变量的选择受 mid 限制"的约束(比如某些方案因为超时不可选,就强制选另一个)
  3. 跑 2-SAT 判断是否有解
  4. 根据判定结果调整二分边界

经典例题:安排日程,最小化最后一场活动的结束时间。

9.2 字典序最小解

适用场景:要求输出所有可行解中字典序最小的那个。

方法一(暴力枚举,O(n·(n+m)))
从第 1 个变量到第 n 个变量依次贪心:

  • 尝试赋值为 0(加约束强制为 0),跑一遍 2-SAT
  • 如果可行,保留这个赋值,继续下一个变量
  • 如果不可行,赋值为 1,继续下一个变量

缺点:要跑 n 次 Tarjan,较慢。

方法二(对称性优化)
利用 2-SAT 的对称性,每次确定一个变量后,把所有"被迫"确定的变量一并标记,不需要每次都跑完整 Tarjan。李煜东《算法进阶指南》中有详细讨论。

9.3 强制变量取特定值

问题:判断是否存在一个解,使得变量 a 取 true(或 false)。

方法:临时加一条边(强制 a 为真:加 \(\neg a \to a\)),跑一遍 2-SAT。

如果要恢复原图,需要撤销这条边。如果多次查询,需要考虑效率问题(比如离线处理)。

9.4 多组询问的 2-SAT

如果有多次修改约束并查询可行性,暴力做法每次重建图重跑 Tarjan 可能超时。这时需要更高级的数据结构(线段树分治 + 可撤销并查集等),超出本文范围。

9.5 3-SAT 的特殊情况

当 k=3 时,一般 3-SAT 是 NP 完全的。但竞赛中遇到"每个约束有 3 个变量"的题,往往是以下可解的特殊形式:

形式 解法
三个中至少对两个 拆成三组二元约束 → 2-SAT(三选二模型)
三个中恰好对一个 一般 NP 完全,但有时有特殊结构
变量取值有特殊限制 可能可以转化为 2-SAT

如果确认是一般 3-SAT,只能用随机化、启发式搜索等方法,不属于 2-SAT 范畴。


附录:常见误区

  1. 数组开小了:2-SAT 有 2n 个节点,dfnlowsccg 等所有数组都要开两倍!RE 的头号元凶。

  2. 变量编号混淆:务必明确 i 和 i+n 分别代表什么,不要在写代码时搞反。建议在代码开头用注释写清楚。

  3. SCC 编号方向:Tarjan 的 SCC 编号是反拓扑序,构造方案时 scc[i] < scc[i+n] 则取 true。Kosaraju 方向相反。用错方向会全错。

  4. 约束方向搞反:蕴含式 \(a \rightarrow b\) 对应边 \(a \to b\),含义是"选 a 必须选 b"。不要写成 \(b \to a\)

  5. 漏加对称边:每个子句对应两条边(原命题和逆否命题),只加一条是错误的。

  6. 把"至少一个"当成"恰好一个":析取是"至少一个为真",不是"恰好一个为真"。恰好一个是异或,需要四条边。


总结:2-SAT 的核心可以概括为八个字——建图正确,SCC 跑通

算法本身(Tarjan)是固定模板,写熟就行。真正的难点在建模:把题目中的自然语言约束准确翻译成蕴含边。建议按"模板题 → 集合选择 → 时间冲突 → 三选二 → 二分答案"的顺序刷题,每类模型掌握两到三道题,2-SAT 就能成为稳定得分点。

posted @ 2026-09-02 21:59  lvwangshu  阅读(28)  评论(0)    收藏  举报