Exploration exhaustive et retour sur trace
Cours complet · informatique (MP2I/MPI), chapitre 14 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
14.1 Quand on ne sait rien de mieux
Les quatre chapitres qui suivent celui-ci — diviser pour régner, gloutons, programmation dynamique — sont des méthodes : elles exploitent une structure du problème pour éviter d'en examiner toutes les solutions. Ce chapitre traite du cas où l'on n'a pas cette chance : il faut les examiner.
Le programme donne à ces chapitres un objectif commun, et il est ambitieux : « donner des outils de conception d'algorithmes et parvenir à ce que les étudiants puissent, dans une situation simple, sélectionner une stratégie pertinente par eux-mêmes et la mettre en œuvre de façon autonome ».
14.2 La force brute
Énumérer toutes les solutions candidates, tester chacune, garder celles qui conviennent. C'est toujours correct, et c'est presque toujours trop lent.
| Ce qu'on énumère | Nombre | ||
|---|---|---|---|
| Les sous-ensembles de objets | |||
| Les permutations de objets | |||
| Les mots de longueur sur |
À un milliard d'opérations par seconde, la première ligne passe d'un millième de seconde à dix-huit minutes quand double ; la deuxième était déjà hors de portée à . Retenez cet écart : il justifie à lui seul tout le reste du livre.
Un sous-ensemble de se code par un entier de bits : le bit vaut si l'élément est pris.
/* Énumère les 2^n sous-ensembles de {0, ..., n-1}. Précondition : 0 <= n < 31. */
for (int masque = 0; masque < (1 << n); masque = masque + 1) {
for (int i = 0; i < n; i = i + 1) {
if ((masque >> i) % 2 == 1) { /* l'élément i est dans ce sous-ensemble */ }
}
}
Deux boucles, aucune récursion, aucune structure : c'est la manière la plus courte d'être exhaustif sur les parties d'un ensemble. Le coût est .
Bonne pratique (Ordonner les données avant de les parcourir)
Le programme suggère d'« évoquer l'intérêt d'ordonner les données avant de les parcourir (par exemple par une droite de balayage) ». Trier coûte une fois, et transforme parfois une recherche quadratique en recherche linéaire.
Exemple : trouver deux nombres d'un tableau dont la somme vaut . Par force brute, on essaie les paires : . En triant d'abord, deux indices partent des extrémités et se rapprochent — si la somme est trop grande on recule le droit, sinon on avance le gauche : pour le tri, puis .
14.3 Le retour sur trace
Plutôt que d'énumérer les solutions complètes, on les construit pas à pas. Dès qu'une solution partielle ne peut plus mener à rien, on l'abandonne et l'on revient au choix précédent.
Le procédé explore un arbre de recherche dont les nœuds sont les solutions partielles ; abandonner, c'est élaguer tout un sous-arbre sans le visiter.
Méthode : Le squelette, toujours le même
(* Étend la solution partielle sol ; appelle traiter sur chaque solution complète. *)
let rec explorer sol =
if complete sol then traiter sol
else
List.iter (fun choix ->
if acceptable sol choix then begin
let sol' = poser sol choix in
explorer sol'; (* on descend *)
(* et l'on remonte : sol est INCHANGÉE si poser ne l'a pas mutée *)
end) (choix_possibles sol)
Trois pièces à écrire pour chaque problème : complete, choix_possibles et surtout acceptable — c'est ce dernier qui élague, et c'est de lui seul que dépend l'efficacité.
Avec une structure modifiée en place, il faut annuler le choix au retour, faute de quoi les branches suivantes hériteraient de l'état de la précédente :
for (int choix = 0; choix < k; choix = choix + 1) {
if (acceptable(sol, choix)) {
poser(sol, choix);
explorer(sol);
defaire(sol, choix); /* <-- LA LIGNE QU'ON OUBLIE */
}
}
L'oubli de defaire ne produit ni erreur ni plantage : il produit des résultats faux, et le sens de l'erreur dépend de ce qu'on omet de défaire. Sur le cas le plus courant — un tableau utilise qu'on ne remet pas à false — les choix restent marqués pris, les branches suivantes n'ont plus rien à explorer, et l'énumération des permutations rend exactement une solution au lieu de . C'est un bogue typique du retour sur trace, invisible aux petits cas.
Placer reines sur un échiquier sans qu'aucune n'en attaque une autre. On place une reine par colonne, de gauche à droite.
(* Compte les placements valides de n reines. Précondition : n >= 1.
pos.(c) est la ligne de la reine de la colonne c ; on remplit de 0 à n-1. *)
let n_reines n =
let pos = Array.make n 0 in
let total = ref 0 in
let compatible c l =
(* la reine (c, l) n'attaque aucune des reines des colonnes 0 .. c-1 *)
let ok = ref true in
for j = 0 to c - 1 do
if pos.(j) = l || abs (pos.(j) - l) = c - j then ok := false
done;
!ok
in
let rec explorer c =
if c = n then incr total
else
for l = 0 to n - 1 do
if compatible c l then begin
pos.(c) <- l;
explorer (c + 1)
(* rien à défaire : pos.(c) sera réécrit au tour suivant *)
end
done
in
explorer 0;
!total
Ce que l'élagage rapporte, mesuré en exécutant cet algorithme et en comptant ses appels :
| solutions | nœuds visités | placements bruts | |
|---|---|---|---|
| 6 | 4 | 153 | |
| 8 | 92 | ||
| 10 | 724 |
Le facteur passe de à quand va de à : l'élagage gagne d'autant plus que le problème est gros, parce qu'il coupe des sous-arbres dont la taille croît, elle aussi, exponentiellement.
Une seule ligne fait tout le travail : abs (pos.(j) - l) = c - j teste les deux diagonales à la fois — deux reines sont sur une même diagonale exactement lorsque l'écart des lignes égale l'écart des colonnes.
Le problème des huit reines figure aussi parmi les exemples possibles d'algorithmes de Las Vegas, au chapitre chap:probabilistes : on tire des placements au hasard jusqu'à en trouver un valide. Deux méthodes, un même problème — et c'est délibéré, le programme les met en regard.
objets de poids et de valeur , un sac de capacité . On veut la valeur maximale transportable.
(* Valeur maximale d'un sous-ensemble d'objets de poids total <= c.
Précondition : c >= 0, i est l'indice du prochain objet examiné. *)
let rec sac p v c i =
if i >= Array.length p then 0
else
let sans = sac p v c (i + 1) in
if p.(i) > c then sans (* ÉLAGAGE : l'objet ne rentre pas *)
else max sans (v.(i) + sac p v (c - p.(i)) (i + 1))
La complexité reste dans le pire cas. Ce même problème sera résolu en au chapitre chap:dynamique, et de façon approchée en glouton au chapitre chap:gloutons : trois méthodes, trois compromis entre exactitude et coût.
14.4 Ce que le retour sur trace ne résout pas
Le retour sur trace divise le travail par des facteurs considérables — huit mille sur les huit reines — mais il reste exponentiel : il n'y a pas de garantie polynomiale. Sur une instance défavorable, aucune branche n'est élaguée et l'on retrouve la force brute.
C'est pourquoi le programme enchaîne, en deuxième année, sur trois façons de composer avec cette limite :
- la séparation et évaluation (branch and bound), qui élague à l'aide d'une borne sur ce que la branche peut encore rapporter — chapitre chap:probabilistes ;
- les algorithmes d'approximation, qui rendent une solution garantie proche de l'optimum, vite ;
- les algorithmes probabilistes, qui acceptent un risque d'erreur ou un temps aléatoire.
Et le chapitre chap:decidabilite expliquera pourquoi ces contournements existent : pour les problèmes np-complets, personne ne connaît d'algorithme polynomial, et l'on ignore s'il en existe.
14.5 Ce qu'il faut retenir
- La force brute est toujours correcte : c'est la solution de repli, et le point de comparaison de toutes les autres.
- , , : mesurer l'ordre de grandeur avant d'écrire. À , l'exhaustif sur les sous-ensembles est déjà hors de portée.
- Le retour sur trace construit pas à pas et abandonne tôt. Tout dépend du test d'acceptabilité — et, en version mutable, de la ligne qui défait.
- Élaguer accélère énormément sans changer la classe : cela reste exponentiel. Les méthodes des chapitres suivants, elles, changent la classe — quand la structure du problème le permet.