Me contacter

NSI Première et Terminale • Programme français, lycées de Montréal

Exercices corrigés de NSI : algorithmique, tris et complexité

Voici une série d'exercices corrigés d'algorithmique pour la spécialité NSI, utilisable en Première comme en Terminale. 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.

L'algorithmique se juge sur trois questions, et l'épreuve les pose toutes les trois : l'algorithme donne-t-il le bon résultat (correction), s'arrête-t-il toujours (terminaison), et combien d'opérations coûte-t-il (complexité) ? Écrire un programme qui marche sur l'exemple du cours ne répond à aucune des trois. Chaque exercice ci-dessous vous fait compter, prouver ou comparer, jamais seulement coder.

Rappel de cours

  • Recherche séquentielle dans un tableau de nn éléments : au pire nn comparaisons, coût linéaire. Recherche dichotomique dans un tableau TRIÉ : environ log2n\log_{2}n comparaisons.
  • Tri par sélection : à chaque tour on cherche le minimum du reste et on l'échange. Nombre de comparaisons toujours égal à n(n1)2\dfrac{n(n-1)}{2}, quel que soit le tableau.
  • Tri par insertion : on insère chaque élément à sa place dans la partie déjà triée. Au pire n(n1)2\dfrac{n(n-1)}{2} comparaisons, mais seulement n1n-1 si le tableau est déjà trié.
  • Invariant de boucle : propriété vraie avant la boucle, préservée par chaque tour, et qui donne le résultat voulu à la sortie. C'est l'outil de preuve de correction.
  • Terminaison : exhiber un variant, entier positif qui décroît strictement à chaque tour.
  • Complexité : on garde l'ordre de grandeur et on ignore les constantes. O(1)O(1), O(logn)O(\log n), O(n)O(n), O(nlogn)O(n\log n), O(n2)O(n^{2}), O(2n)O(2^{n}).
  • Algorithme glouton : à chaque étape on prend le meilleur choix local, sans revenir en arrière. Rapide, mais pas toujours optimal.

Partie A : Les bases (/50)

Exercice 1 : Recherche séquentielle et coût au pire

On travaille sur le tableau t = [7, 2, 9, 4, 1, 8, 3], qui n'est pas trié.

Python
def recherche(t, x):
    for i in range(len(t)):
        if t[i] == x:
            return i
    return -1
  • a) Que renvoie recherche(t, 4) ? Et recherche(t, 5) ? Justifiez le choix de -1 comme valeur de retour.
  • b) Combien de comparaisons effectue la fonction pour chercher 7 ? Pour chercher 3 ? Pour chercher une valeur absente ?
  • c) Donnez le nombre de comparaisons dans le meilleur cas, dans le pire cas, et en moyenne pour un tableau de nn éléments contenant la valeur cherchée.
  • d) Modifiez la fonction pour qu'elle renvoie la LISTE de tous les indices où x apparaît. Que devient alors le coût au pire ?
  • e) Pourquoi ne peut-on pas utiliser la recherche dichotomique sur ce tableau ?
Voir la correction

a) recherche(t, 4) renvoie 3, car t[3] vaut 4. recherche(t, 5) renvoie -1 : la valeur est absente. On choisit -1 parce que c'est un indice IMPOSSIBLE en parcours normal, ce qui permet à l'appelant de distinguer sans ambiguïté l'échec du succès. Renvoyer 0 serait une faute grave, puisque 0 est un indice valide.

b) Pour 7 : une seule comparaison, il est en tête. Pour 3 : sept comparaisons, il est en dernière position. Pour une valeur absente : sept comparaisons également, la boucle va jusqu'au bout.

c) Meilleur cas : 1 comparaison, l'élément est en première position. Pire cas : nn comparaisons, l'élément est en dernier ou absent. En moyenne, si la valeur est présente et que toutes les positions sont équiprobables, on effectue 1+2++nn=n+12\dfrac{1+2+\cdots+n}{n}=\dfrac{n+1}{2} comparaisons, soit environ la moitié du tableau. Dans les trois cas le coût est proportionnel à nn : la recherche séquentielle est de complexité linéaire, O(n)O(n).

d) Il faut supprimer le return anticipé et accumuler les indices, donc parcourir tout le tableau. Le coût devient exactement nn comparaisons dans TOUS les cas, y compris le meilleur : on perd la possibilité de s'arrêter tôt. C'est le prix à payer pour une réponse exhaustive.

e) Parce que le tableau n'est pas trié. La dichotomie repose entièrement sur le fait que comparer la valeur cherchée à l'élément du milieu permet d'éliminer une moitié entière ; si le tableau est en désordre, cette comparaison n'apprend rien sur la position des autres éléments. Trier d'abord coûte plus cher qu'une seule recherche séquentielle : cela ne vaut la peine que si l'on prévoit de nombreuses recherches sur le même tableau.

Python
def tous_les_indices(t, x):
    resultat = []
    for i in range(len(t)):
        if t[i] == x:
            resultat.append(i)
    return resultat        # cout : n comparaisons, toujours

Exercice 2 : Le tri par sélection et son compte exact

Le tri par sélection cherche le minimum de la partie non triée et l'échange avec le premier élément de cette partie. On trie t = [7, 2, 9, 4, 1, 8, 3].

Python
def tri_selection(t):
    for i in range(len(t)):
        m = i
        for j in range(i + 1, len(t)):
            if t[j] < t[m]:
                m = j
        t[i], t[m] = t[m], t[i]
    return t
  • a) Donnez l'état du tableau après chacun des trois premiers tours de la boucle externe.
  • b) Donnez le tableau final.
  • c) Combien de comparaisons la boucle interne effectue-t-elle au tour ii ? Déduisez-en le nombre TOTAL de comparaisons pour n=7n=7, puis la formule générale.
  • d) Ce nombre dépend-il du contenu du tableau ? Comparez le cas d'un tableau déjà trié et celui d'un tableau trié à l'envers.
  • e) Combien d'échanges effectue l'algorithme ? En quoi est-ce un avantage si les éléments sont coûteux à déplacer ?
Voir la correction

a) Tour i = 0 : le minimum du tableau entier est 1, en position 4 ; on l'échange avec 7, ce qui donne [1, 2, 9, 4, 7, 8, 3]. Tour i = 1 : le minimum de la fin est 2, déjà en place, le tableau est inchangé, [1, 2, 9, 4, 7, 8, 3]. Tour i = 2 : le minimum de la fin est 3, en position 6 ; on l'échange avec 9, ce qui donne [1, 2, 3, 4, 7, 8, 9].

b) Le tableau final est [1, 2, 3, 4, 7, 8, 9]. Il se trouve qu'ici il est déjà entièrement trié après trois tours, mais l'algorithme continue quand même : il ne le sait pas.

c) Au tour ii, la boucle interne parcourt les indices de i+1i+1 à n1n-1, soit n1in-1-i comparaisons. Le total est i=0n1(n1i)\sum_{i=0}^{n-1}(n-1-i), c'est-à-dire (n1)+(n2)++1+0(n-1)+(n-2)+\cdots+1+0, dont la valeur est n(n1)2\frac{n(n-1)}{2}. Pour n=7n=7 cela fait 7×62=21\dfrac{7\times 6}{2}=21 comparaisons.

d) Non, il n'en dépend pas du tout. Les deux boucles sont des for dont les bornes ne dépendent que de nn, et aucun test ne peut les interrompre. Un tableau déjà trié coûte exactement les mêmes 21 comparaisons qu'un tableau en désordre complet. C'est la signature du tri par sélection : son coût est le même dans le meilleur et dans le pire cas, ce qui le rend prévisible mais l'empêche de profiter d'un tableau presque trié. La complexité est O(n2)O(n^{2}) dans tous les cas.

e) L'algorithme effectue exactement nn échanges, un par tour de boucle externe (dont certains sont l'échange d'un élément avec lui-même). C'est très peu : là où le tri par insertion peut effectuer de l'ordre de n2n^{2} déplacements, le tri par sélection en fait nn. Si un élément est coûteux à déplacer, par exemple un gros enregistrement, minimiser les écritures devient plus important que minimiser les comparaisons, et le tri par sélection redevient intéressant.

Exercice 3 : Le tri par insertion et la comparaison des deux tris

Le tri par insertion prend les éléments un par un et les glisse à leur place dans la partie déjà triée, comme on range une main de cartes.

Python
def tri_insertion(t):
    for i in range(1, len(t)):
        valeur = t[i]
        j = i - 1
        while j >= 0 and t[j] > valeur:
            t[j + 1] = t[j]
            j = j - 1
        t[j + 1] = valeur
    return t
  • a) Déroulez l'algorithme sur [5, 3, 8, 1] : donnez l'état du tableau après chaque tour de la boucle for.
  • b) Combien de comparaisons la boucle while effectue-t-elle si le tableau est DÉJÀ TRIÉ ? Déduisez-en le coût total dans le meilleur cas.
  • c) Même question si le tableau est trié à l'envers. Donnez le coût total dans le pire cas.
  • d) Comparez tri par sélection et tri par insertion sur trois critères : coût au mieux, coût au pire, nombre de déplacements.
  • e) Un tableau de 10 000 éléments est presque trié : seuls 5 éléments sont mal placés. Lequel des deux tris choisissez-vous, et pourquoi ?
Voir la correction

a) Départ [5, 3, 8, 1]. Tour i = 1 : on insère 3, qui remonte devant 5, ce qui donne [3, 5, 8, 1]. Tour i = 2 : on insère 8, qui est déjà plus grand que 5, une seule comparaison et rien ne bouge, [3, 5, 8, 1]. Tour i = 3 : on insère 1, qui traverse 8, 5 et 3, ce qui donne [1, 3, 5, 8].

b) Si le tableau est déjà trié, la condition t[j] > valeur est fausse dès la première comparaison à chaque tour : la boucle while s'arrête immédiatement. Cela fait exactement une comparaison par tour, soit n1n-1 comparaisons au total, et aucun déplacement. Le coût au mieux est donc LINÉAIRE, O(n)O(n).

c) Si le tableau est trié à l'envers, chaque nouvel élément doit traverser toute la partie déjà triée : ii comparaisons au tour ii. Le total est 1+2++(n1)=n(n1)21+2+\cdots+(n-1)=\dfrac{n(n-1)}{2}, soit O(n2)O(n^{2}), exactement comme le tri par sélection.

d) Coût au mieux : insertion n1n-1, sélection n(n1)2\frac{n(n-1)}{2}, l'insertion gagne largement. Coût au pire : les deux valent n(n1)2\frac{n(n-1)}{2}, égalité. Déplacements : sélection nn échanges seulement, insertion jusqu'à n(n1)2\frac{n(n-1)}{2} décalages, la sélection gagne. Aucun des deux ne domine l'autre : le choix dépend des données et du coût d'un déplacement.

e) Le tri par insertion, sans hésiter. Un tableau presque trié est précisément son meilleur terrain : chaque élément déjà bien placé coûte une seule comparaison, et seuls les 5 intrus provoquent de vrais décalages. Le coût sera de l'ordre de 10 000 opérations. Le tri par sélection, lui, effectuerait ses 10000×9999250\frac{10000\times 9999}{2}\approx 50 millions de comparaisons, indifférent au fait que le travail soit presque fait. C'est le seul des deux capable de profiter de l'ordre déjà présent.

Exercice 4 : La recherche dichotomique

La dichotomie ne fonctionne que sur un tableau TRIÉ. On travaille sur tri = [1, 2, 3, 4, 7, 8, 9], qui compte 7 éléments d'indices 0 à 6.

Python
def dichotomie(t, x):
    g, d = 0, len(t) - 1
    while g <= d:
        m = (g + d) // 2
        if t[m] == x:
            return m
        elif t[m] < x:
            g = m + 1
        else:
            d = m - 1
    return -1
  • a) Déroulez la recherche de 8 : donnez à chaque tour les valeurs de g, d et m. Combien de tours ?
  • b) Même travail pour la recherche de 5, qui est absente.
  • c) Quel est le nombre maximal de tours pour un tableau de 7 éléments ? Et pour nn éléments ?
  • d) Comparez le nombre d'opérations de la recherche séquentielle et de la dichotomie pour n=1 000n=1\ 000 puis n=1 000 000n=1\ 000\ 000.
  • e) Donnez le variant qui prouve la terminaison de la boucle while.
Voir la correction

a) Départ g = 0, d = 6, donc m = 3 et t[3] = 4, inférieur à 8 : on va à droite, g devient 4. Tour 2 : g = 4, d = 6, m = 5 et t[5] = 8 : trouvé, on renvoie 5. Il aura fallu 2 tours.

b) Départ g = 0, d = 6, m = 3, t[3] = 4 < 5 donc g = 4. Tour 2 : g = 4, d = 6, m = 5, t[5] = 8 > 5 donc d = 4. Tour 3 : g = 4, d = 4, m = 4, t[4] = 7 > 5 donc d = 3. Maintenant g = 4 > d = 3, la boucle s'arrête et la fonction renvoie -1. Trois tours ont suffi pour éliminer les 7 cases.

c) Chaque tour divise par deux le nombre de cases restantes : 7, puis 3, puis 1, puis 0. Il faut donc au plus 3 tours pour 7 éléments. En général le nombre de tours est de l'ordre de log2n\log_{2}n, plus précisément log2n+1\lfloor\log_{2}n\rfloor+1 au pire.

d) Pour n=1 000n=1\ 000 : la recherche séquentielle fait jusqu'à 1 000 comparaisons, la dichotomie environ 10, puisque 210=10242^{10}=1024. Pour n=1 000 000n=1\ 000\ 000 : séquentielle 1 000 000, dichotomie environ 20, puisque 2201062^{20}\approx 10^{6}. Multiplier la taille des données par mille n'ajoute que dix tours à la dichotomie. C'est la différence entre un coût linéaire et un coût logarithmique, et c'est ce qui rend la dichotomie irremplaçable sur de grandes données.

e) Le variant est dg+1d-g+1, le nombre de cases encore candidates. C'est un entier, il reste positif tant que la boucle tourne (la condition d'entrée est gdg\leq d), et chaque tour le fait décroître strictement : dans les deux branches on déplace gg ou dd strictement au-delà de mm, ce qui retire au moins la case du milieu. Un entier positif qui décroît strictement ne peut pas décroître indéfiniment : la boucle se termine.

Exercice 5 : Récursivité : factorielle et Fibonacci

Une fonction récursive s'appelle elle-même sur un cas plus petit, jusqu'à atteindre un cas de base qui, lui, ne relance pas d'appel.

Python
def factorielle(n):
    if n <= 1:
        return 1
    return n * factorielle(n - 1)

def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
  • a) Identifiez le cas de base et le cas récursif de chaque fonction.
  • b) Déroulez factorielle(5) : écrivez la chaîne d'appels puis la remontée des résultats.
  • c) Que se passerait-il si l'on écrivait le cas de base de factorielle comme if n == 1 et qu'on appelait factorielle(0) ?
  • d) Donnez les dix premiers termes de la suite de Fibonacci calculés par cette fonction, en commençant à fibonacci(0).
  • e) Écrivez une version ITÉRATIVE de la factorielle, avec une boucle.
Voir la correction

a) Pour factorielle : cas de base n1n\leq 1, qui renvoie 1 sans nouvel appel ; cas récursif n×n\times factorielle(n1)(n-1). Pour fibonacci : cas de base n<2n<2, qui renvoie nn ; cas récursif, la somme des deux appels précédents. Sans cas de base, la récursion ne s'arrêterait jamais et provoquerait une RecursionError.

b) La descente empile factorielle(5), qui appelle factorielle(4), puis (3), (2) et enfin (1). Ce dernier renvoie 1 sans rappel. La remontée donne alors 2×1=22\times 1=2, puis 3×2=63\times 2=6, puis 4×6=244\times 6=24, puis 5×24=1205\times 24=120. Le résultat est 120. Rien n'est calculé pendant la descente : tout le travail se fait à la remontée.

c) Avec if n == 1, l'appel factorielle(0) ne rencontrerait jamais son cas de base : il appellerait factorielle(-1), puis (-2), et ainsi de suite indéfiniment, jusqu'à l'erreur de dépassement de pile. C'est pourquoi on écrit n1n\leq 1 et non n=1n=1 : le cas de base doit couvrir TOUTES les entrées qui ne relancent pas la récursion. Mathématiquement 0!=10!=1, la version correcte donne donc aussi la bonne valeur.

d) fibonacci(0) à fibonacci(9) valent 0, 1, 1, 2, 3, 5, 8, 13, 21, 34. Et fibonacci(10) vaut 55.

e) La version itérative accumule le produit dans une variable.

Python
def factorielle_iterative(n):
    resultat = 1
    for i in range(2, n + 1):
        resultat = resultat * i
    return resultat

# factorielle_iterative(5) vaut 120, comme la version recursive

Partie B : Niveau examen (/50)

Exercice 6 : Classer des complexités et les justifier

Pour chaque fragment, donnez la complexité en fonction de nn et justifiez en comptant les tours de boucle. On ne demande pas le nombre exact d'opérations mais l'ordre de grandeur.

Python
# Fragment A
for i in range(n):
    print(i)

# Fragment B
for i in range(n):
    for j in range(n):
        print(i, j)

# Fragment C
for i in range(n):
    for j in range(i):
        print(i, j)

# Fragment D
i = n
while i > 1:
    i = i // 2

# Fragment E
for i in range(n):
    j = n
    while j > 1:
        j = j // 2
  • a) Donnez la complexité de chacun des cinq fragments A à E.
  • b) Pour le fragment C, donnez le nombre EXACT de tours de la boucle interne, puis expliquez pourquoi sa complexité est la même que celle de B alors qu'il fait deux fois moins de travail.
  • c) Rangez O(1)O(1), O(n2)O(n^{2}), O(logn)O(\log n), O(n)O(n), O(2n)O(2^{n}) et O(nlogn)O(n\log n) du moins coûteux au plus coûteux.
  • d) Un algorithme en O(n2)O(n^{2}) traite 1 000 éléments en 1 seconde. Estimez le temps pour 10 000 éléments, puis pour 100 000.
  • e) Un second algorithme, en O(nlogn)O(n\log n), traite les mêmes 1 000 éléments en 2 secondes, donc deux fois plus lentement. À partir de quelle taille devient-il préférable ? Répondez qualitativement puis vérifiez sur n=100 000n=100\ 000.
Voir la correction

a) A : O(n)O(n), une boucle simple. B : O(n2)O(n^{2}), deux boucles imbriquées de nn tours. C : O(n2)O(n^{2}) également. D : O(logn)O(\log n), la variable est divisée par deux à chaque tour. E : O(nlogn)O(n\log n), une boucle de nn tours contenant chacune une boucle logarithmique.

b) La boucle interne fait 0 tour pour i=0i=0, 1 tour pour i=1i=1, et ainsi de suite, soit au total 0+1++(n1)=n(n1)20+1+\cdots+(n-1)=\dfrac{n(n-1)}{2} tours. C'est bien environ la moitié des n2n^{2} tours du fragment B. Mais la notation OO ignore les facteurs constants : n2n2\dfrac{n^{2}-n}{2} et n2n^{2} ont le même ordre de grandeur, car leur rapport tend vers la constante 12\frac{1}{2}. Ce qu'on retient, c'est la façon dont le coût GRANDIT quand nn double, et dans les deux cas il est multiplié par quatre.

c) Du moins au plus coûteux : O(1)O(1), O(logn)O(\log n), O(n)O(n), O(nlogn)O(n\log n), O(n2)O(n^{2}), O(2n)O(2^{n}).

d) En O(n2)O(n^{2}), multiplier nn par 10 multiplie le temps par 102=10010^{2}=100. Pour 10 000 éléments : environ 100 secondes, soit près de 2 minutes. Pour 100 000 : environ 10 000 secondes, soit près de 3 heures. C'est le passage à l'échelle qui tue les algorithmes quadratiques, pas leur vitesse sur les petits jeux de données.

e) Le second est plus lent au départ mais grandit beaucoup moins vite : il finit forcément par gagner, et le point de bascule se situe pour un nn assez petit. Vérification à n=100 000n=100\ 000 : le quadratique met environ 10 000 s. Pour le second, le coût passe de 1000×log210001041000\times\log_{2}1000\approx 10^{4} à 100 000×log2100 0001,66×106100\ 000\times\log_{2}100\ 000\approx 1{,}66\times 10^{6}, soit environ 167 fois plus, donc 2×1673332\times 167\approx 333 secondes. Il est alors exactement trente fois plus rapide que le premier. Un mauvais algorithme rapide perd toujours contre un bon algorithme lent, dès que les données grossissent.

Exercice 7 : Invariant de boucle et preuve de correction

La fonction ci-dessous calcule la somme des éléments d'un tableau. On veut PROUVER qu'elle est correcte, pas seulement la tester.

Python
def somme(t):
    s = 0
    i = 0
    while i < len(t):
        s = s + t[i]
        i = i + 1
    return s
  • a) Proposez un invariant de boucle reliant s et i.
  • b) Vérifiez les trois points : l'invariant est vrai avant d'entrer dans la boucle, il est préservé par un tour, et il donne le résultat voulu à la sortie.
  • c) Donnez un variant et prouvez la terminaison.
  • d) On modifie le code en écrivant s = s + t[i] APRÈS i = i + 1. Que se passe-t-il ? Sur quelle entrée l'erreur se manifeste-t-elle en premier ?
  • e) Proposez un invariant pour la fonction de recherche du maximum de l'exercice 7 de la série sur les bases de Python.
Voir la correction

a) L'invariant est : au début de chaque tour, s contient la somme des i premiers éléments du tableau, c'est-à-dire s=k=0i1t[k]s=\sum_{k=0}^{i-1}t[k], avec 0ilen(t)0\leq i\leq \text{len}(t).

b) Initialisation : avant la boucle, i=0i=0 et s=0s=0 ; la somme des 0 premiers éléments est bien la somme vide, qui vaut 0. L'invariant est vrai. Conservation : supposons-le vrai en début de tour, donc s=k=0i1t[k]s=\sum_{k=0}^{i-1}t[k]. Le tour ajoute t[i]t[i] à s puis incrémente i. En fin de tour, s vaut k=0it[k]\sum_{k=0}^{i}t[k] et l'indice vaut i+1i+1 : c'est exactement l'invariant écrit pour la nouvelle valeur de i. Terminaison de la preuve : à la sortie, la condition de boucle est fausse, donc i=len(t)i=\text{len}(t), et l'invariant donne s=k=0len(t)1t[k]s=\sum_{k=0}^{\text{len}(t)-1}t[k], la somme de tous les éléments. C'est bien ce qu'on voulait démontrer.

c) Le variant est len(t)i\text{len}(t)-i. C'est un entier, positif tant que la boucle tourne puisque la condition est i<len(t)i<\text{len}(t), et chaque tour l'diminue exactement de 1 car i augmente de 1 et len(t) ne change pas. Une suite d'entiers positifs strictement décroissante est finie : la boucle se termine.

d) En incrémentant i avant de l'utiliser, on saute t[0] et on tente de lire t[len(t)], qui n'existe pas : le programme lève une IndexError. L'erreur se manifeste dès le premier tableau non vide, y compris un tableau à un seul élément : i passe à 1 puis on lit t[1] hors bornes. C'est le décalage d'indice classique, et c'est précisément l'invariant qui permet de le repérer sans exécuter le code, car il n'est plus préservé par le tour.

e) L'invariant est : au début de chaque tour, imax est l'indice du plus grand élément parmi les i premiers, et c'est le plus petit indice où ce maximum est atteint. Initialisation avec imax = 0 et i = 1 : le maximum du seul élément t[0] est bien atteint en 0. Conservation : le tour remplace imax seulement si t[i] est STRICTEMENT plus grand, ce qui préserve à la fois la maximalité et le choix de la première occurrence. À la sortie, i vaut len(t) et l'invariant porte alors sur le tableau entier.

Exercice 8 : Le coût caché de la récursivité naïve

La version récursive de Fibonacci vue à l'exercice 5 est élégante et correcte. Elle est aussi inutilisable au-delà de quelques dizaines de termes, et cet exercice explique pourquoi.

  • a) Dessinez l'arbre des appels de fibonacci(5). Combien de fois fibonacci(2) est-il appelé ?
  • b) Pour fibonacci(10), le nombre total d'appels est 177. Comparez-le à la valeur calculée, qui n'est que 55. Que remarquez-vous ?
  • c) Expliquez pourquoi le coût de cette version croît de façon exponentielle.
  • d) Écrivez une version ITÉRATIVE qui calcule fibonacci(n) en une seule boucle. Quelle est sa complexité ?
  • e) Une autre solution consiste à mémoriser les résultats déjà calculés. Expliquez le principe et donnez la complexité obtenue.
Voir la correction

a) L'appel fibonacci(5) déclenche fibonacci(4) et fibonacci(3) ; fibonacci(4) déclenche à son tour fibonacci(3) et fibonacci(2), et ainsi de suite. En développant tout l'arbre, fibonacci(2) est appelé 3 fois. Le point important est qu'il est recalculé intégralement à chaque fois, sans que rien ne soit conservé d'un appel à l'autre.

b) Il faut 177 appels de fonction pour produire le nombre 55. Autrement dit, on effectue plus de trois fois plus de travail que la valeur du résultat lui-même. Presque tout ce travail est du recalcul pur : dans cet arbre, fibonacci(3) est évalué 21 fois, toujours pour obtenir 2.

c) Chaque appel non terminal en engendre deux, donc le nombre d'appels double approximativement quand nn augmente de 1. On obtient un arbre binaire de profondeur nn, dont le nombre de nœuds croît comme 2n2^{n} (plus précisément comme φn\varphi^{n}, avec φ\varphi le nombre d'or). Passer de n=40n=40 à n=50n=50 multiplie le temps par environ mille : c'est ce qui rend cette version inutilisable, alors même qu'elle est parfaitement correcte. Correction et efficacité sont deux propriétés indépendantes.

d) Il suffit de garder les deux derniers termes et d'avancer. La boucle fait n1n-1 tours et chaque tour coûte une addition : la complexité est LINÉAIRE, O(n)O(n). On passe de 2n2^{n} à nn, ce qui rend fibonacci(1000) instantané.

e) La mémoïsation consiste à ranger chaque résultat dans un dictionnaire dès qu'il est calculé, et à consulter ce dictionnaire avant de relancer un appel. Chaque valeur fibonacci(k) n'est alors calculée qu'une seule fois, pour kk de 0 à nn : la complexité tombe à O(n)O(n) également, au prix d'un espace mémoire O(n)O(n). C'est la même idée que la version itérative, obtenue sans renoncer à l'écriture récursive.

Python
def fibonacci_iteratif(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a                      # O(n)

def fibonacci_memo(n, memo=None):
    if memo is None:
        memo = {}
    if n < 2:
        return n
    if n not in memo:
        memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]                # O(n) aussi

Exercice 9 : Algorithmes gloutons et le contre-exemple qui les met en défaut

Un algorithme glouton construit sa solution par choix successifs, en prenant à chaque étape ce qui paraît le meilleur sur le moment, sans jamais remettre en cause un choix déjà fait. On étudie le rendu de monnaie.

Python
def rendu(somme, pieces):
    resultat = []
    for p in pieces:          # pieces triees du plus grand au plus petit
        while somme >= p:
            somme = somme - p
            resultat.append(p)
    return resultat
  • a) Avec le système européen [200, 100, 50, 20, 10, 5, 2, 1] en centimes, déroulez rendu(178, pieces). Combien de pièces sont rendues ?
  • b) Pourquoi est-il indispensable que la liste des pièces soit triée par ordre décroissant ?
  • c) On invente maintenant un système à trois valeurs : [4, 3, 1]. Que rend l'algorithme glouton pour la somme 6 ? Combien de pièces ?
  • d) Trouvez la solution optimale pour 6 avec ce système. Conclusion sur l'algorithme glouton ?
  • e) Donnez un avantage réel des algorithmes gloutons qui justifie qu'on les utilise malgré ce défaut.
Voir la correction

a) On prend d'abord la plus grosse pièce possible : 100 (reste 78), puis 50 (reste 28), puis 20 (reste 8), puis 5 (reste 3), puis 2 (reste 1), puis 1 (reste 0). La liste rendue est [100, 50, 20, 5, 2, 1], soit 6 pièces. Aucune pièce de 200 n'est prise puisque 200 dépasse 178.

b) Parce que le principe même du glouton est de prendre le meilleur choix local, et « meilleur » signifie ici « la plus grosse pièce qui rentre ». Si la liste n'était pas triée, la boucle épuiserait d'abord des petites pièces et rendrait une solution médiocre, par exemple 178 pièces de 1 centime en commençant par 1. Le tri fait partie de l'algorithme, pas de la préparation des données.

c) Avec [4, 3, 1] et la somme 6 : le glouton prend 4 (reste 2), puis ne peut pas prendre 3, prend 1 (reste 1), puis 1 (reste 0). Il rend [4, 1, 1], soit 3 pièces.

d) La solution optimale est [3, 3], soit 2 pièces seulement. L'algorithme glouton n'est donc PAS optimal en général : en prenant la pièce de 4 il s'est interdit la bonne combinaison, et comme il ne revient jamais en arrière, il ne peut plus se rattraper. Il se trouve que le système européen est, lui, canonique, ce qui rend le glouton optimal dans ce cas particulier, mais c'est une propriété du système de pièces, pas de l'algorithme. Pour un système quelconque, il faut la programmation dynamique.

e) Sa rapidité et sa simplicité. Le glouton ne teste qu'une seule combinaison, celle qu'il construit, là où une recherche exhaustive en examinerait un nombre exponentiel. Quand la solution optimale n'est pas indispensable, ou quand on sait démontrer que le glouton est optimal sur le problème traité, c'est de loin la meilleure approche. Beaucoup d'algorithmes classiques sont gloutons et prouvés optimaux, comme celui de Dijkstra pour les plus courts chemins.

Exercice 10 : Problème : choisir le bon algorithme selon le contexte

Une bibliothèque gère un catalogue de 500 000 ouvrages. Pour chaque situation ci-dessous, indiquez l'algorithme ou la structure à utiliser, donnez sa complexité, et justifiez. Il n'y a pas de bonne réponse unique : c'est la justification qui est notée.

  • a) Le catalogue est trié par numéro ISBN. Un lecteur cherche un ouvrage précis par son ISBN. Que faites-vous et à quel coût ?
  • b) Le catalogue n'est PAS trié et l'on doit y chercher un seul ouvrage, une seule fois. Vaut-il mieux trier d'abord puis faire une dichotomie, ou faire directement une recherche séquentielle ? Justifiez par les coûts.
  • c) Même catalogue non trié, mais on prévoit 10 000 recherches. La réponse change-t-elle ? Posez l'inégalité qui décide.
  • d) On veut afficher les 10 ouvrages les plus empruntés. Faut-il trier tout le catalogue ? Proposez une solution moins coûteuse et donnez sa complexité.
  • e) On veut savoir si deux ouvrages du catalogue ont exactement le même titre. Proposez une méthode naïve, donnez son coût, puis une méthode plus efficace.
Voir la correction

a) Recherche dichotomique, puisque le tableau est trié : environ log2(500 000)19\log_{2}(500\ 000)\approx 19 comparaisons. C'est immédiat, même sur un demi-million d'entrées. Faire une recherche séquentielle ici serait gaspiller l'information précieuse que constitue le tri.

b) Pour UNE seule recherche, la recherche séquentielle gagne largement. Elle coûte au pire n=500 000n=500\ 000 comparaisons. Trier d'abord coûterait de l'ordre de nlog2n500 000×199,5n\log_{2}n\approx 500\ 000\times 19\approx 9{,}5 millions d'opérations, soit près de vingt fois plus, avant même d'avoir commencé à chercher. On ne trie jamais pour une seule requête.

c) Oui, la réponse s'inverse. Il faut comparer kk recherches séquentielles, soit k×nk\times n, au coût du tri suivi de kk dichotomies, soit nlog2n+klog2nn\log_{2}n+k\log_{2}n. Avec k=10 000k=10\ 000 : à gauche 10 000×500 000=510\ 000\times 500\ 000=5 milliards d'opérations ; à droite environ 9,59{,}5 millions pour le tri plus 10 000×19190 00010\ 000\times 19\approx 190\ 000, soit moins de 10 millions. Le tri est amorti dès quelques dizaines de recherches : c'est tout l'intérêt d'organiser les données une fois pour les interroger souvent.

d) Non, trier les 500 000 ouvrages pour n'en garder que 10 est un gaspillage. Il suffit de parcourir le catalogue une seule fois en maintenant les 10 meilleurs vus jusqu'ici : pour chaque ouvrage, on le compare au plus faible des 10 retenus et on l'insère si nécessaire. Le coût est O(n×10)O(n\times 10), donc linéaire en nn, contre O(nlogn)O(n\log n) pour un tri complet. Sur un demi-million d'entrées, cela fait environ 10n=510n=5 millions d'opérations contre nlog2n9,5n\log_{2}n\approx 9{,}5 millions, soit deux fois moins dans le pire des cas. En pratique l'écart est bien plus grand : la quasi-totalité des ouvrages est éliminée dès la première comparaison avec le dixième meilleur, si bien que le coût réel avoisine nn et non 10n10n.

e) Méthode naïve : comparer chaque titre à tous les autres, soit n(n1)2\dfrac{n(n-1)}{2} comparaisons, environ 1,25×10111{,}25\times 10^{11} ici, ce qui est hors de portée. Méthode efficace : trier les titres, puis parcourir la liste triée une fois en comparant chaque titre à son voisin immédiat, car deux titres identiques sont forcément côte à côte après tri. Le coût tombe à O(nlogn)O(n\log n) pour le tri plus O(n)O(n) pour le parcours. Encore mieux, on peut utiliser un dictionnaire ou un ensemble et détecter un doublon en un seul parcours, en O(n)O(n) en moyenne.

Voir aussi

Vous cherchez un tuteur en NSI à Montréal ?

Contactez-moi pour une première séance. Bachelier en informatique de McGill et maîtrise en informatique appliquée de Concordia, je travaille l'algorithmique sur la preuve et le coût, pas seulement sur le code qui tourne.

Site par Studio Squalli