Mỗi bạn trong lớp chọn đi dã ngoại hoặc ở nhà. Có những điều kiện kiểu: “An hoặc Bình phải đi”, “Bình và Chi không cùng đi”. Có cách chọn nào thỏa mọi điều kiện không?
Đó là 2-SAT: biến đúng/sai, mỗi điều kiện dạng “ hoặc ” với , là một biến hay phủ định của nó. Mỗi điều kiện thành hai phép kéo theo, và thành phần liên thông mạnh trên đồ thị kéo theo cho biết có nghiệm hay không, trong , rồi dựng luôn phép gán. Phần lớn công sức nằm ở việc mã hóa đề thành các mệnh đề hai literal: chọn một trong hai phương án, không chọn cùng lúc hai thứ xung đột, các phép AND, OR, XOR.
Đồ thị kéo theo
Biến ứng với hai đỉnh: literal (số hiệu ) và (số hiệu ; phủ định là u ^ 1). Mệnh đề tương đương hai phép kéo theo: nếu sai thì phải đúng, nếu sai thì phải đúng. Thêm cung và .
Mọi literal tới được từ trên đồ thị đều phải đúng khi đúng. Vì vậy nếu và nằm trong cùng một thành phần liên thông mạnh thì kéo theo và ngược lại: vô nghiệm. Điều ngược lại cũng đúng: không biến nào cùng thành phần với phủ định của nó thì luôn có nghiệm (phần dựng ở mục sau).
Ràng buộc khác đều viết được bằng mệnh đề: ép đúng là ; là ; là ; “không cùng và ” là .
Trong hình, hàng trên là , hàng dưới là . Mỗi bước thêm hai cung của một mệnh đề; cuối cùng tô thành phần mạnh và đọc phép gán.
Hỏi nhanh
Mệnh đề cho hai cung nào?
cho và . Với , : và .
Ví dụMạng Phép Toán
Đề. biến, ràng buộc với phép AND, OR hoặc XOR. Có phép gán thỏa hết không?
Lời giải. Mỗi ràng buộc dịch thành một hai mệnh đề hai literal:
- AND bằng 1: ép đúng và ép đúng, tức và . AND bằng 0: không cùng đúng, .
- OR bằng 1: . OR bằng 0: ép cả hai sai.
- XOR bằng 1: , tức . XOR bằng 0: , tức .
Dựng đồ thị kéo theo trên literal, tìm thành phần mạnh bằng Tarjan, vô nghiệm khi một biến nằm cùng thành phần với phủ định của nó.
Bước 1: literal và mệnh đề. Biến có đỉnh (đúng) và (sai), nên phủ định là u ^ 1. clause(a, b) thêm hai cung và . clause(A, A) thêm cung : nếu sai thì phải đúng, tức ép đúng.
void clause(int a, int b) {
g[a ^ 1].push_back(b);
g[b ^ 1].push_back(a);
}
Bước 2: dịch ràng buộc theo đúng bảng trên.
int A = 2 * a, B = 2 * b;
if (op == "AND") {
if (c) clause(A, A), clause(B, B);
else clause(A ^ 1, B ^ 1);
} else if (op == "OR") {
if (c) clause(A, B);
else clause(A ^ 1, A ^ 1), clause(B ^ 1, B ^ 1);
} else {
if (c) clause(A, B), clause(A ^ 1, B ^ 1);
else clause(A, B ^ 1), clause(A ^ 1, B);
}
Bước 3: Tarjan không đệ quy. Đồ thị có tới cung nên không đệ quy. cs đóng vai ngăn xếp gọi hàm, it[u] nhớ đỉnh đã xét tới cung thứ mấy. Gặp đỉnh mới thì đẩy vào cả stk (ngăn xếp của Tarjan) và cs; gặp đỉnh đang trong stk thì cập nhật low[u] bằng tin[v]. Khi hết cung: bỏ khỏi cs, đưa low[u] lên cha (việc mà bản đệ quy làm sau khi lời gọi con trả về). Nếu low[u] == tin[u] thì là gốc của một thành phần mạnh: lấy khỏi stk mọi đỉnh tới .
cs.pop_back();
if (!cs.empty()) low[cs.back()] = min(low[cs.back()], low[u]);
if (low[u] == tin[u]) {
while (true) {
int v = stk.back();
stk.pop_back();
onst[v] = 0, comp[v] = ncomp;
if (v == u) break;
}
ncomp++;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2005;
int n, m, timer, ncomp, tin[N], low[N], comp[N], it[N];
bool onst[N];
vector<int> g[N];
void clause(int a, int b) {
g[a ^ 1].push_back(b);
g[b ^ 1].push_back(a);
}
void tarjan(int V) {
vector<int> stk, cs;
for (int s = 0; s < V; s++) {
if (tin[s]) continue;
cs.push_back(s);
tin[s] = low[s] = ++timer, stk.push_back(s), onst[s] = 1;
while (!cs.empty()) {
int u = cs.back();
if (it[u] < (int)g[u].size()) {
int v = g[u][it[u]++];
if (!tin[v]) {
tin[v] = low[v] = ++timer, stk.push_back(v), onst[v] = 1;
cs.push_back(v);
} else if (onst[v]) low[u] = min(low[u], tin[v]);
} else {
cs.pop_back();
if (!cs.empty()) low[cs.back()] = min(low[cs.back()], low[u]);
if (low[u] == tin[u]) {
while (true) {
int v = stk.back();
stk.pop_back();
onst[v] = 0, comp[v] = ncomp;
if (v == u) break;
}
ncomp++;
}
}
}
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 0; i < m; i++) {
int a, b, c;
string op;
cin >> a >> b >> c >> op;
int A = 2 * a, B = 2 * b;
if (op == "AND") {
if (c) clause(A, A), clause(B, B);
else clause(A ^ 1, B ^ 1);
} else if (op == "OR") {
if (c) clause(A, B);
else clause(A ^ 1, A ^ 1), clause(B ^ 1, B ^ 1);
} else {
if (c) clause(A, B), clause(A ^ 1, B ^ 1);
else clause(A, B ^ 1), clause(A ^ 1, B);
}
}
tarjan(2 * n);
bool ok = 1;
for (int x = 0; x < n; x++)
if (comp[2 * x] == comp[2 * x + 1]) ok = 0;
cout << (ok ? "YES" : "NO") << '\n';
}
3 3
0 1 1 XOR
1 2 1 AND
0 2 1 OR
YES
. Đồ thị kéo theo có tới hàng triệu cung, nên cách lưu danh sách kề ảnh hưởng nhiều tới tốc độ:
Mảng cung liền nhau (đếm bậc, cộng dồn, điền cung) nhanh hơn vector<int> g[N] khoảng 6 lần, tính cả thời gian dựng đồ thị. Quy tắc: mệnh đề đọc hết trước được thì dựng đồ thị dạng mảng liền nhau.
Dựng phép gán
Co các thành phần mạnh được một DAG. Chọn cho mỗi biến literal nằm ở thành phần xuất hiện sau theo thứ tự tô pô: nếu chọn đúng trong khi thì mâu thuẫn, mà khi đứng sau thì không thể có đường từ tới . Tarjan đánh số thành phần theo thứ tự tô pô ngược (thành phần không có cung ra được đánh số trước), nên quy tắc là: đúng khi comp[2x] < comp[2x+1].
Với Kosaraju thì số hiệu đi theo thứ tự tô pô thuận và bất đẳng thức đổi chiều; luôn kiểm quy tắc với đúng thuật toán đang dùng.
Ví dụ dưới là vô nghiệm: kéo theo cả lẫn . Sửa một mệnh đề để có nghiệm.
Ví dụCấu Hình Tùy Chọn
Đề. tùy chọn, yêu cầu, mỗi yêu cầu gồm hai literal dạng + x hay - x, cần ít nhất một đúng. In +/- cho từng tùy chọn, hoặc IMPOSSIBLE.
Lời giải. Mỗi yêu cầu đã là một mệnh đề . Dựng đồ thị kéo theo trên literal, chạy Tarjan, kiểm vô nghiệm như ví dụ trước (clause, tarjan giống hệt). Có nghiệm thì đọc phép gán: tùy chọn bật khi comp[2x] < comp[2x + 1].
Bước 1: đọc literal. + x là , - x là .
int a = 2 * (x1 - 1) + (s1 == '-'), b = 2 * (x2 - 1) + (s2 == '-');
clause(a, b);
Bước 2: đọc phép gán. Tarjan đánh số thành phần theo thứ tự xong; thành phần không có cung ra được đánh số trước, nên số nhỏ hơn là đứng sau trong thứ tự tô pô. Chọn literal ở thành phần đứng sau thì không có đường từ nó tới phủ định của nó, nên không kéo theo mâu thuẫn.
for (int x = 0; x < m; x++) cout << (comp[2 * x] < comp[2 * x + 1] ? '+' : '-') << ' ';
Vì sao cả phép gán đều đúng: nếu một mệnh đề bị vi phạm thì cả và được chọn, mà có cung , nên comp của không nhỏ hơn comp của . Ghép với các bất đẳng thức đã dùng để chọn và được một vòng bất đẳng thức nghiêm ngặt, vô lý.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2e5 + 5;
int n, m, timer, ncomp, tin[N], low[N], comp[N], it[N];
bool onst[N];
vector<int> g[N];
void clause(int a, int b) {
g[a ^ 1].push_back(b);
g[b ^ 1].push_back(a);
}
void tarjan(int V) {
vector<int> stk, cs;
for (int s = 0; s < V; s++) {
if (tin[s]) continue;
cs.push_back(s);
tin[s] = low[s] = ++timer, stk.push_back(s), onst[s] = 1;
while (!cs.empty()) {
int u = cs.back();
if (it[u] < (int)g[u].size()) {
int v = g[u][it[u]++];
if (!tin[v]) {
tin[v] = low[v] = ++timer, stk.push_back(v), onst[v] = 1;
cs.push_back(v);
} else if (onst[v]) low[u] = min(low[u], tin[v]);
} else {
cs.pop_back();
if (!cs.empty()) low[cs.back()] = min(low[cs.back()], low[u]);
if (low[u] == tin[u]) {
while (true) {
int v = stk.back();
stk.pop_back();
onst[v] = 0, comp[v] = ncomp;
if (v == u) break;
}
ncomp++;
}
}
}
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 0; i < n; i++) {
char s1, s2;
int x1, x2;
cin >> s1 >> x1 >> s2 >> x2;
int a = 2 * (x1 - 1) + (s1 == '-'), b = 2 * (x2 - 1) + (s2 == '-');
clause(a, b);
}
tarjan(2 * m);
for (int x = 0; x < m; x++)
if (comp[2 * x] == comp[2 * x + 1]) {
cout << "IMPOSSIBLE\n";
return 0;
}
for (int x = 0; x < m; x++) cout << (comp[2 * x] < comp[2 * x + 1] ? '+' : '-') << ' ';
cout << '\n';
}
4 3
+ 1 + 1
- 2 - 2
+ 3 + 3
+ 1 - 2
+ - +
Độ phức tạp: .
Chọn một trong hai
Dạng hay gặp nhất: mỗi đối tượng chọn phương án hoặc (một biến ), và một số cặp phương án của hai đối tượng xung đột không được cùng chọn. Với cặp xung đột ( chọn , chọn ), thêm mệnh đề . Duyệt mọi cặp đối tượng và mọi cặp phương án là mệnh đề, đủ cho vài nghìn.
Khi cần tối ưu một ngưỡng (khoảng cách nhỏ nhất lớn nhất), chặt nhị phân ngưỡng và kiểm 2-SAT ở mỗi bước.
Hỏi nhanh
Cờ 1 đặt ở hoặc , cờ 2 ở hoặc , cần cách nhau ít nhất . Những cặp nào xung đột?
, , đều nhỏ hơn 8; chỉ là được. Ba mệnh đề cấm ép cờ 1 ở 13 và cờ 2 ở 5.
Ví dụCờ Trên Trục Số
Đề. lá cờ, cờ đặt ở hoặc ; mọi cặp cờ cách nhau ít nhất . In một cách đặt hoặc No.
Lời giải. Biến đúng là cờ đặt ở , sai là đặt ở . Với mỗi cặp cờ và mỗi cặp phương án cách nhau dưới , hai lựa chọn đó không được cùng xảy ra: mệnh đề “không ( chọn ) hoặc không ( chọn )”. Tối đa mệnh đề, rồi 2-SAT như các ví dụ trước.
Bước 1: mệnh đề cấm. Literal 2 * i + p nghĩa là “cờ đặt ở pos[i][p]”. Cặp quá gần cho mệnh đề trên phủ định của cả hai lựa chọn. llabs vì hiệu tọa độ có thể âm.
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
for (int p = 0; p < 2; p++)
for (int q = 0; q < 2; q++)
if (llabs(pos[i][p] - pos[j][q]) < d) clause((2 * i + p) ^ 1, (2 * j + q) ^ 1);
Bước 2: đọc phép gán như ví dụ trước: comp[2i] < comp[2i + 1] thì đúng, in pos[i][0], ngược lại in pos[i][1].
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2005;
int n, timer, ncomp, tin[N], low[N], comp[N], it[N];
ll d, pos[N][2];
bool onst[N];
vector<int> g[N];
void clause(int a, int b) {
g[a ^ 1].push_back(b);
g[b ^ 1].push_back(a);
}
void tarjan(int V) {
vector<int> stk, cs;
for (int s = 0; s < V; s++) {
if (tin[s]) continue;
cs.push_back(s);
tin[s] = low[s] = ++timer, stk.push_back(s), onst[s] = 1;
while (!cs.empty()) {
int u = cs.back();
if (it[u] < (int)g[u].size()) {
int v = g[u][it[u]++];
if (!tin[v]) {
tin[v] = low[v] = ++timer, stk.push_back(v), onst[v] = 1;
cs.push_back(v);
} else if (onst[v]) low[u] = min(low[u], tin[v]);
} else {
cs.pop_back();
if (!cs.empty()) low[cs.back()] = min(low[cs.back()], low[u]);
if (low[u] == tin[u]) {
while (true) {
int v = stk.back();
stk.pop_back();
onst[v] = 0, comp[v] = ncomp;
if (v == u) break;
}
ncomp++;
}
}
}
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> d;
for (int i = 0; i < n; i++) cin >> pos[i][0] >> pos[i][1];
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
for (int p = 0; p < 2; p++)
for (int q = 0; q < 2; q++)
if (llabs(pos[i][p] - pos[j][q]) < d) clause((2 * i + p) ^ 1, (2 * j + q) ^ 1);
tarjan(2 * n);
for (int i = 0; i < n; i++)
if (comp[2 * i] == comp[2 * i + 1]) {
cout << "No\n";
return 0;
}
cout << "Yes\n";
for (int i = 0; i < n; i++) cout << (comp[2 * i] < comp[2 * i + 1] ? pos[i][0] : pos[i][1]) << '\n';
}
2 8
3 13
5 7
Yes
13
5
Độ phức tạp: .
Khoảng thời gian xung đột
Mỗi sự kiện chọn một trong hai khoảng thời gian, hai khoảng được chọn không được giao nhau: đúng mẫu trên với “xung đột” là hai khoảng nửa mở , có . Đổi giờ phút ra số phút để so sánh, in ngược lại theo dạng hh:mm.
Ví dụLịch Nghi Lễ
Đề. sự kiện, sự kiện từ tới , cần nghi lễ dài ở đầu () hoặc cuối (). Các nghi lễ không được chồng nhau. In lịch hoặc NO.
Lời giải. Đổi giờ ra số phút. Biến đúng là nghi lễ ở đầu, ; sai là ở cuối, . Hai khoảng nửa mở giao nhau khi ; mỗi cặp giao nhau của hai sự kiện khác nhau cho một mệnh đề cấm, đúng khuôn của ví dụ cờ.
Bước 1: đổi giờ. toMin đổi hh:mm thành số phút; toStr làm ngược lại, in đủ hai chữ số bằng %02d. L[i][p], R[i][p] là khoảng của phương án ( là đầu, là cuối).
int a = toMin(s), b = toMin(t);
L[i][0] = a, R[i][0] = a + d;
L[i][1] = b - d, R[i][1] = b;
Bước 2: mệnh đề cấm. Điều kiện giao nhau dùng đúng dạng nửa mở, nên hai nghi lễ nối đuôi nhau (một cái kết thúc đúng lúc cái kia bắt đầu) không bị coi là chồng.
if (max(L[i][p], L[j][q]) < min(R[i][p], R[j][q])) clause((2 * i + p) ^ 1, (2 * j + q) ^ 1);
Phần 2-SAT và đọc phép gán giống ví dụ cờ; in lại khoảng đã chọn theo hh:mm.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2005;
int n, timer, ncomp, tin[N], low[N], comp[N], it[N], L[N][2], R[N][2];
bool onst[N];
vector<int> g[N];
void clause(int a, int b) {
g[a ^ 1].push_back(b);
g[b ^ 1].push_back(a);
}
void tarjan(int V) {
vector<int> stk, cs;
for (int s = 0; s < V; s++) {
if (tin[s]) continue;
cs.push_back(s);
tin[s] = low[s] = ++timer, stk.push_back(s), onst[s] = 1;
while (!cs.empty()) {
int u = cs.back();
if (it[u] < (int)g[u].size()) {
int v = g[u][it[u]++];
if (!tin[v]) {
tin[v] = low[v] = ++timer, stk.push_back(v), onst[v] = 1;
cs.push_back(v);
} else if (onst[v]) low[u] = min(low[u], tin[v]);
} else {
cs.pop_back();
if (!cs.empty()) low[cs.back()] = min(low[cs.back()], low[u]);
if (low[u] == tin[u]) {
while (true) {
int v = stk.back();
stk.pop_back();
onst[v] = 0, comp[v] = ncomp;
if (v == u) break;
}
ncomp++;
}
}
}
}
}
int toMin(string s) {
return stoi(s.substr(0, 2)) * 60 + stoi(s.substr(3, 2));
}
string toStr(int t) {
char buf[8];
sprintf(buf, "%02d:%02d", t / 60, t % 60);
return buf;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 0; i < n; i++) {
string s, t;
int d;
cin >> s >> t >> d;
int a = toMin(s), b = toMin(t);
L[i][0] = a, R[i][0] = a + d;
L[i][1] = b - d, R[i][1] = b;
}
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
for (int p = 0; p < 2; p++)
for (int q = 0; q < 2; q++)
if (max(L[i][p], L[j][q]) < min(R[i][p], R[j][q])) clause((2 * i + p) ^ 1, (2 * j + q) ^ 1);
tarjan(2 * n);
for (int i = 0; i < n; i++)
if (comp[2 * i] == comp[2 * i + 1]) {
cout << "NO\n";
return 0;
}
cout << "YES\n";
for (int i = 0; i < n; i++) {
int p = comp[2 * i] < comp[2 * i + 1] ? 0 : 1;
cout << toStr(L[i][p]) << ' ' << toStr(R[i][p]) << '\n';
}
}
3
08:00 08:20 20
08:10 09:00 20
09:10 09:30 20
YES
08:00 08:20
08:40 09:00
09:10 09:30
Độ phức tạp: .
Khi đi thi
- “Mỗi thứ chọn một trong hai”, “ít nhất một trong hai điều kiện”, ràng buộc giữa từng cặp: 2-SAT. Viết sẵn hàm
clause(a, b)thêm hai cung, phủ định là^ 1. - Ép một literal:
clause(a, a). Bằng nhau, khác nhau, AND, OR, XOR đều là một hai mệnh đề. - Gán theo thứ tự thành phần mạnh: với Tarjan, đúng khi
comp[x] < comp[¬x]. DFS không đệ quy khi số đỉnh lớn.
Tóm tắt
- thành và .
- Vô nghiệm khi và chỉ khi có cùng thành phần mạnh với .
- Phép gán: chọn literal ở thành phần đứng sau theo thứ tự tô pô.
- Xung đột giữa các cặp phương án thành mệnh đề cấm; tối ưu ngưỡng thì chặt nhị phân rồi kiểm.