2-SAT
2-SAT (2-satisfiability) là bài toán: cho một công thức logic gồm nhiều mệnh đề (clause), mỗi mệnh đề là phép HOẶC của đúng 2 literal (một biến boolean hoặc phủ định của nó), hãy tìm cách gán giá trị true/false cho các biến sao cho mọi mệnh đề đều đúng — hoặc kết luận vô nghiệm. Trong khi 3-SAT (mỗi mệnh đề 3 literal) là NP-hard, 2-SAT lại giải được trong thời gian tuyến tính nhờ quy về bài toán thành phần liên thông mạnh (SCC) trên một đồ thị kéo theo, thay vì thử tổ hợp gán.
Bài toán
Cho biến boolean và mệnh đề dạng , với là một biến hoặc phủ định của một biến. Cần một phép gán thỏa mãn tất cả mệnh đề, hoặc báo vô nghiệm (UNSAT).
Ý tưởng / Trực giác
Bước 1 — Biến mệnh đề thành "kéo theo" (implication). Mệnh đề nghĩa là "ít nhất một trong hai đúng". Tương đương: nếu một cái sai thì cái kia buộc phải đúng. Viết bằng phép kéo theo:
Trực giác: nếu sai mà mệnh đề vẫn phải đúng thì bắt buộc đúng — đó chính là một ràng buộc dây chuyền.
Bước 2 — Dựng đồ thị kéo theo (implication graph). Mỗi biến sinh ra hai đỉnh: đỉnh " đúng" và đỉnh " sai" (). Mỗi kéo theo là một cạnh có hướng . Như vậy mỗi mệnh đề thêm đúng 2 cạnh. Đường đi nghĩa là "chọn đúng thì kéo theo cũng phải đúng".
Bước 3 — Tính chất quyết định. Nếu trong đồ thị có đường đi và , thì hai đỉnh này nằm chung một chu trình, tức chung một SCC. Khi đó: gán đúng buộc sai (mâu thuẫn), gán sai cũng buộc đúng (mâu thuẫn). Vậy:
2-SAT vô nghiệm tồn tại biến mà và nằm cùng một SCC.
Lý do hai cạnh được thêm "đối xứng" ( và ): nó đảm bảo đồ thị có tính đối ngẫu (skew-symmetry) — nếu có đường thì luôn có đường . Nhờ vậy và không bao giờ cùng SCC một cách "ngẫu nhiên" trừ khi thực sự mâu thuẫn.
Bước 4 — Trích nghiệm từ thứ tự topo của các SCC. Co mỗi SCC thành một đỉnh, ta được một DAG (đồ thị không chu trình). Với mỗi biến, chọn literal nằm ở SCC đứng sau trong thứ tự topo (literal "đích" của các kéo theo). Lý do: kéo theo đi từ SCC sớm sang SCC muộn; chọn literal ở SCC muộn nghĩa là chọn cái mà mọi mũi tên đều trỏ tới, nên không kéo theo gì làm hỏng. Tarjan sinh SCC theo thứ tự topo ngược (SCC đánh số nhỏ là "muộn" hơn trong topo), nên quy tắc rút gọn lại thành: gán true khi comp[x_i] < comp[¬x_i].
Ví dụ chạy tay
Xét biến, 3 mệnh đề:
Quy ước mã hóa đỉnh: literal " đúng" , literal " sai" . Phủ định = đảo bit cuối (^1). Vậy 4 đỉnh:
đỉnh 0 = x0 đỉnh 1 = ¬x0
đỉnh 2 = x1 đỉnh 3 = ¬x1
Thêm cạnh. Mệnh đề thêm và :
(x0 ∨ x1): ¬x0→x1, ¬x1→x0 tức 1→2, 3→0
(¬x0 ∨ x1): x0 →x1, ¬x1→¬x0 tức 0→2, 3→1
(¬x0 ∨ ¬x1): x0 →¬x1, x1 →¬x0 tức 0→3, 2→1
Đồ thị kéo theo:
0 (x0)
/ | \
v v v cạnh: 0→2, 0→3
2 3 ... 1→2
| | 3→0, 3→1
v | 2→1
1 <-+
(¬x0)
Danh sách kề:
0 -> 2, 3
1 -> 2
2 -> 1
3 -> 0, 1
Chạy Tarjan tìm SCC (đánh số SCC theo thứ tự kết thúc — topo ngược):
Thăm 0 -> 2 -> 1. 1 không còn cạnh mới (1->2, 2 đang trong stack)
-> low[1] kéo về 2, low[2] kéo về 1: {1,2} là 1 vòng?
Kiểm tra: 2->1->2 => {1,2} cùng SCC. SCC #0 = {1,2}
Quay lại 0 -> 3 -> 0 (3->0 đóng vòng với 0): {0,3} cùng SCC. SCC #1 = {0,3}
Kết quả comp (chỉ số SCC):
đỉnh: 0 1 2 3
comp: 1 0 0 1
x0 ¬x0 x1 ¬x1
Kiểm tra vô nghiệm: với mỗi biến so comp[2i] và comp[2i+1]:
x0: comp[0]=1, comp[1]=0 -> khác nhau ✔ ổn
x1: comp[2]=0, comp[3]=1 -> khác nhau ✔ ổn
Không biến nào có hai literal chung SCC ⇒ có nghiệm.
Trích nghiệm (val[i] = comp[2i] < comp[2i+1]):
x0: comp[0]=1 < comp[1]=0 ? 1<0 sai -> x0 = false
x1: comp[2]=0 < comp[3]=1 ? 0<1 đúng -> x1 = true
Kiểm chứng :
(x0 ∨ x1) = (F ∨ T) = T ✔
(¬x0 ∨ x1) = (T ∨ T) = T ✔
(¬x0 ∨ ¬x1) = (T ∨ F) = T ✔
Cả 3 mệnh đề đúng — nghiệm hợp lệ.
Cài đặt
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e5 + 5; // số biến tối đa
vector<int> adj[2 * MAXN]; // đồ thị kéo theo: 2 đỉnh / biến
int comp[2 * MAXN]; // chỉ số SCC của mỗi đỉnh
int disc[2 * MAXN], low[2 * MAXN]; // Tarjan: thời điểm thăm & low-link
int timer_val = 0, num_scc = 0;
bool on_stack[2 * MAXN];
stack<int> st;
// Tarjan tìm SCC; SCC được đánh số theo THỨ TỰ TOPO NGƯỢC
void dfs(int u) {
disc[u] = low[u] = ++timer_val;
st.push(u); on_stack[u] = true;
for (int v : adj[u]) {
if (!disc[v]) { // cạnh cây
dfs(v);
low[u] = min(low[u], low[v]);
} else if (on_stack[v]) { // cạnh ngược/chéo về đỉnh còn trong stack
low[u] = min(low[u], disc[v]);
}
}
if (low[u] == disc[u]) { // u là gốc một SCC
while (true) {
int v = st.top(); st.pop();
on_stack[v] = false;
comp[v] = num_scc; // gán nhãn SCC
if (v == u) break;
}
num_scc++;
}
}
struct TwoSat {
int n;
TwoSat(int n) : n(n) {}
// Mã hóa literal: x_i = true -> đỉnh 2*i
// x_i = false -> đỉnh 2*i + 1 (phủ định = ^1)
// Thêm mệnh đề (a OR b): tương đương (¬a→b) và (¬b→a)
void add_clause(int a, int b) {
adj[a ^ 1].push_back(b); // ¬a → b
adj[b ^ 1].push_back(a); // ¬b → a
}
// Ép biến i nhận giá trị val: dùng mệnh đề (l ∨ l)
void set_val(int i, bool val) {
int a = 2 * i + (val ? 0 : 1);
add_clause(a, a);
}
bool solve(vector<bool>& val) {
// reset trạng thái cho 2*n đỉnh
fill(disc, disc + 2 * n, 0);
fill(on_stack, on_stack + 2 * n, false);
fill(comp, comp + 2 * n, -1);
timer_val = num_scc = 0;
for (int i = 0; i < 2 * n; i++)
if (!disc[i]) dfs(i);
val.resize(n);
for (int i = 0; i < n; i++) {
if (comp[2 * i] == comp[2 * i + 1]) return false; // x_i và ¬x_i cùng SCC -> UNSAT
// SCC chỉ số nhỏ = muộn hơn trong topo -> chọn literal ở đó
val[i] = comp[2 * i] < comp[2 * i + 1];
}
return true;
}
};
Ví dụ dùng (mỗi mệnh đề cho 4 số: chỉ số biến và giá trị literal mong muốn của mỗi vế):
int main() {
int n, m; cin >> n >> m;
TwoSat sat(n);
for (int i = 0; i < m; i++) {
int a, b; bool va, vb;
cin >> a >> va >> b >> vb; // (x_a == va) OR (x_b == vb)
sat.add_clause(2 * a + (va ? 0 : 1), 2 * b + (vb ? 0 : 1));
}
vector<bool> val;
if (!sat.solve(val)) cout << "UNSAT\n";
else for (int i = 0; i < n; i++) cout << (val[i] ? "T" : "F") << " ";
}
Mã hóa các ràng buộc thường gặp
Mọi ràng buộc dưới đây đều quy về mệnh đề 2-literal rồi gọi add_clause:
| Ràng buộc | Mệnh đề tương đương |
|---|---|
| (bằng nhau) | và |
| (khác nhau / XOR) | và |
| "không cùng true" (NAND) |
Độ phức tạp
- Thời gian: với đỉnh và cạnh. Tarjan thăm mỗi đỉnh và duyệt mỗi cạnh đúng một lần (DFS), không quay lui lặp; vòng trích nghiệm cuối chạy . Tổng — tuyến tính theo kích thước công thức, thay vì của vét cạn.
- Bộ nhớ: . Danh sách kề lưu cạnh; các mảng
disc, low, comp, on_stackcùng stack Tarjan đều cỡ . Lưu ý mọi mảng phải khai báo theo (gấp đôi số biến) vì mỗi biến có 2 đỉnh.
⚠️ Lỗi thường gặp
- Quên nhân đôi kích thước mảng. Mỗi biến tạo 2 đỉnh nên mọi mảng (
adj,disc,comp, ...) phải cỡ , không phải . Triệu chứng: RuntimeError / truy cập ngoài biên hoặc kết quả sai khi lớn. Luôn đặt hằngMAXNrồi cấp phát2 * MAXN. - Thêm sai/thiếu cạnh đối xứng. Mệnh đề cần cả hai cạnh và . Nếu chỉ thêm một cạnh, đồ thị mất tính skew-symmetry, kết luận SAT/UNSAT và nghiệm đều có thể sai. Mẹo: viết
adj[a^1].push_back(b); adj[b^1].push_back(a);thành một cặp cố định. - Hiểu nhầm chiều thứ tự topo khi trích nghiệm. Tarjan đánh số SCC theo topo ngược: SCC chỉ số nhỏ đứng muộn hơn. Nếu dùng nhầm dấu so sánh (
>thay vì<) bạn sẽ chọn literal sai và ra nghiệm vi phạm mệnh đề dù bài có nghiệm. Quy tắc đúng với cách cài này:val[i] = comp[2*i] < comp[2*i+1]. - Đệ quy DFS tràn stack với lớn. Với tới vài trăm nghìn đỉnh, DFS đệ quy có thể stack overflow. Khắc phục: tăng giới hạn stack, hoặc viết Tarjan/Kosaraju khử đệ quy (dùng stack tường minh).
- Quên reset trạng thái giữa nhiều testcase. Nếu chạy nhiều bộ test trong một lần, phải
filllạidisc, low, comp, on_stack, xóaadj, đặttimer_val = num_scc = 0. Bỏ sót làm SCC của test sau bị "dính" dữ liệu test trước. - Ràng buộc "ép giá trị" bị quên hoặc sai. Để ép phải thêm , tức cạnh (không có cạnh ngược lại). Nếu thêm nhầm thành cạnh hai chiều, bạn vô tình bắt và vào cùng SCC ⇒ báo UNSAT giả.
Biến thể / Mở rộng
- Trích nghiệm bằng Kosaraju (DFS hai lượt) thay cho Tarjan — cùng độ phức tạp, nhiều người thấy dễ nhớ hơn; thứ tự topo lấy từ thứ tự kết thúc lượt 1.
- 2-SAT có hướng dẫn (implication chains nâng cao): nhiều bài mô hình hóa ràng buộc "chọn 1 trong 2 vị trí/khe thời gian, hai đối tượng không đụng nhau" thành 2-SAT (mỗi đối tượng 1 biến boolean = chọn khe A hay B), ví dụ xếp lịch tránh xung đột, tô 2 màu có ràng buộc, đặt biển/quân cờ ở 1 trong 2 ô.
- Nền tảng SCC. 2-SAT chỉ là một ứng dụng của co thành phần liên thông mạnh rồi xử lý trên DAG rút gọn. Nắm vững SCC (Tarjan/Kosaraju) và kỹ thuật co đồ thị + DP trên DAG là tiền đề để giải 2-SAT thành thạo.
Bài tập luyện
- Pizza Khổng Lồ (giantpizza) — (Nâng cao) Bài 2-SAT kinh điển: mỗi người có 2 yêu cầu (thêm/bỏ topping), cần thỏa ít nhất một yêu cầu của mỗi người — mỗi người thành đúng một mệnh đề , áp thẳng khuôn mẫu ở trên.
- Cánh đồng cỏ (grasscow) — (Kỳ cựu) Luyện nền tảng SCC + co đồ thị thành DAG mà 2-SAT dựa vào: tìm SCC, rút gọn rồi DP trên DAG để tối đa số trường thăm được khi cho đảo ngược một cạnh.
Ghi chú: kho bài CTOJ hiện chỉ có đúng một bài 2-SAT thuần (giantpizza); bài grasscow được thêm để luyện chắc kỹ thuật SCC/condensation — bước lõi mà 2-SAT xây trên. Sẽ cập nhật thêm bài 2-SAT khi kho có.