Corrigé Sujet 1 épreuve pratique NSI

Michel Billaud (michel.billaud@laposte.net)

30 janvier 2022

1 Licence

Cette collection de notes est mise à disposition selon les termes de la Licence Creative Commons Attribution - Pas d’Utilisation Commerciale - Partage dans les Mêmes Conditions 2.0 France.

2 Le sujet

Se trouve sur la page https://eduscol.education.fr/2661/banque-des-epreuves-pratiques-de-specialite-nsi, dans https://eduscol.education.fr/document/33178/download

3 Exercice 1 : nombre d’occurrences d’un caractère dans un mot

3.1 La question

Question : Écrire une fonction recherche qui prend en paramètres caractere, un caractère, et mot, une chaîne de caractères, et qui renvoie le nombre d’occurrences de caractere dans mot, c’est-à-dire le nombre de fois où caractere apparaît dans mot.

Clairement, le souhait est de normaliser le choix des noms - ça facilite les corrections -, on ne laisse pas d’autre possibilité que de commencer par

def recherche(caractere, mot):
   ...

Critique : le nom imposé recherche est assez malheureux. L’usage est que le nom d’une fonction décrive le résultat qu’elle calcule. “Recherche”, ça ne dit pas ce qui est recherché. La première position du caractère dans le mot ? juste un booléen qui dit si on l’a trouvé ? …

Bref, un nom comme nombre_occurrences ou nb_occurrences aurait été plus approprié.

3.2 Solution 1 : itération et comptage

Une solution simple, dans le style impératif, est de

def recherche(caractere, mot):
    nb = 0                    # nombre d'occurrences, 0 au départ
    for c in mot:             # pour chaque caractère c du mot
        if c == caractere:    #   - est-ce le bon ?
            nb += 1           #   - oui ! un de plus !
    return nb                 # et voilà le total.

Je suppose que c’est la solution à laquelle les correcteurs s’attendent le plus. Mais on peut faire autrement.

3.3 Solution 2 : sous-liste en intension et comptage

Une chaîne de caractères est aussi une liste de caractères. À ce titre,

def recherche(caractere, mot):
    return len([c for c in mot if c == caractere])

3.4 Solution 3 : la méthode count des chaînes

Quand on connaît le langage Python, on sait que les chaînes sont des séquences qui ont une méthode count qui retourne le nombre d’occurrences d’une sous-séquence.

def recherche3(caractere, mot):
    return mot.count(caractere)      

bref, c’est tout fait.

3.5 Solution 4 : récursion structurelle naïve

Sachant que

on peut s’orienter vers une solution récursive

def recherche(caractere, mot):
    if len(mot) == 0:
        return 0
    else:
        premier = mot[0]
        suite = mot[1:]
        if premier == caractere:
            return 1 + recherche(caractere, suite)
        else:
            return recherche(caractere, suite)

qu’on peut présenter de diverses façons équivalentes, plus ou moins détaillées, par exemple en utilisant seulement une variable pour le nombre d’occurrences qui figurent dans la suite :

def recherche(caractere, mot):
    if len(mot) == 0:
        return 0
    else:
        nb_occ_suite = recherche(mot[1:])
        if mot[0] == caractere:
            return 1 + nb_occ_suite
        else:
            return nb_occ_suite

ou encore, en jonglant avec les expressions conditionnelles de Python

def recherche (caractere, mot):
    return 0 if len(mot) == 0 else (
           recherche(caractere, mot[1:])
              + (1 if caractere == mot[0] else 0))

qui se lit :

3.6 Critique de la solution récursive naïve

Cette solution fournit un résultat correcte, mais elle a un défaut, sa complexité temporelle, liée au fait que la construction de mot[1:] pour un mot de taille nn implique une copie des n1n-1 éléments concernés dans une nouvelle liste.

En gros, si on part d’un mot de 10 lettres, le passage au sous-mot de 9 lettres nécessitera de copier 9 éléments. Mais pour traiter ce mot de 9 lettres, il faudra aussi faire une copie du sous-mot de 8 lettres, et le traiter.

Bref, tout ça va nous coûter 9+8+….+1 étapes. Pour un mot de taille nn, ça nous fera n(n+1)2\frac{n(n+1)}{2} étapes, une quantité qui grandit comme le carré de nn. On parle de complexité temporelle quadratique.

Comme nous disposons d’une autre solution (itérative) qui n’est pas compliquée et qui fait le calcul en un temps qui est simplement proportionnel à nn (complexité linéaire), en pratique, on n’utilisera évidemment pas la solution récursive présentée ci-dessus.

3.7 Solution 5 : Sauvons la récursion !

Ceci ne veut pas dire du tout qu’une approche récursive soit intrinsèquement inefficace.

Ce qui coûte ici, c’est de faire la copie de la fin du mot, pour la passer en paramètre.

À la place, on peut passer le mot inchangé, et un paramètre qui indique l’indice du premier caractère restant à traiter.

Ce qu’on peut présenter sous cette forme

def recherche(caractere, mot, debut = 0):
    if debut == len(mot):
        return 0
    else:
        r = recherche(caractere, mot, debut + 1)
        if caractere == mot[debut]:
            return r + 1
        else:
            return r

Ce coup-ci, le temps de calcul est linéaire.

On retrouve cette manière de faire une récursion sur une sous-liste dans l’exercice précédent.

Note : les expressions conditionnelles de Python permettent de réduire les 4 dernières lignes à

    return  r + 1  if caractere == mot[debut]  else r

qui se lit naturellement “retourne r+1 si ceci-cela, et 0 sinon”.

3.8 Solution 6 : Récursion avec variable tampon

Profitons de l’occasion pour utiliser la technique de la “variable tampon”, qui est également présente dans l’exercice suivant.

En gros, on se sert d’un paramètre supplémentaire qui sert à construire le résultat final. Ici, total indiquera le nombre d’occurrences trouvé jusque là.

Quand la récursion est terminée, la fonction retourne le contenu du tampon

def recherche(caractere, mot, debut = 0, total = 0):
    if debut == len(mot):
        return total
    t = 1  if caractere == mot[debut]  else 0 
    return recherche(caractere, mot, debut + 1, total + t)

La technique du paramètre tampon est souvent utilisée pour obtenir un algorithme récursif où la récursion est terminale (c’est-à-dire que l’appel récursif est la dernière chose qu’on fait dans la fonction).

L’intérêt de la récursion terminale, c’est que l’interprète n’a rien à mémoriser dans la pile lors de l’appel, et peut même transformer l’appel récursif en boucle. Comme on n’empile rien, la taille de la pile utilisée reste constante (si l’interprète est bien fait), au lieu de croître linéairement avec les versions récursives précédentes.

Ce qui nous ramène au premier algorithme, avec la correspondance entre debut et i pour les indices, et total avec nb pour les compteurs.

4 Exercice 2 : rendu de monnaie

4.1 La question

Un bout de programme Python à trous, à compléter

Pieces = [100,50,20,10,5,2,1]

def rendu_glouton(arendre, solution=[], i=0):
    if arendre == 0:
        return ...                                    # 1
    p = pieces[i]                                     # BUG
    if p <= ... :                                     # 2
        solution.append(...)                          # 3
        return rendu_glouton(arendre - p, solution, i)
    else :
        return rendu_glouton(arendre, solution, ...)  # 4

comportement attendu

>>>rendu_glouton_r(68,[],0)
[50, 10, 5, 2, 1]
>>>rendu_glouton_r(291,[],0)
[100, 100, 50, 20, 20, 1]

Critiques :

À ces considérations syntaxiques, dont les rédacteurs du sujet semblent ignorer autant l’existence https://www.python.org/dev/peps/pep-0008 que l’importance (y compris pédagogique), s’ajoute

>>>rendu_glouton_r(68)
[50, 10, 5, 2, 1]
>>>rendu_glouton_r(291)
[100, 100, 50, 20, 20, 1]

4.2 Résolution

Il n’y a jamais que 4 trous à compléter.

Quelques observations

Il parait donc raisonnable

4.3 Corrigé

PIECES = [100, 50, 20, 10, 5, 2, 1]

def rendu_glouton(a_rendre, solution = [], i = 0):
    if a_rendre == 0:
        return solution                                  # 1
    p = PIECES[i]             
    if p <= a_rendre:                                    # 2
        solution.append(p)                               # 3
        return rendu_glouton(a_rendre - p, solution, i)
    else :
        return rendu_glouton(a_rendre, solution, i + 1)  # 4

4.4 Retour à une solution itérative

Dans le code ci-dessus les appels à rendu_glouton sont terminaux (on ne fait rien après).

Ce qui veut dire qu’on peut convertir ce code sous forme itérative (avec des boucle).

Un appel terminal signifie : recommencer avec les valeurs indiquée en paramètres. En détail

D’où l’idée d’avoir deux boucles imbriquées

Le paramètre tampon solution se transforme en variable locale de la fonction, retournée à la sortie de la fonction

Une solution simple :

PIECES = [100,50,20,10,5,2,1]

def rendu_iteratif(a_rendre):
    solution = []
    for piece in PIECES:
        while a_rendre >= piece:
            solution.append(piece)
            a_rendre = a_rendre - piece
    return solution
    
print ("Tests :")
assert rendu_iteratif(68) ==  [50, 10, 5, 2, 1]
assert rendu_iteratif(291) == [100, 100, 50, 20, 20, 1]
print ("Ok !")

Que l’on peut accélérer légèrement en retournant dès qu’une pièce a suffi pour épuiser la somme à rendre

def rendu_iteratif(a_rendre):
    solution = []
    for piece in PIECES:
        while a_rendre >= piece:
            solution.append(piece)
            a_rendre = a_rendre - piece
        if a_rendre == 0:
            return solution
    # on ne passe jamais ici

Dans cette version, on ne sort jamais “par la fin” de la boucle for. Le fait que

permet de conclure qu’il arrivera nécessairement que la condition arendre == 0 soit vraie, au plus tard en rencontrant la pièce 1. Et donc le return solution sera forcément exécuté, qui fera sortir “sauvagement” de la boucle for et de la fonction.