Aller au contenu

Manipulez des listes

Au chapitre 4, nous avons représenté un itinéraire par un terme comme par(laval, par(le_mans, rien)). Cela fonctionne, mais c’est lourd à écrire et chaque programme invente sa propre convention. Prolog fournit une structure standard pour les suites d’éléments, avec une syntaxe dédiée : la liste. C’est la structure de données reine du langage.

Dans ce chapitre, vous allez comprendre comment une liste est faite, écrire les prédicats classiques qui les manipulent, et découvrir la technique de l’accumulateur. Notre fil rouge : une portion de la ligne a du métro de Rennes.

% les stations de la ligne a, de J.F. Kennedy vers Gares
ligne_a([j_f_kennedy, villejean_universite, pontchaillou, anatole_france,
sainte_anne, republique, charles_de_gaulle, gares]).

Une liste est une structure récursive, définie à partir de deux éléments :

  • une constante qui représente la liste vide, notée [] ;
  • un foncteur binaire, le constructeur (historiquement noté .), dont le premier argument est le premier élément (la tête) et le second le reste de la liste (la queue).

La suite 1, 2, 3 est donc le terme .(1, .(2, .(3, []))). Dessiné comme un arbre, il a la forme d’un peigne : chaque nœud porte un élément à gauche et le reste à droite, jusqu’à la liste vide.

Heureusement, personne n’écrit cela. On utilise la notation allégée : [1, 2, 3], et surtout la barre verticale, qui sépare les premiers éléments du reste. [X|L] désigne une liste de tête X et de queue L. Toutes ces écritures désignent le même terme :

[1, 2, 3] = [1 | [2, 3]] = [1, 2 | [3]] = [1, 2, 3 | []]
...123[ ]T = 1Q = [2, 3][1, 2, 3] = .(1, .(2, .(3, [ ])))[T | Q] sépare la tête (un élément)de la queue (une liste)[ ] n’a ni tête ni queue
Une liste est un peigne : chaque nœud porte une tête à gauche et le reste de la liste à droite. [T | Q] coupe juste sous la racine.

Cette barre, combinée à l’unification, permet de découper une liste sans aucune fonction d’accès :

?- [a, b, c, d] = [T | Q].
T = a, Q = [b, c, d]
?- [a, b, c, d] = [X, Y | Reste].
X = a, Y = b, Reste = [c, d]
?- [a] = [T | Q].
T = a, Q = []
?- [] = [T | Q].
No

La liste vide n’a ni tête ni queue : elle ne s’unifie pas avec [T|Q]. C’est ce qui distingue le cas de base du cas récursif dans tous les prédicats qui suivent.

La définition d’une liste se transcrit d’ailleurs directement :

liste([]).
liste([_|Q]) :- liste(Q).

Une station fait-elle partie de la ligne ? X appartient à une liste si c’est sa tête, ou s’il appartient à sa queue :

membre(X, [X|_]).
membre(X, [_|Q]) :- membre(X, Q).

Deux clauses, et un prédicat qui sert dans plusieurs modes.

  • En mode (+, +), il vérifie : ?- ligne_a(L), membre(republique, L). répond Yes.
  • En mode (-, +), il énumère les éléments : ?- membre(X, [10, 20]). donne X = 10, puis X = 20, puis « No ».
  • En mode (+, -), il génère des listes qui contiennent l’élément : ?- membre(10, L). donne L = [10|_], puis L = [_, 10|_], puis L = [_, _, 10|_]… une infinité de réponses.

Remarquez que membre(20, [10, 20, 30, 20, 40]) réussit deux fois, une par occurrence de 20 : l’arbre de recherche a deux feuilles de succès. Ce détail compte quand membre est utilisé au milieu d’un programme : chaque occurrence relancera la suite. La plupart des systèmes fournissent ce prédicat sous le nom member/2.

membre(20, [10, 20, 30, 20, 40])20 ≠ 10 : échecmembre(20, [20, 30, 20, 40])succès n° 1membre(20, [30, 20, 40])20 ≠ 30 : échecmembre(20, [20, 40])succès n° 2membre(20, [40])20 ≠ 40 : échecmembre(20, [])aucune clause : échecclause 1 : X est la tête ?clause 2 : on cherche dans la queue
La clause 1 teste la tête, la clause 2 relance sur la queue : 20 est trouvé deux fois, donc membre réussit deux fois.

Quelle est la longueur d’une liste ? Celle de sa queue, plus un :

% longueur(+L, ?N)
longueur([], 0).
longueur([_|Q], N) :- longueur(Q, NQ), N is NQ + 1.

Cette version marche, mais observez son déroulement pour une liste de 8 stations : Prolog descend jusqu’à la liste vide en empilant 8 additions en attente, puis les effectue en remontant. Chaque appel doit garder en mémoire le calcul qui reste à faire après lui.

On peut compter en descendant, avec un argument supplémentaire qui porte le résultat partiel : l’accumulateur.

longueur(L, N) :- longueur(L, 0, N). % on démarre le compte à 0
% longueur(+L, +Acc, ?N) : N = Acc + longueur de L
longueur([], Acc, Acc). % liste finie : le compte est le résultat
longueur([_|Q], Acc, N) :-
Acc1 is Acc + 1,
longueur(Q, Acc1, N).

L’appel récursif est maintenant le dernier but de la clause : on parle de récursion terminale. Prolog peut alors libérer la mémoire de l’appel courant avant de faire l’appel suivant, exactement comme une boucle. Notez aussi que longueur/2 et longueur/3 sont deux prédicats distincts : c’est une convention courante de nommer l’auxiliaire comme le prédicat principal, avec un argument de plus. Le prédicat prédéfini s’appelle length/2.

longueur([a, b], N)en attente :N is NQ + 1longueur([b], NQ)en attente :NQ is NQ2 + 1longueur([ ], NQ2)remontée : 0, puis 1, puis 2sans accumulateurlongueur([a, b], 0, N)Acc1 = 1longueur([b], 1, N)Acc1 = 2longueur([ ], 2, N)clause d’arrêt : N = 2, rien n’attendavec accumulateur (récursion terminale)
Sans accumulateur, chaque appel garde une addition en attente jusqu'à la remontée. Avec, le compte avance en descendant et rien n'attend : c'est une boucle.

Pour mettre deux listes bout à bout, on ne peut pas écrire [L1|L2] : avec L1 = [1, 2, 3] et L2 = [4, 5], on obtiendrait [[1, 2, 3], 4, 5], une liste dont le premier élément est une liste. Il faut recopier les éléments de la première devant la seconde :

% concat(L1, L2, L3) : L3 est L1 suivie de L2
concat([], L2, L2).
concat([X|R], L2, [X|L3]) :- concat(R, L2, L3).

Ce prédicat (souvent appelé append/3) est bien plus qu’une concaténation, grâce à l’unification. En mode (-, -, +), il découpe une liste de toutes les façons possibles :

?- concat(X, Y, [a, b, c]).
X = [], Y = [a, b, c] ;
X = [a], Y = [b, c] ;
X = [a, b], Y = [c] ;
X = [a, b, c], Y = [] ;
No
[ ]abcX = []Y = [a, b, c]abcX = [a]Y = [b, c]abcX = [a, b]Y = [c]abc[ ]X = [a, b, c]Y = []la séparation (en ambre) avance d’un cran à chaque retour arrière
En mode (−, −, +), concat essaie chaque point de coupe, de gauche à droite : quatre solutions pour une liste de trois éléments.

Et avec un motif dans le troisième argument, il cherche un élément et sépare ce qui est avant et après :

?- ligne_a(L), concat(Avant, [republique | Apres], L).
Avant = [j_f_kennedy, villejean_universite, pontchaillou, anatole_france, sainte_anne],
Apres = [charles_de_gaulle, gares]

Le dernier élément d’une liste s’obtient de la même façon : dernier(X, L) :- concat(_, [X], L).

Construisons maintenant de nouvelles listes. Un bus express ne s’arrête qu’une station sur deux : on veut les éléments de rang impair.

% elem_impair(+L, -R) : R contient les éléments de rang 1, 3, 5...
elem_impair([], []).
elem_impair([X], [X]).
elem_impair([X, _|Q], [X|R]) :- elem_impair(Q, R).

Sur la ligne a, on obtient [j_f_kennedy, pontchaillou, sainte_anne, charles_de_gaulle]. Ici le résultat est construit dans la tête de la clause : [X|R] est posé tout de suite, et l’appel récursif remplit R. On parle de construction directe ou descendante : les éléments du résultat sont dans le même ordre que ceux de la liste de départ.

Pour le trajet retour, il faut inverser la liste. On pourrait retourner la queue puis concaténer la tête à la fin, mais chaque concaténation reparcourt tout le résultat : le coût devient quadratique. La bonne solution utilise un accumulateur, dans lequel on empile les éléments un par un, comme on retourne une pile d’assiettes :

renverser(L, R) :- renverser(L, [], R).
% renverser(+L, +Acc, -R) : R est L inversée, suivie de Acc
renverser([], Acc, Acc).
renverser([X|Q], Acc, R) :- renverser(Q, [X|Acc], R).

Pour [1, 2, 3], l’accumulateur passe par [], [1], [2, 1], [3, 2, 1], puis la première clause le transmet au résultat. Cette construction est dite ascendante : le résultat reste inconnu pendant toute la descente, les éléments se retrouvent en ordre inverse, et le résultat n’est rendu qu’à la fin, par la clause d’arrêt (on parle de passage final de paramètre). Le coût est linéaire.

L (à parcourir)Acc (accumulateur)appel 1123[ ]appel 2231appel 3321appel 4[ ]321→ R = [3, 2, 1]la tête de L passeen tête de Acc :l’ordre s’inverse
Chaque tête de L est empilée en tête de l'accumulateur, comme une pile d'assiettes : l'ordre s'inverse, et la clause d'arrêt rend R = [3, 2, 1].
ConstructionOù se construit le résultatOrdre des élémentsExemple
Directe (descendante)dans la tête de la clause récursivemême ordreelem_impair, concat
Avec accumulateur (ascendante)dans un argument supplémentaire, dans le corpsordre inverserenverser, longueur/3

Pour trouver la N-ième station, on descend dans la liste en décomptant :

% nieme(+N, +L, ?X) : X est le N-ième élément de L (en comptant à partir de 1)
nieme(1, [X|_], X).
nieme(N, [_|Q], X) :-
N > 1,
N1 is N - 1,
nieme(N1, Q, X).

?- ligne_a(L), nieme(3, L, X). donne X = pontchaillou. La garde N > 1 empêche la seconde clause de s’appliquer quand N vaut 1, ce qui ferait continuer la recherche vers 0, −1… inutilement. Ce prédicat ne marche qu’avec N connu : c’est le prix de is.

  • Une liste est [] ou [T|Q] ; [1, 2, 3] n’est qu’une écriture de [1|[2|[3|[]]]], un arbre en forme de peigne.
  • L’unification avec des motifs comme [X, Y|Reste] remplace toutes les fonctions d’accès.
  • membre vérifie et énumère ; en mode (+, -), il génère une infinité de listes.
  • concat concatène, mais aussi découpe une liste de toutes les façons ou isole un élément.
  • La construction directe place le résultat dans la tête (même ordre) ; la construction avec accumulateur le construit dans le corps (ordre inverse, rendu à la fin).
  • L’accumulateur donne une récursion terminale : longueur/3 et renverser/3 sont linéaires et économes en mémoire.

Exercice · Unifier des listes

facile

Donnez le plus général unificateur, s’il existe :

  1. [a, b, c, d] et [Y|Z]
  2. concat([a, b], [c, d], L) et concat([X|X1], Y, [X|Z])
  3. p([a, b], [c], [Y, Z|T]) et p(A, B, [A|B])
  4. [X, X|_] et [a, b, c]
Voir la solution
  1. Y = a, Z = [b, c, d].
  2. Le premier argument donne X = a, X1 = [b] ; le deuxième, Y = [c, d] ; le troisième, L = [a|Z]. Le pgu est X = a, X1 = [b], Y = [c, d], L = [a|Z] : Z reste libre.
  3. A = [a, b] et B = [c]. Le troisième argument devient [Y, Z|T] = [[a, b]|[c]], c’est-à-dire [[a, b], c]. Donc Y = [a, b] (une liste !), Z = c et T = [].
  4. Pas d’unificateur : le premier élément impose X = a, le second X = b.

En code

?- [a, b, c, d] = [Y|Z].
% Y = a, Z = [b, c, d]
?- concat([a, b], [c, d], L) = concat([X|X1], Y, [X|Z]).
% X = a, X1 = [b], Y = [c, d], L = [a|Z]
?- p([a, b], [c], [Y, Z|T]) = p(A, B, [A|B]).
% A = [a, b], B = [c], Y = [a, b], Z = c, T = []
?- [X, X|_] = [a, b, c].
% No

Exercice · Listes triées et fusion

moyen
  1. Écrivez est_triee(+L), vrai si la liste d’entiers L est triée en ordre croissant (au sens large). [] et [1, 1, 1] sont triées, [3, 2] ne l’est pas.
  2. Écrivez fusion(+L1, +L2, -L3), qui fusionne deux listes triées en une liste triée, en temps linéaire. Par exemple, fusion([4, 10, 20], [1, 11, 15], L) donne L = [1, 4, 10, 11, 15, 20].
Voir la solution
  1. On compare les deux premiers éléments, puis on continue avec la queue en gardant le second (onglet Prolog ci-dessous).

  2. On compare les deux têtes et on place la plus petite en tête du résultat (construction directe).

La deuxième clause s’écrit fusion([X|Q], [], [X|Q]) plutôt que fusion(L, [], L) : sinon, pour deux listes vides, les deux premières clauses s’appliqueraient et la réponse sortirait deux fois. Chaque appel récursif retire un élément de L1 ou de L2 : le nombre d’appels est la somme des longueurs, d’où le coût linéaire.

En code

est_triee([]).
est_triee([_]).
est_triee([X, Y|Q]) :- X =< Y, est_triee([Y|Q]).
fusion([], L, L).
fusion([X|Q], [], [X|Q]).
fusion([X|Q1], [Y|Q2], [X|R]) :- X =< Y, fusion(Q1, [Y|Q2], R).
fusion([X|Q1], [Y|Q2], [Y|R]) :- X > Y, fusion([X|Q1], Q2, R).
% ?- est_triee([1, 1, 1]). Yes
% ?- est_triee([3, 2]). No
% ?- fusion([4, 10, 20], [1, 11, 15], L). L = [1, 4, 10, 11, 15, 20]

Exercice · Un itinéraire sans boucle

difficile

On reprend le réseau du chapitre 4, en ajoutant un retour liaison(paris, rennes). qui crée un cycle. Le prédicat chemin/2 peut alors tourner indéfiniment.

Écrivez itineraire(+X, +Y, -I), où I est la liste des villes traversées de X à Y (extrémités comprises), sans passer deux fois par la même ville. On pourra écrire un prédicat auxiliaire chemin_sur(X, Y, Vus, I) qui mémorise dans Vus les villes déjà visitées, et un prédicat hors(X, L) vrai si X n’est pas dans la liste L (utilisez \=, qui réussit si deux termes ne sont pas unifiables).

Quelles réponses obtient-on pour itineraire(rennes, paris, I) et itineraire(paris, saint_malo, I) ?

Voir la solution

Vus est un accumulateur : les villes y sont empilées en ordre inverse, et on le retourne une fois arrivé. Le test hors(Z, Vus) interdit de revenir sur une ville déjà visitée. Comme chaque appel ajoute une ville nouvelle et que le réseau est fini, la recherche termine toujours, même avec des cycles.

  • itineraire(rennes, paris, I) donne I = [rennes, laval, le_mans, paris], puis I = [rennes, vannes, nantes, angers, le_mans, paris], puis « No ».
  • itineraire(paris, saint_malo, I) donne I = [paris, rennes, saint_malo] : le cycle est utilisé une fois, sans boucler.

Le test hors n’utilise \= que sur des villes connues : nous verrons au chapitre 8 pourquoi les tests « négatifs » doivent toujours porter sur des termes instanciés.

En code

hors(_, []).
hors(X, [Y|Q]) :- X \= Y, hors(X, Q).
itineraire(X, Y, I) :- chemin_sur(X, Y, [X], I).
% chemin_sur(+X, +Y, +Vus, -I) : Vus contient les villes visitées, la plus récente en tête
chemin_sur(X, Y, Vus, I) :-
liaison(X, Y),
renverser([Y|Vus], I).
chemin_sur(X, Y, Vus, I) :-
liaison(X, Z),
hors(Z, Vus),
chemin_sur(Z, Y, [Z|Vus], I).
% avec liaison(paris, rennes) en plus du réseau du chapitre 4 :
% ?- itineraire(rennes, paris, I).
% I = [rennes, laval, le_mans, paris] ;
% I = [rennes, vannes, nantes, angers, le_mans, paris] ;
% No
% ?- itineraire(paris, saint_malo, I).
% I = [paris, rennes, saint_malo]