Retour à la page d'accueilNSI · Terminale (maths complémentaires) · Changer de matière
Commencer gratuitement
Terminale (maths complémentaires) · NSI
Préparer l'épreuve de spécialité NSI du bac · Épreuve de spécialité NSI du baccalauréat

Programmation Python et récursivité

À l'épreuve écrite de NSI, un exercice sur trois porte souvent sur la programmation Python : on te donne une fonction, tu dois la faire « tourner à la main », la corriger, la justifier. Comment suivre l'exécution d'une fonction qui s'appelle elle-même, et comment être sûr qu'elle ne tourne pas indéfiniment ? C'est ce que nous allons voir.

Fonctions récursives : cas de base et cas récursif

Une fonction est récursive lorsqu'elle s'appelle elle-même. Pour que cela fonctionne, il faut deux ingrédients, comme pour un escalier : une première marche, et une règle pour passer d'une marche à la suivante.

Exemple : la somme des chiffres d'un entier. Pour n = 2548, n % 10 donne le dernier chiffre (8) et n // 10 donne le reste du nombre (254).

def somme_chiffres(n):

    if n < 10:

        return n

    else:

        return n % 10 + somme_chiffres(n // 10)

Terminaison

Une fonction récursive se termine si, à chaque appel, une quantité entière décroît strictement et reste positive ou nulle : on finit forcément par atteindre le cas de base. Ici, n diminue (n // 10 < n dès que n ≥ 1) et reste un entier naturel : l'exécution se termine.

Pour se protéger, on écrit une assertion au début de la fonction : assert n >= 0. Si la condition est fausse, le programme s'arrête immédiatement avec une AssertionError.

Entraîne-toi★☆☆Pas commencé
On considère la fonction suivante, qui prend en paramètre un entier naturel n. 1. Donner la valeur de 2 548 % 10 et celle de 2 548 // 10. 2. Détailler les appels…

On considère la fonction suivante, qui prend en paramètre un entier naturel n.

def somme_chiffres(n):

    if n < 10:

        return n

    else:

        return n % 10 + somme_chiffres(n // 10)
  1. Donner la valeur de 2 548 % 10 et celle de 2 548 // 10.

  2. Détailler les appels successifs effectués par somme_chiffres(2548), puis donner la valeur renvoyée.

  3. Justifier que l'exécution de somme_chiffres(n) se termine pour tout entier naturel n.

Résoudre
Entraîne-toi★☆☆Pas commencé
On considère la fonction récursive suivante. 1. Donner la valeur renvoyée par puissance(3, 4). 2. Indiquer le nombre total d'appels de la fonction puissance effectués…

On considère la fonction récursive suivante.

def puissance(x, n):

    if n == 0:

        return 1

    else:

        return x * puissance(x, n - 1)
  1. Donner la valeur renvoyée par puissance(3, 4).

  2. Indiquer le nombre total d'appels de la fonction puissance effectués lors de l'exécution de puissance(3, 4), appel initial compris.

  3. Expliquer ce qui se produit lors de l'exécution de puissance(2, -1).

  4. Écrire une assertion à placer au début de la fonction pour se prémunir contre ce problème.

Résoudre

Plusieurs appels récursifs

Une fonction peut aussi se rappeler sur un problème plus petit deux fois. Pour est_palindrome, on compare les extrémités d'une chaîne puis on recommence sur la chaîne privée de ses extrémités (s[1:-1]). La quantité qui décroît est len(s) : elle diminue de 2 à chaque appel.

Avec la suite de Fibonacci, fib(n) appelle fib(n - 1) et fib(n - 2) : le nombre d'appels explose. Si $C(n)$ est ce nombre d'appels, alors $C(n) = 1 + C(n-1) + C(n-2)$ : le « 1 » est l'appel initial, les deux autres termes sont les appels déclenchés par chacun des deux appels directs.

Entraîne-toi★★☆Pas commencé
On considère la fonction suivante, qui prend en paramètre une chaîne de caractères s. 1. Donner la valeur renvoyée par est_palindrome("radar") et préciser le nombre…

On considère la fonction suivante, qui prend en paramètre une chaîne de caractères s.

def est_palindrome(s):

    if len(s) <= 1:

        return True

    elif s[0] != s[-1]:

        return False

    else:

        return est_palindrome(s[1:-1])
  1. Donner la valeur renvoyée par est_palindrome("radar") et préciser le nombre d'appels de la fonction effectués, appel initial compris.

  2. Donner la valeur renvoyée par est_palindrome("lycee") et préciser le nombre d'appels effectués.

  3. Identifier le cas de base et le cas récursif, puis justifier la terminaison en précisant la quantité qui décroît strictement à chaque appel.

  4. Écrire une fonction récursive compte(s, c) qui renvoie le nombre d'occurrences du caractère c dans la chaîne s.

Résoudre
Entraîne-toi★★★Pas commencé
On considère la fonction de Fibonacci définie par $F_0 = 0$, $F_1 = 1$ et $F_n = F_{n-1} + F_{n-2}$ pour $n \geqslant 2$. On note $C(n)$ le nombre total d'appels de la…

On considère la fonction de Fibonacci définie par $F_0 = 0$, $F_1 = 1$ et $F_n = F_{n-1} + F_{n-2}$ pour $n \geqslant 2$.

def fib(n):

    if n <= 1:

        return n

    else:

        return fib(n - 1) + fib(n - 2)

On note $C(n)$ le nombre total d'appels de la fonction fib (appel initial compris) lors de l'exécution de fib(n).

  1. Donner $C(0)$, $C(1)$, puis justifier que $C(n) = 1 + C(n-1) + C(n-2)$ pour $n \geqslant 2$.

  2. Calculer $C(2)$, $C(3)$, $C(4)$ et $C(5)$.

  3. Montrer par récurrence que, pour tout entier $n \geqslant 0$, $C(n) = 2F_{n+1} - 1$.

  4. On propose la version suivante.

def fib_memo(n, memo=None):

    if memo is None:

        memo = {}

    if n in memo:

        return memo[n]

    if n <= 1:

        resultat = n

    else:

        resultat = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)

    memo[n] = resultat

    return resultat
a) Expliquer le rôle du dictionnaire `memo`.

b) Expliquer pourquoi le nombre de calculs effectifs de la valeur $F_k$ est au plus égal à 1 pour chaque $k$ compris entre 0 et $n$.
Résoudre

Mise au point : spécification, assertions et jeux de tests

Un programme qui « marche sur l'exemple du sujet » n'est pas forcément correct. Pour détecter les bugs, on précise d'abord la spécification de la fonction (ce qu'elle reçoit, ce qu'elle renvoie), puis on la teste.

Regarde cette fonction censée renvoyer le plus grand élément d'une liste non vide :

def maximum(tab):

    m = 0

    for x in tab:

        if x > m:

            m = x

    return m

maximum([3, 8, 2]) renvoie bien 8. Mais maximum([-5, -2, -9]) renvoie 0, qui n'est même pas dans la liste ! L'initialisation m = 0 suppose, à tort, que la liste contient un nombre positif.

Entraîne-toi★☆☆Pas commencé
On souhaite écrire une fonction maximum(tab) qui renvoie le plus grand élément d'une liste non vide tab de nombres entiers. Un élève propose le code suivant. 1. Donner…

On souhaite écrire une fonction maximum(tab) qui renvoie le plus grand élément d'une liste non vide tab de nombres entiers. Un élève propose le code suivant.

def maximum(tab):

    m = 0

    for x in tab:

        if x > m:

            m = x

    return m
  1. Donner la valeur renvoyée par maximum([3, 8, 2]).

  2. Donner la valeur renvoyée par maximum([-5, -2, -9]). Expliquer pourquoi cette valeur ne correspond pas à la spécification de la fonction.

  3. Corriger la fonction pour qu'elle soit conforme à sa spécification.

  4. Proposer un jeu de trois tests, dont un avec uniquement des nombres négatifs, qui permet de détecter l'erreur initiale.

Résoudre

Diviser pour régner : le tri fusion

Trier 1 000 cartes d'un coup est pénible. Mais si on coupe le tas en deux, qu'on trie chaque moitié, puis qu'on fusionne les deux tas triés, c'est plus simple. C'est le principe « diviser pour régner » : diviser, résoudre récursivement les sous-problèmes, combiner.

def tri_fusion(tab):

    if len(tab) <= 1:

        return tab

    milieu = len(tab) // 2

    gauche = tri_fusion(tab[:milieu])

    droite = tri_fusion(tab[milieu:])

    return fusion(gauche, droite)

Le cas de base est une liste de 0 ou 1 élément (déjà triée). La longueur est divisée par 2 à chaque niveau : la terminaison est assurée.

La fonction fusion compare les premiers éléments restants des deux listes triées, prend le plus petit, et recommence :

def fusion(l1, l2):

    res = []

    i, j = 0, 0

    while i < len(l1) and j < len(l2):

        if l1[i] <= l2[j]:

            res.append(l1[i])

            i = i + 1

        else:

            res.append(l2[j])

            j = j + 1

    return res + l1[i:] + l2[j:]

Combien ça coûte ? Une liste de $n$ éléments se coupe en deux environ $\log_2 n$ fois, et à chaque niveau la fusion parcourt les $n$ éléments. Le coût est de l'ordre de $n \log_2 n$, bien meilleur que $n^2$ pour un tri simple par comparaisons successives. Ici $\log_2$ sert simplement à compter le nombre de divisions par 2.

Entraîne-toi★★☆Pas commencé
On étudie le tri fusion d'une liste de nombres selon le principe « diviser pour régner ». On rappelle que la fonction fusion prend en paramètres deux listes triées par…

On étudie le tri fusion d'une liste de nombres selon le principe « diviser pour régner ». On rappelle que la fonction fusion prend en paramètres deux listes triées par ordre croissant et renvoie une liste triée contenant tous leurs éléments.

def tri_fusion(tab):

    if len(tab) <= 1:

        return tab

    milieu = len(tab) // 2

    gauche = tri_fusion(tab[:milieu])

    droite = tri_fusion(tab[milieu:])

    return fusion(gauche, droite)
  1. Donner le résultat de fusion([1, 4, 7], [2, 3, 9]).

  2. Écrire la fonction fusion(l1, l2), sans utiliser de fonction de tri prédéfinie.

  3. Représenter les découpages successifs effectués par tri_fusion([6, 2, 9, 4, 1, 7, 3, 8]). Donner le nombre de niveaux de découpage jusqu'aux listes de longueur 1.

  4. Pour une liste de longueur $n = 1\,024$, donner le nombre de niveaux de découpage, puis l'ordre de grandeur $n \log_2 n$ du nombre total d'opérations élémentaires (comparaisons) effectuées lors des fusions.

Résoudre

Programmation dynamique : le rendu de monnaie

Un automate doit rendre la monnaie avec des pièces de 1, 3 et 4 centimes. Pour rendre 6 centimes, l'algorithme glouton (toujours la plus grosse pièce possible) donne 4 + 1 + 1, soit 3 pièces. Or 3 + 3 convient avec 2 pièces : le glouton n'est pas toujours optimal.

La programmation dynamique résout ce problème en calculant les solutions des sous-problèmes une seule fois et en les réutilisant. Notons $m(s)$ le nombre minimal de pièces pour rendre $s$. Alors $m(0) = 0$ et, pour $s \geqslant 1$ :

$$m(s) = 1 + \min \{ m(s - p) \; ; \; p \text{ pièce}, \; p \leqslant s \}$$

Pourquoi ? La dernière pièce rendue est une pièce $p$ ; il reste alors $s - p$ à rendre de façon optimale.

On remplit un tableau de gauche à droite : chaque valeur est calculée une fois, à partir de valeurs déjà connues.

Entraîne-toi★★☆Pas commencé
Un automate de rendu de monnaie dispose de pièces de valeurs 1, 3 et 4 (en centimes d'euro), en quantité illimitée. On cherche à rendre une somme s avec un nombre…

Un automate de rendu de monnaie dispose de pièces de valeurs 1, 3 et 4 (en centimes d'euro), en quantité illimitée. On cherche à rendre une somme s avec un nombre minimal de pièces.

On considère l'algorithme glouton suivant.

def rendu_glouton(s, pieces):

    #pieces est triée par ordre décroissant

    nb = 0

    for p in pieces:

        nb = nb + s // p

        s = s % p

    return nb
  1. Donner la valeur de rendu_glouton(6, [4, 3, 1]). Montrer que ce résultat n'est pas optimal.

  2. On note $m(s)$ le nombre minimal de pièces nécessaires pour rendre la somme $s$. On a $m(0) = 0$ et, pour $s \geqslant 1$, $m(s) = 1 + \min m(s - p)$, le minimum étant pris sur les pièces $p$ telles que $p \leqslant s$. Calculer $m(s)$ pour $s$ allant de 0 à 6.

  3. Écrire une fonction rendu_dyn(s, pieces) qui renvoie le nombre minimal de pièces en construisant une liste m telle que m[i] contienne $m(i)$ pour $0 \leqslant i \leqslant s$.

  4. Expliquer en quoi cette méthode relève de la programmation dynamique.

Résoudre

Programme comme donnée et problème de l'arrêt

En Python, une fonction peut être passée en paramètre d'une autre fonction : un programme est aussi une donnée. On peut donc donner un programme à lui-même.

Question : existe-t-il une fonction arrete(f, x) qui dit, pour tout programme f et toute donnée x, si f(x) se termine ? La réponse est non : le problème de l'arrêt est indécidable.

Entraîne-toi★★★Pas commencé
On suppose qu'il existe une fonction arrete(f, x) qui prend en paramètres une fonction Python f et une donnée x, et qui renvoie True si l'appel f(x) se termine, et False…

On suppose qu'il existe une fonction arrete(f, x) qui prend en paramètres une fonction Python f et une donnée x, et qui renvoie True si l'appel f(x) se termine, et False s'il ne se termine jamais. On suppose que arrete se termine toujours.

On définit la fonction suivante.

def paradoxe(f):

    if arrete(f, f):

        while True:

            pass

    else:

        return 0
  1. Expliquer ce que fait paradoxe(f) lorsque arrete(f, f) vaut True, puis lorsqu'elle vaut False.

  2. On considère l'appel paradoxe(paradoxe).

    a) Supposer que arrete(paradoxe, paradoxe) renvoie True et aboutir à une contradiction.

    b) Supposer que arrete(paradoxe, paradoxe) renvoie False et aboutir à une contradiction.

  3. En déduire que la fonction arrete ne peut pas exister. Énoncer le résultat de décidabilité ainsi établi.

  4. Expliquer en quoi cette démonstration utilise le fait qu'un programme peut être donné en argument d'un autre programme.

Résoudre

Récursivité et nombre de déplacements : les tours de Hanoï

Pour déplacer $n$ disques de la tige de départ à la tige d'arrivée, on déplace d'abord les $n-1$ disques du dessus vers la tige auxiliaire, puis le grand disque, puis on replace les $n-1$ disques dessus.

def hanoi(n, dep, arr, aux):

    if n > 0:

        hanoi(n - 1, dep, aux, arr)

        print(dep, "->", arr)

        hanoi(n - 1, aux, arr, dep)

Pour hanoi(2, "A", "C", "B") : on affiche A -> B, puis A -> C, puis B -> C. Si $h(n)$ est le nombre de déplacements, deux appels sur $n-1$ disques et un déplacement donnent $h(n) = 2h(n-1) + 1$ avec $h(0) = 0$ : on obtient 0, 1, 3, 7, 15… soit $2^n - 1$.

Entraîne-toi★★★Pas commencé
Le problème des tours de Hanoï consiste à déplacer $n$ disques de tailles différentes d'une tige de départ vers une tige d'arrivée, en utilisant une tige auxiliaire. On…

Le problème des tours de Hanoï consiste à déplacer $n$ disques de tailles différentes d'une tige de départ vers une tige d'arrivée, en utilisant une tige auxiliaire. On déplace un seul disque à la fois et on ne pose jamais un disque sur un disque plus petit. Les disques sont initialement empilés par taille décroissante sur la tige de départ.

def hanoi(n, dep, arr, aux):

    if n > 0:

        hanoi(n - 1, dep, aux, arr)

        print(dep, "->", arr)

        hanoi(n - 1, aux, arr, dep)
  1. Donner les affichages successifs produits par l'appel hanoi(2, "A", "C", "B").

  2. On note $h(n)$ le nombre de déplacements de disques effectués par hanoi(n, ...). Justifier que $h(0) = 0$ et que $h(n) = 2\,h(n-1) + 1$ pour $n \geqslant 1$.

  3. Montrer par récurrence que $h(n) = 2^n - 1$ pour tout entier $n \geqslant 0$.

  4. Calculer $h(3)$ et $h(10)$.

  5. Un affichage prend 1 ms. Calculer, en secondes, la durée nécessaire pour $n = 20$ disques, puis la comparer à la durée nécessaire pour $n = 10$. Conclure sur la croissance du coût de cet algorithme.

Résoudre

Retour au début : pour suivre une fonction récursive, on empile les appels jusqu'au cas de base puis on remonte les résultats ; et pour être sûr qu'elle s'arrête, on cherche la quantité entière qui décroît strictement.

L'essentiel

  • Une fonction récursive a un cas de base et un cas récursif sur un problème plus petit.

  • Terminaison : une quantité entière positive ou nulle décroît strictement à chaque appel.

  • Mise au point : spécification, assertions (assert) et jeux de tests variés (cas limites, valeurs négatives).

  • Diviser pour régner (tri fusion) : diviser, résoudre, fusionner ; coût en $n \log_2 n$.

  • Programmation dynamique : on mémorise les solutions des sous-problèmes ; le glouton n'est pas toujours optimal.

  • Le problème de l'arrêt est indécidable : aucun programme ne peut décider, pour tout programme, s'il se termine.

Voir tous les exercices de cette partie