Arbres

Bonjour à toutes et à tous ! 🤩 Aujourd’hui, on va explorer un sujet super cool et très utile en informatique : les arbres binaires et l’arborescence ! Accrochez-vous, ça va être clair, amusant et vous allez voir que c’est partout autour de nous !


🏉 Le tournoi de rugby : une histoire d’arbres !

Imaginez que vous êtes l’organisateur d’un grand tournoi de rugby. Au début, tout est simple : 4 poules de 4 équipes, et les 2 meilleures de chaque poule se qualifient pour les quarts de finale. Jusque-là, tout va bien ! Mais quand il faut expliquer aux spectateurs comment on passe des quarts aux demies, et des demies à la finale, c’est la panique générale ! 🤯

Voici les équipes qualifiées :

  • Poule 1 : 1er Eq1 ; 2e Eq8
  • Poule 2 : 1er Eq2 ; 2e Eq7
  • Poule 3 : 1er Eq3 ; 2e Eq6
  • Poule 4 : 1er Eq4 ; 2e Eq5

Les quarts de finale sont :

  • Quart 1 : Eq1 contre Eq5
  • Quart 2 : Eq2 contre Eq6
  • Quart 3 : Eq3 contre Eq7
  • Quart 4 : Eq4 contre Eq8

Et les demi-finales :

  • Demi-finale 1 : Vainqueur Quart 1 contre Vainqueur Quart 3
  • Demi-finale 2 : Vainqueur Quart 2 contre Vainqueur Quart 4

C’est là que les choses se compliquent ! Pour un spectateur lambda, c’est un vrai casse-tête de suivre tout ça. Et si on utilisait un graphique ? Un truc simple qui montre qui joue contre qui, et comment on arrive à la finale ?

S’ouvre dans une nouvelle fenêtre

Licensed by Google

Bingo ! Ce type de graphique, c’est ce qu’on appelle une structure en arbre ! Ça simplifie énormément la compréhension des liens hiérarchiques. Les spectateurs peuvent facilement visualiser le chemin vers la gloire ! 🏆


🌳 Mais qu’est-ce qu’un arbre en informatique ?

Vous avez déjà croisé des arbres sans le savoir ! Pensez à un arbre généalogique : vous êtes un « fils » de vos parents, qui sont eux-mêmes les « fils » de leurs parents, et ainsi de suite. C’est une structure hiérarchique parfaite !

S’ouvre dans une nouvelle fenêtre

Licensed by Google

Lovely family tree with decorative flowers

Un autre exemple que vous avez vu l’année dernière : les systèmes de fichiers de votre ordinateur (comme sur Linux ou macOS). Vous avez un dossier principal (la « racine »), et à l’intérieur, d’autres dossiers et fichiers. Certains sont plus « profonds » que d’autres. On ne peut pas représenter cette hiérarchie juste avec une simple liste de noms !

Les arbres sont des structures de données super puissantes et très utilisées en informatique parce qu’elles permettent d’organiser des informations de manière hiérarchique. Ça veut dire qu’il y a des relations de « parent-enfant » ou de « supérieur-inférieur » entre les données. Parfois, il y a une notion de temps (comme pour le tournoi : les quarts avant les demies), mais ce n’est pas toujours le cas (le système de fichiers n’a pas de dimension temporelle).


🌲 Les arbres binaires : la version VIP des arbres !

Dans le grand monde des arbres, il y a une catégorie très spéciale et très populaire : les arbres binaires. Notre arbre de tournoi de rugby est un arbre binaire, et l’arbre « père, mère… » aussi. Par contre, le système de fichiers n’en est pas un.

Alors, quelle est la règle d’or d’un arbre binaire ? C’est simple : chaque élément (appelé « nœud ») peut avoir au maximum DEUX branches qui partent de lui. Pas plus ! Dans notre système de fichiers, la racine a beaucoup plus que deux branches qui partent d’elle, donc ce n’est pas un arbre binaire.

Dans la suite, on va se concentrer uniquement sur ces stars : les arbres binaires !


🗣️ Le dico de l’arbre binaire : du vocabulaire à connaître !

Pour ne pas se perdre, voici un petit glossaire des termes clés quand on parle d’arbres binaires. Regardons cet arbre ensemble :

  • Nœud : C’est un élément de l’arbre. Pensez à chaque bulle dans notre schéma (A, B, C, D, etc.) comme un nœud. C’est l’unité de base de notre arbre.
  • Racine : C’est le tout premier nœud de l’arbre, celui par où tout commence. C’est un peu le « chef » de l’arbre ! Dans notre exemple, c’est le nœud A.
  • Fils : Si un nœud a des éléments qui partent de lui, ce sont ses « fils ». Par exemple, les nœuds E et D sont les fils du nœud B.
  • Père : Le contraire de fils ! Le nœud B est le père des nœuds E et D. Chaque nœud (sauf la racine) a un seul père.
  • Feuille : Un nœud qui n’a aucun fils. C’est la fin du chemin pour ce nœud ! Dans notre exemple, D, H, N, O, J, K, L, P et Q sont des feuilles. Ils n’ont personne « en dessous » d’eux.
  • Sous-arbre : À partir d’un nœud (qui n’est pas une feuille), on peut imaginer un « mini-arbre » formé par ce nœud et tous ses descendants. Dans un arbre binaire, on parle de sous-arbre gauche et de sous-arbre droit. Par exemple, à partir de C, le sous-arbre gauche contient F, J et K, et le sous-arbre droit contient G, L, M, P et Q.
  • Arête : C’est le trait qui relie deux nœuds. C’est le « chemin » entre eux.
  • Taille d’un arbre : C’est tout simplement le nombre total de nœuds qu’il contient. Comptez-les tous !
  • Profondeur d’un nœud : C’est la distance entre la racine et ce nœud. On compte le nombre de nœuds sur le chemin.
    • Attention : Parfois, la racine est à la profondeur 1, parfois à 0. C’est une convention ! Dans ce cours, on va dire que la profondeur de la racine est 1. Donc, si la racine est à 1, un nœud juste en dessous (ses fils) sera à 2, et ainsi de suite.
    • Exemples (avec racine à profondeur 1) : profondeur de B = 2 ; profondeur de I = 4 ; profondeur de P = 5.
  • Hauteur d’un arbre : C’est la plus grande profondeur parmi tous les nœuds de l’arbre. En gros, c’est la profondeur de la feuille la plus « basse » de l’arbre.
    • Avec notre convention (racine à profondeur 1), la hauteur de notre arbre est 5 (car P est à la profondeur 5).

🔄 Les arbres sont des structures récursives !

C’est un point super important : les arbres sont comme des poupées russes ! Un nœud, avec ses sous-arbres gauche et droit, peut être vu comme un arbre en lui-même. Et ces sous-arbres sont eux-mêmes des arbres, et ainsi de suite jusqu’aux feuilles. Cette idée de « structure qui contient des structures du même type » est la base de la récursivité, un concept clé en informatique.


🎨 Des arbres de toutes les formes !

On peut avoir des arbres de même taille qui ont des formes très différentes. Regardez ces deux exemples :

  • L’arbre 1 est dit « filiforme » (ou dégénéré). Il ressemble à une longue chaîne. C’est un peu le « serpent » des arbres !
  • L’arbre 2 est dit « complet« . Tous ses nœuds, sauf les feuilles, ont deux fils, et toutes les feuilles sont à la même profondeur. C’est un peu le « buisson » bien taillé ! 🌳

On dit que l’arbre 1 est déséquilibré et l’arbre 2 est équilibré. L’équilibre est important pour l’efficacité de certains algorithmes, comme on le verra.

Quelques maths pour les pros ! 🤓

  • Pour un arbre filiforme de taille n, sa hauteur h est n−1 (si on considère que la racine est à profondeur 0).
  • Pour un arbre complet de taille n, sa hauteur h est lfloorlog_2(n)rfloor (la partie entière de log_2(n)). Pour l’arbre 2 (taille 7), log_2(7)approx2.8, donc sa hauteur est 2.
  • Pour n’importe quel arbre binaire de taille n et de hauteur h, on a toujours : lfloorlog_2(n)rfloorlehlen−1.

🧑‍💻 Arbres binaires en Python : on va les construire !

Python ne propose pas d’arbres binaires « prêts à l’emploi », mais pas de panique ! Plus tard dans l’année, nous allons apprendre à les implémenter nous-mêmes en utilisant la programmation orientée objet. C’est un excellent moyen de comprendre comment ça marche « sous le capot » ! 🛠️


🚀 Les algorithmes sur les arbres binaires : L’aventure continue !

Maintenant que nous maîtrisons le vocabulaire, il est temps de passer à l’action ! Comment manipuler ces arbres ? On va découvrir des algorithmes super utiles.

Chaque nœud d’un arbre binaire a :

  • Une clé (ou valeur) : c’est l’information qu’il contient.
  • Un sous-arbre gauche : le « mini-arbre » de gauche.
  • Un sous-arbre droit : le « mini-arbre » de droite.

Regardons cet arbre :

  • Pour le nœud A (la racine) :
    • Son sous-arbre gauche contient B, C, D, E.
    • Son sous-arbre droit contient F, G, H, I, J.
  • Pour le nœud B :
    • Son sous-arbre gauche contient C, E.
    • Son sous-arbre droit contient D.
  • Un arbre (ou un sous-arbre) vide est représenté par NIL (du latin nihil, qui veut dire « rien »). Si on prend le nœud G :
    • Son sous-arbre gauche contient I.
    • Son sous-arbre droit est vide (NIL).

Retenez bien : un sous-arbre, même s’il n’a qu’un seul nœud ou qu’il est vide, est toujours considéré comme un arbre !

En général, pour un arbre T et un nœud x :

  • T.racine : c’est le nœud racine de l’arbre T.
  • x.gauche : c’est le sous-arbre gauche du nœud x.
  • x.droit : c’est le sous-arbre droit du nœud x.
  • x.clé : c’est la valeur stockée dans le nœud x.

Et si un nœud x est une feuille, alors x.gauche et x.droit sont des arbres vides (NIL).


📏 Calculer la hauteur d’un arbre : l’algorithme magique !

On se souvient de la hauteur ? C’est la profondeur maximale ! Comment la calculer de manière automatique ? Voici un algorithme récursif :

VARIABLE
T : arbre
x : noeud

DEBUT
HAUTEUR(T) :
  si T ≠ NIL : // Si l'arbre n'est pas vide
    x ← T.racine // On prend le noeud racine
    // La hauteur est 1 (pour le noeud actuel) + le max des hauteurs de ses sous-arbres
    renvoyer 1 + max(HAUTEUR(x.gauche), HAUTEUR(x.droit))
  sinon : // Si l'arbre est vide
    renvoyer 0
  fin si
FIN

À faire vous-même 1 Prenez l’arbre ci-dessus et essayez d’appliquer cet algorithme « à la main » ! C’est un super exercice pour comprendre la récursivité en action. N’hésitez pas à dessiner les étapes sur un brouillon. Si vous êtes bloqués, c’est normal, la récursivité peut être un peu tricky au début ! 😉


🔢 Calculer la taille d’un arbre : compter les nœuds !

On veut maintenant savoir combien de nœuds il y a dans un arbre. C’est très similaire au calcul de la hauteur !

VARIABLE
T : arbre
x : noeud

DEBUT
TAILLE(T) :
  si T ≠ NIL : // Si l'arbre n'est pas vide
    x ← T.racine // On prend le noeud racine
    // La taille est 1 (pour le noeud actuel) + la taille du sous-arbre gauche + la taille du sous-arbre droit
    renvoyer 1 + TAILLE(x.gauche) + TAILLE(x.droit)
  sinon : // Si l'arbre est vide
    renvoyer 0
  fin si
FIN

À faire vous-même 2 Appliquez cet algorithme sur l’arbre ci-dessus. Vous devriez le trouver un peu plus simple que le précédent !


🚶 Parcourir un arbre : faire le tour du propriétaire !

Parcourir un arbre, ça veut dire visiter tous ses nœuds, mais dans un ordre bien précis. Il existe plusieurs façons de faire ce « tour du propriétaire ». On va en explorer quelques-unes.

Parcours infixe (ou en « ordre central »)

Cet algorithme visite d’abord le sous-arbre gauche, puis le nœud actuel, puis le sous-arbre droit. C’est comme une visite guidée équilibrée !

VARIABLE
T : arbre
x : noeud

DEBUT
PARCOURS-INFIXE(T) :
  si T ≠ NIL :
    x ← T.racine
    PARCOURS-INFIXE(x.gauche) // Visite le sous-arbre gauche
    affiche x.clé          // Affiche la clé du noeud actuel
    PARCOURS-INFIXE(x.droit)  // Visite le sous-arbre droit
  fin si
FIN

À faire vous-même 3 Vérifiez qu’en appliquant cet algorithme sur l’arbre ci-dessus, l’ordre d’affichage est bien : C, E, B, D, A, I, G, F, H, J.

Parcours préfixe (ou en « ordre de profondeur »)

Ici, on visite d’abord le nœud actuel, puis le sous-arbre gauche, puis le sous-arbre droit. On « affiche » avant d’explorer !

VARIABLE
T : arbre
x : noeud

DEBUT
PARCOURS-PREFIXE(T) :
  si T ≠ NIL :
    x ← T.racine
    affiche x.clé          // Affiche la clé du noeud actuel
    PARCOURS-PREFIXE(x.gauche) // Visite le sous-arbre gauche
    PARCOURS-PREFIXE(x.droit)  // Visite le sous-arbre droit
  fin si
FIN

À faire vous-même 4 Vérifiez qu’en appliquant cet algorithme sur l’arbre ci-dessus, l’ordre d’affichage est bien : A, B, C, E, D, F, G, I, H, J.

Parcours suffixe (ou en « ordre postfixe »)

Pour ce parcours, on visite d’abord le sous-arbre gauche, puis le sous-arbre droit, et enfin le nœud actuel. On « affiche » à la fin de l’exploration !

VARIABLE
T : arbre
x : noeud

DEBUT
PARCOURS-SUFFIXE(T) :
  si T ≠ NIL :
    x ← T.racine
    PARCOURS-SUFFIXE(x.gauche) // Visite le sous-arbre gauche
    PARCOURS-SUFFIXE(x.droit)  // Visite le sous-arbre droit
    affiche x.clé          // Affiche la clé du noeud actuel
  fin si
FIN

À faire vous-même 5 Vérifiez qu’en appliquant cet algorithme sur l’arbre ci-dessus, l’ordre d’affichage est bien : E, C, D, B, I, G, J, H, F, A.

Parcourir un arbre en largeur d’abord

Contrairement aux parcours précédents (qui explorent en profondeur), celui-ci explore niveau par niveau, de gauche à droite. Il utilise une structure de données que vous connaissez : la file (FIFO) !

VARIABLE
T : arbre
Tg : arbre
Td : arbre
x : noeud
f : file (initialement vide)

DEBUT
PARCOURS-LARGEUR(T) :
  enfiler(T.racine, f) // On place la racine dans la file
  tant que f non vide :
    x ← defiler(f) // On retire le premier élément de la file
    affiche x.clé // On l'affiche
    si x.gauche ≠ NIL :
      Tg ← x.gauche
      enfiler(Tg.racine, f) // On ajoute le fils gauche à la file
    fin si
    si x.droit ≠ NIL :
      Td ← x.droit
      enfiler(Td.racine, f) // On ajoute le fils droit à la file
    fin si
  fin tant que
FIN

À faire vous-même 6 Vérifiez qu’en appliquant cet algorithme sur l’arbre ci-dessus, l’ordre d’affichage est bien : A, B, F, C, D, G, H, E, I, J.

Pourquoi parle-t-on de parcours en largeur ? Parce qu’on explore tous les nœuds d’un même niveau (ou « largeur ») avant de passer au niveau suivant. Pensez à une inondation qui se répand !


🕵️ Arbre binaire de recherche : le détective des données !

Un arbre binaire de recherche (ABR) est un type particulier d’arbre binaire, très très utile ! C’est comme un arbre binaire qui a le sens de l’ordre. Pour qu’un arbre soit un ABR, il faut trois choses :

  1. C’est un arbre binaire (logique !).
  2. Les clés des nœuds doivent pouvoir être ordonnées (on peut les comparer : « plus petit que », « plus grand que »). Imaginez des nombres ou des mots dans l’ordre alphabétique.
  3. La règle d’or : pour n’importe quel nœud x :
    • Si un nœud y est dans son sous-arbre gauche, alors y.clé doit être inférieure ou égale à x.clé.
    • Si un nœud y est dans son sous-arbre droit, alors x.clé doit être inférieure ou égale à y.clé.

À faire vous-même 7 Vérifiez que l’arbre ci-dessus est bien un arbre binaire de recherche. Prenez quelques nœuds au hasard et vérifiez la règle !

À faire vous-même 8 Appliquez l’algorithme de parcours infixe (qu’on a vu plus haut) sur l’arbre binaire de recherche ci-dessus. Que remarquez-vous sur l’ordre des clés affichées ? Indice : c’est magique ! ✨


🔍 Recherche d’une clé dans un ABR : plus rapide que l’éclair !

Grâce à la propriété d’ordre des ABR, on peut chercher une clé très efficacement ! C’est un peu comme la recherche dichotomique que vous avez étudiée l’année dernière, mais pour les arbres !

Voici l’algorithme de recherche (version récursive) :

VARIABLE
T : arbre
x : noeud
k : entier // La clé que l'on cherche

DEBUT
ARBRE-RECHERCHE(T,k) :
  si T == NIL : // Si l'arbre est vide, la clé n'y est pas
    renvoyer faux
  fin si
  x ← T.racine // On prend le noeud racine
  si k == x.clé : // Si la clé est celle du noeud actuel, on l'a trouvée !
    renvoyer vrai
  fin si
  si k < x.clé : // Si la clé recherchée est plus petite, on va à gauche
    renvoyer ARBRE-RECHERCHE(x.gauche,k)
  sinon : // Sinon (si elle est plus grande), on va à droite
    renvoyer ARBRE-RECHERCHE(x.droit,k)
  fin si
FIN

À faire vous-même 9 Étudiez attentivement cet algorithme. Il est un peu le couteau suisse des ABR !

À faire vous-même 10 Appliquez l’algorithme ARBRE-RECHERCHE sur l’arbre ci-dessus. On va chercher la clé k = 13. Tracez le chemin !

À faire vous-même 11 Appliquez l’algorithme ARBRE-RECHERCHE sur l’arbre ci-dessus. On va chercher la clé k = 16. Que se passe-t-il ?

L’efficacité de la recherche

  • Si l’ABR est équilibré (comme notre « buisson » !), la recherche est super rapide : sa complexité est en O(log_2(n)). C’est comme la dichotomie !
  • Si l’ABR est filiforme (comme notre « serpent » !), la recherche est moins rapide : sa complexité est en O(n). C’est comme une recherche linéaire dans une liste.

Un O(log_2(n)) est beaucoup plus efficace qu’un O(n) pour les grandes quantités de données ! C’est pourquoi on essaie souvent d’avoir des ABR équilibrés.

Version itérative de la recherche

On peut aussi faire la recherche sans récursion, avec une boucle tant que :

VARIABLE
T : arbre
x : noeud
k : entier

DEBUT
ARBRE-RECHERCHE_ITE(T,k) :
  x ← T.racine
  tant que T ≠ NIL et k ≠ x.clé : // Tant que l'arbre n'est pas vide ET qu'on n'a pas trouvé la clé
    x ← T.racine // IMPORTANT : on doit mettre à jour x AVANT de modifier T
    si k < x.clé :
      T ← x.gauche // On se déplace vers le sous-arbre gauche
    sinon :
      T ← x.droit  // On se déplace vers le sous-arbre droit
    fin si
  fin tant que
  si k == x.clé : // Si on est sorti de la boucle parce qu'on a trouvé la clé
    renvoyer vrai
  sinon : // Sinon, c'est qu'on est tombé sur un NIL, la clé n'est pas là
    renvoyer faux
  fin si
FIN

À faire vous-même 12 Étudiez cet algorithme. Comment se compare-t-il à la version récursive ?


➕ Insertion d’une clé dans un ABR : ajouter une branche !

On peut aussi ajouter de nouveaux nœuds à un ABR, tout en respectant ses règles strictes !

VARIABLE
T : arbre
x : noeud
y : noeud // Le nouveau noeud à insérer

DEBUT
ARBRE-INSERTION(T,y) :
  x ← T.racine // On commence par la racine
  tant que T ≠ NIL : // On parcourt l'arbre jusqu'à trouver l'endroit où insérer
    x ← T.racine // IMPORTANT : on doit mettre à jour x AVANT de modifier T
    si y.clé < x.clé :
      T ← x.gauche // Si la clé à insérer est plus petite, on va à gauche
    sinon :
      T ← x.droit // Sinon, on va à droite
    fin si
  fin tant que
  // On est sorti de la boucle, T est NIL. x est le parent potentiel du nouveau noeud.
  si y.clé < x.clé : // Si la clé à insérer est plus petite que celle du parent
    insérer y à gauche de x // On l'insère comme fils gauche
  sinon : // Sinon (si elle est plus grande)
    insérer y à droite de x // On l'insère comme fils droit
  fin si
FIN

À faire vous-même 13 Étudiez attentivement cet algorithme. Il montre comment maintenir l’ordre d’un ABR lors d’une insertion.

À faire vous-même 14 Appliquez l’algorithme ARBRE-INSERTION sur l’arbre ci-dessus. On va insérer un nouveau nœud avec la clé y.clé = 16. Où va-t-il se placer ?


Voilà, vous avez maintenant toutes les bases pour comprendre les arbres binaires et l’arborescence ! C’est une structure fondamentale en informatique, qui sert à organiser des données de manière efficace. Des tournois de rugby aux fichiers de votre ordinateur, les arbres sont partout !

💾 XML et JSON : les langages secrets du web (et des données) !

Vous avez vu comment les arbres nous aident à organiser des données de manière hiérarchique. Eh bien, dans le monde réel de l’informatique, il y a des formats super populaires qui utilisent cette idée pour échanger des données entre différentes applications, ou pour stocker des informations. Ce sont le XML et le JSON ! Ils sont un peu comme les « langages secrets » que les ordinateurs utilisent pour se parler et se comprendre.

Imaginez que vous êtes un chef et que vous voulez envoyer la recette de votre plat le plus incroyable à un ami. Vous ne pouvez pas juste lui envoyer une image, il a besoin des ingrédients, des quantités, des étapes… Bref, des données structurées ! XML et JSON sont ces « formats de recette » super précis.


📝 XML : l’ancêtre bavard des balises

Le XML (pour eXtensible Markup Language) est un peu le grand-père des formats d’échange de données. Il est né pour être très précis, très formel, et on le reconnaît tout de suite à ses balises !

Pensez aux balises HTML que vous avez peut-être déjà vues (<p>, <h1>, <img>). XML, c’est le même principe, mais vous pouvez inventer vos propres balises ! C’est ce qui le rend « extensible ».

Voici à quoi ressemble notre recette de grand chef en XML :

XML

<recette>
  <nom>Gâteau au chocolat super gourmand</nom>
  <temps_preparation unite="minutes">15</temps_preparation>
  <temps_cuisson unite="minutes">30</tempssson>
  <ingredients>
    <ingredient>
      <nom>Chocolat noir</nom>
      <quantite unite="grammes">200</quantite>
    </ingredient>
    <ingredient>
      <nom>Farine</nom>
      <quantite unite="grammes">100</quantite>
    </ingredient>
    <ingredient>
      <nom>Oeufs</nom>
      <quantite unite="unités">4</quantite>
    </ingredient>
    </ingredients>
  <etapes>
    <etape numero="1">Faire fondre le chocolat.</etape>
    <etape numero="2">Mélanger les œufs et la farine.</etape>
    <etape numero="3">Incorporer le chocolat fondu.</etape>
    </etapes>
</recette>

Ce qu’il faut retenir du XML :

  • Hiérarchique comme un arbre : Chaque balise (<recette>, <ingredients>, <ingredient>) peut contenir d’autres balises, créant une structure en arbre ! C’est comme les nœuds et les sous-arbres que nous avons vus.
  • Balises de début et de fin : Pour chaque élément, vous avez une balise d’ouverture (<nom>) et une balise de fermeture (</nom>). C’est ce qui le rend verbeux (il y a beaucoup de caractères).
  • Attributs : On peut ajouter des informations directement sur les balises (comme unite="minutes" pour temps_preparation).
  • Lisible par l’humain : Même si c’est un peu long, on comprend ce que ça veut dire.
  • Strict : Il y a des règles très précises à respecter pour que le XML soit « bien formé » (par exemple, pas de balise oubliée).

Le XML est encore très utilisé, surtout dans les systèmes d’entreprise où la validation des données est primordiale.


🚀 JSON : le petit nouveau léger et agile

Le JSON (pour JavaScript Object Notation) est arrivé plus tard et a gagné en popularité grâce à sa simplicité et sa légèreté. Il est devenu le chouchou du web et des applications mobiles pour échanger des données. Il est inspiré de la façon dont les objets sont structurés en JavaScript, mais il est universel.

Reprenons notre recette, cette fois en JSON :

JSON

{
  "nom": "Gâteau au chocolat super gourmand",
  "temps_preparation": {
    "valeur": 15,
    "unite": "minutes"
  },
  "temps_cuisson": {
    "valeur": 30,
    "unite": "minutes"
  },
  "ingredients": [
    {
      "nom": "Chocolat noir",
      "quantite": {
        "valeur": 200,
        "unite": "grammes"
      }
    },
    {
      "nom": "Farine",
      "quantite": {
        "valeur": 100,
        "unite": "grammes"
      }
    },
    {
      "nom": "Oeufs",
      "quantite": {
        "valeur": 4,
        "unite": "unités"
      }
    }
  ],
  "etapes": [
    "Faire fondre le chocolat.",
    "Mélanger les œufs et la farine.",
    "Incorporer le chocolat fondu."
  ]
}

Ce qu’il faut retenir du JSON :

  • Deux structures de base :
    • Objets : des paires « clé-valeur » entourées d’accolades {}. Les clés sont des chaînes de caractères (entre guillemets) et les valeurs peuvent être des nombres, des chaînes de caractères, des booléens, null, d’autres objets ou des tableaux. C’est l’équivalent des nœuds avec des attributs en XML.
    • Tableaux (ou listes) : des collections de valeurs ordonnées, entourées de crochets []. C’est l’équivalent d’une liste d’éléments similaires (comme nos ingrédients ou nos étapes).
  • Moins verbeux que le XML : Moins de caractères à écrire, ce qui est pratique pour la vitesse de transmission des données.
  • Très populaire : C’est le format le plus courant pour les API web (Application Programming Interface), c’est-à-dire quand des applications communiquent entre elles sur Internet.
  • Facile à analyser (parser) : Les langages de programmation (comme Python !) ont des outils intégrés qui rendent très facile la lecture et l’écriture de JSON.

Le JSON, avec sa simplicité et sa légèreté, est devenu le roi de l’échange de données sur le web.


🆚 XML vs JSON : qui gagne le match ?

Ce n’est pas vraiment une compétition, ils ont tous les deux leurs points forts !

CaractéristiqueXMLJSON
PhilosophieLangage de balisage extensibleNotation d’objets pour JavaScript (mais universel)
SyntaxeBalises <element> et </element>{} pour objets, [] pour tableaux
LisibilitéTrès lisible, mais verbeuxTrès lisible, plus concis
Taille des fichiersGénéralement plus grandsGénéralement plus petits
Utilisation typiqueConfiguration, documents complexes, SOAPAPI web, applications mobiles, NoSQL
Modèle de donnéesArbre hiérarchiquePaires clé-valeur et tableaux

Exporter vers Sheets

En gros, les deux formats permettent de représenter des données structurées sous forme hiérarchique, exactement comme un arbre ! Le choix entre l’un ou l’autre dépend souvent du contexte et des exigences du projet. Pour la NSI, il est important de connaître les deux car ils illustrent parfaitement l’importance de l’arborescence dans l’organisation des données.

Et voilà, vous êtes maintenant incollables sur les formats XML et JSON, et vous comprenez mieux leur lien avec les arbres

Les défis des arbres binaires et des données structurées !


Exercice 1 : Les Arbres Bâtisseurs 🌳

Le défi : Dessiner toutes les formes possibles d’arbres binaires pour un nombre donné de nœuds. C’est comme être un architecte d’arbres !

Prérequis :

  • Comprendre la définition d’un arbre binaire (chaque nœud a au maximum deux fils).
  • Savoir ce qu’est un nœud.

Consignes : Dessine tous les arbres binaires différents qui ont :

  1. 3 nœuds
  2. 4 nœuds

Indice : La forme compte ! Deux arbres sont différents si leur structure n’est pas la même, même si les nœuds n’ont pas de noms spécifiques.

Solution :

  1. Pour 3 nœuds (il y en a 5) :
    • Le « peigne » à gauche : Nœud central, un fils gauche, et ce fils gauche a un autre fils gauche.
    • Le « peigne » à droite : Nœud central, un fils droit, et ce fils droit a un autre fils droit.
    • La « branche » gauche et droite : Nœud central, un fils gauche, et ce fils gauche a un fils droit.
    • La « branche » droite et gauche : Nœud central, un fils droit, et ce fils droit a un fils gauche.
    • L’arbre « complet » (ou presque) : Nœud central, un fils gauche, un fils droit.
  2. Pour 4 nœuds (il y en a 14) : C’est un peu plus complexe ! Les 5 formes de 3 nœuds peuvent être étendues de diverses manières. Pensez aux variations en ajoutant le quatrième nœud comme fils gauche ou droit d’une feuille existante, ou en transformant une feuille en nœud interne.

Exercice 2 : La Pyramide de Catalan 🔢

Le défi : Sans les dessiner tous, deviner combien d’arbres binaires différents on peut construire avec 5 nœuds. C’est un peu comme une énigme mathématique !

Prérequis :

  • Aucun prérequis spécifique en dehors de la logique. C’est un exercice de reconnaissance de suite !

Informations données :

  • 0 nœud : 1 arbre (l’arbre vide)
  • 1 nœud : 1 arbre
  • 2 nœuds : 2 arbres
  • 3 nœuds : 5 arbres
  • 4 nœuds : 14 arbres

Consignes : Calcule le nombre d’arbres binaires différents contenant 5 nœuds.

Solution : Cette suite est célèbre en mathématiques ! Il s’agit des nombres de Catalan. Pour trouver le nombre d’arbres binaires distincts avec n nœuds, on utilise la formule C_n=frac1n+1binom2nn.

  • Pour n=0, C_0=frac11binom00=1times1=1
  • Pour n=1, C_1=frac12binom21=frac12times2=1
  • Pour n=2, C_2=frac13binom42=frac13times6=2
  • Pour n=3, C_3=frac14binom63=frac14times20=5
  • Pour n=4, C_4=frac15binom84=frac15times70=14

Pour n=5, on calcule : C_5=frac15+1binom2times55=frac16binom105

binom105=frac105(10−5)=frac1055=frac10times9times8times7times65times4times3times2times1=frac30240120=252

C_5=frac16times252=42

Il y a donc 42 arbres binaires différents contenant 5 nœuds !


Exercice 3 : L’Imprimeur d’Arbres Fous 🤯

Le défi : Écrire un algorithme qui affiche un arbre binaire d’une manière très spécifique, en utilisant des parenthèses. C’est comme traduire l’arbre en un langage mathématique !

Prérequis :

  • Comprendre le concept de récursivité.
  • Savoir ce qu’est un nœud, un sous-arbre gauche et un sous-arbre droit.
  • Connaître la notion d’arbre vide (NIL).

Consignes : Écris une fonction affiche(T) (où T est l’arbre) qui respecte ces règles :

  • Si l’arbre est vide, n’affiche rien.
  • Si c’est un nœud, affiche : ( suivi de son sous-arbre gauche (appel récursif), puis sa valeur, puis son sous-arbre droit (appel récursif), et enfin ).

Exemple d’affichage attendu : Pour un arbre où ‘A’ est la racine, ‘B’ est son fils gauche, ‘C’ est le fils droit de ‘B’, et ‘D’ est le fils droit de ‘A’, on doit obtenir : ((B(C))A(D)).

Solution :

Ceci est un parcours infixe un peu spécial !

VARIABLE
T : arbre
x : noeud

DEBUT
AFFICHE(T) :
  si T ≠ NIL :
    x ← T.racine
    affiche "("
    AFFICHE(x.gauche) // Appel récursif pour le sous-arbre gauche
    affiche x.clé     // Affiche la valeur du noeud actuel
    AFFICHE(x.droit)  // Appel récursif pour le sous-arbre droit
    affiche ")"
  fin si
FIN

Testons avec l’exemple ((B(C))A(D)) :

  • AFFICHE(A) :
    • Affiche (
    • Appel AFFICHE(B) :
      • Affiche (
      • Appel AFFICHE(NIL) : (rien affiché)
      • Affiche B
      • Appel AFFICHE(C) :
        • Affiche (
        • Appel AFFICHE(NIL) : (rien affiché)
        • Affiche C
        • Appel AFFICHE(NIL) : (rien affiché)
        • Affiche )
      • Affiche )
    • Affiche A
    • Appel AFFICHE(D) :
      • Affiche (
      • Appel AFFICHE(NIL) : (rien affiché)
      • Affiche D
      • Appel AFFICHE(NIL) : (rien affiché)
      • Affiche )
    • Affiche )

Ce qui donne bien ((B(C))A(D)). Mission accomplie !


Exercice 4 : L’Archéologue de l’Arbre 🕵️‍♀️

Le défi : À partir de la « recette » parenthésée de l’exercice précédent, retrouver la forme de l’arbre. C’est l’inverse du défi précédent !

Prérequis :

  • Comprendre comment l’algorithme affiche fonctionne (Exercice 3).
  • Comprendre la structure d’un arbre binaire (nœuds, sous-arbres gauche/droit).

Consignes :

  1. Dessine l’arbre binaire qui produit la sortie (1((2)3)).
  2. De manière générale, explique comment « reconstruire » un arbre à partir de cette notation parenthésée.

Solution :

  1. Dessin de l’arbre (1((2)3)) :
    • On cherche la valeur centrale du niveau le plus haut : c’est le 1. Le 1 est la racine.
    • Ce qui est à gauche du 1 (entre la première parenthèse et 1) est le sous-arbre gauche. Ici, c’est ((2)3).
    • Ce qui est à droite du 1 (entre 1 et la dernière parenthèse) est le sous-arbre droit. Ici, c’est vide (il n’y a rien).
    • Maintenant, on se concentre sur le sous-arbre gauche ((2)3) :
      • La valeur centrale est 3. C’est donc la racine du sous-arbre gauche du 1.
      • Ce qui est à gauche du 3 (entre la parenthèse et 3) est le sous-arbre gauche de 3. C’est (2).
      • Ce qui est à droite du 3 (entre 3 et la parenthèse) est le sous-arbre droit de 3. C’est vide.
    • Enfin, on regarde (2) :
      • La valeur est 2. C’est la racine du sous-arbre gauche de 3.
      • Ses sous-arbres gauche et droit sont vides.
    Donc l’arbre est :
    • Racine : 1
    • Fils gauche de 1 : 3
    • Fils droit de 1 : vide
    • Fils gauche de 3 : 2
    • Fils droit de 3 : vide
    • Fils gauche de 2 : vide
    • Fils droit de 2 : vide
  2. Comment retrouver l’arbre général ? Cette notation est le résultat d’un parcours infixe récursif. La clé est de trouver la racine du (sous-)arbre actuel.
    • Chaque ensemble de parenthèses (...) représente un (sous-)arbre.
    • À l’intérieur de ces parenthèses, l’élément qui n’est PAS entre d’autres parenthèses (ou qui n’est pas un appel récursif) est la racine de ce (sous-)arbre.
    • Tout ce qui précède cette racine est son sous-arbre gauche.
    • Tout ce qui suit cette racine est son sous-arbre droit.
    • On applique ce principe récursivement pour les sous-arbres gauche et droit jusqu’à ce qu’on arrive à des arbres vides (qui ne sont pas affichés). C’est comme déconstruire une expression mathématique, en identifiant l’opérateur principal en premier.

Exercice 5 : Les Arbres Jumelés 👯‍♀️

Le défi : Apprendre à Python comment comparer deux arbres binaires pour voir s’ils sont identiques. C’est comme enseigner à l’ordinateur à reconnaître des jumeaux !

Prérequis :

  • Avoir une compréhension de la programmation orientée objet (POO) en Python (classes, objets, méthodes).
  • Savoir ce qu’est la méthode spéciale __eq__ en Python (pour la comparaison ==).
  • Comprendre la récursivité.
  • Savoir ce qu’est un arbre vide (représenté ici par None ou NIL).

Consignes : Ajoute une méthode __eq__ à une classe Noeud (ou ArbreBinaire) qui permet de tester l’égalité entre deux arbres binaires avec l’opérateur ==. Attention, il y a un piège !

Le piège : Deux arbres sont égaux si et seulement si :

  1. Ils sont tous les deux vides.
  2. Ou ils ont la même valeur à leur racine, et leurs sous-arbres gauches sont égaux, ET leurs sous-arbres droits sont égaux. La récursivité est votre amie ici !

Solution (en Python) :

On imagine une classe Noeud simple :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    # Méthode __eq__ à ajouter
    def __eq__(self, autre_arbre):
        # Cas 1 : Si les deux sont None (arbres vides), ils sont égaux
        if self is None and autre_arbre is None:
            return True
        # Cas 2 : Si l'un est None et l'autre non, ils sont différents
        if self is None or autre_arbre is None:
            return False

        # Cas 3 : Les deux ne sont pas None. On compare les clés et les sous-arbres.
        # Attention : la comparaison récursive des sous-arbres !
        # 'self.gauche == autre_arbre.gauche' va appeler récursivement __eq__
        # 'self.droit == autre_arbre.droit' va appeler récursivement __eq__
        return (self.cle == autre_arbre.cle and
                self.gauche == autre_arbre.gauche and
                self.droit == autre_arbre.droit)

    # Une méthode pour la représentation pour rendre l'affichage plus clair
    def __repr__(self):
        return f"Noeud({self.cle})"

Explication du piège et de la solution : Le piège classique est de ne pas gérer correctement les cas où un ou les deux arbres sont vides (None). La solution élégante utilise la récursivité et gère les cas None au début. Lorsque self.gauche == autre_arbre.gauche est évalué, Python appelle automatiquement la méthode __eq__ sur ces sous-arbres, même s’ils sont None (ce qui est géré par les premières conditions).

Exemples :

Python

# Créons quelques arbres
arbre1 = Noeud(10, Noeud(5), Noeud(15))
arbre2 = Noeud(10, Noeud(5), Noeud(15)) # Identique à arbre1
arbre3 = Noeud(10, Noeud(5), Noeud(20)) # Différent (clé droite)
arbre4 = Noeud(10, Noeud(5)) # Différent (pas de fils droit)

print(arbre1 == arbre2) # True
print(arbre1 == arbre3) # False
print(arbre1 == arbre4) # False

# Arbres vides
arbre_vide1 = None
arbre_vide2 = None
print(arbre_vide1 == arbre_vide2) # True (car les deux sont None)

# Arbre vide vs non vide
print(arbre1 == arbre_vide1) # False


Exercice 6 : L’Arbre Parfait du Constructeur 📐

Le défi : Construire un arbre binaire « parfait » d’une certaine hauteur. Un arbre parfait est un arbre binaire où tous les nœuds (sauf les feuilles) ont exactement deux fils, et toutes les feuilles sont à la même profondeur. C’est le « Carré de l’Arbre » !

Prérequis :

  • Comprendre la définition d’un arbre binaire parfait.
  • Maîtriser la récursivité.
  • Savoir comment créer un nœud d’arbre (comme avec la classe Noeud de l’exercice 5).

Consignes : Écris une fonction parfait(h) qui prend un entier h (hauteur, ge0) et renvoie un arbre binaire parfait de cette hauteur.

  • Hauteur 0 : un arbre vide (None).
  • Hauteur 1 : un seul nœud (la racine).
  • Hauteur 2 : une racine, deux fils (gauche et droit).
  • Etc.

Solution (en Python) :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    def __repr__(self):
        # Pour visualiser l'arbre facilement (peut être amélioré)
        return f"({self.gauche.__repr__() if self.gauche else 'NIL'}) {self.cle} ({self.droit.__repr__() if self.droit else 'NIL'})"

def parfait(h):
    if h == 0:
        return None # Un arbre de hauteur 0 est un arbre vide
    else:
        # La clé peut être n'importe quoi, on peut utiliser h pour l'exemple
        # On construit un noeud, dont les fils sont des arbres parfaits de hauteur h-1
        return Noeud(f'Niveau{h}', parfait(h-1), parfait(h-1))

# Exemples :
# print(parfait(0)) # None
# print(parfait(1)) # (NIL) Niveau1 (NIL)
# print(parfait(2)) # ((NIL) Niveau1 (NIL)) Niveau2 ((NIL) Niveau1 (NIL))
# print(parfait(3)) # (((NIL) Niveau1 (NIL)) Niveau2 ((NIL) Niveau1 (NIL))) Niveau3 (((NIL) Niveau1 (NIL)) Niveau2 ((NIL) Niveau1 (NIL)))

Explication : L’idée clé est que pour construire un arbre parfait de hauteur h, on crée une racine, et ses deux fils doivent être des arbres parfaits de hauteur h-1. C’est la définition même de la récursivité appliquée à la structure. Le cas de base est h=0, où l’arbre est vide.


Exercice 7 : Le Peigne Gauche du Coiffeur 💇‍♂️

Le défi : Construire un arbre binaire « peigne gauche » d’une certaine hauteur. C’est l’opposé de l’arbre complet ! Chaque nœud n’a qu’un fils gauche, et son fils droit est toujours vide.

Prérequis :

  • Comprendre la définition d’un arbre filiforme (ici, spécifiquement un « peigne gauche »).
  • Maîtriser la récursivité.
  • Savoir comment créer un nœud d’arbre (classe Noeud).

Consignes : Écris une fonction peigne_gauche(h) qui prend un entier h (hauteur, ge0) et renvoie un peigne gauche de cette hauteur.

Solution (en Python) :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    def __repr__(self):
        return f"({self.gauche.__repr__() if self.gauche else 'NIL'}) {self.cle} ({self.droit.__repr__() if self.droit else 'NIL'})"

def peigne_gauche(h):
    if h == 0:
        return None # Un peigne de hauteur 0 est un arbre vide
    else:
        # La racine a un fils gauche qui est un peigne gauche de hauteur h-1
        # et un fils droit vide (None)
        return Noeud(f'Niveau{h}', peigne_gauche(h-1), None)

# Exemples :
# print(peigne_gauche(0)) # None
# print(peigne_gauche(1)) # (NIL) Niveau1 (NIL)
# print(peigne_gauche(2)) # ((NIL) Niveau1 (NIL)) Niveau2 (NIL)
# print(peigne_gauche(3)) # (((NIL) Niveau1 (NIL)) Niveau2 (NIL)) Niveau3 (NIL)

Explication : Le principe est similaire à parfait(h). Pour un peigne gauche de hauteur h, la racine a un fils gauche qui est un peigne gauche de hauteur h-1, et son fils droit est toujours None. Le cas de base est h=0 pour l’arbre vide.


Exercice 8 : L’Inspecteur de Peignes 🔎

Le défi : Vérifier si un arbre donné est bien un « peigne gauche ». C’est l’inverse du défi précédent, un vrai travail d’inspecteur !

Prérequis :

  • Comprendre la définition d’un peigne gauche.
  • Maîtriser la récursivité.
  • Savoir manipuler les arbres (classe Noeud, vérification de None pour les fils).

Consignes : Écris une fonction est_peigne_gauche(T) qui renvoie True si l’arbre T est un peigne gauche, et False sinon.

Solution (en Python) :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

def est_peigne_gauche(T):
    if T is None:
        return True # Un arbre vide est considéré comme un peigne gauche (hauteur 0)

    # Si le fils droit n'est PAS vide, ce n'est pas un peigne gauche
    if T.droit is not None:
        return False

    # Si le fils droit EST vide, on vérifie récursivement le sous-arbre gauche
    # Si le fils gauche est aussi None, c'est une feuille qui n'a pas de fils droit,
    # donc c'est OK pour un peigne.
    return est_peigne_gauche(T.gauche)

# Exemples :
# peigne_ok = peigne_gauche(3)
# print(est_peigne_gauche(peigne_ok)) # True

# arbre_non_peigne = Noeud('A', Noeud('B'), Noeud('C')) # A a un fils droit
# print(est_peigne_gauche(arbre_non_peigne)) # False

# arbre_non_peigne2 = Noeud('A', Noeud('B', droit=Noeud('C'))) # B a un fils droit
# print(est_peigne_gauche(arbre_non_peigne2)) # False

# print(est_peigne_gauche(None)) # True

Explication : La fonction est récursive.

  • Cas de base : Si l’arbre est vide (T is None), c’est un peigne gauche (le plus petit des peignes).
  • Condition de non-peigne : Si le nœud actuel a un fils droit (T.droit is not None), ce n’est PAS un peigne gauche, donc on renvoie False immédiatement.
  • Appel récursif : Si le fils droit est bien vide, on doit juste vérifier que le sous-arbre gauche est lui-même un peigne gauche. On appelle donc est_peigne_gauche sur T.gauche.

Exercice 9 : Le Tour de Passe-Passe Infixe 🧙‍♂️

Le défi : Construire différents arbres de même taille et avec les mêmes valeurs, mais qui donnent le même résultat lors d’un parcours infixe. C’est un peu de la magie des arbres !

Prérequis :

  • Comprendre le parcours infixe (PARCOURS-INFIXE de la leçon).
  • Savoir dessiner un arbre binaire.

Consignes : Donne cinq arbres binaires différents de taille 3, contenant les valeurs 1, 2, et 3, et pour lesquels un parcours infixe affiche toujours :

1
2
3

dans cet ordre.

Solution :

Un parcours infixe affiche : sous-arbre gauche, racine, sous-arbre droit. Pour que l’affichage soit 1, 2, 3, cela signifie que le 1 est soit la racine du sous-arbre gauche le plus à gauche, soit une feuille qui est le premier élément parcouru. Le 2 sera la racine d’un sous-arbre central, et le 3 sera le dernier élément.

Voici 5 arbres différents :

  1. Arbre filiforme à droite (avec 1,2,3) :
    • Racine : 1
    • Fils droit : 2
    • Fils droit de 2 : 3
    Parcours infixe : (NIL) 1 ( (NIL) 2 ( (NIL) 3 (NIL) ) ) => 1, 2, 3
  2. Arbre « en V inversé » (2 en haut) :
    • Racine : 2
    • Fils gauche : 1
    • Fils droit : 3
    Parcours infixe : ( (NIL) 1 (NIL) ) 2 ( (NIL) 3 (NIL) ) => 1, 2, 3
  3. Arbre « virgule inversée » (2 en haut, 3 fils droit de 1) :
    • Racine : 2
    • Fils gauche : 1
    • Fils droit de 1 : 3
    Parcours infixe : ( (NIL) 1 ( (NIL) 3 (NIL) ) ) 2 (NIL) => 1, 3, 2. Oops ! Ça ne marche pas pour cet ordre. Mon erreur, celui-ci donne 1, 3, 2.Correction pour 5 arbres valides (plus difficile que ça en a l’air !) : Un parcours infixe trie toujours les éléments s’il s’agit d’un Arbre Binaire de Recherche (ABR). Pour un arbre binaire quelconque, ce n’est pas garanti.Si les valeurs sont 1, 2, 3 et que l’ordre infixe est 1, 2, 3, cela signifie que 1 est la plus petite valeur du sous-arbre gauche, 2 est la racine ou une valeur intermédiaire, et 3 est la plus grande valeur du sous-arbre droit.Reprenons les 5 arbres binaires possibles avec 3 nœuds (sans valeur spécifique pour le moment) :
    1. (X (Y (Z))) : Peigne gauche (Racine, fg, fg-fg)
    2. (X (Y) (Z)) : Complet (Racine, fg, fd)
    3. ( (X) Y (Z)) : Asymétrique gauche (Racine, fd, fg-fd)
    4. ( (X (Y)) Z) : Asymétrique droite (Racine, fg, fd-fg)
    5. ( (X) Y) : Peigne droit (Racine, fd, fd-fd)
    Maintenant, on affecte les valeurs 1, 2, 3 pour que l’ordre infixe soit 1, 2, 3 :
    • Arbre 1 (Peigne gauche) :
      • Nœud 3 est la racine.
      • Nœud 2 est le fils gauche de 3.
      • Nœud 1 est le fils gauche de 2.
      Parcours Infixe : 1, 2, 3
    • Arbre 2 (Complet) :
      • Nœud 2 est la racine.
      • Nœud 1 est le fils gauche de 2.
      • Nœud 3 est le fils droit de 2.
      Parcours Infixe : 1, 2, 3
    • Arbre 3 (Asymétrique gauche) :
      • Nœud 3 est la racine.
      • Nœud 1 est le fils gauche de 3.
      • Nœud 2 est le fils droit de 1.
      Parcours Infixe : 1, 2, 3
    • Arbre 4 (Asymétrique droite) :
      • Nœud 1 est la racine.
      • Nœud 3 est le fils droit de 1.
      • Nœud 2 est le fils gauche de 3.
      Parcours Infixe : 1, 2, 3
    • Arbre 5 (Peigne droit) :
      • Nœud 1 est la racine.
      • Nœud 2 est le fils droit de 1.
      • Nœud 3 est le fils droit de 2.
      Parcours Infixe : 1, 2, 3
    Ces cinq arbres différents, même s’ils ont des structures distinctes, produiront le même ordre 1, 2, 3 lors d’un parcours infixe. C’est fascinant, non ?

Exercice 10 : La Salle des Mystères 🧐

Le défi : Comprendre pourquoi une bibliothèque (ici, une base de données de salles) contient un nombre si précis de salles. C’est une énigme de combinaison !

Prérequis :

  • Comprendre le principe des combinaisons ou des permutations simples.
  • Connaître les bases de l’alphabet.

Contexte : Une bibliothèque contient 17576 salles. Chaque salle est identifiée par un code unique.

Consignes : Pourquoi la bibliothèque donnée en exemple au début de ce chapitre contient-elle 17576 salles ?

Solution :

Ce nombre nous fait penser à quelque chose qui utilise l’alphabet !

  • L’alphabet latin contient 26 lettres.
  • Si chaque salle est identifiée par un code, et que ce code est composé de lettres (par exemple, « AAA », « AAB », « ABA », etc.), on peut facilement obtenir ce nombre.

26times26times26=263=17576

Cela signifie que chaque salle est probablement identifiée par un code de 3 lettres majuscules (ou minuscules, selon la convention) de l’alphabet latin. C’est une combinaison simple avec répétition.

  • 1ère lettre : 26 choix (A-Z)
  • 2ème lettre : 26 choix (A-Z)
  • 3ème lettre : 26 choix (A-Z)

Total = 26times26times26=17576.

C’est une astuce courante pour générer des identifiants uniques dans de nombreux systèmes !


Exercice 11 : Les Architectes d’ABR 🏗️

Le défi : Dessiner tous les Arbres Binaires de Recherche (ABR) possibles avec seulement trois nœuds, et contenant les valeurs 1, 2, et 3.

Prérequis :

  • Comprendre la définition d’un Arbre Binaire de Recherche (ABR) (les éléments plus petits à gauche, les plus grands à droite).
  • Savoir dessiner un arbre binaire.

Consignes : Dessine tous les ABR possibles formés de trois nœuds et contenant les entiers 1, 2 et 3.

Solution :

Pour un ABR, la position des éléments dépend de leur valeur. Il n’y a pas autant de formes différentes que pour les arbres binaires « normaux » avec un même nombre de nœuds.

Les 5 formes d’arbres binaires avec 3 nœuds (de l’Exercice 1) peuvent être utilisées, mais avec les contraintes des ABR, seules certaines configurations sont valides pour les valeurs 1, 2, 3.

Voici les 5 ABR uniques avec les valeurs 1, 2, 3 :

  1. Racine = 1 (Peigne droit) :
    • Racine : 1
    • Fils droit : 2
    • Fils droit de 2 : 3
  2. Racine = 2 (Arbre complet) :
    • Racine : 2
    • Fils gauche : 1
    • Fils droit : 3
  3. Racine = 3 (Peigne gauche) :
    • Racine : 3
    • Fils gauche : 2
    • Fils gauche de 2 : 1
  4. Racine = 1 (1 est la racine, 3 est son fils droit, 2 est le fils gauche de 3) :
    • Racine : 1
    • Fils droit : 3
    • Fils gauche de 3 : 2
  5. Racine = 3 (3 est la racine, 1 est son fils gauche, 2 est le fils droit de 1) :
    • Racine : 3
    • Fils gauche : 1
    • Fils droit de 1 : 2

Ce sont les 5 formes possibles. Remarquez que l’ordre des valeurs dans un ABR est la clé de sa structure.


Exercice 12 : Le Chasseur de Minimum 🎯

Le défi : Trouver le plus petit élément dans un Arbre Binaire de Recherche (ABR). C’est comme chercher la petite graine au fond du jardin !

Prérequis :

  • Comprendre la définition d’un ABR (les éléments plus petits sont à gauche).
  • Maîtriser la récursivité ou les boucles (itératif).
  • Savoir gérer les arbres vides (None).

Consignes :

  1. Dans un ABR, où se trouve le plus petit élément ?
  2. Écris une fonction minimum(a) qui renvoie le plus petit élément de l’ABR a.
  3. Si l’arbre a est vide, la fonction renvoie None.

Solution (en Python) :

  1. Où se trouve le plus petit élément ? Dans un ABR, le plus petit élément se trouve toujours tout à gauche ! On part de la racine et on descend toujours par le sous-arbre gauche jusqu’à ce qu’on ne puisse plus aller à gauche (c’est-à-dire jusqu’à atteindre une feuille ou un nœud dont le fils gauche est None).
  2. Fonction minimum(a) (version récursive) :Pythonclass Noeud: def __init__(self, cle, gauche=None, droit=None): self.cle = cle self.gauche = gauche self.droit = droit def minimum(a): if a is None: return None # Si l'arbre est vide, pas de minimum if a.gauche is None: return a.cle # Si pas de fils gauche, le noeud actuel est le plus petit else: return minimum(a.gauche) # Sinon, on cherche dans le sous-arbre gauche
  3. Fonction minimum(a) (version itérative – avec boucle tant que) :Pythondef minimum_iteratif(a): if a is None: return None courant = a while courant.gauche is not None: courant = courant.gauche return courant.cle

Les deux versions fonctionnent parfaitement. La version itérative est parfois préférée pour éviter la profondeur de récursion sur des arbres très déséquilibrés.


Exercice 13 : L’Ajouteur Intelligent d’ABR 🧠

Le défi : Modifier l’algorithme d’insertion dans un ABR pour qu’il n’ajoute pas un élément s’il est déjà présent. C’est comme un filtre anti-doublons !

Prérequis :

  • Comprendre l’algorithme d’insertion dans un ABR (vu en cours).
  • Savoir gérer les cas où la clé est déjà trouvée.
  • Connaître la structure de la classe Noeud.

Consignes : Écris une variante du programme d’insertion d’une clé dans un ABR qui n’ajoute pas l’élément x à l’arbre a s’il est déjà dedans.

Solution (en Python) :

On se base sur l’algorithme d’insertion vu en cours :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    def __repr__(self):
        return f"Noeud({self.cle})"

def ajoute_sans_doublon(arbre, nouvelle_cle):
    # Cas 1 : L'arbre est vide, la nouvelle_cle devient la racine
    if arbre is None:
        return Noeud(nouvelle_cle)

    courant = arbre # Pointeur pour parcourir l'arbre
    parent = None   # Pointeur pour retenir le parent du noeud où insérer

    while courant is not None:
        if nouvelle_cle == courant.cle:
            # La clé existe déjà, on ne fait rien et on renvoie l'arbre inchangé
            return arbre
        parent = courant
        if nouvelle_cle < courant.cle:
            courant = courant.gauche
        else: # nouvelle_cle > courant.cle
            courant = courant.droit

    # On a trouvé l'emplacement d'insertion (courant est None, parent est le bon noeud)
    if nouvelle_cle < parent.cle:
        parent.gauche = Noeud(nouvelle_cle)
    else:
        parent.droit = Noeud(nouvelle_cle)

    return arbre # On renvoie la racine de l'arbre (qui n'a pas changé)

# Exemple d'utilisation :
# arbre_test = Noeud(10, Noeud(5), Noeud(15))
# print(arbre_test)
# ajoute_sans_doublon(arbre_test, 7) # Ajoute 7
# print(arbre_test)
# ajoute_sans_doublon(arbre_test, 5) # N'ajoute pas 5 (déjà présent)
# print(arbre_test)

Explication : L’algorithme de parcours est le même que pour la recherche. La différence est que si à un moment donné nouvelle_cle == courant.cle, on sait que l’élément est déjà là, et on s’arrête sans rien ajouter. Si on arrive à courant is None sans avoir trouvé la clé, alors on insère le nouveau nœud comme d’habitude.


Exercice 14 : Le Compteur Flexible d’ABR ⚖️

Le défi : Compter les occurrences d’une valeur dans un ABR, même si l’ABR n’a pas été construit « proprement » (c’est-à-dire que des doublons peuvent être à gauche ou à droite de la racine). On veut être malin et ne pas chercher là où on est sûr de ne rien trouver !

Prérequis :

  • Comprendre la définition d’un ABR.
  • Maîtriser la récursivité.
  • Savoir parcourir un arbre et prendre des décisions basées sur les valeurs.

Contexte : Dans un ABR « normal », si une clé est égale à la racine, elle ne devrait pas apparaître dans les sous-arbres gauche ou droit (selon la convention stricte y.clé < x.clé et x.clé < y.clé). Mais ici, on suppose que les doublons peuvent être présents à gauche OU à droite (y.clé <= x.clé et x.clé <= y.clé). L’objectif est de ne pas chercher inutilement.

Consignes : Écris une fonction compte(x, a) qui renvoie le nombre d’occurrences de x dans l’ABR a. On ne suppose pas que l’arbre a été construit à partir de la fonction ajoute (où les doublons seraient gérés de manière unique), mais qu’il s’agit d’un ABR (donc l’ordre est respecté). Cependant, une valeur égale à la racine peut se trouver encore dans le sous-arbre gauche OU dans le sous-arbre droit. On s’attachera à ne pas descendre dans les sous-arbres dans lesquels on est certain que la valeur x ne peut apparaître.

Solution (en Python) :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    def __repr__(self):
        return f"Noeud({self.cle})"

def compte(x, a):
    if a is None:
        return 0 # Si l'arbre est vide, 0 occurrences

    nb_occurrences = 0

    if x == a.cle:
        nb_occurrences = 1 # On a trouvé une occurrence à la racine

    # Cas 1 : La clé recherchée est plus petite que la clé du noeud actuel
    # On peut chercher dans le sous-arbre gauche. On ne cherche PAS à droite.
    if x < a.cle:
        nb_occurrences += compte(x, a.gauche)
    # Cas 2 : La clé recherchée est plus grande que la clé du noeud actuel
    # On peut chercher dans le sous-arbre droit. On ne cherche PAS à gauche.
    elif x > a.cle:
        nb_occurrences += compte(x, a.droit)
    # Cas 3 : La clé recherchée est ÉGALE à la clé du noeud actuel
    # C'est le cas "flexible" : la clé peut être présente dans les deux sous-arbres.
    # On doit donc chercher dans les DEUX sous-arbres pour les doublons.
    else: # x == a.cle
        nb_occurrences += compte(x, a.gauche)
        nb_occurrences += compte(x, a.droit)

    return nb_occurrences

# Exemple d'utilisation :
# Construisons un ABR "non strict"
#        10
#       /  \
#      5   15
#     / \   / \
#    5   7 15 20
arbre_flexible = Noeud(10,
                      Noeud(5, Noeud(5), Noeud(7)),
                      Noeud(15, Noeud(15), Noeud(20)))

print(f"Occurrences de 5 : {compte(5, arbre_flexible)}")   # Devrait être 2
print(f"Occurrences de 10 : {compte(10, arbre_flexible)}") # Devrait être 1
print(f"Occurrences de 15 : {compte(15, arbre_flexible)}") # Devrait être 2
print(f"Occurrences de 20 : {compte(20, arbre_flexible)}") # Devrait être 1
print(f"Occurrences de 8 : {compte(8, arbre_flexible)}")   # Devrait être 0

Explication : La logique de recherche est similaire à celle d’un ABR « normal », mais avec une condition supplémentaire pour les doublons :

  • Si x < a.cle, on va seulement à gauche.
  • Si x > a.cle, on va seulement à droite.
  • MAIS, si x == a.cle, on incrémente le compteur, puis on continue à chercher dans les deux sous-arbres (a.gauche et a.droit). C’est là la subtilité ! Cela permet de trouver toutes les occurrences de x, même si elles sont réparties de manière non standard (mais toujours « ABR-compatible ») dans l’arbre.

Exercice 15 : L’Ordonnateur d’Arbres 📋

Le défi : Extraire tous les éléments d’un ABR et les ranger dans un tableau trié. C’est comme vider une boîte triée dans un tiroir !

Prérequis :

  • Comprendre le parcours infixe d’un arbre binaire.
  • Comprendre les propriétés d’un ABR (le parcours infixe les retourne dans l’ordre croissant).
  • Savoir manipuler des tableaux (listes en Python).
  • Avoir une classe ABR ou Noeud (avec une racine).

Consignes :

  1. Écris une fonction remplir(a, t) qui ajoute tous les éléments de l’arbre a dans le tableau t (en utilisant t.append(x)) dans l’ordre infixe.
  2. Ajoute ensuite une méthode lister(self) à la classe ABR (ou Noeud pour simplifier) qui renvoie un nouveau tableau contenant tous les éléments de l’ABR self par ordre croissant.

Solution (en Python) :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    def __repr__(self):
        return f"Noeud({self.cle})"

    # Méthode lister à ajouter à la classe Noeud pour l'exercice
    def lister(self):
        resultat = []
        _remplir_infixe_recursif(self, resultat) # Appel à la fonction helper
        return resultat

# Partie 1 : Fonction remplir
def remplir(a, t):
    if a is not None:
        remplir(a.gauche, t)    # Parcourt le sous-arbre gauche
        t.append(a.cle)         # Ajoute la clé du noeud actuel
        remplir(a.droit, t)     # Parcourt le sous-arbre droit

# On peut aussi encapsuler remplir comme une fonction "privée" ou helper
# pour la méthode lister
def _remplir_infixe_recursif(noeud_courant, liste_resultat):
    if noeud_courant is not None:
        _remplir_infixe_recursif(noeud_courant.gauche, liste_resultat)
        liste_resultat.append(noeud_courant.cle)
        _remplir_infixe_recursif(noeud_courant.droit, liste_resultat)

# Exemple d'utilisation :
#        10
#       /  \
#      5   15
#     / \   /
#    3   7 12
arbre_exemple = Noeud(10,
                      Noeud(5, Noeud(3), Noeud(7)),
                      Noeud(15, Noeud(12)))

tableau_vide = []
remplir(arbre_exemple, tableau_vide)
print(f"Tableau rempli avec remplir : {tableau_vide}") # Devrait afficher [3, 5, 7, 10, 12, 15]

# Utilisation de la méthode lister (si vous avez intégré la méthode à la classe Noeud)
print(f"Tableau rempli avec lister : {arbre_exemple.lister()}") # Devrait afficher [3, 5, 7, 10, 12, 15]

Explication :

  • La fonction remplir (ou _remplir_infixe_recursif) est une implémentation directe du parcours infixe. L’ordre infixe sur un ABR garantit que les éléments sont visités dans l’ordre croissant.
  • La méthode lister de la classe Noeud (ou ABR si vous en avez une) initialise un tableau vide, appelle la fonction remplir (ou sa version _remplir_infixe_recursif) pour le remplir, puis renvoie ce tableau. C’est une manière élégante d’intégrer cette fonctionnalité à l’objet arbre.

Exercice 16 : Le Triage d’Arbres Magique ✨

Le défi : Utiliser un ABR pour trier un tableau d’entiers. C’est comme une baguette magique qui range vos nombres !

Prérequis :

  • Avoir la fonction d’insertion dans un ABR (Exercice 13 ou sa version simple).
  • Avoir la méthode lister (Exercice 15).
  • Comprendre le concept de complexité algorithmique (O(n), O(log_2(n)), etc.).

Consignes :

  1. En utilisant les fonctions des exercices précédents (insertion dans un ABR et lister), écris une fonction trier(t) qui reçoit en argument un tableau d’entiers et renvoie un tableau trié contenant les mêmes éléments.
  2. Quelle est l’efficacité de ce tri ?

Solution (en Python) :

Python

# On réutilise les classes et fonctions précédentes ou on les suppose définies
# (Noeud, ajoute_sans_doublon, _remplir_infixe_recursif)

# Assurez-vous que ajoute_sans_doublon est disponible ou utilisez une version simple d'ajout
def ajoute_simple(arbre, nouvelle_cle):
    if arbre is None:
        return Noeud(nouvelle_cle)
    courant = arbre
    parent = None
    while courant is not None:
        parent = courant
        if nouvelle_cle < courant.cle:
            courant = courant.gauche
        else:
            courant = courant.droit
    if nouvelle_cle < parent.cle:
        parent.gauche = Noeud(nouvelle_cle)
    else:
        parent.droit = Noeud(nouvelle_cle)
    return arbre

def trier(tableau_entiers):
    # Étape 1 : Créer un ABR et y insérer tous les éléments du tableau
    arbre_de_tri = None
    for element in tableau_entiers:
        # On utilise ajoute_simple pour éviter les doublons si nécessaire,
        # ou ajoute_sans_doublon pour l'exercice 13
        if arbre_de_tri is None: # Cas particulier pour la première insertion
            arbre_de_tri = Noeud(element)
        else:
            ajoute_simple(arbre_de_tri, element) # ou ajoute_sans_doublon

    # Étape 2 : Lister les éléments de l'ABR en ordre infixe pour obtenir le tableau trié
    # On utilise la fonction _remplir_infixe_recursif (ou la méthode lister)
    tableau_trie = []
    _remplir_infixe_recursif(arbre_de_tri, tableau_trie)
    return tableau_trie

# Exemple d'utilisation :
tableau_initial = [5, 3, 8, 1, 9, 2, 7, 4, 6]
tableau_trie = trier(tableau_initial)
print(f"Tableau initial : {tableau_initial}")
print(f"Tableau trié (méthode ABR) : {tableau_trie}") # Devrait afficher [1, 2, 3, 4, 5, 6, 7, 8, 9]

Efficacité de ce tri :

Ce tri est appelé le tri par arbre binaire. Son efficacité dépend de la forme de l’ABR construit :

  • Construction de l’arbre : Pour insérer n éléments, chaque insertion prend en moyenne O(log_2(n)) (si l’arbre reste équilibré) ou O(n) dans le pire des cas (si l’arbre devient filiforme). Donc, la phase de construction prend en moyenne O(nlog_2(n)) et dans le pire des cas O(n2).
  • Parcours infixe : Pour parcourir tous les n éléments de l’arbre, c’est O(n).

Complexité totale :

  • En moyenne : O(nlog_2(n)). C’est très efficace, comparable à des tris comme le Quicksort ou le Mergesort.
  • Dans le pire des cas (quand l’arbre devient filiforme, par exemple si les nombres sont déjà triés ou presque triés) : O(n2). C’est beaucoup moins efficace, comparable à des tris comme le tri à bulles.

Pour garantir une bonne performance dans tous les cas, il faudrait utiliser des ABR auto-équilibrés (comme les arbres AVL ou Rouge-Noir), mais c’est une autre histoire pour plus tard ! 😉


Exercice 17 : L’Affichage Indenté de l’Explorateur 🗂️

Le défi : Afficher un arbre d’une manière qui ressemble à l’explorateur de fichiers de votre ordinateur, avec une indentation qui montre la profondeur de chaque nœud.

Prérequis :

  • Comprendre le parcours préfixe (racine, gauche, droite).
  • Maîtriser la récursivité.
  • Savoir manipuler des chaînes de caractères (ajouter des espaces pour l’indentation).

Consignes : Écris une fonction affiche(a) qui affiche une arborescence selon un parcours préfixe, avec un nœud par ligne et une marge proportionnelle à la profondeur du nœud.

  • Indication : Écris une fonction récursive qui prend un paramètre supplémentaire : une chaîne de caractères (composée d’espaces) à afficher avant la valeur du nœud.

Exemple de sortie attendue :

A
	B
	  C
	  D
	E
	F
	  G
	  H

(Les « tabulations » sont en fait des espaces, 2 ou 4 par niveau par exemple.)

Solution (en Python) :

Python

class Noeud:
    def __init__(self, cle, gauche=None, droit=None):
        self.cle = cle
        self.gauche = gauche
        self.droit = droit

    def __repr__(self):
        return str(self.cle)

def affiche_arborescence(arbre, indentation=""):
    if arbre is not None:
        print(f"{indentation}{arbre.cle}") # Affiche le noeud actuel avec l'indentation
        # Appel récursif pour le sous-arbre gauche, augmente l'indentation
        affiche_arborescence(arbre.gauche, indentation + "  ") # Ajoutez 2 espaces par niveau
        # Appel récursif pour le sous-arbre droit, augmente l'indentation
        affiche_arborescence(arbre.droit, indentation + "  ")

# Exemple d'utilisation avec l'arbre du cours :
#       A
#      / \
#     B   F
#    / \ / \
#   C  D G  H
#  /    /
# E    I
#       \
#        J

arbre_test = Noeud('A',
                   Noeud('B',
                         Noeud('C', Noeud('E')),
                         Noeud('D')),
                   Noeud('F',
                         Noeud('G', Noeud('I', droit=Noeud('J'))),
                         Noeud('H')))

print("Affichage de l'arborescence :")
affiche_arborescence(arbre_test)

Explication : La fonction affiche_arborescence est récursive et suit l’ordre du parcours préfixe :

  1. Elle gère le cas de base (arbre vide) en ne faisant rien.
  2. Pour un nœud non vide, elle affiche sa cle en la faisant précéder de la indentation actuelle.
  3. Puis elle s’appelle récursivement pour le sous-arbre gauche, en augmentant l’indentation (indentation + " ").
  4. Enfin, elle s’appelle récursivement pour le sous-arbre droit, avec la même indentation augmentée.

Chaque niveau de profondeur ajoute des espaces, créant ainsi l’effet d’arborescence visuelle. C’est super pratique pour débugger ou visualiser la structure d’un arbre !


Exercice 18 : L’Explorateur de Fichiers Python 📂

Le défi : Écrire un programme Python qui explore les répertoires de votre ordinateur et les représente sous forme d’arbre binaire (enfin, une arborescence générale). C’est comme créer votre propre gestionnaire de fichiers !

Prérequis :

  • Comprendre le concept d’arborescence pour les systèmes de fichiers.
  • Maîtriser la récursivité.
  • Savoir utiliser les fonctions du module os de Python (os.listdir, os.path.join, os.path.isdir).
  • Avoir la fonction d’affichage indenté de l’exercice précédent (affiche_arborescence).

Contexte : Les répertoires de votre ordinateur sont déjà une structure d’arbre. On va la modéliser avec notre structure d’arbre personnalisée.

Consignes :

  1. Écris une fonction Python repertoires(nom_repertoire) qui prend un nom de répertoire et renvoie une arborescence (au sens de notre classe Noeud ou une classe Repertoire dédiée) décrivant la structure récursive de ses sous-répertoires.
  2. Utilise les fonctions os.listdir(), os.path.join(), et os.path.isdir().
  3. Teste le résultat en l’affichant avec la fonction affiche_arborescence de l’exercice précédent.

Solution (en Python) :

Pour cet exercice, on va adapter notre classe Noeud pour qu’elle puisse aussi représenter des répertoires. On pourrait aussi créer une classe Repertoire spécifique.

Python

import os

class NoeudRepertoire:
    def __init__(self, nom, enfants=None):
        self.nom = nom
        # Pour un répertoire, 'enfants' est une liste de NoeudRepertoire
        # (et non pas gauche/droit car un répertoire peut avoir plus de 2 sous-répertoires)
        self.enfants = enfants if enfants is not None else []

    def __repr__(self):
        return self.nom

def repertoires(chemin_initial):
    # On va construire un noeud pour le répertoire actuel
    racine_rep = NoeudRepertoire(os.path.basename(chemin_initial))

    try:
        # Liste tous les éléments (fichiers et dossiers) dans le répertoire
        elements = os.listdir(chemin_initial)
    except PermissionError:
        # Gérer les erreurs de permission pour ne pas planter le programme
        print(f"Permission refusée pour accéder à : {chemin_initial}")
        return racine_rep # Retourne juste le noeud sans ses enfants

    # On parcourt chaque élément trouvé
    for element in elements:
        chemin_complet_element = os.path.join(chemin_initial, element)

        # Si l'élément est un répertoire, on l'ajoute récursivement
        if os.path.isdir(chemin_complet_element):
            sous_repertoire_modele = repertoires(chemin_complet_element)
            racine_rep.enfants.append(sous_repertoire_modele)

    return racine_rep

# Fonction d'affichage adaptée pour la nouvelle structure NoeudRepertoire
def affiche_arborescence_repertoire(arbre_rep, indentation=""):
    if arbre_rep is not None:
        print(f"{indentation}{arbre_rep.nom}")
        for enfant in arbre_rep.enfants:
            affiche_arborescence_repertoire(enfant, indentation + "  ")

# --- Test du programme ---
# Choisissez un répertoire de test. Attention : plus il est grand, plus ça prendra de temps !
# Un petit répertoire de test est recommandé, par exemple un dossier que vous avez créé
# avec quelques sous-dossiers et fichiers.

repertoire_a_explorer = "./mon_dossier_test" # Remplacez par un chemin réel et pertinent !

# Créez un dossier de test simple si vous n'en avez pas
if not os.path.exists(repertoire_a_explorer):
    os.makedirs(os.path.join(repertoire_a_explorer, "sous_dossier1"))
    os.makedirs(os.path.join(repertoire_a_explorer, "sous_dossier2", "sous_sous_dossier"))
    with open(os.path.join(repertoire_a_explorer, "fichier1.txt"), "w") as f:
        f.write("test")
    with open(os.path.join(repertoire_a_explorer, "sous_dossier1", "fichier2.py"), "w") as f:
        f.write("print('hello')")
    print(f"Dossier de test '{repertoire_a_explorer}' créé.")

print(f"\nExploration de l'arborescence à partir de : {repertoire_a_explorer}")
arbre_systeme = repertoires(repertoire_a_explorer)
affiche_arborescence_repertoire(arbre_systeme)

# Nettoyage du dossier de test (optionnel)
# import shutil
# shutil.rmtree(repertoire_a_explorer)
# print(f"\nDossier de test '{repertoire_a_explorer}' supprimé.")

Explication :

  1. Classe NoeudRepertoire : On adapte la classe de nœud. Au lieu de gauche et droit, un répertoire peut avoir enfants (une liste), car un dossier peut contenir plus de deux sous-dossiers.
  2. Fonction repertoires :
    • Elle crée un NoeudRepertoire pour le chemin actuel.
    • Elle utilise os.listdir() pour obtenir la liste de tout ce qui se trouve dans ce répertoire.
    • Pour chaque élément, os.path.join() construit le chemin complet.
    • os.path.isdir() vérifie si l’élément est un répertoire.
    • Si c’est un répertoire, la fonction s’appelle elle-même récursivement (repertoires(chemin_complet_element)) pour construire le sous-arbre de ce sous-répertoire, et l’ajoute à la liste enfants du nœud actuel.
    • Si c’est un fichier, on l’ignore (car l’exercice ne demande que les répertoires).
    • Un bloc try-except PermissionError est ajouté pour gérer les accès refusés, courants dans les systèmes de fichiers.
  3. Fonction affiche_arborescence_repertoire : Elle est similaire à l’exercice précédent, mais elle itère sur la liste enfants plutôt que d’appeler gauche et droit.

C’est un exemple très concret de l’application des arbres pour modéliser des structures du monde réel !


Exercice 19 : Le Détective XML 🧐

Le défi : Vérifier si des petits extraits XML sont « bien formés », c’est-à-dire s’ils respectent les règles de base de la syntaxe XML.

Prérequis :

  • Comprendre les bases du format XML (balises ouvrantes, balises fermantes, attributs).
  • Savoir ce qu’est un document bien formé.

Consignes : Pour chacun des petits documents XML ci-dessous, dis s’il est bien formé. Si ce n’est pas le cas, indique l’erreur.

  1. <a></a>
  2. <a><b></b><b></b></a>
  3. <a><b></B></a>
  4. <a><b></a>
  5. <a></a><a></a>
  6. <a><b id="45" v=’45’></b></a>
  7. <a><b id="45" id="48"></b></a>
  8. <a><b id="45"></b><b id="45"></b></a>

Solution :

Un document XML est bien formé s’il respecte des règles syntaxiques de base :

  • Il doit y avoir un unique élément racine.
  • Chaque balise ouverte doit être fermée.
  • Les balises doivent être correctement imbriquées (pas de chevauchement).
  • Les noms de balises sont sensibles à la casse (<b> n’est pas la même chose que </B>).
  • Les attributs doivent avoir des valeurs entre guillemets (" " ou ' ').
  • Les noms d’attributs doivent être uniques au sein d’une même balise.

Allons-y !

  1. <a></a>
    • Bien formé. Racine unique, balise ouverte et fermée correctement.
  2. <a><b></b><b></b></a>
    • Bien formé. Racine unique (<a>), balises imbriquées correctement.
  3. <a><b></B></a>
    • NON bien formé. Erreur : Sensibilité à la casse. La balise ouvrante est <b> mais la balise fermante est </B>. Les majuscules/minuscules doivent correspondre.
  4. <a><b></a>
    • NON bien formé. Erreur : Imbrication incorrecte. La balise <b> est ouverte mais n’est pas fermée avant la fermeture de <a>. L’ordre de fermeture doit être l’inverse de l’ordre d’ouverture.
  5. <a></a><a></a>
    • NON bien formé. Erreur : Multiples racines. Un document XML doit avoir un et un seul élément racine. Ici, il y a deux éléments <a> au niveau le plus haut.
  6. <a><b id="45" v=’45’></b></a>
    • NON bien formé. Erreur : Les attributs doivent avoir leurs valeurs entre guillemets doubles " " ou guillemets simples ' '. Ici, v=’45’ utilise une apostrophe ouvrante () et une apostrophe fermante () qui ne sont pas les bons caractères ASCII pour les guillemets simples (qui sont ''). En général, les guillemets doubles sont préférés.
  7. <a><b id="45" id="48"></b></a>
    • NON bien formé. Erreur : Attributs dupliqués. Un même élément (<b>) ne peut pas avoir deux attributs avec le même nom (id).
  8. <a><b id="45"></b><b id="45"></b></a>
    • Bien formé. C’est un piège ! Même si les deux éléments <b> ont le même id, l’erreur 7 concernait des attributs dupliqués sur la même balise. Ici, ce sont deux balises <b> différentes, chacune avec son propre attribut id="45". C’est valide en XML bien formé (bien que cela puisse poser problème pour la validation avec un schéma DTD/XSD qui demande des IDs uniques, mais ce n’est pas la question ici).

Exercice 20 : Le Compteur de Nœuds XML 📊

Le défi : Compter le nombre de nœuds (éléments) ayant un certain nom de balise dans un document XML.

Prérequis :

  • Comprendre la structure d’un document XML comme un arbre.
  • Maîtriser la récursivité.
  • Savoir parcourir un arbre (même si la représentation DOM est interne à Python).

Contexte : Lorsque Python analyse un document XML, il le transforme en une structure d’objet appelée « DOM » (Document Object Model), qui est en fait une représentation en arbre ! Un nœud DOM peut avoir des « enfants » (d’autres nœuds DOM).

Consignes : Écris une fonction compte_balise(d, nom_balise) qui prend en argument un nœud DOM d et un nom de balise n (ici, nom_balise), et qui renvoie le nombre de nœuds (éléments) ayant ce nom dans le document.

Solution (en Python) :

On va simuler une structure DOM simple avec des dictionnaires pour les nœuds, car la manipulation réelle du DOM avec xml.etree.ElementTree serait un peu lourde pour l’exercice. L’important est la logique récursive.

Python

# Simulation d'un noeud DOM simple
# Chaque noeud a un 'tag' (nom de balise) et une liste 'children' (ses enfants)
def creer_noeud_dom(tag, children=None):
    return {"tag": tag, "children": children if children is not None else []}

def compte_balise(noeud_dom, nom_balise_recherche):
    compteur = 0

    # 1. Vérifier le noeud actuel
    if noeud_dom["tag"] == nom_balise_recherche:
        compteur += 1

    # 2. Parcourir récursivement les enfants
    for enfant in noeud_dom["children"]:
        compteur += compte_balise(enfant, nom_balise_recherche) # Appel récursif

    return compteur

# Exemple d'utilisation avec une structure XML simulée
# <racine>
#   <enfant1>
#     <sous_enfant>
#       <enfant1/>
#     </sous_enfant>
#   </enfant1>
#   <enfant2/>
#   <enfant1/>
# </racine>

arbre_xml_simule = creer_noeud_dom("racine", [
    creer_noeud_dom("enfant1", [
        creer_noeud_dom("sous_enfant", [
            creer_noeud_dom("enfant1")
        ])
    ]),
    creer_noeud_dom("enfant2"),
    creer_noeud_dom("enfant1")
])

print(f"Nombre d'éléments 'enfant1' : {compte_balise(arbre_xml_simule, 'enfant1')}") # Devrait être 2
print(f"Nombre d'éléments 'racine' : {compte_balise(arbre_xml_simule, 'racine')}")   # Devrait être 1
print(f"Nombre d'éléments 'bidon' : {compte_balise(arbre_xml_simule, 'bidon')}")     # Devrait être 0

Explication : La fonction compte_balise est une fonction récursive qui fait un parcours en profondeur de l’arbre XML :

  1. Cas de base implicite : Si un nœud n’a pas d’enfants, la boucle for ne s’exécute pas, et la fonction renvoie le compteur pour ce seul nœud.
  2. Traitement du nœud actuel : Elle vérifie si le tag (nom de balise) du nœud noeud_dom correspond au nom_balise_recherche. Si oui, elle incrémente le compteur.
  3. Appel récursif pour les enfants : Pour chaque enfant du nœud actuel, elle appelle récursivement compte_balise et ajoute le résultat au compteur.

Cette méthode permet de visiter tous les nœuds de l’arbre et de compter ceux qui correspondent au nom de balise recherché.


Exercice 21 : La Fabrique XML 🏭

Le défi : Générer un document XML avec une structure spécifique et le sauvegarder dans un fichier. C’est comme imprimer un arbre en XML !

Prérequis :

  • Comprendre le format XML.
  • Savoir manipuler des fichiers en Python (ouvrir, écrire, fermer).
  • Utiliser la bibliothèque xml.etree.ElementTree de Python (pour la création d’éléments XML).

Consignes : Écris une fonction gen_doc(n) qui prend un entier n et génère un document XML de la forme : <a><b>0</b><b>1</b>...<b>n - 1</b></a> Et sauve ce document dans un fichier docn.xml.

Solution (en Python) :

Python

import xml.etree.ElementTree as ET

def gen_doc(n):
    # Créer l'élément racine 'a'
    root_a = ET.Element("a")

    # Ajouter les éléments 'b' de 0 à n-1
    for i in range(n):
        element_b = ET.SubElement(root_a, "b") # Crée un sous-élément 'b' sous 'a'
        element_b.text = str(i) # Définit le contenu textuel de 'b'

    # Créer un objet ElementTree à partir de la racine
    tree = ET.ElementTree(root_a)

    # Construire le nom du fichier
    nom_fichier = f"doc{n}.xml"

    # Sauvegarder le document XML dans le fichier
    try:
        # pretty_print=True pour un affichage plus lisible dans le fichier
        # encoding="utf-8" pour éviter les problèmes d'encodage
        tree.write(nom_fichier, encoding="utf-8", xml_declaration=True, pretty_print=True)
        print(f"Document XML sauvegardé dans {nom_fichier}")
    except Exception as e:
        print(f"Erreur lors de la sauvegarde du fichier : {e}")

# Exemples d'utilisation :
gen_doc(5) # Va créer doc5.xml
gen_doc(10) # Va créer doc10.xml

Contenu de doc5.xml (exemple) :

XML

&lt;?xml version='1.0' encoding='utf-8'?>
&lt;a>
  &lt;b>0&lt;/b>
  &lt;b>1&lt;/b>
  &lt;b>2&lt;/b>
  &lt;b>3&lt;/b>
  &lt;b>4&lt;/b>
&lt;/a>

Explication :

  1. import xml.etree.ElementTree as ET : Importe la bibliothèque Python standard pour manipuler le XML. C’est pratique et intégré.
  2. ET.Element("a") : Crée l’élément XML de plus haut niveau, la racine <a>.
  3. for i in range(n) : Une boucle pour créer les n éléments <b>.
  4. ET.SubElement(root_a, "b") : Crée un nouvel élément <b> qui est un enfant de root_a.
  5. element_b.text = str(i) : Donne le texte (la valeur numérique i convertie en chaîne) à l’élément <b>.
  6. ET.ElementTree(root_a) : Crée un objet « arbre » XML à partir de l’élément racine.
  7. tree.write(nom_fichier, ...) : Sauvegarde l’arbre XML dans le fichier spécifié. L’option pretty_print=True est très utile pour un affichage lisible.

Exercice 22 : Le Traducteur JSON 🗣️

Le défi : Lire un fichier JSON, interpréter son contenu pour déterminer un mode (« bonjour » ou « bonsoir ») et un nom, puis afficher un message personnalisé. C’est comme créer une petite application de salutations !

Prérequis :

  • Comprendre les bases du format JSON (objets, paires clé-valeur, chaînes de caractères).
  • Savoir manipuler des fichiers en Python (ouvrir, lire, fermer).
  • Utiliser la bibliothèque json de Python (pour lire les fichiers JSON).

Consignes : Écris un programme hello_json.py qui :

  1. Charge un fichier JSON config.json.
  2. Ce fichier config.json contient un dictionnaire avec deux entrées :
    • "mode" : chaîne de caractères "bonjour" ou "bonsoir"
    • "nom" : un nom de personne (par exemple, "Toto")
  3. Le programme affiche ensuite « bonjour Toto » ou « bonsoir Toto » en fonction du mode.

Solution (en Python) :

D’abord, créons le fichier config.json pour le test :

JSON

# config.json (copiez/collez ce contenu dans un fichier nommé config.json)
{
  "mode": "bonjour",
  "nom": "Alice"
}

Vous pouvez modifier « mode » à « bonsoir » ou « nom » à « Bob » pour tester.

Maintenant, le programme hello_json.py :

Python

import json
import os

def hello_json():
    nom_fichier_config = "config.json"

    # Vérifier si le fichier config.json existe
    if not os.path.exists(nom_fichier_config):
        print(f"Erreur : Le fichier '{nom_fichier_config}' n'a pas été trouvé.")
        print("Veuillez créer un fichier config.json avec le contenu suivant :")
        print("""
{
  "mode": "bonjour",
  "nom": "Toto"
}
        """)
        return

    try:
        # Ouvrir et lire le fichier JSON
        with open(nom_fichier_config, 'r', encoding='utf-8') as f:
            data = json.load(f) # Convertit le contenu JSON en un dictionnaire Python

        # Extraire les informations du dictionnaire
        mode = data.get("mode") # Utilise .get() pour éviter une erreur si la clé n'existe pas
        nom = data.get("nom")

        # Vérifier si les clés nécessaires sont présentes
        if mode is None or nom is None:
            print("Erreur : Le fichier config.json doit contenir les clés 'mode' et 'nom'.")
            return

        # Afficher le message personnalisé
        if mode == "bonjour":
            print(f"Bonjour {nom} !")
        elif mode == "bonsoir":
            print(f"Bonsoir {nom} !")
        else:
            print(f"Mode inconnu dans config.json : '{mode}'. Je ne sais pas comment saluer {nom}.")

    except json.JSONDecodeError as e:
        print(f"Erreur de format JSON dans '{nom_fichier_config}' : {e}")
    except Exception as e:
        print(f"Une erreur inattendue est survenue : {e}")

# Appeler la fonction principale du programme
hello_json()

Explication :

  1. import json : Importe le module json qui gère la conversion entre les chaînes JSON et les objets Python (dictionnaires, listes).
  2. with open(nom_fichier_config, 'r', encoding='utf-8') as f: : Ouvre le fichier config.json en mode lecture ('r'). Le with garantit que le fichier est bien fermé après. encoding='utf-8' est essentiel pour les caractères spéciaux.
  3. data = json.load(f) : C’est la ligne magique ! Elle lit le contenu du fichier et le transforme en un dictionnaire Python.
  4. mode = data.get("mode") et nom = data.get("nom") : Accède aux valeurs associées aux clés "mode" et "nom" dans le dictionnaire Python. Utiliser .get() est plus sûr que data["mode"] car cela renvoie None si la clé n’existe pas, évitant une erreur.
  5. Les conditions if/elif/else : Affichent le message de salutation en fonction de la valeur de mode.
  6. try-except : C’est important pour gérer les erreurs, par exemple si le fichier JSON est mal formé (json.JSONDecodeError) ou s’il n’existe pas.

Ce programme montre bien la simplicité du JSON pour échanger des données structurées et la facilité avec laquelle Python peut les manipuler !

la Récursivité


🧐 Qu’est-ce que la Récursivité ?

Imaginez un miroir face à un autre miroir… on voit des reflets de reflets, à l’infini ! La récursivité, c’est un peu ça en programmation. C’est quand une fonction s’appelle elle-même pour résoudre un problème. Ça peut sembler un peu bizarre au début, mais vous allez voir que c’est une idée géniale !

🏃‍♀️ Rappel : Les fonctions qui s’appellent !

Avant de parler de récursivité, rafraîchissons-nous la mémoire sur le fonctionnement des appels de fonctions.

Mini-Challenge 1 : Le Détective de Fonctions 🕵️‍♀️

Analysez et testez ce programme. Essayez de prédire l’ordre d’exécution !

Python

def reveil():
    print("Tic...")
    dodo()
    print("...Tac !")

def dodo():
    print("Zzzzz...")
    # Et si on appelait reveil() ici ? Essayez !
    print("...Mmmh !")

print("Journée qui commence !")
reveil()
print("Journée qui finit !")


Vous avez remarqué que quand reveil() appelle dodo(), l’exécution de reveil() se met en pause. C’est comme si reveil() disait : « Hé, dodo() ! Fais ton truc, et quand tu as fini, dis-le-moi pour que je reprenne ! ».

Pour gérer ça, Python (et la plupart des langages de programmation) utilise une pile d’exécution (ou « call stack »). C’est une sorte de pile de Post-it où chaque Post-it représente une fonction en cours d’exécution. La fonction au sommet est celle qui travaille, et celles en dessous attendent patiemment leur tour. Quand une fonction a fini, son Post-it est retiré du dessus de la pile.

C’est aussi grâce à cette pile que les variables locales à chaque fonction restent bien séparées. Le i de reveil() n’est pas le même que le i de dodo(), même s’ils ont le même nom ! C’est comme si chaque Post-it avait sa propre petite liste de courses.


😱 La Récursivité : Attention à la Boucle Infinie !

Maintenant, imaginez que notre fonction reveil() s’appelle elle-même !

Mini-Challenge 2 : La Cascade Infernale 🌊

Analysez et testez ce programme. Que se passe-t-il ? Pourquoi ?

Python

def chute_libre():
    print("Aïe !")
    chute_libre() # On s'appelle soi-même !

chute_libre()

Vous avez dû voir une erreur : RecursionError: maximum recursion depth exceeded. 🤯 C’est Python qui lève le drapeau rouge ! La pile d’exécution a grandi, grandi, grandi… sans jamais que les fonctions ne se terminent. C’est une boucle infinie récursive !

Pour qu’une fonction récursive soit utile, elle doit avoir une condition d’arrêt (ou « cas de base »). C’est le moment où la fonction ne s’appelle plus elle-même et renvoie une valeur. Sans ça, c’est le crash assuré !


🌟 La Magie de la Récursivité : Exemples Concrets

La récursivité est parfaite pour les problèmes qui peuvent être décomposés en sous-problèmes plus petits, mais de même nature.

🔢 L’Escalier Numérique 🪜

Imaginons que nous voulions afficher les nombres de 0 à n en partant de 0.

Mini-Challenge 3 : Prédiction et Vérification 🔮

Essayez de prévoir ce que va afficher le programme suivant. Puis, testez-le !

Python

def compte_a_rebours(n):
    if n < 0: # Notre condition d'arrêt !
        return
    print(n)
    compte_a_bours(n - 1)

compte_a_rebours(3)

Maintenant, si on voulait compter de 0 à n dans l’ordre croissant, il suffirait de changer l’ordre de print(n) et de l’appel récursif.

Python

def compte_en_avant(n):
    if n < 0:
        return
    compte_en_avant(n - 1)
    print(n) # L'affichage se fait après l'appel récursif !

compte_en_avant(3)

Remarquez la subtilité ! Dans compte_a_rebours, on affiche avant l’appel récursif, donc l’ordre est décroissant. Dans compte_en_avant, on affiche après l’appel récursif, donc l’ordre est croissant, car les print sont exécutés au moment où les fonctions sont « dépilées » de la pile ! C’est un point crucial à comprendre.


🎲 Le Maître des Permutations 🤹‍♂️

Un problème classique et élégant à résoudre avec la récursivité est de générer toutes les permutations d’une liste (toutes les façons d’ordonner ses éléments).

Par exemple, pour [1, 2, 3], les permutations sont [1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1].

Comment ça marche de manière récursive ?

  1. On choisit un élément de la liste.
  2. On permute le reste de la liste.
  3. On combine l’élément choisi avec toutes les permutations du reste.

Mini-Application 1 : Les Arrangements de Mots ✍️

Essayez de compléter cette fonction pour qu’elle affiche toutes les permutations d’une chaîne de caractères (par exemple, « ABC »).

Python

def permuter(chaine):
    if len(chaine) == 1:
        return [chaine]
    
    permutations = []
    for i, char in enumerate(chaine):
        # On choisit un caractère
        reste = chaine[:i] + chaine[i+1:] # Le reste de la chaîne
        
        # On permute le reste
        permutations_restantes = permuter(reste)
        
        # On combine le caractère choisi avec les permutations du reste
        for p_reste in permutations_restantes:
            permutations.append(char + p_reste)
            
    return permutations

print(permuter("ABC"))

C’est un peu plus complexe, mais ça montre bien la puissance de la récursivité pour découper un gros problème en petits morceaux !


🎨 La Beauté des Fractales avec Turtle 🐢

La récursivité est la reine des fractales, ces formes géométriques complexes qui se répètent à l’infini à différentes échelles. Le flocon de Koch est un exemple magnifique.

Mini-Challenge 4 : Le Flocon Magique ❄️

Regardez cette vidéo sur le flocon de Koch : https://www.youtube.com/watch?v=F0vQnI20230 (ou cherchez « flocon de Koch animation » si le lien ne marche pas).

Ensuite, analysez et testez le code suivant. Concentrez-vous sur les paramètres longueur et n (le nombre d’étapes de récursivité).

Python

import turtle as t

t.speed(0) # Vitesse maximale pour un dessin rapide
t.penup()
t.goto(-150, 90) # Position de départ pour que le flocon soit bien centré
t.pendown()

def koch(longueur, n):
    if n == 0:
        t.forward(longueur)
    else:
        # Chaque segment est remplacé par 4 segments plus petits
        koch(longueur / 3, n - 1)
        t.left(60)
        koch(longueur / 3, n - 1)
        t.right(120)
        koch(longueur / 3, n - 1)
        t.left(60)
        koch(longueur / 3, n - 1)

def flocon(taille, etape):
    for _ in range(3): # Un flocon est composé de 3 segments de Koch
        koch(taille, etape)
        t.right(120)

flocon(300, 4) # Essayez différentes valeurs pour taille et etape !
t.done() # Garde la fenêtre ouverte

Le secret du flocon de Koch réside dans la fonction koch : si n est 0, on dessine juste un segment droit. Sinon, on remplace ce segment par 4 segments plus petits, en tournant de 60 ou 120 degrés, et on appelle koch récursivement pour chacun de ces segments ! C’est le principe des fractales : une petite partie ressemble au tout.


🚀 Étendre vos Horizons : Quand utiliser la Récursivité ?

La récursivité est un outil puissant, mais ce n’est pas toujours la meilleure solution.

Quand la récursivité brille ✨:

  • Structures de données récursives : Arbres, listes chaînées… Parcourir ces structures est souvent très élégant avec la récursivité.
  • Problèmes définis récursivement : La factorielle, la suite de Fibonacci, les tours de Hanoï, les fractales… La définition même du problème est récursive.
  • Clarté du code : Pour certains problèmes, la solution récursive est plus courte et plus lisible que la solution itérative (avec des boucles).

Quand il faut être prudent ⚠️:

  • Profondeur de récursion : Chaque appel récursif ajoute une « couche » sur la pile. Si la récursion est trop profonde (beaucoup d’appels successifs), vous risquez le RecursionError. Python a une limite par défaut (souvent autour de 1000 appels).
  • Performance : Les appels de fonctions ont un coût. Parfois, une solution itérative est plus rapide et utilise moins de mémoire. La suite de Fibonacci est un bon exemple : la version récursive est très lente pour de grands nombres car elle recalcule les mêmes valeurs plusieurs fois.

💡 Un petit défi pour la route (pour plus tard) : Les Tours de Hanoï 🗼

Les Tours de Hanoï sont un puzzle classique parfait pour la récursivité. Vous avez 3 piquets et des disques de tailles différentes. Le but est de déplacer tous les disques d’un piquet de départ à un piquet d’arrivée, en ne déplaçant qu’un seul disque à la fois, et en ne posant jamais un grand disque sur un petit.

Mini-Application 2 : Le Déplaceur de Disques 💿

Recherchez les « Tours de Hanoï algorithme récursif » et essayez d’implémenter une fonction Python qui affiche les étapes pour déplacer les disques.


J’espère que ce voyage au cœur de la récursivité vous a plu ! C’est un concept fondamental en informatique qui ouvre la porte à des solutions élégantes et à la compréhension de problèmes complexes. N’oubliez pas la règle d’or : une condition d’arrêt, toujours !

Alors, prêt à empiler les appels récursifs sans faire s’écrouler la pile ? 😉

Défi 1 : Le Compteur d’Étoiles Filantes 🌠

Imaginez que chaque nuit, le nombre d’étoiles filantes que vous voyez est le produit du nombre d’étoiles vues la veille, la veille de la veille, etc., jusqu’à la première nuit où vous n’en avez vu qu’une seule !

Exercice : Créez une fonction récursive nommée compte_etoiles_filantes(jour) qui calcule le nombre cumulé d’étoiles filantes observées jusqu’à un certain jour. Par définition, au jour 0, vous avez vu 1 étoile filante. Pour tout jour n > 0, le nombre d’étoiles est n multiplié par le nombre d’étoiles du jour n-1.

Prérequis : Maîtrise des concepts de base des fonctions (définition, appel, return). Voir la Correction du Défi 1

Analyse du problème : La définition nous est donnée :

  • Cas de base : compte_etoiles_filantes(0) doit retourner 1.
  • Cas récursif : compte_etoiles_filantes(n) doit retourner n * compte_etoiles_filantes(n-1).

Code :

Python

def compte_etoiles_filantes(jour):
    """
    Calcule le nombre cumulé d'étoiles filantes jusqu'à un certain jour.
    jour (int): Le numéro du jour (>= 0).
    Retourne (int): Le nombre cumulé d'étoiles.
    """
    if jour == 0:  # Cas de base : au jour 0, on a vu 1 étoile.
        return 1
    else:  # Cas récursif : le nombre d'étoiles du jour est n * (n-1) étoiles.
        return jour * compte_etoiles_filantes(jour - 1)

# Tests rapides :
print(f"Étoiles au jour 0 : {compte_etoiles_filantes(0)}")  # Attendu : 1
print(f"Étoiles au jour 1 : {compte_etoiles_filantes(1)}")  # Attendu : 1 (1 * 1)
print(f"Étoiles au jour 3 : {compte_etoiles_filantes(3)}")  # Attendu : 6 (3 * 2 * 1)
print(f"Étoiles au jour 5 : {compte_etoiles_filantes(5)}")  # Attendu : 120 (5 * 4 * 3 * 2 * 1)


Défi 2 : La Danse des Fourmis de Syracuse 🐜

Imaginez une fourmi magicienne. Chaque jour, si son nombre de pattes est pair, elle en retire la moitié. Si son nombre de pattes est impair, elle se transforme en trois fourmis et gagne une patte supplémentaire (ce qui triple son nombre initial de pattes plus une). La légende dit que toutes les fourmis finissent toujours par n’avoir qu’une seule patte.

Exercice : Écrivez une fonction récursive danse_syracuse(nombre_pattes) qui prend en paramètre le nombre de pattes initial de la fourmi et affiche la séquence des nombres de pattes jour après jour, jusqu’à ce que la fourmi n’en ait plus qu’une. On supposera nombre_pattes est un entier positif strictement supérieur à 1.

Prérequis : Maîtrise des opérateurs modulo (%) et division entière (//). Comprendre l’importance du cas de base pour éviter la récursion infinie. Voir la Correction du Défi 2

Analyse du problème : La suite de Syracuse est définie par deux règles :

  • Si u_n est pair : u_{n+1} = u_n / 2
  • Si u_n est impair : u_{n+1} = 3 * u_n + 1 Le cas de base est lorsque u_n atteint 1.

Code :

Python

def danse_syracuse(nombre_pattes):
    """
    Affiche la séquence des nombres de pattes d'une fourmi de Syracuse.
    nombre_pattes (int): Le nombre de pattes actuel de la fourmi (doit être > 1).
    """
    print(nombre_pattes)  # On affiche le nombre de pattes actuel

    if nombre_pattes == 1:  # Cas de base : la fourmi n'a plus qu'une patte
        return
    
    if nombre_pattes % 2 == 0:  # Si le nombre de pattes est pair
        danse_syracuse(nombre_pattes // 2)
    else:  # Si le nombre de pattes est impair
        danse_syracuse(3 * nombre_pattes + 1)

# Tests rapides :
print("Séquence pour 6 :")
danse_syracuse(6)
# Attendu :
# 6
# 3
# 10
# 5
# 16
# 8
# 4
# 2
# 1

print("\nSéquence pour 27 :")
danse_syracuse(27)
# La séquence est plus longue, mais doit finir par 1.


Défi 3 : La Mélodie des Nombres Étranges 🎶

Imaginez une mélodie où chaque note dépend des deux notes précédentes et d’une constante. La première note est toujours ‘A’ et la deuxième note est toujours ‘B’. Ensuite, chaque note est calculée en combinant trois fois la note précédente, deux fois l’avant-précédente, et en ajoutant cinq unités d’intensité.

Exercice : Écrivez une fonction récursive melodie_etrange(n, note_A, note_B) qui renvoie la valeur de la n-ième note de cette mélodie, en considérant que n=0 correspond à note_A et n=1 à note_B.

Prérequis : Compréhension des suites définies par récurrence sur plusieurs termes précédents. Voir la Correction du Défi 3

Analyse du problème : La suite est définie par :

  • u_0 = a
  • u_1 = b
  • u_n = 3 * u_{n-1} + 2 * u_{n-2} + 5 pour n >= 2

Les cas de base sont n=0 et n=1. Pour les autres n, c’est un appel récursif.

Code :

Python

def melodie_etrange(n, note_A, note_B):
    """
    Renvoie le n-ième terme d'une suite récursive avec deux cas de base.
    n (int): L'indice du terme à calculer (>= 0).
    note_A (float/int): La valeur du terme u_0.
    note_B (float/int): La valeur du terme u_1.
    Retourne (float/int): Le n-ième terme de la suite.
    """
    if n == 0:  # Premier cas de base
        return note_A
    elif n == 1:  # Deuxième cas de base
        return note_B
    else:  # Cas récursif
        return 3 * melodie_etrange(n - 1, note_A, note_B) + \
               2 * melodie_etrange(n - 2, note_A, note_B) + 5

# Tests rapides :
print(f"Mélodie à l'indice 0 (A=10, B=20): {melodie_etrange(0, 10, 20)}") # Attendu : 10
print(f"Mélodie à l'indice 1 (A=10, B=20): {melodie_etrange(1, 10, 20)}") # Attendu : 20
print(f"Mélodie à l'indice 2 (A=1, B=1): {melodie_etrange(2, 1, 1)}") # Attendu : 3*1 + 2*1 + 5 = 10
print(f"Mélodie à l'indice 3 (A=1, B=1): {melodie_etrange(3, 1, 1)}") # u2=10, u1=1. Attendu : 3*10 + 2*1 + 5 = 37


Défi 4 : Le Labyrinthe des Chiffres Croissants 🔢➡️🔢

Imaginez que vous êtes dans un labyrinthe où chaque pièce est un chiffre. Vous ne pouvez avancer que vers des pièces avec un chiffre plus élevé, et vous voulez visiter toutes les pièces entre un point de départ et un point d’arrivée.

Exercice : Créez une fonction récursive explore_labyrinthe(depart, arrivee) qui affiche tous les entiers de depart à arrivee (inclus), dans l’ordre croissant.

Prérequis : Maîtrise des opérateurs de comparaison et des structures conditionnelles (if). Voir la Correction du Défi 4

Analyse du problème : Le cas de base est lorsque depart dépasse arrivee. Sinon, on affiche depart et on explore le reste du labyrinthe en augmentant depart.

Code :

Python

def explore_labyrinthe(depart, arrivee):
    """
    Affiche tous les entiers entre depart et arrivee (inclus) dans l'ordre.
    depart (int): L'entier de départ.
    arrivee (int): L'entier d'arrivée.
    """
    if depart > arrivee:  # Cas de base : on a dépassé l'arrivée
        return
    else:  # Cas récursif
        print(depart, end=" ") # Afficher l'entier actuel
        explore_labyrinthe(depart + 1, arrivee) # Appeler la fonction pour l'entier suivant

# Tests rapides :
print("Exploration de 0 à 3 :")
explore_labyrinthe(0, 3) # Attendu : 0 1 2 3
print("\nExploration de 5 à 5 :")
explore_labyrinthe(5, 5) # Attendu : 5
print("\nExploration de 7 à 4 (rien n'est affiché) :")
explore_labyrinthe(7, 4) # Attendu : rien (condition depart > arrivee est vraie immédiatement)


Défi 5 : Le Décodeur de Clés Secrètes (PGCD) 🔑

Pour déverrouiller une porte secrète, vous avez besoin du plus grand commun diviseur (PGCD) de deux nombres. La machine utilise un algorithme où si le deuxième nombre est zéro, le PGCD est le premier nombre. Sinon, le PGCD est celui du deuxième nombre et du reste de la division du premier par le deuxième.

Exercice : Écrivez une fonction récursive decode_cle_secrete(a, b) qui renvoie le PGCD de deux entiers positifs a et b, en utilisant l’algorithme d’Euclide.

Prérequis : Connaissance de l’algorithme d’Euclide pour le PGCD, et de l’opérateur modulo (%). Voir la Correction du Défi 5

Analyse du problème : L’algorithme d’Euclide est intrinsèquement récursif :

  • Cas de base : Si b est 0, le PGCD est a.
  • Cas récursif : Sinon, PGCD(a, b) = PGCD(b, a % b).

Code :

Python

def decode_cle_secrete(a, b):
    """
    Calcule le Plus Grand Commun Diviseur (PGCD) de deux entiers positifs.
    a (int): Le premier entier.
    b (int): Le deuxième entier.
    Retourne (int): Le PGCD de a et b.
    """
    if b == 0:  # Cas de base de l'algorithme d'Euclide
        return a
    else:  # Cas récursif
        return decode_cle_secrete(b, a % b)

# Tests rapides :
print(f"PGCD(48, 18) : {decode_cle_secrete(48, 18)}") # Attendu : 6
print(f"PGCD(17, 5) : {decode_cle_secrete(17, 5)}")   # Attendu : 1
print(f"PGCD(100, 25) : {decode_cle_secrete(100, 25)}") # Attendu : 25
print(f"PGCD(7, 0) : {decode_cle_secrete(7, 0)}")     # Attendu : 7


Défi 6 : L’Inspecteur de Chiffres 🕵️‍♀️

Vous êtes un inspecteur et vous devez compter le nombre de chiffres qui composent un suspect numérique. Chaque fois que vous examinez un chiffre, vous retirez ce chiffre et comptez le reste.

Exercice : Écrivez une fonction récursive inspecteur_de_chiffres(n) qui prend un entier positif ou nul n et renvoie son nombre de chiffres décimaux.

Prérequis : Maîtrise des opérateurs de division entière (//). Gérer le cas n=0 correctement. Voir la Correction du Défi 6

Analyse du problème :

  • Le nombre de chiffres d’un nombre entre 0 et 9 est 1. C’est notre cas de base.
  • Pour un nombre n plus grand, le nombre de chiffres est 1 (pour le chiffre des unités) plus le nombre de chiffres de n // 10.

Code :

Python

def inspecteur_de_chiffres(n):
    """
    Renvoie le nombre de chiffres décimaux d'un entier positif ou nul.
    n (int): L'entier à analyser (>= 0).
    Retourne (int): Le nombre de chiffres.
    """
    if n < 10:  # Cas de base : si n est un chiffre (0-9)
        return 1
    else:  # Cas récursif : retirer le dernier chiffre et compter le reste
        return 1 + inspecteur_de_chiffres(n // 10)

# Tests rapides :
print(f"Chiffres de 0 : {inspecteur_de_chiffres(0)}")      # Attendu : 1
print(f"Chiffres de 7 : {inspecteur_de_chiffres(7)}")      # Attendu : 1
print(f"Chiffres de 42 : {inspecteur_de_chiffres(42)}")     # Attendu : 2
print(f"Chiffres de 12345 : {inspecteur_de_chiffres(12345)}") # Attendu : 5


Défi 7 : Le Compteur de Bits Lumineux 💡

Dans le monde binaire, certains bits sont « lumineux » (valent 1) et d’autres sont « éteints » (valent 0). Vous devez compter combien de bits lumineux il y a dans la représentation binaire d’un nombre.

Exercice : Écrivez une fonction récursive compteur_bits_lumineux(n) qui prend un entier positif ou nul n et renvoie le nombre de bits valant 1 dans sa représentation binaire.

Prérequis : Connaissance des opérateurs binaires (% 2 pour le dernier bit, // 2 pour décaler à droite). Voir la Correction du Défi 7

Analyse du problème :

  • Le cas de base est lorsque n est 0, il n’y a aucun bit 1.
  • Pour n > 0, on vérifie le dernier bit (n % 2). S’il vaut 1, on l’ajoute au compte. Ensuite, on compte les bits 1 dans le reste du nombre (n // 2).

Code :

Python

def compteur_bits_lumineux(n):
    """
    Renvoie le nombre de bits valant 1 dans la représentation binaire d'un entier.
    n (int): L'entier positif ou nul.
    Retourne (int): Le nombre de bits à 1.
    """
    if n == 0:  # Cas de base : plus de bits à examiner
        return 0
    else:  # Cas récursif
        # On ajoute 1 si le dernier bit est 1 (n % 2)
        # Puis on compte les bits 1 dans le reste du nombre (n // 2)
        return (n % 2) + compteur_bits_lumineux(n // 2)

# Tests rapides :
print(f"Bits lumineux de 0 (0b0) : {compteur_bits_lumineux(0)}")      # Attendu : 0
print(f"Bits lumineux de 1 (0b1) : {compteur_bits_lumineux(1)}")      # Attendu : 1
print(f"Bits lumineux de 2 (0b10) : {compteur_bits_lumineux(2)}")     # Attendu : 1
print(f"Bits lumineux de 3 (0b11) : {compteur_bits_lumineux(3)}")     # Attendu : 2
print(f"Bits lumineux de 255 (0b11111111) : {compteur_bits_lumineux(255)}") # Attendu : 8
print(f"Bits lumineux de 10 (0b1010) : {compteur_bits_lumineux(10)}")    # Attendu : 2


Défi 8 : Le Détecteur d’Intrusions dans le Tableau 🚨

Vous êtes le gardien d’un tableau de valeurs et vous devez vérifier si une valeur spécifique est présente à partir d’un certain point.

Exercice : Écrivez une fonction récursive detecteur_intrusions(valeur, tableau, indice_depart) qui renvoie True si valeur apparaît dans tableau à partir de indice_depart (inclus) jusqu’à la fin du tableau, et False sinon. On garantit que indice_depart est toujours un indice valide.

Prérequis : Maîtrise des listes/tableaux (accès par indice, len()), des opérateurs de comparaison et des booléens. Voir la Correction du Défi 8

Analyse du problème :

  • Cas de base 1 : Si indice_depart atteint la fin du tableau (len(tableau)), cela signifie que la valeur n’a pas été trouvée, donc on retourne False.
  • Cas de base 2 : Si la valeur à tableau[indice_depart] correspond à valeur, on a trouvé, on retourne True.
  • Cas récursif : Sinon, on continue la recherche à l’indice suivant (indice_depart + 1).

Code :

Python

def detecteur_intrusions(valeur, tableau, indice_depart):
    """
    Vérifie si une valeur est présente dans un tableau à partir d'un certain indice.
    valeur (any): La valeur à rechercher.
    tableau (list): Le tableau dans lequel chercher.
    indice_depart (int): L'indice à partir duquel commencer la recherche (inclus, >=0).
    Retourne (bool): True si la valeur est trouvée, False sinon.
    """
    # Cas de base 1 : L'indice a dépassé la fin du tableau, la valeur n'est pas trouvée.
    if indice_depart == len(tableau):
        return False
    # Cas de base 2 : La valeur est trouvée à l'indice actuel.
    elif tableau[indice_depart] == valeur:
        return True
    # Cas récursif : La valeur n'est pas à l'indice actuel, on cherche dans le reste du tableau.
    else:
        return detecteur_intrusions(valeur, tableau, indice_depart + 1)

# Tests rapides :
mon_tableau = [10, 20, 30, 40, 50]
print(f"20 dans [10, 20, 30, 40, 50] à partir de 0 : {detecteur_intrusions(20, mon_tableau, 0)}") # Attendu : True
print(f"60 dans [10, 20, 30, 40, 50] à partir de 0 : {detecteur_intrusions(60, mon_tableau, 0)}") # Attendu : False
print(f"40 dans [10, 20, 30, 40, 50] à partir de 3 : {detecteur_intrusions(40, mon_tableau, 3)}") # Attendu : True
print(f"10 dans [10, 20, 30, 40, 50] à partir de 1 : {detecteur_intrusions(10, mon_tableau, 1)}") # Attendu : False
print(f"50 dans [10, 20, 30, 40, 50] à partir de 4 : {detecteur_intrusions(50, mon_tableau, 4)}") # Attendu : True


Défi 9 : Le Secret du Triangle Magique de Pascal 🧙‍♂️

Le triangle de Pascal est un artefact mathématique où chaque nombre est la somme des deux nombres directement au-dessus de lui. Les bords du triangle sont toujours des 1.

Exercice :

  1. Écrivez une fonction récursive coefficient_magique(n, p) qui renvoie la valeur du coefficient binomial C(n,p) (le nombre à la ligne n et à la position p dans le triangle de Pascal), en utilisant la définition récursive donnée :
    • C(n,p)=1 si p=0 ou n=p
    • C(n,p)=C(n−1,p−1)+C(n−1,p) sinon.
  2. Utilisez une double boucle for pour afficher les 11 premières lignes (de n=0 à n=10) du triangle de Pascal, en utilisant votre fonction.

Prérequis : Maîtrise des boucles for imbriquées et de l’affichage formaté (print). Comprendre la définition mathématique récursive. Voir la Correction du Défi 9

Analyse du problème : La définition récursive est très claire :

  • Cas de base 1 : p = 0, le coefficient est 1.
  • Cas de base 2 : n = p, le coefficient est 1.
  • Cas récursif : C(n-1, p-1) + C(n-1, p).

Code :

Python

def coefficient_magique(n, p):
    """
    Calcule le coefficient binomial C(n, p) du triangle de Pascal de manière récursive.
    n (int): Le numéro de la ligne (>= 0).
    p (int): La position dans la ligne (0 <= p <= n).
    Retourne (int): La valeur du coefficient C(n, p).
    """
    if p == 0 or n == p:  # Cas de base : les bords du triangle
        return 1
    else:  # Cas récursif : somme des deux éléments au-dessus
        return coefficient_magique(n - 1, p - 1) + coefficient_magique(n - 1, p)

# Tests rapides de la fonction :
print(f"C(0, 0) : {coefficient_magique(0, 0)}")  # Attendu : 1
print(f"C(3, 1) : {coefficient_magique(3, 1)}")  # Attendu : 3
print(f"C(4, 2) : {coefficient_magique(4, 2)}")  # Attendu : 6

# Dessin du triangle de Pascal
print("\n--- Triangle Magique de Pascal (jusqu'à n=10) ---")
for n_ligne in range(11):  # Pour chaque ligne de 0 à 10
    # Optionnel : pour centrer l'affichage (ajuster selon la taille de la console)
    print(" " * (10 - n_ligne), end="")
    for p_position in range(n_ligne + 1):  # Pour chaque position dans la ligne
        print(f"{coefficient_magique(n_ligne, p_position):4d}", end="") # :4d pour aligner
    print() # Passer à la ligne suivante


Défi 10 : L’Art du Flocon Mystérieux de Koch ❄️

Plongez dans le monde des fractales ! Le flocon de Koch est une figure envoûtante qui se construit en ajoutant des triangles à des segments. Chaque segment de la figure se transforme en une version plus petite de la figure elle-même.

Exercice : En utilisant le module turtle, écrivez une fonction récursive dessine_flocon_koch(profondeur, longueur) qui dessine un flocon de Koch d’une certaine profondeur de récursion, à partir d’un segment initial de longueur.

Prérequis : Maîtrise des bases du module turtle (déplacements, rotations). Compréhension de la description géométrique du flocon de Koch. Voir la Correction du Défi 10

Analyse du problème : Le flocon de Koch est construit à partir de trois courbes de Koch de même longueur, tournées de 120 degrés. La courbe de Koch elle-même est récursive :

  • Cas de base : Si profondeur est 0, on dessine simplement un segment.
  • Cas récursif : On remplace un segment par 4 segments plus petits (longueur / 3), avec des rotations.

Code :

Python

import turtle as t

# Configuration initiale de Turtle
t.speed(0) # Vitesse maximale pour un dessin rapide
t.penup()
t.goto(-200, 100) # Position de départ pour centrer le flocon
t.pendown()
t.color("lightblue") # Couleur du flocon
t.pensize(2) # Épaisseur du trait

def courbe_koch(profondeur, longueur):
    """
    Dessine une courbe de Koch de profondeur donnée.
    profondeur (int): Le niveau de récursion (0 pour un segment droit).
    longueur (float): La longueur du segment initial.
    """
    if profondeur == 0:
        t.forward(longueur)
    else:
        # Chaque segment est remplacé par 4 segments plus petits,
        # formant une "bosse" au milieu.
        courbe_koch(profondeur - 1, longueur / 3)
        t.left(60)
        courbe_koch(profondeur - 1, longueur / 3)
        t.right(120)
        courbe_koch(profondeur - 1, longueur / 3)
        t.left(60)
        courbe_koch(profondeur - 1, longueur / 3)

def dessine_flocon_koch(profondeur, longueur):
    """
    Dessine un flocon de Koch complet.
    profondeur (int): Le niveau de récursion du flocon.
    longueur (float): La longueur de chaque côté du triangle initial.
    """
    for _ in range(3): # Un flocon est composé de 3 courbes de Koch
        courbe_koch(profondeur, longueur)
        t.right(120) # Tourner pour dessiner le côté suivant du triangle

# Tests rapides :
# Dessiner un flocon avec 3 niveaux de profondeur et des côtés de 300 unités
dessine_flocon_koch(3, 300)

t.done() # Garde la fenêtre Turtle ouverte jusqu'à fermeture manuelle


Alors, ces défis vous ont-ils fait voir la récursivité sous un nouveau jour ? La récursivité est un outil puissant et élégant quand il est bien utilisé. N’hésitez pas à rejouer ces défis avec différentes valeurs pour bien comprendre comment la pile d’exécution travaille ! Quelle est votre fractale préférée à part celle de Koch ?

Structures de données : listes piles et files

1. Les Listes Chaînées : La Chasse au Trésor ! 🗺️

Imagine tes données non pas comme des objets sagement alignés, mais plutôt comme des indices pour une chasse au trésor ! Chaque indice (un morceau de donnée) ne te dit pas où se trouve le prochain élément juste en étant à côté de lui. Non ! Chaque indice contient une petite note secrète : l’adresse de l’indice suivant !

C’est ça, une liste chaînée !

  • Chaque élément, qu’on appelle souvent un nœud, est comme une petite boîte à deux compartiments :
    • Le premier compartiment contient la valeur de l’élément (ton trésor !).
    • Le deuxième compartiment contient une flèche (un pointeur) vers la boîte suivante.

![Schéma d’une liste chaînée : des « dominos » où la partie gauche contient une valeur et la partie droite une flèche pointant vers le domino suivant, le dernier pointant vers « None » ou « Null ».]

Le dernier élément ? Sa flèche pointe vers None (ou Null), pour dire « fin de la chaîne, plus d’indices ici ! ».

La Magie de la Flexibilité : Insérer et Supprimer sans Déménager ! ✨

L’énorme avantage des listes chaînées, c’est leur flexibilité. Si tu veux ajouter un nouvel indice au milieu de ta chasse au trésor, tu n’as pas besoin de tout réorganiser ! Tu changes juste quelques flèches :

![Schéma montrant l’insertion d’un nouveau domino dans une liste chaînée en modifiant juste deux flèches.]

Pareil pour supprimer : tu « sautes » juste l’élément en question en refaisant pointer la flèche précédente vers l’élément suivant. Facile comme bonjour !


2. Les Listes (Abstraites) : Le Serpentin Flexible de Tes Données 🐍

Maintenant que tu as compris la mécanique interne des listes chaînées, parlons du concept de « Liste » tel qu’il a été popularisé par des langages comme Lisp (qui veut dire « List Processing » !). Pour Lisp, et pour nous en NSI, une liste est une structure de données logique, un Type Abstrait de Données (TAD), dont la particularité est d’avoir une « tête » et une « queue », un peu comme un serpentin :

  • Tête (Head ou car en Lisp) : Le premier élément. C’est l’entrée principale du serpentin.
  • Queue (Tail ou cdr en Lisp) : Le reste de la liste, sans la tête. C’est le corps du serpentin.

Quand on parle de « Liste » dans ce sens abstrait, on ne se soucie pas de comment elle est vraiment construite (chaînée, tableau dynamique, etc.). On se concentre sur les opérations qu’on peut faire dessus :

  • creer_liste_vide() : Pour commencer avec un serpentin sans aucun wagon.
  • est_vide(ma_liste) : Pour vérifier si le serpentin a des wagons ou non.
  • ajouter_en_tete(element, ma_liste) : Tu ajoutes un nouveau wagon à la tête du serpentin. L’ancien premier wagon devient le nouveau deuxième.
  • supprimer_en_tete(ma_liste) : Tu détaches le wagon de tête et tu le récupères. Le serpentin se raccourcit.
  • compter_elements(ma_liste) : Pour connaître la longueur de ton serpentin.
  • construire_liste(element, ancienne_liste) (le célèbre cons de Lisp) : C’est une opération fondamentale ! Elle prend un element et une ancienne_liste, et renvoie une NOUVELLE LISTE où l’element est en tête et l’ancienne_liste est sa queue. C’est super pour « construire » des listes petit à petit ou les transformer sans modifier l’originale : construire_liste(x, construire_liste(y, construire_liste(z, liste_vide))).

Exemples Concrets : La Vie de Nos Listes Abstraites !

Voici un scénario d’aventures pour nos listes :

  1. L = creer_liste_vide()
    • Action : On initialise une liste toute seule, sans rien.
    • État de L : [] (vide)
  2. est_vide(L)
    • Action : On vérifie si L est vide.
    • Résultat : True
  3. L = ajouter_en_tete(3, L)
    • Action : On ajoute le chiffre 3 en tête de L. C’est le premier (et seul) élément.
    • État de L : [3] (tête: 3, queue: vide)
  4. est_vide(L)
    • Action : On revérifie.
    • Résultat : False
  5. L = ajouter_en_tete(5, L)
    • Action : Le 5 arrive en tête ! Le 3 passe en queue.
    • État de L : [5, 3] (tête: 5, queue: [3])
  6. L = ajouter_en_tete(8, L)
    • Action : Le 8 prend la place du 5.
    • État de L : [8, 5, 3] (tête: 8, queue: [5, 3])
  7. t = supprimer_en_tete(L)
    • Action : On retire le 8 de la tête. Il nous est retourné.
    • Valeur de t : 8
    • État de L : [5, 3] (tête: 5, queue: [3])
  8. L1 = creer_liste_vide()
    • Action : Une nouvelle liste, L1, est créée, vide.
    • État de L1 : []
  9. L2 = construire_liste(8, construire_liste(5, construire_liste(3, L1)))
    • Action : On utilise notre sort construire_liste pour créer L2.
    • construire_liste(3, L1) donne [3]
    • construire_liste(5, [3]) donne [5, 3]
    • construire_liste(8, [5, 3]) donne [8, 5, 3]
    • État de L2 : [8, 5, 3] (tête: 8, queue: [5, 3])

À faire vous-même 1 : Le Mystère de la Chaîne de Liste 🕵️‍♀️

Voici une série d’instructions. À toi de jouer les détectives et d’expliquer ce qui se passe à chaque étape, et quel est l’état final de chaque liste !

L = creer_liste_vide()
L = ajouter_en_tete(10,L)
L = ajouter_en_tete(9,L)
L = ajouter_en_tete(7,L)
L1 = creer_liste_vide()
L2 = construire_liste(5, construire_liste(4, construire_liste(3, construire_liste(2, construire_liste(1, construire_liste(0,L1))))))


3. Les Piles : La Tour d’Assiettes Magique ! 🍽️ (Un cas particulier de Liste)

Tu as compris les listes chaînées, et les listes « abstraites » avec leur tête et leur queue. Eh bien, une pile est une liste super stricte ! Imagine une pile d’assiettes dans ta cuisine. Quand tu ajoutes une assiette, tu la mets au-dessus des autres. Quand tu en prends une, tu prends toujours celle qui est tout en haut. Impossible de prendre une assiette du milieu sans faire tout tomber, n’est-ce pas ?

![Dessin d’une pile d’assiettes avec une flèche « Empiler » qui pointe vers le haut de la pile et une flèche « Dépiler » qui pointe vers le bas depuis le haut de la pile, illustrant l’entrée et la sortie par le même côté.]

Les piles en informatique, c’est exactement ça ! C’est le principe LIFO qui règne en maître : Last In, First Out (le dernier élément à être entré est le premier à sortir). C’est hyper courant en info (pour gérer les appels de fonctions, l’historique « annuler » dans un logiciel, etc.) !

Voici les sorts magiques (opérations) que tu peux jeter sur une pile :

  • pile_est_vide?(P) : Pour savoir si ta pile d’assiettes est vide.
  • empiler(P, x) (ou push(P, x)) : Tu ajoutes un nouvel élément x tout en haut de la pile. (C’est comme un ajouter_en_tete !)
  • depiler(P) (ou pop(P)) : Tu récupères l’élément qui est au sommet de la pile et tu le retires. C’est le seul qui est accessible ! (C’est comme un supprimer_en_tete !)
  • sommet(P) (ou peek(P)) : Tu peux juste jeter un œil à l’élément du sommet, sans le retirer de la pile. Super pour voir sans casser la tour !
  • taille(P) : Pour savoir combien d’assiettes il y a dans ta pile.

Exemples Concrets : La Vie de Nos Piles !

Repartons d’une pile P d’éléments : [12, 14, 8, 7, 19, 22] (avec 22 au sommet).

  • depiler(P)
    • Action : On retire l’assiette du dessus.
    • Résultat : Renvoie 22.
    • État de P : [12, 14, 8, 7, 19] (19 est maintenant au sommet)
  • empiler(P, 42)
    • Action : On ajoute le 42 par-dessus tout.
    • État de P : [12, 14, 8, 7, 19, 22, 42] (42 est au sommet)
  • sommet(P)
    • Action : On regarde juste le dessus.
    • Résultat : Renvoie 22.
    • État de P : P n’est PAS modifiée, elle reste [12, 14, 8, 7, 19, 22] (22 est toujours au sommet)
  • si on applique depiler(P) 6 fois de suite, pile_est_vide?(P)
    • Action : On vide la pile.
    • Résultat : Renvoie True.
  • Après avoir appliqué depiler(P) une fois, taille(P)
    • Action : On retire un élément, puis on compte.
    • Résultat : Renvoie 5.

À faire vous-même 2 : Le Mystère du Dépilement 🧐

Soit une pile P composée des éléments suivants : [15, 11, 32, 45, 67] (le sommet de la pile est 67).

Quel est l’effet de l’instruction depiler(P) ? (Qu’est-ce que ça renvoie et quel est le nouvel état de la pile ?)


4. Les Files : La File d’Attente du Cinéma ! 🍿

Comme les piles, les files ont des points communs avec les listes, mais avec une différence cruciale : dans une file, on ajoute des éléments à une extrémité (la « queue » de la file) et on supprime des éléments à l’autre extrémité (la « tête » de la file). On prend souvent l’analogie de la file d’attente devant un magasin ou un cinéma pour décrire une file de données.

![Dessin d’une file d’attente avec une flèche « Ajouter » qui pointe vers l’arrière de la file et une flèche « Retirer » qui pointe vers l’avant de la file, illustrant l’entrée par un côté et la sortie par l’autre.]

Les files sont basées sur le principe FIFO : First In, First Out (le premier élément à être entré est le premier à sortir). C’est le principe d’équité ! Tu le retrouves partout, de la gestion des tâches d’impression aux messages dans une application.

Voici les sorts magiques (opérations) que l’on peut réaliser sur une file :

  • file_est_vide?(F) : Pour savoir si la file d’attente est vide.
  • ajouter(F, x) (ou enqueue(F, x)) : Tu ajoutes un nouvel élément x à la fin de la file.
  • retirer(F) (ou dequeue(F)) : Tu récupères l’élément qui est au début de la file et tu le retires. C’est le premier qui a attendu !
  • premier(F) (ou front(F)) : Tu peux juste jeter un œil à l’élément qui est au début de la file, sans le supprimer.
  • taille(F) : Pour savoir combien de personnes il y a dans ta file.

Exemples Concrets : La Vie de Nos Files !

Repartons d’une file F d’éléments : [22, 19, 7, 8, 14, 12] (22 est le premier élément arrivé, 12 est le dernier).

  • ajouter(F, 42)
    • Action : Le 42 arrive à la fin de la file.
    • État de F : [22, 19, 7, 8, 14, 12, 42] (22 est le premier, 42 est le dernier)
  • retirer(F)
    • Action : On retire le premier arrivé.
    • Résultat : Renvoie 22.
    • État de F : [19, 7, 8, 14, 12] (19 est maintenant le premier, 12 le dernier)
  • premier(F)
    • Action : On regarde qui est le prochain à être servi.
    • Résultat : Renvoie 22.
    • État de F : F n’est PAS modifiée, elle reste [22, 19, 7, 8, 14, 12]
  • si on applique retirer(F) 6 fois de suite, file_est_vide?(F)
    • Action : On vide la file.
    • Résultat : Renvoie True.
  • Après avoir appliqué retirer(F) une fois, taille(F)
    • Action : On retire un élément, puis on compte.
    • Résultat : Renvoie 5.

À faire vous-même 3 : Le Mystère de l’Ajout à la File 🤷‍♀️

Soit une file F composée des éléments suivants : [72, 21, 17, 24, 12, 1] (le premier élément rentré dans la file est 72 ; le dernier élément rentré dans la file est 1).

Quel est l’effet de l’instruction ajouter(F, 25) ? (Quel est le nouvel état de la file ?)


5. Types Abstraits et Implémentation : Les Idées Géniales derrière le Code ! 🧠

Tu sais, les listes chaînées, les listes (abstraites), les piles et les files dont on vient de parler… Ce sont des concepts, des « idées » ! Dans le monde de l’informatique, on appelle ça des Types Abstraits de Données (ou TAD pour les intimes). C’est comme le plan d’une super voiture : on sait ce qu’elle doit faire (rouler, freiner, etc.) et comment on doit interagir avec elle (volant, pédales), mais on ne sait pas encore comment elle est construite sous le capot (moteur, roues, etc.).

L’avantage des TAD, c’est qu’ils nous permettent de réfléchir aux problèmes sans nous soucier des détails techniques du langage de programmation. C’est de la « haute voltige » de la pensée algorithmique !

Mais pour que ton ordinateur (qui est un peu bête, il faut lui tout lui expliquer !) puisse utiliser ces idées, il faut les implémenter. C’est-à-dire, les « traduire » dans un langage de programmation spécifique (Python, Java, C#, etc.).

Comment on implémente ces idées ? Les outils du magicien ! 🛠️

Pour construire nos TAD, les langages de programmation utilisent souvent deux structures concrètes :

  1. Les Tableaux (ou Arrays) : La Commode à Tiroirs ! 🗄️ Imagine une commode avec des tiroirs numérotés qui se suivent parfaitement en mémoire. Chaque tiroir a une adresse précise, juste après le précédent.![Schéma d’un tableau en mémoire, montrant des cases contiguës numérotées (adresses mémoire) remplies de valeurs.]Le hic ? La taille d’une commode classique est fixe. Si tu veux ajouter un tiroir au milieu, tu dois acheter une nouvelle commode plus grande et tout déménager ! C’est un peu lourd.Heureusement, Python a une version « super-héro » des tableaux : les tableaux dynamiques (ce que Python appelle simplement « listes »). Ces « listes Python » sont géniales parce qu’elles gèrent automatiquement le déménagement pour toi quand tu ajoutes ou retires des éléments. C’est pour ça qu’elles sont très souvent utilisées pour implémenter nos TAD (listes, piles, files) ! Attention : Ne confonds pas les « listes Python » avec le concept abstrait de « liste » que nous avons vu au début. Ce sont des « faux amis » ! Une liste Python est un tableau dynamique !![Schéma d’un tableau dynamique : même quadrillage, même valeurs que précédemment mais avec une case vide au milieu de la séquence dans laquelle on va insérer une nouvelle valeur, montrant que l’espace peut s’étendre.]
  2. Les Listes Chaînées : La Chasse au Trésor (revisité) ! 🗺️ Comme nous l’avons vu au début, la liste chaînée est elle-même une structure d’implémentation très courante pour les TAD comme les listes (abstraites), les piles et les files. Leur flexibilité à l’insertion et suppression les rend idéales pour ces rôles.

Python te donne même la liberté d’implémenter ces TAD avec d’autres structures, comme les tuples (qui sont immuables, une propriété sympa pour la programmation fonctionnelle !).


À faire vous-même 4 : Le Dissecteur de Code Python 🐍🔬

Nous allons maintenant voir une implémentation du TAD « Liste » en Python, en utilisant des tuples (qui sont immuables !). C’est une façon très « fonctionnelle » de faire les choses, car chaque opération qui « modifie » la liste en réalité retourne une nouvelle liste (un nouveau tuple).

Étudie attentivement les fonctions suivantes et essaie de comprendre comment elles recréent le comportement de nos sorts magiques de liste :

Python

def vide():
    return None # Une liste vide est représentée par None

def construire_liste(x, L): # C'est notre 'cons' !
    return (x, L) # Un tuple (élément, reste_de_la_liste)

def ajouter_en_tete(L, x): # Attention, l'ordre des arguments est inversé ici par rapport au texte !
    return construire_liste(x, L) # Crée une NOUVELLE liste avec x en tête

def supprimer_en_tete(L):
    # Renvoie la tête (L[0]) et la queue (L[1])
    # Attention: cette fonction suppose que la liste n'est PAS vide.
    # Dans une vraie implémentation, on ajouterait une vérification.
    return (L[0], L[1])

def est_vide(L):
    return L is None # Une liste est vide si elle est None

def compter_elements(L):
    if est_vide(L):
        return 0
    return 1 + compter_elements(L[1]) # Appel récursif sur la queue de la liste


À faire vous-même 5 : L’Expérience en Console ! 🧪

Saisis les fonctions de l’exercice « À faire vous-même 4 » dans une console Python (ou un fichier .py que tu exécutes). Puis, tape successivement ces commandes et observe attentivement ce qui se passe à chaque étape :

Python

L = vide()
print(f"L est vide : {est_vide(L)}")

L = construire_liste(5, construire_liste(4, construire_liste(3, construire_liste(2, construire_liste(1, construire_liste(0,L))))))
print(f"L après construction : {L}")
print(f"L est vide : {est_vide(L)}")
print(f"Nombre d'éléments dans L : {compter_elements(L)}")

L = ajouter_en_tete(L, 6) # Attention à l'ordre des arguments L et x ici !
print(f"L après ajout de 6 : {L}")
print(f"Nombre d'éléments dans L : {compter_elements(L)}")

x, L = supprimer_en_tete(L)
print(f"Élément supprimé (x) : {x}")
print(f"L après suppression : {L}")
print(f"Nombre d'éléments dans L : {compter_elements(L)}")

x, L = supprimer_en_tete(L)
print(f"Élément supprimé (x) : {x}")
print(f"L après 2ème suppression : {L}")
print(f"Nombre d'éléments dans L : {compter_elements(L)}")

Exercices d’application

Exercice 1 : Créer une Séquence Numérique en Liste

Énoncé

Écris une fonction creer_liste_numerique(n) qui prend en argument un entier n, supposé positif ou nul, et renvoie une liste (au sens de notre TAD avec construire_liste) contenant les entiers de 1 à n dans l’ordre croissant. Si n est 0, la liste renvoyée doit être vide.

Prérequis

  • Avoir bien compris le concept de liste abstraite et l’opération construire_liste (ou cons).
  • Savoir gérer les cas de base dans les fonctions récursives (ici, n = 0).

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L): # C'est notre 'cons' !
    return (x, L)

# --- Fonction à écrire pour l'exercice ---

def creer_liste_numerique(n):
    """
    Renvoie une liste (TAD) contenant les entiers de 1 à n.
    Args:
        n (int): Un entier positif ou nul.
    Returns:
        tuple or None: La liste des entiers de 1 à n.
    """
    if n == 0:
        return vide()
    else:
        # On construit la liste de manière récursive, en ajoutant les nombres
        # du plus grand au plus petit pour obtenir l'ordre croissant à la fin
        # (car 'construire_liste' ajoute en tête)
        return construire_liste(n, creer_liste_numerique(n - 1))

# --- Tests ---
print("Exercice 1 :")
L0 = creer_liste_numerique(0)
print(f"creer_liste_numerique(0) : {L0} (vide si None)") # Attend None
L3 = creer_liste_numerique(3)
print(f"creer_liste_numerique(3) : {L3}") # Attend (3, (2, (1, None)))
L5 = creer_liste_numerique(5)
print(f"creer_liste_numerique(5) : {L5}") # Attend (5, (4, (3, (2, (1, None)))))
print("-" * 20)

Explication de la correction : La fonction creer_liste_numerique est récursive.

  • Cas de base : Si n est 0, il n’y a pas d’éléments à ajouter, donc on renvoie une liste vide().
  • Cas récursif : Si n est supérieur à 0, on veut que n soit le premier élément de notre liste. On utilise donc construire_liste(n, ...) et pour le reste de la liste, on fait un appel récursif à creer_liste_numerique(n - 1). Cet appel construira la liste des nombres de 1 à n-1, qui sera la queue de notre liste. Ainsi, le n sera bien en tête, suivi de n-1, etc., jusqu’à 1.

Exercice 2 : Afficher les Éléments d’une Liste

Énoncé

Écris une fonction afficher_elements_liste(ma_liste) qui parcourt et affiche tous les éléments de la liste ma_liste, séparés par des espaces, suivis d’un retour à la ligne. Propose une version récursive et une version itérative (avec une boucle while).

Prérequis

  • Maîtriser la récursion.
  • Savoir manipuler les listes (TAD) avec est_vide, supprimer_en_tete ou en accédant directement à L[0] (tête) et L[1] (queue) si tu utilises l’implémentation par tuple.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

def supprimer_en_tete(L): # Utile pour la version itérative
    return (L[0], L[1])

# --- Fonctions à écrire pour l'exercice ---

# Version récursive
def afficher_elements_liste_rec(ma_liste):
    """
    Affiche récursivement les éléments d'une liste (TAD).
    Args:
        ma_liste (tuple or None): La liste à afficher.
    """
    if not est_vide(ma_liste):
        print(ma_liste[0], end=" ") # Affiche l'élément actuel
        afficher_elements_liste_rec(ma_liste[1]) # Appel récursif sur la queue
    else:
        print() # Pour le retour à la ligne à la fin

# Version itérative (avec boucle while)
def afficher_elements_liste_iter(ma_liste):
    """
    Affiche itérativement les éléments d'une liste (TAD).
    Args:
        ma_liste (tuple or None): La liste à afficher.
    """
    temp_list = ma_liste # On utilise une variable temporaire pour ne pas modifier la liste originale
    while not est_vide(temp_list):
        print(temp_list[0], end=" ")
        temp_list = temp_list[1] # Passe à l'élément suivant (la queue)
    print() # Pour le retour à la ligne à la fin

# --- Tests ---
print("Exercice 2 :")
L_test = construire_liste(10, construire_liste(20, construire_liste(30, vide())))
print("Affichage récursif :")
afficher_elements_liste_rec(L_test) # Attend : 10 20 30
print("Affichage itératif :")
afficher_elements_liste_iter(L_test) # Attend : 10 20 30

L_vide = vide()
print("Affichage récursif liste vide :")
afficher_elements_liste_rec(L_vide) # Attend un retour à la ligne
print("Affichage itératif liste vide :")
afficher_elements_liste_iter(L_vide) # Attend un retour à la ligne
print("-" * 20)

Explication de la correction :

  • Version récursive (afficher_elements_liste_rec) :
    • Cas de base : Si la liste est vide (est_vide(ma_liste) est True), cela signifie qu’on a parcouru tous les éléments. On imprime alors un retour à la ligne et la récursion s’arrête.
    • Cas récursif : Si la liste n’est pas vide, on imprime la tête de la liste (ma_liste[0]) suivie d’un espace (end=" "). Ensuite, on fait un appel récursif sur la queue de la liste (ma_liste[1]) pour traiter les éléments restants.
  • Version itérative (afficher_elements_liste_iter) :
    • On utilise une variable temp_list pour ne pas modifier la liste originale passée en argument.
    • La boucle while continue tant que temp_list n’est pas vide.
    • À chaque itération, on imprime la tête de temp_list et on met à jour temp_list pour qu’elle pointe vers sa propre queue. Cela simule le déplacement le long de la liste chaînée.
    • Une fois la boucle terminée (quand temp_list est None), on imprime un retour à la ligne.

Exercice 3 : Accéder au N-ième Élément (Itératif)

Énoncé

Soit la fonction récursive obtenir_nieme_element :

Python

def obtenir_nieme_element_rec(n, lst):
    """
    Renvoie le n-ième élément de la liste lst.
    Les éléments sont numérotés à partir de 0.
    Args:
        n (int): L'index de l'élément à récupérer.
        lst (tuple or None): La liste.
    Raises:
        IndexError: Si l'indice est invalide (liste trop courte ou liste vide initialement).
    Returns:
        Any: L'élément à l'indice n.
    """
    if lst is None:
        raise IndexError("Indice invalide : la liste est vide ou l'indice est hors limites.")
    if n == 0:
        return lst[0]  # Accède à la tête (valeur)
    else:
        return obtenir_nieme_element_rec(n - 1, lst[1]) # Récursion sur la queue

Réécris cette fonction en utilisant une boucle while.

Prérequis

  • Maîtriser les boucles while.
  • Comprendre comment parcourir une liste chaînée étape par étape.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# --- Fonction à écrire pour l'exercice ---

def obtenir_nieme_element_iter(n, lst):
    """
    Renvoie le n-ième élément de la liste lst en utilisant une boucle while.
    Les éléments sont numérotés à partir de 0.
    Args:
        n (int): L'index de l'élément à récupérer.
        lst (tuple or None): La liste.
    Raises:
        IndexError: Si l'indice est invalide (liste trop courte ou liste vide initialement).
    Returns:
        Any: L'élément à l'indice n.
    """
    if n < 0:
        raise IndexError("L'indice ne peut pas être négatif.")

    current_list = lst
    current_index = 0

    while not est_vide(current_list):
        if current_index == n:
            return current_list[0] # On a trouvé l'élément !
        current_list = current_list[1] # Passe à l'élément suivant (la queue)
        current_index += 1
    
    # Si la boucle se termine, c'est que l'indice est trop grand ou la liste était vide
    raise IndexError("Indice invalide : la liste est trop courte ou l'indice est hors limites.")

# --- Tests ---
print("Exercice 3 :")
L_test = construire_liste('A', construire_liste('B', construire_liste('C', vide())))
print(f"Liste de test : {L_test}")

print(f"Élément à l'index 0 : {obtenir_nieme_element_iter(0, L_test)}") # Attend 'A'
print(f"Élément à l'index 1 : {obtenir_nieme_element_iter(1, L_test)}") # Attend 'B'
print(f"Élément à l'index 2 : {obtenir_nieme_element_iter(2, L_test)}") # Attend 'C'

try:
    print(f"Élément à l'index 3 : {obtenir_nieme_element_iter(3, L_test)}")
except IndexError as e:
    print(f"Erreur attendue : {e}")

try:
    print(f"Élément à l'index 0 (liste vide) : {obtenir_nieme_element_iter(0, vide())}")
except IndexError as e:
    print(f"Erreur attendue : {e}")

try:
    print(f"Élément à l'index -1 : {obtenir_nieme_element_iter(-1, L_test)}")
except IndexError as e:
    print(f"Erreur attendue : {e}")
print("-" * 20)

Explication de la correction :

  • On initialise une variable current_list à la liste d’origine et un current_index à 0.
  • La boucle while continue tant que current_list n’est pas vide.
  • À chaque itération :
    • On vérifie si current_index est égal à n. Si oui, on a trouvé l’élément et on le renvoie (current_list[0]).
    • Si non, on « avance » dans la liste en faisant pointer current_list vers sa queue (current_list[1]) et on incrémente current_index.
  • Si la boucle se termine sans avoir trouvé l’élément (c’est-à-dire que current_list est devenue None avant que current_index n’atteigne n), cela signifie que l’indice n est hors des limites de la liste. On lève alors une IndexError.
  • On ajoute une vérification pour les indices négatifs en début de fonction.

Exercice 4 : Compter les Occurrences

Énoncé

Écris une fonction compter_occurrences(valeur, ma_liste) qui renvoie le nombre de fois où valeur apparaît dans ma_liste. Propose une version récursive et une version itérative (avec une boucle while).

Prérequis

  • Maîtriser la récursion.
  • Savoir parcourir une liste chaînée.
  • Connaître l’opérateur d’égalité ==.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# --- Fonctions à écrire pour l'exercice ---

# Version récursive
def compter_occurrences_rec(valeur, ma_liste):
    """
    Compte récursivement le nombre d'occurrences d'une valeur dans une liste (TAD).
    Args:
        valeur (Any): La valeur à chercher.
        ma_liste (tuple or None): La liste où chercher.
    Returns:
        int: Le nombre d'occurrences.
    """
    if est_vide(ma_liste):
        return 0 # Si la liste est vide, il n'y a pas d'occurrences
    
    # Vérifie si la tête correspond à la valeur
    count = 0
    if ma_liste[0] == valeur:
        count = 1
    
    # Ajoute le compte de la queue de la liste
    return count + compter_occurrences_rec(valeur, ma_liste[1])

# Version itérative (avec boucle while)
def compter_occurrences_iter(valeur, ma_liste):
    """
    Compte itérativement le nombre d'occurrences d'une valeur dans une liste (TAD).
    Args:
        valeur (Any): La valeur à chercher.
        ma_liste (tuple or None): La liste où chercher.
    Returns:
        int: Le nombre d'occurrences.
    """
    count = 0
    current_list = ma_liste
    while not est_vide(current_list):
        if current_list[0] == valeur:
            count += 1
        current_list = current_list[1] # Passe à l'élément suivant
    return count

# --- Tests ---
print("Exercice 4 :")
L_test = construire_liste(1, construire_liste(2, construire_liste(1, construire_liste(3, construire_liste(1, vide())))))
print(f"Liste de test : {L_test}")

print(f"Occurrences de 1 (récursif) : {compter_occurrences_rec(1, L_test)}") # Attend 3
print(f"Occurrences de 2 (récursif) : {compter_occurrences_rec(2, L_test)}") # Attend 1
print(f"Occurrences de 4 (récursif) : {compter_occurrences_rec(4, L_test)}") # Attend 0

print(f"Occurrences de 1 (itératif) : {compter_occurrences_iter(1, L_test)}") # Attend 3
print(f"Occurrences de 2 (itératif) : {compter_occurrences_iter(2, L_test)}") # Attend 1
print(f"Occurrences de 4 (itératif) : {compter_occurrences_iter(4, L_test)}") # Attend 0

print(f"Occurrences dans liste vide : {compter_occurrences_rec(5, vide())}") # Attend 0
print("-" * 20)

Explication de la correction :

  • Version récursive (compter_occurrences_rec) :
    • Cas de base : Si la liste est vide, il n’y a pas d’occurrences, on renvoie 0.
    • Cas récursif : On initialise count à 0 ou 1 selon si la tête de la liste correspond à valeur. Puis, on ajoute le résultat de l’appel récursif sur la queue de la liste. C’est le principe « diviser pour régner » : le problème est résolu en combinant la solution pour la tête et la solution pour le reste de la liste.
  • Version itérative (compter_occurrences_iter) :
    • On initialise un count à 0.
    • On parcourt la liste avec une boucle while tant qu’elle n’est pas vide.
    • À chaque élément, on vérifie si sa valeur (current_list[0]) est égale à valeur. Si oui, on incrémente count.
    • On passe à l’élément suivant (current_list = current_list[1]).
    • Finalement, on renvoie le count.

Exercice 5 : Trouver la Première Occurrence

Énoncé

Écris une fonction trouver_rang(valeur, ma_liste) qui renvoie le rang (l’index) de la première occurrence de valeur dans ma_liste. Si valeur n’est pas trouvée, la fonction renvoie None. Les rangs commencent à 0. Propose une version récursive et une version itérative (avec une boucle while).

Prérequis

  • Maîtriser la récursion.
  • Savoir parcourir une liste chaînée en gardant la trace de l’index.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# --- Fonctions à écrire pour l'exercice ---

# Version récursive (fonction auxiliaire nécessaire pour gérer l'index)
def trouver_rang_rec(valeur, ma_liste):
    """
    Renvoie récursivement le rang de la première occurrence d'une valeur.
    Args:
        valeur (Any): La valeur à chercher.
        ma_liste (tuple or None): La liste où chercher.
    Returns:
        int or None: Le rang de la première occurrence ou None si non trouvé.
    """
    def _trouver_rang_aux(valeur, current_list, current_index):
        if est_vide(current_list):
            return None # Si on arrive à la fin de la liste, la valeur n'est pas trouvée
        
        if current_list[0] == valeur:
            return current_index # Si la tête correspond, on renvoie l'index actuel
        
        # Sinon, on cherche dans la queue avec l'index incrémenté
        return _trouver_rang_aux(valeur, current_list[1], current_index + 1)
    
    return _trouver_rang_aux(valeur, ma_liste, 0) # Appel initial avec index 0

# Version itérative (avec boucle while)
def trouver_rang_iter(valeur, ma_liste):
    """
    Renvoie itérativement le rang de la première occurrence d'une valeur.
    Args:
        valeur (Any): La valeur à chercher.
        ma_liste (tuple or None): La liste où chercher.
    Returns:
        int or None: Le rang de la première occurrence ou None si non trouvé.
    """
    current_list = ma_liste
    current_index = 0
    while not est_vide(current_list):
        if current_list[0] == valeur:
            return current_index # On a trouvé, on renvoie l'index
        current_list = current_list[1] # Passe à l'élément suivant
        current_index += 1
    return None # Si la boucle se termine, la valeur n'a pas été trouvée

# --- Tests ---
print("Exercice 5 :")
L_test = construire_liste('pomme', construire_liste('banane', construire_liste('cerise', construire_liste('banane', vide()))))
print(f"Liste de test : {L_test}")

print(f"Rang de 'banane' (récursif) : {trouver_rang_rec('banane', L_test)}") # Attend 1
print(f"Rang de 'pomme' (récursif) : {trouver_rang_rec('pomme', L_test)}") # Attend 0
print(f"Rang de 'fraise' (récursif) : {trouver_rang_rec('fraise', L_test)}") # Attend None

print(f"Rang de 'banane' (itératif) : {trouver_rang_iter('banane', L_test)}") # Attend 1
print(f"Rang de 'pomme' (itératif) : {trouver_rang_iter('pomme', L_test)}") # Attend 0
print(f"Rang de 'fraise' (itératif) : {trouver_rang_iter('fraise', L_test)}") # Attend None

print(f"Rang dans liste vide : {trouver_rang_rec('x', vide())}") # Attend None
print("-" * 20)

Explication de la correction :

  • Version récursive (trouver_rang_rec) :
    • Cette version utilise une fonction auxiliaire interne (_trouver_rang_aux) pour pouvoir passer l’index courant (current_index) dans les appels récursifs. La fonction principale trouver_rang_rec se contente d’initialiser cet index à 0.
    • Cas de base (auxiliaire) : Si la liste est vide, cela signifie que valeur n’a pas été trouvée, on renvoie None.
    • Cas récursif (auxiliaire) : Si la tête de current_list est valeur, on a trouvé, on renvoie current_index. Sinon, on cherche dans la queue (current_list[1]) en incrémentant current_index.
  • Version itérative (trouver_rang_iter) :
    • On utilise current_list pour parcourir la liste et current_index pour garder le compte de la position.
    • La boucle while continue tant que current_list n’est pas vide.
    • À chaque pas, on vérifie si l’élément actuel correspond à valeur. Si oui, on renvoie current_index et on sort de la fonction.
    • Si non, on passe à l’élément suivant et on incrémente l’index.
    • Si la boucle se termine, c’est que valeur n’a pas été trouvée, on renvoie None.

Exercice 6 : Concaténation Inversée et Renversement Efficace

Énoncé

Écris une fonction récursive concatener_inverse(l1, l2) qui renvoie une nouvelle liste résultant de la concaténation de l2 à la fin de la liste l1 renversée, mais sans créer explicitement de liste inversée intermédiaire ni utiliser de fonction renverser séparée.

Ensuite, en déduis une fonction renverser_efficace(lst) qui renvoie une nouvelle liste contenant les éléments de lst dans l’ordre inverse, avec une bonne efficacité.

Prérequis

  • Maîtriser la récursion.
  • Comprendre l’opération construire_liste et son effet sur l’ordre des éléments.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# Fonctions d'affichage pour les tests
def afficher_liste_simple(ma_liste):
    elements = []
    current = ma_liste
    while not est_vide(current):
        elements.append(str(current[0]))
        current = current[1]
    return "(" + " ".join(elements) + ")"

# --- Fonctions à écrire pour l'exercice ---

def concatener_inverse(l1, l2):
    """
    Renvoie une nouvelle liste qui est la concaténation de l2 à la fin de l1 inversée.
    Ne crée pas l'inverse de l1 explicitement.
    Args:
        l1 (tuple or None): La première liste.
        l2 (tuple or None): La deuxième liste.
    Returns:
        tuple or None: La nouvelle liste concaténée et inversée.
    """
    if est_vide(l1):
        return l2 # Si l1 est vide, son inverse est vide, donc on renvoie juste l2
    else:
        # On prend la tête de l1, et on l'ajoute à la fin de la concaténation
        # inverse de la queue de l1 avec l2
        # C'est magique : en "déroulant" l1, on la reconstruit à l'envers sur l2 !
        return concatener_inverse(l1[1], construire_liste(l1[0], l2))

def renverser_efficace(lst):
    """
    Renvoie une nouvelle liste contenant les éléments de lst dans l'ordre inverse,
    en utilisant concatener_inverse pour une bonne efficacité.
    Args:
        lst (tuple or None): La liste à inverser.
    Returns:
        tuple or None: La liste inversée.
    """
    return concatener_inverse(lst, vide())

# --- Tests ---
print("Exercice 6 :")
L_test1 = construire_liste(1, construire_liste(2, construire_liste(3, vide())))
L_test2 = construire_liste('A', construire_liste('B', vide()))

print(f"Liste 1 : {afficher_liste_simple(L_test1)}") # Attend (1 2 3)
print(f"Liste 2 : {afficher_liste_simple(L_test2)}") # Attend (A B)

# L1 inversée (3 2 1) + L2 (A B) => (3 2 1 A B)
res_concat_inv = concatener_inverse(L_test1, L_test2)
print(f"concatener_inverse(L1, L2) : {afficher_liste_simple(res_concat_inv)}")
# Attend : (3 2 1 A B)

res_renverser = renverser_efficace(L_test1)
print(f"renverser_efficace(L1) : {afficher_liste_simple(res_renverser)}")
# Attend : (3 2 1)

res_renverser_vide = renverser_efficace(vide())
print(f"renverser_efficace(liste_vide) : {afficher_liste_simple(res_renverser_vide)}")
# Attend : ()
print("-" * 20)

Explication de la correction :

  • concatener_inverse(l1, l2) :
    • C’est une technique récursive très astucieuse !
    • Cas de base : Si l1 est vide, cela signifie que tous ses éléments ont été traités et « ajoutés » à l2. Donc, l2 contient maintenant tous les éléments de l1 (inversés) suivis de ses propres éléments originaux. On renvoie simplement l2.
    • Cas récursif : Si l1 n’est pas vide :
      • On prend la tête de l1 (l1[0]).
      • On fait un appel récursif sur la queue de l1 (l1[1]).
      • MAIS, au lieu de passer l2 directement, on passe construire_liste(l1[0], l2). Cela signifie qu’on « ajoute » la tête actuelle de l1 à la tête de la liste l2 qui sera construite dans les appels futurs. À chaque appel récursif, l’élément de l1 est ajouté en tête d’une liste qui grossit et qui finit par être l2. C’est comme si on empilait les éléments de l1 sur l2 au fur et à mesure qu’on les dépile de l1, ce qui les inverse naturellement.
  • renverser_efficace(lst) :
    • Une fois concatener_inverse comprise, renverser_efficace devient trivial. Il suffit de concaténer lst (la liste à inverser) avec une liste vide (vide()). La fonction concatener_inverse va inverser lst et y ajouter la liste vide à la fin, ce qui donne exactement l’inverse de lst.
    • Cette approche est efficace car elle ne crée pas de multiples listes intermédiaires complètes, mais construit la liste inversée « en passant ».

Exercice 7 : Comparer l’Identité de Deux Listes

Énoncé

Écris une fonction sont_identiques(l1, l2) qui renvoie un booléen (True ou False) indiquant si les listes l1 et l2 sont identiques. Pour être identiques, elles doivent contenir exactement les mêmes éléments, dans le même ordre. On suppose que l’on peut comparer les éléments avec l’opérateur ==. Propose une version récursive et une version itérative (avec une boucle while).

Prérequis

  • Savoir manipuler les listes chaînées.
  • Gérer les différents cas (listes vides, listes de tailles différentes, éléments différents).

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# --- Fonctions à écrire pour l'exercice ---

# Version récursive
def sont_identiques_rec(l1, l2):
    """
    Vérifie récursivement si deux listes (TAD) sont identiques.
    Args:
        l1 (tuple or None): La première liste.
        l2 (tuple or None): La deuxième liste.
    Returns:
        bool: True si les listes sont identiques, False sinon.
    """
    if est_vide(l1) and est_vide(l2):
        return True # Si les deux sont vides, elles sont identiques
    
    if est_vide(l1) or est_vide(l2):
        return False # Si l'une est vide et l'autre non, elles ne sont pas identiques
    
    # Si les têtes sont différentes, les listes ne sont pas identiques
    if l1[0] != l2[0]:
        return False
    
    # Si les têtes sont identiques, on compare récursivement les queues
    return sont_identiques_rec(l1[1], l2[1])

# Version itérative (avec boucle while)
def sont_identiques_iter(l1, l2):
    """
    Vérifie itérativement si deux listes (TAD) sont identiques.
    Args:
        l1 (tuple or None): La première liste.
        l2 (tuple or None): La deuxième liste.
    Returns:
        bool: True si les listes sont identiques, False sinon.
    """
    current_l1 = l1
    current_l2 = l2

    while not est_vide(current_l1) and not est_vide(current_l2):
        if current_l1[0] != current_l2[0]:
            return False # Éléments différents, donc pas identiques
        current_l1 = current_l1[1]
        current_l2 = current_l2[1]
    
    # Après la boucle, les deux listes devraient être vides si elles sont identiques
    return est_vide(current_l1) and est_vide(current_l2)

# --- Tests ---
print("Exercice 7 :")
L_test1 = construire_liste(1, construire_liste(2, construire_liste(3, vide())))
L_test2 = construire_liste(1, construire_liste(2, construire_liste(3, vide())))
L_test3 = construire_liste(1, construire_liste(2, construire_liste(4, vide()))) # Différent par élément
L_test4 = construire_liste(1, construire_liste(2, vide())) # Différent par taille

print(f"L1 : {afficher_liste_simple(L_test1)}")
print(f"L2 : {afficher_liste_simple(L_test2)}")
print(f"L3 : {afficher_liste_simple(L_test3)}")
print(f"L4 : {afficher_liste_simple(L_test4)}")

print(f"L1 et L2 (rec) : {sont_identiques_rec(L_test1, L_test2)}") # Attend True
print(f"L1 et L3 (rec) : {sont_identiques_rec(L_test1, L_test3)}") # Attend False
print(f"L1 et L4 (rec) : {sont_identiques_rec(L_test1, L_test4)}") # Attend False
print(f"L1 et L1 (rec) : {sont_identiques_rec(L_test1, L_test1)}") # Attend True (même référence)
print(f"Liste vide et liste vide (rec) : {sont_identiques_rec(vide(), vide())}") # Attend True
print(f"L1 et liste vide (rec) : {sont_identiques_rec(L_test1, vide())}") # Attend False

print(f"L1 et L2 (iter) : {sont_identiques_iter(L_test1, L_test2)}") # Attend True
print(f"L1 et L3 (iter) : {sont_identiques_iter(L_test1, L_test3)}") # Attend False
print(f"L1 et L4 (iter) : {sont_identiques_iter(L_test1, L_test4)}") # Attend False
print(f"Liste vide et liste vide (iter) : {sont_identiques_iter(vide(), vide())}") # Attend True
print("-" * 20)

Explication de la correction :

  • Version récursive (sont_identiques_rec) :
    • On gère d’abord les cas de base :
      • Si les deux listes sont vides, elles sont identiques (True).
      • Si l’une est vide et l’autre non, elles ne peuvent pas être identiques (False).
    • Ensuite, si aucune des deux n’est vide, on compare leurs têtes (l1[0] et l2[0]). Si elles sont différentes, les listes ne sont pas identiques (False).
    • Si les têtes sont identiques, on fait un appel récursif pour comparer les queues des deux listes (l1[1] et l2[1]).
  • Version itérative (sont_identiques_iter) :
    • On utilise deux variables (current_l1, current_l2) pour parcourir les deux listes en parallèle.
    • La boucle while continue tant qu’aucune des deux listes n’est vide.
    • À chaque pas, on compare les éléments actuels (current_l1[0] et current_l2[0]). Si une différence est trouvée, on renvoie False immédiatement.
    • Si les éléments sont égaux, on passe aux queues des deux listes.
    • Après la boucle, si les deux listes sont devenues vides en même temps (est_vide(current_l1) and est_vide(current_l2)), c’est qu’elles étaient identiques. Sinon (si une seule est vide, ou aucune mais elles se sont arrêtées pour une autre raison – impossible ici), elles ne le sont pas.

Exercice 8 : Insérer dans une Liste Triée

Énoncé

Écris une fonction récursive inserer_triee(x, lst) qui prend en arguments un entier x et une liste d’entiers lst, supposée déjà triée par ordre croissant. La fonction doit renvoyer une nouvelle liste dans laquelle x a été inséré à sa place correcte pour maintenir l’ordre trié.

Exemple : insérer la valeur 3 dans la liste (1, 2, 5, 8) renvoie la liste (1, 2, 3, 5, 8).

Prérequis

  • Maîtriser la récursion.
  • Comprendre comment construire_liste crée de nouvelles listes.
  • Gérer les comparaisons d’éléments.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# Fonctions d'affichage pour les tests
def afficher_liste_simple(ma_liste):
    elements = []
    current = ma_liste
    while not est_vide(current):
        elements.append(str(current[0]))
        current = current[1]
    return "(" + " ".join(elements) + ")"

# --- Fonction à écrire pour l'exercice ---

def inserer_triee(x, lst):
    """
    Insère un élément x dans une liste triée (TAD) en conservant l'ordre.
    Renvoie une nouvelle liste.
    Args:
        x (int): L'entier à insérer.
        lst (tuple or None): La liste d'entiers triée.
    Returns:
        tuple or None: La nouvelle liste avec x inséré.
    """
    if est_vide(lst):
        # Si la liste est vide, on crée une liste avec juste x
        return construire_liste(x, vide())
    
    # Si x est plus petit ou égal à la tête de la liste, x est inséré ici
    if x <= lst[0]:
        return construire_liste(x, lst)
    else:
        # Sinon, x est plus grand que la tête, on cherche sa place dans la queue
        # et on reconstruit la liste avec la tête actuelle.
        return construire_liste(lst[0], inserer_triee(x, lst[1]))

# --- Tests ---
print("Exercice 8 :")
L_triee1 = construire_liste(1, construire_liste(2, construire_liste(5, construire_liste(8, vide()))))
print(f"Liste triée originale : {afficher_liste_simple(L_triee1)}")

res1 = inserer_triee(3, L_triee1)
print(f"Insertion de 3 : {afficher_liste_simple(res1)}") # Attend (1 2 3 5 8)

res2 = inserer_triee(0, L_triee1)
print(f"Insertion de 0 : {afficher_liste_simple(res2)}") # Attend (0 1 2 5 8)

res3 = inserer_triee(10, L_triee1)
print(f"Insertion de 10 : {afficher_liste_simple(res3)}") # Attend (1 2 5 8 10)

res4 = inserer_triee(5, L_triee1) # Insertion d'un doublon
print(f"Insertion de 5 : {afficher_liste_simple(res4)}") # Attend (1 2 5 5 8)

res5 = inserer_triee(7, vide())
print(f"Insertion dans liste vide : {afficher_liste_simple(res5)}") # Attend (7)
print("-" * 20)

Explication de la correction :

  • Cas de base : Si la liste lst est vide, x doit être le seul élément de la nouvelle liste. On renvoie donc construire_liste(x, vide()).
  • Cas récursif :
    • Si x est plus petit ou égal à la tête de la liste actuelle (lst[0]), alors x doit être inséré avant cette tête. On renvoie alors construire_liste(x, lst).
    • Sinon (si x est plus grand que lst[0]), cela signifie que lst[0] doit rester à sa place. On fait donc un appel récursif pour insérer x dans la queue de la liste (lst[1]). Le résultat de cet appel récursif formera la nouvelle queue, à laquelle on « raccroche » la tête actuelle de lst.

Exercice 9 : Tri par Insertion Récursif

Énoncé

En te servant de la fonction inserer_triee de l’exercice précédent, écris une fonction récursive tri_par_insertion(lst) qui prend en argument une liste d’entiers lst et renvoie une nouvelle liste, contenant les mêmes éléments mais triée par ordre croissant.

Prérequis

  • Avoir terminé l’Exercice 8 (inserer_triee).
  • Maîtriser la récursion.
  • Comprendre le principe du tri par insertion : prendre un élément, l’insérer au bon endroit dans une liste déjà triée.

Proposition de correction

Python

# On réutilise les fonctions d'aide et inserer_triee
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# Fonction de l'exercice 8
def inserer_triee(x, lst):
    if est_vide(lst):
        return construire_liste(x, vide())
    if x <= lst[0]:
        return construire_liste(x, lst)
    else:
        return construire_liste(lst[0], inserer_triee(x, lst[1]))

# Fonctions d'affichage pour les tests
def afficher_liste_simple(ma_liste):
    elements = []
    current = ma_liste
    while not est_vide(current):
        elements.append(str(current[0]))
        current = current[1]
    return "(" + " ".join(elements) + ")"

# --- Fonction à écrire pour l'exercice ---

def tri_par_insertion(lst):
    """
    Trie une liste (TAD) par insertion récursive.
    Renvoie une nouvelle liste triée.
    Args:
        lst (tuple or None): La liste à trier.
    Returns:
        tuple or None: La nouvelle liste triée.
    """
    if est_vide(lst):
        return vide() # Une liste vide est déjà triée
    
    # On prend la tête de la liste
    tête = lst[0]
    # On trie récursivement la queue de la liste
    queue_triee = tri_par_insertion(lst[1])
    # Et on insère la tête dans la queue triée
    return inserer_triee(tête, queue_triee)

# --- Tests ---
print("Exercice 9 :")
L_non_triee1 = construire_liste(5, construire_liste(2, construire_liste(8, construire_liste(1, construire_liste(3, vide())))))
print(f"Liste non triée : {afficher_liste_simple(L_non_triee1)}")

res_tri1 = tri_par_insertion(L_non_triee1)
print(f"Liste triée : {afficher_liste_simple(res_tri1)}") # Attend (1 2 3 5 8)

L_non_triee2 = construire_liste(9, construire_liste(1, construire_liste(5, vide())))
print(f"Liste non triée : {afficher_liste_simple(L_non_triee2)}")
res_tri2 = tri_par_insertion(L_non_triee2)
print(f"Liste triée : {afficher_liste_simple(res_tri2)}") # Attend (1 5 9)

res_tri_vide = tri_par_insertion(vide())
print(f"Liste vide triée : {afficher_liste_simple(res_tri_vide)}") # Attend ()

L_single = construire_liste(42, vide())
print(f"Liste à un élément triée : {afficher_liste_simple(L_single)}")
res_single = tri_par_insertion(L_single)
print(f"Résultat : {afficher_liste_simple(res_single)}") # Attend (42)
print("-" * 20)

Explication de la correction :

  • Cas de base : Si la liste lst est vide, elle est déjà triée. On renvoie une liste vide.
  • Cas récursif :
    • On prend le premier élément de la liste (lst[0]). C’est l’élément que nous allons insérer.
    • On appelle récursivement tri_par_insertion sur le reste de la liste (lst[1]). Cet appel nous renvoie une queue_triee (une nouvelle liste, qui est le reste de la liste d’origine, mais trié !).
    • Enfin, on utilise la fonction inserer_triee (de l’Exercice 8) pour insérer la tête de la liste d’origine dans cette queue_triee à sa place correcte. Le principe du tri par insertion est parfaitement adapté à cette structure récursive : pour trier une liste, tu tries le « reste » de la liste, puis tu insères le premier élément à sa place dans ce « reste » déjà trié.

Exercice 10 : Conversion Tableau vers Liste Chaînée

Énoncé

Écris une fonction tableau_vers_liste_chaine(tableau) qui prend en argument un tableau (une liste Python native) tableau et renvoie une liste chaînée (au sens de notre TAD avec construire_liste) contenant les éléments du tableau dans le même ordre. On suggère de l’écrire avec une boucle for ou while pour une efficacité optimale, mais une version récursive est aussi possible (un peu moins intuitive ici).

Prérequis

  • Comprendre la différence entre un tableau Python (liste native) et notre TAD « liste chaînée ».
  • Savoir parcourir un tableau Python.
  • Savoir construire une liste chaînée.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# Fonctions d'affichage pour les tests
def afficher_liste_simple(ma_liste):
    elements = []
    current = ma_liste
    while not est_vide(current):
        elements.append(str(current[0]))
        current = current[1]
    return "(" + " ".join(elements) + ")"

# --- Fonction à écrire pour l'exercice ---

# Version itérative (la plus commune et souvent la plus efficace ici)
def tableau_vers_liste_chaine(tableau):
    """
    Convertit un tableau Python en une liste chaînée (TAD).
    Args:
        tableau (list): Le tableau Python.
    Returns:
        tuple or None: La liste chaînée correspondante.
    """
    resultat_liste = vide()
    # On parcourt le tableau EN REVERS pour pouvoir utiliser construire_liste (ajouter en tête)
    for element in reversed(tableau):
        resultat_liste = construire_liste(element, resultat_liste)
    return resultat_liste

# Une version alternative itérative sans reversed (un peu plus complexe car nécessite de gérer le "suivant")
# def tableau_vers_liste_chaine_alt(tableau):
#     if not tableau:
#         return vide()
#     
#     head_node = construire_liste(tableau[0], vide())
#     current_node = head_node
#     
#     for i in range(1, len(tableau)):
#         # Ici, on devrait créer un nouveau nœud et l'attacher à current_node[1]
#         # Mais avec notre implémentation de tuple, c'est pas direct car les tuples sont immuables.
#         # Cette approche serait plus naturelle avec une implémentation de liste chaînée mutable (avec des classes).
#         pass # C'est pourquoi la version reversed est privilégiée ici.


# Version récursive (moins intuitive pour cette tâche)
def tableau_vers_liste_chaine_rec(tableau):
    """
    Convertit récursivement un tableau Python en une liste chaînée (TAD).
    Args:
        tableau (list): Le tableau Python.
    Returns:
        tuple or None: La liste chaînée correspondante.
    """
    if not tableau:
        return vide()
    # On prend le premier élément du tableau et on le construit sur le reste
    # qui est la conversion récursive du reste du tableau.
    return construire_liste(tableau[0], tableau_vers_liste_chaine_rec(tableau[1:]))


# --- Tests ---
print("Exercice 10 :")
tab1 = [10, 20, 30, 40]
res_liste1 = tableau_vers_liste_chaine(tab1)
print(f"Tableau {tab1} -> Liste Chaînée : {afficher_liste_simple(res_liste1)}") # Attend (10 20 30 40)

tab2 = ['a', 'b']
res_liste2 = tableau_vers_liste_chaine(tab2)
print(f"Tableau {tab2} -> Liste Chaînée : {afficher_liste_simple(res_liste2)}") # Attend (a b)

tab3 = []
res_liste3 = tableau_vers_liste_chaine(tab3)
print(f"Tableau {tab3} -> Liste Chaînée : {afficher_liste_simple(res_liste3)}") # Attend ()

print("--- Version Récursive ---")
res_liste1_rec = tableau_vers_liste_chaine_rec(tab1)
print(f"Tableau {tab1} -> Liste Chaînée (réc) : {afficher_liste_simple(res_liste1_rec)}")

res_liste3_rec = tableau_vers_liste_chaine_rec(tab3)
print(f"Tableau {tab3} -> Liste Chaînée (réc) : {afficher_liste_simple(res_liste3_rec)}")
print("-" * 20)

Explication de la correction :

  • Version itérative (tableau_vers_liste_chaine) :
    • La clé ici est d’utiliser reversed(tableau). Pourquoi ? Parce que notre fonction construire_liste ajoute toujours un élément en tête de la liste existante. Si on parcourt le tableau normalement, on construirait la liste à l’envers. En parcourant le tableau à l’envers, on ajoute le dernier élément en premier, puis l’avant-dernier qui devient la nouvelle tête, et ainsi de suite. Au final, la liste est construite dans le bon ordre.
    • On initialise resultat_liste à vide, puis on ajoute les éléments un par un.
  • Version récursive (tableau_vers_liste_chaine_rec) :
    • Cas de base : Si le tableau est vide, on renvoie une liste vide.
    • Cas récursif : On prend le premier élément du tableau (tableau[0]) et on l’ajoute en tête d’une liste qui est le résultat de l’appel récursif sur le reste du tableau (tableau[1:]). tableau[1:] crée une copie du reste du tableau, ce qui peut être moins performant pour de très grands tableaux mais est très lisible.

Exercice 11 : Trouver la Dernière Cellule (Implémentation Chaînée)

Énoncé

Écris une fonction obtenir_derniere_cellule(lst) qui renvoie la dernière cellule (le dernier tuple (valeur, None)) de la liste chaînée lst. On suppose que la liste lst n’est pas vide.

Prérequis

  • Comprendre l’implémentation de la liste chaînée avec des tuples (valeur, suivant).
  • Savoir qu’une cellule est le tuple entier.
  • Savoir qu’une cellule (valeur, None) est la dernière.

Proposition de correction

Python

# On réutilise notre implémentation de liste basée sur des tuples
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# Fonctions d'affichage pour les tests
def afficher_liste_simple(ma_liste):
    elements = []
    current = ma_liste
    while not est_vide(current):
        elements.append(str(current[0]))
        current = current[1]
    return "(" + " ".join(elements) + ")"

# --- Fonction à écrire pour l'exercice ---

def obtenir_derniere_cellule(lst):
    """
    Renvoie la dernière cellule (tuple) d'une liste chaînée non vide.
    Args:
        lst (tuple): La liste chaînée (non vide).
    Returns:
        tuple: La dernière cellule de la liste.
    """
    if est_vide(lst):
        # Cette condition ne devrait normalement pas être atteinte d'après l'énoncé,
        # mais c'est une bonne pratique de la gérer.
        raise ValueError("La liste ne doit pas être vide pour cette opération.")

    current_cell = lst
    # On parcourt tant que la "queue" de la cellule actuelle n'est pas vide (None)
    while not est_vide(current_cell[1]):
        current_cell = current_cell[1] # On passe à la cellule suivante
    return current_cell # Quand la boucle s'arrête, current_cell est la dernière

# --- Tests ---
print("Exercice 11 :")
L_test = construire_liste(1, construire_liste(2, construire_liste(3, vide())))
print(f"Liste de test : {afficher_liste_simple(L_test)}")

derniere = obtenir_derniere_cellule(L_test)
print(f"Dernière cellule de (1 2 3) : {derniere}") # Attend (3, None)

L_single = construire_liste(42, vide())
print(f"Liste à un élément : {afficher_liste_simple(L_single)}")
derniere_single = obtenir_derniere_cellule(L_single)
print(f"Dernière cellule de (42) : {derniere_single}") # Attend (42, None)

# Test du cas d'erreur (si l'énoncé n'avait pas garanti non vide)
try:
    obtenir_derniere_cellule(vide())
except ValueError as e:
    print(f"Erreur attendue sur liste vide : {e}")
print("-" * 20)

Explication de la correction :

  • On initialise current_cell avec le début de la liste (lst).
  • La boucle while continue tant que la queue de la current_cell n’est pas vide (current_cell[1] n’est pas None).
  • À chaque itération, on fait avancer current_cell à la cellule suivante (current_cell = current_cell[1]).
  • Lorsque la boucle se termine, cela signifie que current_cell[1] est None, ce qui veut dire que current_cell est la dernière cellule de la liste. On la retourne.
  • J’ai ajouté une gestion d’erreur pour le cas où la liste serait vide, même si l’énoncé supposait qu’elle ne l’était pas.

Exercice 12 : Concaténation « En Place » (avec notre TAD)

Énoncé

En utilisant la fonction obtenir_derniere_cellule de l’exercice précédent, écris une fonction concatener_en_place_simule(l1, l2) qui réalise une concaténation « en place » des listes l1 et l2. Cela signifie que la fonction doit « relier » la dernière cellule de l1 à la première cellule de l2. Cette fonction doit renvoyer la toute première cellule de la concaténation (l1 si l1 n’est pas vide, sinon l2).

Attention : Avec notre implémentation de listes basée sur des tuples, une vraie « modification en place » est impossible car les tuples sont immuables. Nous allons donc simuler cette opération en créant de nouveaux tuples pour représenter la nouvelle structure, mais en conservant l’idée de « lier » la fin de l1 au début de l2. Cette fonction sera donc plus complexe qu’avec une vraie liste chaînée mutable.

Alternativement, tu peux simplifier la simulation en considérant que l1 doit être reconstruite depuis le début si elle n’est pas vide, pour que son dernier élément pointe vers l2.

Prérequis

  • Avoir terminé l’Exercice 11 (obtenir_derniere_cellule).
  • Comprendre le concept d’immuabilité des tuples en Python et l’impact sur la « modification en place ».
  • Maîtriser la récursion pour la reconstruction.

Proposition de correction

Note sur l’immuabilité : L’énoncé original de l’exercice 59 sur pixees.fr est conçu pour des implémentations de listes chaînées mutables (où on peut modifier la partie suivant d’un nœud existant). Notre implémentation avec des tuples (valeur, suivant) est immuable. Cela signifie que (3, None) ne peut jamais devenir (3, (4, None)). Pour « modifier » la liste, nous devons toujours créer de nouvelles parties de la liste.

Il y a deux façons de l’aborder avec notre TAD immuable :

  1. Approche « purement fonctionnelle » (reconstruction) : La plus idiomatique avec des tuples. On reconstruit l1 en changeant le pointeur None de sa dernière cellule pour qu’il pointe vers l2.
  2. Approche « simplifiée » (pour coller à l’esprit de l’exercice original, mais qui n’est pas une vraie « modification en place ») : Si l1 est vide, le résultat est l2. Si l1 n’est pas vide, on inverse l1 (pour pouvoir la parcourir facilement), puis on la concatène avec l2. Cette approche perd l’aspect « en place » mais utilise les primitives.

Je vais te proposer la première approche, qui est plus fidèle à la nature immuable de nos tuples et qui montre la complexité de simuler l’en-place avec des immuables. C’est un excellent point de discussion sur les Trade-offs !

Python

# On réutilise nos fonctions d'aide
def vide():
    return None

def construire_liste(x, L):
    return (x, L)

def est_vide(L):
    return L is None

# Fonction de l'exercice 11
def obtenir_derniere_cellule(lst):
    if est_vide(lst):
        raise ValueError("La liste ne doit pas être vide pour cette opération.")
    current_cell = lst
    while not est_vide(current_cell[1]):
        current_cell = current_cell[1]
    return current_cell

# Fonctions d'affichage pour les tests
def afficher_liste_simple(ma_liste):
    elements = []
    current = ma_liste
    while not est_vide(current):
        elements.append(str(current[0]))
        current = current[1]
    return "(" + " ".join(elements) + ")"

# --- Fonction à écrire pour l'exercice (Simulation "en place" par reconstruction) ---

# Fonction auxiliaire pour copier une liste jusqu'à un certain point
def _copier_jusqu_a_element(liste, element_cible, nouvelle_fin):
    """
    Copie la liste 'liste' jusqu'à l'élément 'element_cible' et attache 'nouvelle_fin' à ce point.
    Cette fonction est récursive et gère la reconstruction de la liste IMMUABLE.
    """
    if est_vide(liste):
        # Ne devrait pas arriver si element_cible est dans la liste
        return vide()
    
    # Si on arrive à l'élément cible (la dernière cellule de l1), on change son pointeur
    if liste == element_cible: # Comparaison par identité (même tuple)
        return construire_liste(liste[0], nouvelle_fin)
    
    # Sinon, on copie la tête et on continue la récursion sur la queue
    return construire_liste(liste[0], _copier_jusqu_a_element(liste[1], element_cible, nouvelle_fin))


def concatener_en_place_simule(l1, l2):
    """
    Simule une concaténation "en place" de l1 et l2 pour des listes immuables.
    Args:
        l1 (tuple or None): La première liste.
        l2 (tuple or None): La deuxième liste.
    Returns:
        tuple or None: La nouvelle liste résultante (début de l1 ou l2).
    """
    if est_vide(l1):
        return l2 # Si l1 est vide, le résultat est simplement l2
    
    if est_vide(l2):
        return l1 # Si l2 est vide, le résultat est simplement l1

    # On trouve la dernière "cellule" de l1
    derniere_cell_l1 = obtenir_derniere_cellule(l1)
    
    # Avec des listes immuables (tuples), on ne peut pas faire :
    # derniere_cell_l1[1] = l2 (ça ne marcherait pas car les tuples ne sont pas mutables)
    
    # On doit donc reconstruire l1 en changeant le pointeur de sa dernière cellule.
    # C'est ici que l'approche "en place" perd de son sens avec des immuables.
    # On va reconstruire l1 jusqu'à la dernière cellule, puis la faire pointer vers l2.
    
    # Utilisons la fonction auxiliaire pour "reconstruire" l1 avec le nouveau lien
    # C'est une "modification" qui crée une nouvelle version de l1
    nouvelle_l1 = _copier_jusqu_a_element(l1, derniere_cell_l1, l2)
    
    return nouvelle_l1 # Le début de la concaténation est le début de la nouvelle l1

# --- Tests ---
print("Exercice 12 :")
L_a = construire_liste(1, construire_liste(2, vide()))
L_b = construire_liste('A', construire_liste('B', vide()))

print(f"L_a originale : {afficher_liste_simple(L_a)}")
print(f"L_b originale : {afficher_liste_simple(L_b)}")

# Tentative de concaténation "en place"
L_concat = concatener_en_place_simule(L_a, L_b)

print(f"L_a après simulation : {afficher_liste_simple(L_a)}") # L_a devrait rester inchangée si l'implémentation est immuable
print(f"L_concat (résultat) : {afficher_liste_simple(L_concat)}") # Attend (1 2 A B)

# Test avec L_a vide
L_vide = vide()
L_concat_vide_debut = concatener_en_place_simule(L_vide, L_b)
print(f"L_concat (vide + L_b) : {afficher_liste_simple(L_concat_vide_debut)}") # Attend (A B)

# Test avec L_b vide
L_concat_vide_fin = concatener_en_place_simule(L_a, L_vide)
print(f"L_concat (L_a + vide) : {afficher_liste_simple(L_concat_vide_fin)}") # Attend (1 2)

# Test avec les deux vides
L_concat_deux_vides = concatener_en_place_simule(L_vide, L_vide)
print(f"L_concat (vide + vide) : {afficher_liste_simple(L_concat_deux_vides)}") # Attend ()
print("-" * 20)

Explication de la correction :

C’est l’exercice le plus délicat avec notre implémentation par tuples car les tuples sont immuables. On ne peut pas simplement dire ma_cellule[1] = nouvelle_valeur si ma_cellule est un tuple. Cela lèverait une erreur.

  1. Gestion des cas limites : Si l1 est vide, la concaténation donne l2. Si l2 est vide, elle donne l1. C’est simple et direct.
  2. Trouver la dernière cellule de l1 : On utilise obtenir_derniere_cellule(l1). Cela nous donne la référence au tuple qui est le dernier élément de l1.
  3. La « simulation » de la modification : Puisque nous ne pouvons pas modifier le tuple derniere_cell_l1 en changeant sa partie [1] (suivant), nous devons reconstruire la partie de l1 qui mène à cette dernière cellule, en faisant en sorte que la nouvelle version de cette dernière cellule pointe vers l2.
    • La fonction auxiliaire _copier_jusqu_a_element parcourt l1 récursivement. Quand elle atteint le derniere_cell_l1 (comparaison par identité pour s’assurer que c’est bien la même référence au tuple), elle crée un nouveau tuple pour cette dernière cellule, avec sa valeur originale et en le faisant pointer vers l2 au lieu de None.
    • Les appels récursifs précédents de _copier_jusqu_a_element « raccrochent » ensuite les éléments précédents de l1 à cette nouvelle structure, recréant une « nouvelle version » de l1 qui a l2 attachée à sa fin.
  4. Retour : La fonction renvoie le début de cette nouvelle_l1.

Cette implémentation met en lumière pourquoi les listes chaînées « en place » sont souvent implémentées avec des objets mutables (classes en Python, structs en C) où les attributs valeur et suivant peuvent être directement modifiés, sans avoir à reconstruire toute une portion de la liste. C’est un excellent exemple des compromis liés aux choix d’implémentation !

Exercices : Les Piles et Files, C’est de la Bombe ! 💥

Ces exercices vont te propulser au niveau supérieur. Nous allons cette fois travailler avec des implémentations de piles et files basées sur des classes Python, ce qui se rapproche de la réalité du code. Prépare-toi à coder !


Exercice 13 : Compléter la Classe Pile

Énoncé

On te fournit le squelette d’une classe Pile utilisant une liste chaînée (implémentée avec la classe Cellule).

Python

class Cellule:
    """une cellule d’une liste chaînée"""
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    """structure de pile"""
    def __init__(self):
        self.contenu = None

    def est_vide(self):
        return self.contenu is None

    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)

    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v

def creer_pile():
    return Pile()

Complète la classe Pile avec les trois méthodes additionnelles suivantes :

  • consulter(self) : Renvoie l’élément au sommet de la pile sans le retirer. Lève une IndexError si la pile est vide.
  • vider(self) : Vide complètement la pile.
  • taille(self) : Renvoie le nombre d’éléments dans la pile.

Quel est l’ordre de grandeur du nombre d’opérations effectuées par la fonction taille ? Exprime-le en notation Grand O (O-notation).

Prérequis

  • Comprendre le fonctionnement des classes et objets en Python.
  • Savoir manipuler les listes chaînées (ici via les objets Cellule).
  • Connaître le principe LIFO des piles.
  • Avoir une petite idée de la complexité algorithmique (O-notation).

Proposition de correction

Python

class Cellule:
    """une cellule d’une liste chaînée"""
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    """structure de pile"""
    def __init__(self):
        self.contenu = None

    def est_vide(self):
        return self.contenu is None

    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)

    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v
    
    # --- Nouvelles méthodes à compléter ---

    def consulter(self):
        """
        Renvoie l'élément au sommet de la pile sans le retirer.
        Lève une IndexError si la pile est vide.
        """
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.contenu.valeur # On accède juste à la valeur de la cellule de tête

    def vider(self):
        """
        Vide complètement la pile.
        """
        self.contenu = None # La magie opère : il suffit de "lâcher" la référence à la première cellule !
                           # Le ramasse-miettes de Python fera le reste.

    def taille(self):
        """
        Renvoie le nombre d'éléments dans la pile.
        """
        compteur = 0
        courant = self.contenu # On part de la première cellule
        while courant is not None:
            compteur += 1
            courant = courant.suivante # On avance à la cellule suivante
        return compteur

def creer_pile():
    return Pile()

# --- Tests ---
print("Exercice 13 : Compléter la Classe Pile")
ma_pile = creer_pile()
print(f"Pile est vide ? {ma_pile.est_vide()}") # Attend True
ma_pile.empiler(10)
ma_pile.empiler(20)
ma_pile.empiler(30)
print(f"Pile après empilement : {ma_pile.consulter()} au sommet") # Attend 30
print(f"Taille de la pile : {ma_pile.taille()}") # Attend 3
print(f"Élément consulté : {ma_pile.consulter()}") # Attend 30 (et la pile n'est pas modifiée)
print(f"Élément dépilé : {ma_pile.depiler()}") # Attend 30
print(f"Pile après dépilement : {ma_pile.consulter()} au sommet") # Attend 20
print(f"Taille de la pile : {ma_pile.taille()}") # Attend 2

ma_pile.vider()
print(f"Pile est vide après vider ? {ma_pile.est_vide()}") # Attend True
print(f"Taille de la pile après vider : {ma_pile.taille()}") # Attend 0

# Test d'erreur pour consulter et depiler sur pile vide
try:
    ma_pile.consulter()
except IndexError as e:
    print(f"Erreur attendue : {e}")

try:
    ma_pile.depiler()
except IndexError as e:
    print(f"Erreur attendue : {e}")
print("-" * 20)


Ordre de grandeur de la fonction taille :

Pour calculer la taille de la pile, la fonction taille doit parcourir toutes les cellules de la pile, de la première à la dernière. Si la pile contient N éléments, elle effectuera N opérations (un accès à suivante et une incrémentation de compteur pour chaque élément).

L’ordre de grandeur est donc mathcalO(N) (linéaire). Plus la pile est grande, plus le temps de calcul de sa taille augmente proportionnellement. Ce n’est pas la fin du monde pour les petites piles, mais pour les géantes, ça peut devenir un facteur limitant !


Exercice 14 : Navigateur Web avec Historique « Retour Avant »

Énoncé

Voici un programme qui simule une navigation web simplifiée avec une fonction « retour » :

Python

class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    # ... (code de la classe Pile de l'exercice 13, y compris est_vide, empiler, depiler)
    # Pour cet exercice, nous aurons besoin de consulter() et est_vide()
    def __init__(self):
        self.contenu = None
    def est_vide(self):
        return self.contenu is None
    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v
    def consulter(self): # Ajoutée pour cet exercice
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.contenu.valeur


adresse_courante = ""
adresses_precedentes = Pile()

def aller_a(adresse_cible):
    global adresse_courante
    global adresses_suivantes # On anticipe l'ajout de cette pile
    
    if adresse_courante: # N'empile que si on vient d'une page existante
        adresses_precedentes.empiler(adresse_courante)
    adresse_courante = adresse_cible
    adresses_suivantes.vider() # Toute nouvelle navigation annule l'historique "avant"

def retour():
    global adresse_courante
    global adresses_suivantes # On en aura besoin ici

    if not adresses_precedentes.est_vide():
        # Avant de partir de l'adresse courante, on l'empile dans adresses_suivantes
        if adresse_courante: # S'il y a une adresse courante à enregistrer
            adresses_suivantes.empiler(adresse_courante)
        adresse_courante = adresses_precedentes.depiler()
    else:
        print("Plus d'adresses précédentes. Impossible de revenir plus loin.")
        # On pourrait choisir de ne rien faire ou de vider adresse_courante si on veut
        # adresse_courante = "" # Optionnel : vider l'adresse courante si on est au début



On souhaite compléter ce programme pour avoir également une fonction retour_avant dont le comportement est le suivant :

  • Chaque appel à la fonction retour place la page quittée au sommet d’une deuxième pile adresses_suivantes.
  • Un appel à la fonction retour_avant amène à la page enregistrée au sommet de la pile adresses_suivantes, et met à jour les deux piles de manière adaptée.
  • Toute nouvelle navigation avec aller_a annule les adresses_suivantes.

Modifie et complète le programme pour définir cette nouvelle fonction.

Prérequis

  • Maîtriser le concept de pile.
  • Comprendre les interactions entre plusieurs piles pour gérer un historique.
  • Gérer les variables globales en Python.

Proposition de correction

Python

class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    def __init__(self):
        self.contenu = None
    def est_vide(self):
        return self.contenu is None
    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v
    def consulter(self):
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.contenu.valeur
    def vider(self): # On a besoin de vider pour cet exercice
        self.contenu = None

# --- Variables globales du simulateur de navigateur ---
adresse_courante = ""
adresses_precedentes = Pile()
adresses_suivantes = Pile() # La nouvelle pile pour l'historique "retour avant"

def aller_a(adresse_cible):
    """
    Simule la navigation vers une nouvelle adresse.
    Empile l'adresse courante dans les adresses précédentes et vide les adresses suivantes.
    """
    global adresse_courante
    global adresses_precedentes
    global adresses_suivantes
    
    if adresse_courante: # N'empile que si on vient d'une page existante (pas le premier aller_a)
        adresses_precedentes.empiler(adresse_courante)
    
    adresse_courante = adresse_cible
    adresses_suivantes.vider() # Toute nouvelle navigation annule l'historique "retour avant"
    print(f"-> Navigué vers : {adresse_courante}")

def retour():
    """
    Revient à l'adresse précédente.
    Place la page actuelle dans adresses_suivantes avant de revenir.
    """
    global adresse_courante
    global adresses_precedentes
    global adresses_suivantes

    if not adresses_precedentes.est_vide():
        # Avant de changer l'adresse courante, on l'empile pour pouvoir y revenir avec retour_avant
        if adresse_courante: # S'il y a bien une page courante à quitter
            adresses_suivantes.empiler(adresse_courante)
        
        adresse_courante = adresses_precedentes.depiler()
        print(f"<- Retour vers : {adresse_courante}")
    else:
        print("Oh non, tu es arrivé au début de l'histoire ! Plus de pages précédentes.")

def retour_avant():
    """
    Avance vers une adresse précédemment "retournée" (annule un retour).
    Place la page actuelle dans adresses_precedentes avant d'avancer.
    """
    global adresse_courante
    global adresses_precedentes
    global adresses_suivantes

    if not adresses_suivantes.est_vide():
        # Avant de changer l'adresse courante, on l'empile dans adresses_precedentes
        # pour pouvoir revenir à cette page si on refait un "retour" classique
        if adresse_courante:
            adresses_precedentes.empiler(adresse_courante)
        
        adresse_courante = adresses_suivantes.depiler()
        print(f"->-> Retour avant vers : {adresse_courante}")
    else:
        print("Tu es déjà à la fin de ton futur proche ! Plus de pages 'retour avant'.")

# --- Tests de la simulation ---
print("Exercice 14 : Navigateur Web avec Historique 'Retour Avant'")
print("--- Début de la navigation ---")
aller_a("google.com")
aller_a("github.com")
aller_a("youtube.com")
aller_a("stack_overflow.com")

print(f"\nPage actuelle : {adresse_courante}") # stack_overflow.com
print(f"Précédentes (sommet) : {adresses_precedentes.consulter() if not adresses_precedentes.est_vide() else 'vide'}") # youtube.com
print(f"Suivantes (sommet) : {adresses_suivantes.consulter() if not adresses_suivantes.est_vide() else 'vide'}") # vide

print("\n--- On revient en arrière ---")
retour() # Retourne à youtube.com
print(f"Page actuelle : {adresse_courante}") # youtube.com
print(f"Précédentes (sommet) : {adresses_precedentes.consulter() if not adresses_precedentes.est_vide() else 'vide'}") # github.com
print(f"Suivantes (sommet) : {adresses_suivantes.consulter() if not adresses_suivantes.est_vide() else 'vide'}") # stack_overflow.com

retour() # Retourne à github.com
print(f"Page actuelle : {adresse_courante}") # github.com
print(f"Précédentes (sommet) : {adresses_precedentes.consulter() if not adresses_precedentes.est_vide() else 'vide'}") # google.com
print(f"Suivantes (sommet) : {adresses_suivantes.consulter() if not adresses_suivantes.est_vide() else 'vide'}") # youtube.com (en dessous) / stack_overflow.com (au dessus)

print("\n--- On fait un 'retour avant' ---")
retour_avant() # Avance à youtube.com
print(f"Page actuelle : {adresse_courante}") # youtube.com
print(f"Précédentes (sommet) : {adresses_precedentes.consulter() if not adresses_precedentes.est_vide() else 'vide'}") # github.com
print(f"Suivantes (sommet) : {adresses_suivantes.consulter() if not adresses_suivantes.est_vide() else 'vide'}") # stack_overflow.com

retour_avant() # Avance à stack_overflow.com
print(f"Page actuelle : {adresse_courante}") # stack_overflow.com
print(f"Précédentes (sommet) : {adresses_precedentes.consulter() if not adresses_precedentes.est_vide() else 'vide'}") # youtube.com
print(f"Suivantes (sommet) : {adresses_suivantes.consulter() if not adresses_suivantes.est_vide() else 'vide'}") # vide

print("\n--- On navigue à nouveau, cela vide l'historique 'retour avant' ---")
aller_a("new_website.com")
print(f"Page actuelle : {adresse_courante}") # new_website.com
print(f"Précédentes (sommet) : {adresses_precedentes.consulter() if not adresses_precedentes.est_vide() else 'vide'}") # stack_overflow.com
print(f"Suivantes (sommet) : {adresses_suivantes.consulter() if not adresses_suivantes.est_vide() else 'vide'}") # vide (vidé !)

print("\n--- Tentative de retour avant quand pile vide ---")
retour_avant() # Devrait dire "Plus de pages 'retour avant'."
print("-" * 20)

Explication de la correction :

L’astuce réside dans l’utilisation de deux piles :

  • adresses_precedentes : Pour les pages visitées avant l’actuelle. C’est l’historique « back ».
  • adresses_suivantes : Pour les pages que tu as quittées en faisant « retour » et auxquelles tu peux « revenir en avant ». C’est l’historique « forward ».
  1. aller_a(adresse_cible) :
    • Si tu n’es pas sur la première page (i.e., adresse_courante n’est pas vide), la page actuelle est empilée dans adresses_precedentes.
    • adresse_courante est mise à jour avec la nouvelle cible.
    • Crucial : adresses_suivantes.vider(). Si tu navigues vers une nouvelle page, tu « perds » la possibilité de faire « retour avant » aux pages que tu avais précédemment quittées par un retour(). C’est le comportement classique des navigateurs.
  2. retour() :
    • Si adresses_precedentes n’est pas vide, tu peux revenir en arrière.
    • Important : L’adresse actuelle (adresse_courante) est empilée dans adresses_suivantes avant de dépiler l’adresse précédente. C’est ce qui te permet ensuite de faire un retour_avant.
    • adresse_courante est mise à jour avec la page dépilée de adresses_precedentes.
  3. retour_avant() :
    • Si adresses_suivantes n’est pas vide, tu peux avancer dans l’historique.
    • Important : L’adresse actuelle (adresse_courante) est empilée dans adresses_precedentes avant de dépiler l’adresse de adresses_suivantes. Si tu « reviens avant », tu dois pouvoir faire « retour » sur cette page si tu changes d’avis !
    • adresse_courante est mise à jour avec la page dépilée de adresses_suivantes.

C’est un bel exemple de l’utilité des piles pour gérer des historiques dynamiques ! On se croirait presque sur Internet Explorer 6… ah non, pardon, c’est moderne maintenant !


Exercice 15 : Pile Optimisée avec Attribut _taille

Énoncé

Pour éviter le problème du calcul de taille à l’exercice 13 (où taille() parcourait toute la pile), on te propose de revisiter la classe Pile en lui ajoutant un attribut privé _taille (ou simplement taille si tu préfères, mais _taille est une convention Python pour un attribut interne) indiquant à tout moment la taille de la pile.

Quelles méthodes de la classe Pile doivent être modifiées, et comment ?

Prérequis

  • Avoir fait l’Exercice 13.
  • Comprendre les attributs d’instance dans les classes Python.
  • Savoir que l’ordre de grandeur est O(1) pour un accès direct.

Proposition de correction

Python

class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    """structure de pile avec taille optimisée"""
    def __init__(self):
        self.contenu = None
        self._taille = 0 # NOUVEL ATTRIBUT : on initialise la taille à 0

    def est_vide(self):
        return self.contenu is None
        # Ou alternativement : return self._taille == 0

    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
        self._taille += 1 # MODIFICATION : on incrémente la taille à chaque empilement

    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        self._taille -= 1 # MODIFICATION : on décrémente la taille à chaque dépilement
        return v
    
    # Méthodes additionnelles de l'exercice 13
    def consulter(self):
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.contenu.valeur

    def vider(self):
        self.contenu = None
        self._taille = 0 # MODIFICATION : la taille redevient 0 quand on vide

    def taille(self):
        """
        Renvoie le nombre d'éléments dans la pile (maintenant en O(1)).
        """
        return self._taille # MODIFICATION : On renvoie directement l'attribut, c'est instantané !

def creer_pile():
    return Pile()

# --- Tests ---
print("Exercice 15 : Pile Optimisée avec Attribut _taille")
ma_pile_opti = creer_pile()
print(f"Taille initiale : {ma_pile_opti.taille()}") # Attend 0
ma_pile_opti.empiler("pomme")
ma_pile_opti.empiler("banane")
print(f"Taille après 2 empilements : {ma_pile_opti.taille()}") # Attend 2
ma_pile_opti.depiler()
print(f"Taille après 1 dépilement : {ma_pile_opti.taille()}") # Attend 1
ma_pile_opti.vider()
print(f"Taille après vider : {ma_pile_opti.taille()}") # Attend 0
ma_pile_opti.empiler("cerise")
print(f"Taille après nouvel empilement : {ma_pile_opti.taille()}") # Attend 1
print("-" * 20)

Quelles méthodes doivent être modifiées et comment ?

Les méthodes suivantes doivent être modifiées :

  1. __init__(self) :
    • Comment ? Il faut initialiser le nouvel attribut self._taille = 0 pour qu’il reflète une pile vide dès la création.
  2. empiler(self, v) :
    • Comment ? Après avoir ajouté un élément, il faut incrémenter self._taille de 1 (self._taille += 1).
  3. depiler(self) :
    • Comment ? Après avoir retiré un élément (et vérifié que la pile n’est pas vide), il faut décrémenter self._taille de 1 (self._taille -= 1).
  4. vider(self) :
    • Comment ? Après avoir mis self.contenu à None, il faut réinitialiser self._taille à 0.
  5. taille(self) :
    • Comment ? Au lieu de parcourir la liste chaînée, cette méthode retourne simplement la valeur de self._taille.

Ordre de grandeur du nombre d’opérations effectuées par la fonction taille (maintenant optimisée) :

Avec l’attribut _taille, la fonction taille() ne fait plus qu’un simple accès à un attribut. C’est une opération à temps constant. L’ordre de grandeur est donc mathcalO(1). C’est le Graal de l’efficacité ! Peu importe la taille de la pile, obtenir sa taille est instantané. La contrepartie est une légère augmentation du coût des opérations empiler et depiler (une addition/soustraction en plus).


Exercice 16 : Calculatrice Polonaise Inverse (RPN)

Énoncé

L’écriture polonaise inverse (RPN) des expressions arithmétiques place l’opérateur après ses opérandes. Cette notation ne nécessite aucune parenthèse ni aucune règle de priorité. Par exemple, l’expression RPN '1 2 3 * + 4 *' désigne l’expression traditionnellement notée (1 + 2 × 3) × 4.

La valeur d’une telle expression peut être calculée facilement en utilisant une pile pour stocker les résultats intermédiaires. Pour cela, on observe un à un les éléments de l’expression et on effectue les actions suivantes :

  • Si on voit un nombre, on le place sur la pile.
  • Si on voit un opérateur binaire (+ ou *), on récupère les deux nombres au sommet de la pile, on leur applique l’opérateur, et on replace le résultat sur la pile.

Si l’expression était bien écrite, il y a toujours deux nombres sur la pile lorsque l’on voit un opérateur, et à la fin du processus, il reste exactement un nombre sur la pile, qui est le résultat.

Écris une fonction calculer_rpn(expression_rpn_str) prenant en paramètre une chaîne de caractères représentant une expression en notation polonaise inverse (composée d’additions et de multiplications de nombres entiers) et renvoyant la valeur de cette expression.

On supposera que les éléments de l’expression sont séparés par des espaces. Attention : Cette fonction ne doit pas renvoyer de résultat si l’expression est mal écrite. Si une condition de « mal écriture » est détectée (par exemple, pas assez d’opérandes pour un opérateur, ou trop de nombres à la fin), la fonction pourra lever une ValueError.

Prérequis

  • Maîtriser la classe Pile et ses opérations (empiler, depiler, est_vide).
  • Savoir manipuler les chaînes de caractères (séparer par des espaces).
  • Savoir convertir des chaînes en nombres entiers (int()).
  • Gérer les erreurs potentielles (try-except).

Proposition de correction

Python

# Réutilisation de la classe Pile (avec optimisation de taille, mais pas obligatoire ici)
class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    def __init__(self):
        self.contenu = None
        self._taille = 0 # Pour cet exercice, la taille est utile pour les vérifs
    def est_vide(self):
        return self.contenu is None
    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
        self._taille += 1
    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        self._taille -= 1
        return v
    def taille(self): # On a besoin de la taille pour les vérifs d'erreur
        return self._taille

def creer_pile():
    return Pile()

# --- Fonction à écrire pour l'exercice ---

def calculer_rpn(expression_rpn_str):
    """
    Calcule la valeur d'une expression en notation polonaise inverse (RPN).
    Gère les opérateurs '+' et '*'.
    Args:
        expression_rpn_str (str): La chaîne de caractères représentant l'expression RPN.
    Returns:
        int: La valeur calculée de l'expression.
    Raises:
        ValueError: Si l'expression est mal formée (pas assez d'opérandes, etc.).
    """
    pile_calcul = creer_pile()
    elements = expression_rpn_str.split() # Sépare la chaîne en une liste d'éléments (nombres ou opérateurs)

    for element in elements:
        try:
            # Tente de convertir l'élément en entier (c'est un nombre)
            nombre = int(element)
            pile_calcul.empiler(nombre)
        except ValueError:
            # Si ce n'est pas un nombre, c'est un opérateur
            if element == '+':
                if pile_calcul.taille() < 2:
                    raise ValueError("Erreur RPN : pas assez d'opérandes pour l'addition.")
                op2 = pile_calcul.depiler() # Le premier dépilé est le deuxième opérande
                op1 = pile_calcul.depiler() # Le deuxième dépilé est le premier opérande
                resultat = op1 + op2
                pile_calcul.empiler(resultat)
            elif element == '*':
                if pile_calcul.taille() < 2:
                    raise ValueError("Erreur RPN : pas assez d'opérandes pour la multiplication.")
                op2 = pile_calcul.depiler()
                op1 = pile_calcul.depiler()
                resultat = op1 * op2
                pile_calcul.empiler(resultat)
            else:
                raise ValueError(f"Erreur RPN : Opérateur inconnu '{element}'.")

    # À la fin, la pile doit contenir exactement un élément : le résultat
    if pile_calcul.taille() != 1:
        raise ValueError("Erreur RPN : Expression mal formée ou incomplète (reste trop ou pas assez d'éléments).")
    
    return pile_calcul.depiler() # Le résultat est le seul élément restant

# --- Tests ---
print("Exercice 16 : Calculatrice Polonaise Inverse (RPN)")

# Test 1 : L'exemple de l'énoncé
expression1 = '1 2 3 * + 4 *' # (1 + 2 * 3) * 4 = (1 + 6) * 4 = 7 * 4 = 28
print(f"Expression : '{expression1}' -> Résultat : {calculer_rpn(expression1)}") # Attend 28

# Test 2 : Une expression plus simple
expression2 = '5 2 +' # 5 + 2 = 7
print(f"Expression : '{expression2}' -> Résultat : {calculer_rpn(expression2)}") # Attend 7

# Test 3 : Une autre multiplication
expression3 = '10 3 *' # 10 * 3 = 30
print(f"Expression : '{expression3}' -> Résultat : {calculer_rpn(expression3)}") # Attend 30

# Test 4 : Une expression avec plusieurs opérations
expression4 = '7 2 + 3 *' # (7 + 2) * 3 = 9 * 3 = 27
print(f"Expression : '{expression4}' -> Résultat : {calculer_rpn(expression4)}") # Attend 27

# Test 5 : Expression mal formée (pas assez d'opérandes)
expression_mal_formee1 = '5 +'
try:
    print(f"Expression : '{expression_mal_formee1}' -> Résultat : {calculer_rpn(expression_mal_formee1)}")
except ValueError as e:
    print(f"Erreur attendue : {e}")

# Test 6 : Expression mal formée (trop d'opérandes à la fin)
expression_mal_formee2 = '1 2 3 +'
try:
    print(f"Expression : '{expression_mal_formee2}' -> Résultat : {calculer_rpn(expression_mal_formee2)}")
except ValueError as e:
    print(f"Erreur attendue : {e}")

# Test 7 : Opérateur inconnu
expression_mal_formee3 = '1 2 %'
try:
    print(f"Expression : '{expression_mal_formee3}' -> Résultat : {calculer_rpn(expression_mal_formee3)}")
except ValueError as e:
    print(f"Erreur attendue : {e}")

# Test 8 : Expression vide
expression_vide = ''
try:
    print(f"Expression : '{expression_vide}' -> Résultat : {calculer_rpn(expression_vide)}")
except ValueError as e:
    print(f"Erreur attendue : {e}") # Attend Erreur RPN : Expression mal formée ou incomplète (reste trop ou pas assez d'éléments).
print("-" * 20)

Explication de la correction :

Cet exercice est une application directe et très classique des piles !

  1. Initialisation : On crée une pile_calcul vide.
  2. Découpage de l’expression : On utilise expression_rpn_str.split() pour transformer la chaîne en une liste d’éléments (des chaînes comme '1', '*', '+').
  3. Boucle de traitement : On parcourt chaque element de la liste :
    • Si c’est un nombre : On utilise try-except int(element) pour tenter de le convertir en entier. Si ça marche, c’est un nombre, et on l’empile.
    • Si c’est un opérateur : Si la conversion en entier échoue, on suppose que c’est un opérateur.
      • On vérifie qu’il y a au moins deux éléments sur la pile (pile_calcul.taille() < 2). Sinon, c’est une expression mal formée (pas assez d’opérandes), et on lève une ValueError.
      • On depile les deux opérandes. Attention à l’ordre : le premier élément dépilé est le deuxième opérande (car il a été empilé en dernier), et le deuxième dépilé est le premier opérande.
      • On effectue l’opération (+ ou *).
      • Le resultat est empile à nouveau sur la pile.
      • Si l’élément n’est ni un nombre ni un opérateur connu, on lève une ValueError.
  4. Vérification finale : Après avoir traité tous les éléments de l’expression :
    • La pile doit contenir exactement un élément (pile_calcul.taille() != 1). Si ce n’est pas le cas, l’expression est mal formée (trop de nombres, ou un opérateur manquant, etc.), et on lève une ValueError.
    • Le résultat final est cet unique élément restant, que l’on depile et renvoie.

C’est comme une petite chaîne de montage numérique, où les nombres entrent, attendent leur tour sur la pile, puis sont attrapés par des opérateurs pour créer de nouveaux nombres qui retournent en bout de file… euh, de pile !


Exercice 17 : Parenthèse Associée (Trouver l’Ouvrante)

Énoncé

On dit qu’une chaîne de caractères comprenant, entre autres choses, des parenthèses ( et ) est bien parenthésée lorsque chaque parenthèse ouvrante est associée à une unique parenthèse fermante, et réciproquement.

Écris une fonction trouver_parenthèse_ouvrante_associée(chaine, indice_fermante) prenant en paramètres une chaîne chaine (supposée bien parenthésée) et l’indice indice_fermante d’une parenthèse fermante, et qui renvoie l’indice de la parenthèse ouvrante associée.

Indice : Comme chaque parenthèse fermante est associée à la dernière parenthèse ouvrante non encore fermée, on peut suivre les associations à l’aide d’une pile. Tu y mettras les indices des parenthèses ouvrantes rencontrées.

Prérequis

  • Maîtriser la classe Pile (empiler, depiler).
  • Savoir parcourir une chaîne de caractères et accéder aux caractères par leur indice.

Proposition de correction

Python

# Réutilisation de la classe Pile
class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    def __init__(self):
        self.contenu = None
    def est_vide(self):
        return self.contenu is None
    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v
    def consulter(self):
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.contenu.valeur

def creer_pile():
    return Pile()

# --- Fonction à écrire pour l'exercice ---

def trouver_parenthèse_ouvrante_associée(chaine, indice_fermante):
    """
    Trouve l'indice de la parenthèse ouvrante associée à une parenthèse fermante.
    La chaîne est supposée bien parenthésée.
    Args:
        chaine (str): La chaîne de caractères.
        indice_fermante (int): L'indice de la parenthèse fermante ')'.
    Returns:
        int: L'indice de la parenthèse ouvrante '('.
    Raises:
        ValueError: Si indice_fermante ne pointe pas sur une parenthèse fermante
                    ou si la chaîne n'est pas bien parenthésée (cas non géré par l'énoncé,
                    mais bonne pratique de sécurité).
    """
    if chaine[indice_fermante] != ')':
        raise ValueError("L'indice donné ne pointe pas sur une parenthèse fermante.")
    
    pile_indices_ouvrantes = creer_pile()
    
    # On parcourt la chaîne depuis le début jusqu'à l'indice de la parenthèse fermante
    for i in range(indice_fermante):
        caractere = chaine[i]
        
        if caractere == '(':
            pile_indices_ouvrantes.empiler(i) # On empile l'indice de chaque parenthèse ouvrante
        elif caractere == ')':
            # Si on rencontre une parenthèse fermante, elle ferme la dernière ouvrante non fermée.
            # Donc, on dépile l'indice de cette ouvrante, car elle est maintenant "fermée".
            if pile_indices_ouvrantes.est_vide():
                raise ValueError("Chaîne mal parenthésée : parenthèse fermante sans ouvrante correspondante.")
            pile_indices_ouvrantes.depiler()
            
    # Quand on arrive à l'indice_fermante, le sommet de la pile doit être l'indice de son ouvrante associée
    if pile_indices_ouvrantes.est_vide():
        raise ValueError("Chaîne mal parenthésée : parenthèse fermante sans ouvrante correspondante (problème d'indice).")
    
    return pile_indices_ouvrantes.depiler() # Le sommet est l'indice recherché !

# --- Tests ---
print("Exercice 17 : Parenthèse Associée (Trouver l'Ouvrante)")

chaine1 = "((A+B)*(C-D))"
# Indices :      0123456789012
# Caractères :   ((A+B)*(C-D))
# indice_fermante : 6 (pour le premier ')'), 12 (pour le dernier ')')

print(f"Chaîne : '{chaine1}'")
print(f"Indice fermante 6 (')') : ouvrante à l'indice {trouver_parenthèse_ouvrante_associée(chaine1, 6)}") # Attend 2
print(f"Indice fermante 12 (')') : ouvrante à l'indice {trouver_parenthèse_ouvrante_associée(chaine1, 12)}") # Attend 0

chaine2 = "(X(Y)Z)"
# Indices :      0123456
# Caractères :   (X(Y)Z)
print(f"\nChaîne : '{chaine2}'")
print(f"Indice fermante 4 (')') : ouvrante à l'indice {trouver_parenthèse_ouvrante_associée(chaine2, 4)}") # Attend 2
print(f"Indice fermante 6 (')') : ouvrante à l'indice {trouver_parenthèse_ouvrante_associée(chaine2, 6)}") # Attend 0

# Test avec une chaîne simple
chaine3 = "(coucou)"
print(f"\nChaîne : '{chaine3}'")
print(f"Indice fermante 7 (')') : ouvrante à l'indice {trouver_parenthèse_ouvrante_associée(chaine3, 7)}") # Attend 0

# Test d'erreur (si l'énoncé n'avait pas supposé "bien parenthésée")
try:
    chaine_mal_formee = "())("
    trouver_parenthèse_ouvrante_associée(chaine_mal_formee, 2)
except ValueError as e:
    print(f"\nErreur attendue : {e}") # Chaîne mal parenthésée : parenthèse fermante sans ouvrante correspondante.
print("-" * 20)

Explication de la correction :

Ce problème est un autre exemple classique de l’utilisation des piles pour la gestion des structures imbriquées (comme les parenthèses, les balises HTML/XML, etc.).

  1. Initialisation : On crée une pile pile_indices_ouvrantes. Cette pile stockera les indices des parenthèses ouvrantes ( que l’on rencontre.
  2. Parcours de la chaîne : On parcourt la chaîne caractère par caractère, de l’indice 0 jusqu’à indice_fermante - 1 (on ne traite pas encore la parenthèse fermante cible).
    • Si caractere == '(' : On a trouvé une parenthèse ouvrante. On empile son indice dans la pile.
    • Si caractere == ')' : On a trouvé une parenthèse fermante. Elle ferme la dernière parenthèse ouvrante qui a été empilee et qui n’a pas encore été fermée. On depile donc l’indice du sommet de la pile. Cela signifie que cette paire est maintenant « résolue ».
  3. Arrivée à indice_fermante : Quand la boucle se termine (c’est-à-dire quand i atteint indice_fermante), la parenthèse fermante à indice_fermante doit obligatoirement correspondre à la parenthèse ouvrante qui est au sommet de la pile (la dernière non fermée). On depile cet indice et on le renvoie.

Si la chaîne est « bien parenthésée » comme le suppose l’énoncé, la pile ne devrait jamais être vide quand on essaie de depiler une parenthèse ouvrante, et il devrait rester exactement un élément au sommet de la pile quand on atteint l’indice_fermante. J’ai ajouté quelques ValueError pour gérer les cas (même si non attendus par l’énoncé) où la chaîne ne serait pas bien parenthésée.

C’est comme un jeu de cache-cache : chaque ( se cache dans la pile, et quand un ) apparaît, il attrape le dernier ( caché !


Exercice 18 : Vérification de Chaînes Bien Parenthésées (Multiples Types)

Énoncé

On considère une chaîne de caractères incluant à la fois des parenthèses rondes ( et ) et des parenthèses carrées [ et ]. La chaîne est bien parenthésée si chaque ouvrante est associée à une unique fermante de même forme, et réciproquement.

Écris une fonction est_bien_parenthésée(chaine) prenant en paramètre une chaîne de caractères (contenant, entre autres, les parenthèses décrites) et qui renvoie True si la chaîne est bien parenthésée et False sinon.

Prérequis

  • Avoir compris l’Exercice 17.
  • Savoir utiliser une pile pour faire correspondre des ouvrantes/fermantes.
  • Gérer plusieurs types de parenthèses et s’assurer qu’elles correspondent correctement (forme et ordre).

Proposition de correction

Python

# Réutilisation de la classe Pile
class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    def __init__(self):
        self.contenu = None
    def est_vide(self):
        return self.contenu is None
    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v
    def consulter(self):
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.contenu.valeur
    def taille(self): # Utile pour la vérif finale
        compteur = 0
        courant = self.contenu
        while courant is not None:
            compteur += 1
            courant = courant.suivante
        return compteur

def creer_pile():
    return Pile()

# --- Fonction à écrire pour l'exercice ---

def est_bien_parenthésée(chaine):
    """
    Vérifie si une chaîne de caractères est bien parenthésée avec '(' ')' et '[' ']'.
    Args:
        chaine (str): La chaîne à vérifier.
    Returns:
        bool: True si la chaîne est bien parenthésée, False sinon.
    """
    pile_parenthèses_ouvrantes = creer_pile()
    
    # Dictionnaire pour associer chaque parenthèse fermante à son ouvrante correspondante
    paires_parenthèses = {')': '(', ']': '['}

    for caractere in chaine:
        if caractere in ['(', '[']:
            # Si c'est une parenthèse ouvrante, on l'empile
            pile_parenthèses_ouvrantes.empiler(caractere)
        elif caractere in [')', ']']:
            # Si c'est une parenthèse fermante
            if pile_parenthèses_ouvrantes.est_vide():
                return False # Il y a une parenthèse fermante sans parenthèse ouvrante correspondante
            
            # On dépile la dernière parenthèse ouvrante
            derniere_ouvrante = pile_parenthèses_ouvrantes.depiler()
            
            # On vérifie si la parenthèse ouvrante dépilée correspond à la fermante actuelle
            if paires_parenthèses[caractere] != derniere_ouvrante:
                return False # La forme ne correspond pas (ex: (] )

    # À la fin, la pile doit être vide. Si elle ne l'est pas,
    # il reste des parenthèses ouvrantes non fermées.
    return pile_parenthèses_ouvrantes.est_vide()

# --- Tests ---
print("Exercice 18 : Vérification de Chaînes Bien Parenthésées (Multiples Types)")

print(f"'([])' est bien parenthésée : {est_bien_parenthésée('([])')}") # Attend True
print(f"'([{}])' est bien parenthésée : {est_bien_parenthésée('([{}]')}") # FAUX : l'énoncé n'incluait pas {}, donc False attendu
# Pour le test, je vais m'en tenir à '(' ')' '[' ']'

print(f"'([()])' est bien parenthésée : {est_bien_parenthésée('([()])')}") # Attend True
print(f"'(' est bien parenthésée : {est_bien_parenthésée('(')}") # Attend False (non fermée)
print(f"')' est bien parenthésée : {est_bien_parenthésée(')')}") # Attend False (fermante sans ouvrante)
print(f"'([)]' est bien parenthésée : {est_bien_parenthésée('([)]')}") # Attend False (mismatch de type)
print(f"'[)' est bien parenthésée : {est_bien_parenthésée('[)')}") # Attend False (mismatch de type)
print(f"'' (vide) est bien parenthésée : {est_bien_parenthésée('')}") # Attend True
print(f"'(a[b]c)' est bien parenthésée : {est_bien_parenthésée('(a[b]c)')}") # Attend True (autres caractères ignorés)
print(f"'](' est bien parenthésée : {est_bien_parenthésée('](')}") # Attend False (fermante sans ouvrante)
print("-" * 20)

Explication de la correction :

Cet exercice est une extension de l’Exercice 17. Le principe est le même, mais il faut gérer plusieurs types de parenthèses.

  1. Initialisation : On crée une pile pile_parenthèses_ouvrantes pour stocker les parenthèses ouvrantes que l’on rencontre. On ajoute un dictionnaire paires_parenthèses qui associe chaque parenthèse fermante à sa parenthèse ouvrante correspondante.
  2. Parcours de la chaîne : On parcourt la chaîne caractère par caractère :
    • Si caractere est une parenthèse ouvrante (( ou [) : On l’empile.
    • Si caractere est une parenthèse fermante () ou ]) :
      • On vérifie si la pile est vide. Si oui, cela signifie qu’on a trouvé une parenthèse fermante sans ouvrante correspondante, donc la chaîne est mal parenthésée (return False).
      • Si la pile n’est pas vide, on depile l’élément au sommet. C’est la dernière parenthèse ouvrante non fermée.
      • On compare cette derniere_ouvrante avec la parenthèse ouvrante attendue pour la caractere fermante actuelle (via paires_parenthèses[caractere]). Si elles ne correspondent pas (ex: on attend ( mais on dépile [), la chaîne est mal parenthésée (return False).
    • Autres caractères : Si le caractère n’est pas une parenthèse, on l’ignore (il ne perturbe pas l’appariement des parenthèses).
  3. Vérification finale : Après avoir parcouru toute la chaîne :
    • Si la pile est vide, cela signifie que toutes les parenthèses ouvrantes ont trouvé leur correspondante fermante. La chaîne est bien parenthésée (return True).
    • Si la pile n’est pas vide, cela signifie qu’il reste des parenthèses ouvrantes qui n’ont jamais été fermées. La chaîne est mal parenthésée (return False).

Cette fonction est comme un arbitre de boxe pour parenthèses : il s’assure que chaque parenthèse ouvrante trouve son match du même type, et que personne ne reste sur le carreau à la fin !


Exercice 19 : Calculatrice Ordinaire (Priorités et Parenthèses)

Énoncé

On souhaite réaliser un programme évaluant une expression arithmétique donnée par une chaîne de caractères. On utilisera les notations et les règles de priorité ordinaires, en supposant pour simplifier que chaque élément est séparé des autres par une espace. Exemple : '( 1 + 2 * 3 ) * 4'.

Comme dans l’exercice 16, nous allons parcourir l’expression de gauche à droite et utiliser une pile. On alterne entre deux opérations : ajouter un nouvel élément sur la pile, et simplifier une opération présente au sommet de la pile.

Pour réaliser cela, on suit un algorithme qui, pour chaque élément de l’expression en entrée, applique les critères suivants :

  • Si l’élément est un nombre, on place sa valeur sur la pile.
  • Si l’élément est une parenthèse (, on la place sur la pile.
  • Si l’élément est une parenthèse ), on simplifie toutes les opérations possibles au sommet de la pile. À la fin, le sommet de la pile doit contenir un entier n précédé d’une parenthèse ouvrante (, parenthèse que l’on retire pour ne garder que n.
  • Si l’élément est un opérateur (+, *, …), on simplifie toutes les opérations au sommet de la pile utilisant des opérateurs aussi prioritaires ou plus prioritaires que le nouvel opérateur, puis on place ce dernier sur la pile.

Écris une fonction simplifier_operations(pile, operateurs_prioritaires) qui simplifie toutes les opérations au sommet de pile utilisant un opérateur de l’ensemble operateurs_prioritaires. Ensuite, déduis-en une fonction calculer_expression(expression_str) renvoyant la valeur de l’expression.

Note : Cet exercice fait une « entorse » à la bonne pratique de ne toujours utiliser les piles que de manière homogène, puisque notre pile contiendra à la fois des symboles (chaînes de caractères) et des nombres entiers. C’est un cas d’usage courant pour ce type de problème.

Prérequis

  • Avoir fait l’Exercice 16 (Calculatrice RPN) pour la base des opérations.
  • Maîtriser les piles.
  • Comprendre les règles de priorité des opérateurs et la gestion des parenthèses.
  • Ce sera une fonction plus complexe, prenant en compte le type de l’élément au sommet de la pile.

Proposition de correction

Python

# Réutilisation de la classe Pile (pour des éléments hétérogènes)
class Cellule:
    def __init__(self, v, s):
        self.valeur = v
        self.suivante = s

class Pile:
    def __init__(self):
        self.contenu = None
    def est_vide(self):
        return self.contenu is None
    def empiler(self, v):
        self.contenu = Cellule(v, self.contenu)
    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        v = self.contenu.valeur
        self.contenu = self.contenu.suivante
        return v
    def consulter(self):
        if self.est_vide():
            return None # Retourne None au lieu de lever une erreur pour faciliter les vérifs
        return self.contenu.valeur
    def taille(self):
        compteur = 0
        courant = self.contenu
        while courant is not None:
            compteur += 1
            courant = courant.suivante
        return compteur


def creer_pile():
    return Pile()

# --- Fonctions à écrire pour l'exercice ---

# Dictionnaire des priorités des opérateurs (plus le nombre est grand, plus la priorité est élevée)
PRIORITES_OPERATEURS = {
    '+': 1,
    '*': 2
}

def appliquer_operateur(op, op1, op2):
    """Applique l'opérateur sur les deux opérandes."""
    if op == '+':
        return op1 + op2
    elif op == '*':
        return op1 * op2
    else:
        raise ValueError(f"Opérateur inconnu : {op}")

def simplifier_operations(pile, operateurs_prioritaires):
    """
    Simplifie les opérations au sommet de la pile si l'opérateur est dans operateurs_prioritaires.
    Continue tant qu'il y a un opérateur pertinent au sommet.
    Args:
        pile (Pile): La pile contenant les éléments de l'expression.
        operateurs_prioritaires (set): Un ensemble d'opérateurs à simplifier.
    Raises:
        ValueError: Si la pile est mal formée pour l'opération.
    """
    while True:
        # On vérifie qu'il y a au moins 3 éléments pour une opération (op1, op, op2)
        if pile.taille() < 3:
            break # Pas assez d'éléments pour une opération
        
        # Le sommet de la pile doit être un nombre (op2)
        op2_val = pile.consulter()
        if not isinstance(op2_val, (int, float)): # On accepte int/float pour les nombres
            break # Le sommet n'est pas un nombre, on ne peut pas simplifier une opération
        pile.depiler() # Dépile op2

        # Le nouvel sommet doit être un opérateur
        operateur = pile.consulter()
        if not isinstance(operateur, str) or operateur not in operateurs_prioritaires:
            pile.empiler(op2_val) # On remet op2 car l'opérateur ne nous intéresse pas
            break # Pas l'opérateur désiré au sommet, ou ce n'est pas une chaîne
        pile.depiler() # Dépile l'opérateur

        # L'avant-dernier élément doit être un nombre (op1)
        op1_val = pile.consulter()
        if not isinstance(op1_val, (int, float)):
            # Oh non, la pile est mal formée ! On remet tout et on lève l'erreur.
            pile.empiler(operateur)
            pile.empiler(op2_val)
            raise ValueError("Pile mal formée : opérande manquant avant un opérateur.")
        pile.depiler() # Dépile op1

        # On applique l'opération et on empile le résultat
        resultat = appliquer_operateur(operateur, op1_val, op2_val)
        pile.empiler(resultat)

def calculer_expression(expression_str):
    """
    Évalue une expression arithmétique avec priorités et parenthèses.
    Args:
        expression_str (str): La chaîne de caractères de l'expression.
    Returns:
        int or float: La valeur de l'expression.
    Raises:
        ValueError: Si l'expression est mal formée.
    """
    pile = creer_pile()
    elements = expression_str.split()

    for element in elements:
        try:
            # Si c'est un nombre, l'empiler
            nombre = int(element)
            pile.empiler(nombre)
        except ValueError:
            # Si ce n'est pas un nombre, c'est un symbole
            if element == '(':
                pile.empiler(element)
            elif element == ')':
                # Simplifier toutes les opérations jusqu'à la parenthèse ouvrante
                simplifier_operations(pile, set(PRIORITES_OPERATEURS.keys())) # Simplifie tous les opérateurs
                
                # Vérifier que le sommet est un nombre et l'avant-dernier une '('
                if pile.est_vide() or not isinstance(pile.consulter(), (int, float)):
                    raise ValueError("Parenthèse fermante inattendue ou expression mal formée (pas de nombre avant ')').")
                
                resultat_parenthèse = pile.depiler() # Le résultat de l'opération entre parenthèses
                
                if pile.est_vide() or pile.depiler() != '(': # L'avant-dernier doit être '(' et on le retire
                    raise ValueError("Parenthèse fermante sans parenthèse ouvrante correspondante.")
                
                pile.empiler(resultat_parenthèse) # Empiler le résultat de la parenthèse
            elif element in PRIORITES_OPERATEURS:
                # Si c'est un opérateur, simplifier les opérateurs de priorité égale ou supérieure
                operateurs_a_simplifier = {op for op, prio in PRIORITES_OPERATEURS.items() 
                                            if prio >= PRIORITES_OPERATEURS[element]}
                simplifier_operations(pile, operateurs_a_simplifier)
                pile.empiler(element) # Empiler le nouvel opérateur
            else:
                raise ValueError(f"Élément inconnu dans l'expression : '{element}'.")
    
    # Après avoir parcouru tous les éléments, simplifier toutes les opérations restantes
    simplifier_operations(pile, set(PRIORITES_OPERATEURS.keys()))

    # À la fin, la pile doit contenir un seul élément : le résultat final
    if pile.taille() != 1 or not isinstance(pile.consulter(), (int, float)):
        raise ValueError("Expression mal formée ou incomplète (reste trop ou pas assez d'éléments, ou pas un nombre final).")
    
    return pile.depiler()

# --- Tests ---
print("Exercice 19 : Calculatrice Ordinaire")

# Exemple de l'énoncé : (1 + 2 * 3) * 4 = (1 + 6) * 4 = 7 * 4 = 28
expr1 = '( 1 + 2 * 3 ) * 4'
print(f"'{expr1}' = {calculer_expression(expr1)}") # Attend 28

# Exemple simple
expr2 = '5 + 2 * 3' # 5 + 6 = 11
print(f"'{expr2}' = {calculer_expression(expr2)}") # Attend 11

# Autre exemple
expr3 = '( 10 - 5 ) * 2' # N'inclut pas le '-' par défaut, donc erreur attendue
try:
    print(f"'{expr3}' = {calculer_expression(expr3)}")
except ValueError as e:
    print(f"Erreur attendue : {e}") # Élément inconnu : '-'

# Pour que ça marche avec -, il faut l'ajouter :
PRIORITES_OPERATEURS['-'] = 1 # Même priorité que +
def appliquer_operateur_full(op, op1, op2):
    if op == '+': return op1 + op2
    elif op == '*': return op1 * op2
    elif op == '-': return op1 - op2
    else: raise ValueError(f"Opérateur inconnu : {op}")
# Et modifier la fonction simplifier_operations pour utiliser cette nouvelle fonction
# Pour rester fidèle à l'exercice original, je n'implémente pas ça ici.

expr4 = '2 * ( 3 + 4 )' # 2 * 7 = 14
print(f"'{expr4}' = {calculer_expression(expr4)}") # Attend 14

expr5 = '1 + 2 + 3 + 4' # 10
print(f"'{expr5}' = {calculer_expression(expr5)}") # Attend 10

expr6 = '( ( 1 + 2 ) * 3 )' # (3 * 3) = 9
print(f"'{expr6}' = {calculer_expression(expr6)}") # Attend 9

# Tests d'erreur
expr_err1 = '1 + * 2'
try:
    print(f"'{expr_err1}' = {calculer_expression(expr_err1)}")
except ValueError as e:
    print(f"Erreur attendue : {e}") # Pile mal formée : opérande manquant...

expr_err2 = '( 1 + 2'
try:
    print(f"'{expr_err2}' = {calculer_expression(expr_err2)}")
except ValueError as e:
    print(f"Erreur attendue : {e}") # Expression mal formée ou incomplète...

expr_err3 = '1 + )'
try:
    print(f"'{expr_err3}' = {calculer_expression(expr_err3)}")
except ValueError as e:
    print(f"Erreur attendue : {e}") # Parenthèse fermante sans parenthèse ouvrante correspondante.
print("-" * 20)

Explication de la correction :

Cet exercice est le boss final de la manipulation de piles pour les expressions arithmétiques ! Il implémente l’algorithme de Shunting-yard simplifié (ou un équivalent) pour évaluer les expressions.

  1. PRIORITES_OPERATEURS : Un dictionnaire pour stocker la priorité de chaque opérateur. Plus le nombre est grand, plus l’opérateur est prioritaire.
  2. appliquer_operateur(op, op1, op2) : Une fonction utilitaire pour effectuer l’opération.
  3. simplifier_operations(pile, operateurs_prioritaires) : C’est le cœur de la logique de simplification :
    • Elle boucle tant qu’il y a potentiellement une opération à simplifier.
    • Elle vérifie qu’il y a suffisamment d’éléments pour une opération (opérande2, opérateur, opérande1).
    • Elle depile l’opérande2, puis l’opérateur.
    • Crucial : Elle vérifie si l’opérateur dépilé fait partie des operateurs_prioritaires (ceux que l’on veut simplifier à ce moment précis). Si non, elle rempile l’opérateur et l’opérande2 et s’arrête (car cet opérateur n’est pas à simplifier maintenant).
    • Si l’opérateur est pertinent, elle depile l’opérande1.
    • Elle applique l’opération et empile le résultat.
  4. calculer_expression(expression_str) : La fonction principale :
    • Elle parcourt les elements de l’expression.
    • Nombre : Empile le nombre.
    • Parenthèse ouvrante ( : Empile simplement le caractère (.
    • Parenthèse fermante ) :
      • Appelle simplifier_operations avec tous les opérateurs possibles (PRIORITES_OPERATEURS.keys()). Cela va résoudre tout ce qui se trouve à l’intérieur des parenthèses.
      • Après la simplification, le sommet de la pile doit être le résultat de l’expression entre parenthèses, et juste en dessous, il doit y avoir la parenthèse ouvrante (. On depile le résultat, puis on depile et vérifie le (. Enfin, on empile le résultat de la parenthèse.
    • Opérateur (+ ou *) :
      • Détermine les opérateurs operateurs_a_simplifier qui ont une priorité égale ou supérieure à l’opérateur actuel.
      • Appelle simplifier_operations avec cet ensemble d’opérateurs pour résoudre les opérations précédentes de haute priorité.
      • Enfin, empile l’opérateur actuel.
    • Fin de l’expression : Après avoir traité tous les éléments, on appelle simplifier_operations une dernière fois avec tous les opérateurs pour vider la pile et obtenir le résultat final.
    • Vérification finale : La pile doit contenir un unique nombre, qui est le résultat.

C’est un algorithme ingénieux qui utilise la pile pour respecter les règles de priorité (ce qui est plus prioritaire est calculé plus tôt) et les parenthèses (elles forcent la simplification immédiate de ce qu’elles contiennent). Bravo si tu as réussi à le déchiffrer et à l’implémenter, c’est de la haute voltige !


Exercice 20 : Pile Bornée (Implémentation par Tableau)

Énoncé

Une pile bornée est une pile dotée à sa création d’une capacité maximale. On propose l’interface suivante :

FonctionDescription
creer_pile(c)Crée et renvoie une pile bornée de capacité c.
est_vide(p)Renvoie True si la pile est vide et False sinon.
est_pleine(p)Renvoie True si la pile est pleine et False sinon.
empiler(p, e)Ajoute e au sommet de p si p n’est pas pleine, et lève une exception IndexError sinon.
depiler(p)Retire et renvoie l’élément au sommet de p si p n’est pas vide, et lève une exception IndexError sinon.

Exporter vers Sheets

On propose de réaliser une telle pile bornée à l’aide d’un tableau (liste Python native) dont la taille est fixée à la création et correspond à la capacité. Les éléments de la pile sont stockés consécutivement à partir de l’indice 0 (qui contient l’élément du fond de la pile). On se donne également un entier enregistrant le nombre d’éléments dans la pile, qui permet donc également de désigner l’indice de la prochaine case libre. Ainsi, les éléments sont ajoutés et retirés du côté droit du tableau :

Indices : 0         1         ...      nb-1      nb      ... capacité-1
           |         |         ...       |        |      ...      |
Contenu : [ a ,       b ,       ...      z ,     None ,   ...    None  ]
                                                     ^
                                                     |
                                                (Prochaine case libre)

Réalise une telle structure à l’aide d’une classe ayant pour attributs le tableau fixe et le nombre d’éléments dans la pile bornée.

Prérequis

  • Comprendre le concept de pile bornée.
  • Savoir manipuler les listes Python natives (qui simulent un tableau).
  • Gérer les indices dans un tableau.
  • Connaître les opérations d’ajout/retrait pour une pile LIFO.

Proposition de correction

Python

class PileBornee:
    """Structure de pile bornée implémentée avec un tableau (liste Python)."""
    def __init__(self, capacite):
        if capacite <= 0:
            raise ValueError("La capacité d'une pile bornée doit être positive.")
        self.capacite = capacite
        self.tableau = [None] * capacite # Le tableau fixe de taille 'capacite'
        self.nb_elements = 0 # Indique le nombre d'éléments et l'indice de la prochaine case libre

    def est_vide(self):
        return self.nb_elements == 0

    def est_pleine(self):
        return self.nb_elements == self.capacite

    def empiler(self, e):
        if self.est_pleine():
            raise IndexError("empiler sur une pile pleine")
        
        self.tableau[self.nb_elements] = e # On ajoute l'élément à l'indice de la prochaine case libre
        self.nb_elements += 1 # On incrémente le nombre d'éléments (qui devient le nouvel indice de la case libre)

    def depiler(self):
        if self.est_vide():
            raise IndexError("depiler sur une pile vide")
        
        self.nb_elements -= 1 # On décrémente le nombre d'éléments pour pointer sur le dernier
        valeur = self.tableau[self.nb_elements] # On récupère l'élément au sommet
        self.tableau[self.nb_elements] = None # Optionnel: "vider" la case (pour le ramasse-miettes)
        return valeur

    def consulter(self): # Utile pour les tests, comme à l'exercice 13
        if self.est_vide():
            raise IndexError("consulter sur une pile vide")
        return self.tableau[self.nb_elements - 1] # Le sommet est le dernier élément ajouté


def creer_pile_bornee(c):
    return PileBornee(c)

# --- Tests ---
print("Exercice 20 : Pile Bornée (Implémentation par Tableau)")
ma_pile_bornee = creer_pile_bornee(3) # Capacité de 3

print(f"Pile vide ? {ma_pile_bornee.est_vide()}") # Attend True
print(f"Pile pleine ? {ma_pile_bornee.est_pleine()}") # Attend False
print(f"Nombre d'éléments : {ma_pile_bornee.nb_elements}") # Attend 0

ma_pile_bornee.empiler("pomme")
print(f"Empilé 'pomme'. Nombre d'éléments : {ma_pile_bornee.nb_elements}") # Attend 1
print(f"Sommet : {ma_pile_bornee.consulter()}") # Attend 'pomme'

ma_pile_bornee.empiler("banane")
print(f"Empilé 'banane'. Nombre d'éléments : {ma_pile_bornee.nb_elements}") # Attend 2
print(f"Sommet : {ma_pile_bornee.consulter()}") # Attend 'banane'

ma_pile_bornee.empiler("cerise")
print(f"Empilé 'cerise'. Nombre d'éléments : {ma_pile_bornee.nb_elements}") # Attend 3
print(f"Sommet : {ma_pile_bornee.consulter()}") # Attend 'cerise'
print(f"Pile pleine ? {ma_pile_bornee.est_pleine()}") # Attend True

# Test d'empilement sur pile pleine
try:
    ma_pile_bornee.empiler("kiwi")
except IndexError as e:
    print(f"Erreur attendue : {e}")

print(f"Dépilé : {ma_pile_bornee.depiler()}") # Attend 'cerise'
print(f"Nombre d'éléments : {ma_pile_bornee.nb_elements}") # Attend 2
print(f"Sommet : {ma_pile_bornee.consulter()}") # Attend 'banane'
print(f"Pile pleine ? {ma_pile_bornee.est_pleine()}") # Attend False

ma_pile_bornee.depiler()
ma_pile_bornee.depiler()
print(f"Nombre d'éléments : {ma_pile_bornee.nb_elements}") # Attend 0
print(f"Pile vide ? {ma_pile_bornee.est_vide()}") # Attend True

# Test de dépilement sur pile vide
try:
    ma_pile_bornee.depiler()
except IndexError as e:
    print(f"Erreur attendue : {e}")
print("-" * 20)

Explication de la correction :

L’implémentation d’une pile avec un tableau est très efficace !

  • __init__(self, capacite) :
    • On stocke la capacite maximale.
    • On crée un self.tableau (une liste Python) de la taille spécifiée, remplie de None (ou n’importe quelle valeur par défaut).
    • self.nb_elements est le compteur crucial : il indique non seulement le nombre d’éléments, mais aussi le premier indice disponible pour le prochain empiler, et l’indice du dernier élément nb_elements - 1.
  • est_vide() et est_pleine() :
    • Elles sont directes : on compare self.nb_elements à 0 ou à self.capacite.
  • empiler(self, e) :
    • On vérifie d’abord si la pile n’est pas pleine. Si elle l’est, on lève une IndexError.
    • On place le nouvel élément e à l’indice self.nb_elements (qui est la première case libre).
    • On incrémente self.nb_elements.
  • depiler(self) :
    • On vérifie d’abord si la pile n’est pas vide. Si elle l’est, on lève une IndexError.
    • On décrémente self.nb_elements. Maintenant, self.nb_elements pointe sur l’indice du dernier élément valide qui vient d’être dépilé.
    • On récupère la valeur à cet indice.
    • Optionnel mais bonne pratique : self.tableau[self.nb_elements] = None pour « vider » la case et aider le ramasse-miettes à libérer la mémoire si l’objet n’est plus référencé ailleurs.
    • On renvoie la valeur.
  • consulter(self) :
    • Si la pile est vide, erreur.
    • Sinon, le sommet est toujours à l’indice self.nb_elements - 1.

Cette implémentation est super rapide ! Toutes les opérations (empiler, depiler, est_vide, est_pleine, consulter, taille) se font en temps constant, mathcalO(1), car elles ne nécessitent qu’un accès direct au tableau ou à un attribut, sans boucle. C’est comme avoir un accès direct au dernier livre posé sur la pile, sans avoir à parcourir toute la bibliothèque !


Exercice 21 : File Bornée (Implémentation par Tableau Circulaire)

Énoncé

Une file bornée est une file dotée à sa création d’une capacité maximale. On propose l’interface suivante :

FonctionDescription
creer_file(c)Crée et renvoie une file bornée de capacité c.
est_vide(f)Renvoie True si la file est vide et False sinon.
est_pleine(f)Renvoie True si la file est pleine et False sinon.
ajouter(f, e)Ajoute e à l’arrière de f si f n’est pas pleine, et lève une exception IndexError sinon.
retirer(f)Retire et renvoie l’élément à l’avant de f si f n’est pas vide, et lève une exception IndexError sinon.

Exporter vers Sheets

Comme pour la pile bornée (exercice 20), on propose de réaliser une telle file bornée à l’aide d’un tableau dont la taille est fixée à la création et correspond à la capacité. Les éléments de la file sont stockés consécutivement à partir d’un indice premier correspondant à l’avant de la file, et le tableau est considéré comme circulaire : après la dernière case, les éléments reviennent à la première.

Dans ce schéma, un élément retiré l’est au niveau de l’indice premier, et un élément ajouté l’est à l’autre extrémité.

État 1 : File non pleine
Indices : 0           ...         premier      ...   (premier+nb-1)%cap    ... capacité-1
           |                        |                           |
Contenu : [None, ..., None, ...,    a   ,      b    ,   ...     z   ,    None, ..., None]
                                   ^                                      ^
                                   |                                      |
                                 premier                                 (premier+nb)%cap (prochaine place pour ajouter)

État 2 : File circulaire (les éléments "débordent")
Indices : 0           ...   (premier+nb-1)%cap ...   premier-1    premier   ... capacité-1
           |                        |                       |          |
Contenu : [ k ,       l ,   ...     z   ,       None, ..., None,    a   ,      b    , ...   j ]
           ^                                                ^          ^
           |                                                |          |
      (premier+nb)%cap                                    premier    (premier+nb)%cap si vide

Réalise une telle structure à l’aide d’une classe ayant pour attributs :

  • le tableau fixe (self.tableau),
  • le nombre d’éléments dans la file bornée (self.nb_elements),
  • l’indice du premier élément (self.premier_indice).

Prérequis

  • Comprendre le concept de file bornée et le principe FIFO.
  • Savoir manipuler les listes Python natives.
  • Maîtriser l’arithmétique modulo (%) pour la gestion circulaire des indices.
  • Gérer les cas d’insertion et de retrait à différentes extrémités.

Proposition de correction

Python

class FileBornee:
    """Structure de file bornée implémentée avec un tableau circulaire."""
    def __init__(self, capacite):
        if capacite <= 0:
            raise ValueError("La capacité d'une file bornée doit être positive.")
        self.capacite = capacite
        self.tableau = [None] * capacite # Le tableau fixe
        self.nb_elements = 0 # Nombre d'éléments actuellement dans la file
        self.premier_indice = 0 # Indice du premier élément (tête de la file)
        # L'indice de la prochaine case où ajouter un élément sera :
        # (self.premier_indice + self.nb_elements) % self.capacite

    def est_vide(self):
        return self.nb_elements == 0

    def est_pleine(self):
        return self.nb_elements == self.capacite

    def ajouter(self, e):
        if self.est_pleine():
            raise IndexError("ajouter sur une file pleine")
        
        # Calcul de l'indice où ajouter le nouvel élément
        # C'est (premier_indice + nombre_elements) % capacite
        indice_ajout = (self.premier_indice + self.nb_elements) % self.capacite
        self.tableau[indice_ajout] = e
        self.nb_elements += 1 # Incrémente le nombre d'éléments

    def retirer(self):
        if self.est_vide():
            raise IndexError("retirer d'une file vide")
        
        valeur = self.tableau[self.premier_indice] # Récupère l'élément à la tête
        self.tableau[self.premier_indice] = None # Optionnel: "vider" la case
        
        # Avance l'indice du premier élément de manière circulaire
        self.premier_indice = (self.premier_indice + 1) % self.capacite
        self.nb_elements -= 1 # Décrémente le nombre d'éléments
        return valeur

    def consulter_premier(self): # Méthode ajoutée pour consulter le premier sans le retirer
        if self.est_vide():
            raise IndexError("consulter sur une file vide")
        return self.tableau[self.premier_indice]

def creer_file_bornee(c):
    return FileBornee(c)

# --- Tests ---
print("Exercice 21 : File Bornée (Implémentation par Tableau Circulaire)")
ma_file_bornee = creer_file_bornee(4) # Capacité de 4

print(f"File vide ? {ma_file_bornee.est_vide()}") # True
print(f"File pleine ? {ma_file_bornee.est_pleine()}") # False
print(f"Nombre d'éléments : {ma_file_bornee.nb_elements}") # 0
print(f"Premier indice : {ma_file_bornee.premier_indice}") # 0
print(f"Tableau initial : {ma_file_bornee.tableau}") # [None, None, None, None]

ma_file_bornee.ajouter("Client A")
print(f"\nAjout Client A. Nbr éléments: {ma_file_bornee.nb_elements}, Premier indice: {ma_file_bornee.premier_indice}, Tableau: {ma_file_bornee.tableau}")
# Attendu: Nbr: 1, Premier: 0, Tableau: ['Client A', None, None, None]

ma_file_bornee.ajouter("Client B")
print(f"Ajout Client B. Nbr éléments: {ma_file_bornee.nb_elements}, Premier indice: {ma_file_bornee.premier_indice}, Tableau: {ma_file_bornee.tableau}")
# Attendu: Nbr: 2, Premier: 0, Tableau: ['Client A', 'Client B', None, None]

print(f"\nConsultation premier : {ma_file_bornee.consulter_premier()}") # Attendu: Client A

print(f"Retrait : {ma_file_bornee.retirer()}") # Attendu: Client A
print(f"Nbr éléments: {ma_file_bornee.nb_elements}, Premier indice: {ma_file_bornee.premier_indice}, Tableau: {ma_file_bornee.tableau}")
# Attendu: Nbr: 1, Premier: 1, Tableau: [None, 'Client B', None, None]

ma_file_bornee.ajouter("Client C")
ma_file_bornee.ajouter("Client D")
ma_file_bornee.ajouter("Client E") # La file devrait être pleine après ça

print(f"\nAjouts C, D, E. Nbr éléments: {ma_file_bornee.nb_elements}, Premier indice: {ma_file_bornee.premier_indice}, Tableau: {ma_file_bornee.tableau}")
# Attendu: Nbr: 4, Premier: 1, Tableau: ['Client E', 'Client B', 'Client C', 'Client D']
# 'Client B' à indice 1, 'Client C' à indice 2, 'Client D' à indice 3, 'Client E' à indice 0 (circulaire)

print(f"File pleine ? {ma_file_bornee.est_pleine()}") # True

# Test d'ajout sur file pleine
try:
    ma_file_bornee.ajouter("Client F")
except IndexError as e:
    print(f"Erreur attendue : {e}")

print(f"\nRetrait : {ma_file_bornee.retirer()}") # Attendu: Client B
print(f"Nbr éléments: {ma_file_bornee.nb_elements}, Premier indice: {ma_file_bornee.premier_indice}, Tableau: {ma_file_bornee.tableau}")
# Attendu: Nbr: 3, Premier: 2, Tableau: [None, None, 'Client C', 'Client D']

print(f"Retrait : {ma_file_bornee.retirer()}") # Attendu: Client C
print(f"Retrait : {ma_file_bornee.retirer()}") # Attendu: Client D
print(f"Retrait : {ma_file_bornee.retirer()}") # Attendu: Client E (le dernier via l'enroulement)

print(f"Nbr éléments: {ma_file_bornee.nb_elements}, Premier indice: {ma_file_bornee.premier_indice}, Tableau: {ma_file_bornee.tableau}")
# Attendu: Nbr: 0, Premier: 1 (revient à 1 après avoir retiré E), Tableau: [None, None, None, None]
print(f"File vide ? {ma_file_bornee.est_vide()}") # True

# Test de retrait sur file vide
try:
    ma_file_bornee.retirer()
except IndexError as e:
    print(f"Erreur attendue : {e}")
print("-" * 20)

Explication de la correction :

L’implémentation d’une file bornée avec un tableau circulaire est un peu plus délicate que la pile, mais tout aussi efficace une fois maîtrisée. Le secret, c’est l’opérateur modulo (%) !

  • __init__(self, capacite) :
    • self.capacite : La taille fixe du tableau.
    • self.tableau : Le tableau lui-même, initialisé avec None.
    • self.nb_elements : Le nombre actuel d’éléments dans la file.
    • self.premier_indice : C’est l’indice dans le tableau où se trouve le premier élément de la file (la tête). Quand la file est vide, cet indice peut être n’importe quoi, mais 0 est une bonne convention.
  • est_vide() et est_pleine() :
    • Similaires à la pile bornée, basées sur self.nb_elements.
  • ajouter(self, e) :
    • On vérifie si la file est pleine.
    • L’indice où le nouvel élément doit être ajouté est calculé par (self.premier_indice + self.nb_elements) % self.capacite.
      • self.premier_indice + self.nb_elements te donne l’indice « logique » si le tableau était infini.
      • Le % self.capacite le « ramène » au début du tableau si l’indice dépasse la fin, simulant ainsi la circularité.
    • On place e à cet indice_ajout.
    • On incrémente self.nb_elements.
  • retirer(self) :
    • On vérifie si la file est vide.
    • On récupère la valeur de l’élément à l’indice self.premier_indice.
    • Optionnel : self.tableau[self.premier_indice] = None pour libérer la référence.
    • Crucial : On met à jour self.premier_indice en le faisant avancer d’une position de manière circulaire : self.premier_indice = (self.premier_indice + 1) % self.capacite.
    • On décrémente self.nb_elements.
    • On renvoie la valeur retirée.
  • consulter_premier(self) :
    • Accède simplement à self.tableau[self.premier_indice].

Toutes les opérations de la file bornée implémentée ainsi ont une complexité en temps constant, mathcalO(1). C’est le moyen le plus efficace d’implémenter une file avec un tableau, sans avoir à déplacer tous les éléments à chaque retrait (ce qui serait mathcalO(N)).


Exercice 22 : Simulation d’Attente aux Guichets

Énoncé

Dans cet exercice, on se propose d’évaluer le temps d’attente de clients à des guichets, en comparant la solution d’une unique file d’attente et la solution d’une file d’attente par guichet.

Modélisation :

  • Le temps est modélisé par une variable globale horloge_globale, incrémentée à chaque tour de boucle.
  • Quand un nouveau client arrive, il est placé dans une file sous la forme d’un entier égal à la valeur de l’horloge (son heure d’arrivée).
  • Quand un client est servi (sort de sa file), son temps d’attente est horloge_globale - heure_arrivée_client.
  • On totalise le nombre de clients servis et le temps d’attente cumulé pour calculer le temps d’attente moyen.

Simulation des guichets :

  • On se donne un nombre N_GUICHETS (par exemple, 5).
  • Pour simuler la disponibilité d’un guichet, on utilise un tableau disponibilite_guichets de taille N_GUICHETS. disponibilite_guichets[i] indique le nombre de tours d’horloge où le guichet i sera occupé. Quand cette valeur vaut 0, le guichet est libre.
  • Quand un client est servi par le guichet i, on choisit un temps de traitement pour ce client au hasard entre 0 et N_GUICHETS (inclus), et on l’affecte à disponibilite_guichets[i].
  • À chaque tour d’horloge, on réalise deux opérations :
    1. On fait apparaître un nouveau client.
    2. Pour chaque guichet i :
      • S’il est disponible (disponibilite_guichets[i] == 0), il sert un nouveau client (pris dans sa propre file ou dans l’unique file, selon le modèle).
      • Sinon, on décrémente disponibilite_guichets[i].

Mission : Écris un programme qui effectue une telle simulation, sur 100 000 tours d’horloge, et affiche au final le temps d’attente moyen. Compare avec différentes stratégies (unique file vs. une file par guichet, et pour plusieurs files : choix aléatoire ou choix de la file la plus courte).

Prérequis

  • Avoir fait l’Exercice 21 (File Bornée), ou au moins comprendre bien le concept de file. Pour simplifier, tu peux utiliser les listes natives de Python comme des files (append pour ajouter, pop(0) pour retirer), mais une vraie implémentation de file est préférable pour la performance à grande échelle.
  • Utiliser des boucles.
  • Générer des nombres aléatoires (random module).
  • Calculer des moyennes.

Proposition de correction

Python

import random

# Réutilisation d'une classe File simple (listes Python pour faciliter l'exercice)
# Pour une vraie simulation à grande échelle, la FileBornee serait mieux.
class FileSimple:
    def __init__(self):
        self.elements = []
    def est_vide(self):
        return len(self.elements) == 0
    def ajouter(self, e):
        self.elements.append(e)
    def retirer(self):
        if self.est_vide():
            raise IndexError("retirer d'une file vide")
        return self.elements.pop(0) # pop(0) est O(N) mais suffisant pour la démo
    def taille(self):
        return len(self.elements)

# --- Paramètres de la simulation ---
N_GUICHETS = 5
DUREE_SIMULATION = 100000 # Tours d'horloge

# --- Simulation 1 : Une seule file d'attente pour tous les guichets ---
def simuler_unique_file():
    print("\n--- Simulation : Une seule file d'attente pour tous les guichets ---")
    
    file_unique = FileSimple()
    disponibilite_guichets = [0] * N_GUICHETS # Temps avant que le guichet soit libre
    
    total_attente = 0
    clients_servis = 0
    
    for horloge_globale in range(DUREE_SIMULATION):
        # 1. Apparition d'un nouveau client (son heure d'arrivée est l'horloge actuelle)
        file_unique.ajouter(horloge_globale) # Le client arrive à l'heure 'horloge_globale'

        # 2. Pour chaque guichet
        for i in range(N_GUICHETS):
            if disponibilite_guichets[i] == 0: # Guichet i est libre
                if not file_unique.est_vide(): # Y a-t-il un client à servir ?
                    heure_arrivee_client = file_unique.retirer()
                    attente_client = horloge_globale - heure_arrivee_client
                    
                    total_attente += attente_client
                    clients_servis += 1
                    
                    # Le guichet devient occupé pour un temps aléatoire (entre 0 et N_GUICHETS)
                    disponibilite_guichets[i] = random.randint(0, N_GUICHETS)
            else:
                # Guichet occupé, décrémente son temps d'occupation
                disponibilite_guichets[i] -= 1
    
    print(f"Clients servis : {clients_servis}")
    print(f"Temps d'attente cumulé : {total_attente}")
    if clients_servis > 0:
        temps_attente_moyen = total_attente / clients_servis
        print(f"Temps d'attente moyen : {temps_attente_moyen:.2f}")
    else:
        print("Aucun client servi.")

# --- Simulation 2 : Une file d'attente par guichet (choix aléatoire) ---
def simuler_multi_files_aleatoire():
    print("\n--- Simulation : Une file d'attente par guichet (choix aléatoire) ---")
    
    files_guichets = [FileSimple() for _ in range(N_GUICHETS)] # Une liste de files
    disponibilite_guichets = [0] * N_GUICHETS
    
    total_attente = 0
    clients_servis = 0
    
    for horloge_globale in range(DUREE_SIMULATION):
        # 1. Apparition d'un nouveau client
        # Le client choisit un guichet au hasard pour sa file
        guichet_choisi = random.randint(0, N_GUICHETS - 1)
        files_guichets[guichet_choisi].ajouter(horloge_globale)

        # 2. Pour chaque guichet
        for i in range(N_GUICHETS):
            if disponibilite_guichets[i] == 0: # Guichet i est libre
                if not files_guichets[i].est_vide(): # Y a-t-il un client dans SA file ?
                    heure_arrivee_client = files_guichets[i].retirer()
                    attente_client = horloge_globale - heure_arrivee_client
                    
                    total_attente += attente_client
                    clients_servis += 1
                    
                    disponibilite_guichets[i] = random.randint(0, N_GUICHETS)
            else:
                disponibilite_guichets[i] -= 1
    
    print(f"Clients servis : {clients_servis}")
    print(f"Temps d'attente cumulé : {total_attente}")
    if clients_servis > 0:
        temps_attente_moyen = total_attente / clients_servis
        print(f"Temps d'attente moyen : {temps_attente_moyen:.2f}")
    else:
        print("Aucun client servi.")

# --- Simulation 3 : Une file d'attente par guichet (choix de la file la plus courte) ---
def simuler_multi_files_plus_courte():
    print("\n--- Simulation : Une file d'attente par guichet (choix de la file la plus courte) ---")
    
    files_guichets = [FileSimple() for _ in range(N_GUICHETS)]
    disponibilite_guichets = [0] * N_GUICHETS
    
    total_attente = 0
    clients_servis = 0
    
    for horloge_globale in range(DUREE_SIMULATION):
        # 1. Apparition d'un nouveau client
        # Le client choisit la file la plus courte
        min_taille = float('inf') # Infini pour trouver le minimum
        indice_file_plus_courte = 0
        for i in range(N_GUICHETS):
            if files_guichets[i].taille() < min_taille:
                min_taille = files_guichets[i].taille()
                indice_file_plus_courte = i
        
        files_guichets[indice_file_plus_courte].ajouter(horloge_globale)

        # 2. Pour chaque guichet
        for i in range(N_GUICHETS):
            if disponibilite_guichets[i] == 0: # Guichet i est libre
                if not files_guichets[i].est_vide(): # Y a-t-il un client dans SA file ?
                    heure_arrivee_client = files_guichets[i].retirer()
                    attente_client = horloge_globale - heure_arrivee_client
                    
                    total_attente += attente_client
                    clients_servis += 1
                    
                    disponibilite_guichets[i] = random.randint(0, N_GUICHETS)
            else:
                disponibilite_guichets[i] -= 1
    
    print(f"Clients servis : {clients_servis}")
    print(f"Temps d'attente cumulé : {total_attente}")
    if clients_servis > 0:
        temps_attente_moyen = total_attente / clients_servis
        print(f"Temps d'attente moyen : {temps_attente_moyen:.2f}")
    else:
        print("Aucun client servi.")

# --- Exécution des simulations ---
print("Exercice 22 : Simulation d'Attente aux Guichets")
simuler_unique_file()
simuler_multi_files_aleatoire()
simuler_multi_files_plus_courte()
print("-" * 20)

Explication de la correction :

Cet exercice est une application concrète et passionnante des files pour la modélisation de systèmes réels.

  1. Classe FileSimple : J’ai utilisé une version simplifiée de la classe File basée sur les listes Python. Pour une simulation plus exigeante en performance, il serait judicieux d’utiliser la FileBornee de l’exercice 21, car list.pop(0) est coûteux en O(N).
  2. Paramètres : N_GUICHETS et DUREE_SIMULATION sont configurables.
  3. Boucle Principale de Simulation : Pour chaque horloge_globale (chaque « tour de boucle » ou « unité de temps ») :
    • Arrivée d’un client : Un nouveau client est toujours ajouté à une file. Son « heure d’arrivée » est simplement la valeur actuelle de horloge_globale.
    • Gestion des guichets : On parcourt chaque guichet i.
      • Guichet libre (disponibilite_guichets[i] == 0) :
        • S’il y a un client dans la file du guichet (ou dans la file unique), ce client est retiré.
        • On calcule son temps d’attente (horloge_globale - heure_arrivee_client).
        • On met à jour les totaux (total_attente, clients_servis).
        • Le guichet devient occupé pour une durée aléatoire (random.randint(0, N_GUICHETS)).
      • Guichet occupé (disponibilite_guichets[i] > 0) :
        • On décrémente son temps d’occupation (disponibilite_guichets[i] -= 1).
  4. Stratégies de File :
    • simuler_unique_file() :
      • Tous les clients arrivent dans une seule file_unique.
      • Chaque guichet, s’il est libre, prend le prochain client de cette file_unique.
    • simuler_multi_files_aleatoire() :
      • Les clients sont répartis aléatoirement entre les N_GUICHETS files d’attente (une par guichet). random.randint(0, N_GUICHETS - 1) choisit la file.
      • Chaque guichet ne sert que les clients de sa propre file.
    • simuler_multi_files_plus_courte() :
      • Les clients choisissent la file qui a actuellement le min_taille (la file la plus courte). C’est la stratégie la plus « intelligente » pour le client.
      • Comme précédemment, chaque guichet ne sert que les clients de sa propre file.
  5. Résultats : À la fin de la simulation, on affiche le nombre total de clients servis et le temps d’attente moyen.

Comparaison des stratégies (résultats typiques) :

Tu remarqueras que :

  • La file unique est généralement la plus efficace ! Pourquoi ? Parce qu’elle garantit qu’aucun guichet n’est inactif s’il y a des clients en attente, même si un guichet a sa propre file vide. Les ressources (guichets) sont utilisées de manière optimale. C’est l’équité pour le système.
  • Les files multiples avec choix aléatoire sont les moins efficaces. Certains guichets peuvent être débordés tandis que d’autres sont inactifs avec des files courtes ou vides, car les clients ne peuvent pas passer d’une file à l’autre.
  • Les files multiples avec choix de la plus courte se situent entre les deux. C’est un compromis qui améliore l’efficacité par rapport au choix aléatoire, mais n’atteint généralement pas l’optimum de la file unique car les clients restent « bloqués » dans la file qu’ils ont choisie.

Cette simulation est une illustration parfaite de la théorie des files d’attente et de la manière dont les choix de conception (ici, le nombre et la gestion des files) peuvent avoir un impact majeur sur la performance d’un système. Et voilà, tu es désormais un pro des files d’attente… au moins en Python !

Programmation fonctionnelle

durant la classe de première j’ai pu évoquer de manière très rapide et superficielle les effets de bords. Pour être sûr de partir du bon pied on va reprendre de A à Z cette notion afin d’avoir de bonnes bases pour la programmation fonctionnelle.

L’Effet de Bord : Le Vilain Petit Canard de la Programmation ! 🦆

Imagine que tu donnes une mission à quelqu’un. Tu t’attends à ce qu’il fasse juste cette mission, n’est-ce pas ? En programmation, c’est un peu pareil ! Quand une fonction, disons notre amie fct, s’amuse à changer la valeur d’une variable qui n’est pas censée être de son ressort (comme notre i qui passe de 3 à 4 tout seul), on appelle ça un effet de bord. C’est un peu comme si ta fonction faisait de la « magie noire » en coulisses ! ✨

Exemple 1 : Modification d’une variable globale

Dans cet exemple, la fonction incremente_compteur() modifie la valeur d’une variable compteur qui est définie en dehors de la fonction (une variable globale).

compteur = 0 # Variable globale

def incremente_compteur():
  global compteur # On déclare qu'on veut modifier la variable globale
  compteur += 1
  print(f"Le compteur est maintenant : {compteur}")

print(f"Compteur avant appel : {compteur}")
incremente_compteur()
print(f"Compteur après 1er appel : {compteur}")
incremente_compteur()
print(f"Compteur après 2ème appel : {compteur}")

Pourquoi c’est un effet de bord ? La fonction incremente_compteur() ne se contente pas de faire un calcul ou de retourner une valeur ; elle modifie l’état d’une variable qui existe en dehors de son propre périmètre.

Exemple 2 : Modification d’une liste passée en argument

Dans cet exemple, la fonction ajouter_element() modifie une liste qui lui est passée en argument. En Python, les listes (et d’autres objets mutables comme les dictionnaires) sont passées par référence, ce qui signifie que la fonction travaille directement sur l’objet original.

ma_liste = [1, 2, 3] # Une liste

def ajouter_element(liste_a_modifier, element):
  liste_a_modifier.append(element)
  print(f"La liste à l'intérieur de la fonction : {liste_a_modifier}")

print(f"Liste avant appel : {ma_liste}")
ajouter_element(ma_liste, 4)
print(f"Liste après appel : {ma_liste}")

Pourquoi c’est un effet de bord ? La fonction ajouter_element() modifie directement ma_liste qui est une variable définie à l’extérieur. Si tu ne t’attends pas à ce comportement, cela peut créer des bugs. Souvent, pour éviter cet effet de bord avec les listes, on préférera retourner une nouvelle liste modifiée :

# Version sans effet de bord (ou effet de bord "maîtrisé" via le retour)
ma_liste_originale = [1, 2, 3]

def ajouter_element_sans_effet_de_bord(liste_initiale, element):
  nouvelle_liste = list(liste_initiale) # On crée une copie de la liste
  nouvelle_liste.append(element)
  return nouvelle_liste

print(f"Liste originale avant appel : {ma_liste_originale}")
liste_modifiee = ajouter_element_sans_effet_de_bord(ma_liste_originale, 4)
print(f"Liste originale après appel (inchangée) : {ma_liste_originale}")
print(f"Nouvelle liste modifiée : {liste_modifiee}")


Ces exemples montrent bien comment des fonctions peuvent avoir des « effets secondaires » inattendus sur l’état général de ton programme !

Pourquoi c’est « mal » les effets de bord ?

Même si dans les petits codes, ça peut sembler anodin, dans les programmes géants, les effets de bord peuvent vite transformer ton code en véritable casse-tête chinois (ou en plat de spaghettis si tu préfères ! 🍝).

  • Comportements imprévus : Ils sont les rois des surprises désagréables. Tu penses que ta variable a une certaine valeur, et paf, un effet de bord l’a changée sans prévenir. Bonjour les bugs !
  • Code illisible : Tenter de comprendre un programme truffé d’effets de bord, c’est comme essayer de démêler un nœud gordien les yeux bandés. C’est un vrai cauchemar pour la lecture et la maintenance.
  • Prédictibilité zéro : Quand l’état de tes variables peut changer à tout moment, impossible de savoir à quoi t’attendre. Ton code devient une boîte noire imprévisible.

En gros, les effets de bord, c’est la pagaille assurée ! C’est pourquoi, en général, on essaie de les éviter comme la peste. L’utilisation du mot-clé global ? À consommer avec une extrême modération !

Heureusement, il y a un super-héros pour ça : la programmation fonctionnelle ! Ce style de programmation a pour mission de minimiser au maximum ces vilains effets de bord, pour un code plus propre, plus lisible et plus prévisible.


Alors, prêt à dompter les effets de bord dans tes prochains codes ?

Plongeon dans la Programmation Fonctionnelle : Le Code Anti-Surprise ! 🦸‍♂️

Tu connais déjà la programmation impérative (on donne des ordres, étape par étape) et peut-être même la programmation orientée objet (on organise le code autour de « choses » et leurs actions). Eh bien, la programmation fonctionnelle, c’est un autre style de pensée, un peu comme une philosophie de code !

Son credo ? ZÉRO effet de bord ! 🚫 Pour faire simple, en programmation fonctionnelle, on évite comme la peste de modifier les variables existantes. Imagine que tes variables sont des rochers immuables : une fois posées, elles ne bougent plus !

Mais comment on fait alors ? On utilise des fonctions (d’où le nom !), mais attention, pas n’importe quelles fonctions ! On parle ici de fonctions pures.

La Fonction Pure : Le Ninja du Code Prévisible 🥋

Une fonction pure, c’est comme un mini-ordinateur super fiable :

  1. Son résultat ne dépend que de ce que tu lui donnes en entrée (ses paramètres).
  2. Elle ne touche à rien d’autre en dehors d’elle-même. Pas de modification de variables externes, pas de surprises !

C’est simple : même si tu l’appelles 100 fois avec les mêmes entrées, tu auras toujours le même résultat ! La prédictibilité, c’est son super-pouvoir !


À l’Action : Fonctions Pures vs. Fonctions Imprévues !

Test express 1 : Le Détective de Variables Étrangères

Regarde ce code :

Python

niveau_de_difficulte = 5 # Une variable globale qui traîne par là

def est_difficile():
  if niveau_de_difficulte > 5: # Oups, on regarde une variable externe !
    return "C'est un défi !"
  else :
    return "Facile comme bonjour."

print(est_difficile())

Alors, la fonction est_difficile() est-elle pure ? 🤔

La réponse : Non ! La pauvre est_difficile() n’est pas pure car elle jette un œil à niveau_de_difficulte, une variable qui vit en dehors d’elle. Si niveau_de_difficulte change, le comportement de la fonction change sans que tu ne modifies ses paramètres. Mystère et boule de gomme !


Test express 2 : La Pureté Incarnée

Maintenant, observe celle-ci :

Python

def est_difficile_pure(niveau): # Le niveau est passé en paramètre, nickel !
  if niveau > 5:
    return "C'est un défi !"
  else :
    return "Facile comme bonjour."

print(est_difficile_pure(7))
print(est_difficile_pure(3))

Et celle-ci, pure ou pas pure ? 🤔

La réponse : Oui, c’est une fonction pure ! Son résultat (facile ou difficile) dépend uniquement du niveau que tu lui donnes en entrée. Elle ne va pas fouiner ailleurs, elle est super indépendante !


La Règle d’Or : Créer plutôt que Modifier !

La programmation fonctionnelle, c’est comme être un collectionneur méticuleux. Plutôt que de « retoucher » un objet existant, tu en crées un tout nouveau à partir de l’ancien.

Test express 3 : L’Artiste du Changement (qui modifie en douce)

Python

liste_de_courses = ["pommes", "lait"]

def ajouter_article(article):
  liste_de_courses.append(article) # Attention, on modifie la liste originale !
  print(f"Ma liste dans la fonction : {liste_de_courses}")

print(f"Ma liste AVANT : {liste_de_courses}")
ajouter_article("pain")
print(f"Ma liste APRÈS : {liste_de_courses}")

Est-ce que ça respecte le mode fonctionnel ? 🧐

La réponse : Non, non et re-non ! La fonction ajouter_article est une véritable rebelle : elle modifie directement liste_de_courses. C’est un effet de bord en plein ! La méthode append() en Python, par exemple, est un classique des opérations qui modifient une donnée existante.


Test express 4 : L’Artiste du Nouveau (le chic fonctionnel)

Et cette version, alors ?

Python

liste_de_courses_originale = ["pommes", "lait"]

def ajouter_article_pure(liste_actuelle, article):
  # On ne modifie RIEN ! On crée une NOUVELLE liste avec l'ancien contenu + le nouvel article
  nouvelle_liste = liste_actuelle + [article]
  return nouvelle_liste

print(f"Ma liste originale AVANT : {liste_de_courses_originale}")
ma_nouvelle_liste = ajouter_article_pure(liste_de_courses_originale, "pain")
print(f"Ma liste originale APRÈS (elle est restée intacte !) : {liste_de_courses_originale}")
print(f"Ma NOUVELLE liste : {ma_nouvelle_liste}")

Alors, ça, c’est du fonctionnel pur et dur ! 🤩

La réponse : Oui, bingo ! La fonction ajouter_article_pure est un modèle de pureté fonctionnelle. Elle ne touche pas à la liste_de_courses_originale. Au lieu de ça, elle prend la liste existante, y ajoute l’article, et crée une toute nouvelle liste qu’elle te renvoie. L’originale est saine et sauve ! Plus de risque d’effet de bord caché !


Choisir Son Super-Pouvoir de Programmation 💪

Impératif, Orienté Objet, Fonctionnel… Il n’y a pas de « meilleur » paradigme. Chaque approche a ses forces et ses faiblesses. Le vrai talent du programmeur, c’est de savoir choisir le bon outil pour le bon travail. Parfois, la puissance de l’objet est parfaite ; d’autres fois, la clarté et la prévisibilité du fonctionnel sont indispensables.

Alors, quel style de code vas-tu maîtriser ensuite ? 😉

Applications

Exercice 1 : Le Détective de Super-Héros ! 🦸‍♀️

Imagine que tu as une liste de super-héros, et tu veux trouver le premier qui correspond à un certain critère (par exemple, « Est-ce qu’il peut voler ? »).

Exercice : Écris une fonction trouve_super_heros(critere, equipe_de_heros) qui prend en entrée :

  • critere : une fonction qui teste si un super-héros a le super-pouvoir recherché (elle renvoie True ou False).
  • equipe_de_heros : une liste de super-héros (chaque super-héros est une chaîne de caractères).

La fonction devra retourner le nom du premier super-héros qui satisfait le critere. Si aucun ne convient, elle retourne None, car il n’y a pas de héros à la hauteur !

Connaissances nécessaires : Pas de nouvelle connaissance majeure, c’est une application directe de la compréhension des fonctions comme arguments et des boucles.


Python

# Solution Exercice 1 : Le Détective de Super-Héros

def trouve_super_heros(critere, equipe_de_heros):
  """
  Recherche le premier super-héros dans l'équipe qui satisfait un critère donné.

  Args:
    critere: Une fonction qui prend un super-héros (chaîne) et retourne True/False.
    equipe_de_heros: Une liste de noms de super-héros (chaînes).

  Returns:
    Le nom du premier super-héros qui correspond au critère, ou None si aucun.
  """
  for heros in equipe_de_heros:
    if critere(heros):
      return heros # On a trouvé notre champion !
  return None # Personne à l'horizon...

# --- Testons notre détective ! ---

# Nos super-pouvoirs de détection (fonctions de critère)
def peut_voler(heros):
  return "vole" in heros.lower() or "volant" in heros.lower()

def est_costume_rouge(heros):
  return "rouge" in heros.lower()

def a_cape(heros):
  return "cape" in heros.lower()

equipe_alpha = ["SuperMan", "Batman", "WonderWoman", "Flash"]
equipe_beta = ["Spider-Man", "Iron Man", "Captain America", "Hulk"]
equipe_gamma = ["Aquaman", "Cyborg"]

print(f"Équipe Alpha : {equipe_alpha}")
print(f"Premier héros qui vole : {trouve_super_heros(peut_voler, equipe_alpha)}")
print(f"Premier héros en costume rouge : {trouve_super_heros(est_costume_rouge, equipe_alpha)}")
print(f"Premier héros avec cape : {trouve_super_heros(a_cape, equipe_alpha)}")
print("-" * 30)

print(f"Équipe Beta : {equipe_beta}")
print(f"Premier héros qui vole : {trouve_super_heros(peut_voler, equipe_beta)}")
print(f"Premier héros en costume rouge : {trouve_super_heros(est_costume_rouge, equipe_beta)}")
print(f"Premier héros avec cape : {trouve_super_heros(a_cape, equipe_beta)}")
print("-" * 30)

print(f"Équipe Gamma : {equipe_gamma}")
print(f"Premier héros avec cape : {trouve_super_heros(a_cape, equipe_gamma)}") # Aucun avec cape


Exercice 2 : La Boîte à Magie !

Imagine que tu as une boîte pleine d’ingrédients (ta liste), et tu as une baguette magique (ta fonction f) qui peut transformer chaque ingrédient individuellement. Tu veux que ta fonction te donne une nouvelle boîte avec tous les ingrédients transformés, sans toucher à la boîte d’origine !

Exercice : Écris une fonction transforme_ingredients(recette_magique, liste_ingredients) qui prend :

  • recette_magique : une fonction qui va transformer un ingrédient.
  • liste_ingredients : une liste d’ingrédients.

La fonction devra retourner une nouvelle liste (une nouvelle boîte !) où chaque ingrédient de départ a été transformé par la recette_magique.

Fais-le de deux façons :

  1. Avec une bonne vieille boucle for (la méthode « traditionnelle »).
  2. Avec la notation par compréhension de liste (la méthode « magique » et compacte de Python) !

Connaissances nécessaires : La compréhension de liste est une nouvelle notion à aborder ici.

Explication de la compréhension de liste : C’est une syntaxe super cool et compacte en Python pour créer des listes. Au lieu d’écrire une boucle for et d’utiliser append(), tu peux tout faire en une seule ligne, entre crochets [].

Format : [expression for item in iterable if condition]

  • expression : Ce que tu veux ajouter à ta nouvelle liste pour chaque élément.
  • item : La variable qui prendra la valeur de chaque élément de l’itérable.
  • iterable : La liste, la chaîne, etc., sur laquelle tu boucles.
  • if condition (optionnel) : Une condition pour filtrer les éléments.

Exemple rapide :

Python

nombres = [1, 2, 3, 4]
carres = [n*n for n in nombres] # [1, 4, 9, 16]
pairs = [n for n in nombres if n % 2 == 0] # [2, 4]


Python

# Solution Exercice 2 : La Boîte à Magie

def transforme_ingredients_boucle(recette_magique, liste_ingredients):
  """
  Transforme chaque ingrédient d'une liste en utilisant une boucle for.
  Retourne une NOUVELLE liste.
  """
  nouvelle_liste = []
  for ingredient in liste_ingredients:
    nouvelle_liste.append(recette_magique(ingredient))
  return nouvelle_liste

def transforme_ingredients_comprehension(recette_magique, liste_ingredients):
  """
  Transforme chaque ingrédient d'une liste en utilisant une compréhension de liste.
  Retourne une NOUVELLE liste.
  """
  # C'est la syntaxe magique !
  return [recette_magique(ingredient) for ingredient in liste_ingredients]

# --- Testons nos boîtes à magie ! ---

# Quelques recettes magiques (fonctions)
def double_valeur(x):
  return x * 2

def rend_majuscule(chaine):
  return chaine.upper()

def ajoute_exclamation(texte):
  return texte + " !"

mes_chiffres = [1, 5, 10, 100]
mes_mots = ["pomme", "banane", "cerise"]

print(f"Chiffres originaux : {mes_chiffres}")
print(f"Chiffres doublés (boucle) : {transforme_ingredients_boucle(double_valeur, mes_chiffres)}")
print(f"Chiffres doublés (compréhension) : {transforme_ingredients_comprehension(double_valeur, mes_chiffres)}")
print("-" * 30)

print(f"Mots originaux : {mes_mots}")
print(f"Mots en majuscules (boucle) : {transforme_ingredients_boucle(rend_majuscule, mes_mots)}")
print(f"Mots en majuscules (compréhension) : {transforme_ingredients_comprehension(rend_majuscule, mes_mots)}")
print("-" * 30)

print(f"Mots avec exclamation (boucle) : {transforme_ingredients_boucle(ajoute_exclamation, mes_mots)}")
print(f"Mots avec exclamation (compréhension) : {transforme_ingredients_comprehension(ajoute_exclamation, mes_mots)}")


Exercice 3 Le Chef d’Orchestre des Opérations ! 🎶

Ton exercice original :

Imagine que tu as une liste de « notes de musique » (x1, x2, ...). Tu veux d’abord les « arranger » avec une fonction f (par exemple, les mettre en « majeur » ou « mineur »), puis tu veux les « enchaîner » toutes ensemble avec une opération op (comme les « lier » les unes aux autres).

Exercice : Écris une fonction chef_dorchestre(f_transforme, op_enchaine, partition) qui prend :

  • f_transforme : une fonction pour transformer chaque élément de la liste.
  • op_enchaine : une fonction pour combiner (enchaîner) deux éléments transformés.
  • partition : la liste des éléments de départ.

La fonction doit d’abord appliquer f_transforme à chaque élément de la partition, puis combiner tous les résultats avec op_enchaine, de gauche à droite.

Ensuite, utilise cette fonction chef_dorchestre pour :

  • Prendre une liste de nombres (par exemple [10, 20, 30]).
  • Transformer chaque nombre en sa représentation textuelle (par exemple, 10 devient "10").
  • Combiner ces textes en une seule chaîne, où les nombres sont séparés par une virgule et un espace (par exemple "10, 20, 30").

Connaissances nécessaires : Aucune nouvelle connaissance spécifique, mais cela combine la manipulation de fonctions en arguments et le concept de « réduction » (appliquer une opération de manière cumulative sur une liste), ce qui est un classique de la programmation fonctionnelle (souvent appelé reduce ou fold).


Python

# Solution Exercice 3 : Le Chef d'Orchestre des Opérations

def chef_dorchestre(f_transforme, op_enchaine, partition):
  """
  Applique une transformation f_transforme à chaque élément de la partition,
  puis combine les résultats avec l'opération op_enchaine.
  La partition est supposée non vide.
  """
  if not partition:
    raise ValueError("La partition ne doit pas être vide pour le chef d'orchestre !")

  # Étape 1 : Transformer chaque élément (utiliser ce qu'on a appris à l'Exercice 2 !)
  elements_transformes = [f_transforme(x) for x in partition]

  # Étape 2 : Enchaîner les éléments transformés
  resultat_final = elements_transformes[0]
  for i in range(1, len(elements_transformes)):
    resultat_final = op_enchaine(resultat_final, elements_transformes[i])

  return resultat_final

# --- Appliquons notre chef d'orchestre pour créer des chaînes stylées ! ---

# La "recette" pour transformer un nombre en texte (f_transforme)
def en_chaine(nombre):
  return str(nombre)

# L'"opération" pour enchaîner les textes avec une virgule (op_enchaine)
def concat_avec_virgule(texte1, texte2):
  return texte1 + ", " + texte2

mes_chiffres_pour_affichage = [1, 2, 3, 4]
autres_nombres = [100, 200, 300, 400, 500]
nombres_aleatoires = [42, 7, 99, 13, 27]


print(f"Liste originale : {mes_chiffres_pour_affichage}")
chaine_finale = chef_dorchestre(en_chaine, concat_avec_virgule, mes_chiffres_pour_affichage)
print(f"Représentation en chaîne : \"{chaine_finale}\"")
print("-" * 30)

print(f"Liste originale : {autres_nombres}")
chaine_finale_2 = chef_dorchestre(en_chaine, concat_avec_virgule, autres_nombres)
print(f"Représentation en chaîne : \"{chaine_finale_2}\"")
print("-" * 30)

print(f"Liste originale : {nombres_aleatoires}")
chaine_finale_3 = chef_dorchestre(en_chaine, concat_avec_virgule, nombres_aleatoires)
print(f"Représentation en chaîne : \"{chaine_finale_3}\"")


Exercice 4 : Le Multiplicateur de Pouvoirs ! ⚡

Imagine que tu as un super-pouvoir (une fonction f). Tu veux une machine qui, quand tu lui donnes ce super-pouvoir, te crée un nouveau super-pouvoir qui fait exactement la même chose… mais deux fois de suite !

Exercice : Écris une fonction amplificateur_de_pouvoirs(super_pouvoir) qui prend une fonction super_pouvoir en argument. Cette fonction amplificateur_de_pouvoirs doit te retourner une nouvelle fonction. Cette nouvelle fonction, quand tu l’appelleras avec une valeur, appliquera super_pouvoir deux fois de suite à cette valeur.

Ensuite, essaie de deviner (puis vérifie avec le code) le résultat de : amplificateur_de_pouvoirs(amplificateur_de_pouvoirs(lambda x: x + 3))(5) (C’est un peu un défi pour les méninges, mais on y va étape par étape !)

Connaissances nécessaires :

  • Fonctions comme valeurs de retour : Une fonction peut non seulement prendre des fonctions en argument, mais aussi en retourner ! C’est une notion clé en programmation fonctionnelle.
  • lambda fonctions (fonctions anonymes) : Elles permettent de créer des petites fonctions très rapidement, sans leur donner de nom. Utile pour des fonctions simples qu’on n’utilisera qu’une fois.
    • Format : lambda arguments: expression
    • Exemple : lambda x: x * 2 est une fonction qui prend x et retourne x * 2.

Python

# Solution Exercice 4 : Le Multiplicateur de Pouvoirs

def amplificateur_de_pouvoirs(super_pouvoir):
  """
  Prend une fonction (super_pouvoir) et retourne une NOUVELLE fonction
  qui applique le super_pouvoir deux fois de suite.
  """
  def nouveau_super_pouvoir(argument):
    # Applique le super_pouvoir une première fois
    resultat_une_fois = super_pouvoir(argument)
    # Applique le super_pouvoir une deuxième fois sur le résultat précédent
    return super_pouvoir(resultat_une_fois)
  
  return nouveau_super_pouvoir # On retourne la NOUVELLE fonction !

# --- Testons le défi de l'amplificateur ! ---

# Notre super_pouvoir de base : ajouter 3
ajoute_trois = lambda x: x + 3
print(f"Fonction de base (ajoute 3) : lambda x: x + 3")

# Étape 1 : un premier amplificateur
# amplificateur_de_pouvoirs(ajoute_trois) retourne une fonction qui fait (x + 3) + 3 = x + 6
ajoute_six = amplificateur_de_pouvoirs(ajoute_trois)
print(f"Après 1er amplificateur (ajoute 6) : {ajoute_six(5)}") # Devrait donner 5 + 6 = 11

# Étape 2 : un deuxième amplificateur sur le premier amplificateur !
# amplificateur_de_pouvoirs(ajoute_six) retourne une fonction qui fait (x + 6) + 6 = x + 12
ajoute_douze = amplificateur_de_pouvoirs(ajoute_six)
print(f"Après 2ème amplificateur (ajoute 12) : {ajoute_douze(5)}") # Devrait donner 5 + 12 = 17

# Le défi complet : amplificateur_de_pouvoirs(amplificateur_de_pouvoirs(lambda x: x + 3))(5)
# Cela se traduit par : ajoute_douze(5)
resultat_defi = amplificateur_de_pouvoirs(amplificateur_de_pouvoirs(ajoute_trois))(5)
print(f"Résultat du défi : amplificateur_de_pouvoirs(amplificateur_de_pouvoirs(lambda x: x + 3))(5) = {resultat_defi}")

# Vérification :
# lambda x: x + 3 appliquée une fois donne x + 3
# amplificateur_de_pouvoirs(lambda x: x + 3) -> fonction qui fait (x + 3) + 3 = x + 6
# amplificateur_de_pouvoirs(cela) -> fonction qui fait (x + 6) + 6 = x + 12
# Donc, (lambda x: x + 12)(5) = 5 + 12 = 17. Le résultat est bien 17.


Exercice 5 : Le Mixeur de Potions Magiques ! 🧪


Imagine que tu as deux potions magiques : une potion A (g) et une potion B (f). Tu veux créer une troisième potion (h) qui combine les effets des deux : tu verses d’abord l’ingrédient dans la potion A, et le résultat de la potion A est ensuite versé dans la potion B.

Exercice : Écris une fonction mixeur_de_potions(potion_b, potion_a) qui prend deux fonctions (potion_b et potion_a) en arguments. Elle doit retourner une nouvelle fonction (potion_combine) qui représente leur composition. Autrement dit, si tu donnes un ingrédient x à potion_combine, elle appliquera d’abord potion_a à x, puis appliquera potion_b au résultat de potion_a(x).

Connaissances nécessaires : Aucune nouvelle connaissance, c’est une application directe de la notion de fonction retournant une fonction, comme dans l’exercice précédent.


Python

# Solution Exercice 5 : Le Mixeur de Potions Magiques

def mixeur_de_potions(potion_b, potion_a):
  """
  Compose deux fonctions (potion_b après potion_a) et retourne la fonction combinée.
  """
  def potion_combine(ingredient_de_base):
    # D'abord, l'ingrédient passe par la potion_a
    resultat_de_a = potion_a(ingredient_de_base)
    # Ensuite, le résultat de la potion_a passe par la potion_b
    resultat_final = potion_b(resultat_de_a)
    return resultat_final
  
  return potion_combine # On retourne la nouvelle potion combinée !

# --- Testons notre mixeur ! ---

# Nos potions de base
def potion_grossissante(nombre): # Potion A (g)
  return nombre * 10

def potion_effacante(nombre): # Potion B (f)
  return str(nombre) + " effacé !"

def potion_magique_carre(x): # Une autre potion A
  return x * x

def potion_magique_plus_un(y): # Une autre potion B
  return y + 1

# Créons une potion combinée : d'abord multiplier par 10, puis ajouter " effacé !"
ma_potion_finale = mixeur_de_potions(potion_effacante, potion_grossissante)
print(f"Effet de ma_potion_finale(5) : {ma_potion_finale(5)}") # Devrait donner "50 effacé !"
print(f"Effet de ma_potion_finale(12) : {ma_potion_finale(12)}") # Devrait donner "120 effacé !"
print("-" * 30)

# Créons une autre potion combinée : d'abord au carré, puis +1
potion_carre_plus_un = mixeur_de_potions(potion_magique_plus_un, potion_magique_carre)
print(f"Effet de potion_carre_plus_un(3) : {potion_carre_plus_un(3)}") # Devrait donner (3*3)+1 = 10
print(f"Effet de potion_carre_plus_un(5) : {potion_carre_plus_un(5)}") # Devrait donner (5*5)+1 = 26


Exercice 6 L’Alchimiste des Ensembles ! 🧪✨

Dans le monde de la programmation fonctionnelle, même quand on manipule des collections, on essaie de ne pas les « abîmer » ou les modifier directement. Python nous aide avec ses ensembles (sets) qui ont des opérations super pratiques et non destructives pour la plupart.

Connaissances nécessaires :

  • Les ensembles (sets) en Python : Ce sont des collections non ordonnées d’éléments uniques. Ils sont très utiles pour les opérations mathématiques d’ensembles.
  • Création d’un ensemble : mon_set = {1, 2, 3} ou mon_set = set([1, 2, 3])
  • Opérations d’ensembles (non destructives) :
    • Union (|) : Combine les éléments des deux ensembles. A | B
    • Intersection (&) : Trouve les éléments communs aux deux ensembles. A & B
    • Différence (-) : Trouve les éléments du premier ensemble qui ne sont pas dans le second. A - B

Exercice : Imagine une « identité » mystérieuse entre trois ensembles A, B, C (des groupes d’éléments, par exemple) : A ∩ (B ∪ C) = (A ∩ B) ∪ (A ∩ C)

C’est une loi de la logique et des ensembles appelée la distributivité de l’intersection sur l’union.

Écris un code Python qui :

  1. Définit trois ensembles d’exemple (par exemple, des nombres ou des lettres).
  2. Calcule le côté gauche de l’identité : A ∩ (B ∪ C).
  3. Calcule le côté droit de l’identité : (A ∩ B) ∪ (A ∩ C).
  4. Vérifie si les deux résultats sont égaux.

L’objectif est de n’utiliser que les opérations d’ensembles |, -, et & qui renvoient de nouveaux ensembles sans modifier les originaux !


Python

# Solution Exercice 6 : L'Alchimiste des Ensembles

# Nos trois ensembles magiques (ils restent intacts, promis !)
set_A = {1, 2, 3, 4, 5, 6}
set_B = {4, 5, 6, 7, 8, 9}
set_C = {2, 3, 7, 8, 10, 11}

print(f"Ensemble A : {set_A}")
print(f"Ensemble B : {set_B}")
print(f"Ensemble C : {set_C}")
print("-" * 30)

# Calcul du côté gauche : A ∩ (B ∪ C)
# Étape 1 : B ∪ C (Union de B et C)
b_union_c = set_B | set_C
print(f"B ∪ C : {b_union_c}")

# Étape 2 : A ∩ (résultat de l'étape 1) (Intersection de A et (B ∪ C))
cote_gauche = set_A & b_union_c
print(f"A ∩ (B ∪ C) : {cote_gauche}")
print("-" * 30)

# Calcul du côté droit : (A ∩ B) ∪ (A ∩ C)
# Étape 1 : A ∩ B (Intersection de A et B)
a_inter_b = set_A & set_B
print(f"A ∩ B : {a_inter_b}")

# Étape 2 : A ∩ C (Intersection de A et C)
a_inter_c = set_A & set_C
print(f"A ∩ C : {a_inter_c}")

# Étape 3 : (résultat étape 1) ∪ (résultat étape 2) (Union de (A ∩ B) et (A ∩ C))
cote_droit = a_inter_b | a_inter_c
print(f"(A ∩ B) ∪ (A ∩ C) : {cote_droit}")
print("-" * 30)

# Vérification finale : Sont-ils identiques ?
est_egal = (cote_gauche == cote_droit)
print(f"Les deux côtés sont-ils égaux ? {est_egal}")

if est_egal:
  print("L'identité de distributivité de l'intersection sur l'union est bien vérifiée !")
else:
  print("Oups, quelque chose ne va pas... L'identité n'est pas vérifiée.")


Exercice 7 Le Compte à Rebours Zen ! 🧘

un :

Imagine que tu es un maître zen et que tu dois répéter une méditation (f) un certain nombre de fois (n), mais chaque méditation doit être suivie d’un moment de silence paisible (t secondes de pause).

Exercice : Écris une fonction compte_a_rebours_zen(meditation_fonction, nombre_repetitions, duree_pause_en_secondes) qui prend :

  • meditation_fonction : une fonction à appeler. Cette fonction recevra le numéro de l’appel (0, 1, 2, …) comme argument.
  • nombre_repetitions : le nombre de fois que la meditation_fonction doit être appelée.
  • duree_pause_en_secondes : la durée de la pause entre chaque appel.

La fonction devra appeler meditation_fonction de 0 à nombre_repetitions - 1, en faisant une pause après chaque appel.

Connaissances nécessaires :

  • Le module time et sa fonction time.sleep().

Explication de time.sleep() : Cette fonction met en pause l’exécution de ton programme pendant le nombre de secondes spécifié. C’est très utile pour simuler des délais, ou dans notre cas, pour représenter des moments de silence.

Python

import time # N'oublie pas d'importer le module time !

# Exemple :
print("Début...")
time.sleep(2) # Attend 2 secondes
print("...Fin !")


Python

# Solution Exercice 7 : Le Compte à Rebours Zen

import time # Indispensable pour les pauses !

def compte_a_rebours_zen(meditation_fonction, nombre_repetitions, duree_pause_en_secondes):
  """
  Appelle une fonction 'nombre_repetitions' fois, avec une pause entre chaque appel.

  Args:
    meditation_fonction: La fonction à appeler. Elle recevra le numéro d'itération.
    nombre_repetitions: Le nombre total d'appels.
    duree_pause_en_secondes: La durée de la pause en secondes après chaque appel.
  """
  print(f"Début du compte à rebours zen pour {nombre_repetitions} répétitions de {meditation_fonction.__name__}...")
  for i in range(nombre_repetitions):
    meditation_fonction(i) # Appel de la fonction avec le numéro de l'itération
    if i < nombre_repetitions - 1: # On ne fait pas de pause après le dernier appel
      time.sleep(duree_pause_en_secondes)
  print("Fin du compte à rebours zen. Namaste !")

# --- Testons notre maître zen ! ---

# Une fonction de méditation simple
def petite_meditation(numero):
  print(f"Méditation n°{numero + 1} : 'Inspirer la sérénité...'")

def verification_chrono(etape):
  print(f"Vérification de l'étape {etape} : Top !")

print("--- Test 1 : Méditation courte ---")
compte_a_rebours_zen(petite_meditation, 3, 1) # 3 méditations, 1 seconde de pause

print("\n--- Test 2 : Vérification rapide ---")
compte_a_rebours_zen(verification_chrono, 5, 0.5) # 5 vérifications, 0.5 seconde de pause


Exercice 8 Le Chronométreur Magique ! ⏱️✨

Parfois, on veut savoir non seulement ce que fait notre sortilège (fonction), mais aussi combien de temps il lui faut pour opérer sa magie ! C’est là que le chronométreur magique entre en scène.

Exercice : Écris une fonction chronometreur_magique(sortilege, cible_magique) qui prend :

  • sortilege : une fonction à tester.
  • cible_magique : l’argument sur lequel lancer le sortilege.

Cette fonction doit retourner un duo magique :

  1. Le résultat du sortilege appliqué à la cible_magique.
  2. Le temps (en secondes) qu’il a fallu pour que le sortilege s’exécute.

Utilise la fonction time.perf_counter() pour une mesure de temps précise (elle est super pour mesurer des durées courtes et répétées !).

Ensuite, mesure le temps pris par une boucle_infini_lente qui simule une tâche longue et ennuyeuse :

Python

def boucle_infini_lente(iterations):
    # Imagine qu'on compte des grains de sable un par un
    compteur_sable = 0
    for i in range(iterations):
        compteur_sable += 1 # Un grain de sable de plus
    return compteur_sable

Mesure le temps pris par boucle_infini_lente avec un argument de 10 000 000 (dix millions). Prépare-toi, ça peut prendre un petit moment !

Connaissances nécessaires :

  • Le module time et la fonction time.perf_counter().

Explication de time.perf_counter() : time.perf_counter() retourne la valeur d’un compteur de performance, qui est très précis pour mesurer des durées (il n’est pas affecté par les ajustements de l’horloge système). Pour mesurer une durée :

  1. Appelle time.perf_counter() avant l’opération.
  2. Appelle time.perf_counter() après l’opération.
  3. La différence entre les deux est le temps écoulé.

import time

temps_debut = time.perf_counter()
# Fais quelque chose qui prend du temps ici
for _ in range(1000000):
    pass
temps_fin = time.perf_counter()
temps_ecoule = temps_fin - temps_debut
print(f"Temps écoulé : {temps_ecoule} secondes")


Python

# Solution Exercice 8 : Le Chronométreur Magique

import time

def chronometreur_magique(sortilege, cible_magique):
  """
  Mesure le temps d'exécution d'un sortilège (fonction) sur une cible.

  Args:
    sortilege: La fonction à chronométrer.
    cible_magique: L'argument à passer à la fonction.

  Returns:
    Une paire (tuple) : (résultat_du_sortilege, temps_pris_en_secondes).
  """
  temps_avant = time.perf_counter() # On démarre le chrono
  resultat = sortilege(cible_magique) # On lance le sortilège
  temps_apres = time.perf_counter() # On arrête le chrono
  
  temps_total = temps_apres - temps_avant
  return (resultat, temps_total)

# La fonction de notre "boucle_infini_lente"
def boucle_infini_lente(iterations):
    compteur_sable = 0
    for i in range(iterations):
        compteur_sable += 1
    return compteur_sable

# --- Testons le chronométreur sur notre boucle lente ! ---

grand_nombre_iterations = 10_000_000 # Dix millions (les underscores rendent le nombre plus lisible)

print(f"Mesure du temps pour 'boucle_infini_lente' avec {grand_nombre_iterations} itérations...")
resultat_boucle, temps_boucle = chronometreur_magique(boucle_infini_lente, grand_nombre_iterations)

print(f"Le résultat de la boucle est : {resultat_boucle}")
print(f"Le temps pris par la boucle est : {temps_boucle:.4f} secondes") # .4f pour 4 décimales

print("\n--- Test rapide avec une fonction simple ---")
def addition_simple(a):
    return a + 100

resultat_add, temps_add = chronometreur_magique(addition_simple, 50)
print(f"Résultat de l'addition simple : {resultat_add}")
print(f"Temps pris pour l'addition simple : {temps_add:.6f} secondes") # Plus de décimales pour les courtes durées


Exercice 9 L’Adaptateur de Sortilèges ! 🧙‍♂️⚙️

Notre chronometreur_magique est génial, mais il a un petit défaut : il ne peut mesurer que les sortilèges qui prennent un seul argument ! Mais que faire si notre sortilège secret a besoin de plusieurs ingrédients (plusieurs arguments) ? Pas de panique, la programmation fonctionnelle a une astuce : les fonctions anonymes (lambda) peuvent venir à la rescousse pour « envelopper » notre sortilège complexe !

Exercice : Utilise ton chronometreur_magique de l’exercice précédent pour mesurer le temps d’exécution de la fonction boucle_geante_nichee suivante :

Python

def boucle_geante_nichee(longueur_x, longueur_y):
    # Imagine que tu comptes des étoiles dans deux constellations différentes
    etoiles_comptees = 0
    for x in range(longueur_x):
        for y in range(longueur_y):
            etoiles_comptees += 1
    return etoiles_comptees

Mesure le temps pris par un appel de boucle_geante_nichee(1000, 10000) sans modifier ni chronometreur_magique ni boucle_geante_nichee. Indique clairement comment tu utilises la lambda fonction pour faire le lien.

Connaissances nécessaires :

  • lambda fonctions pour capturer des arguments : Les fonctions lambda sont parfaites pour créer des petites fonctions « à la volée » qui peuvent fixer certains arguments d’une autre fonction.

Python

# Solution Exercice 9 : L'Adaptateur de Sortilèges

import time

# On réutilise notre chronometreur_magique de l'exercice précédent
def chronometreur_magique(sortilege, cible_magique):
  temps_avant = time.perf_counter()
  resultat = sortilege(cible_magique) # La fonction est appelée avec un SEUL argument
  temps_apres = time.perf_counter()
  temps_total = temps_apres - temps_avant
  return (resultat, temps_total)

# La fonction de notre "boucle_geante_nichee" qui a DEUX arguments
def boucle_geante_nichee(longueur_x, longueur_y):
    etoiles_comptees = 0
    for x in range(longueur_x):
        for y in range(longueur_y):
            etoiles_comptees += 1
    return etoiles_comptees

# --- Comment utiliser chronometreur_magique avec boucle_geante_nichee ? ---

# L'astuce est de créer une fonction lambda qui "enveloppe" boucle_geante_nichee
# et lui fournit les deux arguments nécessaires, tout en ne prenant qu'UN SEUL argument
# (qui sera ignoré par cette lambda, ou pourrait être un argument "factice" si nécessaire).

# Ici, la lambda est très simple : elle ne prend aucun argument réel (x)
# mais appelle boucle_geante_nichee avec les arguments fixes que nous voulons tester.
# Le 'cible_magique' de chronometreur_magique sera juste un placeholder ici.
# On aurait pu aussi faire : lambda _ : boucle_geante_nichee(1000, 10000)
# Le underscore _ est une convention pour un argument qu'on n'utilise pas.

# Méthode 1 : lambda qui ne prend pas vraiment d'argument "utile"
fonction_pour_chrono_1 = lambda x: boucle_geante_nichee(1000, 10000)
# L'argument 'cible_magique' de chronometreur_magique peut être n'importe quoi ici, par ex None
resultat_boucle_1, temps_boucle_1 = chronometreur_magique(fonction_pour_chrono_1, None)

print(f"--- Mesure de boucle_geante_nichee(1000, 10000) ---")
print(f"Le résultat de la boucle est : {resultat_boucle_1}")
print(f"Le temps pris par la boucle est : {temps_boucle_1:.4f} secondes")

print("\n--- Autre façon de penser la lambda (plus explicite) ---")
# On peut aussi créer une lambda qui "fixe" les arguments et ne prend plus qu'un argument vide
# pour satisfaire la signature de chronometreur_magique
def preparer_boucle_geante():
    return boucle_geante_nichee(1000, 10000)

resultat_boucle_2, temps_boucle_2 = chronometreur_magique(preparer_boucle_geante, None) # On passe None car preparer_boucle_geante n'a pas d'argument
print(f"Le résultat de la boucle est : {resultat_boucle_2}")
print(f"Le temps pris par la boucle est : {temps_boucle_2:.4f} secondes")

# Note : Dans des cas plus complexes, on utiliserait le concept de "currying"
# ou des outils comme `functools.partial` pour créer ces fonctions adaptées.


Exercice 10 : Le Compteur de Clics Magique ! 🖱️🔢

La programmation événementielle, c’est un peu comme si ton programme attendait que quelque chose de spécial se passe (un « événement », comme un clic de souris !) pour réagir. C’est le cœur de toutes les applications avec des boutons, des menus, etc. Et devine quoi ? Les fonctions pures et l’absence d’effets de bord peuvent rendre ces réactions super prévisibles !

Exercice : Reprends le squelette de code Tkinter (la bibliothèque pour créer des interfaces graphiques en Python). Ton objectif est de transformer le bouton en un compteur de clics interactif. À chaque fois que l’utilisateur cliquera sur le bouton, le nombre affiché sur le bouton devra s’incrémenter de 1.

Connaissances nécessaires :

  • Concepts de base de Tkinter : tk.Tk() pour la fenêtre, tk.Button() pour un bouton, b.pack() pour placer un widget, fenetre.mainloop() pour lancer la boucle d’événements.
  • Variable IntVar de Tkinter : Pour gérer des nombres qui peuvent changer et être affichés dans des widgets. C’est une variable spéciale qui « sait » quand elle est modifiée et met à jour automatiquement l’affichage du widget. C’est plus fonctionnel que de modifier directement le texte du bouton à la volée car Tkinter gère l’état pour nous.
  • command dans Tkinter : L’argument command d’un bouton prend une fonction. Cette fonction est appelée sans argument lorsque le bouton est cliqué.

# Solution Exercice 10 : Le Compteur de Clics Magique

import tkinter as tk

fenetre = tk.Tk()

# Connaissance nécessaire : Utiliser une IntVar pour que Tkinter gère l'affichage dynamique
# C'est une façon plus propre de gérer l'état pour les widgets graphiques.
compteur_clics = tk.IntVar() # On crée une variable Tkinter pour notre compteur
compteur_clics.set(0) # On initialise sa valeur à 0

# Le bouton : maintenant son texte est lié à notre IntVar
b = tk.Button(fenetre, textvariable=compteur_clics) # 'textvariable' lie le texte à l'IntVar
b.pack(pady=50) # Juste pour le centrer un peu

# --- Ici, le code demandé pour l'incrémentation ! ---

# La fonction qui sera appelée quand on clique sur le bouton
# Elle n'a pas besoin de prendre d'argument (l'événement est géré par Tkinter en interne)
def incrementer_compteur():
  # On récupère la valeur actuelle de l'IntVar, on l'incrémente, et on la met à jour
  valeur_actuelle = compteur_clics.get()
  compteur_clics.set(valeur_actuelle + 1)
  # Pas d'effet de bord sur une variable globale "normale" ici, on utilise l'IntVar de Tkinter
  # qui est conçue pour gérer l'état de l'interface de manière contrôlée.

# On associe notre fonction au bouton via l'argument 'command'
b.config(command=incrementer_compteur)

# --- Fin du code demandé ---

fenetre.title("Cliquer sur le bouton, magie !")
fenetre.geometry("400x200") # Fenêtre un peu plus petite
fenetre.mainloop()


Exercice 11 : La Symphonie des Chiffres ! 🎼

Dans ce défi, tu es le chef d’orchestre d’une symphonie de chiffres ! Tu as une étiquette qui doit afficher le « chiffre du jour », et 10 boutons, chacun représentant un chiffre de 0 à 9. Quand tu cliques sur un bouton, l’étiquette doit afficher le chiffre de ce bouton.

Exercice : Complète le code Tkinter pour que :

  1. L’étiquette chiffre affiche initialement « Cliquez sur un bouton ! ».
  2. Quand un bouton (de 0 à 9) est cliqué, l’étiquette affiche le chiffre correspondant à ce bouton.

Attention au piège classique de la boucle ! Souviens-toi que lorsque b.bind("<Button-1>", changer_chiffre(c)) est exécuté dans la boucle, changer_chiffre(c) est appelé immédiatement, et c’est le résultat de cet appel qui est lié à l’événement. Or, tu veux que la fonction soit appelée plus tard, quand le bouton est cliqué, avec la bonne valeur de c pour ce bouton spécifique.

Connaissances nécessaires :

  • tk.Label : Un widget pour afficher du texte ou des images.
  • label.config(text=...) : Pour changer le texte d’une étiquette après sa création.
  • grid() : Un gestionnaire de géométrie qui place les widgets dans une grille.
  • Piège de la closure dans les boucles : Quand tu définis une fonction (ou lambda) dans une boucle qui capture une variable de la boucle, cette variable est évaluée au moment où la fonction est appelée, pas au moment où elle est définie. Pour « fixer » la valeur de la variable pour chaque itération, tu dois la passer comme argument par défaut à une lambda ou à une fonction interne.

Explication du piège et de la solution : Si tu écris b.bind("<Button-1>", changer_chiffre(c)), la fonction changer_chiffre est appelée tout de suite dans la boucle for, avec la valeur de c de l’itération courante. Et le résultat de cet appel est ce qui est attaché au bouton. Ce n’est pas ce que tu veux. Tu veux que changer_chiffre soit appelée plus tard, quand le bouton est cliqué.

La solution typique est d’utiliser une lambda fonction qui capture la valeur correcte de c pour chaque bouton :

Python

# Mauvais (piège !) :
# b.bind("<Button-1>", changer_chiffre(c))
# Ici, changer_chiffre(c) est appelé immédiatement, et le résultat est lié.

# Bon (solution) :
# b.bind("<Button-1>", lambda event, arg=c: changer_chiffre(arg))
# Ou plus simple pour 'command' (mais ici c'est bind) :
# b.config(command=lambda arg=c: changer_chiffre(arg))
# La 'lambda' crée une petite fonction qui, elle, sera appelée plus tard.
# L'argument `event` est souvent passé par Tkinter aux fonctions liées à `bind`,
# d'où la nécessité de le capturer si la lambda est directement liée par `bind`.
# Si la lambda est utilisée avec 'command=', pas besoin de 'event'.

# Dans notre cas, avec 'bind', la fonction passée reçoit un objet 'event'.
# Donc si changer_chiffre(c) attend juste `c`, il faut ignorer `event`.
# La meilleure pratique pour `bind` quand on veut passer un argument spécifique :
# `b.bind("<Button-1>", lambda event, chiffre_a_afficher=c: changer_chiffre(chiffre_a_afficher))`
# L'argument par défaut `chiffre_a_afficher=c` "fixe" la valeur de `c` pour chaque lambda créée dans la boucle.


# Solution Exercice 11 : La Symphonie des Chiffres

import tkinter as tk

fenetre = tk.Tk()

# L'étiquette qui affichera le chiffre
chiffre_affiche = tk.Label(fenetre, text="Cliquez sur un bouton !", font=("Arial", 24))
chiffre_affiche.grid(row=1, column=1, columnspan=10, pady=20) # Mieux centré

# --- Ici, le code à compléter pour la fonction changer_chiffre ! ---

def changer_chiffre(chiffre_a_afficher):
  """
  Met à jour le texte de l'étiquette 'chiffre_affiche' avec le chiffre donné.
  Cette fonction est "pure" dans le sens où elle ne modifie pas de variables externes directes,
  mais agit sur un widget Tkinter via sa méthode config.
  """
  # On utilise la méthode 'config' de l'objet Label pour changer son texte
  chiffre_affiche.config(text=chiffre_a_afficher)

# --- Fin du code à compléter ---

# Création des 10 boutons
for i in range(10):
   chiffre_bouton = str(i) # Le chiffre à afficher sur le bouton et à passer à la fonction
   b = tk.Button(fenetre, text=chiffre_bouton, font=("Arial", 16), width=4, height=2)
   b.grid(row=2, column=i+1, padx=2, pady=10) # Placement en grille

   # LE PIÈGE ET SA SOLUTION !
   # On utilise une lambda pour "capturer" la valeur de chiffre_bouton pour chaque itération.
   # 'event=None' est souvent inclus car .bind() passe un objet événement.
   # 'chiffre_pour_callback=chiffre_bouton' crée un argument par défaut qui fixe la valeur de la variable
   # au moment de la définition de la lambda, évitant le problème de la closure.
   b.bind("<Button-1>", lambda event, chiffre_pour_callback=chiffre_bouton: changer_chiffre(chiffre_pour_callback))

fenetre.title("Symphonie des Chiffres")
fenetre.geometry("600x250") # Fenêtre adaptée
fenetre.mainloop()


Exercice 12 : L’Agence de Voyage Spatiale Immuable ! 🚀🌌


Bienvenue à l’Agence de Voyage Spatiale « Pure & Simple » ! Ici, les coordonnées des planètes (nos « points ») et les formations stellaires (nos « triangles ») sont immuables. Une fois que tu as défini une planète, elle ne change jamais de place ! Si tu veux la « déplacer », nous te donnons simplement une nouvelle planète à ses nouvelles coordonnées. C’est l’essence de l’immuabilité en programmation fonctionnelle : pas de modification en place, juste de nouvelles créations !

Exercice : Utilise la bibliothèque point_imm.py fournie ci-dessous pour :

  1. Crée 4 « planètes » : alpha (2,5), beta (7,1), gamma (3,8), et delta (9,4).
  2. Écris une fonction deplacer_formation_stellaire(formation_orig, deplacement_x, deplacement_y) qui prend une formation stellaire (formation_orig, qui est un triangle) et des valeurs de déplacement (deplacement_x, deplacement_y). Elle doit retourner une nouvelle formation stellaire où chaque planète a été déplacée. Rappelle-toi : utilise la fonction deplacer existante et crée toujours de nouvelles entités !
  3. Crée deux « constellations » :
    • constellation_matin : formée des planètes alpha, beta, gamma.
    • constellation_soir : formée des planètes beta, gamma, delta.
  4. Lance des « expéditions » pour créer de nouvelles constellations :
    • constellation_lointaine_1 : obtenue en déplaçant constellation_matin de (-1, -1).
    • constellation_lointaine_2 : obtenue en déplaçant constellation_soir de (2, 3).

Affiche toutes tes planètes et constellations pour voir la magie de l’immuabilité !

Connaissances nécessaires : Revoir la manipulation des tuples, et bien comprendre que les fonctions de cette bibliothèque ne modifient pas leurs arguments, mais en renvoient de nouveaux.


# Solution Exercice 12 : L'Agence de Voyage Spatiale Immuable

# fichier point_imm.py (reproduction pour l'exercice)
def point(x, y):
   """construire un point"""
   return (x, y)

def deplacer(p, dx, dy):
   """translation d’un point (renvoie un nouveau point !)"""
   return point(p[0]+dx, p[1]+dy)

def triangle(p1, p2, p3):
   """construction d’un triangle (tuple de points !)"""
   return (p1, p2, p3)

# une constante utile
origine = point(0, 0)

# --- Code Python pour l'exercice ---

# 1. Créer 4 planètes (points)
alpha = point(2, 5)
beta = point(7, 1)
gamma = point(3, 8)
delta = point(9, 4)

print(f"Planète Alpha : {alpha}")
print(f"Planète Beta : {beta}")
print(f"Planète Gamma : {gamma}")
print(f"Planète Delta : {delta}")
print("-" * 30)

# 2. Écrire la fonction deplacer_formation_stellaire
def deplacer_formation_stellaire(formation_orig, deplacement_x, deplacement_y):
  """
  Déplace chaque point d'une formation stellaire (triangle) et renvoie une NOUVELLE formation.
  """
  p1_deplace = deplacer(formation_orig[0], deplacement_x, deplacement_y)
  p2_deplace = deplacer(formation_orig[1], deplacement_x, deplacement_y)
  p3_deplace = deplacer(formation_orig[2], deplacement_x, deplacement_y)
  
  # On crée un nouveau triangle avec les points déplacés
  return triangle(p1_deplace, p2_deplace, p3_deplace)

# 3. Créer deux constellations (triangles)
constellation_matin = triangle(alpha, beta, gamma)
constellation_soir = triangle(beta, gamma, delta)

print(f"Constellation du Matin : {constellation_matin}")
print(f"Constellation du Soir : {constellation_soir}")
print("-" * 30)

# 4. Lancer des expéditions pour créer de nouvelles constellations
deplacement_1_x, deplacement_1_y = -1, -1
deplacement_2_x, deplacement_2_y = 2, 3

constellation_lointaine_1 = deplacer_formation_stellaire(
    constellation_matin, deplacement_1_x, deplacement_1_y
)
constellation_lointaine_2 = deplacer_formation_stellaire(
    constellation_soir, deplacement_2_x, deplacement_2_y
)

print(f"Constellation lointaine 1 (déplacée de {deplacement_1_x},{deplacement_1_y}) : {constellation_lointaine_1}")
print(f"Constellation lointaine 2 (déplacée de {deplacement_2_x},{deplacement_2_y}) : {constellation_lointaine_2}")
print("-" * 30)

# Vérifions que les originales sont intactes (le principe de l'immuabilité !)
print(f"Constellation du Matin (originale, inchangée) : {constellation_matin}")
print(f"Constellation du Soir (originale, inchangée) : {constellation_soir}")


Exercice 13 L’Agence de Voyage Spatiale « Mutante » ! 👾🌌


Bienvenue à l’Agence de Voyage Spatiale « Mutante » ! Ici, les planètes (nos « points ») et les formations stellaires (nos « triangles ») sont… eh bien, mutables ! Ça veut dire que quand tu « déplaces » une planète, c’est la planète elle-même qui change de coordonnées. C’est pratique, mais ça peut créer des surprises et des « effets de bord » imprévus si tu n’y prends pas garde !

Exercice : Utilise la bibliothèque point_mut.py fournie ci-dessous pour explorer les conséquences de la mutabilité.

  1. Question de philosophie : Est-ce une bonne idée d’avoir une variable globale origine = point(0, 0) dans ce système « mutant » ? Explique pourquoi. (Réfléchis à ce qui se passerait si tu déplaces origine par erreur !)
  2. Crée 4 « planètes » : alpha (2,5), beta (7,1), gamma (3,8), et delta (9,4).
  3. Crée deux « constellations » :
    • constellation_matin_mut : formée des planètes alpha, beta, gamma.
    • constellation_soir_mut : formée des planètes beta, gamma, delta.
  4. Lance des « expéditions » pour déplacer les constellations :
    • Déplace constellation_matin_mut de (-1, -1).
    • Déplace constellation_soir_mut de (2, 3). Observe bien les coordonnées de toutes les planètes et constellations après ces opérations. Obtiens-tu les mêmes résultats que dans l’exercice précédent avec l’approche « immuable » pour constellation_lointaine_1 et constellation_lointaine_2 ? Qu’est-ce qui se passe avec beta et gamma ?
  5. Défi de l’ingénieur spatial : Propose une modification (une « mise à jour logicielle ») à la classe triangle dans point_mut.py pour qu’elle puisse déplacer un triangle sans modifier les points originaux qui le composent, mais plutôt en créant un nouveau triangle avec des copies déplacées des points. Fais-la ressembler à la fonction deplacer_formation_stellaire de l’exercice précédent.

Connaissances nécessaires :

  • Objets mutables vs. immuables : Comprendre la différence fondamentale. Les objets mutables peuvent être modifiés après leur création (listes, dictionnaires, instances de classes par défaut). Les objets immuables ne peuvent pas (tuples, nombres, chaînes de caractères).
  • Copie d’objets : Pour éviter les effets de bord avec des objets mutables, il faut parfois faire des copies explicites (ex: point_copy = point(p.x, p.y) ou import copy; copy.deepcopy(obj)).

Python

# Solution Exercice 13 : L'Agence de Voyage Spatiale "Mutante"

# fichier point_mut.py (reproduction pour l'exercice)
class point:
   def __init__(self, x, y):
      self.x = x
      self.y = y

   def deplacer(self, dx, dy):
      # Modifie l'instance du point EN PLACE
      self.x += dx
      self.y += dy
   
   def __repr__(self): # Pour un affichage plus clair du point
       return f"Point({self.x}, {self.y})"

class triangle:
   def __init__(self, p1, p2, p3):
      self.p1 = p1
      self.p2 = p2
      self.p3 = p3

   def deplacer(self, dx, dy):
      # Modifie les points DEJA EXISTANTS du triangle EN PLACE
      self.p1.deplacer(dx, dy)
      self.p2.deplacer(dx, dy)
      self.p3.deplacer(dx, dy)

   def __repr__(self): # Pour un affichage plus clair du triangle
       return f"Triangle({self.p1}, {self.p2}, {self.p3})"

# --- Code Python pour l'exercice ---

# 1. Question de philosophie : Est-il pertinent de rajouter une variable globale `origine` ?
# Réponse : NON ! Ce serait une TRÈS mauvaise idée.
# Si 'origine' était un `point` (objet mutable), et qu'une fonction déplaçait `origine`
# pour une raison quelconque (par exemple, pour un calcul temporaire), alors TOUS
# les autres modules qui utiliseraient `origine` se retrouveraient avec un point déplacé
# sans le savoir. Cela créerait des effets de bord extrêmement difficiles à débugger.
# En programmation orientée objet/impérative avec des objets mutables, les globales sont à éviter,
# surtout si elles représentent un état qui pourrait être modifié.

print("--- Analyse de la mutabilité ---")
print("1. Rajouter une variable globale 'origine' mutable est une mauvaise idée.")
print("   Si l'objet 'origine' est modifié par erreur, cela aurait des effets de bord imprévisibles")
print("   sur toutes les parties du code qui s'attendent à ce qu'il reste (0,0).")
print("-" * 30)

# 2. Créer 4 points a, b, c et d.
alpha_mut = point(2, 5)
beta_mut = point(7, 1)
gamma_mut = point(3, 8)
delta_mut = point(9, 4)

print(f"Planète Alpha (mut) : {alpha_mut}")
print(f"Planète Beta (mut) : {beta_mut}")
print(f"Planète Gamma (mut) : {gamma_mut}")
print(f"Planète Delta (mut) : {delta_mut}")
print("-" * 30)

# 3. Créer deux triangles t1 et t2 constitué de a, b, c et b, c, d.
constellation_matin_mut = triangle(alpha_mut, beta_mut, gamma_mut)
constellation_soir_mut = triangle(beta_mut, gamma_mut, delta_mut) # PARTAGE DES POINTS BETA ET GAMMA !

print(f"Constellation du Matin (mut) AVANT déplacement : {constellation_matin_mut}")
print(f"Constellation du Soir (mut) AVANT déplacement : {constellation_soir_mut}")
print(f"Points individuels AVANT : Beta={beta_mut}, Gamma={gamma_mut}")
print("-" * 30)

# 4. Déplacer t1 de (-1,-1) et t2 de (2,3).
print("Déplacement des constellations...")
constellation_matin_mut.deplacer(-1, -1)
constellation_soir_mut.deplacer(2, 3)

print(f"Constellation du Matin (mut) APRÈS déplacement (-1,-1) : {constellation_matin_mut}")
print(f"Constellation du Soir (mut) APRÈS déplacement (2,3) : {constellation_soir_mut}")
print(f"Points individuels APRÈS : Beta={beta_mut}, Gamma={gamma_mut}")
print("-" * 30)

# Comparaison avec l'Exercice 12 (Partie 1)
# Non, on n'obtient PAS des triangles équivalents aux triangles t3 et t4 de la Partie 1.
# Le problème ici est que beta_mut et gamma_mut sont des objets partagés entre
# constellation_matin_mut et constellation_soir_mut.
# Quand constellation_matin_mut.deplacer(-1, -1) est appelé, beta_mut et gamma_mut sont modifiés.
# Puis, quand constellation_soir_mut.deplacer(2, 3) est appelé, beta_mut et gamma_mut sont à nouveau modifiés,
# mais à partir de LEURS NOUVELLES POSITIONS, et cela affecte aussi constellation_matin_mut
# car elle pointe toujours vers les MÊMES objets beta_mut et gamma_mut.
# C'est l'effet de bord classique de la mutabilité !

print("4. Obtient-on des triangles équivalents à ceux de la Partie 1 ? NON.")
print("   Les points Beta et Gamma sont PARTAGÉS entre les deux triangles. ")
print("   Le déplacement d'un triangle affecte aussi l'autre si les points sont partagés,")
print("   ce qui est une conséquence des effets de bord de la mutabilité.")
print("   Les coordonnées de Beta et Gamma sont le résultat d'une double modification !")
print("-" * 30)


# 5. Proposer une modification de l’implémentation mutable qui corrige le problème.
# L'objectif est de rendre la méthode 'deplacer' de 'triangle' non destructive,
# c'est-à-dire qu'elle retourne un NOUVEAU triangle avec des points déplacés,
# au lieu de modifier les points du triangle original.

import copy # Pour copier les points proprement

# Nouvelle implémentation (fichier point_mut_corrige.py)
class point_corrige:
   def __init__(self, x, y):
      self.x = x
      self.y = y

   def deplacer(self, dx, dy):
      # Cette méthode continue de modifier le point en place,
      # mais la classe triangle_corrige va l'utiliser différemment.
      self.x += dx
      self.y += dy
   
   def __repr__(self):
       return f"Point({self.x}, {self.y})"

class triangle_corrige:
   def __init__(self, p1, p2, p3):
      # Il est crucial que ces points soient des COPIES si on veut éviter les partages implicites
      # lorsque les points originaux sont passés comme arguments.
      # Pour cet exercice, on va se concentrer sur la méthode deplacer pour créer de nouveaux points.
      self.p1 = p1
      self.p2 = p2
      self.p3 = p3

   def deplacer_non_destructif(self, dx, dy):
      """
      Déplace le triangle en renvoyant un NOUVEAU triangle avec les points déplacés.
      Les points du triangle original NE SONT PAS MODIFIÉS.
      """
      # On crée de NOUVEAUX points pour le nouveau triangle
      # On doit copier les points originaux avant de les déplacer pour ne pas modifier les originaux
      new_p1 = point_corrige(self.p1.x, self.p1.y) # Crée une copie du point 1
      new_p2 = point_corrige(self.p2.x, self.p2.y) # Crée une copie du point 2
      new_p3 = point_corrige(self.p3.x, self.p3.y) # Crée une copie du point 3

      new_p1.deplacer(dx, dy) # Déplace la COPIE
      new_p2.deplacer(dx, dy) # Déplace la COPIE
      new_p3.deplacer(dx, dy) # Déplace la COPIE
      
      # On retourne un nouveau triangle avec ces nouveaux points déplacés
      return triangle_corrige(new_p1, new_p2, new_p3)

   def __repr__(self):
       return f"Triangle({self.p1}, {self.p2}, {self.p3})"

print("5. Proposition de modification pour corriger le problème (méthode non-destructive) :")

# Recréons les points originaux pour le test de la version corrigée
alpha_corrige = point_corrige(2, 5)
beta_corrige = point_corrige(7, 1)
gamma_corrige = point_corrige(3, 8)
delta_corrige = point_corrige(9, 4)

constellation_matin_corrige = triangle_corrige(alpha_corrige, beta_corrige, gamma_corrige)
constellation_soir_corrige = triangle_corrige(beta_corrige, gamma_corrige, delta_corrige)

print(f"Constellation du Matin (corrige) AVANT déplacement : {constellation_matin_corrige}")
print(f"Constellation du Soir (corrige) AVANT déplacement : {constellation_soir_corrige}")
print(f"Points individuels AVANT : Beta={beta_corrige}, Gamma={gamma_corrige}")
print("-" * 30)

print("Déplacement des constellations via la nouvelle méthode non-destructive...")
# Les variables _t1 et _t2 sont les NOUVEAUX triangles résultants des déplacements
constellation_lointaine_1_corrige = constellation_matin_corrige.deplacer_non_destructif(-1, -1)
constellation_lointaine_2_corrige = constellation_soir_corrige.deplacer_non_destructif(2, 3)

print(f"Constellation du Matin (corrige) APRÈS déplacement (originale inchangée) : {constellation_matin_corrige}")
print(f"Constellation du Soir (corrige) APRÈS déplacement (originale inchangée) : {constellation_soir_corrige}")
print(f"Points individuels APRÈS (originaux inchangés) : Beta={beta_corrige}, Gamma={gamma_corrige}")
print("-" * 30)

print(f"Nouvelle Constellation lointaine 1 (obtenue du Matin) : {constellation_lointaine_1_corrige}")
print(f"Nouvelle Constellation lointaine 2 (obtenue du Soir) : {constellation_lointaine_2_corrige}")

# Maintenant, les résultats pour constellation_lointaine_1_corrige et _2_corrige
# devraient correspondre à t3 et t4 de la partie 1, car les points originaux
# n'ont pas été modifiés.

Révisions sur la structure de liste en Python

Exercice 1

Écrire une fonction qui reçoit comme argument une liste de nombres à virgule et recherche lequel est le plus grand et lequel est le plus petit.

La spécification de la fonction est la suivante :

1
2
3
4
5
def minmax(liste: list[float]) -> tuple[float]:
    """
    Retourne un tuple constitué des valeurs minimale
    et maximale de la liste
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def minmax(liste: list[float]) -> tuple[float]:
    """
    Retourne un tuple constitué des valeurs minimale
    et maximale de la liste
    """
    val_min = liste[0]
    val_max = liste[0]
    for i in range(1, len(liste) - 1):
        if liste[i] > val_max:
            val_max = liste[i]
        if liste[i] < val_min:
            val_min = liste[i]
    return val_min, val_max

Exercice 2

Écrire une fonction qui reçoit comme argument une liste et détermine le nombre d’éléments dans la liste. Ne pas utiliser la fonction len du langage.

La spécification de la fonction est la suivante :

1
2
3
4
5
def longueur(liste: list[float]) -> int:
    """
    Retourne le nombre d'éléments dans la
    liste
    """

Solution
1
2
3
4
5
6
7
8
9
def longueur(liste: list[float]) -> int:
    """
    Retourne le nombre d'éléments dans la
    liste
    """
    longueur = 0
    for i in range(len(liste)):
        longueur += 1
    return longueur

Exercice 3

Écrire une fonction qui simule le tirage du Loto. Pour rappel, il s’agit de tirer aléatoirement 6 entiers compris entre 1 et 49.

Remarque. Un numéro ne peut apparaître qu’une seule fois. Il est donc nécessaire de stocker le résultat de chaque tirage dans une liste et de vérifier s’il est présent ou pas.

La spécification de la fonction est la suivante :

1
2
3
4
5
def loto() -> list[int]:
    """
    Simule le tirage du Loto. retourne une liste de 6 entiers compris
    entre 1 et 49.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def loto() -> list[int]:
    """
    Simule le tirage du Loto. retourne une liste de 6 entiers compris
    entre 1 et 49.
    """
    tirages = []
    nbre_valeurs = 6
    while len(tirages) <= nbre_valeurs:
        tirage = randint(1, 49)
        if tirage not in tirages:
            tirages.append(tirage)
    return tirages

Exercice 4

Écrire une fonction qui reçoit une liste de notes comprises entre 0 et 20 comme argument et retourne la moyenne de ces notes et le pourcentage de ces notes comprises dans les intervalles : [0, moy – 3], [moy – 3, moy + 3] et [moy + 3, 20] où moy est la valeur moyenne des notes.

La signature de la fonction est :

1
2
3
4
5
6
7
def statistiques(notes: List[float]) -> Tuple[float]:
    """
    Détermine les statistiques des notes reçues 
    en argument : moyenne et notes comprises dans
    les intervalles [0, moy - 3[, [moy - 3, moy + 3[, 
    [moy + 3, 20]
    """

Cette fonction doit utiliser les deux fonctions de signature :

1
2
3
4
5
def moyenne(liste_notes: List[float]) -> float:
    """
    Détermine la moyenne des notes reçues en 
    argument dans la liste passée en argument.
    """

et

1
2
3
4
5
def nombre_dans_intervalle(liste: List[float], valeur_min: float, valeur_max: float) -> float:
    """
    Détermine le nombre de valeurs de liste 
    comprises dans l'intervalle [val_min, val_max[.
    """

Exercice 5

Écrire une fonction qui, partir de deux points de l’espace à trois dimensions, calcule la distance euclidienne entre ces deux points. Remarque. Les coordonnées d’un point sont stockées dans un tuple.

La spécification de la fonction est :

1
2
3
4
5
6
7
def distance(pt1: tuple[float], pt2: tuple[float]) -> float:
    """
    Détermine la distance entre les points pt1 et 
    pt2.
    ERREUR si les dimensions des tuples ne 
    correspondent pas.
    """

Exercice 6

Reprendre l’exercice précédent et considérer que les points appartiennent à un espace de dimension N (N étant potentiellement grand).

La spécification de la fonction est :

1
2
3
4
5
6
7
def distanceN(pt1: Tuple[float], pt2: Tuple[float]) -> float:
    """
    Détermine la distance entre les points pt1 et 
    pt2.
    ERREUR si les dimensions des tuples ne 
    correspondent pas.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from math import sqrt

def distanceN(pt1: tuple[float], pt2: tuple[float]) -> float:
    """
    Détermine la distance entre les points pt1 et
    pt2.

    ERREUR si les dimensions des tuples ne
    correspondent pas.
    """
    if len(pt1) != len(pt2):
        raise Exception("Les dimensions ne correspondent pas !")

    carre_distance: float = 0
    for i in range(len(pt1)):
        carre_distance += (pt2[i] - pt1[i])**2

    return sqrt(carre_distance)

Exercice 7

Écrire une fonction qui retourne une la table de multiplication de tous les nombres entiers compris entre 0 et n sous forme d’une liste de listes.

La spécification de la fonction est :

1
2
3
4
5
6
7
8
def table_multiplication(n: int) -> list[list[int]]:
    """
    Détermine la table de multiplication de tous les 
    entiers compris entre 1 et n.
    
    >>> table_multiplication(4)
    [[1, 2, 3, 4], [2, 4, 6, 8], [3, 6, 9, 12], [4, 8, 12, 16]]
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

def table_multiplication(n: int) -> list[list[int]]:
    """
    Détermine la table de multiplication de tous les 
    entiers compris entre 1 et n.

    >>> table_multiplication(4)
    [[1, 2, 3, 4], [2, 4, 6, 8], [3, 6, 9, 12], [4, 8, 12, 16]]
    """
    table = []
    for i in range(1, n + 1):
        ligne = []
        for j in range(1, n + 1):
            ligne.append(i * j)
        table.append(ligne)
    return table

Révisions sur les structures de boucle en Python

Exercice 1

Écrire et exécuter une fonction qui retourne une chaîne de caractères formée par une suite des 10 premiers termes de la table de multiplication d’un entier a passé en argument. La spécification de la fonction est :

1
2
3
4
5
6
7
8
def multiplication(a: int) -> str:
    """
    Retourne une chaîne de caractères formées des 10 premiers
    nombres de la table de multiplication de a.
    
    >>> multiplication(7)
    '7 14 21 28 35 42 49 56 63 70 '
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def multiplication(a: int) -> str:
    """
    Retourne une chaîne de caractères formées des 10 premiers
    nombres de la table de multiplication de a.

    >>> multiplication(7)
    '7 14 21 28 35 42 49 56 63 70 '
    """
    chaine_retour = ""
    nbre_tours_boucle = 10

    # for i in range(1, nbre_tours_boucle + 1):
    #    chaine_retour += str(a * i)
    #    chaine_retour += " "

    i = 1
    while i <= nbre_tours_boucle:
        chaine_retour += str(a * i)
        chaine_retour += " "
        i += 1

    return chaine_retour

Exercice 2

Écrire et exécuter une fonction qui retourne une chaîne de caractères formée par une suite des 10 premiers termes de la table de multiplication d’un entier a passé en argument en signalant au passage (à l’aide d’un astérisque) ceux qui sont des multiples de 3. La spécification de la fonction est :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def multiplication_multiple_trois(a: int) -> str:
    """
    Retourne une chaîne de caractères formées des 10 premiers
    nombres de la table de multiplication de a en signalant au
    passage ceux qui sont des multiples de 3.
    
    >>> multiplication_multiple_trois(2)
    '2 4 6* 8 10 12* 14 16 18* 20 '
    >>> multiplication_multiple_trois(7)
    '7 14 21* 28 35 42* 49 56 63* 70 '
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def multiplication_multiple_trois(a: int) -> str:
    """
    Retourne une chaîne de caractères formées des 10 premiers
    nombres de la table de multiplication de a en signalant au
    passage ceux qui sont des multiples de 3.

    >>> multiplication_multiple_trois(2)
    '2 4 6* 8 10 12* 14 16 18* 20 '
    >>> multiplication_multiple_trois(7)
    '7 14 21* 28 35 42* 49 56 63* 70 '
    """
    chaine_retour = ""
    nbre_tours_boucle = 10

    for i in range(1, nbre_tours_boucle + 1):
        resultat_multiplication = a * i
        chaine_retour += str(resultat_multiplication)

        if resultat_multiplication % 3 == 0:
            chaine_retour += "*"

        chaine_retour += " "

    return chaine_retour

Exercice 3

Écrire et exécuter une fonction qui calcule les 50 premiers termes de la table de multiplication d’un nombre a passé en argument mais qui retourne une chaîne de caractères formée seulement par ceux qui sont des multiples de 7. La spécification de la fonction est :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def multiplication_multiple_sept(a: int) -> str:
    """
    Calcule les 50 premiers termes de la table de a mais retourne
    une chaîne de caractères contenant uniquement les multiples de 7.
    
    >>> multiplication_multiple_sept(2)
    '14 28 42 56 70 84 98 '
    >>> multiplication_multiple_sept(9)
    '63 126 189 252 315 378 441 '
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def multiplication_multiple_sept(a: int) -> str:
    """
    Calcule les 50 premiers termes de la table de a mais retourne
    une chaîne de caractères contenant uniquement les multiples de 7.

    >>> multiplication_multiple_sept(2)
    '14 28 42 56 70 84 98 '
    >>> multiplication_multiple_sept(9)
    '63 126 189 252 315 378 441 '
    """
    chaine_retour = ""
    nbre_tours_boucle = 50

    for i in range(1, nbre_tours_boucle + 1):
        resultat_multiplication = a * i
        if resultat_multiplication % 7 == 0:
            chaine_retour += str(resultat_multiplication)
            chaine_retour += " "
    return chaine_retour

Exercice 4

Écrire et exécuter une fonction qui calcule et retourne une chaîne de caractères formée de la liste des diviseurs du nombre passé en argument. La spécification de la fonction est :

1
2
3
4
5
6
7
8
def diviseurs(a: int) -> str:
    """
    Retourne une chaîne de caractères formée de la liste des diviseurs
    de a.
    
    >>> diviseurs(18)
    '18 9 6 3 2 1 '
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def diviseurs(a: int) -> str:
    """
    Retourne une chaîne de caractères formée de la liste des diviseurs
    de a.

    >>> diviseurs(18)
    '18 9 6 3 2 1 '
    """
    chaine_retour = ""
    diviseur_possible = a

    # while diviseur_possible >= 1:
    #    if a % diviseur_possible == 0:
    #        chaine_retour += str(diviseur_possible)
    #        chaine_retour += " "
    #    diviseur_possible -= 1

    for i in range(a, 0, -1):  # i est diviseur possible
        if a % i == 0:
            chaine_retour += str(i)
            chaine_retour += " "

    return chaine_retour

Exercice 5

Écrire une fonction qui retourne une chaîne de caractère formée des 10 premiers termes de la table de multiplication de 1 à 10. Le caractère de passage à la ligne \n doit être utilisé afin de séparer les différentes tables (de 2, de 3, etc.).

Remarque : Utiliser deux boucles imbriquées.

La spécification de la fonction est :

1
2
3
4
def table_multiplication() -> str:
    """
    Retourne la table de multiplication des nombres de 1 à 10.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def table_multiplication() -> str:
    """
    Retourne la table de multiplication des nombres de 1 à 10.
    """
    chaine_retour = ""
    nbre_termes = 10

    for i in range(1, nbre_termes + 1):
        for j in range(1, nbre_termes + 1):
            chaine_retour += str(i * j)
            chaine_retour += " "
        chaine_retour += "\n"

    return chaine_retour

Remarque : Afin de visualiser le résultat sous forme d’un tableau, utiliser l’instruction suivante, dans la console, pour tester la fonction :

1
>>> print(table_multiplication())

Exercice 6

Écrire et exécuter une fonction qui demande 10 nombres à l’utilisateur et qui détermine lequel est le plus grand et lequel est le plus petit. Les deux résultats sont retournés au sein d’une unique chaîne de caractères.

Remarque : la fonction qui permet de récupérer du texte entré au clavier est input :

1
valeur = float(input("Entrez votre valeur : "))

La specification de la fonction est :

1
2
3
4
5
def plus_grand_plus_petit() -> str:
    """
    Demande à l'utilisateur d'entrer 10 valeurs et retourne une
    chaîne de caractères formée des deux valeurs max et min.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def plus_grand_plus_petit() -> str:
    """
    Demande à l'utilisateur d'entrer 10 valeurs et retourne une
    chaîne de caractères formée des deux valeurs max et min.
    """
    nbre_tours = 10
    valeur_max = float('-inf')  # Plus petite valeur possible
    valeur_min = float('inf')   # Plus grande valeur possible

    for i in range(1, nbre_tours + 1):
        valeur = float(input(f"Entrez la valeur {i} : "))
        if valeur > valeur_max:
            valeur_max = valeur
        if valeur < valeur_min:
            valeur_min = valeur

    return f"Min : {valeur_min}, Max : {valeur_max}"

Exercice 7

Reprendre l’exercice précédent mais en faisant en sorte que le nombre de valeurs demandées à l’utilisateur soit passé en argument à la fonction.


Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def plus_grand_plus_petit(nbre_tours: int) -> str:
    """
    Demande à l'utilisateur d'entrer 10 valeurs et retourne une
    chaîne de caractères formée des deux valeurs max et min.
    """
    valeur_max = float('-inf')  # Plus petite valeur possible
    valeur_min = float('inf')   # Plus grande valeur possible

    for i in range(1, nbre_tours + 1):
        valeur = float(input(f"Entrez la valeur {i} : "))
        if valeur > valeur_max:
            valeur_max = valeur
        if valeur < valeur_min:
            valeur_min = valeur

    return f"Min : {valeur_min}, Max : {valeur_max}"

Exercice 8

Écrire et exécuter une fonction qui demande à l’utilisateur d’entrer 10 notes et qui retourne la moyenne de ces notes. La spécification de la fonction est :

1
2
3
4
def moyenne() -> float:
    """
    Demande 10 notes à l'utilisateur et retourne la moyenne.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def moyenne() -> float:
    """
    Demande 10 notes à l'utilisateur et retourne la moyenne.
    """
    nbre_notes = 10
    total_notes = 0
    for i in range(nbre_notes):
        note = float(input(f"Entrez la note {i + 1} : "))
        total_notes += note
    return total_notes / nbre_notes

Exercice 9

Modifier le programme précédent de façon à ce que le nombre de notes à prendre en compte soit passé en argument de la fonction.


Solution
1
2
3
4
5
6
7
8
9
def moyenne(nbre_notes: int) -> float:
    """
    Demande 10 notes à l'utilisateur et retourne la moyenne.
    """
    total_notes = 0
    for i in range(nbre_notes):
        note = float(input(f"Entrez la note {i + 1} : "))
        total_notes += note
    return total_notes / nbre_notes

Exercice 10

Modifier le programme précédent de façon à ce que l’utilisateur n’ait pas à indiquer le nombre de notes qu’il souhaite saisir. Une note négative terminer la saisie.

Remarque : la fonction doit afficher le nombre de notes saisies.


Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def moyenne_auto() -> tuple[int, float]:
   """
   Demande des notes à l'utilisateur tant que ce dernier
   ne rentre pas une note négative.
   Retourne le nombre de notes et la moyenne.
   """
   nbre_notes = 0
   total_notes = 0
   stop = False
   while not stop:
       note = float(input(f"Entrez la note {nbre_notes + 1} : "))
       if note < 0:
           stop = True
       else:
           total_notes += note
           nbre_notes += 1
   return (nbre_notes, total_notes / nbre_notes)

Exercice 11

Écrire et exécuter une fonction qui simule un tirage du Loto.
La spécification de la fonction est

1
2
3
4
5
def loto_naif() -> str:
    """
    Retourne 6 entiers sélectionnés aléatoirement dans l'intervalle
    [1, 49].
    """

Remarque. Normalement, lorsqu’un numéro est tiré, il ne peut pas apparaître à nouveau. On acceptera cependant qu’un même numéro puisse apparaitre plusieurs fois puisqu’on ne connaît pas encore de structure de contrôle qui permet de facilement « stocker » plusieurs valeurs.


Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from random import randint

def loto_naif() -> str:
    """
    Retourne 6 entiers sélectionnés aléatoirement dans l'intervalle
    [1, 49].
    """
    nbre_valeurs = 6
    rep = ""
    for i in range(nbre_valeurs):
        valeur = randint(1, 49)
        rep += str(valeur) + " "
    return rep

Exercice 12

Écrire et exécuter une fonction qui tire au hasard un nombre entier compris entre 1 et 50 et demande à l’utilisateur de le deviner.

Cette fonction doit indiquer à l’utilisateur si sa tentative est trop grande ou trop petite et quitter dès l’instant où il a deviné le nombre en indiquant le nombre de tentatives.

Remarque. La fonction ne doit rien retourner, elle doit utiliser la fonction print pour afficher à l’écran les informations. Sa spécification est

1
2
3
4
5
def devine() -> None:
    """
    Déterminer aléatoirement un nombre compris en 1 et 50 et demande à l'utilisateur de le deviner.
    La fonction affiche des messages qui aident l'utilisateur dans recherche et quitte dès que cette dernière est fructueuse.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def devine() -> None:
    """
    Déterminer aléatoirement un nombre compris en 1 et 50 et demande à l'utilisateur de le deviner.
    La fonction affiche des messages qui aident l'utilisateur dans recherche et quitte dès que cette dernière est fructueuse.
    """
    nbre_a_deviner = randint(1, 50)
    nbre_tentatives = 0
    trouve = False
    while not trouve:
        proposition = int(input("Entrez votre proposition : "))
        nbre_tentatives += 1
        if proposition == nbre_a_deviner:
            trouve = True
        elif proposition > nbre_a_deviner:
            print("La proposition est trop grande !")
        else:
            print("La proposition est trop petite !")
    print(f"Nombre de tentatives : {nbre_tentatives}")

Exercice 13

Écrire et exécuter une fonction qui affiche l’alphabet à l’endroit si elle reçoit l’argument "croissant" ou à l’envers si elle reçoit l’argument "decroissant".

Remarque. On peut obtenir le code décimal d’un caractère à l’aide de la fonction ord. À l’opposé, le caractère correspondant à un entier naturel dans la table ASCII est obtenu (si possible) à l’aide de la fonction chr.

La spécification de la fonction est

1
2
3
4
5
6
7
8
def alphabet(sens: str) -> str:
    """
    Retourne les lettres de l'alphabet en fonction de la chaîne de caractères
    passée en argument.
    Les valeurs possibles pour cet argument sont 'croissant' ou 'decroissant'.

    Code ascii [97; 122]
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def alphabet(sens: str) -> str:
    """
    Retourne les lettres de l'alphabet en fonction de la chaîne de caractères
    passée en argument.
    Les valeurs possibles pour cet argument sont 'croissant' ou 'decroissant'.

    Code ascii [97; 122]
    """
    if sens == "croissant":
        debut_code, fin_code, increment, pas = 97, 122, 1, 1
    else:
        debut_code, fin_code, increment, pas = 122, 97, -1, -1

    rep = ""
    for i in range(debut_code, fin_code + increment, pas):
        rep += chr(i)

    return rep

Exercice 14

Écrire et exécuter une fonction qui détermine les n premiers termes de la « suite de Fibonacci » définie par :

u1=1u2=1un=u(n1)+u(n2) pour n>2

Cette fonction doit recevoir en argument la valeur de n et retourner la suite de nombres sous forme de chaîne de caractères. Spécification de la fonction :

1
2
3
4
def fibo(n: int) -> str:
    """
    Retourne les n premiers termes de la suite de Fibonacci.
    """

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def fibo(n: int) -> str:
    """
    Retourne les n premiers termes de la suite de Fibonacci.
    """
    rep = "0 1 "
    u = 0
    v = 1
    for i in range(2, n):
        z = u + v
        rep += str(z) + " "
        u = v
        v = z
    return rep