This is an old revision of the document!
Problema satisfiabilității formulelor booleene (SAT) este exemplul canonic de problemă NP-completă. Ea are aplicații importante în mai multe domenii, printre care proiectarea și verificarea circuitelor electronice, verificarea aderenței unui sistem la o specificație dată, criptanaliză, alocare de resurse și chiar genetică. Prin urmare, există multe eforturi de cercetare cu scopul identificării de abordări care să permită rezolvarea în timp rezonabil a instanțelor problemei SAT întâlnite în practică. Astfel, doi algoritmi notabili sunt Davis–Putnam–Logemann–Loveland (DPLL) și conflict-driven clause learning (CDCL). Deși în esență complexitatea algoritmilor moderni rămâne exponențială în cazul cel mai defavorabil, ei utilizează anumite principii care în practică permit reducerea substanțială a spațiului de căutare, înlesnind rezolvarea instanțelor cu sute de mii de variabile și milioane de constrângeri.
Inspirându-se din algoritmii menționați mai sus, tema propune implementarea unor mecanisme de rezolvare mai eficientă a SAT, rezultatul fiind un mic SAT solver. În final, acesta va fi utilizat pentru rezolvarea problemei 3-colorare a unui graf, cunoscută la rândul său ca fiind NP-completă.
Tema este împărțită în 3 etape:
Deadline-ul depinde de semigrupa în care sunteți repartizați. Restanțierii care refac tema și nu refac laboratorul beneficiază de ultimul deadline, și anume în zilele de 02.05, 09.05, respectiv 16.05.
Rezolvările tuturor etapelor pot fi trimise până în ziua laboratorului 10 (deadline hard pentru toate etapele). Orice exercițiu trimis după un deadline soft se punctează la jumătate. Cu alte cuvinte, nota finală pe etapă se calculează conform formulei: n = (n1 + n2) / 2 (n1 = nota obținută înainte de deadline; n2 = nota obținută după deadline). Când toate submisiile preced deadline-ul, nota pe ultima submisie constituie nota finală (întrucât n1 = n2).
În fiecare etapă, veți valorifica ce ați învățat în săptămâna anterioară și veți avea la dispoziție un schelet de cod, cu toate că vor exista trimiteri la etapele anterioare. Enunțul caută să ofere o imagine de ansamblu atât la nivel conceptual, cât și în privința aspectelor care se doresc implementate, în timp ce detaliile se găsesc direct în schelet.
În cadrul SAT, operăm cu formule booleene în forma normală conjunctivă (FNC), care denotă conjuncții de disjuncții de literali booleeni. Mai precis, utilizăm următorii termeni:
x1
, x2
etc.x1
, ¬x2
etc.(x1 ∨ ¬x2 ∨ x3)
etc.(x1 ∨ ¬x2 ∨ x3) ∧ (¬x4 ∨ x5)
etc.{x1, ¬x2}
, care înseamnă că variabila x1
este asumată adevărată, iar x2
, falsă.În plus, utilizăm și termenii:
(x1)
etc.¬x1
este pur în raport cu formula (¬x1 ∨ ¬x2) ∧ (¬x1 ∨ x2)
, întrucât nu apare și x1
, dar x2
și ¬x2
nu sunt puri.O instanță a SAT, reprezentată de o formulă în FNC, urmărește determinarea satisfiabilității formulei, adică a existenței unei interpretări în raport cu care întreaga formulă este adevărată. Dacă o astfel de interpretare există, se dorește și identificarea acesteia. În esență, o formulă în FNC este satisfiabilă dacă putem identifica cel puțin un literal adevărat în fiecare clauză (proprietatea P). De exemplu:
(x1 ∨ ¬x2 ∨ x3) ∧ (¬x4 ∨ x5)
este satisfiabilă, existând mai multe interpretări care îndeplinesc proprietatea (P): {x1, ¬x4}
, {x1, ¬x2, x5}
etc. Observați că este posibil să nu fie necesară menționarea tuturor variabilelor în interpretare. Odată ce am asumat literalii x1
și ¬x4
adevărați, formula devine adevărată independent de valorile de adevăr ale celorlalte variabile.(x1 ∨ ¬x1 ∨ x2) ∧ (¬x3 ∨ x3)
este validă/tautologică, adică adevărată în toate interpretările, deci satisfiabilă.(x1) ∧ (¬x1)
este contradictorie, adică falsă în toate interpretările, deci nesatisfiabilă.Distingem și următoarele cazuri particulare:
Prima etapă a temei abordează reprezentările în Haskell ale conceptelor de mai sus, definirea unor funcții de bază care operează cu aceste reprezentări, și implementarea unui mecanism naiv de verificare a satisfiabilității bazat pe enumerarea interpretărilor.
Mai precis, vom utiliza următoarele reprezentări:
x1
devine 1
.x1
devine 1
, iar ¬x1
devine -1
.(x1 ∨ ¬x2 ∨ x3)
devine {1, -2, 3}
.(x1 ∨ ¬x2 ∨ x3) ∧ (¬x4 ∨ x5)
devine {{1, -2, 3}, {-4, 5}}
.{x1, ¬x2}
devine {1, -2}
.
Având în vedere că, în interiorul unei clauze, un literal apare cel mult o dată, iar ordinea literalilor este irelevantă, utilizăm pentru reprezentarea unei clauze mulțimi ordonate de literali în locul listelor. Pentru aceasta, folosim modulul Data.Set
(versiunea 0.6.7, corespunzătoare vmchecker), care definește tipul Set a
(mulțime ordonată cu elemente de tipul a
). Pe baza unui argument similar, formulele sunt reprezentate ca mulțimi ordonate de clauze. Având în vedere că multe dintre funcțiile pe mulțimi au același nume cu cele pe liste (map
, filter
, foldr
etc.), conflictele se evită plasând la începutul scheletului liniile:
import Data.Set (Set) import qualified Data.Set as Set
Semnificația este următoarea:
Set
poate fi utilizat ca atare.Set.
. De exemplu, Set.map
denotă funcționala pe mulțimi, în timp ce map
continuă să refere funcționala pe liste standard.Construcțiile și mecanismele de limbaj pe care le veți exploata în rezolvare sunt:
(x `f`)
sau (`f` y)
let
sau where
).
Modulul de interes din schelet este Formula
, care conține reprezentarea conceptelor de mai sus, precum și operațiile pe care trebuie să le implementați. Găsiți detaliile despre funcționalitate și despre constrângerile de implementare, precum și exemple, direct în fișier. Aveți de completat definițiile care încep cu *** TODO ***
.
Pentru rularea testelor, încărcați în interpretor modulul TestFormula
și evaluați main
.
Este suficient ca arhiva pentru vmchecker să conțină doar modulul Formula
.
Comentariile majorității funcțiilor din schelet conțin CONSTRÂNGERI
, a căror nerespectare atrage depunctări parțiale.
În plus, se impune operarea direct pe reprezentarea de mulțimi a clauzelor, formulelor și interpretărilor. Conversiile intermediare la liste și înapoi la mulțimi atrag depunctarea totală a funcțiilor implementate în acest fel! Funcția predefinită în schelet toLiteralLists
vă este oferită doar pentru a vizualiza mai ușor formulele; nu este gândită pentru a fi folosită efectiv în implementări.
Etapa 1 a presupus implementarea unui mecanism simplu de verificare a satisfiabilității, bazat pe enumerarea interpretărilor. Un dezavantaj al acestei abordări îl constituie generarea completă a unei interpretări, în sensul acoperirii tuturor variabilelor din formulă, cu toate că asumpțiile despre literalii adevărați făcute până la un moment dat pot invalida deja formula, indiferent de asumpțiile realizate ulterior pentru restul variabilelor. De exemplu, pentru formula (x1) ∧ (¬x1 ∨ x2)
, asumpția că literalul ¬x1
este adevărat (numită, pe scurt, asumpția ¬x1
) invalidează formula (mai precis, prima clauză), indiferent de asumpția făcută ulterior pentru variabila x2
.
Prin urmare, ar fi mai avantajos să observăm starea formulei după fiecare nouă asumpție. Astfel, odată ce asumăm un literal adevărat, variabila corespunzătoare poate fi eliminată din formulă, după următoarele principii:
De exemplu, pentru formula (x1 ∨ x2) ∧ (¬x1 ∨ x3)
și asumpția x1
, avem următoarele:
x1
, și anume (x1 ∨ x2)
, sunt eliminate, obținând formula (¬x1 ∨ x3)
.¬x1
, pot fi eliminate din clauzele rămase, obținând formula (x3)
.În urma unei secvențe de eliminări, se pot obține următoarele situații descrise în etapa 1:
De exemplu, în formula de mai sus:
x1
, care a condus la formula (x3)
, realizăm asumpția suplimentară x3
, obținem formula vidă, deci interpretarea {x1, x3}
satisface formula originală, indiferent de asumpția pentru variabila x2
.x3
, realizăm opusul său, ¬x3
, obținem o formulă cu clauza vidă, ()
, care nu mai poate fi satisfăcută.În cel de-al doilea caz, de conflict, trebuie să revenim prin backtracking la o asumpție anterioară și să încercăm opusul ei. Esența unei rezolvări mai eficiente o constituie strategia de alegere a următoarei asumpții, atât în prezent, când este eliminată o nouă variabilă, cât și în trecut, când este necesară revenirea la o variabilă eliminată deja.
Pentru a înțelege mai bine situațiile întâlnite la realizarea unei asumpții în prezent, să luăm formula de la începutul enunțului acestei etape: (x1) ∧ (¬x1 ∨ x2)
. Dacă alegem la întâmplare următoarea asumpție, de exemplu, ¬x1
, riscăm să desfășurăm un proces de calcul inutil, întrucât clauza (x1)
ar deveni imediat falsă. În schimb, la o analiză mai atentă, observăm că există un singur mod de a satisface o clauză unitară, și deci asumpția x1
este impusă. Prin urmare, este o idee bună să detectăm mai întâi prezența clauzelor unitare, și să le satisfacem pe acestea.
De remarcat că satisfacerea unei clauze unitare poate genera noi clauze unitare, care nu aveau inițial această proprietate. De exemplu, în formula anterioară, asumpția x1
conduce prin eliminare la formula (x2)
, în care apare clauza unitară (x2)
, absentă din formula originală. Prin urmare, prelucrarea clauzelor unitare trebuie repetată până la absența acestora din formulă.
O altă situație interesantă vizează literalii puri. Din moment ce complementul unui literal nu apare în formulă, este întotdeauna avantajos să asumăm acel literal, fără teama inducerii vreunui conflict. La fel ca la clauzele unitare, eliminarea unui literal pur poate genera noi literali puri, deci prelucrarea trebuie repetată. De exemplu, în formula (x1 ∨ x2) ∧ (¬x2 ∨ x3) ∧ (¬x2 ∨ ¬x3)
, nu există clauze unitare, dar există un singur literal pur, x1
. Prin eliminarea lui, se obține formula (¬x2 ∨ x3) ∧ (¬x2 ∨ ¬x3)
, în care apare literalul pur ¬x2
, care nu era pur în formula originală. Eliminându-l și pe acesta, se obține formula vidă.
De remarcat că cele două prelucrări de mai sus pot interacționa: satisfacerea unei clauze unitare poate introduce nu numai alte clauze unitare, ci și literali puri, iar eliminarea unui literal pur poate introduce nu numai alți literali puri, ci și clauze unitare. În cazul în care sunt disponibile ambele opțiuni, se preferă satisfacerea clauzelor unitare mai întâi, întrucât ele pot conduce la conflicte, și este de dorit evidențierea cât mai timpurie a acestora, pentru a scuti alt efort de calcul care oricum nu ar împiedica generarea unui conflict. Numai dacă formula nu conține nici clauze unitare, și nici literali puri, are rost să recurgem la asumpții oarecare.
În exemplul final, demonstrăm toate cele trei tipuri de prelucrări pe formula (¬x1 ∨ x4) ∧ (x1 ∨ x2 ∨ ¬x3) ∧ (¬x2 ∨ x3 ∨ ¬x4)
:
¬x4
, care conduce prin eliminare la formula (¬x1) ∧ (x1 ∨ x2 ∨ ¬x3)
.(¬x1)
, și prioritizăm satisfacerea acesteia, cu toate că există și literalii puri x2
și ¬x3
. Eliminarea lui ¬x1
conduce la formula (x2 ∨ ¬x3)
.¬x3
și obținem formula vidă, care corespunde satisfacerii formulei originale sub interpretarea {¬x1, ¬x3, ¬x4}
.Conflictele (clauzele vide) și alegerile mai eficiente în caz de revenire la asumpțiile anterioare vor fi abordate în etapa 3.
Algoritmul final de rezolvare utilizează atât varianta curentă a formulei, care surprinde eliminările realizate până la un moment dat, cât și varianta originală. Prin urmare, simpla reprezentare a unei formule ca o mulțime de clauze, ca în etapa 1, devine insuficientă pentru modelarea întregii informații necesare. Din fericire, o mică extensie acoperă noua nevoie: în locul unei mulțimi, utilizăm un tablou asociativ (Data.Map
, consultați acest tutorial), în care cheile sunt clauzele curente, iar valorile, clauzele originale. Fiecărei clauze curente (chei) îi corespunde clauza originală (valoarea) din care a fost obținută prin eliminări.
De exemplu, formula (x1 ∨ x2) ∧ (¬x1 ∨ x3)
este reprezentată inițial, înainte de orice eliminare, prin tabloul {({1, 2}, {1, 2}), ({-1, 3}, {-1, 3})}
, unde am folosit notația (clauză-curentă, clauză-originală)
pentru a desemna o corespondență cheie-valoare. Evident, înainte de vreo eliminare, cheile și valorile coincid. Eliminând literalul 1
, așa cum a fost demonstrat mai sus, se obține noul tablou, {({3}, {-1, 3})}
, în care clauza originală {1, 2}
a fost înlăturată, și în care varianta curentă a clauzei originale {-1, 3}
este {3}
.
A doua etapă a temei abordează aceste reprezentări extinse ale formulelor, mecanismul de eliminare a literalilor, și prelucrarea clauzelor unitare și a literalilor puri.
Similar cu etapa 1, funcțiile pe tablouri trebuie prefixate cu Map.
. Remarcăm, totuși, că funcționalele cu nume standard pe tablouri (Map.map
, Map.filter
etc.) operează pe valori. Veți avea nevoie mai degrabă de funcționalele care iau în calcul și cheile (Map.mapKeys
, Map.filterWithKey
, Map.foldrWithKey
etc.), întrucât acestea sunt supuse transformărilor, în timp ce valorile rămân neschimbate.
Construcțiile și mecanismele de limbaj pe care le veți exploata în rezolvare, pe lângă cele din etapa 1, sunt:
data
)
Modulul de interes din schelet este ExtendedFormula
, care conține reprezentarea extinsă a formulelor, precum și operațiile pe care trebuie să le implementați. Găsiți detaliile despre funcționalitate și despre constrângerile de implementare, precum și exemple, direct în fișier. Aveți de completat definițiile care încep cu *** TODO ***
.
Pentru rularea testelor, încărcați în interpretor modulul TestExtendedFormula
și evaluați main
.
Este suficient ca arhiva pentru vmchecker să conțină modulele ExtendedFormula
și Formula
din etapa 1.
Comentariile majorității funcțiilor din schelet conțin CONSTRÂNGERI
, a căror nerespectare atrage depunctări parțiale.
În plus, se impune operarea direct pe reprezentările de mulțimi sau tablouri ale clauzelor, formulelor și interpretărilor. Conversiile intermediare la liste și înapoi la mulțimi sau tablouri atrag depunctarea totală a funcțiilor implementate în acest fel!