NSI Terminale • Programme français, lycées de Montréal

Exercices corrigés de NSI : les structures de données

Voici une série d'exercices corrigés de NSI pour la classe de Terminale, sur les structures de données du programme français. Elle s'adresse aux élèves des lycées français de Montréal, le Lycée Marie de France et le Collège Stanislas.

Le fil de la série : une structure de données n'est pas un rangement, c'est un CONTRAT. Elle se définit par les opérations qu'elle autorise et par leur coût, et choisir une structure revient toujours à décider quelles opérations seront gratuites et lesquelles seront chères. Chaque exercice revient à cette question, du choix entre pile et file jusqu'au choix entre matrice et listes d'adjacence.

Trois pièges sont désignés nommément dans le corrigé : l'ordre des deux dépilements dans une soustraction, qui ne se voit qu'à la première opération non commutative ; la hauteur d'un arbre binaire de recherche, qui dépend de l'ordre d'insertion et non du nombre de valeurs ; et la matrice d'adjacence appliquée à un graphe creux.

Série autocorrigée Tape tes réponses sous chaque question : la page te dit juste ou faux avant d'ouvrir la correction. Avec un compte, chaque bonne réponse du premier coup rapporte des points.

Ce chapitre fait partie de NSI en Terminale
Avant de commencer Fiche de révision : les pièges et la méthode de ce chapitre

Avant ce chapitre

Ces notions sont supposées acquises ici. Si le premier exercice résiste, le blocage vient presque toujours de l'une d'elles, pas du chapitre lui-même.

Remonter plus loin : la chaîne complète (4 chapitres) ↓

Le chemin de remédiation, du plus ancien au plus proche. Un élève qui reprend ce chapitre de zéro le reprend dans cet ordre.

  1. 1Algorithmique et PythonSeconde, Mathématiques
  2. 2Python : types, contrôle, fonctions et tableauxPremière
  3. 3Algorithmique : preuve, terminaison et coûtPremière
  4. 4Algorithmique : recherche, tris, récursivité et complexité

Rappel de cours

  • Pile : LIFO, dernier entré premier sorti. File : FIFO, premier entré premier sorti. Changer l'une pour l'autre change tout algorithme qui les emploie.
  • En notation postfixée, le premier élément dépilé est le SECOND opérande. L'erreur ne se voit qu'avec une opération non commutative.
  • File avec deux piles : chaque élément subit au plus 4 opérations dans sa vie, d'où un coût amorti constant.
  • Liste chaînée : insertion en tête constante, accès au kk-ième linéaire. Tableau : l'inverse.
  • Arbre binaire : taille = nombre de noeuds, hauteur = nombre d'arêtes du plus long chemin. Arbre vide de hauteur 1-1.
  • Un arbre binaire de taille nn a une hauteur comprise entre log2n\lfloor \log_{2} n \rfloor et n1n - 1.
  • Arbre complet de hauteur hh : taille 2h+112^{h+1} - 1.
  • Les trois parcours récursifs ne diffèrent que par la position du traitement de la racine. Le parcours en largeur exige une FILE et n'est pas récursif.
  • Le parcours infixe d'un arbre binaire de recherche donne les valeurs triées.
  • Matrice d'adjacence : n2n^{2} cases. Listes d'adjacence : n+2mn + 2m entrées. Pour un graphe creux, l'écart se compte en milliers.

Partie A : Les bases (/50)

Exercice 1 : Une structure de données est un contrat

Avant toute implémentation, une structure se définit par son INTERFACE : la liste des opérations autorisées et leur coût. Deux implémentations différentes du même contrat sont interchangeables pour l'utilisateur.

class Pile:
    def __init__(self):
        self.contenu = []

    def est_vide(self):
        return len(self.contenu) == 0

    def empiler(self, x):
        self.contenu.append(x)

    def depiler(self):
        return self.contenu.pop()

    def sommet(self):
        return self.contenu[-1]
  • a) Donnez les quatre opérations de l'interface d'une pile et dites, pour chacune, ce qu'elle attend et ce qu'elle renvoie. Que signifie l'abréviation LIFO ?
  • b) On empile 3, puis 7, puis 2, puis on dépile deux fois, puis on empile 5. Donnez le contenu de la pile, du fond vers le sommet, et les valeurs renvoyées par les dépilements.
  • c) La méthode depiler ne vérifie pas que la pile est non vide. Décrivez ce qui se produit sur une pile vide et proposez deux conceptions différentes pour traiter ce cas.
  • d) On remplace l'implémentation par une liste chaînée, sans changer les noms des méthodes. Le programme qui utilise la pile doit-il être modifié ? Formulez le principe en jeu.
  • e) Un élève écrit self.contenu.pop(0) au lieu de pop(). L'objet obtenu respecte-t-il encore le contrat d'une pile ? Quel est le second problème, plus insidieux ?

Tape tes réponses, la page te dit juste ou faux 0/7

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Pile : créer, tester, empiler, dépiler ; LIFO
  • b) Renvoie 2 puis 7, reste 3, 5
  • c) Pile vide : exception explicite ou contrat
  • d) Encapsulation : programmer contre l'interface
  • e) pop(0) : une file, et un coût linéaire

a) Les quatre opérations. Créer une pile vide, qui n'attend rien et renvoie la structure. Tester si elle est vide, qui n'attend rien et renvoie un booléen. Empiler, qui attend une valeur et ne renvoie rien mais modifie la pile. Dépiler, qui n'attend rien, retire l'élément du sommet et le renvoie. On y ajoute souvent une cinquième opération, lire le sommet sans le retirer. LIFO signifie « dernier entré, premier sorti » : l'élément rendu par un dépilement est toujours le plus récemment empilé.

b) Après avoir empilé 3, 7 puis 2, la pile contient 3, 7, 2 du fond vers le sommet. Le premier dépilement renvoie 2, le second renvoie 7 : la pile ne contient plus que 3. On empile ensuite 5, donc la pile contient 3, 5 du fond vers le sommet. Les valeurs renvoyées sont donc 2 puis 7, dans cet ordre, ce qui est bien l'inverse de leur ordre d'arrivée.

c) Sur une pile vide, l'appel à pop sur une liste vide lève une exception IndexError, dont le message parle d'une liste alors que l'utilisateur croyait manipuler une pile : la fuite d'implémentation est déjà un défaut en soi. Deux conceptions possibles. La première est DÉFENSIVE : depiler teste est_vide et lève une exception explicite, du genre « dépilement d'une pile vide », ce qui interrompt le programme au bon endroit avec le bon message. La seconde est CONTRACTUELLE : on documente que dépiler une pile vide est interdit, et c'est à l'appelant de tester avant. La première est plus sûre, la seconde est plus rapide ; le choix se justifie, il ne se devine pas.

d) Non, le programme utilisateur n'a pas à être modifié, à condition qu'il n'ait jamais accédé à l'attribut contenu. C'est le principe d'ENCAPSULATION, ou de séparation entre interface et implémentation : l'utilisateur programme contre le contrat, jamais contre la réalisation. Ce principe est ce qui permet d'optimiser une structure sans réécrire les programmes qui s'en servent, et c'est aussi pourquoi accéder directement à contenu depuis l'extérieur est une faute, même quand le langage l'autorise.

e) Avec pop(0), on retire l'élément d'INDICE 0, c'est-à-dire le plus anciennement empilé : l'objet obtenu se comporte comme une file, pas comme une pile. Il ne respecte donc plus le contrat. Le second problème, plus insidieux, est le COÛT : retirer le premier élément d'une liste Python oblige à décaler tous les autres, donc l'opération est linéaire en la taille au lieu d'être constante. Une boucle qui vide la structure passe ainsi d'un coût linéaire à un coût quadratique, sans qu'aucun test fonctionnel ne le signale, puisque le résultat, lui, serait correct si l'on voulait une file.

Exercice 2 : Les piles à l'oeuvre : notation postfixée et parenthésage

Une pile n'est pas un exercice de style : c'est la structure qui permet d'évaluer une expression sans parenthèses et de vérifier qu'un texte est bien parenthésé. Les deux algorithmes tiennent en dix lignes.

def evaluer(expression):
    p = Pile()
    for jeton in expression.split():
        if jeton == '+':
            b = p.depiler(); a = p.depiler(); p.empiler(a + b)
        elif jeton == '*':
            b = p.depiler(); a = p.depiler(); p.empiler(a * b)
        elif jeton == '-':
            b = p.depiler(); a = p.depiler(); p.empiler(a - b)
        else:
            p.empiler(float(jeton))
    return p.depiler()
  • a) Faites la trace de evaluer('3 4 + 5 *') : donnez le contenu de la pile après chaque jeton, puis le résultat.
  • b) Faites la trace de evaluer('7 2 3 * -') et donnez le résultat. Expliquez le rôle de l'ordre des deux dépilements pour la soustraction.
  • c) Écrivez en notation postfixée l'expression (2+3)×(85)(2 + 3) \times (8 - 5), puis l'expression 2+3×852 + 3 \times 8 - 5. Que constatez-vous sur les parenthèses ?
  • d) Décrivez l'algorithme qui vérifie le bon parenthésage d'un texte contenant des parenthèses, des crochets et des accolades. Précisez les trois cas de rejet.
  • e) Appliquez cet algorithme aux trois textes suivants et concluez pour chacun : ( [ ] { } ), ( [ ) ], ( ( ).

Tape tes réponses, la page te dit juste ou faux 0/9

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) 3 4 + 5 * vaut 35
  • b) 7 2 3 * - vaut 1 : le premier dépilé est b
  • c) 2 3 + 8 5 - * et 2 3 8 * + 5 -
  • d) Trois rejets : pile vide, types différents, pile non vide
  • e) Accepté, rejeté, rejeté

a) Jeton 3 : la pile contient 3. Jeton 4 : elle contient 3, 4. Jeton + : on dépile 4 puis 3, on empile 3+4=73 + 4 = 7, la pile contient 7. Jeton 5 : elle contient 7, 5. Jeton * : on dépile 5 puis 7, on empile 7×5=357 \times 5 = 35. Le résultat renvoyé est 35.

b) Jeton 7 : pile 7. Jeton 2 : pile 7, 2. Jeton 3 : pile 7, 2, 3. Jeton * : on dépile 3 puis 2, on empile 2×3=62 \times 3 = 6, pile 7, 6. Jeton - : on dépile 6 dans b puis 7 dans a, on empile ab=76=1a - b = 7 - 6 = 1. Le résultat est 1. L'ordre des dépilements est décisif : le premier dépilé est le SECOND opérande, puisque la pile rend les éléments dans l'ordre inverse de leur arrivée. Écrire a = p.depiler() avant b donnerait 67=16 - 7 = -1. Pour l'addition et la multiplication, qui sont commutatives, l'erreur passerait inaperçue, ce qui la rend d'autant plus dangereuse : elle ne se révèle qu'à la première soustraction.

c) (2+3)×(85)(2 + 3) \times (8 - 5) s'écrit 2 3 + 8 5 - *, et 2+3×852 + 3 \times 8 - 5 s'écrit 2 3 8 * + 5 -. On constate qu'AUCUNE parenthèse n'apparaît : la notation postfixée n'en a pas besoin, parce que la position des opérateurs suffit à fixer l'ordre des opérations. C'est précisément ce qui la rend commode pour une machine, et c'est la forme qu'un compilateur produit avant de générer le code.

d) L'algorithme. On parcourt le texte caractère par caractère. Si le caractère est une ouvrante, on l'empile. Si c'est une fermante, on regarde la pile : si elle est vide, on rejette ; sinon on dépile et on vérifie que l'ouvrante dépilée correspond au type de la fermante, faute de quoi on rejette. À la fin du parcours, si la pile n'est pas vide, on rejette. Les trois cas de rejet sont donc : une fermante alors que la pile est vide ; une fermante d'un type différent de la dernière ouvrante ; et une pile non vide à la fin.

e) Premier texte, ( [ ] { } ) : on empile la parenthèse, on empile le crochet, la fermante crochet correspond et dépile, on empile l'accolade, la fermante accolade correspond et dépile, la fermante parenthèse correspond et dépile, la pile est vide à la fin. Le texte est ACCEPTÉ. Deuxième texte, ( [ ) ] : on empile la parenthèse et le crochet, puis arrive une fermante parenthèse alors que le sommet est un crochet : les types diffèrent, on REJETTE au deuxième cas. Troisième texte, ( ( : on empile deux parenthèses et le parcours s'achève avec une pile non vide, on REJETTE au troisième cas. Les trois cas de rejet sont donc chacun illustré une fois, sauf le premier, qu'illustrerait un texte commençant par une fermante.

Exercice 3 : Les files, et la file construite avec deux piles

Une file sert dès qu'il faut traiter des demandes dans leur ordre d'arrivée : impression, messages, parcours en largeur d'un graphe. On peut la construire avec deux piles, et l'analyse de son coût réserve une surprise.

class File:
    def __init__(self):
        self.entree = Pile()
        self.sortie = Pile()

    def enfiler(self, x):
        self.entree.empiler(x)

    def defiler(self):
        if self.sortie.est_vide():
            while not self.entree.est_vide():
                self.sortie.empiler(self.entree.depiler())
        return self.sortie.depiler()
  • a) Donnez l'interface d'une file et le sens de l'abréviation FIFO. Citez deux situations concrètes où c'est une file, et non une pile, qu'il faut employer.
  • b) On enfile 1, 2, 3, puis on défile deux fois, puis on enfile 4, puis on défile deux fois. Donnez, à chaque étape, le contenu des deux piles et la valeur renvoyée.
  • c) Expliquez pourquoi le transvasement d'une pile dans l'autre remet les éléments dans le bon ordre.
  • d) Un défilement peut coûter cher, quand il faut transvaser. Montrez cependant que sur une suite de nn enfilements et nn défilements, le coût TOTAL est proportionnel à nn. Comment s'appelle ce type d'analyse ?
  • e) Un élève déplace le transvasement dans enfiler au lieu de defiler. Le résultat reste-t-il correct ? Que devient le coût total sur la même suite d'opérations ?

Tape tes réponses, la page te dit juste ou faux 0/7

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) File : FIFO, impression et parcours en largeur
  • b) Défilements : 1, 2, 3, 4
  • c) Deux inversions rétablissent l'ordre
  • d) Au plus 4n4n opérations : analyse amortie
  • e) Correct mais quadratique

a) L'interface d'une file : créer une file vide ; tester si elle est vide ; enfiler une valeur, qui l'ajoute en queue ; défiler, qui retire et renvoie la valeur de tête. FIFO signifie « premier entré, premier sorti ». Deux situations qui exigent une file : la gestion d'une file d'attente de travaux d'impression, où l'équité impose de servir dans l'ordre d'arrivée ; et le parcours en largeur d'un graphe, où l'on doit épuiser les sommets à distance kk avant ceux à distance k+1k+1, ce qu'une pile ferait exactement à l'envers.

b) Après les trois enfilements : entrée contient 1, 2, 3 du fond au sommet, sortie est vide. Premier défilement : sortie étant vide, on transvase, donc sortie contient 3, 2, 1 du fond au sommet ; on dépile et on renvoie 1. Deuxième défilement : sortie n'est pas vide, on dépile directement et on renvoie 2 ; sortie contient encore 3. On enfile 4 : entrée contient 4, sortie contient 3. Troisième défilement : sortie n'est pas vide, on renvoie 3 ; sortie est maintenant vide. Quatrième défilement : sortie étant vide, on transvase le 4, puis on le renvoie. Les valeurs renvoyées sont donc 1, 2, 3, 4, dans l'ordre d'arrivée.

c) Parce qu'une pile inverse l'ordre, et qu'inverser deux fois rétablit l'ordre initial. Les éléments entrent dans la pile d'entrée dans leur ordre d'arrivée, donc le plus ancien se retrouve au FOND. Le transvasement les dépile du plus récent au plus ancien et les empile dans la pile de sortie : le plus ancien arrive donc en dernier, c'est-à-dire au SOMMET de la pile de sortie. Un dépilement de la pile de sortie rend alors bien le plus ancien, ce qui est exactement le comportement d'une file.

d) Chaque élément est empilé au plus une fois dans la pile d'entrée, dépilé au plus une fois de celle-ci, empilé au plus une fois dans la pile de sortie et dépilé au plus une fois de celle-ci : il subit donc au plus 4 opérations élémentaires au cours de sa vie entière. Pour nn éléments, le coût total est donc au plus 4n4n opérations, c'est-à-dire proportionnel à nn. Le coût MOYEN par opération est donc constant, bien qu'un défilement isolé puisse coûter beaucoup. Ce type de raisonnement s'appelle une analyse AMORTIE : on ne majore pas chaque opération séparément, on majore le coût total d'une suite d'opérations.

e) Le résultat reste correct : les éléments sortent toujours dans l'ordre d'arrivée, puisque la pile de sortie est maintenue en permanence dans le bon ordre. En revanche le coût explose. Chaque enfilement doit transvaser toute la pile de sortie vers l'entrée, ajouter l'élément, puis tout retransvaser : il coûte donc un temps proportionnel au nombre d'éléments présents. Sur nn enfilements, le coût total devient de l'ordre de 1+2++n=n(n+1)/21 + 2 + \dots + n = n(n+1)/2, c'est-à-dire QUADRATIQUE. Pour n=10 000n = 10\ 000, on passe de 40 00040\ 000 opérations à environ 50 000 00050\ 000\ 000, soit mille deux cent cinquante fois plus. Le résultat correct masque donc une régression majeure, que seuls un raisonnement ou une mesure révèlent.

Exercice 4 : Listes chaînées : ce qui est gratuit et ce qui est cher

La figure montre une liste chaînée de quatre maillons. Chaque maillon porte une valeur et l'adresse du suivant ; le dernier ne pointe sur rien. Aucun maillon ne sait où il se trouve dans la liste.

7395Nonetête
  • a) Donnez la valeur du troisième maillon et expliquez le chemin exact que le programme doit suivre pour y accéder. Quel est le coût d'un accès au kk-ième maillon ?
  • b) Écrivez la fonction longueur(tete) qui compte les maillons. Donnez son coût.
  • c) Décrivez, en trois opérations élémentaires, l'insertion d'un maillon de valeur 4 en tête de liste. Donnez son coût et comparez-le à l'insertion en tête dans un tableau.
  • d) Décrivez la suppression du maillon de valeur 9. Quelle information faut-il avoir conservée pendant le parcours, et pourquoi ?
  • e) Dressez le tableau comparatif liste chaînée contre tableau, sur quatre opérations : accès au kk-ième élément, insertion en tête, insertion en fin, et occupation mémoire. Concluez sur le critère de choix.

Tape tes réponses, la page te dit juste ou faux 0/7

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Accès au kk-ième maillon en temps linéaire
  • b) longueur parcourt tout : coût linéaire
  • c) Insertion en tête à coût constant
  • d) Suppression : garder le précédent
  • e) Tableau par défaut, liste si les insertions dominent

a) Le troisième maillon porte la valeur 9. Pour y accéder, le programme part de la tête, lit le maillon 7 et suit son pointeur, lit le maillon 3 et suit son pointeur, et arrive au maillon 9 : il a dû traverser tous les maillons précédents. Le coût d'un accès au kk-ième maillon est donc proportionnel à kk, et dans le pire des cas proportionnel à la longueur de la liste. C'est la différence fondamentale avec un tableau, où l'adresse du kk-ième élément se calcule par une addition et où l'accès coûte donc un temps constant.

b) La fonction : on initialise un compteur à 0 et une variable courante à tete ; tant que courante n'est pas None, on incrémente le compteur et on remplace courante par courante.suivant ; on renvoie le compteur. Le coût est LINÉAIRE en la longueur, puisque chaque maillon est visité exactement une fois. Contrairement à un tableau, une liste chaînée ne connaît pas sa longueur : il faut la compter, ou bien la maintenir dans un attribut mis à jour à chaque insertion et suppression.

c) Trois opérations : créer un nouveau maillon de valeur 4 ; faire pointer son champ suivant sur l'ancienne tête ; faire pointer la variable tete sur le nouveau maillon. Le coût est CONSTANT, indépendant de la longueur de la liste, et aucun autre maillon n'est touché. Dans un tableau, insérer en tête oblige à décaler tous les éléments d'une case, donc le coût est linéaire. C'est le principal argument en faveur de la liste chaînée.

d) Il faut faire pointer le champ suivant du maillon PRÉCÉDENT, celui de valeur 3, sur le maillon de valeur 5. L'information à conserver pendant le parcours est donc une référence sur le maillon précédent, parce qu'un maillon ne donne accès qu'à son successeur, jamais à son prédécesseur : arrivé sur le maillon 9, il est trop tard, on ne peut plus revenir en arrière. On parcourt donc avec deux variables, précédent et courant, avancées ensemble. Une variante consiste à s'arrêter un cran plus tôt, en testant courant.suivant.valeur, ce qui évite la seconde variable au prix d'un cas particulier sur le premier maillon.

e) Accès au kk-ième élément : tableau constant, liste linéaire ; avantage au tableau. Insertion en tête : tableau linéaire, liste constante ; avantage à la liste. Insertion en fin : tableau constant en moyenne si de la place est réservée, liste linéaire s'il faut parcourir, constante si l'on maintient un pointeur de queue ; avantage au tableau, sauf conception particulière. Occupation mémoire : le tableau stocke les valeurs seules et de façon contiguë, la liste ajoute un pointeur par maillon, souvent 8 octets, et disperse les maillons en mémoire, ce qui dégrade fortement le cache ; avantage au tableau. Critère de choix : on prend une liste chaînée quand les insertions et suppressions en tête ou en milieu dominent et que l'accès indexé est rare ; dans tous les autres cas, et notamment dès que la performance du cache compte, le tableau gagne.

Exercice 5 : Arbres binaires : taille, hauteur, feuilles

La figure donne un arbre binaire. Chaque noeud a au plus deux fils, gauche et droit, et l'ordre des deux fils compte. Tout le vocabulaire de l'exercice se lit sur cette figure.

831016144713
  • a) Donnez la racine, la taille, la hauteur, la liste des feuilles et la liste des noeuds internes.
  • b) Donnez les fils gauche et droit de chaque noeud interne. Quels noeuds n'ont qu'un seul fils ?
  • c) Écrivez les fonctions récursives taille(a) et hauteur(a). Précisez le cas de base de chacune.
  • d) Un arbre binaire de taille nn a une hauteur comprise entre deux bornes. Donnez ces bornes, dites quand chacune est atteinte, et calculez-les pour n=9n = 9 puis pour n=1 000 000n = 1\ 000\ 000.
  • e) Un arbre binaire dont tous les niveaux sont complets a une hauteur hh. Exprimez sa taille en fonction de hh, puis calculez la taille pour h=10h = 10 et pour h=20h = 20.

Tape tes réponses, la page te dit juste ou faux 0/13

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Racine 8, taille 9, hauteur 3, 4 feuilles
  • b) 10 et 14 n'ont qu'un fils
  • c) Hauteur de l'arbre vide 1-1
  • d) log2nhn1\lfloor \log_{2} n \rfloor \leq h \leq n - 1
  • e) Taille 2h+112^{h+1} - 1

a) La racine est 8. La taille, c'est-à-dire le nombre de noeuds, vaut 9. La hauteur, comptée en nombre d'arêtes du plus long chemin depuis la racine, vaut 3 : le chemin 8, 3, 6, 4 en compte trois. Les feuilles, noeuds sans aucun fils, sont 1, 4, 7 et 13, soit quatre feuilles. Les noeuds internes sont 8, 3, 6, 10 et 14, soit cinq noeuds. On vérifie : 4+5=94 + 5 = 9, ce qui redonne bien la taille.

b) Le noeud 8 a pour fils gauche 3 et pour fils droit 10. Le noeud 3 a pour fils gauche 1 et pour fils droit 6. Le noeud 6 a pour fils gauche 4 et pour fils droit 7. Le noeud 10 n'a pas de fils gauche et a pour fils droit 14. Le noeud 14 a pour fils gauche 13 et pas de fils droit. Les noeuds n'ayant qu'un seul fils sont donc 10 et 14. Il faut noter que ces deux noeuds ne sont PAS interchangeables avec un noeud dont le fils unique serait de l'autre côté : dans un arbre binaire, un fils gauche unique et un fils droit unique donnent deux arbres différents.

c) La fonction taille : si l'arbre est vide, renvoyer 0 ; sinon renvoyer 1+taille(gauche)+taille(droit)1 + \text{taille}(\text{gauche}) + \text{taille}(\text{droit}). Le cas de base est l'arbre vide, qui vaut 0. La fonction hauteur : si l'arbre est vide, renvoyer 1-1 ; sinon renvoyer 1+max(hauteur(gauche),hauteur(droit))1 + \max(\text{hauteur}(\text{gauche}), \text{hauteur}(\text{droit})). Le cas de base est ici encore l'arbre vide, mais il vaut 1-1 et non 0, afin qu'un arbre réduit à sa racine ait la hauteur 0. Choisir 0 pour l'arbre vide donnerait la hauteur en nombre de NIVEAUX au lieu d'arêtes : les deux conventions existent, il faut annoncer celle qu'on emploie et s'y tenir, c'est la source d'erreur numéro un du chapitre.

d) La hauteur minimale est log2n\lfloor \log_{2} n \rfloor, atteinte quand l'arbre est le plus équilibré possible, chaque niveau étant rempli avant le suivant. La hauteur maximale est n1n - 1, atteinte quand l'arbre dégénère en peigne, chaque noeud n'ayant qu'un seul fils. Pour n=9n = 9 : minimum log29=3\lfloor \log_{2} 9 \rfloor = 3, maximum 8. L'arbre de la figure a la hauteur 3 : il est donc optimalement équilibré. Pour n=106n = 10^{6} : minimum log2106=19\lfloor \log_{2} 10^{6} \rfloor = 19, maximum 999 999999\ 999. L'écart entre 19 et un million est exactement ce qui rend l'équilibrage indispensable.

e) Un arbre complet de hauteur hh compte 2k2^{k} noeuds au niveau kk, pour kk allant de 0 à hh, donc une taille de 1+2+4++2h=2h+111 + 2 + 4 + \dots + 2^{h} = 2^{h+1} - 1. Pour h=10h = 10 : 2111=2 0472^{11} - 1 = 2\ 047 noeuds. Pour h=20h = 20 : 2211=2 097 1512^{21} - 1 = 2\ 097\ 151 noeuds. Doubler la hauteur ne double pas la taille, il l'élève à peu près au carré, à un facteur 2 près : c'est la même croissance exponentielle que celle des pages web du chapitre de SNT, et c'est elle qui explique qu'un arbre équilibré de vingt niveaux suffise à ranger deux millions de valeurs.

Partie B : Problèmes et raisonnement (/50)

Exercice 6 : Les quatre parcours d'un arbre

Un arbre se parcourt de quatre façons standard. Trois sont des variantes récursives qui ne diffèrent que par la POSITION du traitement de la racine ; la quatrième n'est pas récursive du tout. On travaille sur l'arbre de l'exercice 5.

  • a) Donnez le parcours préfixe, le parcours infixe et le parcours suffixe de l'arbre de l'exercice 5.
  • b) Écrivez les trois fonctions récursives correspondantes et dites en quoi elles diffèrent, en une phrase.
  • c) Que constatez-vous sur le parcours infixe ? Quelle propriété de l'arbre cela révèle-t-il ?
  • d) Donnez le parcours en largeur. Expliquez pourquoi il ne s'écrit pas récursivement et quelle structure de données il exige.
  • e) À quoi sert chacun des quatre parcours en pratique ? Donnez un usage précis pour chacun.

Tape tes réponses, la page te dit juste ou faux 0/8

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Préfixe, infixe, suffixe de l'arbre
  • b) Seule la place de la racine change
  • c) Infixe trié : arbre binaire de recherche
  • d) Largeur : 8, 3, 10, 1, 6, 14, 4, 7, 13 avec une file
  • e) Copier, trier, évaluer, plus court chemin

a) Parcours PRÉFIXE, racine puis gauche puis droit : 8, 3, 1, 6, 4, 7, 10, 14, 13. Parcours INFIXE, gauche puis racine puis droit : 1, 3, 4, 6, 7, 8, 10, 13, 14. Parcours SUFFIXE, gauche puis droit puis racine : 1, 4, 7, 6, 3, 13, 14, 10, 8. Les trois listes contiennent exactement les neuf mêmes valeurs, seul l'ordre change.

b) Les trois fonctions ont la même structure : si l'arbre est vide, on ne fait rien ; sinon on appelle la fonction sur le sous-arbre gauche, on traite la racine, et on appelle la fonction sur le sous-arbre droit. Elles ne diffèrent que par la POSITION de la ligne qui traite la racine : avant les deux appels pour le préfixe, entre les deux pour l'infixe, après les deux pour le suffixe. C'est la seule différence, et c'est déjà tout le sujet.

c) Le parcours infixe donne 1, 3, 4, 6, 7, 8, 10, 13, 14, c'est-à-dire les valeurs TRIÉES par ordre croissant. Cela révèle que l'arbre est un arbre binaire de RECHERCHE : pour tout noeud, toutes les valeurs de son sous-arbre gauche lui sont inférieures et toutes celles de son sous-arbre droit lui sont supérieures. La réciproque est vraie : un arbre binaire dont le parcours infixe est croissant est un arbre binaire de recherche, et c'est d'ailleurs le test le plus simple pour le vérifier.

d) Parcours en LARGEUR, niveau par niveau et de gauche à droite : 8, 3, 10, 1, 6, 14, 4, 7, 13. Il ne s'écrit pas récursivement parce que la récursion descend naturellement dans un sous-arbre jusqu'au bout avant de passer au suivant, alors que le parcours en largeur exige de traiter tous les noeuds d'un niveau AVANT de descendre. Il faut donc mémoriser explicitement les noeuds en attente, dans l'ordre où ils ont été découverts : c'est exactement le contrat d'une FILE. L'algorithme : enfiler la racine ; tant que la file n'est pas vide, défiler un noeud, le traiter, puis enfiler ses fils non vides. Remplacer la file par une pile transforme d'ailleurs ce parcours en un parcours en profondeur, ce qui montre bien que c'est la structure, et non l'algorithme, qui décide de l'ordre.

e) Le parcours PRÉFIXE sert à recopier ou sérialiser un arbre : la racine étant écrite en premier, la reconstruction peut commencer dès le premier élément lu. Le parcours INFIXE sert à obtenir les valeurs triées d'un arbre binaire de recherche, ou à réécrire une expression arithmétique en notation usuelle. Le parcours SUFFIXE sert à libérer un arbre ou à évaluer une expression : on a besoin des résultats des deux sous-arbres avant de pouvoir traiter la racine, et c'est exactement la notation postfixée de l'exercice 2. Le parcours en LARGEUR sert à trouver le plus court chemin en nombre d'arêtes, puisqu'il découvre les noeuds par distance croissante à la racine, et à afficher un arbre niveau par niveau.

Exercice 7 : Arbre binaire de recherche : recherche, insertion, équilibre

Les deux arbres de la figure contiennent les mêmes cinq valeurs. Ils sont tous deux des arbres binaires de recherche corrects, et pourtant l'un est cinq fois plus lent que l'autre.

1234532415hauteur 4hauteur 2
  • a) Énoncez la propriété qui caractérise un arbre binaire de recherche. Vérifiez-la sur les deux arbres de la figure.
  • b) Donnez la suite d'insertions qui produit l'arbre de gauche, puis une suite qui produit celui de droite. Que faut-il en conclure sur la construction d'un tel arbre ?
  • c) Décrivez l'algorithme de recherche d'une valeur. Comptez le nombre de comparaisons pour chercher 5 dans chacun des deux arbres, puis pour chercher une valeur absente.
  • d) Sur l'arbre de l'exercice 5, comptez les comparaisons pour chercher 7, puis pour chercher 11. Quel est le lien général entre le nombre de comparaisons et la hauteur ?
  • e) Un arbre contient un million de valeurs. Donnez le nombre de comparaisons dans le pire cas selon qu'il est équilibré ou dégénéré. Citez le principe des structures qui garantissent l'équilibre.

Tape tes réponses, la page te dit juste ou faux 0/8

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Propriété sur tout le sous-arbre
  • b) La forme dépend de l'ordre d'insertion
  • c) 5 comparaisons contre 3
  • d) Au plus hauteur plus un comparaisons
  • e) 20 contre 1 000 0001\ 000\ 000 : rééquilibrer

a) La propriété : pour tout noeud, toutes les valeurs du sous-arbre GAUCHE lui sont strictement inférieures, et toutes celles du sous-arbre DROIT lui sont strictement supérieures. Attention, la condition porte sur tout le sous-arbre, pas seulement sur les fils immédiats. Vérification à gauche : chaque noeud n'a qu'un fils droit portant une valeur plus grande, la propriété est satisfaite. À droite : la racine 3 a un sous-arbre gauche contenant 1 et 2, tous deux inférieurs à 3, et un sous-arbre droit contenant 4 et 5, tous deux supérieurs ; les sous-arbres vérifient à leur tour la propriété. Les deux arbres sont donc bien des arbres binaires de recherche.

b) L'arbre de gauche s'obtient en insérant 1, 2, 3, 4, 5 dans cet ordre : chaque valeur étant plus grande que toutes les précédentes, elle descend systématiquement à droite. L'arbre de droite s'obtient par exemple en insérant 3, 2, 4, 1, 5, ou 3, 4, 2, 5, 1. Il faut en conclure que la FORME d'un arbre binaire de recherche ne dépend pas de son contenu mais de l'ORDRE D'INSERTION, et que le pire ordre possible est justement l'ordre trié, qui est aussi le plus fréquent quand on charge un fichier déjà classé. Un arbre construit à partir de données triées dégénère toujours en peigne.

c) Algorithme : partir de la racine ; comparer la valeur cherchée à celle du noeud ; si elles sont égales, c'est trouvé ; si la valeur cherchée est plus petite, descendre à gauche ; sinon descendre à droite ; si l'on atteint un sous-arbre vide, la valeur est absente. Pour chercher 5 dans l'arbre de gauche : on compare à 1, 2, 3, 4 puis 5, soit 5 comparaisons. Dans celui de droite : on compare à 3, puis 4, puis 5, soit 3 comparaisons. Pour une valeur absente, par exemple 6, on descend jusqu'au bout : 5 comparaisons à gauche, 3 à droite ; le coût d'un échec est celui du chemin le plus long emprunté, donc au pire la hauteur plus un.

d) Sur l'arbre de l'exercice 5, chercher 7 : on compare à 8, la valeur est plus petite donc on va à gauche vers 3 ; 7 est plus grand que 3 donc on va à droite vers 6 ; 7 est plus grand que 6 donc on va à droite vers 7, trouvé. Cela fait 4 comparaisons. Chercher 11 : on compare à 8, plus grand donc à droite vers 10 ; plus grand que 10 donc à droite vers 14 ; plus petit que 14 donc à gauche vers 13 ; plus petit que 13 donc à gauche, sous-arbre vide, absent. Cela fait 4 comparaisons. Le lien général : le nombre de comparaisons est au plus la hauteur plus 1, puisque chaque comparaison fait descendre d'exactement un niveau.

e) Pour un million de valeurs, un arbre équilibré a une hauteur d'environ log2106=19\lfloor \log_{2} 10^{6} \rfloor = 19, donc au plus 20 comparaisons. Un arbre dégénéré a une hauteur de 999 999999\ 999, donc jusqu'à 1 000 0001\ 000\ 000 comparaisons : cinquante mille fois plus. Le principe des structures qui garantissent l'équilibre est de RÉÉQUILIBRER à chaque insertion et suppression, par des rotations locales qui préservent la propriété d'arbre binaire de recherche tout en réduisant la hauteur. Le coût d'une rotation est constant, et une insertion n'en déclenche qu'un nombre borné : on paie donc un facteur constant pour garantir la hauteur logarithmique, ce qui est un des meilleurs marchés de toute l'algorithmique.

Exercice 8 : Cinq affirmations à corriger

Chacune des cinq affirmations suivantes est FAUSSE. Dites pourquoi et donnez l'énoncé correct.

  • 1) « Une pile et une file diffèrent seulement par le nom de leurs opérations. »
  • 2) « Accéder au dixième élément d'une liste chaînée coûte le même prix que dans un tableau. »
  • 3) « Un arbre binaire de recherche contenant n valeurs a toujours une hauteur d'environ log2(n). »
  • 4) « Le parcours en largeur d'un arbre s'écrit récursivement, comme les trois autres. »
  • 5) « Une matrice d'adjacence est toujours préférable à des listes d'adjacence, car l'accès y est immédiat. »

Tape tes réponses, la page te dit juste ou faux 0/5

1)
2)
3)
4)
5)
Voir la correction

Réponses

  • 1) LIFO contre FIFO
  • 2) Accès constant contre linéaire
  • 3) Hauteur selon l'ordre d'insertion
  • 4) Largeur : une file, pas de récursion
  • 5) Matrice pour les graphes denses seulement

1) FAUX. Elles diffèrent par l'ORDRE de sortie, qui est le contraire l'un de l'autre : une pile rend le dernier entré, une file rend le premier entré. Ce choix change le résultat de tout algorithme qui les emploie : le même parcours de graphe devient un parcours en profondeur avec une pile et un parcours en largeur avec une file. Énoncé correct : la pile est LIFO, la file est FIFO, et remplacer l'une par l'autre change le comportement, jamais seulement le vocabulaire.

2) FAUX. Dans un tableau, l'adresse du dixième élément se CALCULE par une addition : le coût est constant. Dans une liste chaînée, il faut suivre neuf pointeurs, car aucun maillon ne donne accès qu'à son successeur : le coût est proportionnel à l'indice. Énoncé correct : l'accès indexé est constant dans un tableau et linéaire dans une liste chaînée ; c'est le prix à payer pour l'insertion en tête à coût constant.

3) FAUX. La hauteur dépend de l'ORDRE D'INSERTION, pas du nombre de valeurs. Insérées dans l'ordre croissant, un million de valeurs donnent un peigne de hauteur 999 999999\ 999. Énoncé correct : la hauteur est comprise entre log2n\lfloor \log_{2} n \rfloor et n1n - 1 ; seule une structure auto-équilibrée garantit la borne logarithmique.

4) FAUX. La récursion explore un sous-arbre entièrement avant de passer au suivant, alors que le parcours en largeur doit traiter tous les noeuds d'un niveau avant de descendre. Énoncé correct : le parcours en largeur s'écrit avec une FILE explicite, dans une boucle ; c'est le seul des quatre parcours qui ne soit pas naturellement récursif.

5) FAUX. La matrice d'adjacence occupe n2n^{2} cases quel que soit le nombre d'arêtes, et pour un graphe creux, comme un réseau routier, elle est presque entièrement remplie de zéros. Énoncé correct : la matrice est préférable pour un graphe DENSE ou quand on teste souvent l'existence d'une arête précise ; les listes d'adjacence, qui occupent une place proportionnelle au nombre d'arêtes, sont préférables pour un graphe creux et pour tout parcours, où l'on veut énumérer les voisins et non les tester un par un.

Exercice 9 : Graphes : matrice, listes, et parcours

La figure donne un graphe non orienté de cinq sommets, accompagné de sa matrice d'adjacence. Les deux représentations décrivent le même objet et n'ont pas du tout le même coût.

ABCDEA01010B10100C01011D10101E00110ABCDE
  • a) Vérifiez la cohérence entre le dessin et la matrice : donnez les arêtes, le degré de chaque sommet, et le nombre total d'arêtes. Quelles deux propriétés la matrice possède-t-elle nécessairement ici ?
  • b) Donnez les listes d'adjacence du même graphe. Comparez l'occupation mémoire des deux représentations pour ce graphe, puis pour un réseau routier de 40 000 sommets et 90 000 arêtes.
  • c) Donnez le parcours en profondeur depuis A, en visitant les voisins par ordre alphabétique. Donnez le parcours en largeur depuis A avec la même règle.
  • d) Utilisez le parcours en largeur pour donner la distance de A à chacun des cinq sommets. Pourquoi le parcours en profondeur ne donnerait-il pas ces distances ?
  • e) Le graphe est-il connexe ? Comment le vérifie-t-on à l'aide d'un parcours ? Que faudrait-il faire s'il ne l'était pas ?

Tape tes réponses, la page te dit juste ou faux 0/12

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) 6 arêtes, matrice symétrique à diagonale nulle
  • b) 17 entrées contre 25 ; 1,6×1091{,}6 \times 10^{9} contre 220 000220\ 000
  • c) Profondeur A, B, C, D, E ; largeur A, B, D, C, E
  • d) Distances 0, 1, 2, 1, 2
  • e) Connexe : un parcours atteint tout

a) Les arêtes lues sur la matrice sont A-B, A-D, B-C, C-D, C-E et D-E, soit six arêtes, ce qui correspond au dessin. Les degrés se lisent en sommant chaque ligne : A vaut 2, B vaut 2, C vaut 3, D vaut 3, E vaut 2. La somme des degrés vaut 2+2+3+3+2=122 + 2 + 3 + 3 + 2 = 12, soit deux fois le nombre d'arêtes, ce qui confirme les six arêtes. Deux propriétés nécessaires : la matrice est SYMÉTRIQUE, puisque le graphe n'est pas orienté et qu'une arête relie ses deux extrémités dans les deux sens ; et sa DIAGONALE est nulle, puisqu'aucun sommet n'est relié à lui-même.

b) Listes d'adjacence : A donne B et D ; B donne A et C ; C donne B, D et E ; D donne A, C et E ; E donne C et D. Occupation pour ce graphe : la matrice occupe 52=255^{2} = 25 cases, les listes occupent 5+2×6=175 + 2 \times 6 = 17 entrées, soit une tête de liste par sommet plus deux entrées par arête. L'écart est faible ici. Pour le réseau routier : la matrice occuperait 40 0002=1,6×10940\ 000^{2} = 1{,}6 \times 10^{9} cases, soit 1,6 milliard, alors que les listes occuperaient 40 000+2×90 000=220 00040\ 000 + 2 \times 90\ 000 = 220\ 000 entrées. Le rapport vaut environ 7 2737\ 273 : la matrice est ici tout simplement impraticable, et c'est le cas de tous les graphes réels, qui sont creux.

c) Parcours en PROFONDEUR depuis A, voisins par ordre alphabétique : on visite A, puis son premier voisin B, puis le premier voisin non visité de B qui est C, puis le premier voisin non visité de C qui est D, puis le premier voisin non visité de D qui est E. L'ordre est donc A, B, C, D, E. Parcours en LARGEUR depuis A : on visite A, puis tous ses voisins B et D, puis les voisins non encore visités de B, soit C, puis ceux de D, soit E. L'ordre est A, B, D, C, E. Les deux parcours visitent les mêmes sommets, dans un ordre différent.

d) Le parcours en largeur donne les distances suivantes : A à distance 0 ; B et D à distance 1 ; C et E à distance 2. On vérifie sur le dessin : C n'est pas voisin de A, mais il l'est de B et de D, donc il est bien à distance 2. Le parcours en profondeur ne donnerait pas ces distances parce qu'il s'enfonce le plus loin possible avant de revenir : il découvre C au troisième rang par le chemin A, B, C, ce qui donne ici la distance 2 par chance, mais il découvre D par le chemin A, B, C, D, ce qui donnerait la distance 3 alors que D est voisin direct de A. Seul le parcours en largeur garantit qu'un sommet est découvert par un plus court chemin, parce qu'il épuise les sommets à distance kk avant d'ouvrir ceux à distance k+1k+1.

e) Oui, le graphe est connexe : le parcours depuis A atteint les cinq sommets. La méthode générale consiste à lancer un parcours, en profondeur ou en largeur peu importe, depuis un sommet quelconque, et à comparer le nombre de sommets visités au nombre total de sommets : l'égalité caractérise la connexité. S'il ne l'était pas, il faudrait relancer un parcours depuis un sommet non encore visité, et recommencer tant qu'il en reste : chaque parcours découvre alors une COMPOSANTE CONNEXE, et le nombre de parcours nécessaires est le nombre de composantes. Le coût total reste linéaire en la taille du graphe, puisque chaque sommet et chaque arête n'est examiné qu'une fois.

Exercice 10 : Problème : la sortie du labyrinthe

Un labyrinthe est une grille de 30 lignes sur 40 colonnes. Chaque case est libre ou murée. On se déplace d'une case à une case voisine par un côté, jamais en diagonale. On cherche le plus court chemin de l'entrée à la sortie.

  • a) Modélisez le labyrinthe par un graphe : que sont les sommets, que sont les arêtes ? Donnez le nombre maximal de sommets et le nombre maximal d'arêtes.
  • b) Quel parcours faut-il employer pour obtenir le plus COURT chemin ? Justifiez en une phrase, puis décrivez l'algorithme complet.
  • c) Comment reconstitue-t-on le chemin lui-même, et pas seulement sa longueur ? Décrivez la structure supplémentaire à maintenir et la façon de l'exploiter.
  • d) Sur une grille pleine de 30 sur 40 sans aucun mur, quelle est la distance de la case en haut à gauche à la case en bas à droite ? Combien de plus courts chemins existe-t-il ? Donnez la formule et un ordre de grandeur.
  • e) Le labyrinthe devient une grille de 3 000 sur 4 000. Donnez le coût du parcours en largeur, l'occupation mémoire de la file dans le pire cas, et dites pourquoi une matrice d'adjacence serait un contresens.

Tape tes réponses, la page te dit juste ou faux 0/8

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) 1 2001\ 200 sommets, 2 3302\ 330 arêtes au plus
  • b) Parcours en largeur, marquer avant d'enfiler
  • c) Tableau des prédécesseurs
  • d) Distance 68, (6829)1,4×1019\binom{68}{29} \approx 1{,}4 \times 10^{19} chemins
  • e) Linéaire ; matrice de 1,44×10141{,}44 \times 10^{14} cases

a) Les SOMMETS sont les cases libres du labyrinthe ; les ARÊTES relient deux cases libres voisines par un côté. Le nombre maximal de sommets est 30×40=1 20030 \times 40 = 1\ 200, atteint quand aucune case n'est murée. Pour les arêtes : il y a 30×39=1 17030 \times 39 = 1\ 170 paires horizontales voisines et 29×40=1 16029 \times 40 = 1\ 160 paires verticales, soit 1 170+1 160=2 3301\ 170 + 1\ 160 = 2\ 330 arêtes au maximum. On vérifie l'ordre de grandeur : environ deux arêtes par case, ce qui est bien la signature d'un graphe très creux.

b) Il faut le parcours en LARGEUR, parce qu'il découvre les sommets par distance croissante à la source : le premier chemin par lequel il atteint la sortie est donc nécessairement un plus court. L'algorithme complet : créer une file et y enfiler la case d'entrée, marquée à la distance 0 ; marquer cette case comme visitée ; tant que la file n'est pas vide, défiler une case, et pour chacun de ses voisins libres non visités, le marquer visité, lui attribuer la distance de la case défilée plus 1, et l'enfiler. Si la sortie est marquée, sa distance est la longueur du plus court chemin ; si la file se vide sans l'atteindre, la sortie est inaccessible. Le marquage AVANT l'enfilement, et non au défilement, est essentiel : sans lui, une même case peut être enfilée plusieurs fois et le coût cesse d'être linéaire.

c) On maintient une structure supplémentaire, un tableau de PRÉDÉCESSEURS : au moment où l'on marque un voisin, on enregistre la case depuis laquelle on l'a découvert. À la fin, on part de la sortie et on remonte de prédécesseur en prédécesseur jusqu'à l'entrée, ce qui donne le chemin à l'envers ; il suffit de le retourner. Le coût de cette reconstruction est proportionnel à la longueur du chemin, donc négligeable devant le parcours lui-même, et le surcoût mémoire est d'une case par case, ce qui est le prix normal à payer pour connaître le chemin et pas seulement sa longueur.

d) Sur une grille pleine, aller du coin haut gauche au coin bas droit demande de descendre 29 fois et d'aller à droite 39 fois, dans un ordre quelconque : la distance vaut 29+39=6829 + 39 = 68. Le nombre de plus courts chemins est le nombre de façons de placer les 29 descentes parmi les 68 déplacements, soit le coefficient binomial (6829)\binom{68}{29}. Sa valeur vaut environ 1,4×10191{,}4 \times 10^{19}, soit près de quatorze milliards de milliards. C'est un excellent rappel : le plus court chemin est très rarement unique, et un algorithme qui les énumérerait tous serait sans espoir alors que celui qui en trouve un est linéaire.

e) Sur une grille de 3 000×4 0003\ 000 \times 4\ 000, il y a 12 000 00012\ 000\ 000 cases et environ 2×12 000 000=24 000 0002 \times 12\ 000\ 000 = 24\ 000\ 000 arêtes. Le coût du parcours en largeur est proportionnel à la somme des deux, soit environ 3,6×1073{,}6 \times 10^{7} opérations, ce qui prend moins d'une seconde. La file contient au plus les cases d'un front d'onde, dont la taille est de l'ordre du périmètre du front : quelques milliers de cases dans le pire cas, ce qui est parfaitement raisonnable. Une matrice d'adjacence serait un contresens complet : elle occuperait 12 000 0002=1,44×101412\ 000\ 000^{2} = 1{,}44 \times 10^{14} cases, soit cent quarante mille milliards, alors que le graphe n'a que vingt-quatre millions d'arêtes. C'est le cas d'école du graphe creux, où la représentation implicite par la grille elle-même est encore meilleure que des listes d'adjacence, puisque les voisins d'une case se calculent au lieu d'être stockés.

Partie C : les classiques (/50)

Exercice 11 : Dictionnaires et tables de hachage

Un dictionnaire retrouve une valeur à partir de sa clé en un temps moyen constant, quelle que soit sa taille. Il repose sur une TABLE DE HACHAGE : un tableau de cases et une fonction qui calcule, à partir de la clé, l'indice de la case où ranger le couple.

On modélise une table de 7 cases pour des clés entières, avec la fonction de hachage kkmod7k \mapsto k \bmod 7 ; chaque case contient la liste des couples qui y tombent.

table = [[] for _ in range(7)]

def inserer(table, cle, valeur):
    case = table[cle % 7]
    for couple in case:
        if couple[0] == cle:
            couple[1] = valeur
            return
    case.append([cle, valeur])

def chercher(table, cle):
    for couple in table[cle % 7]:
        if couple[0] == cle:
            return couple[1]
    return None
  • a) On insère les clés 10, 22, 31, 4, 15 et 45, avec des valeurs quelconques. Donnez la case de chaque clé et le contenu de la table. Combien de collisions, c'est-à-dire d'insertions dans une case déjà occupée, se produit-il ?
  • b) Combien de comparaisons de clés chercher effectue-t-elle pour la clé 45 ? Pour la clé 17, absente ? Pour la clé 5 ?
  • c) On appelle inserer(table, 31, 'x'). Le nombre de couples de la table augmente-t-il ? Quel est le rôle de la boucle de inserer ?
  • d) Le taux de remplissage est le rapport du nombre de couples au nombre de cases. Calculez-le après la question a). On insère ensuite 70 clés en tout, réparties uniformément dans les 7 cases : combien de comparaisons faut-il en moyenne pour une recherche fructueuse ? Que fait un vrai dictionnaire pour conserver un coût constant ?
  • e) En Python, l'instruction d = {[1, 2]: 'a'} lève une erreur TypeError. Expliquez pourquoi une clé ne doit pas être modifiable, en raisonnant sur la fonction de hachage, et proposez une clé qui convient.

Tape tes réponses, la page te dit juste ou faux 0/13

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Cases 3, 1, 3, 4, 1, 3 : trois collisions
  • b) 3, 3 et 0 comparaisons
  • c) Clé existante : valeur remplacée
  • d) Taux 0,8570{,}857 ; 5,55{,}5 comparaisons à 10 par case
  • e) Clé non modifiable : tuple (1, 2)

a) 10mod7=310 \bmod 7 = 3, 22mod7=122 \bmod 7 = 1, 31mod7=331 \bmod 7 = 3, 4mod7=44 \bmod 7 = 4, 15mod7=115 \bmod 7 = 1 et 45mod7=345 \bmod 7 = 3. La case 1 contient les couples de clés 22 et 15, la case 3 ceux de clés 10, 31 et 45, la case 4 celui de clé 4 ; les autres cases sont vides. Collisions : 31 tombe sur 10, 15 tombe sur 22, 45 tombe sur 10 et 31, soit 3 collisions. Six clés dans sept cases suffisent donc à en provoquer trois : les collisions ne sont pas une anomalie, elles sont la règle, et la structure doit les gérer.

b) Pour 45 : la case 3 contient 10, 31 puis 45, donc 3 comparaisons. Pour 17 : 17mod7=317 \bmod 7 = 3, on compare à 10, 31 et 45 sans succès, soit 3 comparaisons avant de renvoyer None. Pour 5 : 5mod7=55 \bmod 7 = 5, la case est vide, donc 0 comparaison. Le coût d'une recherche ne dépend pas de la taille totale de la table, mais seulement de la longueur de la case visée.

c) Non. La boucle parcourt la case 3, trouve un couple de clé 31 et remplace sa valeur par 'x', puis la fonction se termine : aucun couple n'est ajouté. Le rôle de la boucle est de garantir l'UNICITÉ des clés, qui est le contrat d'un dictionnaire ; sans elle, la case contiendrait deux couples de clé 31, et chercher renverrait toujours le plus ancien.

d) Taux de remplissage : 6/70,8576 / 7 \approx 0{,}857. Avec 70 clés uniformément réparties, chaque case en contient 10 ; une recherche fructueuse trouve la clé au rang 1, 2, et ainsi de suite jusqu'à 10 avec la même probabilité, donc 1+2++1010=5,5\frac{1 + 2 + \dots + 10}{10} = 5{,}5 comparaisons en moyenne. Le coût croît donc avec le taux de remplissage. Un vrai dictionnaire AGRANDIT sa table dès que ce taux dépasse un seuil, en général en doublant le nombre de cases et en redistribuant toutes les clés : cette redistribution coûte cher, mais elle est assez rare pour que le coût moyen par insertion reste constant, exactement comme dans l'analyse amortie de l'exercice 3.

e) La case d'un couple est calculée à partir de la clé AU MOMENT de l'insertion. Si la clé était une liste et qu'on la modifiait ensuite, sa nouvelle empreinte désignerait en général une autre case : le couple resterait rangé dans l'ancienne, et plus aucune recherche ne le trouverait. Python interdit donc les objets modifiables comme clés, listes, ensembles et dictionnaires. On utilise à la place le tuple (1, 2), qui contient les mêmes valeurs mais ne peut pas changer.

Exercice 12 : Annuler et rétablir avec deux piles

Un éditeur mémorise les actions de l'utilisateur pour pouvoir les annuler, puis les rétablir. On le modélise avec deux piles, la classe Pile étant celle de l'exercice 1 ; chaque action ajoute un mot à la fin du texte.

class Editeur:
    def __init__(self):
        self.texte = []
        self.annulables = Pile()
        self.retablissables = Pile()

    def ecrire(self, mot):
        self.texte.append(mot)
        self.annulables.empiler(mot)
        self.retablissables = Pile()

    def annuler(self):
        if not self.annulables.est_vide():
            mot = self.annulables.depiler()
            self.texte.pop()
            self.retablissables.empiler(mot)

    def retablir(self):
        if not self.retablissables.est_vide():
            mot = self.retablissables.depiler()
            self.texte.append(mot)
            self.annulables.empiler(mot)
  • a) On exécute ecrire('le'), ecrire('chat'), ecrire('dort'), puis annuler() deux fois. Donnez le texte et le contenu des deux piles, du fond vers le sommet.
  • b) On poursuit par retablir(). Donnez le texte et le contenu des deux piles.
  • c) On poursuit par ecrire('mange'), puis retablir(). Donnez le texte obtenu. Pourquoi ecrire vide-t-elle la pile des rétablissables ?
  • d) Une séance comporte nn écritures, suivies de nn annulations puis de nn rétablissements. Combien d'empilements et de dépilements sont effectués au total ? Quel est le coût de chaque méthode ?
  • e) Pour limiter la mémoire, on ne veut conserver que les 100 dernières actions annulables. Pourquoi une pile ne convient-elle plus telle quelle, et quelle structure faut-il employer ?

Tape tes réponses, la page te dit juste ou faux 0/10

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Texte le ; retablissables dort, chat
  • b) Texte le chat ; retablissables dort
  • c) le chat mange : historique divergé
  • d) 5n5n opérations, coût constant
  • e) Garder 100 actions : file à double entrée

a) Après les trois écritures, le texte vaut le chat dort et annulables contient le, chat, dort. La première annulation dépile dort, le retire du texte et l'empile dans retablissables ; la seconde fait de même avec chat. Le texte vaut donc le ; annulables contient le ; retablissables contient dort, chat du fond vers le sommet, chat étant au sommet puisqu'il a été annulé en dernier.

b) retablir dépile chat, le remet à la fin du texte et l'empile dans annulables. Le texte vaut le chat ; annulables contient le, chat ; retablissables ne contient plus que dort. Les deux piles fonctionnent en miroir : ce qui sort de l'une entre dans l'autre.

c) ecrire('mange') donne le chat mange et remplace retablissables par une pile vide ; le retablir suivant ne fait donc rien, et le texte reste le chat mange. Vider la pile est indispensable parce que l'historique a DIVERGÉ : dort avait été écrit après chat dans une version du texte que l'utilisateur a abandonnée. Le rétablir maintenant produirait le chat mange dort, un texte que personne n'a jamais écrit.

d) Chaque écriture fait 1 empilement, chaque annulation 1 dépilement et 1 empilement, chaque rétablissement 1 dépilement et 1 empilement, en plus des opérations sur la liste texte. Total : n+2n+2n=5nn + 2n + 2n = 5n opérations sur les piles, par exemple 5 0005\ 000 pour n=1 000n = 1\ 000. Chaque méthode a un coût CONSTANT, indépendant de la longueur de l'historique, parce qu'elle ne touche que les sommets des piles et la fin du texte.

e) Conserver les 100 dernières actions oblige, à la cent unième, à supprimer la PLUS ANCIENNE, qui se trouve au fond de la pile, là où une pile ne donne aucun accès. Il faut une structure qui permette d'ajouter et de retirer aux deux extrémités : une file à double entrée, que Python fournit sous le nom de deque, ou un tableau circulaire de 100 cases comme celui de l'exercice suivant. On retire alors par le fond et on travaille par le sommet, chaque opération restant à coût constant.

Exercice 13 : Supprimer dans un arbre binaire de recherche

On construit un arbre binaire de recherche en insérant, dans cet ordre, les valeurs 50, 30, 70, 20, 40, 60, 80, 35, 45 et 65. Supprimer une valeur doit préserver la propriété d'arbre binaire de recherche, et l'opération distingue trois cas selon le nombre de fils du noeud supprimé.

  • a) Dessinez l'arbre obtenu. Donnez sa hauteur, en nombre d'arêtes, et le nombre de ses feuilles.
  • b) Premier cas, le noeud est une feuille. Supprimez 20 et décrivez l'opération.
  • c) Deuxième cas, le noeud n'a qu'un fils. Supprimez 60 dans l'arbre de départ et justifiez que la propriété est préservée.
  • d) Troisième cas, le noeud a deux fils. On remplace sa valeur par celle de son SUCCESSEUR, la plus petite valeur de son sous-arbre droit, puis on supprime le noeud du successeur. Supprimez 30 dans l'arbre de départ : quel est le successeur, et quel sous-arbre gauche de la racine obtient-on ?
  • e) Pourquoi le successeur n'a-t-il jamais de fils gauche ? Donnez le parcours infixe de l'arbre obtenu en d) et vérifiez qu'il est croissant. Quel est le coût d'une suppression ?

Tape tes réponses, la page te dit juste ou faux 0/8

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Hauteur 3, cinq feuilles
  • b) Feuille : on la détache
  • c) Un fils : 65 remonte sous 70
  • d) Deux fils : successeur 35
  • e) Infixe croissant, coût en hauteur

a) La racine est 50. Son fils gauche est 30, qui a pour fils 20 et 40 ; 40 a pour fils 35 et 45. Son fils droit est 70, qui a pour fils 60 et 80 ; 60 n'a qu'un fils droit, 65. La hauteur vaut 3, atteinte par les chemins 50, 30, 40, 35 et 50, 70, 60, 65. Les feuilles sont 20, 35, 45, 65 et 80 : il y en a 5.

b) Une feuille n'a aucun descendant : on la détache simplement de son père, en mettant à vide le fils gauche de 30. Aucun autre noeud n'est déplacé, et la propriété reste vraie puisque les valeurs restantes n'ont pas changé de place.

c) On remplace 60 par son unique sous-arbre : 65 devient le fils gauche de 70. La propriété est préservée parce que 65 appartenait déjà au sous-arbre gauche de 70, donc 65<7065 < 70, et au sous-arbre droit de 50, donc 65>5065 > 50 : en le remontant d'un niveau, on ne le fait changer d'aucun des sous-arbres qui le contenaient.

d) Le sous-arbre droit de 30 contient 40, 35 et 45 ; sa plus petite valeur est 35, obtenue en descendant toujours à gauche depuis 40. On écrit 35 à la place de 30, puis on supprime l'ancienne feuille 35, ce qui relève du premier cas. Le sous-arbre gauche de la racine a maintenant pour racine 35, avec pour fils gauche 20 et pour fils droit 40, lui-même n'ayant plus qu'un fils droit, 45. On vérifie : 20 est bien inférieur à 35, et 40 et 45 lui sont bien supérieurs.

e) Le successeur est le noeud le plus à gauche du sous-arbre droit : on l'atteint en descendant à gauche tant que c'est possible. S'il avait un fils gauche, ce fils serait plus petit que lui tout en appartenant au même sous-arbre, ce qui contredirait sa minimalité. Sa suppression relève donc toujours du premier ou du deuxième cas, jamais du troisième, et la récursion s'arrête. Parcours infixe de l'arbre obtenu : 20, 35, 40, 45, 50, 60, 65, 70, 80, croissant. Coût : on descend jusqu'au noeud, puis jusqu'au successeur, en suivant un seul chemin depuis la racine, donc au plus la hauteur plus un noeuds visités. La suppression coûte comme la recherche, et hérite du même besoin d'équilibre.

Exercice 14 : Une file circulaire dans un tableau

Une file de capacité fixe se range dans un tableau sans jamais décaler ses éléments : on mémorise l'indice de la tête et le nombre d'éléments, et les indices tournent grâce au modulo. C'est la structure des tampons de clavier, de son ou de réseau.

class FileCirculaire:
    def __init__(self, capacite):
        self.tab = [None] * capacite
        self.tete = 0
        self.taille = 0

    def enfiler(self, x):
        if self.taille == len(self.tab):
            raise IndexError('file pleine')
        queue = (self.tete + self.taille) % len(self.tab)
        self.tab[queue] = x
        self.taille = self.taille + 1

    def defiler(self):
        if self.taille == 0:
            raise IndexError('file vide')
        x = self.tab[self.tete]
        self.tete = (self.tete + 1) % len(self.tab)
        self.taille = self.taille - 1
        return x
  • a) On crée f = FileCirculaire(4), on enfile 'a', 'b' et 'c', puis on défile deux fois. Donnez les valeurs renvoyées, puis tab, tete et taille.
  • b) On enfile ensuite 'd', 'e' et 'f'. Donnez à chaque fois l'indice de la case utilisée, puis l'état final de tab, tete et taille.
  • c) Que se passe-t-il si l'on enfile 'g' ? Dans quel ordre les quatre éléments présents sortiront-ils ?
  • d) Pourquoi mémorise-t-on la taille ? Montrez qu'avec seulement l'indice de tête et l'indice de la prochaine case libre, une file pleine et une file vide seraient indiscernables.
  • e) Comparez le coût d'un défilement ici, avec une liste Python et pop(0), et avec la file à deux piles de l'exercice 3. Pour 1 000 0001\ 000\ 000 d'opérations sur une file qui contient en permanence environ 1 0001\ 000 éléments, dont la moitié de défilements, estimez le nombre de décalages provoqués par pop(0).

Tape tes réponses, la page te dit juste ou faux 0/12

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Renvoie a puis b ; tete 2, taille 1
  • b) Cases 3, 0, 1 : tab = ['e', 'f', 'c', 'd']
  • c) Pleine ; sortie c, d, e, f
  • d) Sans taille, pleine et vide se confondent
  • e) Constant ; pop(0) : 5×1085 \times 10^{8} décalages

a) Les trois enfilements remplissent les cases 0, 1 et 2. Le premier défilement renvoie 'a' et fait passer tete à 1, le second renvoie 'b' et fait passer tete à 2. On obtient tab = ['a', 'b', 'c', None], tete = 2 et taille = 1. Les valeurs 'a' et 'b' sont encore écrites dans le tableau, mais elles ne font plus partie de la file : seules les cases de tete à tete + taille - 1, modulo 4, comptent.

b) Pour 'd' : (2+1)mod4=3(2 + 1) \bmod 4 = 3, case 3, taille 2. Pour 'e' : (2+2)mod4=0(2 + 2) \bmod 4 = 0, case 0, qui écrase l'ancien 'a', taille 3. Pour 'f' : (2+3)mod4=1(2 + 3) \bmod 4 = 1, case 1, taille 4. État final : tab = ['e', 'f', 'c', 'd'], tete = 2, taille = 4. La queue a fait le tour du tableau et se trouve maintenant AVANT la tête : c'est le sens du mot circulaire.

c) taille vaut 4, égale à la capacité : enfiler lève l'exception « file pleine », et rien n'est écrasé. Les éléments sortiront en partant de la tête, case 2, puis cases 3, 0 et 1 : 'c', 'd', 'e', 'f', c'est-à-dire exactement leur ordre d'arrivée.

d) Notons qq l'indice de la prochaine case libre. Une file vide a q=tq = t, puisqu'aucun élément ne sépare la tête de la queue. Une file pleine a fait le tour complet, donc q=(t+4)mod4=tq = (t + 4) \bmod 4 = t aussi. Les deux situations donnent les mêmes indices et ne se distinguent plus. Mémoriser la taille lève l'ambiguïté ; l'autre solution classique consiste à s'interdire de remplir la dernière case, une file de capacité 4 n'acceptant alors que 3 éléments.

e) Ici, un défilement lit une case et met à jour deux entiers : coût CONSTANT, même dans le pire cas. Avec pop(0), chaque défilement décale tous les éléments suivants, soit environ 1 0001\ 000 décalages ; pour 500 000500\ 000 défilements, cela fait 5×1085 \times 10^{8} décalages, là où la file circulaire n'en fait aucun. La file à deux piles est constante en moyenne, grâce à l'analyse amortie, mais un défilement isolé peut transvaser toute la pile : pour un tampon audio qui doit répondre à intervalles réguliers, c'est la garantie dans le pire cas de la file circulaire qui compte.

Exercice 15 : Dépendances, cycles et ordre topologique

Un gestionnaire de logiciels doit installer cinq paquets A, B, C, D et E. Un arc de X vers Y signifie que X doit être installé avant Y. Le graphe orienté est représenté par le dictionnaire graphe = {'A': ['B', 'C'], 'B': ['E'], 'C': ['E'], 'D': ['E'], 'E': []}.

La fonction ci-dessous calcule un ordre d'installation par un parcours en profondeur. Un sommet est blanc tant qu'il n'a pas été atteint, gris pendant sa visite, noir quand tous ses successeurs ont été traités.

def tri_topologique(graphe):
    ordre = []
    etat = {s: 'blanc' for s in graphe}

    def visiter(s):
        etat[s] = 'gris'
        for v in graphe[s]:
            if etat[v] == 'gris':
                raise ValueError('cycle')
            if etat[v] == 'blanc':
                visiter(v)
        etat[s] = 'noir'
        ordre.append(s)

    for s in graphe:
        if etat[s] == 'blanc':
            visiter(s)
    return ordre[::-1]
  • a) Donnez le degré entrant de chaque sommet. Quels paquets peuvent être installés en premier ?
  • b) Faites la trace de tri_topologique(graphe) : donnez l'ordre dans lequel les sommets deviennent noirs, puis la liste renvoyée.
  • c) Vérifiez que chaque arc va d'un sommet placé avant vers un sommet placé après dans cette liste. Donnez un autre ordre valide, puis le nombre total d'ordres valides.
  • d) On ajoute l'arc de E vers A. Montrez qu'aucun ordre d'installation n'existe plus, et indiquez à quel moment de la trace l'exception est levée.
  • e) Pourquoi rencontrer un voisin gris signale-t-il un cycle, alors que rencontrer un voisin noir n'en signale pas ? Quel est le coût de l'algorithme pour un graphe de nn sommets et mm arcs ?

Tape tes réponses, la page te dit juste ou faux 0/9

a)
b)
c)
d)
e)
Voir la correction

Réponses

  • a) Degrés entrants 0, 1, 1, 0, 3 : A et D d'abord
  • b) Noirs E, B, C, A, D ; ordre D, A, C, B, E
  • c) 8 ordres valides
  • d) Cycle A, B, E : voisin gris
  • e) Gris : chemin en cours ; coût n+mn + m

a) Degrés entrants : A 0, B 1, C 1, D 0, E 3. Peuvent être installés en premier les paquets sans prérequis, c'est-à-dire de degré entrant nul : A et D.

b) On visite A, qui devient gris, puis son premier voisin B, puis le voisin E de B. E n'a aucun successeur : il devient noir le premier. On revient à B, qui devient noir, puis à A, dont le second voisin C mène à E, déjà noir : C devient noir, puis A. La boucle principale passe ensuite à D, encore blanc, dont le voisin E est noir : D devient noir. Ordre des sommets noirs : E, B, C, A, D. La liste renvoyée est son inverse : D, A, C, B, E.

c) Positions dans D, A, C, B, E : A avant B et C, B avant E, C avant E, D avant E. Les cinq arcs vont bien vers l'avant. Un autre ordre valide est A, D, B, C, E. Dénombrement : E doit venir après tous les autres, donc en dernier ; parmi les 4!=244! = 24 ordres de A, B, C et D, il faut que A précède B et C, ce qui arrive dans un tiers des cas, puisque A doit être le premier des trois. Il y a donc 8 ordres valides : un tri topologique n'est presque jamais unique.

d) Les arcs A vers B, B vers E et E vers A forment un CYCLE : A devrait être installé avant B, B avant E et E avant A, donc A avant lui-même, ce qui est impossible. Dans la trace, A devient gris, puis B, puis E ; en examinant le voisin A de E, on le trouve gris, et l'exception est levée à cet instant, avant qu'aucun sommet soit devenu noir.

e) Les sommets gris sont exactement ceux dont la visite est EN COURS : ils forment le chemin qui mène de la racine du parcours au sommet visité. Trouver un voisin gris, c'est trouver un arc qui revient sur ce chemin, donc un cycle. Un sommet noir, lui, a été entièrement exploré : s'il existait un chemin de lui vers le sommet courant, le parcours l'aurait déjà suivi et le sommet courant serait noir à son tour. L'arc vers un noir mène donc vers une partie déjà terminée, sans retour possible. Coût : chaque sommet est visité une fois et chaque arc examiné une fois, soit un temps proportionnel à n+mn + m.

Chapitre précédent Algorithmique : recherche, tris, récursivité et complexité Chapitre suivant Bases de données relationnelles et langage SQL

Ce chapitre resservira dans

Les chapitres qui le réclament en amont, plus tard dans l'année ou dans les années suivantes.

Voir aussi

Vous cherchez un tuteur en NSI à Montréal ?

Contactez-moi pour une première séance. Les structures de données sont le coeur de l'épreuve écrite de Terminale, et ce sont aussi les questions qui rapportent le plus vite, parce que les mêmes quatre structures reviennent d'année en année.

Site par Studio Squalli