Chương 7·Bài 7.8

2-SAT

Cho nn biến logic, mỗi biến nhận giá trị đúng hoặc sai, và mm điều kiện, mỗi điều kiện có dạng “aa hoặc bb”, trong đó aa, bb là một biến hoặc phủ định của một biến. Tìm một cách gán giá trị cho các biến thỏa mọi điều kiện, hoặc cho biết không có, trong O(n+m)O(n + m).

Đây là bài toán 2-SAT. Mỗi điều kiện “aa hoặc bb” được viết thành hai câu “nếu aa sai thì bb đúng” và “nếu bb sai thì aa đúng”, và các câu “nếu… thì…” này tạo thành một đồ thị có hướng gọi là đồ thị kéo theo. Tìm các thành phần liên thông mạnh của đồ thị (bài 3.9) là biết ngay có nghiệm hay không, và thứ tự của các thành phần cho luôn một cách gán. Phần lớn công sức khi giải bài là dịch đề thành các điều kiện hai vế như vậy: 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 giữa hai biến.

IBài toán 2-SAT và cách thử hết

Một biến logic nhận một trong hai giá trị: đúng hoặc sai. Với biến xx, ta viết ¬x\lnot x (đọc là “không xx”) cho phủ định của nó: ¬x\lnot x đúng khi xx sai và ngược lại. Mỗi biến xx và phủ định ¬x\lnot x của nó được gọi là các literal. Một mệnh đề là một điều kiện dạng a∨ba \lor b (đọc là “aa hoặc bb”), với aa, bb là hai literal; mệnh đề được thỏa khi ít nhất một trong hai literal đúng.

Ví dụ với ba biến x1,x2,x3x_1, x_2, x_3 và ba mệnh đề

x1∨x2,¬x1∨x3,¬x2∨¬x3,x_1 \lor x_2, \qquad \lnot x_1 \lor x_3, \qquad \lnot x_2 \lor \lnot x_3,

cách gán x1x_1 đúng, x2x_2 sai, x3x_3 đúng thỏa cả ba mệnh đề.

Cách dễ nghĩ ra nhất là thử hết 2n2^n cách gán, mỗi cách kiểm mm mệnh đề, tốn O(2n⋅m)O(2^n \cdot m). Với n=30n = 30 đã cần hơn 10910^9 cách gán, nên chỉ dùng được khi nn rất nhỏ. Điểm đặc biệt của 2-SAT là mỗi mệnh đề chỉ có hai literal, và nhờ vậy có cách giải tuyến tính bằng đồ thị.

IIĐồ thị kéo theo

Mệnh đề a∨ba \lor b có nghĩa là: nếu aa sai thì bb phải đúng, và nếu bb sai thì aa phải đúng. Viết bằng ký hiệu kéo theo ⇒\Rightarrow:

a∨b⟺(¬a⇒b) vaˋ (¬b⇒a).a \lor b \quad \Longleftrightarrow \quad (\lnot a \Rightarrow b) \ \text{và} \ (\lnot b \Rightarrow a).

Ta dựng đồ thị kéo theo: mỗi literal là một đỉnh, nên có 2n2n đỉnh; mỗi mệnh đề a∨ba \lor b thêm hai cung ¬a→b\lnot a \to b và ¬b→a\lnot b \to a. Một cung u→vu \to v nghĩa là “nếu uu đúng thì vv đúng”. Đi theo một đường u→⋯→vu \to \dots \to v, các câu “nếu… thì…” nối tiếp nhau, nên nếu uu đúng thì mọi literal trên đường, kể cả vv, đều phải đúng.

Trong code, biến xx (đánh số từ 00) có hai đỉnh: literal xx là đỉnh 2x2x, literal ¬x\lnot x là đỉnh 2x+12x + 1. Hai đỉnh này chỉ khác nhau ở bit cuối, nên phủ định của đỉnh uu là đỉnh u⊕1u \oplus 1, viết trong C++ là u ^ 1. Hàm thêm một mệnh đề chỉ có hai dòng:

C++
void clause(ll a, ll b) {
    g[a ^ 1].push_back(b);
    g[b ^ 1].push_back(a);
}

Độ phức tạp: O(1)O(1) cho mỗi mệnh đề, cả đồ thị có 2n2n đỉnh và 2m2m cung.

Hỏi nhanh

Mệnh đề x1∨¬x2x_1 \lor \lnot x_2 được thêm vào đồ thị kéo theo.

Hai cung nào được thêm?

Trong mô phỏng dưới đây, hàng trên là các literal xkx_k, hàng dưới là ¬xk\lnot x_k. Mỗi bước thêm hai cung của một mệnh đề; bước cuối tô các thành phần liên thông mạnh và đọc ra cách gán, sẽ nói ở mục sau.

Đồ thị kéo theo của các mệnh đề.

IIIKiểm tra có nghiệm và dựng cách gán

Nhắc lại từ bài 3.9: hai đỉnh liên thông mạnh khi mỗi đỉnh đi tới được đỉnh kia, và quan hệ này chia các đỉnh thành các thành phần liên thông mạnh.

Nếu xx và ¬x\lnot x nằm trong cùng một thành phần liên thông mạnh, thì có đường từ xx tới ¬x\lnot x và có đường từ ¬x\lnot x tới xx. Gán xx đúng thì ¬x\lnot x phải đúng, tức xx sai; gán xx sai thì ¬x\lnot x đúng, kéo theo xx đúng. Cả hai cách đều mâu thuẫn, nên các mệnh đề không có nghiệm. Điều bất ngờ là chiều ngược lại cũng đúng: nếu không biến nào nằm cùng thành phần với phủ định của nó, thì luôn có nghiệm, và dựng được ngay.

Thuật toán Kosaraju ở bài 3.9 đánh số các thành phần theo thứ tự tô pô: nếu có cung từ thành phần mang số ii tới thành phần mang số jj khác nó thì i<ji < j. Cách gán là: với mỗi biến xx, chọn literal nằm ở thành phần có số lớn hơn, tức là đứng sau trong thứ tự tô pô. Nghĩa là xx đúng khi số thành phần của xx lớn hơn số thành phần của ¬x\lnot x, và sai trong trường hợp ngược lại.

Chứng minh

Ký hiệu c(u)c(u) là số thành phần của đỉnh uu. Mọi cung u→vu \to v đều có c(u)≤c(v)c(u) \le c(v): cùng thành phần thì bằng nhau, khác thành phần thì theo thứ tự tô pô.

Giả sử có một mệnh đề a∨ba \lor b không được thỏa, tức là aa và bb đều được gán sai. Theo cách gán, ¬a\lnot a được chọn nên c(¬a)>c(a)c(\lnot a) > c(a), và ¬b\lnot b được chọn nên c(¬b)>c(b)c(\lnot b) > c(b). Mệnh đề này cho hai cung ¬a→b\lnot a \to b và ¬b→a\lnot b \to a, nên c(¬a)≤c(b)c(\lnot a) \le c(b) và c(¬b)≤c(a)c(\lnot b) \le c(a). Ghép lại:

c(¬a)≤c(b)<c(¬b)≤c(a)<c(¬a),c(\lnot a) \le c(b) < c(\lnot b) \le c(a) < c(\lnot a),

vô lý. Vậy mọi mệnh đề đều được thỏa.

Mô phỏng dưới đây là một hệ không có nghiệm: x1x_1 kéo theo cả x3x_3 lẫn ¬x3\lnot x_3, còn ¬x1\lnot x_1 kéo theo cả x2x_2 lẫn ¬x2\lnot x_2. Bạn có thể sửa một mệnh đề để hệ có nghiệm:

Một hệ vô nghiệm.

Ví dụCấu Hình Tùy Chọn

Đề. Có m≤105m \le 10^5 tùy chọn, mỗi tùy chọn bật hoặc tắt, và n≤105n \le 10^5 yêu cầu. Mỗi yêu cầu gồm hai điều kiện dạng + x (tùy chọn xx bật) hoặc - x (tùy chọn xx tắt), và cần ít nhất một trong hai điều kiện đúng. In + hoặc - cho từng tùy chọn, hoặc IMPOSSIBLE nếu không có cách nào.

Lời giải. Mỗi yêu cầu đúng là một mệnh đề a∨ba \lor b. Tùy chọn xx (đánh số từ 11) bật là đỉnh 2(x−1)2(x - 1), tắt là đỉnh 2(x−1)+12(x - 1) + 1. Ta dựng đồ thị kéo theo cùng đồ thị ngược của nó, chạy Kosaraju như bài 3.9: lần duyệt thứ nhất ghi thứ tự xong, lần thứ hai duyệt đồ thị ngược theo thứ tự xong giảm dần và đánh số thành phần từ 11. Sau đó kiểm từng biến, rồi đọc cách gán theo quy tắc ở trên. Đồ thị kéo theo có thể có hàng triệu cung, nên danh sách kề lưu kiểu int cho đỡ tốn bộ nhớ.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, m, k, vis[N], comp[N];
vector<int> g[N], rg[N], ord;

void clause(ll a, ll b) {
    g[a ^ 1].push_back(b);
    g[b ^ 1].push_back(a);
    rg[b].push_back(a ^ 1);
    rg[a].push_back(b ^ 1);
}

void dfs1(ll u) {
    vis[u] = 1;
    for (int v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

void dfs2(ll u) {
    comp[u] = k;
    for (int v : rg[u])
        if (!comp[v]) dfs2(v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= n; i++) {
        char s1, s2;
        ll x1, x2;
        cin >> s1 >> x1 >> s2 >> x2;
        clause(2 * (x1 - 1) + (s1 == '-'), 2 * (x2 - 1) + (s2 == '-'));
    }
    for (ll u = 0; u < 2 * m; u++)
        if (!vis[u]) dfs1(u);
    for (ll i = 2 * m - 1; i >= 0; i--)
        if (!comp[ord[i]]) {
            k++;
            dfs2(ord[i]);
        }
    for (ll x = 0; x < m; x++)
        if (comp[2 * x] == comp[2 * x + 1]) {
            cout << "IMPOSSIBLE\n";
            return 0;
        }
    for (ll 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: O(n+m)O(n + m), vì Kosaraju duyệt mỗi đỉnh và mỗi cung hai lần; với n=m=105n = m = 10^5 cần khoảng 1,2⋅1061{,}2 \cdot 10^6 phép tính.

Hỏi nhanh

Một bạn viết chương trình như ví dụ trên, nhưng đọc cách gán với dấu so sánh ngược lại: tùy chọn bật khi số thành phần của literal “bật” nhỏ hơn số thành phần của literal “tắt”. Với đúng một yêu cầu + 1 + 1, tức là ép tùy chọn 11 phải bật, chương trình in ra -.

Vì sao chương trình sai?

IVDịch các ràng buộc khác thành mệnh đề

Nhiều bài không cho sẵn mệnh đề, mà cho các ràng buộc giữa hai biến. Mỗi ràng buộc như vậy thường dịch được thành một hoặc hai mệnh đề:

  • Ép aa đúng: mệnh đề a∨aa \lor a, cho cung ¬a→a\lnot a \to a (nếu aa sai thì aa phải đúng).
  • aa bằng bb: hai mệnh đề a∨¬ba \lor \lnot b và ¬a∨b\lnot a \lor b.
  • aa khác bb: hai mệnh đề a∨ba \lor b và ¬a∨¬b\lnot a \lor \lnot b.
  • Không được cùng đúng: mệnh đề ¬a∨¬b\lnot a \lor \lnot b.

Hỏi nhanh

Hai biến aa và bb phải nhận giá trị khác nhau: một biến đúng thì biến kia sai.

Cặp mệnh đề nào diễn tả đúng ràng buộc này?

Ví dụMạng Phép Toán

Đề. Có N≤1000N \le 1000 biến nhận giá trị 00 hoặc 11 và M≤106M \le 10^6 ràng buộc dạng Xa op Xb=cX_a \ \mathrm{op}\ X_b = c, với phép toán là AND, OR hoặc XOR và cc là 00 hoặc 11. Hỏi có cách gán thỏa mọi ràng buộc không.

Lời giải. Coi giá trị 11 là đúng. Mỗi ràng buộc dịch thành một hoặc hai mệnh đề theo bảng trên:

  • AND bằng 11: ép cả hai đúng. AND bằng 00: không được cùng đúng.
  • OR bằng 11: a∨ba \lor b. OR bằng 00: ép cả hai sai.
  • XOR bằng 11: aa khác bb. XOR bằng 00: aa bằng bb.

Sau đó dựng đồ thị kéo theo trên 2N2N đỉnh và kiểm như ví dụ trước. Mỗi ràng buộc cho tới hai mệnh đề, nên đồ thị có tới 4⋅1064 \cdot 10^6 cung, cộng thêm chừng ấy cung ở đồ thị ngược.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2005;
ll n, m, k, vis[N], comp[N];
vector<int> g[N], rg[N], ord;

void clause(ll a, ll b) {
    g[a ^ 1].push_back(b);
    g[b ^ 1].push_back(a);
    rg[b].push_back(a ^ 1);
    rg[a].push_back(b ^ 1);
}

void dfs1(ll u) {
    vis[u] = 1;
    for (int v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

void dfs2(ll u) {
    comp[u] = k;
    for (int v : rg[u])
        if (!comp[v]) dfs2(v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 0; i < m; i++) {
        ll a, b, c;
        string op;
        cin >> a >> b >> c >> op;
        ll 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);
        }
    }
    for (ll u = 0; u < 2 * n; u++)
        if (!vis[u]) dfs1(u);
    for (ll i = 2 * n - 1; i >= 0; i--)
        if (!comp[ord[i]]) {
            k++;
            dfs2(ord[i]);
        }
    bool ok = 1;
    for (ll 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

Độ phức tạp: O(N+M)O(N + M); với M=106M = 10^6 cần khoảng 1,6⋅1071{,}6 \cdot 10^7 phép tính để dựng và duyệt cả hai đồ thị.

Với đồ thị có hàng triệu cung, cách lưu danh sách kề ảnh hưởng nhiều tới tốc độ:

Thành phần mạnh của đồ thị 400 nghìn đỉnh, 2 triệu cung.vector<int> g[N]253 ms · gấp 6,3 lầnmảng cung liền nhau40 ms
Thành phần mạnh của đồ thị 400 nghìn đỉnh, 2 triệu cung, hai cách lưu cung.

Lưu các cung trong một mảng liền nhau (đếm bậc của từng đỉnh, cộng dồn để biết chỗ bắt đầu, rồi điền cung vào) nhanh hơn vector<int> g[N] khoảng 66 lần, tính cả thời gian dựng đồ thị. Khi đọc được hết mệnh đề trước rồi mới chạy, và giới hạn thời gian chặt, nên cân nhắc cách lưu này.

Sử dụng kiến thức này cũng có thể giải bài có một biến nhận giá trị nguyên ff từ 11 tới KK, chẳng hạn mức công suất. Dùng KK literal “f≥kf \ge k” với k=1,2,…,Kk = 1, 2, \dots, K, nối thành chuỗi kéo theo “f≥k+1f \ge k + 1” ⇒\Rightarrow “f≥kf \ge k”. Một điều kiện như “chọn trạm ii thì li≤f≤ril_i \le f \le r_i” khi đó thành hai mệnh đề: chọn trạm ii kéo theo “f≥lif \ge l_i”, và chọn trạm ii kéo theo phủ định của “f≥ri+1f \ge r_i + 1”.

VChọn một trong hai phương án có xung đột

Dạng hay gặp nhất của 2-SAT: mỗi đối tượng ii phải chọn đúng một trong hai phương án, phương án 00 hoặc phương án 11, và một số cặp phương án của hai đối tượng khác nhau xung đột, không được chọn cùng lúc. Ta dùng một biến cho mỗi đối tượng: literal “đối tượng ii chọn phương án pp”. Với mỗi cặp xung đột “đối tượng ii chọn pp” và “đối tượng jj chọn qq”, thêm mệnh đề cấm: không (ii chọn pp) hoặc không (jj chọn qq). Duyệt mọi cặp đối tượng và mọi cặp phương án tạo ra O(n2)O(n^2) mệnh đề, đủ dùng khi nn cỡ vài nghìn.

Câu hỏi nhanh dành cho tài khoản Pro

Mở khóa bằng tài khoản Pro

Ví dụCờ Trên Trục Số

Đề. Có N≤1000N \le 1000 lá cờ, cờ thứ ii đặt ở tọa độ XiX_i hoặc YiY_i. Mọi cặp cờ phải cách nhau ít nhất DD. In Yes cùng một cách đặt, hoặc in No nếu không đặt được.

Lời giải. Literal 2i+p2i + p nghĩa là “cờ ii đặt ở phương án pp”, với phương án 00 là XiX_i và phương án 11 là YiY_i; literal 2i+12i + 1 đúng là phủ định của literal 2i2i, đúng như cách đánh số ở trên. Với mỗi cặp cờ i<ji < j và mỗi cặp phương án (p,q)(p, q) cách nhau dưới DD, thêm mệnh đề cấm trên phủ định của cả hai lựa chọn. Có tới 4(N2)≈2⋅1064 \binom{N}{2} \approx 2 \cdot 10^6 mệnh đề, rồi giải 2-SAT như các ví dụ trước; cờ ii đặt ở XiX_i khi số thành phần của đỉnh 2i2i lớn hơn của đỉnh 2i+12i + 1.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2005;
ll n, d, k, vis[N], comp[N], pos[N][2];
vector<int> g[N], rg[N], ord;

void clause(ll a, ll b) {
    g[a ^ 1].push_back(b);
    g[b ^ 1].push_back(a);
    rg[b].push_back(a ^ 1);
    rg[a].push_back(b ^ 1);
}

void dfs1(ll u) {
    vis[u] = 1;
    for (int v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

void dfs2(ll u) {
    comp[u] = k;
    for (int v : rg[u])
        if (!comp[v]) dfs2(v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> d;
    for (ll i = 0; i < n; i++) cin >> pos[i][0] >> pos[i][1];
    for (ll i = 0; i < n; i++)
        for (ll j = i + 1; j < n; j++)
            for (ll p = 0; p < 2; p++)
                for (ll q = 0; q < 2; q++)
                    if (llabs(pos[i][p] - pos[j][q]) < d) clause((2 * i + p) ^ 1, (2 * j + q) ^ 1);
    for (ll u = 0; u < 2 * n; u++)
        if (!vis[u]) dfs1(u);
    for (ll i = 2 * n - 1; i >= 0; i--)
        if (!comp[ord[i]]) {
            k++;
            dfs2(ord[i]);
        }
    for (ll i = 0; i < n; i++)
        if (comp[2 * i] == comp[2 * i + 1]) {
            cout << "No\n";
            return 0;
        }
    cout << "Yes\n";
    for (ll 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

Độ phức tạp: O(N2)O(N^2) cho việc tạo mệnh đề và chạy Kosaraju; với N=1000N = 1000 cần khoảng 10710^7 phép tính.

Sử dụng kiến thức này cũng có thể giải bài: mỗi sự kiện chọn tổ chức nghi lễ ở đầu hoặc ở cuối khoảng thời gian của nó, và hai nghi lễ không được chồng lên nhau. Hai phương án xung đột khi hai khoảng thời gian giao nhau. Khi đề hỏi khoảng cách DD lớn nhất có thể (thay vì cho trước DD), ta chặt nhị phân trên DD (bài 0.6) và mỗi bước kiểm bằng 2-SAT.

Bài nâng cao

Xem 2 bài nâng cao

Ví dụTrung Vị Trên Ba Hàng

Đề. Có n≤500n \le 500 biến a1,…,ana_1, \dots, a_n, mỗi biến nhận giá trị −1-1 hoặc 11, và một bảng 33 hàng, nn cột. Mỗi ô ghi ii hoặc −i-i, nghĩa là ô đó có giá trị aia_i hoặc −ai-a_i. Hỏi có cách chọn các aia_i để trong mọi cột, trung vị của ba giá trị (giá trị đứng giữa sau khi sắp xếp) bằng 11 không. Có nhiều bộ dữ liệu.

Lời giải. Coi ai=1a_i = 1 là biến xix_i đúng. Ô ghi ii có giá trị 11 khi literal xix_i đúng, ô ghi −i-i có giá trị 11 khi literal ¬xi\lnot x_i đúng. Như vậy mỗi ô ứng với một literal, và ô có giá trị 11 đúng khi literal của nó đúng.

Ba giá trị chỉ là −1-1 hoặc 11, nên trung vị bằng 11 khi và chỉ khi có ít nhất hai giá trị bằng 11. Điều kiện “ít nhất hai trong ba” nghe như cần ba literal, nhưng nó tương đương với “trong mỗi cặp ô của cột, có ít nhất một ô bằng 11”: nếu có ít nhất hai ô bằng 11 thì cặp nào cũng chứa một ô như vậy; nếu có nhiều nhất một ô bằng 11 thì hai ô còn lại tạo thành một cặp toàn −1-1. Vậy mỗi cột cho đúng ba mệnh đề hai vế, mỗi cặp ô một mệnh đề, và bài toán thành 2-SAT với 3n3n mệnh đề.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005;
ll t, n, k, vis[N], comp[N], c[3][N];
vector<int> g[N], rg[N], ord;

void clause(ll a, ll b) {
    g[a ^ 1].push_back(b);
    g[b ^ 1].push_back(a);
    rg[b].push_back(a ^ 1);
    rg[a].push_back(b ^ 1);
}

void dfs1(ll u) {
    vis[u] = 1;
    for (int v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

void dfs2(ll u) {
    comp[u] = k;
    for (int v : rg[u])
        if (!comp[v]) dfs2(v);
}

ll lit(ll v) {
    return v > 0 ? 2 * (v - 1) : 2 * (-v - 1) + 1;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> t;
    while (t--) {
        cin >> n;
        for (ll u = 0; u < 2 * n; u++) g[u].clear(), rg[u].clear(), vis[u] = comp[u] = 0;
        ord.clear();
        k = 0;
        for (ll r = 0; r < 3; r++)
            for (ll j = 0; j < n; j++) cin >> c[r][j];
        for (ll j = 0; j < n; j++) {
            ll a = lit(c[0][j]), b = lit(c[1][j]), d = lit(c[2][j]);
            clause(a, b), clause(a, d), clause(b, d);
        }
        for (ll u = 0; u < 2 * n; u++)
            if (!vis[u]) dfs1(u);
        for (ll i = 2 * n - 1; i >= 0; i--)
            if (!comp[ord[i]]) {
                k++;
                dfs2(ord[i]);
            }
        bool ok = 1;
        for (ll x = 0; x < n; x++)
            if (comp[2 * x] == comp[2 * x + 1]) ok = 0;
        cout << (ok ? "YES" : "NO") << '\n';
    }
}
2
2
1 2
1 -2
-1 2
2
1 -1
1 -1
2 2

Độ phức tạp: O(n)O(n) cho mỗi bộ dữ liệu, vì có 2n2n đỉnh và 6n6n cung; với n=500n = 500 cần khoảng 10410^4 phép tính mỗi bộ.

Ví dụKết Quả Bắt Buộc

Đề. Có N≤1000N \le 1000 đề xuất, mỗi đề xuất nhận kết quả Y hoặc N, và M≤4000M \le 4000 yêu cầu dạng “đề xuất BB nhận XX hoặc đề xuất CC nhận YY”. Nếu không có cách nào thỏa mọi yêu cầu, in IMPOSSIBLE. Ngược lại, với mỗi đề xuất in Y nếu mọi cách thỏa đều cho nó Y, in N nếu mọi cách thỏa đều cho nó N, và in ? nếu cả hai kết quả đều có thể.

Lời giải. Mỗi yêu cầu là một mệnh đề. Bài này không chỉ hỏi một cách gán, mà hỏi một literal có bắt buộc đúng hay không. Ta có thể thấy rằng, khi các mệnh đề có nghiệm:

  • literal ll bắt buộc đúng khi và chỉ khi có đường từ ¬l\lnot l tới ll trong đồ thị kéo theo.

Nếu có đường đó thì gán ll sai sẽ kéo theo ll đúng, mâu thuẫn, nên ll phải đúng. Chiều ngược lại cần một lập luận dài hơn (xem ô Chứng minh). Với mỗi đề xuất xx, ta kiểm hai đường: từ “xx nhận N” tới “xx nhận Y” thì in Y; từ “xx nhận Y” tới “xx nhận N” thì in N; không có đường nào thì in ?. Nếu có cả hai đường thì xx và phủ định của nó nằm cùng một thành phần liên thông mạnh, các yêu cầu không có nghiệm.

Đồ thị chỉ có 2N=20002N = 2000 đỉnh và 2M=80002M = 8000 cung, nên ta duyệt riêng từ từng đỉnh, không cần tìm thành phần liên thông mạnh.

Chứng minh

Giả sử các mệnh đề có nghiệm và không có đường từ ll tới ¬l\lnot l. Ta dựng một nghiệm có ll đúng. Gọi RR là tập các literal tới được từ ll, và gán đúng mọi literal trong RR.

RR không chứa một literal cùng phủ định của nó. Đồ thị kéo theo có tính đối xứng: mỗi cung u→vu \to v đi cùng cung ¬v→¬u\lnot v \to \lnot u, vì hai cung của một mệnh đề là như vậy. Nếu RR chứa cả yy lẫn ¬y\lnot y, thì có đường l→¬yl \to \lnot y, nên có đường đối xứng y→¬ly \to \lnot l; nối với đường l→yl \to y ta được đường từ ll tới ¬l\lnot l, trái giả thiết.

Mọi mệnh đề đều thỏa được. Xét mệnh đề a∨ba \lor b. Nếu aa bị gán sai vì ¬a\lnot a thuộc RR, thì cung ¬a→b\lnot a \to b cho bb thuộc RR, nên bb đúng; tương tự nếu bb bị gán sai. Mệnh đề có một literal được gán đúng thì đã thỏa. Các mệnh đề còn lại chỉ chứa những biến chưa được gán; chúng là một phần của các mệnh đề ban đầu nên được thỏa bởi chính một nghiệm ban đầu, giữ nguyên giá trị của các biến chưa gán đó.

Vậy có một nghiệm với ll đúng, tức là ¬l\lnot l không bắt buộc đúng. Lật lại: ll bắt buộc đúng thì phải có đường từ ¬l\lnot l tới ll.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2005;
ll n, m, vis[N];
vector<ll> g[N];

void clause(ll a, ll b) {
    g[a ^ 1].push_back(b);
    g[b ^ 1].push_back(a);
}

bool reach(ll s, ll t) {
    for (ll u = 0; u < 2 * n; u++) vis[u] = 0;
    vector<ll> st = {s};
    vis[s] = 1;
    while (!st.empty()) {
        ll u = st.back();
        st.pop_back();
        if (u == t) return 1;
        for (ll v : g[u])
            if (!vis[v]) {
                vis[v] = 1;
                st.push_back(v);
            }
    }
    return 0;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        ll b, c;
        char x, y;
        cin >> b >> x >> c >> y;
        clause(2 * (b - 1) + (x == 'N'), 2 * (c - 1) + (y == 'N'));
    }
    string res;
    for (ll i = 0; i < n; i++) {
        bool yes = reach(2 * i + 1, 2 * i), no = reach(2 * i, 2 * i + 1);
        if (yes && no) {
            cout << "IMPOSSIBLE\n";
            return 0;
        }
        res += yes ? 'Y' : no ? 'N' : '?';
    }
    cout << res << '\n';
}
3 4
1 Y 2 Y
1 Y 2 N
2 N 3 Y
2 N 3 N

Độ phức tạp: O(N⋅(N+M))O(N \cdot (N + M)), vì mỗi đề xuất duyệt đồ thị hai lần; với N=1000N = 1000 và M=4000M = 4000 cần khoảng 2⋅1072 \cdot 10^7 phép tính.

Khi đi thi

  • Gặp “mỗi thứ chọn một trong hai”, “ít nhất một trong hai điều kiện”, hay ràng buộc giữa từng cặp đối tượng thì nghĩ tới 2-SAT. Viết sẵn hàm thêm mệnh đề, với phủ định là ^ 1.
  • Ép một literal aa đúng bằng mệnh đề a∨aa \lor a; bằng nhau, khác nhau, AND, OR, XOR đều là một hoặc hai mệnh đề. Nên thử từng cách dịch trên ví dụ nhỏ trước khi chạy cả bài.
  • Đọc cách gán đúng chiều với thuật toán đang dùng: với Kosaraju, xx đúng khi số thành phần của xx lớn hơn của ¬x\lnot x; với Tarjan thì ngược lại. Khi số biến tới 10610^6, DFS đệ quy có thể tràn bộ nhớ ngăn xếp, nên cân nhắc viết DFS bằng ngăn xếp tự quản lý.

Tóm tắt

  • Mệnh đề a∨ba \lor b thành hai cung ¬a→b\lnot a \to b và ¬b→a\lnot b \to a trên đồ thị 2n2n đỉnh.
  • Không có nghiệm khi và chỉ khi có biến xx nằm cùng thành phần liên thông mạnh với ¬x\lnot x.
  • Cách gán: chọn literal ở thành phần đứng sau theo thứ tự tô pô.
  • Một literal ll bắt buộc đúng khi có đường từ ¬l\lnot l tới ll.
  • Xung đột giữa các cặp phương án thành mệnh đề cấm; cần tối ưu một ngưỡng thì chặt nhị phân rồi kiểm bằng 2-SAT.

Bài tập

Bài tập ngoài

Chọn một trong hai phương án

VInnokenty and a Football LeagueCodeforces

Đọc thêm