Listes et filtrage
Cours complet · OCaml (option informatique), chapitre 2 · prépas MPSI et MP, option informatique
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>2.1 Introduction et motivation
Le chapitre précédent manipulait des valeurs isolées — un entier, un booléen, un couple. Pour calculer sérieusement, il faut des collections : une suite de notes, les sommets d'un graphe, les caractères d'un mot. En OCaml, la structure de donnée fondamentale est la liste — non par hasard, mais parce qu'elle épouse exactement le style du langage : on la construit et on l'inspecte par filtrage, et on la parcourt par récursion.
Ce chapitre est doublement central. D'une part il introduit la liste, omniprésente jusqu'à la fin du cours. D'autre part il présente le filtrage par motifs (match), le mécanisme qui, en OCaml, remplace à lui seul la plupart des if imbriqués : on décrit la forme de la valeur examinée, et le compilateur vérifie qu'aucun cas n'a été oublié. On rencontrera aussi le type option, manière propre et sûre de modéliser l'absence de résultat — l'équivalent honnête du None de Python, mais contrôlé par le typeur.
2.2 Le type liste
Une liste OCaml est une suite finie, ordonnée et homogène de valeurs : tous ses éléments ont le même type. Une liste d'entiers a le type int list, une liste de booléens bool list, etc.
# [1; 2; 3];;
- : int list = [1; 2; 3]
# ["a"; "b"];;
- : string list = ["a"; "b"]
# [];;
- : 'a list = []
Les éléments sont séparés par des points-virgules ;, pas par des virgules. [1, 2, 3] n'est pas une erreur de syntaxe mais un piège : la virgule y forme un n-uplet, si bien que [1, 2, 3] est une liste à un seul élément, le triplet (1, 2, 3) — de type (int int int) list. Une liste homogène d'entiers s'écrit [1; 2; 3].
2.2.1 Les deux constructeurs
Toute liste est bâtie à partir de deux constructeurs seulement :
[](« liste vide », lu nil) : la liste sans aucun élément ;::(« cons ») :x :: rest la liste dont le premier élément (la tête) estxet dont le reste (la queue) est la lister.
La notation [1; 2; 3] n'est qu'un sucre syntaxique pour 1 :: 2 :: 3 :: []. L'opérateur :: associe à droite, donc 1 :: (2 :: (3 :: [])).
# 1 :: [2; 3];;
- : int list = [1; 2; 3]
# 0 :: 1 :: 2 :: [];;
- : int list = [0; 1; 2]
:: ajoute un élément en tête, en temps constant, sans modifier la liste de départ : les listes sont immuables. x :: r ne « change » pas r ; elle construit une nouvelle liste qui partage r comme queue. On ne peut pas, en revanche, ajouter efficacement en fin de liste : c'est la tête qui est le point d'accès naturel.
2.3 Le filtrage par motifs
Pour examiner une valeur selon sa forme, OCaml offre le filtrage (pattern matching) :
match expression with
| motif_1 -> resultat_1
| motif_2 -> resultat_2
| ...
L'expression est confrontée aux motifs dans l'ordre ; le premier qui « colle » fournit le résultat. Sur une liste, deux motifs suffisent à tout couvrir : [] (la liste est vide) et x :: reste (elle a au moins un élément, de tête x et de queue reste).
let decrire l =
match l with
| [] -> "liste vide"
| [x] -> "un seul élément"
| x :: y :: _ -> "au moins deux éléments"
Le motif [x] filtre les listes à exactement un élément ; x :: y :: _ filtre celles d'au moins deux (le _ ignore la queue). Les noms x, y introduits dans un motif sont utilisables dans le résultat correspondant.
Un filtrage doit être exhaustif : tout cas possible doit être couvert. Si l'on oublie un cas, le compilateur émet un avertissement Warning: this pattern-matching is not exhaustive, et le programme lèvera une exception s'il rencontre la forme oubliée à l'exécution. Cet avertissement est un cadeau : il signale, gratuitement, un cas que l'on n'a pas pensé à traiter. On ne l'ignore jamais.
Dans un motif, on ne peut pas réutiliser une variable déjà liée ni nommer deux fois la même variable. Le motif x :: x :: _, qui voudrait dire « les deux premiers éléments sont égaux », est interdit ; il faut filtrer x :: y :: _ puis tester x = y. Le symbole _ (joker), lui, peut apparaître autant de fois qu'on veut : il ne lie rien.
L'ordre des motifs compte dès qu'ils ont des cas communs : le premier qui colle gagne. Un motif _ placé en tête masquerait tous les suivants (le compilateur le signale par unused match case). On va donc du plus particulier au plus général.
2.4 La récursion sur les listes
Comme une liste est, par construction, soit vide, soit « une tête suivie d'une queue (qui est une liste plus courte) », elle s'analyse naturellement par récursion, sur exactement ce découpage.
Méthode : Traiter une liste par récursion
On filtre la liste selon ses deux formes :
- cas de base
[]: que renvoyer pour la liste vide ? - cas récursif
x :: reste: comment combiner la têtexavec le résultat de l'appel récursif surreste(plus courte) ?
La terminaison est assurée : la queue est strictement plus courte, on atteint donc [] en un nombre fini d'étapes.
let rec longueur l =
match l with
| [] -> 0
| _ :: reste -> 1 + longueur reste
La tête n'importe pas (d'où _), seul compte qu'il y ait un élément de plus que dans la queue. Déroulé : longueur [7; 8] 1 + longueur [8] 1 + (1 + longueur []) 1 + (1 + 0) 2.
let rec somme l =
match l with
| [] -> 0
| x :: reste -> x + somme reste
Ici la tête x sert : on l'ajoute à la somme de la queue. Le cas de base renvoie 0, élément neutre de l'addition — un choix qui n'est pas anodin (voir l'exercice sur le produit).
Le module List fournit List.length et d'autres fonctions toutes faites, que l'on rappellera le moment venu. Mais l'objectif de ce chapitre est de savoir les réécrire : c'est en maîtrisant le schéma [] / x :: reste qu'on saura programmer toutes les autres.
2.5 Concaténation et coût
2.5.1 L'opérateur @
La concaténation @ met bout à bout deux listes de même type.
# [1; 2] @ [3; 4; 5];;
- : int list = [1; 2; 3; 4; 5]
Complexité : Coût de `@`
l1 @ l2 se calcule en parcourant entièrement la liste de gauche l1 pour en recopier les éléments devant l2 (qui, elle, est partagée sans copie). Le coût est donc proportionnel à la longueur de l1, et indépendant de celle de l2. En conséquence, ajouter un élément en fin de liste par l @ [x] coûte un parcours complet de l : à éviter dans une boucle (on accumulerait un coût quadratique), là où x :: l est en temps constant.
On peut réécrire @ pour comprendre ce coût :
let rec concatene l1 l2 =
match l1 with
| [] -> l2
| x :: reste -> x :: concatene reste l2
On voit que chaque élément de l1 provoque un :: : il y en a exactement la longueur de l1.
2.6 Le type option
Comment une fonction signale-t-elle « il n'y a pas de résultat » — la tête d'une liste vide, la recherche d'un absent ? En OCaml, on ne renvoie pas une valeur bidon : on emploie le type option.
Pour tout type 'a, le type 'a option a exactement deux formes de valeurs :
None: l'absence de valeur ;Some v: la présence de la valeurv(de type'a).
On exploite une option par filtrage : les deux cas None et Some v forment un filtrage exhaustif, ce qui force l'appelant à traiter le cas d'absence.
let tete_option l =
match l with
| [] -> None
| x :: _ -> Some x
# tete_option [10; 20];;
- : int option = Some 10
# tete_option [];;
- : int option = None
À l'usage, on est obligé de distinguer Some x de None par un filtrage : impossible d'oublier le cas vide, le typeur veille. C'est tout l'intérêt de option sur une valeur sentinelle.
L'autre voie pour signaler l'échec est l'exception failwith "message", qui interrompt le calcul. On la réserve aux situations qui violent une précondition (« cette fonction ne doit jamais être appelée sur une liste vide »). Lorsque l'absence est un résultat normal et attendu, option est préférable, car elle se traite sans interrompre le programme.
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>2.7 Exercices résolus
Niveau (application directe du cours)
Donner le type et la valeur affichés par le toplevel.
[true; false; true] ;; 1 :: 2 :: [] ;; [3] @ [] ;; [[1]; [2; 3]] ;;
Puis expliquer pourquoi [1; "deux"] est refusée.
Démonstration
- : bool list = [true; false; true]
- : int list = [1; 2]
- : int list = [3]
- : int list list = [[1]; [2; 3]]
La dernière est une liste de listes d'entiers. Quant à [1; "deux"], elle est refusée car une liste est homogène : on ne peut mêler un int et un string ; le typeur signale une incohérence.
Sans utiliser List.length, écrire longueur et dérouler longueur [4; 5; 6].
Démonstration
let rec longueur l =
match l with
| [] -> 0
| _ :: reste -> 1 + longueur reste
Déroulé : longueur [4;5;6] 1 + longueur [5;6] 1 + (1 + longueur [6]) 1 + (1 + (1 + longueur [])) 1 + 1 + 1 + 0 = 3.
Écrire produit qui renvoie le produit des éléments d'une liste d'entiers. Que doit renvoyer le cas de base, et pourquoi ?
Démonstration
let rec produit l =
match l with
| [] -> 1
| x :: reste -> x * produit reste
Le cas de base renvoie 1, élément neutre de la multiplication (et non 0, qui annulerait tout). Conséquence cohérente : le produit d'une liste vide vaut 1, comme le produit vide en mathématiques.
Niveau (raisonnement intermédiaire)
Écrire appartient x l qui renvoie true si x figure dans l, false sinon.
Démonstration
let rec appartient x l =
match l with
| [] -> false
| t :: reste -> x = t || appartient x reste
La liste vide ne contient rien (false). Sinon, ou bien la tête est x, ou bien il faut chercher dans la queue. L'évaluation paresseuse de || arrête la recherche dès la première rencontre. (C'est, à peu de chose près, la fonction List.mem du module standard.)
Écrire dernier l renvoyant le dernier élément d'une liste, et levant failwith "liste vide" si elle est vide. Indiquer le cas de base subtil.
Démonstration
let rec dernier l =
match l with
| [] -> failwith "liste vide"
| [x] -> x
| _ :: reste -> dernier reste
Le cas de base utile n'est pas [] (qui est une erreur de précondition) mais [x] : une liste à un seul élément, dont le dernier est sa tête. Sinon, le dernier de l est celui de sa queue. L'ordre des motifs importe : [x] doit précéder _ :: reste, plus général.
Écrire indice x l qui renvoie Some i où i est la position (à partir de ) de la première occurrence de x, ou None si x est absent.
Démonstration
let rec indice x l =
match l with
| [] -> None
| t :: reste ->
if x = t then Some 0
else
match indice x reste with
| None -> None
| Some i -> Some (i + 1)
Si la tête est x, sa position est 0. Sinon on cherche dans la queue : si on l'y trouve à la position i, c'est i + 1 dans l ; si elle y est absente, elle l'est aussi dans l. Le type option force à traiter les deux issues — pas d'oubli possible.
Réécrire @ sous le nom concatene, et justifier que son coût est proportionnel à la longueur du premier argument.
Démonstration
let rec concatene l1 l2 =
match l1 with
| [] -> l2
| x :: reste -> x :: concatene reste l2
Chaque appel récursif retire une tête de l1 et produit un :: ; il y a donc exactement appels et constructions, puis on renvoie l2 telle quelle (sans la copier). Le coût est , indépendant de l2.
Niveau (approfondissement)
Écrire renverse de deux façons : une version naïve avec @, une version à accumulateur. Comparer les coûts.
Démonstration
Version naïve.
let rec renverse_naif l =
match l with
| [] -> []
| x :: reste -> renverse_naif reste @ [x]
Correcte, mais chaque @ [x] reparcourt tout le préfixe déjà renversé : le coût total est pour une liste de longueur .
Version à accumulateur.
let renverse l =
let rec aux acc l =
match l with
| [] -> acc
| x :: reste -> aux (x :: acc) reste
in
aux [] l
On empile chaque tête devant l'accumulateur (en temps constant grâce à ::) ; à la fin, acc contient les éléments dans l'ordre inverse. Coût . (La technique de l'accumulateur, jointe à un let rec interne, est un schéma à connaître par cœur.)
Écrire nb_occ x l qui compte le nombre d'apparitions de x dans l.
Démonstration
let rec nb_occ x l =
match l with
| [] -> 0
| t :: reste ->
if t = x then 1 + nb_occ x reste
else nb_occ x reste
On ajoute 1 quand la tête est x, et l'on poursuit dans la queue dans tous les cas. On vérifie un invariant utile : nb_occ x l longueur l, avec égalité si tous les éléments valent x.
Écrire maximum l renvoyant le plus grand entier de l, et levant failwith si l est vide. Soigner le cas de base.
Démonstration
let rec maximum l =
match l with
| [] -> failwith "liste vide : pas de maximum"
| [x] -> x
| x :: reste ->
let m = maximum reste in
if x > m then x else m
Le cas de base est [x] (le maximum d'un singleton est son élément). Pour une liste plus longue, le maximum est le plus grand de la tête et du maximum de la queue. On nomme ce dernier (let m = ...) pour ne le calculer qu'une fois — l'écrire deux fois (if x > maximum reste then x else maximum reste) doublerait inutilement le travail à chaque étage. (Variante propre : renvoyer un int option pour traiter la liste vide sans exception.)
- Une liste
'a listest finie, ordonnée, homogène ; éléments séparés par;(la virgule ferait un n-uplet !). - Deux constructeurs :
[]et::;[1; 2; 3]=1 :: 2 :: 3 :: [].::ajoute en tête, en temps constant ; listes immuables. - Filtrage
match l with [] -> ... | x :: reste -> ...: exhaustif (sinon avertissement), pas de variable répétée,_joker, l'ordre compte (du particulier au général). - Récursion sur liste : cas
[](que renvoyer ?) et casx :: reste(combinerxavec l'appel surreste) ; terminaison garantie (queue plus courte). @concatène en coût proportionnel à la longueur du membre gauche :l @ [x]est coûteux,x :: lnon.option:None/Some vpour modéliser proprement l'absence ; le filtrage force à traiter le casNone.failwithpour les violations de précondition.- Technique de l'accumulateur (avec
let recinterne) : transforme un parcours coûteux en parcours linéaire (cf.renverse).
2.8 Exercices d'entraînement
Légende : application directe, raisonnement intermédiaire, approfondissement ; signale un classique incontournable. La numérotation prolonge celle des dix exercices résolus.
Thème A — Construire et lire des listes.
- [11.] Donner le type et la valeur de
5 :: [],[] @ [1; 2],[true] @ [false],[(1, 2); (3, 4)]. - [12.] Écrire
teteetqueue(avecfailwithsur la liste vide) par filtrage. - [13.] Écrire
nieme l nrenvoyant len-ième élément (à partir de ),failwithsi l'indice est hors borne.
Thème B — Parcours et accumulation.
- [14.] Écrire
somme_carres l(somme des carrés des entiers del). - [15.] Écrire
compte_pairs l(nombre d'entiers pairs) ettous_positifs l(tous les éléments sont ?). - [16.] Écrire
intervalle a bqui construit la liste[a; a+1; ...; b](et[]sia > b).
Thème C — Transformations.
- [17.] Écrire
incremente lqui renvoie une nouvelle liste où chaque entier est augmenté de (sans modifierl). - [18.] Écrire
garde_pairs lqui renvoie la sous-liste des éléments pairs, dans l'ordre. - [19.] Écrire
aplatis lqui, d'uneint list list, fait uneint listen concaténant ; discuter son coût selon qu'on concatène à gauche ou via un accumulateur.
Thème D — option et recherche.
- [20.] Écrire
dernier_option l : 'a option(sans exception). - [21.] Écrire
maximum_option l : int optionrenvoyantNonesur la liste vide. - [22.] Écrire
premier_pair l : int optionrenvoyant le premier élément pair s'il existe,Nonesinon, en s'arrêtant dès qu'il est trouvé.