Skip to main content Informatique en prépa - Lycée Malherbe 

4- Retour sur trace et files de priorité

I. Retour sur trace

a) Principe

Beaucoup de problèmes consistent à trouver une solution parmi un très grand nombre de possibilités (placer des pièces, remplir une grille...). L'algorithme de retour sur trace (backtracking) construit une solution pas à pas :

On explore ainsi un arbre des choix possibles, en profondeur, mais en coupant les branches qui ne peuvent pas mener à une solution : c'est beaucoup plus efficace que d'essayer toutes les combinaisons.

Retour sur trace
Entrée : une solution partielle
1.Si la solution partielle est complète
2.Renvoyer la solution
3.Pour chaque choix compatible avec la solution partielle
4.Ajouter ce choix à la solution partielle
5.Si la recherche à partir de la nouvelle solution partielle réussit
6.Renvoyer la solution trouvée
7.Retirer ce choix (retour en arrière)
8.Renvoyer "échec"

b) Un exemple : rendre la monnaie

On veut rendre une somme avec des pièces de 5 et de 4 (en nombre illimité, mais sans pièce de 1). Par exemple, pour :

Voici l'arbre des choix explorés : en rouge, les impasses ; en vert, la solution.

s8 8 s3 3 s8->s3 5 s4 4 s8->s4 4 s0 0 s4->s0 4

Depuis 3, aucune pièce ne convient : c'est une impasse, et on revient en 8 pour essayer la pièce suivante.

OCaml

Loading editor...

(* renvoie Some (liste de pièces de somme s), ou None s'il n'y en a pas ;
   les pièces sont essayées dans l'ordre de la liste *)
let rec rendre (pieces : int list) (s : int) : int list option =
  if s = 0 then Some []
  else
    let rec essayer (l : int list) : int list option = match l with
      | [] -> None                          (* plus aucun choix : impasse *)
      | p :: q ->
        if p > s then essayer q
        else match rendre pieces (s - p) with
          | Some solution -> Some (p :: solution)
          | None -> essayer q               (* retour en arrière : pièce suivante *)
    in
    essayer pieces;;

rendre [5; 4] 8;;
rendre [5; 4] 13;;
rendre [5; 4] 11;;

La fonction essayer parcourt les choix possibles (les pièces) ; si l'appel récursif échoue, on passe au choix suivant.

Dans le TP 4.2, on applique cette méthode à deux problèmes plus gros : le placement de huit reines sur un échiquier, et la résolution d'un sudoku.

c) Lien avec min-max

L'algorithme min-max (vu en ITC) explore lui aussi un arbre de choix en profondeur : celui des coups possibles des deux joueurs. La différence est qu'on ne cherche pas une solution, mais le meilleur coup, en supposant que l'adversaire joue au mieux.

II. Files de priorité

a) Interface

Une file de priorité est une structure qui stocke des éléments associés à des priorités (ici, des entiers), avec les opérations :

On l'implémente par un tas min stocké dans un tableau : la case 0 contient la racine, et les fils de la case i sont les cases 2i + 1 et 2i + 2. Le tableau a une taille fixée à la création : la file est bornée, et mutable.

Essayez ces opérations ci-dessous :

123171936725100
1 0
2 1
3 2
17 3
19 4
36 5
7 6
25 7
100 8
Complexité

Un tas de éléments a une hauteur : l'ajout et l'extraction du minimum sont en .

b) Utilisation

L'implémentation en OCaml est l'objet du TP 4.1 (partie III). La cellule suivante la contient : exécutez-la pour pouvoir utiliser les fonctions creer, ajouter, extraire_min et est_vide dans la suite.

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Par exemple :

OCaml

Loading editor...

let f = creer 10 "";;
List.iter (fun (x, p) -> ajouter f x p) [("réviser", 3); ("manger", 1); ("dormir", 2); ("jouer", 5)];;
while not (est_vide f) do
  let (p, x) = extraire_min f in
  Printf.printf "%s (priorité %d)\n" x p
done;;

III. Applications

a) Tri par tas

On ajoute tous les éléments dans une file de priorité, puis on les extrait dans l'ordre :

OCaml

Loading editor...

let tri_tas (l : int list) : int list =
  let f = creer (List.length l) 0 in
  List.iter (fun x -> ajouter f x x) l;
  List.init (List.length l) (fun _ -> snd (extraire_min f));;

tri_tas [5; 2; 9; 1; 5; 6; 3];;

La complexité est , comme le tri fusion.

b) Algorithme de Dijkstra

Dans l'algorithme de Dijkstra, on extrait à chaque étape le sommet non visité le plus proche. Avec une file de priorité, on ajoute un sommet à chaque fois qu'on améliore sa distance ; quand on extrait un sommet déjà visité, on l'ignore.

Algorithme de Dijkstra avec une file de priorité
Entrées : G un graphe pondéré (poids positifs), d un sommet de départ
Sortie : dist, la distance de d à chaque sommet
1.Soit dist un tableau rempli de +∞, et dist[d] ← 0
2.Soit F une file de priorité vide ; ajouter d à F avec la priorité 0
3.Tant que F n'est pas vide
4.Extraire de F le sommet s de plus petite priorité p
5.Si s n'est pas encore visité
6.Marquer s comme visité
7.Pour chaque voisin v de s, relié par une arête de poids w
8.Si p + w < dist[v]
9.dist[v] ← p + w
10.Ajouter v à F avec la priorité p + w
11.Renvoyer dist

Un sommet peut être ajouté plusieurs fois dans la file (une fois par amélioration de sa distance) : seule la première extraction compte, avec la plus petite distance.

Par exemple, sur le graphe orienté suivant, depuis le sommet 0, on obtient les distances :

0 0 1 1 0->1 4 2 2 0->2 1 3 3 1->3 1 2->1 2 2->3 5

L'implémentation en OCaml (avec la file de priorité de la partie II) :

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Chaque arête ajoute au plus un élément dans la file : avec sommets et arêtes, la complexité est .

c) Algorithme de Kruskal

Pour construire un arbre couvrant de poids minimal d'un graphe non orienté pondéré, l'algorithme de Kruskal considère les arêtes par poids croissant, et garde une arête si elle ne crée pas de cycle (voir le chapitre suivant, sur les graphes). Une file de priorité donne les arêtes dans l'ordre :

Algorithme de Kruskal avec une file de priorité
Entrées : G un graphe non orienté pondéré et connexe, à n sommets
Sortie : A, un arbre couvrant de poids minimal (un ensemble d'arêtes)
1.Soit F une file de priorité contenant toutes les arêtes, avec leur poids pour priorité
2.Soit A un ensemble vide ; chaque sommet forme sa propre composante
3.Tant que F n'est pas vide
4.Extraire de F l'arête (a, b) de plus petit poids
5.Si a et b ne sont pas dans la même composante
6.Ajouter (a, b) à A
7.Fusionner les composantes de a et de b
8.Renvoyer A

Pour savoir si deux sommets sont dans la même composante, chaque composante a un représentant : chaque sommet connaît un "parent" dans sa composante, et on remonte les parents jusqu'au représentant. Pour fusionner deux composantes, le représentant de l'une devient le parent du représentant de l'autre.

Par exemple, sur le graphe de 5 sommets dont les arêtes sont (poids 4), (1), (2), (5), (8), (3) et (9), on garde les arêtes , , et , pour un poids total de 11.

L'implémentation en OCaml :

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

Exercices

Exercice 1

Écrire une fonction k_plus_petits (l : int list) (k : int) : int list qui renvoie les plus petits éléments d'une liste, en , à l'aide d'une file de priorité.

OCaml

Loading editor...

let k_plus_petits (l : int list) (k : int) : int list =
  let f = creer (List.length l) 0 in
  List.iter (fun x -> ajouter f x x) l;
  List.init (min k (List.length l)) (fun _ -> snd (extraire_min f));;
Exercice 2

Modifier la fonction dijkstra pour renvoyer aussi le tableau des prédécesseurs (le sommet par lequel on arrive sur le plus court chemin).

OCaml

Loading editor...

On ajoute un tableau pred = Array.make n (-1), et on écrit pred.(v) <- s à chaque fois qu'on améliore dist.(v). On renvoie le couple (dist, pred).