Algorithme de Kosaraju et 2-SAT¶
Ce TP accompagne le chapitre Algorithmique avancée des graphes. Il est disponible sous forme de notebook Jupyter.
Kosaraju¶
On va considérer un type graphe où les sommets ne sont pas forcément
des entiers et où on stocke une table de hachage pour faire la
correspondance entre la valeur d'un sommet et son indice.
type 'a graphe = {
sommets : 'a array;
sommets_indices : ('a, int) Hashtbl.t;
ladj : int list array
}
Question 1. Écrire une fonction qui étant donné un tableau t va
construire la table des associations t.(i) -> i qui permettra ainsi de
remonter du tableau à l'indice.
Note C'est le moment de retourner apprendre par cœur la
documentation du module Hashtbl.
Corrigé
let table_indices (t:'a array) : ('a, int) Hashtbl.t =
let h = Hashtbl.create (Array.length t) in
for i = 0 to Array.length t - 1 do
Hashtbl.add h t.(i) i
done;
h
Question 2. Écrire une fonction indice qui prend un graphe et un sommet
et renvoie son indice en utilisant la table sommets_indices.
Corrigé
let indice g a = Hashtbl.find g.sommets_indices a
Question 3. En déduire une fonction cree_graphe qui va prendre un
tableau sommets et créer un graphe sans arêtes dont ce sont les sommets.
Corrigé
let cree_graphe sommets =
{
sommets = sommets;
sommets_indices = table_indices sommets;
ladj = Array.make (Array.length sommets) []
}
Question 4. Écrire une fonction ajoute_arete telle que
ajoute_arete g a b ajoute l'arête a -> b où les sommets sont donnés par
leur valeur et non pas par leur indice.
Corrigé
let ajoute_arete g a b =
let ia = indice g a in
let ib = indice g b in
if not (List.mem ib g.ladj.(ia))
then g.ladj.(ia) <- ib :: g.ladj.(ia)
Les deux cellules suivantes permettent de définir le graphe vu dans le cours en exemple :
let ex_cours = cree_graphe [|'a';'b';'c';'d';'e';'f';'g';'h';'i';'j';'k';'l';'m';'n';'o';'p'|]
List.iter (fun (a,b) -> ajoute_arete ex_cours a b)
[('a','b'); ('b','f'); ('g','a');('f','g');
('c','h'); ('g','c'); ('d','c');('h','d');
('e','f'); ('f','l'); ('e','i'); ('g','k');
('h','l'); ('i','n'); ('j','m'); ('j','k');
('k','l'); ('k','h'); ('l','o'); ('l','p');
('m','i'); ('n','j'); ('n','o'); ('o','k')]
Question 5. Écrire une fonction graphe_miroir qui renvoie le graphe
miroir du graphe donné, c'est-à-dire qui renverse toutes les arêtes.
Corrigé
let graphe_miroir g =
let gr = {
sommets = g.sommets;
sommets_indices = g.sommets_indices;
ladj = Array.make (Array.length g.sommets) []
} in
for i = 0 to Array.length g.sommets - 1 do
List.iter (fun j -> gr.ladj.(j) <- i :: gr.ladj.(j)) g.ladj.(i)
done;
gr
Question 6. Écrire une fonction dfs qui va parcourir en profondeur un
graphe et appliquer une fonction de post-traitement. Pas besoin de noter
les temps de sortie puisqu'ils correspondent au moment où on effectue ce
traitement.
Corrigé
let rec dfs g trait visites x =
if not visites.(x)
then begin
visites.(x) <- true;
List.iter (fun y ->
if not visites.(y)
then dfs g trait visites y
) g.ladj.(x);
trait x
end
Question 7. En déduire une fonction kosaraju qui renvoie un tableau
indiquant le numéro de la composante fortement connexe de chaque sommet.
Attention, cette fonction va être un peu compliquée, on indique les grandes lignes ici :
- on initialise un tableau de visites ;
- on va construire une liste
ordredes indices de sommets rencontrés lors d'un premier DFS de tout le graphe grâce à un post-traitement (avec une référence) ; - on calcule \(G^R\) et on initialise un tableau de visites ;
- on fait des DFS dans \(G^R\) mais en prenant les sommets depuis la
liste
ordre. En post-traitement, on va remplir un tableaucompoùcomp.(x)sera le numéro de la composante fortement connexe dex.
On commence par numéroter les composantes à 1.
Corrigé
let kosaraju g =
let n = Array.length g.sommets in
let ordre = ref [] in
let visites = Array.make n false in
for i = 0 to n-1 do
if not visites.(i)
then dfs g (fun x -> ordre := x :: !ordre) visites i
done;
let gr = graphe_miroir g in
let visites = Array.make n false in
let ncomp = ref 0 in
let comp = Array.make n 0 in
List.iter (fun ia ->
if not visites.(ia)
then begin
incr ncomp;
dfs gr (fun x -> comp.(x) <- !ncomp) visites ia
end
) !ordre;
comp
Comme on ajoute chaque sommet en tête de ordre au moment où il devient
traité, ordre contient les sommets par temps de sortie décroissant.
Pour tester :
assert (kosaraju ex_cours = [|3; 3; 4; 4; 1; 3; 3; 4; 2; 2; 4; 4; 2; 2; 4; 5|]);
"OK"
Question 8. Cette représentation des composantes va nous être utile mais
elle n'est pas très maniable. Écrire une fonction listes_comp telle que
listes_comp sommets comp renvoie le tableau des composantes données
sous forme de listes de sommets.
Corrigé
let listes_comp sommets comp =
let n = Array.fold_left max 0 comp in
let comp_l = Array.make n [] in
for i = 0 to Array.length comp - 1 do
let n = comp.(i) in
comp_l.(n-1) <- sommets.(i) :: comp_l.(n-1)
done;
comp_l
Pour tester :
assert (listes_comp ex_cours.sommets (kosaraju ex_cours) =
[|['e']; ['n'; 'm'; 'j'; 'i'];
['g'; 'f'; 'b'; 'a']; ['o'; 'l'; 'k'; 'h'; 'd'; 'c']; ['p']|]);
"OK"
Remarque
Pour tester kosaraju plus largement, on peut comparer son résultat sur
des petits graphes aléatoires avec une méthode naïve : on calcule pour
chaque sommet \(x\) l'ensemble des sommets accessibles par un parcours, et on
vérifie que comp.(x) = comp.(y) si et seulement si \(y\) est accessible
depuis \(x\) et \(x\) depuis \(y\). On peut aussi vérifier que pour toute arête
\(x \rightarrow y\), comp.(x) <= comp.(y) : c'est la propriété de
numérotation utilisée pour 2-SAT ci-dessous.
2-SAT¶
On va résoudre 2-SAT en utilisant l'algorithme de Kosaraju. Tout d'abord, on rappelle que les formules de 2-SAT sont des conjonctions de clauses comportant chacune deux littéraux (voir le chapitre Calcul propositionnel).
Exemple : \(f = (a \vee \neg b) \wedge (\neg a \vee b) \wedge (\neg a \vee \neg b) \wedge (a \vee \neg c)\)
On reprend un type proche de ce qui a été fait l'an dernier, sauf que les clauses sont des couples de littéraux.
type lit = Pos of char | Neg of char
type clause = lit * lit
type formule = clause list
La formule donnée au-dessus s'écrit alors :
let f = [ (Pos 'a', Neg 'b'); (Neg 'a', Pos 'b'); (Neg 'a', Neg 'b'); (Pos 'a', Neg 'c') ]
Question 9. Écrire une fonction label : lit -> char qui renvoie
l'étiquette d'un littéral et une fonction neg : lit -> lit qui renvoie le
littéral opposé d'un littéral donné.
Corrigé
let label = function Pos a | Neg a -> a
let neg = function Pos a -> Neg a | Neg a -> Pos a
Question 10. Écrire une fonction variables qui renvoie les étiquettes de
tous les littéraux sans répétitions. On pourra utiliser
List.sort_uniq Stdlib.compare.
Corrigé
let variables f =
let rec var_aux f =
match f with
| [] -> []
| c::f' -> var_clause c @ var_aux f'
and var_clause (l1,l2) = [label l1; label l2]
in List.sort_uniq Stdlib.compare (var_aux f)
Question 11. On peut écrire \(a \vee b\) sous la forme implicative \((\neg a \rightarrow b) \wedge(\neg b \rightarrow a)\).
En faisant cela, on peut alors construire un graphe sur les littéraux où \(l \rightarrow l'\) quand cette implication apparaît dans la formule.
Écrire une fonction implication_graphe qui étant donnée f renvoie ce
graphe.
Corrigé
let implication_graphe f =
let v = variables f in
let s = List.concat (List.map (fun x -> [Pos x; Neg x]) v) in
let sommets = Array.of_list s in
let g = cree_graphe sommets in
List.iter (fun (a,b) ->
ajoute_arete g (neg a) b;
ajoute_arete g (neg b) a
) f;
g
Les sommets sont rangés par variable : le sommet d'indice \(2i\) est
Pos x et celui d'indice \(2i+1\) est Neg x pour la \(i\)-ème variable x.
On cherche alors une valuation des variables telle qu'il n'y ait pas d'arête \(\top \rightarrow \perp\) d'un littéral vrai vers un littéral faux. En effet, toutes les autres affectations vérifient la clause correspondante. Ainsi, tous les littéraux d'une composante fortement connexe doivent nécessairement avoir la même valeur de vérité. Si \(C \rightarrow C'\) pour deux composantes, on ne peut pas affecter la valeur vraie à \(C\) et fausse à \(C'\). On remarque que le graphe est symétrique par contraposition, donc il existe une composante niée \(\neg C\) pour chaque composante \(C\).
L'idée pour résoudre 2-SAT est donc de calculer les composantes fortement connexes et de parcourir leur graphe acyclique en partant des puits, en affectant la valeur vraie à la première composante rencontrée entre \(C\) et \(\neg C\).
Pour résoudre 2-SAT, on va appliquer Kosaraju sur le graphe d'implication de la formule.
kosaraju (implication_graphe f)
Sur ce graphe, on remarque que si deux littéraux opposés sont dans la même composante fortement connexe, alors la formule est insatisfiable. Si ce n'est pas le cas, on peut démontrer que la valuation suivante fonctionne :
Cette affirmation repose sur deux propriétés :
- les composantes sont numérotées dans l'ordre croissant d'un tri topologique de leur graphe, qui est acyclique : pour toute arête \(x \rightarrow y\), \(comp(x) \le comp(y)\) ;
- le graphe d'implication possède une symétrie par contraposition : si \(a_1 \rightarrow a_2 \dots \rightarrow a_n\) alors \(\neg a_n \rightarrow \neg a_{n-1} \dots \rightarrow \neg a_1\).
Théorème
Une formule de 2-SAT est satisfiable si et seulement si aucune variable \(a\) n'est dans la même composante fortement connexe que \(\neg a\) dans son graphe d'implication. Dans ce cas, la valuation \(v\) ci-dessus est un modèle.
Preuve
Si \(v\) est un modèle, chaque arête \(l \rightarrow l'\) provient d'une clause \(\neg l \vee l'\) satisfaite par \(v\) : si \(l\) est vrai, \(l'\) aussi. Par récurrence, deux littéraux reliés par un chemin \(l \leadsto l'\) vérifient : si \(l\) est vrai, \(l'\) l'est. Si \(a\) et \(\neg a\) étaient dans la même composante, on aurait \(a \leadsto \neg a\) et \(\neg a \leadsto a\), donc \(a\) vrai entraîne \(\neg a\) vrai et réciproquement : impossible.
Réciproquement, supposons que \(comp(a) \neq comp(\neg a)\) pour toute variable \(a\). Montrons d'abord que la valuation \(v\) rend vrai le littéral \(l\) (positif ou négatif) si et seulement si \(comp(l) > comp(\neg l)\). C'est la définition si \(l = a\) ; si \(l = \neg a\), \(l\) est vrai si et seulement si \(comp(a) < comp(\neg a)\), c'est-à-dire \(comp(l) > comp(\neg l)\) puisque les deux numéros sont distincts.
Soit une clause \(l \vee l'\) et supposons par l'absurde que \(v\) rende \(l\) et \(l'\) faux : \(comp(l) < comp(\neg l)\) et \(comp(l') < comp(\neg l')\). Les arêtes \(\neg l \rightarrow l'\) et \(\neg l' \rightarrow l\) du graphe donnent, par la première propriété, \(comp(\neg l) \le comp(l')\) et \(comp(\neg l') \le comp(l)\). On obtient \(comp(l) < comp(\neg l) \le comp(l') < comp(\neg l') \le comp(l)\), ce qui est absurde. Toutes les clauses sont donc satisfaites.
La première propriété découle de l'étude de Kosaraju dans le cours : la deuxième passe découvre les composantes par temps de sortie décroissant, et une arête \(x \rightarrow y\) entre deux composantes distinctes impose \(t_s(\overline{y}) < t_s(\overline{x})\), donc \(comp(x) < comp(y)\).
On remarque que la preuve n'utilise pas directement la symétrie : celle-ci garantit simplement que \(\neg C\) est une composante, ce qui donne l'interprétation précédente en termes de composantes.
Question 12. En déduire une fonction resout_2sat qui étant donné une
formule renvoie None si elle est insatisfiable, et Some l où l est une
liste de couples (variable,valeur) représentant une valuation solution.
Corrigé
let resout_2sat f =
let g = implication_graphe f in
let comp = kosaraju g in
let n = Array.length g.sommets / 2 in
let valuation = ref [] in
let insoluble = ref false in
for i = 0 to n-1 do
(* g.sommets.(2*i) est Pos a et g.sommets.(2*i+1) est Neg a *)
let a = label g.sommets.(2*i) in
if comp.(2*i) = comp.(2*i+1)
then insoluble := true
else valuation := (a, comp.(2*i) > comp.(2*i+1)) :: !valuation
done;
if !insoluble
then None
else Some (List.rev !valuation)
Sur l'exemple, resout_2sat f renvoie
Some [('a', false); ('b', false); ('c', false)], qui est bien un modèle
de \(f\).
Remarque
On peut tester resout_2sat contre une méthode naïve qui essaie toutes
les valuations des variables : sur des milliers de formules aléatoires à
au plus 6 variables, quand resout_2sat renvoie Some v, on vérifie que
v satisfait la formule, et quand elle renvoie None, on vérifie
qu'aucune valuation ne la satisfait.