This is an old revision of the document!


Haskell: SAT Solving

  • Data publicării: 09.04.2025
  • Data ultimei modificări: 24.04.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, 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):

  • 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!

Resurse

Changelog

  • 24.04 (20:05): Publicat etapa 2
  • 09.04 (12:07): Publicat etapa 1
pp/25/teme/haskell-sat.1745558894.txt.gz · Last modified: 2025/04/25 08:28 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