Partie I - Fondamentaux Python · Chapitre 4 · Fiche d'exercices

Les Fonctions

8 exercices corriges · difficulte croissante

Toutes les fonctions demandees doivent etre accompagnees d'une docstring precisant leur specification et de tests par assert.

Exercice 1Effets de bord : predire l'etat finalApplication

Que vaut x apres chaque fragment ? Justifier.

a) def f(n): n = n * 2
   x = 3; f(x)

b) def f(t): t.append(0)
   x = [1]; f(x)

c) def f(t): t = t + [0]
   x = [1]; f(x)

d) def f(t): t[0] = 99
   x = [1, 2]; f(x)

e) def f(s): s = s.upper()
   x = "ab"; f(x)

f) def f(t): return t.sort()
   x = [3, 1]; y = f(x)
Afficher le corrige
Valeur de xExplication
a3int immuable ; n est un nom local reaffecte
b[1, 0]append modifie l'objet lui-meme
c[1]t + [0] cree une nouvelle liste, affectee au nom local
d[99, 2]affectation d'une case : modification en place
e"ab"les chaines sont immuables ; upper renvoie une copie
f[1, 3], et y vaut None sort trie en place et renvoie None
Regle mnemotechnique

Une affectation t = ... dans la fonction ne sort jamais. Une mutation t.append(...), t[i] = ..., t.sort() sort toujours (si l'objet est mutable).

Exercice 2Primalite et decomposition en facteurs premiersEntrainement
  1. Ecrire est_premier(n) renvoyant un booleen.
  2. Ecrire premiers_jusqua(n) renvoyant la liste des nombres premiers \(\le n\), en reutilisant la fonction precedente.
  3. Ecrire nieme_premier(k) renvoyant le \(k\)-ieme nombre premier (nieme_premier(1) vaut 2).
  4. Ecrire decomposition(n) renvoyant la liste des facteurs premiers de \(n\) avec repetition : decomposition(360) vaut [2, 2, 2, 3, 3, 5].
Afficher le corrige
def est_premier(n):
    """n est-il un nombre premier ? n entier quelconque."""
    if n < 2:
        return False
    if n % 2 == 0:
        return n == 2
    d = 3
    while d * d <= n:
        if n % d == 0:
            return False
        d += 2                    # seuls les diviseurs impairs restent a tester
    return True

def premiers_jusqua(n):
    """Liste croissante des nombres premiers <= n."""
    return [k for k in range(2, n + 1) if est_premier(k)]

def nieme_premier(k):
    """k-ieme nombre premier, k >= 1."""
    compte, n = 0, 1
    while compte < k:
        n += 1
        if est_premier(n):
            compte += 1
    return n

def decomposition(n):
    """Facteurs premiers de n >= 2, avec multiplicite, en ordre croissant."""
    facteurs = []
    d = 2
    while d * d <= n:
        while n % d == 0:         # on epuise le facteur d
            facteurs.append(d)
            n //= d
        d += 1
    if n > 1:                     # reste un facteur premier > sqrt(n initial)
        facteurs.append(n)
    return facteurs

assert premiers_jusqua(20) == [2, 3, 5, 7, 11, 13, 17, 19]
assert nieme_premier(1) == 2 and nieme_premier(100) == 541
assert decomposition(360) == [2, 2, 2, 3, 3, 5]
assert decomposition(97) == [97]
from math import prod
for n in range(2, 2000):
    assert prod(decomposition(n)) == n

Dans decomposition, le test final if n > 1 est indispensable : apres la boucle, ce qui reste de n est soit 1, soit un facteur premier superieur a la racine de la valeur courante (par exemple pour \(n = 2\cdot 97\)).

Exercice 3Reecrire les fonctions nativesApplication

Ecrire, sans utiliser les fonctions natives correspondantes :

  1. mon_max(t) : maximum d'une liste non vide.
  2. mon_sum(t) : somme des elements.
  3. mon_abs(x), mon_min(a, b).
  4. moyenne_ponderee(valeurs, poids) : \(\frac{\sum v_i p_i}{\sum p_i}\), avec verification que les deux listes ont meme longueur et que la somme des poids est non nulle.
Afficher le corrige
def mon_max(t):
    """Plus grand element de la liste non vide t."""
    m = t[0]
    for x in t:
        if x > m:
            m = x
    return m

def mon_sum(t):
    """Somme des elements de t (0 si t est vide)."""
    s = 0
    for x in t:
        s += x
    return s

def mon_abs(x):
    return x if x >= 0 else -x

def mon_min(a, b):
    return a if a < b else b

def moyenne_ponderee(valeurs, poids):
    """Moyenne de valeurs ponderee par poids.

    Preconditions : meme longueur, somme des poids non nulle.
    Renvoie None si elles ne sont pas verifiees.
    """
    if len(valeurs) != len(poids) or len(valeurs) == 0:
        return None
    total = mon_sum(poids)
    if total == 0:
        return None
    s = 0
    for i in range(len(valeurs)):
        s += valeurs[i] * poids[i]
    return s / total

assert mon_max([-5, -2, -9]) == -2
assert mon_sum([]) == 0
assert moyenne_ponderee([12, 16], [1, 3]) == 15.0
assert moyenne_ponderee([1], [0]) is None

Noter que mon_sum([]) vaut 0 (element neutre) alors que mon_max([]) n'a pas de sens : d'ou la precondition « liste non vide », qu'il faut expliciter.

Exercice 4Le piege de la valeur par defaut mutableEntrainement

On considere :

def mystere(t, x=[]):
    x.append(t)
    return x

print(mystere(1))
print(mystere(2))
print(mystere(3, []))
print(mystere(4))
  1. Predire l'affichage, puis verifier.
  2. Expliquer precisement le mecanisme.
  3. Corriger la fonction.
  4. Existe-t-il des cas ou une valeur par defaut mutable est acceptable ?
Afficher le corrige

1.

[1]
[1, 2]
[3]
[1, 2, 4]

2. L'expression [] qui sert de valeur par defaut est evaluee une seule fois, au moment ou l'instruction def est executee. La liste ainsi creee est stockee dans l'attribut mystere.__defaults__ et reutilisee a chaque appel omettant l'argument. Les appels 1, 2 et 4 partagent donc la meme liste, qui s'allonge. Le troisieme appel fournit explicitement une nouvelle liste, sans effet sur celle par defaut.

print(mystere.__defaults__)     # ([1, 2, 4],)  : la liste partagee

3.

def correcte(t, x=None):
    """x=None signifie 'liste vide neuve a chaque appel'."""
    if x is None:
        x = []
    x.append(t)
    return x

4. Oui, si la valeur par defaut n'est jamais modifiee, ou si le partage est precisement l'effet recherche (memoisation par cache partage, compteur d'appels). Mais il faut alors le documenter explicitement, car c'est contre-intuitif :

def fib(n, cache={0: 0, 1: 1}):
    """Le cache partage entre les appels est ici volontaire (memoisation)."""
    if n not in cache:
        cache[n] = fib(n-1, cache) + fib(n-2, cache)
    return cache[n]
Exercice 5Fonctions d'ordre superieur et point fixeApprofondissement

Une fonction \(f\) etant donnee, ecrire les fonctions suivantes qui prennent f en parametre :

  1. tabule(f, a, b, n) : liste des \(f(a + k h)\) pour \(k = 0..n\), \(h=(b-a)/n\).
  2. compose(f, g) : renvoie la fonction \(x \mapsto f(g(x))\).
  3. iterer(f, n) : renvoie \(f^{\circ n}\), c'est-a-dire \(f\) composee \(n\) fois avec elle-meme (\(f^{\circ 0} = \mathrm{id}\)).
  4. point_fixe(f, x0, eps, nmax) : itere \(x_{k+1} = f(x_k)\) jusqu'a \(|x_{k+1}-x_k| < \varepsilon\) ou \(n_{\max}\) iterations. Tester sur \(f(x)=\cos x\).
Afficher le corrige
def tabule(f, a, b, n):
    """Valeurs de f en n+1 points equirepartis de [a, b]."""
    h = (b - a) / n
    return [f(a + k*h) for k in range(n + 1)]

def compose(f, g):
    """Renvoie la fonction x -> f(g(x))."""
    def h(x):
        return f(g(x))
    return h

def iterer(f, n):
    """Renvoie f o f o ... o f (n fois). n = 0 donne l'identite."""
    def h(x):
        for _ in range(n):
            x = f(x)
        return x
    return h

def point_fixe(f, x0, eps=1e-12, nmax=1000):
    """Iteration de point fixe. Renvoie (x, nb_iterations, convergence)."""
    x = x0
    for k in range(1, nmax + 1):
        y = f(x)
        if abs(y - x) < eps:
            return y, k, True
        x = y
    return x, nmax, False

from math import cos, sqrt
carre = lambda x: x*x
assert compose(carre, lambda x: x + 1)(3) == 16      # (3+1)^2
assert iterer(lambda x: 2*x, 5)(1) == 32             # 2^5
assert iterer(carre, 0)(7) == 7                      # identite

x, k, ok = point_fixe(cos, 1.0)
print(x, k, ok)      # 0.7390851332151607  (le "nombre de Dottie"), ~90 iterations
Notion — fonction d'ordre superieur

Une fonction qui prend une fonction en argument ou en renvoie une est dite d'ordre superieur. C'est possible parce qu'en Python les fonctions sont des objets de premiere classe : on peut les affecter, les stocker dans une liste, les passer en parametre. Cette idee est reutilisee pour les cles de tri (key=) et pour les methodes numeriques des chapitres 21 a 23, ou l'on passe la fonction a integrer ou a annuler.

La convergence de \(x_{k+1} = \cos(x_k)\) est garantie par le theoreme du point fixe : \(\cos\) est \(k\)-lipschitzienne de rapport \(|\sin| \le \sin(1) \approx 0{,}84 < 1\) sur \([0,1]\), qui est stable par \(\cos\). La convergence est lineaire, d'ou le nombre eleve d'iterations ; la methode de Newton (chapitre 21) fera bien mieux.

Exercice 6Un module de calcul polynomialApprofondissement

Ecrire un module de gestion de polynomes representes par la liste de leurs coefficients, \(P = \sum a_i X^i\) etant code par [a0, a1, ..., an].

  1. evalue(P, x) par le schema de Horner.
  2. somme(P, Q) et produit(P, Q).
  3. derive(P).
  4. Verifier sur des exemples que \((PQ)' = P'Q + PQ'\).
  5. Quelle est la complexite de produit ?
Afficher le corrige
def evalue(P, x):
    """Valeur en x du polynome P = [a0, a1, ..., an], par Horner. n multiplications."""
    v = 0
    for a in reversed(P):
        v = v * x + a
    return v

def somme(P, Q):
    """Somme de deux polynomes."""
    n = max(len(P), len(Q))
    R = [0] * n
    for i, a in enumerate(P):
        R[i] += a
    for i, a in enumerate(Q):
        R[i] += a
    return R

def produit(P, Q):
    """Produit de deux polynomes (algorithme naif)."""
    if not P or not Q:
        return []
    R = [0] * (len(P) + len(Q) - 1)
    for i, a in enumerate(P):
        for j, b in enumerate(Q):
            R[i + j] += a * b
    return R

def derive(P):
    """Polynome derive."""
    return [i * P[i] for i in range(1, len(P))]

P = [1, 2, 3]        # 1 + 2X + 3X^2
Q = [0, 1]           # X
assert evalue(P, 2) == 1 + 4 + 12
assert somme(P, Q) == [1, 3, 3]
assert produit(P, Q) == [0, 1, 2, 3]
assert derive(P) == [2, 6]

# (PQ)' = P'Q + PQ'
import random
for _ in range(200):
    A = [random.randint(-5, 5) for _ in range(random.randint(1, 6))]
    B = [random.randint(-5, 5) for _ in range(random.randint(1, 6))]
    g = derive(produit(A, B))
    d = somme(produit(derive(A), B), produit(A, derive(B)))
    g += [0] * (len(d) - len(g))
    d += [0] * (len(g) - len(d))
    assert g == d
print("formule de Leibniz verifiee")

5. La double boucle de produit effectue exactement \(\deg P \cdot \deg Q\) multiplications, soit \(\Theta(nm)\) et \(\Theta(n^2)\) pour deux polynomes de meme degre. L'algorithme de Karatsuba abaisse ce cout a \(\Theta(n^{\log_2 3}) \approx \Theta(n^{1{,}585})\), et la transformee de Fourier rapide a \(\Theta(n\log n)\).

Le schema de Horner, lui, n'utilise que \(n\) multiplications et \(n\) additions pour evaluer un polynome de degre \(n\), contre \(\Theta(n^2)\) pour la methode naive qui recalcule chaque \(x^i\). On peut montrer que \(n\) multiplications sont necessaires : Horner est optimal.

Exercice 7Ecrire son propre banc de testsEntrainement

On veut ecrire un mini-systeme de test.

  1. Ecrire verifie(nom, obtenu, attendu) qui affiche OK ou un message d'erreur detaille, et renvoie un booleen.
  2. Ecrire lance_tests(f, cas) ou cas est une liste de couples (arguments, resultat_attendu), qui applique f et compte les reussites.
  3. L'appliquer a une fonction fibonacci(n).
  4. Que se passe-t-il si f leve une exception ? Rendre lance_tests robuste.
Afficher le corrige
def verifie(nom, obtenu, attendu):
    """Compare obtenu et attendu, affiche le resultat, renvoie un booleen."""
    if obtenu == attendu:
        print(f"  OK   {nom}")
        return True
    print(f"  ECHEC {nom} : obtenu {obtenu!r}, attendu {attendu!r}")
    return False

def lance_tests(f, cas):
    """cas : liste de (tuple_arguments, resultat_attendu). Renvoie (reussis, total)."""
    reussis = 0
    print(f"Tests de {f.__name__} :")
    for args, attendu in cas:
        nom = f"{f.__name__}{args}"
        try:
            obtenu = f(*args)
        except Exception as e:
            print(f"  ERREUR {nom} : {type(e).__name__} : {e}")
            continue
        if verifie(nom, obtenu, attendu):
            reussis += 1
    print(f"=> {reussis}/{len(cas)} tests reussis\n")
    return reussis, len(cas)

def fibonacci(n):
    """n-ieme terme de la suite de Fibonacci, F(0)=0, F(1)=1."""
    if n < 0:
        raise ValueError("n doit etre positif")
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

lance_tests(fibonacci, [
    ((0,), 0), ((1,), 1), ((10,), 55), ((30,), 832040),
    ((-1,), None),          # provoque volontairement une exception
    ((5,), 999),            # echec volontaire
])
Tests de fibonacci :
  OK   fibonacci(0,)
  OK   fibonacci(1,)
  OK   fibonacci(10,)
  OK   fibonacci(30,)
  ERREUR fibonacci(-1,) : ValueError : n doit etre positif
  ECHEC fibonacci(5,) : obtenu 5, attendu 999
=> 4/6 tests reussis

4. Sans le bloc try / except, la premiere exception interromprait toute la campagne de tests. Le capturer permet de signaler l'anomalie et de poursuivre. Noter f.__name__ qui donne le nom de la fonction, et f(*args) qui deballe le tuple d'arguments.

Bonne pratique

En Python, on utilise en pratique le module unittest ou pytest. En CPGE, un simple assert suffit : il est concis et leve AssertionError en pointant la ligne fautive.

Exercice 8Memoisation et comptage de cheminsConcours

Type concours. Une fonction \(f\) est dite memoisee si elle conserve les resultats deja calcules pour ne pas les recalculer.

  1. Ecrire memoise(f) qui prend une fonction d'un argument entier et renvoie une version memoisee.
  2. L'appliquer a une fonction de comptage des chemins dans une grille : \(C(i,j)\) est le nombre de chemins allant de \((0,0)\) a \((i,j)\) par pas unitaires vers la droite ou vers le haut. Etablir la relation de recurrence.
  3. Sans memoisation, montrer que le nombre d'appels pour calculer \(C(n,n)\) est au moins \(\binom{2n}{n}\). Avec memoisation, quel est le cout ?
  4. Verifier que \(C(i,j) = \binom{i+j}{i}\).
Afficher le corrige
def memoise(f):
    """Renvoie une version memoisee de f (fonction d'un ou plusieurs arguments hachables)."""
    cache = {}
    def g(*args):
        if args not in cache:
            cache[args] = f(*args)
        return cache[args]
    g.cache = cache          # accessible pour l'inspection
    g.__name__ = "memo_" + f.__name__
    return g

2. Pour atteindre \((i,j)\), le dernier pas vient soit de \((i-1,j)\), soit de \((i,j-1)\). Ces deux familles de chemins sont disjointes et les recouvrent toutes, d'ou \[ C(i,j) = C(i-1,j) + C(i,j-1), \qquad C(i,0) = C(0,j) = 1. \]

appels = 0

def chemins(i, j):
    global appels
    appels += 1
    if i == 0 or j == 0:
        return 1
    return chemins(i-1, j) + chemins(i, j-1)

appels = 0; chemins(10, 10); print(appels)        # 369511 appels

chemins_memo = memoise(chemins)
appels = 0; print(chemins_memo(10, 10), appels)   # 184756, et bien moins d'appels

3. Sans memoisation, l'arbre des appels a exactement \(C(n,n)\) feuilles (chaque feuille correspond a un chemin complet, puisque le calcul explore un a un tous les chemins). Un arbre binaire a \(F\) feuilles ayant au moins \(F\) noeuds internes lorsque chaque noeud interne a deux fils, le nombre total d'appels est \(\ge 2C(n,n) - 1\), avec \(C(n,n) = \binom{2n}{n} \sim \frac{4^n}{\sqrt{\pi n}}\) : croissance exponentielle.

Avec memoisation, chaque couple \((i,j)\) de la grille n'est calcule qu'une fois, et son calcul coute \(O(1)\) : le total est \(\Theta(n^2)\) operations et \(\Theta(n^2)\) memoire. On passe de \(4^n\) a \(n^2\).

4. Un chemin de \((0,0)\) a \((i,j)\) est une suite de \(i+j\) pas dont exactement \(i\) sont horizontaux : il est entierement determine par le choix des positions de ces \(i\) pas parmi \(i+j\). D'ou \(C(i,j) = \binom{i+j}{i}\).

from math import comb
for i in range(9):
    for j in range(9):
        assert chemins_memo(i, j) == comb(i + j, i)
print("formule verifiee")
A retenir

La memoisation transforme un algorithme recursif exponentiel en algorithme polynomial des que le nombre de sous-problemes distincts est polynomial. C'est le principe de la programmation dynamique descendante. Python fournit le decorateur functools.lru_cache qui fait exactement cela.

Retour au coursChapitre 4 — Les Fonctions
Relire le cours →