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

Exercices corrigés de NSI : langages et programmation

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

Le fil de la série : écrire un programme correct et SAVOIR qu'il est correct sont deux choses différentes. La récursivité, les tests, les invariants et les preuves servent la seconde, et c'est elle que le baccalauréat évalue, à travers des questions qui demandent de justifier plutôt que de coder.

Trois pièges sont désignés nommément dans le corrigé : croire qu'une récursion sans cas de base boucle indéfiniment, croire que Fibonacci naïf est lent à cause du langage, et croire qu'un jeu de tests qui passe prouve la correction.

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

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

Avant ce chapitre

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

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

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

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

Rappel de cours

  • Chaque appel récursif empile un cadre. Rien n'est calculé à la descente, tout l'est à la remontée.
  • Sans cas de base, la pile déborde : RecursionError, pas boucle infinie. Python limite la profondeur à environ 1 000.
  • Fibonacci naïf : T(n)=1+T(n1)+T(n2)T(n) = 1 + T(n-1) + T(n-2), soit 2F(n+1)12 F(n+1) - 1 appels. T(30)=2 692 537T(30) = 2\ 692\ 537 contre 31 avec mémoïsation.
  • self désigne l'instance. c.methode(x) est en réalité Classe.methode(c, x).
  • L'encapsulation est un contrat, pas une barrière : le tiret bas signale, il n'interdit pas.
  • Polymorphisme : l'appel self.aire() est résolu à l'exécution selon la classe réelle de l'objet.
  • On hérite quand la sous-classe peut remplacer la classe parente PARTOUT sans surprendre l'appelant.
  • Invariant de boucle : propriété vraie à chaque tour, portant sur le travail déjà fait. Variant : entier positif strictement décroissant.
  • Correction partielle : si ça se termine, c'est juste. Correction totale : et ça se termine.
  • Le problème de l'arrêt est indécidable : aucune méthode universelle. Cela n'empêche pas de prouver la terminaison d'un programme donné.

Partie A : Les bases (/50)

Exercice 1 : Récursivité et pile d'appels

Une fonction récursive s'appelle elle-même. Le langage ne fait rien de magique : il empile un cadre par appel, et c'est cette pile, visible sur la figure, qui explique tout le comportement.

fact(4)renvoie 24fact(3)renvoie 6fact(2)renvoie 2fact(1)renvoie 1sommet
def fact(n):
    if n <= 1:
        return 1
    return n * fact(n - 1)
  • a) Suivez la figure et décrivez l'exécution de fact(4) : ordre d'empilement, moment où le premier résultat apparaît, ordre de dépilement.
  • b) Quel est le cas de base ? Que se passerait-il si on l'oubliait ? Quel message obtiendrait-on exactement ?
  • c) Combien de cadres la pile contient-elle au plus pour fact(n) ? Pour quelle valeur de n un programme Python échoue-t-il par défaut, et pourquoi cette limite existe-t-elle ?
  • d) Réécrivez fact de façon itérative. Comparez les deux versions sur trois critères : lisibilité, mémoire, robustesse.
  • e) Écrivez une fonction récursive qui inverse une chaîne de caractères, puis donnez sa trace sur la chaîne « abcd ».

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

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

Réponses

  • a) fact(4) : quatre cadres, résultat 24 à la remontée
  • b) Sans cas de base : RecursionError
  • c) nn cadres, limite vers 1 000
  • d) Itératif : mémoire constante
  • e) inverse(s[1:]) + s[0] donne dcba

a) L'appel fact(4) empile un cadre, constate que 4 est supérieur à 1 et appelle fact(3) : un deuxième cadre est empilé, puis un troisième pour fact(2), puis un quatrième pour fact(1). C'est à ce moment, et seulement à ce moment, que le premier résultat apparaît : fact(1) atteint le cas de base et renvoie 1 sans rien appeler. Le dépilement suit alors l'ordre INVERSE de l'empilement : fact(2) reçoit 1 et renvoie 2×1=22 \times 1 = 2 ; fact(3) reçoit 2 et renvoie 3×2=63 \times 2 = 6 ; fact(4) reçoit 6 et renvoie 4×6=244 \times 6 = 24. Rien n'est calculé à la descente, tout l'est à la remontée : c'est exactement ce que montre la colonne de droite de la figure.

b) Le cas de base est n inférieur ou égal à 1, qui renvoie 1 sans appel récursif. Sans lui, chaque appel en déclencherait un autre indéfiniment, avec des valeurs de n de plus en plus petites qui ne rencontreraient jamais de condition d'arrêt. La pile grossirait jusqu'à saturation, et Python lèverait une RecursionError, avec le message « maximum recursion depth exceeded ». Le programme ne bouclerait donc pas éternellement : il s'arrêterait par épuisement de la pile, ce qui est une chance, car cela donne un message au lieu d'un gel.

c) Pour fact(n), la pile contient au plus n cadres, un par valeur de n jusqu'au cas de base. Python fixe par défaut une profondeur maximale de récursion de l'ordre de 1 000 : un appel à fact(1500) échoue donc, alors que le calcul lui-même n'aurait rien d'impossible. Cette limite existe parce que la pile d'exécution occupe une zone mémoire de taille fixe, réservée au démarrage du fil : sans garde-fou, une récursion incontrôlée l'écraserait et corromprait les données voisines. Mieux vaut une erreur nette qu'un comportement imprévisible.

d) Version itérative : on initialise un résultat à 1, puis pour k allant de 2 à n on multiplie le résultat par k, et on renvoie le résultat. Comparaison. LISIBILITÉ : la version récursive colle à la définition mathématique, la version itérative demande d'inventer un accumulateur ; avantage au récursif pour la factorielle. MÉMOIRE : la version récursive consomme n cadres de pile, la version itérative une seule variable ; avantage net à l'itératif. ROBUSTESSE : la version itérative fonctionne pour n valant un million, la récursive échoue au-delà de mille ; avantage net à l'itératif. On retient que la récursivité est un outil d'EXPRESSION, excellent quand le problème est naturellement récursif, comme un arbre, et coûteux quand une simple boucle suffit.

e) La fonction : si la chaîne est vide, renvoyer la chaîne vide ; sinon renvoyer l'inverse de tout sauf le premier caractère, suivi du premier caractère. En Python : return inverse(s[1:]) + s[0]. Trace sur « abcd » : inverse('abcd') appelle inverse('bcd') et gardera 'a' ; inverse('bcd') appelle inverse('cd') et gardera 'b' ; inverse('cd') appelle inverse('d') et gardera 'c' ; inverse('d') appelle inverse('') et gardera 'd' ; inverse('') renvoie ''. À la remontée : 'd', puis 'dc', puis 'dcb', puis 'dcba'. Le résultat est 'dcba'. On note au passage que chaque appel construit une nouvelle chaîne, donc cette version coûte un temps quadratique en la longueur, ce que la version itérative évite.

Exercice 2 : Le coût d'une récursivité mal écrite

L'arbre donne tous les appels engendrés par le calcul récursif naïf de fib(4). On y voit apparaître plusieurs fois les mêmes sous-problèmes, et c'est de là que vient la catastrophe.

fib(4)fib(3)fib(2)fib(2)fib(1)fib(1)fib(0)fib(1)fib(0)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)
  • a) Comptez les appels sur la figure. Quels sous-problèmes sont calculés plusieurs fois, et combien de fois ?
  • b) On note T(n)T(n) le nombre total d'appels pour fib(n). Donnez la relation de récurrence vérifiée par TT, avec ses cas de base, puis calculez T(2)T(2) à T(6)T(6).
  • c) On montre que T(n)=2F(n+1)1T(n) = 2 F(n+1) - 1, où FF est la suite de Fibonacci commençant par F(1)=F(2)=1F(1) = F(2) = 1. Calculez T(30)T(30) sachant que F(31)=1 346 269F(31) = 1\ 346\ 269.
  • d) On mémorise les résultats déjà calculés dans un dictionnaire. Écrivez la fonction et donnez le nombre d'appels pour n valant 30. Quel facteur de gain ?
  • e) Écrivez la version itérative, donnez son coût en temps et en mémoire, et dites laquelle des trois versions vous choisiriez, avec un argument par version écartée.

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

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

Réponses

  • a) 9 appels, sous-problèmes recalculés
  • b) T(n)=1+T(n1)+T(n2)T(n) = 1 + T(n-1) + T(n-2)
  • c) T(30)=2 692 537T(30) = 2\ 692\ 537
  • d) Mémoïsation : 31 calculs
  • e) Itératif : O(n)O(n) temps, O(1)O(1) mémoire

a) L'arbre compte 9 appels au total pour fib(4). Les sous-problèmes répétés : fib(2) apparaît DEUX fois, une fois sous fib(3) et une fois directement sous fib(4) ; fib(1) apparaît TROIS fois ; fib(0) apparaît deux fois. Aucun résultat n'est conservé, si bien que chaque occurrence est entièrement recalculée. Ce gaspillage n'est pas anecdotique : c'est lui qui rend le coût exponentiel.

b) Un appel à fib(n) pour n supérieur à 1 fait un appel pour lui-même, plus tous ceux de fib(n-1) et de fib(n-2) : donc T(n)=1+T(n1)+T(n2)T(n) = 1 + T(n-1) + T(n-2), avec T(0)=T(1)=1T(0) = T(1) = 1. Calculs : T(2)=1+1+1=3T(2) = 1 + 1 + 1 = 3 ; T(3)=1+3+1=5T(3) = 1 + 3 + 1 = 5 ; T(4)=1+5+3=9T(4) = 1 + 5 + 3 = 9, ce qui confirme la figure ; T(5)=1+9+5=15T(5) = 1 + 9 + 5 = 15 ; T(6)=1+15+9=25T(6) = 1 + 15 + 9 = 25.

c) T(30)=2×F(31)1=2×1 346 2691=2 692 537T(30) = 2 \times F(31) - 1 = 2 \times 1\ 346\ 269 - 1 = 2\ 692\ 537 appels. Vérification de la formule sur un petit cas : pour n=5n = 5, F(6)=8F(6) = 8 donc T(5)=2×81=15T(5) = 2 \times 8 - 1 = 15, ce qui coïncide avec le calcul de la question b. Près de trois millions d'appels pour un résultat qui tient sur six chiffres : la croissance est exponentielle, de raison le nombre d'or, environ 1,6181{,}618.

d) La fonction mémoïsée : on crée un dictionnaire vide en dehors de la fonction, ou passé en argument par défaut ; à chaque appel, si n figure déjà comme clé, on renvoie la valeur enregistrée ; sinon on calcule comme avant, on enregistre le résultat sous la clé n, et on le renvoie. Pour n valant 30, chaque valeur de 0 à 30 n'est calculée qu'une seule fois, soit 31 calculs, les autres appels se réduisant à une lecture dans le dictionnaire. Le facteur de gain vaut 2 692 537/3186 8562\ 692\ 537 / 31 \approx 86\ 856, soit près de quatre-vingt-sept mille fois moins de travail. Le code n'a pourtant changé que de deux lignes.

e) Version itérative : on garde deux variables a et b valant 0 et 1, et on répète n fois l'affectation simultanée a, b devient b, a + b ; on renvoie a. Son coût est LINÉAIRE en temps, n additions, et CONSTANT en mémoire, deux variables. Je choisirais celle-là. Argument contre la version naïve : elle est exponentielle, donc inutilisable au-delà d'une quarantaine. Argument contre la version mémoïsée : elle est correcte et rapide, mais elle consomme n cadres de pile et n entrées de dictionnaire, donc une mémoire linéaire pour un problème qui n'en demande aucune, et elle reste soumise à la limite de profondeur de récursion. La leçon générale : la mémoïsation sauve un algorithme récursif, elle ne le rend pas meilleur qu'une itération quand celle-ci existe.

Exercice 3 : Programmation orientée objet : classe, instance, encapsulation

Une classe réunit des données et les opérations autorisées sur ces données. L'intérêt n'est pas de ranger le code : c'est de garantir qu'aucune opération ne laissera l'objet dans un état incohérent.

class Compte:
    def __init__(self, titulaire, solde=0):
        self.titulaire = titulaire
        self._solde = solde

    def deposer(self, montant):
        if montant <= 0:
            raise ValueError('montant invalide')
        self._solde = self._solde + montant

    def retirer(self, montant):
        if montant > self._solde:
            raise ValueError('solde insuffisant')
        self._solde = self._solde - montant

    def solde(self):
        return self._solde
  • a) Distinguez classe, instance, attribut et méthode. Combien d'objets crée l'expression Compte('Ada') suivie de Compte('Bob') ?
  • b) Quel est le rôle du paramètre self ? Que se passerait-il si on l'oubliait dans la signature de deposer ?
  • c) On exécute c = Compte('Ada', 100) puis c.deposer(50) puis c.retirer(200). Donnez l'état final et ce qui se produit exactement à la dernière ligne.
  • d) Pourquoi l'attribut est-il nommé avec un tiret bas initial ? Un utilisateur peut-il quand même écrire c._solde = -1000 ? Que garantit alors réellement l'encapsulation ?
  • e) On appelle INVARIANT DE CLASSE une propriété vraie après chaque méthode publique. Énoncez celui de cette classe et montrez que chaque méthode le préserve.

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

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

Réponses

  • a) Classe, instance, attribut, méthode
  • b) self désigne l'instance
  • c) Solde 150, ValueError levée
  • d) Tiret bas : convention
  • e) Invariant solde 0\geq 0, trou du constructeur

a) La CLASSE est le modèle : elle décrit ce que tout compte possède et ce qu'on peut lui faire. Une INSTANCE est un objet créé d'après ce modèle, avec ses propres valeurs. Un ATTRIBUT est une donnée portée par l'instance, ici titulaire et solde. Une MÉTHODE est une fonction définie dans la classe et appelée sur une instance. Les deux expressions créent DEUX objets distincts, chacun avec son propre titulaire et son propre solde : modifier l'un ne touche pas l'autre, contrairement à ce qui se passerait avec un alias.

b) Le paramètre self désigne l'INSTANCE sur laquelle la méthode est appelée : c'est par lui que la méthode accède aux attributs de cet objet-là plutôt qu'à ceux d'un autre. Quand on écrit c.deposer(50), Python appelle en réalité Compte.deposer(c, 50), et c'est c qui arrive dans self. Si on l'oubliait dans la signature, l'appel passerait quand même l'instance comme premier argument, qui se retrouverait dans montant, et l'ajout de 50 n'aurait aucun destinataire : Python lèverait une TypeError signalant que la méthode prend un argument et en reçoit deux.

c) Après la création, le solde vaut 100. Après le dépôt de 50, il vaut 150. Le retrait de 200 déclenche la condition montant supérieur au solde, donc la méthode lève une ValueError portant le message « solde insuffisant » et n'exécute PAS la soustraction. L'état final est donc un solde de 150, inchangé, et une exception remontée à l'appelant. Le point important est que l'objet reste COHÉRENT : il vaut mieux une exception qu'un compte à moins 50 que personne n'aurait remarqué.

d) Le tiret bas initial est une CONVENTION qui signale aux autres programmeurs que l'attribut est interne et qu'il ne faut pas y toucher depuis l'extérieur. Python ne l'interdit pas : l'écriture c._solde = -1000 fonctionne parfaitement et casse l'invariant. L'encapsulation ne garantit donc pas l'impossibilité, elle garantit la RESPONSABILITÉ : tant qu'on ne passe que par les méthodes publiques, aucun état incohérent n'est atteignable, et si un tel état apparaît, on sait où chercher. C'est un contrat entre programmeurs autant qu'un mécanisme du langage, et les langages qui l'imposent réellement, comme Java avec le mot-clé privé, ne font que déplacer la question, puisque la réflexion permet encore de contourner.

e) L'invariant de classe : le solde est toujours supérieur ou égal à zéro. Vérification méthode par méthode. Le CONSTRUCTEUR le préserve si le solde initial est positif ou nul, ce qui n'est pas vérifié dans le code proposé : c'est un défaut, il faudrait rejeter un solde initial négatif. DEPOSER n'ajoute qu'un montant strictement positif, donc il ne peut pas rendre le solde négatif. RETIRER refuse tout montant supérieur au solde, donc le résultat reste positif ou nul. SOLDE ne modifie rien. Trois méthodes sur quatre préservent l'invariant, et l'analyse a mis au jour le trou du constructeur, ce qui est exactement à quoi sert un invariant : il transforme une relecture vague en une vérification finie.

Exercice 4 : Héritage et polymorphisme

Le schéma donne une hiérarchie de classes. Une flèche signifie « hérite de » : la classe de départ reprend tout ce que possède la classe d'arrivée et peut y ajouter ou y redéfinir.

FormeCercleRectangleTriangleCarre
class Forme:
    def aire(self):
        raise NotImplementedError

    def decrire(self):
        return 'aire = ' + str(self.aire())

class Rectangle(Forme):
    def __init__(self, L, l):
        self.L = L
        self.l = l

    def aire(self):
        return self.L * self.l
  • a) Écrivez la classe Cercle, qui hérite de Forme et dont le constructeur reçoit le rayon. Puis la classe Carre, qui hérite de Rectangle sans redéfinir aire.
  • b) On crée un rectangle de 6 sur 4, un carré de côté 5 et un cercle de rayon 3. Donnez les trois aires, au centième près pour le cercle.
  • c) La méthode decrire est écrite une seule fois, dans Forme, et fonctionne pour les trois objets. Comment s'appelle ce mécanisme et sur quoi repose-t-il exactement ?
  • d) Pourquoi aire lève-t-elle une exception dans Forme au lieu de renvoyer 0 ? Que se passerait-il si l'on créait une Forme directement ?
  • e) Un élève propose de faire hériter Carre de Forme plutôt que de Rectangle, en réécrivant aire. Donnez un argument pour et un argument contre, puis tranchez.

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

b)
c)
d)
e)
Voir la correction

Réponses

  • a) Cercle hérite de Forme, Carre de Rectangle
  • b) 24, 25 et 9π28,279\pi \approx 28{,}27
  • c) Polymorphisme : résolution à l'exécution
  • d) Classe abstraite : NotImplementedError
  • e) Hériter seulement si substituable

a) La classe Cercle : elle hérite de Forme ; son constructeur reçoit self et r, et enregistre self.r = r ; sa méthode aire renvoie π×r2\pi \times r^{2}, ce qui s'écrit pi * self.r ** 2 après avoir importé pi. La classe Carre : elle hérite de Rectangle ; son constructeur reçoit self et c, et appelle le constructeur de la classe parente avec c et c, ce qui s'écrit super().__init__(c, c). Elle ne redéfinit pas aire : celle de Rectangle convient déjà, puisqu'un carré est un rectangle dont les deux côtés sont égaux. C'est exactement l'intérêt de l'héritage, ne pas réécrire ce qui est déjà juste.

b) Rectangle de 6 sur 4 : aire =6×4=24= 6 \times 4 = 24. Carré de côté 5 : le constructeur appelle celui de Rectangle avec 5 et 5, donc aire =5×5=25= 5 \times 5 = 25. Cercle de rayon 3 : aire =π×32=9π28,27= \pi \times 3^{2} = 9\pi \approx 28{,}27. Les trois objets répondent à la même méthode decrire, qui appellera pour chacun la bonne version de aire.

c) Le mécanisme s'appelle le POLYMORPHISME. Il repose sur le fait que l'appel self.aire() est résolu à l'EXÉCUTION, en fonction de la classe réelle de l'objet, et non à l'écriture en fonction du type déclaré. Quand decrire s'exécute sur un cercle, self.aire() trouve la méthode aire de Cercle ; sur un carré, il trouve celle de Rectangle, héritée. La méthode decrire n'a donc jamais besoin de savoir à quelle forme elle a affaire, et l'ajout d'une classe Triangle demain ne demandera aucune modification. C'est le principal bénéfice pratique de l'objet : on ajoute des cas sans toucher au code existant.

d) Parce que Forme est une classe ABSTRAITE : elle décrit ce que toute forme doit savoir faire, sans savoir le faire elle-même. Renvoyer 0 serait un mensonge silencieux, qui produirait des résultats faux dans les calculs de total sans jamais signaler l'oubli. Lever une exception transforme une erreur de CONCEPTION, une sous-classe qui a oublié d'implémenter aire, en une erreur bruyante et localisée. Si l'on créait une Forme directement, l'objet existerait, mais tout appel à aire ou à decrire lèverait NotImplementedError : le défaut apparaîtrait donc au premier usage, ce qui est le meilleur moment.

e) Argument POUR : un carré n'a qu'un seul paramètre, alors qu'il hérite de deux attributs L et l ; si Rectangle acquiert un jour une méthode qui change une seule dimension, le carré héritera d'une opération qui casse son invariant, car un carré redimensionné dans une seule direction n'est plus un carré. Argument CONTRE : refaire hériter Carre de Forme oblige à réécrire aire, périmètre, et toute autre méthode déjà écrite dans Rectangle, ce qui duplique du code et fera diverger les deux versions à la première correction. Je tranche en faveur du POUR : la duplication se gère, la violation d'invariant ne se gère pas. La règle générale est qu'on hérite quand la sous-classe peut être utilisée PARTOUT où la classe parente est attendue, sans surprendre l'appelant ; ce n'est pas le cas ici dès qu'une méthode de redimensionnement existe. Le carré et le rectangle sont d'ailleurs l'exemple canonique de ce piège.

Exercice 5 : Mise au point : spécifier, tester, isoler

Un programme qui donne le bon résultat sur l'exemple du cours n'est pas un programme correct. La différence se joue sur les cas limites, et se constate avec des tests écrits AVANT d'avoir la réponse.

def indice_max(t):
    """Renvoie l'indice du plus grand élément de la liste t."""
    imax = 0
    for i in range(1, len(t)):
        if t[i] > t[imax]:
            imax = i
    return imax
  • a) Complétez la spécification : que faut-il ajouter sur les préconditions et sur le cas des ex aequo ?
  • b) Écrivez six jeux de tests significatifs, en justifiant le choix de chacun. Donnez le résultat attendu.
  • c) Que se passe-t-il si la liste est vide ? Proposez deux traitements et dites lequel respecte le mieux la spécification que vous avez écrite en a.
  • d) Un élève remplace le test par t[i] >= t[imax]. Le résultat change-t-il ? Sur quels jeux de tests la différence est-elle visible ?
  • e) Distinguez une assertion, un test unitaire et un point d'arrêt. Pour chacun, dites à quel moment il sert et ce qu'il ne peut pas faire.

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

b)
c)
d)
e)
Voir la correction

Réponses

  • a) Précondition non vide, premier maximum
  • b) Six jeux dont ex aequo et négatifs
  • c) Liste vide : lever ValueError
  • d) Comparaison large : dernier maximum
  • e) Assertion, test unitaire, point d'arrêt

a) La spécification doit préciser trois choses. La PRÉCONDITION : la liste doit être non vide, et ses éléments doivent être deux à deux comparables. La POSTCONDITION : la valeur renvoyée est un indice valide, et aucun élément n'est strictement supérieur à celui d'indice renvoyé. Le cas des EX AEQUO : si plusieurs éléments sont maximaux, la fonction renvoie l'indice du PREMIER d'entre eux, ce qui est le comportement du code puisque la comparaison est stricte. Écrire ce dernier point n'est pas une formalité : sans lui, un programme appelant ne peut pas savoir sur quel comportement compter, et une réécriture ultérieure pourrait le changer sans que personne ne s'en aperçoive.

b) Six jeux. Un seul élément, [7] : réponse 0, cas limite minimal. Maximum en tête, [9, 3, 5] : réponse 0, vérifie que l'initialisation ne fausse rien. Maximum en fin, [3, 5, 9] : réponse 2, vérifie que la boucle va jusqu'au bout. Maximum au milieu, [3, 9, 5] : réponse 1, cas ordinaire. Ex aequo, [4, 9, 9, 2] : réponse 1, vérifie la règle du premier maximum. Valeurs négatives, [-8, -3, -5] : réponse 1, vérifie qu'aucune hypothèse de positivité ne s'est glissée, erreur fréquente quand on initialise le maximum à zéro au lieu du premier élément.

c) Sur une liste vide, len(t) vaut 0, la boucle ne s'exécute pas, et la fonction renvoie 0 : un indice qui n'existe pas. C'est le pire des comportements, car il ne signale rien et l'appelant provoquera un IndexError beaucoup plus loin, à un endroit sans rapport. Deux traitements possibles. Le premier : lever explicitement une ValueError avec un message clair. Le second : renvoyer une valeur nulle, à charge pour l'appelant de la tester. Le premier respecte mieux la spécification écrite en a, puisque celle-ci pose la non-vacuité en PRÉCONDITION : violer une précondition doit produire une erreur, pas un résultat. Renvoyer une valeur nulle changerait le type de retour et obligerait chaque appelant à un test, ce qui est précisément ce qu'une précondition évite.

d) Oui, le résultat change en cas d'ex aequo : avec une comparaison large, chaque nouvelle occurrence du maximum remplace l'indice, si bien que la fonction renvoie l'indice du DERNIER maximum au lieu du premier. Sur [4, 9, 9, 2], la version stricte renvoie 1 et la version large renvoie 2. La différence n'est visible que sur les jeux de tests comportant des ex aequo : les cinq autres jeux de la question b donneraient exactement les mêmes réponses. C'est la démonstration qu'un jeu de tests sans ex aequo laisserait passer la modification sans rien dire, alors qu'elle change le contrat.

e) Une ASSERTION est une vérification écrite DANS le code, qui s'exécute à chaque passage et interrompt le programme si elle est fausse ; elle sert à documenter et à faire respecter une hypothèse interne, et elle ne peut pas remplacer la validation des données venues de l'extérieur, puisqu'on la désactive souvent en production. Un TEST UNITAIRE est un programme séparé qui appelle la fonction sur des cas choisis et compare au résultat attendu ; il sert à vérifier le contrat et surtout à détecter les régressions lors des modifications futures, mais il ne prouve rien sur les cas non testés. Un POINT D'ARRÊT suspend l'exécution pour inspecter l'état ; il sert à COMPRENDRE un comportement déjà constaté comme faux, et il ne laisse aucune trace, donc il ne protège en rien contre le retour du même défaut. Les trois sont complémentaires : l'assertion documente, le test protège, le point d'arrêt explique.

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

Exercice 6 : Prouver qu'un programme est correct

La figure montre l'état d'une boucle en cours : une zone déjà examinée, une zone qui reste. La propriété qui reste vraie à chaque tour s'appelle l'invariant, et c'est elle qui transforme une conviction en démonstration.

7395281640déjà examinéreste à examineri
  • a) Pour la fonction indice_max de l'exercice 5, énoncez l'invariant de boucle en une phrase précise, en vous appuyant sur la figure.
  • b) Démontrez l'invariant en trois temps : initialisation, conservation, et conclusion à la sortie de boucle.
  • c) Qu'est-ce qu'un variant de boucle ? Donnez celui de cette fonction et montrez qu'il prouve la terminaison.
  • d) Distinguez correction partielle et correction totale. Laquelle avez-vous établie en b, laquelle en c ?
  • e) Un test qui passe prouve-t-il la correction ? Une preuve dispense-t-elle des tests ? Répondez aux deux questions en donnant à chaque fois ce que l'autre méthode apporte que celle-ci n'apporte pas.

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

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

Réponses

  • a) Invariant sur le préfixe examiné
  • b) Initialisation, conservation, conclusion
  • c) Variant len(t)i\text{len}(t) - i
  • d) Partielle plus terminaison : totale
  • e) Preuve et tests complémentaires

a) L'invariant : à l'entrée de chaque tour de boucle, la variable imax contient l'indice d'un plus grand élément parmi les éléments d'indices 0 à i1i - 1, c'est-à-dire parmi la zone déjà examinée de la figure, et c'est l'indice du premier d'entre eux en cas d'égalité. Formulé autrement, imax est la réponse correcte pour le préfixe examiné jusqu'ici. Le point de méthode : un invariant utile parle du travail DÉJÀ FAIT, jamais de ce qui reste, et c'est ce qui le rend vrai avant même le premier tour.

b) INITIALISATION : avant le premier tour, ii vaut 1 et imax vaut 0. La zone examinée se réduit à l'élément d'indice 0, dont imax est bien l'indice du maximum : l'invariant est vrai. CONSERVATION : supposons l'invariant vrai à l'entrée d'un tour d'indice ii. Le corps compare t[i] à t[imax]. Si t[i] est strictement plus grand, alors t[i] dépasse tous les éléments de la zone examinée, puisqu'il dépasse leur maximum, et imax prend la valeur ii : l'invariant est vrai pour la zone étendue à ii. Sinon, t[i] ne dépasse pas t[imax], donc l'ancien maximum reste maximal sur la zone étendue et imax est inchangé : l'invariant est encore vrai. Dans les deux cas il est préservé, et la stricte inégalité garantit qu'un ex aequo ne déplace pas imax, donc que le premier maximum est conservé. CONCLUSION : la boucle se termine lorsque ii atteint len(t). L'invariant s'applique alors à la zone d'indices 0 à len(t) moins 1, c'est-à-dire à la liste entière : imax est bien l'indice du premier maximum de t. C'est exactement la postcondition annoncée.

c) Un variant de boucle est une quantité entière, positive ou nulle, qui décroît STRICTEMENT à chaque tour. Son existence prouve la terminaison, car une suite d'entiers positifs strictement décroissante est finie. Ici, on peut prendre len(t)i\text{len}(t) - i : il vaut len(t) moins 1 au départ, il est positif ou nul tant qu'on entre dans la boucle, et il diminue exactement de 1 à chaque tour puisque ii augmente de 1. La boucle effectue donc au plus len(t) moins 1 tours et se termine.

d) La CORRECTION PARTIELLE affirme : si le programme se termine, alors son résultat vérifie la postcondition. La CORRECTION TOTALE ajoute qu'il se termine effectivement. En b, on a établi la correction partielle, par l'invariant ; en c, la terminaison, par le variant. Les deux réunies donnent la correction totale. La distinction n'est pas scolaire : un programme peut être parfaitement correct au sens partiel et boucler indéfiniment, auquel cas il n'a jamais l'occasion d'être faux, et il est pourtant inutilisable.

e) Un test qui passe ne prouve PAS la correction : il montre que le programme est juste sur les cas essayés, et rien au-delà. La fonction de l'exercice 5 passe les six tests de la question b sans traiter la liste vide. Ce que la preuve apporte en plus : une garantie sur TOUS les cas, y compris ceux auxquels personne n'a pensé. Une preuve ne dispense pas des tests pour autant. Ce que les tests apportent en plus : ils vérifient que le programme réellement exécuté correspond à celui qu'on a prouvé, ce qui exclut les fautes de frappe, les erreurs de traduction du raisonnement en code, les défauts de l'environnement, dépassement d'entier, arrondi de flottant, bibliothèque qui ne fait pas ce qu'elle annonce. La preuve porte sur le modèle, les tests portent sur la machine, et les deux échouent sur des choses différentes.

Exercice 7 : Trois paradigmes pour le même problème

Un paradigme n'est pas une syntaxe, c'est une façon de décomposer un problème. Le même calcul, la somme des carrés des nombres pairs d'une liste, s'écrit de trois manières.

# impératif
def somme_v1(t):
    total = 0
    for x in t:
        if x % 2 == 0:
            total = total + x * x
    return total

# fonctionnel
def somme_v2(t):
    return sum(x * x for x in t if x % 2 == 0)
  • a) Calculez le résultat des deux fonctions sur la liste [3, 4, 7, 6, 2, 9].
  • b) Caractérisez le paradigme impératif et le paradigme fonctionnel en une phrase chacun. Sur quoi porte la différence : le résultat, la façon de l'obtenir, ou les deux ?
  • c) Qu'appelle-t-on effet de bord ? Laquelle des deux versions en produit ? Pourquoi une fonction sans effet de bord est-elle plus facile à tester et à paralléliser ?
  • d) Écrivez une troisième version, orientée objet, sous forme d'une classe Serie construite sur la liste et offrant une méthode somme_carres_pairs. Quel est l'intérêt réel de cette version, et dans quel cas ?
  • e) Un même langage peut-il servir plusieurs paradigmes ? Citez pour Python un élément relevant de chacun des trois, et dites ce qui décide du choix en pratique.

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

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

Réponses

  • a) Somme des carrés pairs : 56
  • b) Impératif : étapes ; fonctionnel : expression
  • c) Sans effet de bord : test et parallélisme faciles
  • d) Objet si invariant ou variantes
  • e) Python multiparadigme

a) Les nombres pairs de la liste sont 4, 6 et 2. Leurs carrés valent 16, 36 et 4. La somme vaut 16+36+4=5616 + 36 + 4 = 56. Les deux fonctions renvoient 56, ce qui est le point de départ de l'exercice : elles calculent exactement la même chose.

b) Le paradigme IMPÉRATIF décrit une SUITE D'ÉTAPES qui modifient un état : on part d'un total nul et on le fait évoluer. Le paradigme FONCTIONNEL décrit une EXPRESSION à évaluer, composée de transformations appliquées à des valeurs, sans état modifié. La différence ne porte pas sur le résultat, qui est identique, mais sur la façon de l'obtenir et sur ce qu'on peut en dire : la version fonctionnelle se lit comme la définition du problème, la version impérative se lit comme sa recette.

c) Un effet de bord est une modification observable de l'état extérieur à la fonction : écrire dans une variable globale, modifier un argument, écrire dans un fichier, afficher quelque chose. La version impérative modifie la variable locale total, ce qui n'est pas un effet de bord au sens strict puisque total meurt avec l'appel ; aucune des deux versions n'a d'effet de bord observable, et c'est justement ce qui les rend interchangeables. Une fonction sans effet de bord est plus facile à TESTER parce qu'il suffit de lui donner des arguments et de comparer le résultat, sans préparer ni vérifier un état ; et plus facile à PARALLÉLISER parce que deux appels ne peuvent pas se gêner, aucun n'écrivant dans un emplacement que l'autre lit. C'est exactement le problème de section critique de l'exercice sur la concurrence, qui disparaît par construction.

d) La classe : son constructeur reçoit la liste et l'enregistre dans un attribut ; la méthode somme_carres_pairs parcourt cet attribut et renvoie la somme, par l'un ou l'autre des deux styles précédents. L'intérêt réel n'est pas dans ce calcul, qui n'a pas besoin d'objet : il apparaît dès que l'on veut plusieurs opérations sur la même donnée, un état à maintenir entre les appels, par exemple un cache des résultats déjà calculés, ou plusieurs variantes de série partageant la même interface, par exemple une série paresseuse lisant un fichier au fur et à mesure. L'objet se justifie quand il y a un INVARIANT à protéger ou une famille de variantes à substituer, jamais pour envelopper une fonction unique.

e) Oui, la plupart des langages modernes sont multiparadigmes. Pour Python : la boucle for avec affectation relève de l'IMPÉRATIF ; les compréhensions, les fonctions passées en argument et les fonctions anonymes relèvent du FONCTIONNEL ; les classes, l'héritage et les méthodes spéciales relèvent de l'OBJET. Ce qui décide en pratique n'est pas la mode mais la nature du problème : on prend le fonctionnel pour une transformation de données sans état, l'objet dès qu'il y a un invariant à garantir ou des variantes à substituer, et l'impératif quand l'algorithme est intrinsèquement séquentiel ou quand la performance exige un contrôle fin. Un bon programme mélange les trois, chacun là où il est le plus clair.

Exercice 8 : Cinq affirmations à corriger

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

  • 1) « Une fonction récursive sans cas de base boucle indéfiniment. »
  • 2) « La version récursive de Fibonacci est lente parce que Python est lent. »
  • 3) « L'encapsulation empêche d'accéder aux attributs privés d'un objet en Python. »
  • 4) « Un jeu de tests qui passe prouve que la fonction est correcte. »
  • 5) « Puisqu'aucun programme ne peut décider de l'arrêt, il est impossible de savoir si un programme donné se termine. »

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

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

Réponses

  • 1) Débordement de pile
  • 2) Coût algorithmique
  • 3) Encapsulation par contrat
  • 4) Tester ne prouve pas
  • 5) Indécidable en général seulement

1) FAUX. Chaque appel empile un cadre, et la pile est de taille finie : le programme s'arrête par épuisement de la pile, avec une RecursionError. Énoncé correct : une récursion sans cas de base s'arrête sur un débordement de pile, ce qui est une chance, car un message d'erreur vaut mieux qu'un gel silencieux.

2) FAUX. Le défaut est ALGORITHMIQUE et non linguistique : le nombre d'appels croît exponentiellement, environ 2 692 5372\ 692\ 537 pour n valant 30, parce que les mêmes sous-problèmes sont recalculés. Un langage cent fois plus rapide ne ferait gagner qu'une dizaine de valeurs de n, puisque 1,618101231{,}618^{10} \approx 123. Énoncé correct : la version naïve est lente parce qu'elle recalcule ses sous-problèmes ; la mémoïsation la ramène à 31 calculs, dans le même langage.

3) FAUX. Le tiret bas initial est une convention, pas une barrière : écrire c._solde = -1000 fonctionne. Énoncé correct : l'encapsulation garantit qu'aucun état incohérent n'est atteignable TANT QU'ON PASSE PAR LES MÉTHODES PUBLIQUES ; c'est un contrat entre programmeurs, dont le langage ne fait que signaler l'existence.

4) FAUX. Un test montre que le programme est juste sur les cas essayés, et rien de plus. La fonction indice_max passe les six tests de l'exercice 5 tout en renvoyant un indice inexistant sur une liste vide. Énoncé correct : un jeu de tests détecte des erreurs, il n'établit jamais leur absence ; seule une preuve, invariant et variant, porte sur tous les cas.

5) FAUX, et la confusion est classique. L'indécidabilité du problème de l'arrêt dit qu'aucun programme ne peut décider l'arrêt pour TOUT couple formé d'un programme et d'une entrée. Elle ne dit rien sur un cas particulier : on démontre très bien la terminaison de la plupart des programmes réels, par un variant. Énoncé correct : il n'existe pas de méthode universelle, ce qui n'empêche nullement de prouver la terminaison d'un programme donné.

Exercice 9 : Calculabilité : le problème de l'arrêt

Certaines questions ne sont pas difficiles : elles sont sans réponse algorithmique possible. La démonstration tient en une page et n'utilise que ce que vous savez déjà d'une fonction et d'un appel.

  • a) Énoncez précisément le problème de l'arrêt. Pourquoi ne peut-on pas simplement exécuter le programme pour voir s'il s'arrête ?
  • b) On suppose qu'il existe une fonction arrete(p, e) qui renvoie True si le programme p s'arrête sur l'entrée e, et False sinon, et qui se termine toujours. Construisez la fonction diagonale qui aboutit à une contradiction, et expliquez la contradiction.
  • c) Concluez sur l'existence de arrete. Le résultat dépend-il du langage employé ? Justifiez.
  • d) Citez deux conséquences pratiques de ce résultat pour un développeur ou pour un éditeur d'antivirus.
  • e) Un outil d'analyse annonce « ce programme ne contient aucune boucle infinie ». Que peut-il garantir réellement ? Expliquez les notions de faux positif et de faux négatif dans ce contexte et dites laquelle un outil sérieux choisit d'accepter.

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

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

Réponses

  • a) Arrêt : décider pour tout programme
  • b) Diagonale contradictoire
  • c) arrete n'existe pas, quel que soit le langage
  • d) Pas d'outil universel ni d'antivirus parfait
  • e) Accepter les faux positifs

a) Le problème de l'arrêt demande s'il existe un ALGORITHME qui, recevant en entrée le texte d'un programme et une donnée, répond en un temps fini et à coup sûr par oui ou par non à la question « ce programme s'arrête-t-il sur cette donnée ». On ne peut pas simplement exécuter le programme pour voir, parce qu'après une heure d'attente on ne sait toujours rien : le programme s'arrêtera peut-être dans une minute, peut-être jamais. L'exécution répond « oui » quand la réponse est oui, mais elle ne répond JAMAIS « non ». Or on exige un algorithme qui se termine dans les deux cas.

b) On construit la fonction suivante, appelons-la diagonale, qui prend un programme p en argument : si arrete(p, p) renvoie True, alors diagonale entre dans une boucle infinie ; sinon, diagonale s'arrête immédiatement. Autrement dit, diagonale fait exactement le CONTRAIRE de ce que arrete prédit pour p appliqué à lui-même. On applique alors diagonale à son propre texte. Deux cas, tous deux impossibles. Si diagonale(diagonale) s'arrête, c'est que arrete(diagonale, diagonale) a renvoyé False, donc que diagonale ne s'arrête pas sur cette entrée : contradiction. Si diagonale(diagonale) ne s'arrête pas, c'est que arrete a renvoyé True, donc que diagonale s'arrête sur cette entrée : contradiction encore. Les deux branches sont contradictoires, et il n'y en a pas de troisième.

c) La supposition de départ, l'existence de arrete, conduit à une contradiction dans tous les cas : elle est donc fausse. Aucune fonction arrete, totale et correcte, n'existe. Le résultat NE DÉPEND PAS du langage employé : la construction n'utilise que la capacité à prendre un programme comme donnée, à l'appeler, à tester un booléen et à boucler, capacités que possède tout langage assez expressif pour être utilisable. Tous ces langages calculent d'ailleurs exactement la même classe de fonctions, et un langage qui échapperait à l'argument serait un langage dans lequel on ne pourrait pas écrire diagonale, donc trop pauvre pour programmer.

d) Première conséquence, pour le DÉVELOPPEUR : aucun outil ne pourra jamais garantir, sur tout programme, l'absence de boucle infinie, l'égalité de deux fonctions, ou le fait qu'une ligne soit atteignable. Il faut donc se contenter d'outils qui répondent « oui », « non » ou « je ne sais pas », et écrire soi-même les variants pour les cas qui comptent. Seconde conséquence, pour un ÉDITEUR D'ANTIVIRUS : il n'existe pas et ne peut pas exister de programme qui décide, pour tout fichier, s'il est malveillant, puisque cela reviendrait à décider une propriété du comportement d'un programme. C'est pourquoi les antivirus fonctionnent par SIGNATURES de menaces déjà connues et par heuristiques comportementales, méthodes qui se trompent nécessairement dans les deux sens et qu'il faut mettre à jour sans fin.

e) Un tel outil ne peut garantir la réponse que sur les programmes qu'il sait analyser ; sur les autres, il devrait dire « je ne sais pas ». Un FAUX POSITIF serait ici de signaler une boucle infinie qui n'existe pas : l'outil réveille le développeur pour rien, ce qui coûte du temps mais ne laisse rien passer. Un FAUX NÉGATIF serait de déclarer sain un programme qui boucle réellement : l'outil endort la vigilance, et le défaut part en production. Un outil sérieux choisit d'accepter les FAUX POSITIFS et refuse les faux négatifs : il est CONSERVATEUR, il refuse ce dont il n'est pas sûr. C'est le principe de tout analyseur statique et de tout vérificateur de type : il rejette des programmes corrects plutôt que d'accepter des programmes faux, et c'est ce choix, et non une quelconque limite technique, qui explique qu'il paraisse parfois trop sévère.

Exercice 10 : Problème : concevoir, tester et prouver un module de file de priorité

On veut un module qui gère une file d'attente où chaque élément porte une priorité entière : le prochain servi est celui de priorité la plus basse, et à priorité égale le premier arrivé. On le construira, on le testera et on prouvera une de ses propriétés.

  • a) Écrivez la spécification complète du module : les opérations, leurs préconditions et leurs postconditions, et l'invariant de structure.
  • b) Proposez deux implémentations, l'une par liste triée à l'insertion, l'autre par liste non triée avec recherche au retrait. Donnez le coût de chaque opération dans chacune.
  • c) Sur une suite de nn insertions suivies de nn retraits, calculez le coût total de chacune des deux implémentations. Comparez pour n=10 000n = 10\ 000.
  • d) Écrivez six jeux de tests couvrant les cas ordinaires et les cas limites, en incluant le cas des priorités égales.
  • e) Énoncez et démontrez l'invariant de la première implémentation, puis dites ce que cette preuve ne couvre pas.

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

b)
c)
d)
e)
Voir la correction

Réponses

  • a) Spécification et invariant de structure
  • b) Liste triée ou non triée
  • c) 5×107\approx 5 \times 10^{7} contre 2,66×1052{,}66 \times 10^{5} pour un tas
  • d) Six jeux dont priorités égales
  • e) Invariant prouvé, tests toujours nécessaires

a) La spécification. OPÉRATIONS. Créer une file vide : aucune précondition, postcondition, la file est vide. Enfiler un élément avec une priorité : précondition, la priorité est un entier ; postcondition, la file contient un élément de plus, et l'ordre de service des autres n'est pas modifié. Défiler : précondition, la file n'est pas vide ; postcondition, l'élément renvoyé est de priorité minimale, et parmi ceux de priorité minimale, c'est le plus anciennement enfilé ; la file en contient un de moins. Tester la vacuité : aucune précondition, renvoie un booléen. INVARIANT de structure : le nombre d'éléments est positif ou nul, et pour toute paire d'éléments présents, l'ordre de service est déterminé de façon unique par le couple formé de la priorité et du rang d'arrivée.

b) Implémentation par LISTE TRIÉE : à l'insertion, on parcourt la liste pour trouver la position, ce qui coûte un temps linéaire, puis on insère ; le retrait prend le premier élément, coût constant. Implémentation par LISTE NON TRIÉE : l'insertion ajoute en fin, coût constant ; le retrait parcourt toute la liste pour trouver le minimum, coût linéaire. Les deux échangent donc exactement le même coût entre les deux opérations. Une troisième implémentation, par tas binaire, offre un coût logarithmique pour les DEUX opérations, ce qui est strictement meilleur dès que les deux sont fréquentes.

c) Sur nn insertions puis nn retraits. LISTE TRIÉE : la kk-ième insertion parcourt au plus k1k - 1 éléments, soit un total de 1+2++(n1)=n(n1)/21 + 2 + \dots + (n-1) = n(n-1)/2 opérations ; les nn retraits coûtent nn opérations. Total de l'ordre de n2/2n^{2}/2. LISTE NON TRIÉE : les insertions coûtent nn ; le kk-ième retrait parcourt nk+1n - k + 1 éléments, soit encore n(n+1)/2n(n+1)/2 au total. Total également de l'ordre de n2/2n^{2}/2. Pour n=10 000n = 10\ 000 : dans les deux cas environ 5×1075 \times 10^{7} opérations. Un tas binaire donnerait 2nlog2n2×10 000×13,32,66×1052 n \log_{2} n \approx 2 \times 10\ 000 \times 13{,}3 \approx 2{,}66 \times 10^{5} opérations, soit environ 188 fois moins. La conclusion est que le choix entre les deux premières implémentations ne se fait pas sur le total mais sur le PROFIL d'usage : si l'on insère beaucoup et retire rarement, la liste non triée gagne, et réciproquement.

d) Six jeux. Un, file vide : défiler doit lever une erreur, et le test de vacuité doit renvoyer vrai. Deux, un seul élément : enfiler puis défiler rend cet élément, et la file redevient vide. Trois, priorités déjà croissantes, enfiler 1, 2, 3 : on doit défiler 1, 2, 3. Quatre, priorités décroissantes, enfiler 3, 2, 1 : on doit défiler 1, 2, 3, ce qui vérifie que l'ordre d'arrivée n'écrase pas la priorité. Cinq, PRIORITÉS ÉGALES, enfiler A puis B tous deux en priorité 5 : on doit défiler A puis B, ce qui vérifie la règle du premier arrivé. Six, entrelacement, enfiler 5, défiler, enfiler 2, enfiler 5, défiler, défiler : vérifie que les insertions faites après un retrait s'ordonnent correctement avec les éléments restants. Le cinquième jeu est le plus important : c'est le seul qui distingue une implémentation correcte d'une implémentation qui utiliserait une comparaison large et servirait le dernier arrivé.

e) INVARIANT de la première implémentation : après chaque opération, la liste interne est triée par priorité croissante, et les éléments de même priorité y figurent dans leur ordre d'arrivée. DÉMONSTRATION. À la création, la liste est vide : l'invariant est vrai, il n'y a aucune paire à vérifier. Pour l'INSERTION, on parcourt la liste jusqu'au premier élément de priorité STRICTEMENT supérieure et l'on insère juste avant lui. Les éléments qui précèdent ont une priorité inférieure ou égale, ceux qui suivent une priorité supérieure ou égale : le tri est préservé. La stricte inégalité place le nouvel élément APRÈS tous ceux de même priorité déjà présents, donc l'ordre d'arrivée est préservé lui aussi. Pour le RETRAIT, on enlève le premier élément : une sous-liste d'une liste triée reste triée, et retirer un élément ne change pas l'ordre relatif des autres. L'invariant est donc préservé par toutes les opérations. Ce que la preuve NE COUVRE PAS : elle porte sur le modèle, pas sur le code réellement exécuté. Elle ne dit rien d'un débordement d'entier sur les priorités, d'une erreur d'indice dans la boucle d'insertion, d'un comportement en présence de plusieurs fils d'exécution, ni du fait que la liste utilisée se comporte réellement comme on le suppose. C'est exactement pour cela que les six jeux de tests de la question d restent nécessaires.

Partie C : les classiques (/50)

Exercice 11 : Les tours de Hanoï

On dispose de trois piquets A, B et C et de nn disques de tailles différentes, empilés sur A du plus grand au plus petit. Il faut les transporter sur C en ne déplaçant qu'un disque à la fois, et sans jamais poser un disque sur un plus petit. La fonction affiche les déplacements et renvoie leur nombre.

def hanoi(n, depart, arrivee, inter):
    if n == 0:
        return 0
    k = hanoi(n - 1, depart, inter, arrivee)
    print(depart, '->', arrivee)
    k = k + 1
    k = k + hanoi(n - 1, inter, arrivee, depart)
    return k
  • a) Donnez les déplacements affichés par hanoi(2, 'A', 'C', 'B') et la valeur renvoyée.
  • b) Donnez la liste des déplacements affichés par hanoi(3, 'A', 'C', 'B'). Combien y en a-t-il ?
  • c) On note M(n)M(n) le nombre de déplacements. Écrivez la relation de récurrence que suit la fonction, puis démontrez que M(n)=2n1M(n) = 2^{n} - 1. Calculez M(10)M(10).
  • d) Une légende parle de 64 disques déplacés à raison d'un par seconde. Combien de temps faudrait-il, en années ?
  • e) Combien de cadres la pile d'appels contient-elle au plus pour hanoi(64, 'A', 'C', 'B') ? Le programme échouerait-il par dépassement de la pile ? Que conclure sur les deux ressources, temps et mémoire ?

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

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

Réponses

  • a) hanoi(2) : 3 déplacements
  • b) hanoi(3) : 7 déplacements
  • c) M(n)=2n1M(n) = 2^{n} - 1, M(10)=1 023M(10) = 1\ 023
  • d) 64 disques : 585\approx 585 milliards d'années
  • e) 65 cadres : mémoire linéaire, temps exponentiel

a) hanoi(2) appelle hanoi(1, 'A', 'B', 'C'), qui affiche A -> B ; puis affiche A -> C ; puis appelle hanoi(1, 'B', 'C', 'A'), qui affiche B -> C. Trois déplacements, et la fonction renvoie 3 : le petit disque s'écarte sur B, le grand passe sur C, le petit le rejoint.

b) Les déplacements : A -> C, A -> B, C -> B, A -> C, B -> A, B -> C, A -> C. Il y en a 7. Les trois premiers transportent les deux petits disques sur B, le quatrième déplace le grand disque de A à C, et les trois derniers ramènent les deux petits de B sur C : on reconnaît deux fois la solution pour 2 disques, de part et d'autre du déplacement du plus grand.

c) Pour déplacer nn disques, la fonction déplace n1n - 1 disques, puis le plus grand, puis de nouveau n1n - 1 disques : M(n)=2M(n1)+1M(n) = 2\,M(n-1) + 1, avec M(0)=0M(0) = 0. Par récurrence : M(0)=201=0M(0) = 2^{0} - 1 = 0 est vrai ; si M(n1)=2n11M(n-1) = 2^{n-1} - 1, alors M(n)=2(2n11)+1=2n1M(n) = 2(2^{n-1} - 1) + 1 = 2^{n} - 1. Donc M(10)=2101=1 023M(10) = 2^{10} - 1 = 1\ 023. On montre d'ailleurs qu'aucune méthode ne fait mieux : cette solution récursive est optimale.

d) 26411,84×10192^{64} - 1 \approx 1{,}84 \times 10^{19} secondes. En années : 1,84×10193,156×1075,8×1011\frac{1{,}84 \times 10^{19}}{3{,}156 \times 10^{7}} \approx 5{,}8 \times 10^{11}, soit environ 585 milliards d'années, plus de quarante fois l'âge de l'univers.

e) Chaque appel sur nn disques en attend un sur n1n - 1, jusqu'au cas de base : la pile contient au plus n+1n + 1 cadres, soit 65 pour n=64n = 64. C'est très loin de la limite de profondeur de Python, de l'ordre de 1 000 : le programme ne déborderait jamais de la pile, il tournerait simplement pendant des centaines de milliards d'années. La mémoire utilisée est LINÉAIRE en nn alors que le temps est EXPONENTIEL : les deux ressources ne se mesurent pas de la même façon, et un programme peut être très économe en mémoire tout en étant inexécutable.

Exercice 12 : Un programme est une donnée

Pour l'ordinateur, un programme n'est qu'une suite de caractères ou d'octets, qu'un autre programme peut lire, transformer, produire ou exécuter. La fonction eval de Python évalue une chaîne de caractères comme une expression Python.

def fabrique_puissance(k):
    source = 'lambda x: x ** ' + str(k)
    return eval(source)

cube = fabrique_puissance(3)
print(cube(4))
  • a) Qu'affiche le programme ci-dessous ? Quelle valeur de k donnerait une fonction qui renvoie 1 pour tout x non nul ?
  • b) Dans la fonction fabrique_puissance, la variable source contient-elle du code ou une donnée ? Expliquez en quoi ce programme illustre la notion de programme en tant que donnée.
  • c) Distinguez un compilateur et un interpréteur. Dans quelle catégorie ranger Python, et que fait-il réellement d'un fichier source ?
  • d) Un site web construit la chaîne source à partir d'un exposant saisi par l'utilisateur dans un formulaire, puis l'évalue. Quel est le danger ? Quel lien avec la question des requêtes SQL construites par concaténation ?
  • e) Citez deux situations quotidiennes où un programme est manipulé comme une simple donnée par un autre programme.

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

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

Réponses

  • a) cube(4) affiche 64
  • b) La chaîne source devient du code
  • c) Python : bytecode puis interprétation
  • d) eval d'une saisie : injection de code
  • e) Téléchargement et chargement d'un exécutable

a) fabrique_puissance(3) construit la chaîne lambda x: x ** 3 et l'évalue : on obtient une fonction qui élève au cube. cube(4) vaut 43=644^{3} = 64, et le programme affiche 64. Pour obtenir une fonction qui renvoie toujours 1, il faut k=0k = 0, puisque x0=1x^{0} = 1.

b) Pendant sa construction, source est une DONNÉE : une chaîne de caractères qu'on assemble par concaténation, comme n'importe quel texte. Elle ne devient du code qu'au moment où eval la fait analyser et exécuter par l'interpréteur. Le même objet est donc tour à tour une donnée et un programme, selon ce qu'on en fait : c'est exactement ce que signifie la notion de programme en tant que donnée, et c'est elle qui rend possible la démonstration du problème de l'arrêt, où un programme reçoit le texte d'un programme en argument.

c) Un COMPILATEUR traduit tout le programme source, une fois pour toutes, en un programme équivalent dans un autre langage, souvent le langage machine, qu'on exécute ensuite sans lui. Un INTERPRÉTEUR lit le programme et exécute ses instructions au fur et à mesure, et doit être présent à chaque exécution. Python est habituellement rangé parmi les langages interprétés, mais il fait les deux : il compile d'abord le fichier source en un code intermédiaire, le bytecode, puis une machine virtuelle interprète ce bytecode. Compilateur et interpréteur sont eux-mêmes des programmes dont les données d'entrée sont des programmes.

d) L'utilisateur peut saisir, à la place d'un nombre, n'importe quelle expression Python, qui sera exécutée sur le serveur avec tous ses droits : lire ou effacer des fichiers, lancer des commandes. C'est une INJECTION DE CODE, exactement du même type que l'injection SQL : une donnée fournie par l'extérieur est collée dans un texte qui sera ensuite interprété comme du code. Le remède est le même dans son principe : ne jamais faire interpréter une saisie, mais la convertir et la vérifier comme une donnée, ici avec int(saisie), qui échoue sur tout ce qui n'est pas un entier.

e) Le TÉLÉCHARGEMENT d'une application : le navigateur ou le magasin d'applications copie un fichier d'octets sans l'exécuter, et un antivirus l'analyse comme une donnée avant qu'il soit lancé. Le LANCEMENT d'un programme par le système d'exploitation : celui-ci lit le fichier exécutable sur le disque, le charge en mémoire, puis donne la main à ses instructions. On peut ajouter le compilateur, qui transforme un programme en un autre, et l'éditeur de texte, pour lequel un code source n'est qu'un texte.

Exercice 13 : Gérer les erreurs avec les exceptions

Une exception interrompt l'exécution normale dès qu'une erreur survient, et remonte les appels jusqu'à trouver un bloc qui la traite. Le bloc finally s'exécute dans tous les cas, qu'une exception ait eu lieu ou non.

def moyenne(notes):
    try:
        resultat = sum(notes) / len(notes)
    except ZeroDivisionError:
        print('liste vide')
        resultat = None
    except TypeError:
        print('valeur non numérique')
        resultat = None
    finally:
        print('fin du calcul')
    return resultat
  • a) Qu'affiche et que renvoie moyenne([12, 15, 9]) ?
  • b) Qu'affiche et que renvoie moyenne([]) ? Quelle ligne a levé l'exception, et laquelle n'a jamais été exécutée ?
  • c) Qu'affiche moyenne([12, 'abs', 9]) ? Combien de lignes sont affichées ?
  • d) Un élève remplace les deux clauses except par une seule clause except: sans type d'exception. Pourquoi est-ce une mauvaise idée ? Donnez un exemple de défaut qu'elle masquerait.
  • e) On appelle la fonction sur chacune des listes [12, 15, 9], [], [1, 'a'] et [10]. Combien de fois « fin du calcul » est-il affiché ? Pour la liste vide, vaut-il mieux renvoyer None ou laisser l'exception remonter ? Reliez à la notion de précondition.

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

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

Réponses

  • a) Renvoie 12,012{,}0 après fin du calcul
  • b) ZeroDivisionError traitée, renvoie None
  • c) TypeError : 2 lignes affichées
  • d) except sans type masque les erreurs
  • e) finally 4 fois ; précondition violée

a) Aucune erreur : resultat vaut 363=12,0\frac{36}{3} = 12{,}0. Le bloc finally affiche fin du calcul, puis la fonction renvoie 12,012{,}0, un flottant puisque la division / renvoie toujours un flottant en Python.

b) sum([]) vaut 0 et len([]) vaut 0 : la division lève ZeroDivisionError. La clause correspondante affiche liste vide et met resultat à None, puis finally affiche fin du calcul, et la fonction renvoie None. C'est la division qui a levé l'exception ; l'affectation de son résultat à resultat, dans le bloc try, n'a jamais eu lieu, puisque l'exception interrompt l'instruction avant qu'elle se termine.

c) sum([12, 'abs', 9]) tente d'ajouter une chaîne à un entier et lève TypeError, avant même la division. Le programme affiche valeur non numérique, puis fin du calcul : 2 lignes, et la fonction renvoie None.

d) Une clause except sans type attrape TOUTES les exceptions, y compris celles qu'on n'avait pas prévues. Si l'on écrit par erreur sum(note) au lieu de sum(notes), la NameError produite serait avalée et transformée en un résultat None silencieux : le programme continuerait avec une fausse valeur, et le défaut réapparaîtrait bien plus loin, sans aucun indice. Elle attrape même l'interruption demandée au clavier. On n'attrape que les exceptions qu'on sait traiter, et on laisse les autres interrompre le programme.

e) Le bloc finally s'exécute à chaque appel, qu'il y ait une erreur ou non : 4 affichages. Pour la liste vide, si la spécification pose en PRÉCONDITION que la liste est non vide, il vaut mieux laisser remonter l'exception, ou en lever une plus explicite : un appel qui viole le contrat doit produire une erreur, pas un résultat. Renvoyer None oblige chaque appelant à tester ce cas, et celui qui oublie de le faire provoquera une erreur plus loin, à un endroit sans rapport, exactement comme dans l'exercice 5 avec la liste vide.

Exercice 14 : La classe Fraction et ses méthodes spéciales

On veut calculer exactement avec des fractions. Les méthodes dont le nom est entouré de deux tirets bas sont appelées par Python lui-même : écrire a + b appelle a.__add__(b), écrire a == b appelle a.__eq__(b), et print(a) utilise a.__repr__(). La fonction gcd du module math renvoie le plus grand diviseur commun, toujours positif.

from math import gcd

class Fraction:
    def __init__(self, num, den):
        if den == 0:
            raise ZeroDivisionError('dénominateur nul')
        if den < 0:
            num, den = -num, -den
        g = gcd(num, den)
        self.num = num // g
        self.den = den // g

    def __add__(self, autre):
        return Fraction(self.num * autre.den + autre.num * self.den,
                        self.den * autre.den)

    def __eq__(self, autre):
        return self.num == autre.num and self.den == autre.den

    def __repr__(self):
        return str(self.num) + '/' + str(self.den)
  • a) Que valent les attributs num et den de Fraction(6, -8) ? Détaillez le passage dans le constructeur.
  • b) Calculez Fraction(1, 6) + Fraction(1, 3) en suivant le code. Qu'affiche print appliqué au résultat ?
  • c) Énoncez l'invariant de la classe. Pourquoi la méthode __eq__, qui se contente de comparer les attributs, n'est-elle correcte que grâce à lui ? Donnez un exemple.
  • d) On calcule la somme des Fraction(1, k * (k + 1)) pour k allant de 1 à 9. Quel résultat obtient-on ? Justifiez par un calcul à la main.
  • e) En flottants, 0.1 + 0.1 + 0.1 == 0.3 vaut False. Que vaut Fraction(1, 10) + Fraction(1, 10) + Fraction(1, 10) == Fraction(3, 10), et pourquoi ? Que se passe-t-il si l'on écrit Fraction(1, 2) + 1 ?

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

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

Réponses

  • a) Fraction(6, -8) rangée 3/4-3/4
  • b) 1/6+1/3=1/21/6 + 1/3 = 1/2
  • c) Invariant : forme irréductible, den positif
  • d) Somme télescopique 9/109/10
  • e) Calcul exact ; AttributeError avec un entier

a) Le dénominateur est négatif : on change les deux signes, num vaut 6-6 et den vaut 8. Puis gcd(-6, 8) vaut 2, et l'on divise : num vaut 3-3 et den vaut 4. La fraction est rangée sous la forme 34-\frac{3}{4}, avec le signe porté par le numérateur.

b) __add__ construit Fraction(1 × 3 + 1 × 6, 6 × 3), soit Fraction(9, 18). Le constructeur calcule gcd(9, 18) = 9 et ramène à num = 1 et den = 2. print affiche 1/2. On retrouve bien 16+13=16+26=12\frac{1}{6} + \frac{1}{3} = \frac{1}{6} + \frac{2}{6} = \frac{1}{2}.

c) L'invariant : den est strictement positif, et num et den sont PREMIERS ENTRE EUX. Toute fraction a une seule écriture de cette forme, si bien que deux fractions égales ont exactement les mêmes attributs. Sans la simplification, Fraction(1, 2) et Fraction(2, 4) auraient des attributs différents et __eq__ déclarerait fausse l'égalité 12=24\frac{1}{2} = \frac{2}{4}. Avec l'invariant, Fraction(2, 4) est rangée comme 1/2, et l'égalité est reconnue. Le constructeur étant le seul endroit où l'on crée des fractions, c'est lui seul qui doit garantir l'invariant.

d) 1k(k+1)=1k1k+1\frac{1}{k(k+1)} = \frac{1}{k} - \frac{1}{k+1}, donc la somme se télescope : (112)+(1213)++(19110)=1110=910\left(1 - \frac{1}{2}\right) + \left(\frac{1}{2} - \frac{1}{3}\right) + \dots + \left(\frac{1}{9} - \frac{1}{10}\right) = 1 - \frac{1}{10} = \frac{9}{10}. Le programme obtient Fraction(9, 10), avec num = 9 et den = 10, exactement.

e) L'égalité vaut True. Les fractions sont représentées EXACTEMENT par deux entiers, et les entiers de Python ne sont jamais arrondis : 110+110+110\frac{1}{10} + \frac{1}{10} + \frac{1}{10} donne 3/10 sans aucune perte. Le flottant 0.1, lui, n'a pas d'écriture finie en binaire, et la somme de trois arrondis diffère de l'arrondi de 0.3. Quant à Fraction(1, 2) + 1, la méthode __add__ reçoit l'entier 1 dans autre et tente de lire autre.den : Python lève une AttributeError, un entier n'ayant pas d'attribut den. Il faudrait convertir l'entier en Fraction(1, 1) au début de __add__.

Exercice 15 : Récursivité sur des listes imbriquées

Une liste peut contenir d'autres listes, elles-mêmes composées de listes : c'est une structure récursive, et les fonctions qui la traitent le sont naturellement. On travaille sur t = [1, [2, [3, 4]], [], [[5]]].

def aplatir(t):
    resultat = []
    for x in t:
        if isinstance(x, list):
            resultat = resultat + aplatir(x)
        else:
            resultat.append(x)
    return resultat

def profondeur(t):
    if not isinstance(t, list):
        return 0
    if t == []:
        return 1
    return 1 + max(profondeur(x) for x in t)
  • a) Que renvoie aplatir(t) ?
  • b) Combien d'appels à aplatir l'appel aplatir(t) provoque-t-il au total, celui-ci compris ? Donnez la liste des arguments.
  • c) Que renvoie profondeur(t) ? Détaillez le calcul pour chacun des quatre éléments de t.
  • d) Quel est le cas de base de chaque fonction ? On exécute t2 = [1] puis t2.append(t2) : que se passe-t-il si l'on appelle aplatir(t2), et pourquoi ?
  • e) La ligne resultat = resultat + aplatir(x) crée une nouvelle liste à chaque fois. Quel est l'inconvénient pour une grande structure, et quelle écriture l'évite ? Comment écrirait-on aplatir sans récursivité ?

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

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

Réponses

  • a) [1, 2, 3, 4, 5]
  • b) 6 appels, un par liste
  • c) profondeur(t) vaut 3
  • d) Liste cyclique : RecursionError
  • e) extend, ou une pile explicite

a) La fonction parcourt t : 1 est ajouté ; [2, [3, 4]] est aplatie en [2, 3, 4] ; [] donne [] ; [[5]] donne [5]. Le résultat est [1, 2, 3, 4, 5].

b) Un appel par liste rencontrée : t lui-même, [2, [3, 4]], [3, 4], [], [[5]] et [5]. Cela fait 6 appels. Les entiers ne provoquent aucun appel : ils sont ajoutés directement.

c) profondeur(1) vaut 0, puisque 1 n'est pas une liste. profondeur([2, [3, 4]]) vaut 1+max(0,profondeur([3,4]))1 + \max(0, \text{profondeur}([3, 4])), et profondeur([3, 4]) vaut 1+max(0,0)=11 + \max(0, 0) = 1, donc 2. profondeur([]) vaut 1 par le second cas de base. profondeur([[5]]) vaut 1+profondeur([5])=1+1=21 + \text{profondeur}([5]) = 1 + 1 = 2. Finalement profondeur(t) vaut 1+max(0,2,1,2)=31 + \max(0, 2, 1, 2) = 3.

d) Pour aplatir, le cas de base est implicite : un élément qui n'est pas une liste est ajouté sans appel, et une liste vide ne provoque aucun tour de boucle. Pour profondeur, deux cas de base : un élément qui n'est pas une liste vaut 0, la liste vide vaut 1. Avec t2, la liste se contient ELLE-MÊME : aplatir(t2) rencontre t2 parmi ses éléments et s'appelle sur t2, indéfiniment, sans jamais atteindre de cas de base. Chaque appel empile un cadre, et Python lève RecursionError. La terminaison reposait sur une hypothèse implicite, que chaque sous-liste soit strictement plus petite que la liste qui la contient, et une structure cyclique la viole.

e) L'expression resultat + aplatir(x) recopie les deux listes dans une troisième : pour une structure contenant nn éléments répartis en nombreuses sous-listes, les recopies successives peuvent coûter un temps QUADRATIQUE. L'écriture resultat.extend(aplatir(x)) ajoute les éléments à la liste existante sans la recopier. Sans récursivité, on remplace la pile d'appels par une PILE EXPLICITE : on y place t, puis, tant qu'elle n'est pas vide, on dépile un élément ; si c'est une liste, on empile ses éléments en ordre inverse, sinon on l'ajoute au résultat. C'est ce que fait l'interpréteur pour la version récursive, rendu visible.

Chapitre précédent Processus, réseaux et sécurisation Chapitre suivant Diviser pour régner, programmation dynamique et graphes

Voir aussi

Vous cherchez un tuteur en NSI à Montréal ?

Contactez-moi pour une première séance. Les questions de justification, invariant, variant, complexité, sont celles qui départagent les copies au baccalauréat, et ce sont aussi les plus rapides à travailler quand on a la bonne méthode.

Site par Studio Squalli