2-SAT 算法
2-SAT 算法
目录
- 预备知识 A:数理逻辑基础
- 预备知识 B:计算复杂度理论
- 预备知识 C:图论基础
- 预备知识 D:算法与数据结构
- 一、问题背景与定义
- 二、核心思想:蕴含图建模
- 三、算法原理:强连通分量
- 五、可行性判定与方案构造
- 六、完整模板代码
- 七、经典建图技巧总结
- 九、常见变形与进阶
- 附录:常见误区
预备知识 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)发现:
口诀:"与"和"或"互换,否定号分配到两边。
在 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\) | \(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 和 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\) | \(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 类问题举例:
- 排序的判定版本("这个数组是否已经有序?")
- 最短路问题("从 s 到 t 的距离是否 ≤ k?")
- 最小生成树判定
- 2-SAT 问题(本文主题)
B.3.2 NP 类
NP 类(Nondeterministic Polynomial Time):所有可以在多项式时间内验证一个"解"是否正确的判定问题构成的集合。
换句话说,如果你猜出了一个候选解,你能在多项式时间内检验它对不对。
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 完全的,当且仅当:
- \(X \in NP\)(X 属于 NP 类)
- 对所有 \(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 完全的
证明思路:
-
3-SAT ∈ NP:给一个指派,O(m) 验证所有子句。
-
把 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) \]可以证明原公式可满足当且仅当新公式可满足。
-
因此所有 SAT(任意长度子句)都可以多项式时间归约到 3-SAT。
-
因为 SAT 是 NP 完全的,所以 3-SAT 也是 NP 完全的。
-
对于 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):有向图的一个极大强连通子图。
"极大"的含义:不能再加入更多顶点而仍然保持强连通。
性质:
- 每个顶点恰好属于一个 SCC(SCC 构成顶点集的一个划分)
- 同一 SCC 内的任意两顶点互相可达
- 不同 SCC 之间的边只能单向(否则它们会合并为一个 SCC)
C.2.3 缩点(Condensation)
把每个 SCC 缩成一个"超级节点",原图就变成了一个有向无环图(DAG),称为缩点图或DAG 图。
缩点后得到 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)
- 拓扑序不一定唯一
求拓扑序的算法:
- Kahn 算法(BFS 法):不断找入度为 0 的点加入拓扑序,O(V+E)
- 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 算法简介(了解)
算法步骤:
- 对原图 G 进行 DFS,按完成时间从晚到早记录节点顺序(后序遍历的逆序)
- 按上述顺序,对逆图(所有边反向的图)\(G^T\) 进行 DFS
- 每次 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 个子句同时被满足。
也可以把每个子句理解为一个约束条件:
注意:2-SAT 中的"2"指的是每个子句有 2 个文字,而不是只有 2 个变量。变量数可以是任意的 n。
问题的两种形式
竞赛中 2-SAT 通常有两种问法:
- 判定问题:是否存在满足所有约束的方案?(输出 YES/NO)
- 构造问题:如果存在,输出任意一组可行解。
有时还会有字典序最小解、必须包含某个变量等变形。
二、核心思想:蕴含图建模
2-SAT 的核心技巧是将逻辑约束转化为图论中的有向边,从而把一个逻辑问题变成图的可达性问题。这张图称为蕴含图(Implication Graph)。
2.1 蕴含关系
回忆蕴含(详见预备知识 A.4)的定义:
读作"如果 a 成立,那么 b 一定成立"。蕴含有一个重要性质:原命题与逆否命题等价,即 \(a \rightarrow b \iff \neg b \rightarrow \neg a\)。
对于 2-SAT 的每个子句 \(l_1 \vee l_2\),利用蕴含的定义,可以改写为两个蕴含式:
理解:"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\) 为真。
- 同一变量的两个文字不在同一个 SCC(可行性已经保证)
- \(l\) 的否定 \(\neg l\) 所在的 SCC,记为 \(S'\)
- \(S'\) 与 \(S\) 哪个拓扑序更靠后?
- 如果 \(S'\) 在 \(S\) 后面(拓扑序更靠后),则 \(S'\) 已经被处理过了
- 如果 \(S'\) 在 \(S\) 前面,则还没处理
- 我们的策略是选拓扑序更靠后的那个。这意味着:
- 若选 \(l\) 为真,则 \(l\) 的所有后继(沿蕴含边能到达的文字)都必须为真
- 这些后继的 SCC 拓扑序都在 \(l\) 之后(因为边从前往后指)
- 它们的否定呢?如果某个后继 \(m\) 的否定 \(\neg m\) 也在后面,那在处理 \(\neg m\) 时就会选 \(\neg m\) 而不是 \(m\),矛盾
- 但可行性判定保证了 \(m\) 和 \(\neg m\) 不在同一个 SCC
- 且如果 \(m\) 在后面被选了,说明 \(m\) 的拓扑序比 \(\neg m\) 更靠后
- 因此 \(\neg m\) 一定在前面,不会被选
- 由归纳假设,前面的选择都是无矛盾的;当前选择也不会产生新矛盾。
综上,按"选拓扑序更靠后的状态"的策略构造的赋值一定满足所有约束。
直观理解
对于每个变量,我们选择"推导顺序"中后出现的那个取值。因为如果先选了 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 判定。
典型问题:
- 最小化最大时间/距离/费用
- 最大化最小值
思路:
- 二分答案 mid
- 加入"第 i 个变量的选择受 mid 限制"的约束(比如某些方案因为超时不可选,就强制选另一个)
- 跑 2-SAT 判断是否有解
- 根据判定结果调整二分边界
经典例题:安排日程,最小化最后一场活动的结束时间。
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 范畴。
附录:常见误区
-
数组开小了:2-SAT 有 2n 个节点,
dfn、low、scc、g等所有数组都要开两倍!RE 的头号元凶。 -
变量编号混淆:务必明确 i 和 i+n 分别代表什么,不要在写代码时搞反。建议在代码开头用注释写清楚。
-
SCC 编号方向:Tarjan 的 SCC 编号是反拓扑序,构造方案时
scc[i] < scc[i+n]则取 true。Kosaraju 方向相反。用错方向会全错。 -
约束方向搞反:蕴含式 \(a \rightarrow b\) 对应边 \(a \to b\),含义是"选 a 必须选 b"。不要写成 \(b \to a\)。
-
漏加对称边:每个子句对应两条边(原命题和逆否命题),只加一条是错误的。
-
把"至少一个"当成"恰好一个":析取是"至少一个为真",不是"恰好一个为真"。恰好一个是异或,需要四条边。
总结:2-SAT 的核心可以概括为八个字——建图正确,SCC 跑通。
算法本身(Tarjan)是固定模板,写熟就行。真正的难点在建模:把题目中的自然语言约束准确翻译成蕴含边。建议按"模板题 → 集合选择 → 时间冲突 → 三选二 → 二分答案"的顺序刷题,每类模型掌握两到三道题,2-SAT 就能成为稳定得分点。
原创·转载请注明出处
✦
浙公网安备 33010602011771号