CHAPITRE 7 : QUAND LA FONCTION S'APPELLE ELLE-MÊME

Une fonction récursive ne laisse voir aucune boucle. Il faut une autre technique : écrire ce que la fonction coûte en fonction de ce qu'elle coûte sur un problème plus petit.

Écrire la récurrence

def factorielle(n):
    if n <= 1:                      # cas de base
        return 1
    return n * factorielle(n - 1)   # 1 multiplication + un appel sur n-1

Appelons T(n) le coût pour un argument n. La fonction fait une multiplication, un coût constant, plus un appel sur n−1 :

T(n) = T(n−1) + 1     avec T(1) = 1

Déroulons :

T(n)   = T(n-1) + 1
       = T(n-2) + 1 + 1
       = T(n-3) + 1 + 1 + 1
       ...
       = T(1) + (n-1) fois 1
       = n

Donc O(n). Le résultat est intuitif : on descend de n à 1, une marche à la fois.

Quand on divise au lieu de décrémenter

def dicho(tab, x, debut, fin):
    if debut > fin:
        return -1
    m = (debut + fin) // 2
    if tab[m] == x:
        return m
    if tab[m] < x:
        return dicho(tab, x, m + 1, fin)   # moitié droite
    return dicho(tab, x, debut, m - 1)     # moitié gauche

Un seul appel récursif, sur la moitié du tableau, plus un coût constant :

T(n) = T(n/2) + 1

T(n) = T(n/2) + 1
     = T(n/4) + 2
     = T(n/8) + 3
     ...
     = T(1) + log₂(n)

Donc O(log n). On ne peut diviser n par deux que log₂ n fois avant d'arriver à 1.

Soustraire dans l'appel récursif donne du linéaire ou pire. Diviser donne du logarithmique. C'est la même règle qu'au chapitre 6, vue de l'autre côté.

L'arbre d'appels : voir le travail

Prenons le tri fusion. Il coupe en deux, trie chaque moitié, puis fusionne, et la fusion coûte n.

T(n) = 2 · T(n/2) + n

Dessinons les appels. À chaque niveau, les problèmes sont deux fois plus petits, mais deux fois plus nombreux.

niveau 0 :        n                       -> travail : n
niveau 1 :     n/2   n/2                  -> travail : n/2 + n/2 = n
niveau 2 :   n/4 n/4 n/4 n/4              -> travail : 4 x n/4   = n
   ...
niveau log n :  1 1 1 1 ... 1 (n fois)    -> travail : n

Chaque niveau coûte n. Il y a log₂ n niveaux. Total : O(n log n).

Cet arbre explique d'un coup pourquoi les bons tris sont en n log n : le découpage donne la hauteur log n, le recollage donne la largeur n.

L'exponentielle qui se cache

def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

T(n) = T(n−1) + T(n−2) + 1

Deux appels, et presque aucune réduction de taille. L'arbre double quasiment à chaque niveau : la complexité est exponentielle, environ O(1,618ⁿ).

Concrètement : fib(40) demande plus de 300 millions d'appels, et fib(50) fait attendre plusieurs minutes. Le code tient en trois lignes et il est catastrophique.

Le même calcul en linéaire. Le problème de fib récursif est qu'il recalcule sans arrêt les mêmes valeurs : fib(35) est évalué des millions de fois. En gardant les résultats :

def fib(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a
# O(n), et fib(1000) est instantané

On est passé de 1,6ⁿ à n. C'est le gain le plus spectaculaire du cours, et il ne demande aucune astuce : juste ne pas refaire deux fois le même calcul.

Le théorème maître

Pour les récurrences de la forme T(n) = a · T(n/b) + f(n), a appels sur des problèmes b fois plus petits, plus un travail f(n), on peut conclure sans dérouler.

On compare f(n) à nlogb a, qui mesure le travail des feuilles de l'arbre.

CasConditionRésultatInterprétation
1f(n) plus petitΘ(nlogb a) les feuilles dominent
2f(n) du même ordreΘ(nlogb a · log n) tous les niveaux comptent pareil
3f(n) plus grandΘ(f(n)) la racine domine

Tri fusion : a = 2, b = 2, f(n) = n. On calcule log₂ 2 = 1, donc n1 = n. Or f(n) = n : c'est le cas 2. Résultat : Θ(n log n). Retrouvé en deux lignes.

Recherche dichotomique : a = 1, b = 2, f(n) = 1. On a log₂ 1 = 0, donc n⁰ = 1, du même ordre que f(n). Cas 2 encore : Θ(log n).

Un parcours d'arbre binaire : a = 2, b = 2, f(n) = 1. Ici nlog₂ 2 = n, bien plus grand que 1. Cas 1 : Θ(n).

Ses limites. Le théorème exige des sous-problèmes de même taille. Il ne s'applique donc pas à fib, où les appels portent sur n−1 et n−2, ni au tri rapide dans son pire cas, où le découpage est déséquilibré (1 et n−1). Pour ces cas-là, il faut revenir à l'arbre.

À retenir

  • Analyser une récursion = écrire T(n) en fonction de T(plus petit).
  • T(n) = T(n−1) + 1 donne O(n). T(n) = T(n/2) + 1 donne O(log n).
  • T(n) = 2T(n/2) + n donne O(n log n) : log n niveaux, n par niveau.
  • Une récursion qui recalcule les mêmes valeurs devient exponentielle : garder les résultats la ramène au linéaire.

Exercices d'application

Exercice 1
Appliquez le théorème maître à T(n) = 4 T(n/2) + n.
Voir le corrigeMasquer le corrige

On a a = 4, b = 2, f(n) = n.

log_b(a) = log2(4) = 2
n^log_b(a) = n²

f(n) = n est plus petit que n² : on est dans le cas 1, les feuilles dominent.

T(n) = Θ(n²)

Vérification par l'arbre : au niveau k il y a 4k appels sur des problèmes de taille n/2k, soit un travail de 4k × n/2k = 2kn. Le travail double à chaque niveau : le dernier niveau écrase tous les autres. C'est bien la signature du cas 1.

Exercice 2
Quelle est la complexité de cette fonction ?
def tours(n, a, b, c):
    if n == 0:
        return
    tours(n-1, a, c, b)
    print(a, "->", b)
    tours(n-1, c, b, a)
Voir le corrigeMasquer le corrige

C'est le problème des tours de Hanoï. Deux appels sur n−1, plus un affichage constant :

T(n) = 2 T(n−1) + 1

T(n) = 2 T(n-1) + 1
     = 4 T(n-2) + 2 + 1
     = 8 T(n-3) + 4 + 2 + 1
     ...
     = 2^n - 1

Donc O(2ⁿ). Et ce n'est pas un défaut de l'algorithme : déplacer n disques exige 2ⁿ−1 mouvements, on l'a démontré. L'algorithme est optimal ; c'est le problème qui est exponentiel.

Noter que le théorème maître ne s'applique pas ici : l'appel porte sur n−1, pas sur n/b.

Exercice 3
Pourquoi le tri rapide est-il en O(n log n) en moyenne mais O(n²) au pire ?
Voir le corrigeMasquer le corrige

Tout dépend du pivot choisi.

Pivot bien placé (vers le milieu) : les deux moitiés font n/2 chacune.

T(n) = 2 T(n/2) + n → O(n log n)

Pivot au bord (le plus petit ou le plus grand élément) : un côté est vide, l'autre contient n−1 éléments.

T(n) = T(n−1) + n → n + (n−1) + … + 1 = O(n²)

Ce pire cas se produit justement sur un tableau déjà trié si l'on prend toujours le premier élément comme pivot, ce qui est exactement l'entrée la plus fréquente en pratique. D'où les deux parades habituelles : choisir le pivot au hasard, ou prendre la médiane de trois valeurs.

Cherchez avant d'ouvrir le corrige : c'est en butant sur les difficultes que l'on progresse.

Questions / Réponses

Aucune question. Soyez le premier à poser une question.
★ ★ ★ ★ ★
2 votes. Moyenne 5 sur 5.

Ajouter un commentaire

Anti-spam