Doby's Lab

2-SAT 연구일지 본문

PS/Study Note

2-SAT 연구일지

도비(Doby) 2022. 4. 30. 11:21

2-SAT에 대하여 공부해봅시다.

 

2-Satisfiability의 약자로 boolean variable들의 충족 가능성을 묻는 문제입니다.

 

BOJ 2-SAT 3(11280), 2-SAT 4(11281)를 기반으로 연구합니다.

 

공부하는 데에 도움이 된 블로그 링크들

1(https://blog.naver.com/kks227/220803009418)

2(https://everenew.tistory.com/140)


22.04.03 연구일지

CNF(Conjunctive Normal Form) 식을 true가 될 수 있게 boolean variable의 조합이 있는지 조합이 있다면 어떤 조합으로 이루어지는지 알아내는 알고리즘입니다.

2-SAT에서 CNF는 2-CNF로 이는 한 Clause당 boolean variable이 최대 2개가 있다는 뜻입니다.

 

한 Clause안에서 boolean variable들끼리는 OR연산(논리합)이 되어있으며 Clause끼리는 AND연산(논리곱)으로 이루어집니다.

f = (x1 | ~x2) & (x2 | ~x3) & (x3 | ~x1) & (~x4 | ~x2)

 

&연산에 의해 모든 Clause는 true값을 내놓아야 합니다.

즉, 2-SAT 문제는 각 Clause의 변수들 중 적어도 하나가 true가 되게 하는 것이 문제를 푸는 방향이 됩니다.

 

OR이 true가 되는 조건은

1) x1 = true, x2 = true

2) x1 = true, x2 = false

3) x1 = false, x2 = true

가 있습니다.

 

두 variable 중에 적어도 하나만 true를 내놓는다면?

논리합이기 때문에 두 변수 모두 다 true가 아니어도 됩니다.

즉, 이렇게 정리할 수 있습니다.

 

✔️2-SAT 핵심 아이디어(1): 각 Clause에서 true를 내놓으려면?

~x1 -> x2: x1이 false라면, x2가 true여야 한다.

~x2 -> x1: x2가 false라면, x1이 true여야 한다.

 

+ 논리학(Logics)에서도 위 내용은 논리적 동치라고 나와있습니다.

(p V q) (~ p -> q)

+ (p V q)는 교환 법칙이 성립함으로 (~q -> p)를 도출할 수 있습니다.

 

그러면 아까 위의 식에서 다음 true propostion 조건문들을 도출할 수 있습니다.

~x1 -> ~x2

x2 -> x1

~x2 -> ~x3

x3 -> x2

~x3 -> ~x1

x1 -> x3

x4 -> ~x2

x2 -> ~x4

 

이들을 삼단논법을 이용해서 추론하여 그래프로 나타낼 수 있습니다.

Skew-Symmatric Graph (반대칭 그래프)

다음과 같이 그래프로 나타내면 SCC가 형성되어있는 것을 확인할 수 있습니다. 

 

모순

✔️2-SAT 핵심 아이디어(2): 2-SAT이 성립하지 않는 순간, 모순

 

위 그림에는 없지만 추론하는 과정에서 같은 SCC안에 ~p와 p가 같이 들어있다면, 이건 모순을 의미합니다.

같은 SCC안에 들어있다는 것은 함축하였을 때, ~p -> p와 p -> ~p임을 의미하기 때문입니다.

 

둘 중 하나의 명제가 좌변이 false이고, 우변이 true라면 이는 성립하겠지만 그 반대는 성립하지 않기 때문에 모순입니다.

>> 이와 관련에서 무슨 말인지 밑에서 다루겠습니다. (명제 논리, 조건문)

 

즉, 모순이 없다면 식 f에서 true값을 가져올 수 있는 boolean variable의 조합이 있다는 뜻입니다.

 

명제 논리, 조건문

✔️2-SAT 핵심 아이디어(3): 명제 논리에 따른 조건문이 참 혹은 거짓이 되는 경우

명제 논리에 따르면 조건문은 아래 표와 같은 경우에서 참임을 의미합니다.

p q p -> q
T F F
F F T
T T T
F T T

p가 참일 때, q가 거짓인 경우를 제외하면 모든 경우에서 참입니다.

>> 즉, SCC를 구성하는 노드 중 하나라도 참이 있다면 해당 SCC는 모두 참이어야 합니다.

 

여기까지의 지식을 가지고서 2-SAT을 만족하는 조합이 있는지 없는지를 찾을 수 있습니다.

 

이를 코드로 표현하기 위해 모델링하려면

  1. 각 Clause에서 두 가지 명제를 도출해낸다.
  2. 도출해낸 명제들을 가지고, 그래프를 만든다.
  3. 그래프에서 SCC를 구하고, 어떤 한 변수와 그 변수의 NOT 변수가 같은 SCC에 없다면 이는 성립함을 의미한다.

여기까지의 지식을 가지고서 2-SAT 3 (11280)을 풀 수 있습니다.

+변수에 대해서 변수 그 자체는 2를 곱하여 짝수로 나타내고, NOT 변수는 2를 곱하여 1을 더해주었습니다.

#include <iostream>
#include <vector>
#include <stack>
#include <algorithm>
#define MAX 20002
using namespace std;

vector<vector<int>> SCC;
vector<int> adj[MAX];
int visitedOrder[MAX];
int sccNum[MAX];
stack<int> s;
int order = 0, sccCnt = 0;
int n, m;

int dfs(int now){
    int parent = visitedOrder[now] = ++order;
    s.push(now);
    
    for(int i = 0; i < adj[now].size(); i++){
        int next = adj[now][i];
        
        if(visitedOrder[next] == -1){
            parent = min(parent, dfs(next));
        }
        else if(sccNum[next] == -1){
            parent = min(parent, visitedOrder[next]);
        }
    }
    
    if(visitedOrder[now] == parent){
        vector<int> scc;
        while(1){
            int temp = s.top(); s.pop();
            scc.push_back(temp);
            sccNum[temp] = sccCnt;
            if(temp == now) break;
        }
        
        SCC.push_back(scc);
        sccCnt++;
    }
    
    return parent;
}

void tarjan(){
    fill(visitedOrder, visitedOrder + MAX, -1);
    fill(sccNum, sccNum + MAX, -1);
    for(int i = 2; i <= 2 * n + 1; i++){
        if(visitedOrder[i] == -1) dfs(i);
    }
}

int main(){
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int a, b; cin >> a >> b;
        //a = 2 * i, NOT a = 2 * i + 1
        
        // 2가지 명제 도출 과정
        int not_a, not_b;
        if(a < 0){ // NOT
            a = -a;
            a *= 2; a++;
            not_a = a - 1;
        }
        else{
            a *= 2;
            not_a = a + 1;
        }
        
        if(b < 0){
            b = -b;
            b *= 2; b++;
            not_b = b - 1;
        }
        else{
            b *= 2;
            not_b = b + 1;
        }
        
        // 그래프 만들기
        adj[not_a].push_back(b);
        adj[not_b].push_back(a);
    }
    
    tarjan();
    
    // 모순이 있는지 확인
    bool flag = 1;
    for(int i = 2; i <= 2 * n; i += 2){
        if(sccNum[i] == sccNum[i + 1]){
            flag = 0; break;
        }
    }
    
    if(flag == 1) cout << 1;
    else cout << 0;
    
    return 0;
}

여기까지 2-SAT이 성립하는지에 대하여 알아보았습니다.

 

✔️2-SAT 핵심 아이디어(4): 2-SAT이 성립한다면 변수들은 어떤 값인지 알 수 있을까?

 

만약 2-SAT이 성립한다면 각 boolean variable들은 어떤 값을 가져야 할까요?

위에서 말했듯이 SCC를 구성하는 변수들은 모두 같은 값을 가져야 합니다.

 

그렇게 되면 SCC를 노드 단위로 보면 됩니다.

아까 위의 그림을 다시 가져와서 보면, {x4} -> {~x1, ~x2, ~x3}로 가고 있습니다.

p q p -> q
T F F
F F T
T T T
F T T

이 표에 의거하여 x4를 false로 두는 것이 명제를 해치지 않기 때문에 좋습니다.

즉, 위상 정렬을 통해 처음 방문한 값들을 false로 두는 것이 좋습니다. ~x4도 indegree가 없기 때문에 ~x4를 false로 두어도 됩니다.

 

>> 변수 혹은 not 변수 중 먼저 방문하는 순서대로 false를 준다는 뜻입니다.

 

✔️2-SAT 핵심 아이디어(5): 위상 정렬을 통한 SCC끼리의 조건문

 

위상 정렬을 확인하는 방법은 Tarjan Algorithm의 특성으로 만들어진 순서를 반대로 뒤집으면 위상 정렬이 됩니다.

즉, SCC에 생긴 순서대로 번호를 매기고, 이를 내림차순으로 정렬하면 됩니다.

>> 이 아이디어에서 좀 놀랬습니다..

왜 위상 정렬이 되는지는 자세한 내용은 Tarjan Algortihm 연구일지를 참고하길 바랍니다.

(https://draw-code-boy.tistory.com/274)

 

+ 예를 들어, ~x1 -> x1의 경로가 있다면 이는 상관없습니다. 이미 ~x1을 방문하면서 ~x1은 false가 되었고, x1은 true가 되었기 때문입니다.

 

여기까지 내용을 인지하고, 코드로 모델링한다면

  1. 각 Clause에서 두 가지 명제를 도출해낸다.
  2. 도출해낸 명제들을 가지고, 그래프를 만든다.
  3. 그래프에서 SCC를 구하고, 어떤 한 변수와 그 변수의 NOT 변수가 같은 SCC에 없다면 이는 성립함을 의미한다.
  4. 성립한다면 각 변수가 어떤 값을 지니는지 알 수 있다.
  5. 각 SCC를 하나의 노드로 보고, 위상 정렬에 의해 제일 늦게 만들어진 SCC를 순서로 노드를 방문하여 만나는 변수에 false값을 준다.

 

여기까지의 지식을 가지고서 2-SAT 4 (11281)을 풀 수 있습니다.

#include <iostream>
#include <vector>
#include <algorithm>
#include <stack>
#define MAX 20002
#define pii pair<int, int>
using namespace std;

vector<vector<int>> SCC;
vector<int> adj[MAX];
stack<int> s;
int visitedOrder[MAX];
int sccNum[MAX];
int order = 0, sccCnt = 0;
int n, m;

bool cmp(pii a, pii b){
    return a.second > b.second;
}

int dfs(int now){
    int minOrder = visitedOrder[now] = ++order;
    s.push(now);
    
    for(int i = 0; i < adj[now].size(); i++){
        int next = adj[now][i];
        
        if(visitedOrder[next] == -1){
            minOrder = min(minOrder , dfs(next));
        }
        else if(sccNum[next] == -1){
            minOrder = min(minOrder, visitedOrder[next]);
        }
    }
    
    if(visitedOrder[now] == minOrder){
        vector<int> scc;
        while(true){
            int temp = s.top(); s.pop();
            sccNum[temp] = sccCnt;
            scc.push_back(temp);
            if(temp == now) break;
        }
        
        SCC.push_back(scc);
        sccCnt++;
    }
    
    return minOrder;
}

void tarjan(){
    fill(visitedOrder, visitedOrder + MAX, -1);
    fill(sccNum, sccNum + MAX, -1);
    
    for(int i = 2; i <= 2 * n + 1; i++){
        if(visitedOrder[i] == -1) dfs(i);
    }
}

int main(){
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int a, b; cin >> a >> b;
        
        int not_a, not_b;
        if(a < 0){
            a = -a; a *= 2; a += 1;
            not_a = a - 1;
        }
        else{
            a *= 2;
            not_a = a + 1;
        }
        
        if(b < 0){
            b = -b; b *= 2; b += 1;
            not_b = b - 1;
        }
        else{
            b *= 2;
            not_b = b + 1;
        }
        
        adj[not_a].push_back(b);
        adj[not_b].push_back(a);
    }
    
    tarjan();
    
    bool flag = 1;
    for(int i = 2; i <= 2 * n; i += 2){
        if(sccNum[i] == sccNum[i + 1]){
            flag = 0; break;
        }
    }
    
    if(flag){
        cout << 1 << '\n';
        vector<pii> satOrder;
        for(int i = 2; i <= 2 * n + 1; ++i){
            //{vertex || ~vertex, vertex's SCCnum}
            satOrder.push_back({i, sccNum[i]});
        }
        
        // SCC가 만들어진 순서를 내림차순으로 정렬해주었다.
        sort(satOrder.begin(), satOrder.end(), cmp);
        
        vector<int> answer(n + 1, -1);
        
        for(int i = 0; i < satOrder.size(); i++){
            int vertex = satOrder[i].first;
            int varIdx = vertex / 2;
            int isTrue = (vertex % 2 == 0);
            if(answer[varIdx] != -1) continue;
            
            // 처음 방문하는 변수에 true이면 false, false이면 true를 준다.
            answer[varIdx] = !isTrue;
        }
        
        for(int i = 1; i < answer.size(); i++){
            cout << answer[i] << ' ';
        }
    }
    else{
        cout << 0 << '\n';
    }
    return 0;
}

결론

여태껏 그래프 알고리즘만 공부하다가 논리학에 관련된 알고리즘을 공부하다 보니 시간을 들여야 했습니다. 물론, SCC를 활용하여 그래프 알고리즘이기도 하지만. 다른 영역의 알고리즘을 건드려야겠다는 생각이 부쩍 들었었는데 한 걸음 나아간 것 같습니다.

 

그리고, 반년 전쯤 알고리즘을 막 시작할 때, '2-SAT'이라는 이름이 멋져 보여서 저런 알고리즘은 엄청 어려울 거 같아서 언젠간 배우고 싶었는데 SCC를 공부하다 보니 자연스럽게 2-SAT을 건드리게 되어 기분이 오묘한 순간이었습니다.

 

아직은 미숙지 된 내용들에 대해 계속 추가적으로 수정하거나 구현을 해보며 익혀야겠습니다.

728x90

'PS > Study Note' 카테고리의 다른 글

Exponentiation By Squaring Code  (0) 2022.05.05
Sparse Table (희소 배열) 연구일지  (0) 2022.05.01
SCC, Tarjan Algorithm 연구 일지  (0) 2022.04.22
Convex Hull (Monotone Chain 기법) 연구 일지  (0) 2022.04.17
SCC 알고리즘 2가지  (0) 2022.04.02