DPLL 演算法(求解k-SAT問題)詳解(C++實現)

chesium發表於2022-03-08

\(\text{By}\ \mathsf{Chesium}\)

DPLL 演算法,全稱為 Davis-Putnam-Logemann-Loveland(戴維斯-普特南-洛吉曼-洛夫蘭德)演算法,是一種完備的,基於回溯(backtracking)的搜尋演算法,用於判定命題邏輯公式(為合取正規化形式)的可滿足性,也就是求解 SAT(布林可滿足性問題)的一種(或者一類)演算法。

SAT 問題簡介

何為布林可滿足性問題?給定一條真值表示式,包含邏輯變數(又稱 變數命題變號原子,用小寫字母 \(a,b,\dots\) 表示)、邏輯與(AND,記為 “\(\wedge\)” )運算子、邏輯或(OR,記為 “\(\vee\)” )運算子以及(NOT,否定,記為“\(\neg\)”)運算子,如:

\[(a\wedge\neg b\wedge(\neg(c\vee d\vee\neg a)\vee(b\wedge\neg d)))\vee(\neg(\neg(\neg b\vee a)\wedge c)\wedge d) \]

是否存在一組對這些變數的賦值(如把所有 \(a\)\(d\) 均賦值為 \(\mathrm{True}\) ,將所有 \(b\)\(c\) 賦值為 \(\mathrm{False}\) ),使得整條式子最終的運算結果為 \(\mathrm{True}\) ?若可以,那麼這個性質被稱為這條邏輯公式的可滿足性(satisfiability),如何快速高效地判斷任意指定邏輯公式的可滿足性是理論電腦科學中的一個重要的問題,也是第一個被證明為NP-完全(NP-complete,NPC)的問題。

暴力方案

對於這個問題,我們能夠很容易地想到一種“暴力”的判定方法:測試這些變數賦值的每種可能的排列方式(如全部賦為 \(\mathrm{True}\) 、其一為 \(\mathrm{True}\) 其他全為 \(\mathrm{False}\) ……),若存在一種賦值排列使得公式的結果為 \(\mathrm{True}\) ,那麼就可以說明這條公式是可滿足的。但很顯然,最壞情況下這種方法需要我們測試 \(2^n\) 種(\(n\) 為變數數)賦值排列,而用於檢查每種賦值排列最終的運算結果也是不可忽略的。因此,隨著公式規模的擴大,這種暴力演算法所需的運算量會呈指數級飛快增長,這是我們不可接受的。

演算法概述

但是根據現有計算複雜度理論,SAT問題是無法在多項式時間複雜度內解決的,DPLL演算法也不例外。

DPLL演算法是一種搜尋演算法,思想與DFS(Depth-first search,深度優先搜尋)十分相似,或者說DPLL演算法本身就屬於DFS的範疇,其類似於上述我們設想的“暴力”演算法:搜尋所有可能的賦值排列。

具體地說,演算法會在公式中選擇一個變數(命題變號),將其賦值為 \(\mathrm{True}\) ,化簡賦值後的公式,如果簡化的公式是可滿足的(遞迴地判斷),那麼原公式也是可滿足的。否則就反過來將該變數賦值為 \(\mathrm{False}\) ,再執行一遍遞迴的判定,若也不能滿足,那麼原公式便是不可滿足的。

這被稱為 分離規則 (splitting rule),因為其將原問題分離為了兩個更加簡單的問題。

概念說明

DPLL演算法求解的是合取正規化(Conjunctive normal form,CNF),這是指形如下式的邏輯公式:

\[(a\vee b\vee\neg c)\wedge (\neg d\vee x_1\vee\neg x_2\vee\dots\vee x_7)\wedge (\neg r\vee v\vee g)\wedge\dots\wedge (a\vee d\vee\neg d) \]

其由多個括號括住部分的邏輯與組成,每一個括號內又是許多變數或變數的否定(邏輯非)的邏輯或組成。可以證明,所有隻包含邏輯與、邏輯或、邏輯非、邏輯蘊含和括號的邏輯公式均可化為等價的合取正規化。下面,我們稱整個正規化為“公式”,稱每個括號裡的部分為該公式的子句(clause),每個子句中的每個變數或其否定為文字(literal)。

可以看出,要使整條公式結果為 \(\mathrm{True}\) ,其所有子句都必須為 \(\mathrm{True}\) ,也就是說,每個子句中都至少有一個文字為 \(\mathrm{True}\) ,這個結論下面會用到。

DPLL 演算法中的化簡步驟實際上就是移除所有在賦值後值為 \(\mathrm{True}\) 的子句,以及所有在賦值後值為 \(\mathrm{False}\) 的文字。

化簡步驟

這兩個化簡步驟是 DPLL 演算法與我們“暴力”演算法的主要區別,它們大大減少了搜尋量,亦即加快了演算法的執行速度。

第一個化簡步驟:單位子句傳播(Unit propagation)

我們稱只含有一個(未賦值)變數的子句為單位子句(unit clause),根據上面的結論,要想讓公式為 \(\mathrm{True}\) ,這個子句必須為 \(\mathrm{True}\) ,即這個變數對應的文字必須被賦值為 \(\mathrm{True}\)

比如下面的這條公式:

\[(a\vee b\vee c\vee\neg d)\wedge(\neg a\vee c)\wedge(\neg c\vee d)\wedge(a) \]

其中最後一個子句就為單位子句,亦即我們要使文字 \((a)\)\(\mathrm{True}\)

然後,我們要依次處理這個變數在其他子句中的出現,如果另一個子句中的一個文字與單位子句中的文字相同,如上面例子中的 \((a\vee b\vee c\vee\neg d)\) 子句,我們知道 \((a)\) 的值必須為 \(\mathrm{True}\) ,所以這個子句也肯定為 \(\mathrm{True}\) ,這意味著這個子句就不會對整個公式產生額外的約束(即 \(b,c,d\) 的取值不會影響該子句的取值),我們完全可以忽略這個子句,那就刪掉它吧。

再考慮上式中第二個子句,其中出現了 \((a)\) 的否定文字,我們知道它不可能為 \(\mathrm{True}\) 了,要讓這個子句的值為 \(\mathrm{True}\) ,只能寄希望於 \(c\) 的取值了,我們完全可以把 \(\neg a\) 刪除(因為有沒有它不影響該子句的取值)。

而第上式中第三個子句不包含 \((a)\) 或其否定的出現,即 \(a\) 的取值不影響這個子句的取值,我們保持其不變即可。

這樣,上述公式便被化簡為了:

\[(c)\wedge(\neg c\vee d)\wedge(a) \]

這個操作就被稱為單位子句傳播

概括:對於所有隻包含一個文字 \(\mathrm{L}\) 的子句,對於公式剩餘部分中的每個子句 \(\mathrm{C}\)

  • \(\mathrm{C}\) 包含 \(\mathrm{L}\)(非否定),則刪除 \(\mathrm{C}\)
  • \(\mathrm{C}\) 包含 \(\neg\mathrm{L}\),則刪除這個 \(\neg\mathrm{L}\)

經過一次操作,我們發現公式中又出現了一個新的單位子句 \((c)\) ,我們可以繼續對其實施一遍單位子句傳播,一直到整個公式中不存在任何一個單位子句對應的變數在其他子句中出現為止。

上式可被化簡為:

\[(c)\wedge(d)\wedge(a) \]

現在即使公式中每個子句都是單位子句,但是其分別對應的變數 \(c,d,a\) 沒有在除單位子句之外的子句中出現了,單位子句傳播已經沒有用了,我們要實施第二個化簡步驟。

第二個化簡步驟:孤立文字消去(Pure literal elimination)

如果一個變數在整個公式中只出現了一次,那麼我們可以將其進行恰當的賦值,使其所在的子句為 \(\mathrm{True}\) 。具體地說,如果其出現的那一次是以否定形式出現的,那麼就將變數賦值為 \(\mathrm{False}\) ,這可使其對應文字為 \(\mathrm{True}\) ,即使其所在子句為 \(\mathrm{True}\) ,反正則將變數賦值為 \(\mathrm{True}\) ,最終也能使其所在的子句為 \(\mathrm{True}\) ,接下來就和上述單位子句傳播中發現子句為 \(\mathrm{True}\) 時的處理方式相同——刪掉這個子句。

一句話概括,就為:刪除所有孤立變數所在的子句

對於以下的公式:

\[(\neg r\vee u)\wedge(r\vee \color{red}{c}\vee\neg u)\wedge(\neg k\vee r)\wedge(\color{blue}{\neg d}\wedge k) \]

其中標紅的變數 \(c\) 在整個公式中只出現了一次,我們可以將其賦值為 \(\mathrm{True}\) 使得其所在的子句 \((r\vee \color{red}{c}\vee\neg u)\)\(\mathrm{True}\) ,我們可以將這個子句刪除。同樣的,標藍的變數 \(d\) 在整個公式中只出現了一次,且是以否定形式出現的,我們可以將其賦值為 \(\mathrm{False}\) ,使其所在子句為 \(\mathrm{True}\) ,我們也可以將其刪除。由此,公式被化簡為了:

\[(\neg r\vee u)\wedge(\neg k\vee r) \]

再來看上面的例子:

\[(c)\wedge(d)\wedge(a) \]

所有三個變數都是孤立出現的,我們可以把這三個子句全部刪除,整個公式就為空了,由此我們能判斷出原公式是可滿足的。

以上就是這兩個化簡步驟。

演算法流程

下面給出 DPLL 演算法的虛擬碼,先前說過,DPLL 演算法實質上是一個深度優先搜尋演算法,所以兩者十分相似。

\[\begin{aligned} &\mathtt{1}\quad \mathtt{\color{red}{Algorithm}}\ \ \mathrm{DPLL}(\mathtt{CNF}\ \ \color{green}{\Phi}):=\\ &\mathtt{2}\quad\qquad \mathtt{\color{red}{do}}\ \ \text{UP}(\color{green}{\Phi})\ \ \mathtt{\color{red}{until}}\ \ \text{It changed nothing}.\\ &\mathtt{3}\quad\qquad \mathtt{\color{red}{do}}\ \ \text{PLE}(\color{green}{\Phi})\ \ \mathtt{\color{red}{until}}\ \ \text{It changed nothing}.\\ &\mathtt{4}\quad\qquad \mathtt{\color{red}{if}}\ \ \color{green}{\Phi}=\varnothing\ \ \mathtt{\color{red}{then}}\\ &\mathtt{5}\quad\qquad\qquad \mathtt{\color{red}{return}}\ \ \mathrm{\color{blue}{true}}.\\ &\mathtt{6}\quad\qquad \mathtt{\color{red}{if}}\ \ \exists L\in\color{green}{\Phi},L=\varnothing\ \ \mathtt{\color{red}{then}}\\ &\mathtt{7}\quad\qquad\qquad \mathtt{\color{red}{return}}\ \ \mathrm{\color{blue}{false}}.\\ &\mathtt{8}\quad\qquad x\leftarrow\mathrm{ChooseVariable}(\color{green}{\Phi})\\ &\mathtt{9}\quad\qquad \mathtt{\color{red}{return}}\ \ \mathrm{DPLL}(\color{green}{\Phi}_{x\to\mathrm{\color{blue}{true}}}) \ \ \mathtt{\color{red}{or}}\ \ \mathrm{DPLL}(\color{green}{\Phi}_{x\to\mathrm{\color{blue}{false}}}) \end{aligned} \]

其中 \(\mathrm{UP}(\Phi)\)\(\mathrm{PLE}(\Phi)\) 分別是指對公式 \(\Phi\) 進行單位子句傳播孤立文字消去\(\mathrm{ChooseVariable}(\Phi)\) 是指在公式 \(\Phi\) 中選取一個變數(未賦值),根據現有的研究,這個選取變數的策略(被稱為啟發函式(heuristic function))會大大影響 DPLL 演算法的執行效率,根據變數選擇策略不同,DPLL 演算法也有許多變種,但這不在我們現在的討論範圍內,作為初學者,我們就讓\(\mathrm{ChooseVariable}(\Phi)\) 直接選擇變數序列中的第一個變數。

\(9\) 行中的 \(\Phi_{x\to\mathrm{true}}\) 是指將公式 \(\Phi\) 中的變數 \(x\) 賦值為 \(\mathrm{True}\),並根據在化簡規則中描述過的方式處理賦值變數(刪除包含其肯定出現的子句,並刪除其否定形式的文字)後的公式, \(\Phi_{x\to\mathrm{false}}\) 也如此,只不過將兩種操作反過來。

可以看出這是個遞迴程式,對於輸入的非空的原始公式 \(\Phi_0\),其在兩種情況下中止:

  • 公式 \(\Phi\) 為空,產生這種情況的原因只可能是:各個子句經過變數的賦值後值必為 \(\mathrm{True}\),不對 \(\Phi\) 中其他變數的賦值產生約束而全被刪除。這意味著原始的 \(\Phi_0\) 經過一部分(當然也可能是全部)變數的賦值後其所有子句的值都恆為 \(\mathrm{True}\)\(\Phi_0\) 是可滿足的。
  • 公式 \(\Phi\) 包含空子句,產生這種情況的原因只可能是:這個子句中所有文字均在經過賦值後值為 \(\mathrm{False}\),因此這些文字均被刪除了,那麼這個子句便不可能值為 \(\mathrm{True}\),公式 \(\Phi\) 是不可滿足的。(這並不代表 \(\Phi_0\) 無法滿足,因為這只是一種可能的賦值排列)

具體實現

接下來,我們就開始著手從零實現一個基礎款(不帶複雜的 \(\mathrm{ChooseVariable}(\Phi)\) 啟發函式)的 DPLL 演算法。

注意到,演算法中涉及到大量的文字刪除和子句刪除操作,而且可能出現在文字列表和子句列表中間的任意位置(即不是簡單地刪除頭或尾),而且處理各個子句、文字時遍歷較多,而無需隨機訪問。我使用了連結串列(Linked list)來儲存我們處理的公式。具體地說,我們使用一個二維連結串列來儲存合取正規化,它可以看作是子句的列表,而每個子句又可看作文字的列表。

每個文字有兩個屬性:變數編號(整數)和是否為否定文字(布林值)。輸入時我們將所有變數識別符號離散化為變數編號。
用二維連結串列來儲存合取正規化

圖1:用二維連結串列來儲存合取正規化

要刪除一個文字時,我們只需將前一個文字的 \(\mathrm{.nxt}\) 指標指向下一個文字,並將下一個文字的 \(\mathrm{.prv}\) 指標指向前一個文字即可,刪除子句同理。

但是,我們發現演算法過程中涉及到 找到特定邏輯變數的所有文字 的操作,如將某個變數賦值時就必須依次處理其所有文字,若只採取上述連結串列的結構,每次處理時就必須遍歷所有子句、文字。我們可以通過再維護一個按變數名索引的二維連結串列,從而實現高效地遍歷任意變數的所有文字。這看上去像是給上面的連結串列結構增加了許多“跳線”。對於合取正規化:

\[(a\vee\neg c\vee d)\wedge(d\vee\neg b\vee c\vee\neg t)\wedge(\neg a\vee b\vee c) \]

我們就可以建立如下圖的結構來儲存:

在二維連結串列的基礎上新增“跳線”以實現更高效的遍歷

圖2:在二維連結串列的基礎上新增“跳線”以實現更高效的遍歷

當然,其中僅僅畫出了部分關鍵的指標結構,具體實現中天藍色的“跳線”也是雙向的,我們也可以通過增加一些額外的指標儲存實現 通過文字找到其所在子句、通過文字找到其對應的文字列表

除了圖中的結構,通過在文字、子句的刪除中維護一個“沒有經過單位子句傳播的單位子句”集合(或列表),以及一個 只有一個對應文字的變數 集合,我們可以不通過遍歷找到所有單位子句和孤立變數以上述兩個化簡步驟。

但是,難題還在後頭:這是個遞迴演算法,涉及到對前幾次歷史版本的回溯。具體地說,在某種賦值(部分)組合下公式不可能滿足,這時我們需要還原剛剛進行的化簡操作和賦值操作,檢查不同的賦值下公式能否滿足,即進入另一個搜尋分支。

如何進行回溯呢?最簡單的就如虛擬碼中的,遞迴時直接通過呼叫函式中引數的複製傳遞複製一份整個公式結構的歷史版本,這聽上去雖然效率不高但實現簡單,但事實上對於包含如此多指標的資料結構,要複製出完整、獨立的一份必然涉及到大量指標的重定向,而這是十分困難且涉及到許多細節的,何況即使實現了,面對較大的遞迴層數,程式會佔用很多記憶體,而且包含大量重複的冗餘部分。

這裡,我採用了一種基於 增量儲存 思想的資料結構。DPLL 演算法可以看作一個在二叉樹上進行 DFS 搜尋的演算法,程式在執行這種遞迴演算法時會在函式(遞迴時就是自身)的呼叫中維護一個堆疊,儲存每次函式呼叫中的區域性變數。我仿照了這種結構,用棧來儲存公式結構在一層層搜尋的賦值中改變的部分。

具體地說,上面 圖2 中的每一個箭頭都是一個“指標棧”,儲存著一系列的指標,標識該指標在遞迴過程中的一系列變化。在每一個搜尋到的公式狀態節點進行化簡、賦值時,我們只訪問、修改棧頂的指標,並用一個集合來標識在本次處理(化簡、賦值)中修改過的指標棧,這些集合又用一個棧來維護。回溯至上一層時遍歷棧頂的集合,將其中所有指標棧的棧頂釋出,從而實現對歷史公式版本的還原。上述資料結構可以看作一個簡單的 部分可持久化連結串列組 ,當然這其中也有許多可供優化的地方。

實現程式碼

下面給出部分核心程式碼,完整程式碼可見:

“指標棧連結串列”實現部分:

template <typename T>
struct node {
  stack<node<T> *> prvPS, nxtPS;
  slist<T> *L;
  T *X = nullptr;

  node(slist<T> *l, T *x = nullptr, node<T> *_prv = nullptr,
       node<T> *_nxt = nullptr) {
    this->L = l;
    if (x != nullptr) this->X = new T(*x);
    this->init_upd(_prv, _nxt);
  }

  void __upd(node<T> *_prv, node<T> *_nxt) {
    if (_prv != nullptr) this->prvPS.push(_prv);
    if (_nxt != nullptr) this->nxtPS.push(_nxt);
  }

  void init_upd(node<T> *_prv, node<T> *_nxt) {
    if (_prv != nullptr)
      while (!this->prvPS.empty()) this->prvPS.pop();
    if (_nxt != nullptr)
      while (!this->nxtPS.empty()) this->nxtPS.pop();
    this->__upd(_prv, _nxt);
  }

  void upd(node<T> *_prv, node<T> *_nxt) {
    if (_prv != nullptr) {
      auto it = this->L->Recorder->ch.top().find(&(this->prvPS));
      if (it == this->L->Recorder->ch.top().end())
        this->L->Recorder->ch.top().insert(&(this->prvPS));
      else
        this->prvPS.pop();
      this->prvPS.push(_prv);
    }
    if (_nxt != nullptr) {
      auto it = this->L->Recorder->ch.top().find(&(this->nxtPS));
      if (it == this->L->Recorder->ch.top().end())
        this->L->Recorder->ch.top().insert(&(this->nxtPS));
      else
        this->nxtPS.pop();
      this->nxtPS.push(_nxt);
    }
  }

  bool isHead() { return this->L->begin() == this; }
  bool isTail() { return this->L->end() == this; }
  node<T> *prev() { return this->prvPS.top(); }
  node<T> *next() { return this->nxtPS.top(); }
};

template <typename T>
struct slist {
  stack<node<T> *> beginPS, endPS;
  rmRecorder<T> *Recorder = nullptr;

  slist() {
    auto primNode = new node<T>(this);
    this->beginPS.push(primNode);
    this->endPS.push(primNode);
  }

  node<T> *begin() { return this->beginPS.top(); }
  node<T> *end() { return this->endPS.top(); }

  void regRec(rmRecorder<T> *rec) { this->Recorder = rec; }

  bool empty() { return this->begin() == this->end(); }

  bool single() {
    if (this->empty()) return false;
    return this->begin()->next() == this->end();
  }

  void add(T x) {
    if (this->empty()) {
      while (!this->beginPS.empty()) this->beginPS.pop();
      this->beginPS.push(new node<T>(this, &x, nullptr, this->end()));
      this->end()->init_upd(this->begin(), nullptr);
    } else {
      auto NewNode = new node<T>(this, &x, this->end()->prev(), this->end());
      this->end()->prev()->init_upd(nullptr, NewNode);
      this->end()->init_upd(NewNode, nullptr);
    }
  }

  void rm(node<T> *nd) {
    if (nd->L != this) return;
    if (nd == this->end()) return;
    if (nd == this->begin()) {
      auto it = this->Recorder->ch.top().find(&this->beginPS);
      if (it == this->Recorder->ch.top().end())
        this->Recorder->ch.top().insert(&this->beginPS);
      else
        this->beginPS.pop();
      this->beginPS.push(nd->next());
    } else {
      nd->prev()->upd(nullptr, nd->next());
      nd->next()->upd(nd->prev(), nullptr);
    }
  }

  T *front() { return this->begin()->X; }

  T *back() { return this->end()->prev()->X; }
};

template <typename T>
struct rmRecorder {
  stack<set<stack<node<T> *> *>> ch;
  int layer = 0;

  rmRecorder() { this->nextLayer(); }

  void nextLayer() {
    this->ch.push(set<stack<node<T> *> *>());
    this->layer++;
  }

  void backtrack() {
    for (auto it = this->ch.top().begin(); it != this->ch.top().end(); it++)
      (*it)->pop();
    this->layer--;
    ch.pop();
  }
};

資料結構部分:

struct Literal {
  llu index;  //
  bool neg;
  CNF *cnf;
  node<Clause> *cl;
  node<Occur> *oc;
  Literal(CNF *_cnf, string s, bool _neg);
  string str();
  void RemoveOccurrence();
};

struct Clause {
  slist<Literal> *lt;
  CNF *cnf;
  Clause(CNF *_cnf);
  string str();
};

struct Occur {
  node<Literal> *lit;
  Occur(node<Literal> *_lit) { this->lit = _lit; }
};

struct AvAtom {
  llu index;
  slist<Occur> *oc;
  CNF *cnf;
  AvAtom(CNF *_cnf, llu i);
};

struct CNF {
  map<string, llu> Dict;
  vector<string> Atoms;
  vector<ll> scheme;
  llu AtomN = 0;
  slist<Clause> CL;
  slist<AvAtom> AVA;
  vector<node<AvAtom> *> avAtoms;
  rmRecorder<Literal> Rec_Literal;
  rmRecorder<Clause> Rec_Clause;
  rmRecorder<Occur> Rec_Occur;
  rmRecorder<AvAtom> Rec_AvAtom;
  stack<list<ll>> Rec_assign;
  CNF() {
    this->CL.regRec(&this->Rec_Clause);
    this->AVA.regRec(&this->Rec_AvAtom);
  }
  void read();
  string str();
  string occurStr();
  string schemeStr();
  void removeLiteral(node<Clause> *cl, node<Literal> *lit);
  void removeClause(node<Clause> *cl);
  ll AssignLiteralIn(node<Clause> *cl, node<Literal> *unit);
  bool PureLiteralAssign();
  bool UnitPropagate();
  void nextLayer();
  void backtrack();
  bool containEmptyClause = false;
  bool DPLL(bool disableSimp);
};

void CNF::removeLiteral(node<Clause> *cl, node<Literal> *lit) {
  cout << "DEL literal \"" << lit->X->str() << "\" in \"" << cl->X->str()
       << "\"" << endl;
  lit->X->RemoveOccurrence();
  cl->X->lt->rm(lit);
}

void CNF::removeClause(node<Clause> *cl) {
  cout << "DEL Clause \"" << cl->X->str() << "\"" << endl;
  for (auto lit = cl->X->lt->begin(); lit != cl->X->lt->end();
       lit = lit->next())
    lit->X->RemoveOccurrence();
  this->CL.rm(cl);
}

void CNF::nextLayer() {
  this->Rec_Literal.nextLayer();
  this->Rec_Clause.nextLayer();
  this->Rec_Occur.nextLayer();
  this->Rec_AvAtom.nextLayer();
  this->Rec_assign.push(list<ll>());
}

void CNF::backtrack() {
  this->Rec_Literal.backtrack();
  this->Rec_Clause.backtrack();
  this->Rec_Occur.backtrack();
  this->Rec_AvAtom.backtrack();
  for (auto it = this->Rec_assign.top().begin();
       it != this->Rec_assign.top().end(); it++)
    this->scheme[*it] = 0;
  this->Rec_assign.pop();
}

演算法主體部分:

ll CNF::AssignLiteralIn(node<Clause> *cl, node<Literal> *unit) {
  this->scheme[unit->X->index] = unit->X->neg ? 2 : 1;
  this->Rec_assign.top().push_back(unit->X->index);
  bool changed = false;
  for (auto it = cl->X->lt->begin(); it != cl->X->lt->end(); it = it->next())
    if (it->X->index == unit->X->index) {
      if (it->X->neg == unit->X->neg) {
        this->removeClause(cl);
        return 1;
      } else {
        this->removeLiteral(cl, it);
        if (cl->X->lt->empty()) {
          this->containEmptyClause = true;
          return 2;
        }
        changed = true;
      }
    }
  return changed ? 1 : 0;
}

bool CNF::UnitPropagate() {
  bool ok = false;
  for (auto it1 = this->CL.begin(); it1 != this->CL.end(); it1 = it1->next())
    if (it1->X->lt->single()) {
      node<Literal> *A = it1->X->lt->begin();
      for (auto it2 = this->CL.begin(); it2 != this->CL.end();
           it2 = it2->next()) {
        if (it1 == it2) continue;
        ll res = this->AssignLiteralIn(it2, A);
        if (res == 2) return false;
        if (res) ok = true;
      }
      if (ok) return true;
    }
  return false;
}

bool CNF::PureLiteralAssign() {
  for (llu i = 0; i < this->AtomN; i++)
    if (this->avAtoms[i]->X->oc->single()) {
      this->scheme[this->avAtoms[i]->X->index] =
          this->avAtoms[i]->X->oc->begin()->X->lit->X->neg ? 2 : 1;
      this->Rec_assign.top().push_back(this->avAtoms[i]->X->index);
      this->removeClause(this->avAtoms[i]->X->oc->begin()->X->lit->X->cl);
      return true;
    }
  return false;
}

bool CNF::DPLL(bool disableSimp = false) {
  stack<ll> STACK;
  AvAtom *x;
  ll layerNow = -1, Status;
  STACK.push(0);
  while (!STACK.empty()) {
    Status = STACK.top();
    STACK.pop();
    /***/ cout << "=== NEW STATUS : " << Status << " ===" << endl;
    while (layerNow >= abs(Status)) {
      layerNow--;
      /***/ cout << "BACKTRACK: -> " << layerNow << endl;
      this->backtrack();
    }
    layerNow = abs(Status);
    /***/ cout << "FORMULA: begin processing(layer=" << layerNow
               << "):" << endl;
    /***/ cout << this->str();
    this->nextLayer();
    if (Status == 0) {
      /***/ cout << "INIT: skip assignments" << endl;
      goto SIMPLIFICATION;
    }
    x = this->AVA.begin()->X;
    /***/ cout << "ASSIGN: \"" << Atoms[x->index] << "\" -> "
               << (Status > 0 ? "True" : "False") << endl;
    this->scheme[x->index] = Status > 0 ? 1 : 2;
    this->Rec_assign.top().push_back(x->index);
    for (auto it = x->oc->begin(); it != x->oc->end(); it = it->next()) {
      if ((Status < 0) == it->X->lit->X->neg)
        this->removeClause(it->X->lit->X->cl);
      else {
        this->removeLiteral(it->X->lit->X->cl, it->X->lit);
        if (it->X->lit->X->cl->X->lt->empty()) {
          this->containEmptyClause = true;
          break;
        }
      }
    }
    /***/ cout << "FORMULA: finish assignments:" << endl;
    /***/ cout << this->str();
  SIMPLIFICATION:
    if (!disableSimp) {
      while (this->UnitPropagate()) {
      }
      /***/ cout << "FORMULA: Unit-propagatated:" << endl;
      /***/ cout << this->str();
      while (this->PureLiteralAssign()) {
      }
      /***/ cout << "FORMULA: Pure-literal-assigned:" << endl;
      /***/ cout << this->str();
    }
    if (this->CL.empty()) {
      /***/ cout << "***FORMULA IS EMPTY: It can be satisfied." << endl;
      /***/ cout << "***ALGORITHM FINISHED." << endl;
      return true;
    }
    if (this->containEmptyClause) {
      /***/ cout << "***FORMULA CONTAIN EMPTY CLAUSES: backtrack." << endl
                 << endl;
      this->containEmptyClause = false;
      continue;
    }
    STACK.push(abs(Status) + 1);
    STACK.push(-abs(Status) - 1);
    /***/ cout << endl;
  }
  /***/ cout << "***The formula cannot be satisfied." << endl;
  /***/ cout << "***ALGORITHM FINISHED." << endl;
  return false;
}

程式碼使用示例

最低 C++ 標準:C++ 11

輸入格式:一行一個正整數 \(n\),表示合取正規化包含 \(n\) 個子句。接下來 \(n\) 行,第 \(i\) 行開頭為一個正整數 \(k_i\) 表示該子句包含 \(k_i\) 個文字,隨即有 \(k_i\) 個以空格分隔的字串,表示各個文字,若該字串以^開頭,則表示該文字為否定文字。

例如,下列合取正規化:

\[\begin{aligned} &(a\vee b)\\ \wedge\ &(\neg a\vee\neg c)\\ \wedge\ &(b\vee\neg t\vee a\vee\neg c)\\ \wedge\ &(c\vee d)\\ \wedge\ &a \end{aligned} \]

的輸入程式碼就為:

5
2 a b
2 ^a ^c
4 b ^t a ^c
2 c d
1 a

包含標頭檔案dpll.hpp,保證其和slist.hpp在同一資料夾下,即可建立CNF物件,呼叫其.read()方法以從標準輸入輸出中讀入合取正規化。接著便可通過呼叫方法.DPLL()應用演算法(加上引數true可以使演算法跳過化簡步驟),許多除錯資訊都會一併輸出出來。如果要獲取一種可行的賦值方案(前提是公式可滿足),可以在應用 DPLL 演算法後輸出 .schemeStr() 方法生成的字串,其樣式如下:

"a" -> True
"b" -> _
"c" -> False
"t" -> _
"d" -> True

每行表示一個變數的賦值,若賦值為下劃線_則說明其賦值為truefalse均可。

我們對上述合取正規化示例應用演算法,輸出應為:

=== NEW STATUS : 0 ===
FORMULA: begin processing(layer=0):
{
|   ( a ∨ b )
| ∧ ( ¬a ∨ ¬c )
| ∧ ( b ∨ ¬t ∨ a ∨ ¬c )
| ∧ ( c ∨ d )
| ∧ a
}
INIT: skip assignments
DEL Clause "a ∨ b"
DEL literal "¬a" in "¬a ∨ ¬c"
DEL Clause "b ∨ ¬t ∨ a ∨ ¬c"
DEL literal "c" in "c ∨ d"
FORMULA: Unit-propagatated:
{
|   ¬c
| ∧ d
| ∧ a
}
DEL Clause "a"
DEL Clause "¬c"
DEL Clause "d"
FORMULA: Pure-literal-assigned:
{
}
***FORMULA IS EMPTY: It can be satisfied.
***ALGORITHM FINISHED.
1
"a" -> True
"b" -> _
"c" -> False
"t" -> _
"d" -> True

從中可以清晰地看到演算法執行的流程和經過各個化簡步驟後公式的內容。這條公式經過一次化簡後就足以判斷其是否可滿足了,我們通過.DPLL(true)禁用化簡步驟可以清晰地看到演算法回溯的過程:

=== NEW STATUS : 0 ===
FORMULA: begin processing(layer=0):
{
|   ( a ∨ b )
| ∧ ( ¬a ∨ ¬c )
| ∧ ( b ∨ ¬t ∨ a ∨ ¬c )
| ∧ ( c ∨ d )
| ∧ a
}
INIT: skip assignments

=== NEW STATUS : -1 ===
FORMULA: begin processing(layer=1):
{
|   ( a ∨ b )
| ∧ ( ¬a ∨ ¬c )
| ∧ ( b ∨ ¬t ∨ a ∨ ¬c )
| ∧ ( c ∨ d )
| ∧ a
}
ASSIGN: "a" -> False
DEL literal "a" in "a ∨ b"
DEL Clause "¬a ∨ ¬c"
DEL literal "a" in "b ∨ ¬t ∨ a ∨ ¬c"
DEL literal "a" in "a"
FORMULA: finish assignments:
{
|   b
| ∧ ( b ∨ ¬t ∨ ¬c )
| ∧ ( c ∨ d )
| ∧ (  )
}
***FORMULA CONTAIN EMPTY CLAUSES: backtrack.

=== NEW STATUS : 1 ===
BACKTRACK: -> 0
FORMULA: begin processing(layer=1):
{
|   ( a ∨ b )
| ∧ ( ¬a ∨ ¬c )
| ∧ ( b ∨ ¬t ∨ a ∨ ¬c )
| ∧ ( c ∨ d )
| ∧ a
}
ASSIGN: "a" -> True
DEL Clause "a ∨ b"
DEL literal "¬a" in "¬a ∨ ¬c"
DEL Clause "b ∨ ¬t ∨ a ∨ ¬c"
DEL Clause "a"
FORMULA: finish assignments:
{
|   ¬c
| ∧ ( c ∨ d )
}

=== NEW STATUS : -2 ===
FORMULA: begin processing(layer=2):
{
|   ¬c
| ∧ ( c ∨ d )
}
ASSIGN: "c" -> False
DEL Clause "¬c"
DEL literal "c" in "c ∨ d"
FORMULA: finish assignments:
{
|   d
}

=== NEW STATUS : -3 ===
FORMULA: begin processing(layer=3):
{
|   d
}
ASSIGN: "d" -> False
DEL literal "d" in "d"
FORMULA: finish assignments:
{
|   (  )
}
***FORMULA CONTAIN EMPTY CLAUSES: backtrack.

=== NEW STATUS : 3 ===
BACKTRACK: -> 2
FORMULA: begin processing(layer=3):
{
|   d
}
ASSIGN: "d" -> True
DEL Clause "d"
FORMULA: finish assignments:
{
}
***FORMULA IS EMPTY: It can be satisfied.
***ALGORITHM FINISHED.
1
"a" -> True
"b" -> _
"c" -> False
"t" -> _
"d" -> True

輸出中間出現BACKTRACK就說明演算法執行了一次回溯,將公式還原回賦值、化簡前的形態。

參考

相關文章