Haskell: SAT Solving

  • Data publicării: 09.04.2025
  • Data ultimei modificări: 08.05.2025
  • Deadline hard: ziua laboratorului 10

Obiective

  • Aplicarea mecanismelor funcționale, de tipuri (inclusiv polimorfism) și de evaluare leneșă din limbajul Haskell.
  • Exploatarea evaluării leneșe pentru decuplarea conceptuală a prelucrărilor realizate.

Descriere generală și organizare

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:

  • una pe care o veți rezolva după laboratorul 7, cu deadline în ziua laboratorului 8
  • una pe care o veți rezolva după laboratorul 8, cu deadline în ziua laboratorului 9
  • una pe care o veți rezolva după laboratorul 9, cu deadline în ziua laboratorului 10.

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.

Etapa 1

Î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:

  • variabilă booleană: x1, x2 etc.
  • literal boolean: o variabilă sau negația ei: x1, ¬x2 etc.
  • clauză: disjuncție de literali: (x1 ∨ ¬x2 ∨ x3) etc.
  • formulă în FNC: conjuncție de clauze: (x1 ∨ ¬x2 ∨ x3) ∧ (¬x4 ∨ x5) etc.
  • interpretare: mulțime de literali asumați adevărați: {x1, ¬x2}, care înseamnă că variabila x1 este asumată adevărată, iar x2, falsă.

În plus, utilizăm și termenii:

  • clauză unitară: clauză cu un singur literal: (x1) etc.
  • literal pur în raport cu o formulă: literal pentru care complementul nu apare în acea formulă: ¬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:

  • Formula (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.
  • Formula (x1 ∨ ¬x1 ∨ x2) ∧ (¬x3 ∨ x3) este validă/tautologică, adică adevărată în toate interpretările, deci satisfiabilă.
  • Formula (x1) ∧ (¬x1) este contradictorie, adică falsă în toate interpretările, deci nesatisfiabilă.

Distingem și următoarele cazuri particulare:

  • Formula vidă este satisfiabilă, întrucât proprietatea (P) este îndeplinită banal prin absența clauzelor.
  • Formula care conține clauza vidă este nesatisfiabilă, întrucât clauza vidă nu poate conține un literal asumat adevărat într-o interpretare.

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:

  • O variabilă este reprezentată printr-un întreg pozitiv: x1 devine 1.
  • Un literal este reprezentat printr-un întreg nenul, al cărui semn reflectă polaritatea literalului: x1 devine 1, iar ¬x1 devine -1.
  • O clauză este reprezentată printr-o mulțime de literali (vedeți mai jos): (x1 ∨ ¬x2 ∨ x3) devine {1, -2, 3}.
  • O formulă este reprezentată printr-o mulțime de clauze (vedeți mai jos): (x1 ∨ ¬x2 ∨ x3) ∧ (¬x4 ∨ x5) devine {{1, -2, 3}, {-4, 5}}.
  • O interpretare este reprezentată printr-o mulțime de literali (vedeți mai jos): {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:

  • Prima linie afirmă că doar constructorul de tip Set poate fi utilizat ca atare.
  • A doua linie afirmă că toate celelalte entități importate din modul trebuie prefixate cu 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:

  • mulțimi și liste
  • funcționale pe mulțimi și liste, pentru a observa universalitatea acestor construcții
  • secțiuni, adică aplicații parțiale infixate, ca (x `f`) sau (`f` y)
  • list comprehensions, pentru descrierea concisă a unor prelucrări
  • evaluare leneșă, implicită în Haskell, pentru decuplarea conceptuală a transformărilor din cadrul unei secvențe
  • construcții de legare a variabilelor locale (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.

Depunctări

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 2

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:

  • Toate clauzele care conțin literalul devin adevărate și pot fi înlăturate din formulă.
  • În clauzele rămase, toate aparițiile complementului literalului devin false și, nemaiputând contribui la satisfacerea clauzelor care le conțin, pot fi înlăturate.

De exemplu, pentru formula (x1 ∨ x2) ∧ (¬x1 ∨ x3) și asumpția x1, avem următoarele:

  • Clauzele care conțin literalul x1, și anume (x1 ∨ x2), sunt eliminate, obținând formula (¬x1 ∨ x3).
  • Aparițiile complementului literalului, și anume ¬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:

  • Formula vidă, în urma satisfacerii și înlăturării tuturor clauzelor, ceea ce înseamnă că formula originală este satisfăcută în baza asumpțiilor realizate.
  • Formula care conține clauza vidă, în urma înlăturării tuturor literalilor dintr-o clauză pe motivul falsității acestora, ceea ce înseamnă că acea clauză nu mai poate fi satisfăcută, și deci formula originală nu mai poate fi satisfăcută în baza asumpțiilor curente.

De exemplu, în formula de mai sus:

  • Dacă după asumpția 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.
  • Dacă în locul asumpției 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. Î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):

  • Inițial nu există nici clauze unitare, și nici literali puri, așa că asumăm la întâmplare literalul ¬x4, care conduce prin eliminare la formula (¬x1) ∧ (x1 ∨ x2 ∨ ¬x3).
  • Acum, se evidențiază clauza unitară (¬x1), și prioritizăm satisfacerea acesteia, cu toate că există și literalii puri x2 și ¬x3. Eliminarea lui ¬x1 conduce la formula (x2 ∨ ¬x3).
  • În final, nu există clauze unitare, așa că eliminăm literalul pur ¬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:

  • tablouri asociative
  • funcționale pe tablouri
  • tipuri de date utilizator (data)
  • pattern matching.

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.

Depunctări

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!

Etapa 3

Etapa 2 a abordat situațiile în care o formulă poate fi satisfăcută mergând numai „înainte”, alegând în fiecare pas unul dintre cele trei tipuri de acțiuni de eliminare, în această ordine, a clauzelor unitare, a literalilor puri și a literalilor oarecare. Rămâne de stabilit cum trebuie procedat când, la un moment dat, eliminarea unui literal produce o clauză vidă (conflict).

De exemplu, plecând de la formula (¬x7 ∨ x1) ∧ (¬x6 ∨ ¬x2 ∨ x1) ∧ (¬x5 ∨ x1) ∧ (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1 ∨ x6) ∧ (¬x2 ∨ x5 ∨ x7) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2), se aplică mai întâi următoarea secvență de acțiuni, conform celor discutate în etapa 2:

  1. Cum nu există clauze unitare sau literali puri, se decide mai întâi eliminarea literalului oarecare ¬x7, care produce formula: (¬x6 ∨ ¬x2 ∨ x1) ∧ (¬x5 ∨ x1) ∧ (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1 ∨ x6) ∧ (¬x2 ∨ x5) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2).
  2. Similar, eliminarea literalului oarecare ¬x6 produce formula: (¬x5 ∨ x1) ∧ (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1) ∧ (¬x2 ∨ x5) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2).
  3. Apoi, eliminarea literalului oarecare ¬x5 produce formula: (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1) ∧ (¬x2) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2).
  4. În acest moment se evidențiază clauza unitară (¬x2), care conduce prin eliminare la formula: (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x1) ∧ (x1).
  5. Mai departe, oricare dintre clauzele unitare (¬x1) sau (x1) ar fi eliminate, s-ar obține o clauză vidă (conflict).

Ce ar trebui făcut în acest caz? Intuitiv, ar trebui revenit prin backtracking la o asumpție anterioară și explorat opusul ei. Dar acest lucru ridică o altă întrebare: la ce punct decizional ar trebui să ne întoarcem? Evident, clauzele unitare impun alegerea, și nu ar avea sens să încercăm opusul; de exemplu, revenirea la asumpția ¬x2 (pasul 4 de mai sus), dictată de clauza unitară (¬x2), și explorarea variantei x2 ar fi inutile. Prin urmare, ar trebui să revenim la cel mai recent punct decizional în care există o alternativă viabilă; de exemplu, am putea reveni la asumpția ¬x5 (pasul 3), arbitrară, și explora asumpția x5. Din păcate, și această cale ar conduce la conflict, și ar trebui revenit un nivel și mai sus, la asumpția ¬x6 (pasul 2) etc.

Se ridică următoarea întrebare interesantă: am putea oare reveni într-un punct mai precis din trecut, astfel încât să evităm încercarea măcar a câtorva alternative care în final ar conduce tot la conflict? În exemplul de mai sus, atât secvența inițială ¬x7 - ¬x6 - ¬x5, cât și secvența alternativă ¬x7 - ¬x6 - x5 au condus la conflicte. Puteam să o evităm cu totul pe a doua?

La baza răspunsului stă observația că un conflict poate fi indus de fapt timpuriu, printr-o anumită combinație de asumpții incompatibile realizate în trecut, dar manifestat târziu. În exemplul de mai sus, combinația de asumpții incompatibile este {¬x7, ¬x5}, care conduce la clauza unitară (¬x2), provenită din clauza originală (¬x2 ∨ x5 ∨ x7). Eliminarea acesteia conduce pe ambele căi de mai sus la conflicte. Prin urmare, ar trebui revenit într-un punct cât mai distant din trecut în care incompatibilitatea să fie înlăturată, astfel încât toate asumpțiile făcute ulterior asupra altor variabile să se armonizeze cu modificarea realizată. Acest lucru nu previne complet apariția altor conflicte pe măsură ce se realizează noi asumpții, dar reduce spațiul de căutare.

Remarcăm că, din moment ce clauzele unitare impun literalii eliminați, ca urmare a asumpțiilor anterioare, ei nu vor apărea în combinația conflictuală („nu aveam de ales decât să-i eliminăm”). Ne interesează să identificăm literalii pentru care asumpția opusă era viabilă.

Evident că rămân întrebările:

  1. Cum determinăm asumpțiile incompatibile?
  2. Cum determinăm punctul în care ne întoarcem?

Învățarea de noi clauze

Pentru a răspunde la întrebarea (1), introducem mai întâi conceptul de rezoluție a două clauze, care va fi prezentat formal în capitolul de programare logică al cursului. Pentru temă, este suficient să o definim astfel: dacă o clauză C1 conține literalul xi, iar o clauză C2, complementul său, ¬xi (sau viceversa), cele două clauze pot fi rezolvate în raport cu literalii respectivi, obținând o nouă clauză, R (rezolvent), care conține toți literalii celor două clauze, mai puțin xi și ¬xi. În plus, din punct de vedere logic, avem că (C1 ∧ C2) ⟹ R, adică R este adevărat ori de câte ori C1 și C2 sunt.

De exemplu:

  • Clauzele (x1 ∨ x2) și (¬x1 ∨ x3 ∨ x4) pot fi rezolvate în raport cu variabila x1, producând clauza (x2 ∨ x3 ∨ x4).
  • Clauzele (x1 ∨ x2) și (x1 ∨ x3 ∨ x4) nu pot fi rezolvate în raport cu nicio variabilă, întrucât nu există niciun literal din prima clauză al cărui complement să apară în a doua.

Utilizând rezoluția, introducem următorul algoritm de determinare a asumpțiilor incompatibile:

  1. În momentul obținerii unei clauze vide, se determină clauza originală din care aceasta a provenit, care servește drept clauză curentă.
  2. Se parcurge istoricul de asumpții realizate în sens anticronologic (prezent-trecut) și:
    1. Dacă acțiunea curentă este de eliminare a unei clauze unitare (Unit), rezolvăm (dacă este posibil, conform definiției de mai sus a rezoluției) clauza stocată în acțiune cu clauza curentă, în raport cu literalul stocat de asemenea în acțiune, rezultând o nouă clauză curentă.
    2. Dacă acțiunea curentă este de altă natură, clauză curentă rămâne neschimbată.
  3. Varianta finală a clauzei curente rezumă motivul conflictului.

Rezolvând doar în raport cu literalii impuși de eliminarea clauzelor unitare, obținem efectul dorit ca aceștia să nu apară în combinația de asumpții incompatibile. Nevoia de operare pe clauzele originale justifică formulele extinse din etapa 2.

Să aplicăm algoritmul pe exemplul de mai sus, operând de data aceasta în ordine inversă, dinspre pasul 5, care a generat conflictul, către pasul 1:

  • 5. Presupunând că s-a decis eliminarea clauzei unitare (¬x1), care a condus la vidarea clauzei (x1) (conflict), se determină clauza originală din care s-a obținut această clauză vidă, și anume (x1 ∨ x2), care devine clauza curentă. Apoi, începe consultarea acțiunilor realizate deja, conform algoritmului de mai sus. Mai întâi, se analizează cea mai recentă acțiune, de eliminare a clauzei unitare (¬x1), care stochează în reprezentarea sa literalul ¬x1 și clauza originală din care s-a obținut această clauză unitară, și anume (¬x1 ∨ x2). Rezolvând-o pe aceasta cu clauza curentă (x1 ∨ x2) în raport cu literalul ¬x1, se obține rezolventul (x2), reprezentând noua clauză curentă.
  • 4. Revenind încă un pas în trecut, se întâlnește acțiunea de eliminare a clauzei unitare (¬x2), care stochează în reprezentarea sa literalul ¬x2 și clauza originală din care s-a obținut această clauză unitară, și anume (¬x2 ∨ x5 ∨ x7). Rezolvând-o pe aceasta cu clauza curentă (x2) în raport cu literalul ¬x2, se obține rezolventul (x5 ∨ x7), reprezentând noua clauză curentă.
  • 3, 2, 1. Acțiunile rămase nu sunt de eliminare a unei clauze unitare, și prin urmare clauza curentă rămâne (x5 ∨ x7).

Pentru a înțelege semnificația acestei clauze, (x5 ∨ x7), să ne amintim combinația de asumpții incompatibile depistată mai sus, {¬x7, ¬x5}, conform căreia ambele variabile, x7 și x5, au fost asumate false. Pentru a depăși incompatibilitatea, cel puțin una dintre variabile trebuie asumată adevărată, adică exact ce codifică clauza (x5 ∨ x7). Proprietatea rezolventului afirmă că acesta este întotdeauna adevărat, dacă clauzele pe baza căruia a fost obținut sunt adevărate. Prin urmare, dacă vrem ca clauza vidată (x1 ∨ x2) și clauzele devenite unitare și eliminate (¬x1 ∨ x2) și (¬x2 ∨ x5 ∨ x7) să fie simultan adevărate (într-o anumită interpretare), atunci și clauza (x5 ∨ x7) trebuie să fie adevărată (în acea interpretare). Observați cum într-adevăr clauza referă doar variabile asupra cărora s-au realizat asumpții arbitrare, ca x5 și x7, nu și variabile pentru care asumpțiile au fost impuse, ca x2.

(x5 ∨ x7) poartă numele de clauză învățată (learned clause), întrucât nu face parte din formula originală. Dacă clauza învățată însăși este vidă, înseamnă că este imposibil ca clauzele din care a fost obținută să fie adevărate simultan, și deci formula originală este nesatisfiabilă.

Pentru a ne asigura că noua clauză, (x5 ∨ x7), nu va mai fi niciodată vidată de asumpțiile realizate, o putem adăuga la formula originală, și ne putem întoarce în trecut la cel mai distant punct în care această clauză devine unitară (funcția backtrackToUnitClause din etapa 2), astfel încât următoarea acțiune să o satisfacă imediat. Revenirea se poate face cu un număr arbitrar de pași în trecut, motiv pentru care poartă numele de backtracking necronologic. Revenirea la cel mai distant punct, și nu la cel mai recent, scade probabilitatea de eșec al căii curente, datorat altor asumpții realizate pe anterioara cale eșuată. Astfel, răspundem și la întrebarea (2). Remarcăm că, din moment ce rezolventul derivă logic din anumite clauze ale formulei originale, formula rezultată prin adăugarea clauzei învățate la formula originală este echivalentă cu formula originală.

În exemplul de mai sus, se adaugă clauza învățată (x5 ∨ x7) la formula originală, obținându-se formula (¬x7 ∨ x1) ∧ (¬x6 ∨ ¬x2 ∨ x1) ∧ (¬x5 ∨ x1) ∧ (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1 ∨ x6) ∧ (¬x2 ∨ x5 ∨ x7) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2) ∧ (x5 ∨ x7):

  1. Prin backtracking necronologic se revine la pasul 1, imediat după eliminarea literalului ¬x7, în urma căreia clauza învățată, (x5 ∨ x7), devine unitară, (x5). Noua formulă este (¬x6 ∨ ¬x2 ∨ x1) ∧ (¬x5 ∨ x1) ∧ (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1 ∨ x6) ∧ (¬x2 ∨ x5) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2) ∧ (x5).
  2. Noul pas 2 este diferit acum, întrucât prezența clauzei unitare (x5) determină eliminarea acesteia și obținerea formulei (¬x6 ∨ ¬x2 ∨ x1) ∧ (x1) ∧ (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ ¬x1 ∨ x6) ∧ (¬x1 ∨ x2) ∧ (x1 ∨ x2). Acesta este punctul în care clauza învățată își produce efectele.
  3. Se elimină clauza unitară (x1) și se obține formula (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (¬x2 ∨ x6) ∧ (x2).
  4. Se elimină clauza unitară (x2) și se obține formula (¬x4 ∨ x3) ∧ (¬x3 ∨ x4) ∧ (x6).
  5. Se elimină clauza unitară (x6) și se obține formula (¬x4 ∨ x3) ∧ (¬x3 ∨ x4).
  6. Se asumă literalul oarecare ¬x4 și se obține formula (¬x3).
  7. Se elimină clauza unitară (¬x3) și se obține formula vidă. SUCCES!

Observați cum combinația alternativă neproductivă menționată mai sus, ¬x7 - ¬x6 - x5, a fost evitată, prin realizarea de data aceasta a asumpției x5 înaintea asumpției pentru variabila x6.

Algoritmul complet de satisfacere

Am ajuns în punctul în care putem integra toate conceptele discutate până acum, în algoritmul complet de satisfacere:

  1. Se prelucrează toate clauzele unitare.
  2. Dacă se obține formula vidă, formula originală este SATISFIABILĂ și se construiește interpretarea utilizând istoricul curent. STOP.
  3. Dacă formula conține clauza vidă (conflict), se învață o nouă clauză.
    1. Dacă clauza învățată este vidă, formula este NESATISFIABILĂ. STOP.
    2. Altfel, se revine în istoric la cel mai distant punct în care clauza învățată este unitară, și se sare la pasul 1.
  4. Se prelucrează toți literalii puri și se sare la pasul 1.
  5. Numai dacă nu există literali puri, se asumă un literal oarecare și se sare la pasul 1.

Aplicație la problema 3-colorare

Problema 3-colorare a unui graf neorientat urmărește asocierea unei culori din trei (Red, Green, Blue) fiecărui nod din graf, astfel încât oricare două noduri adiacente să fie colorate diferit. Problema poate fi rezolvată prin reducere la SAT, parcurgând etapele:

  1. Codificarea instanței 3-colorare într-o formulă CNF.
  2. Satisfacerea formulei, dacă este posibil.
  3. Decodificarea eventualei interpretări care satisface formula pentru a obține colorarea grafului.

Vom întrebuința următoarea schemă de codificare a unui graf într-o formulă CNF:

  1. Fiecărui nod n din graf îi corespund trei variabile booleene, aferente celor trei culori posibile: x[n,R], x[n,G], x[n,B].
  2. Fiecare nod n are cel puțin o culoare: (x[n,R] ∨ x[n,G] ∨ x[n,B]).
  3. Fiecare nod n are cel mult o culoare: (¬x[n,R] ∨ ¬x[n,G]) ∧ (¬x[n,R] ∨ ¬x[n,B]) ∧ (¬x[n,G] ∨ ¬x[n,B]).
  4. Fiecare muchie (n, o) are capetele colorate diferit: (¬x[n,R] ∨ ¬x[o,R]) ∧ (¬x[n,G] ∨ ¬x[o,G]) ∧ (¬x[n,B] ∨ ¬x[o,B]).

Considerăm că nodurile grafului sunt reprezentate prin numere naturale mai mari sau egale cu 1, iar cele trei variabile booleene aferente unui nod n sunt reprezentate prin numerele 10n+1, 10n+2, 10n+3. De exemplu, nodului 1 îi corespund variabilele 11, 12, 13, nodului 2, variabilele 21, 22, 23 etc. Variabila 11 corespunde propoziției „Nodul 1 este roșu”, variabila 22, propoziției „Nodul 2 este verde”, variabila 33, propoziției „Nodul 3 este albastru” etc.

De exemplu, pentru graful neorientat cu nodurile {1, 2} și muchia (1, 2), se obține formula cu următoarele variabile și clauze (numerele itemilor oglindesc enumerarea de mai sus):

  1. Variabilele 11, 12, 13, 21, 22, 23.
  2. Clauzele de tip cel puțin o culoare: {11, 12, 13}, {21, 22, 23}.
  3. Clauzele de tip cel mult o culoare: {-11, -12}, {-11, -13}, {-12, -13}, {-21, -22}, {-21, -23}, {-22, -23}.
  4. Clauzele de tip capete colorate diferit: {-11, -21}, {-12, -22}, {-13, -23}.

Pentru întreaga formulă descrisă mai sus, algoritmul de satisfacere ar putea produce interpretarea {-23, -22, -13, -11, 12, 21}, care corespunde colorării {(1, Green), (2, Red)}.

Precizări

A treia etapă a temei abordează rezolvarea clauzelor, învățarea clauzelor, algoritmul de satisfacere și aplicația la problema 3-colorare.

Construcțiile și mecanismele de limbaj pe care le veți exploata în rezolvare, pe lângă cele din etapele 1 și 2, sunt:

  • polimorfismul ad-hoc
  • clasele.

Modulul de interes din schelet este Solver, care conține 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 TestSolver și evaluați main. Ultimul test (stress) utilizează grafuri mai mari și execuția poate dura câteva secunde.

Este suficient ca arhiva pentru vmchecker să conțină modulele Solver, ExtendedFormula din etapa 2 și Formula din etapa 1.

Depunctările urmează aceleași principii ca în primele două etape.

Resurse

Referințe

Changelog

  • 08.05 (23:30): Publicat testele etapei 3 și actualizat tipurile Graph, Color și ThreeColoring din Solver.hs cu deriving (Show, Eq) în loc de deriving Show.
  • 30.04 (22:30): Publicat etapa 3, momentan fără teste
  • 27.04 (09:50): Etapa 2: Actualizat teste backtrackToUnitClause
  • 24.04 (20:05): Publicat etapa 2
  • 09.04 (12:07): Publicat etapa 1
pp/25/teme/haskell-sat.txt · Last modified: 2025/05/08 23:34 by mihnea.muraru
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0