Dans cette partie nous allons donc traiter le cas des algorithmes récursifs, et voir comment le calcul de leur complexité se déduit d'une étude de suite .
Dans un algorithme itératif, le décompte du nombre d'opérations élémentaires se fait en additionnant le nombre d'opérations de chaque itération. C'est pourquoi dans le chapitre précédent nous avons souvent utilisé des calculs de sommes pour arriver à nos fins.
La complexité d’un algorithme récursif traitant des données d'une certaine taille va quand à elle naturellement dépendre de la complexité du même algorithme avec des données de taille inférieure. Cette complexité va donc être une suite définie par récurrence .
Définition
Soit k k élément de N ∗ ℕ∗ , et ( u n ) n ∈ N (un)n∈ℕ une suite réelle.
On dit que la suite ( u n ) n ∈ N (un)n∈ℕ est une suite définie par une récurrence d'ordre k k s'il existe une fonction f f définie sur R k ℝk et à valeur dans R ℝ telle que
u n = f ( u n − 1 , u n − 2 , . . . , u n − k ) un=f(un−1,un−2,...,un−k)
Dans la suite de cette partie on se limitera au cas des suites arithmético-géométriques et des suites récurrentes linéaires d’ordre 2 2 . Cela sera déjà bien suffisant pour calculer la complexité de nombreux algorithmes récursifs.
Suites arithmético-géométriques
Commençons par une définition, sans doute déjà connue du lecteur.
Définition
Une suite réelle ( u n ) n ∈ N (un)n∈ℕ est dite arithmético-géométrique s'il existe deux réels a a et b b tels que
∀ n ∈ N ∗ , u n = a u n − 1 + b ∀n∈ℕ*, un=aun−1+b
On prend l'habitude de distinguer deux cas particuliers, celui des suites arithmétiques et celui des suites géométriques .
Définition
Avec les notations de la définition précédente :
Si a = 1 a=1 , on dit que la suite ( u n ) n ∈ N (un)n∈ℕ est une suite arithmétique de raison b b .
Si b = 0 b=0 , on dit que la suite ( u n ) n ∈ N (un)n∈ℕ est une suite géométrique de raison a a .
Le résultat suivant nous donne l'expression explicite du terme général d'une suite arithmético-géométrique.
Propriété
Soit ( u n ) n ∈ N (un)n∈ℕ une suite arithmético-géométrique associée aux réels a a et b b .
Si a = 1 a=1 , alors
∀ n ∈ N , u n = u 0 + n b ∀n∈ℕ, un=u0+nb
Si a ≠ 1 a≠1 , et si l'on pose l = b 1 − a l=b1-a , alors
∀ n ∈ N , u n = ( u 0 − l ) a n + l ∀n∈ℕ, un=(u0−l)an+l
On admettra ce résultat, ce n’est pas l’objet de ce cours. Ce n’est ceci-dit pas bien méchant à démontrer.
Nous utiliserons peu cette expression exacte, mais plutôt l'approximation suivante qui nous renseigne sur l'ordre de grandeur du terme général d'une suite arithmético-géométrique.
Propriété
Soit ( u n ) n ∈ N (un)n∈ℕ une suite arithmético-géométrique associée aux réels a a et b b .
On suppose que a a , b b et u 0 u0 sont positifs.
Si a = 1 a=1 , u n = Θ ( n ) un=Θ(n) .
Si a > 1 a>1 , u n = Θ ( a n ) un=Θ(an) .
La restriction que a a , b b et u 0 u0 soient positifs n'est absolument pas génante car ça sera toujours le cas dans notre contexte de calculs de complexité.
Voyons maintenant sur des exemples comment appliquer ce qui précède. Le premier concerne la très fameuse fonction factorielle.
Example 1.1. Complexité de la fonction factorielle récursive
Rappelons tout d'abord que n ! = 1 × 2 × 3 × ... × n n!=1×2×3×...×n et que cette fonction vérifie la relation de récurrence n ! = n × ( n − 1 ) ! n!=n×(n-1)! .
L'implémentation en Python de cette fonction est :
def factorielleRecursive (n) :
if n == 0 :
return 1
else :
return n*factorielleRecursive(n-1 )
Cette fonction n'est composée que d'une structure conditionelle.
Si n > 0 n>0 , une opération élémentaire (une comparaison) est effectuée lors du test, et une autre (un produit) lors de l'appel récursif. On a donc dans ce cas T ( n ) = T ( n − 1 ) + 2 T(n)=T(n-1)+2 .
Si n = 0 n=0 , seule la comparaison a lieu donc T ( 0 ) = 1 T(0)=1 .
La complexité T T est donc une suite arithmético-géométrique associée aux constantes a = 1 a=1 et b = 2 b=2 .
D'après les résultats précédents, on a alors T ( n ) = 1 + 2 n T(n)=1+2n et surtout T ( n ) = Θ ( n ) T(n)=Θ(n) . Cet algorithme est donc de complexité linéaire.
A noter que l'algorithme itératif calculant cette factorielle a la même complexité . Le voici en effet en Python :
def factorielleIterative (n) :
resultat = 1
for i in range(2 ,n+1 ):
resultat *= i
return resultat
On a une structure for qui réalise n − 1 n-1 itérations, chacune comportant le même nombre fixe d'opérations élémentaires. C'est pourquoi là aussi T ( n ) = Θ ( n ) T(n)=Θ(n) .
L'exemple suivant est le fameux problème des tours de Hanoï , imaginé par le mathématicien français Edouard Lucas (1842-1891).
Ce problème a été recontré dans les séances d'exercices du cours 1ADS mais nous allons le redétailler.
Example 1.2. Problème des tours de Hanoï
Le but est de déplacer n n disques de diamètres différents, d’une tour initiale (celle de gauche) à une tour finale (celle de droite) en passant par une tour intermédiaire (celle du milieu).
Initialement, tous les disques sont empilés sur la tour initiale, du plus grand au plus petit.
Les règles de déplacement sont les suivantes :
On ne peut bouger qu’un seul disque à la fois.
On ne peut déplacer un disque que vers une tour vide, ou sur un disque plus grand.
Etudions "à la main" le cas n = 3 n=3 .
Voici la position initiale :
Effectuons ces trois premiers mouvements :
On a donc réussi à bouger les deux plus petits disques vers la tour intermédiaire en passant par la tour finale.
Bougeons maintenant le plus grand :
Il ne reste plus qu'à effectuer ces trois derniers mouvements qui ressemblent évidemment fortement aux trois premiers :
Et le problème est résolu pour trois disques.
Essayons maintenant de reproduire dans le cas général ce que l'on vient de faire dans ce cas très particulier. On arrive vite à la conclusion que la résolution du problème pour toute valeur de n n est récursive :
On déplace n − 1 n-1 disques de la tour initiale à la tour intermédiaire en passant par la tour finale.
On déplace le plus grand disque de la tour initiale vers la tour finale.
On déplace n − 1 n-1 disques de la tour intermédiaire à la tour finale en passant par la tour initiale.
Le cas de base étant le déplacement d'un seul disque.
En Python cela donne cette implémentation :
def hanoi (de,vers,via,n) :
if n==1 :
print(de," vers " ,vers)
else :
hanoi(de,via,vers,n-1 )
print(de," vers " ,vers)
hanoi(via,vers,de,n-1 )
hanoi("1" ,"3" ,"2" ,3 )
Sans surprises, pour trois disques on retrouve bien les mouvements précédemment mis en images :
1 vers 3
1 vers 2
3 vers 2
1 vers 3
2 vers 1
2 vers 3
1 vers 3
Venons-en maintenant au calcul de la complexité de cette procédure qui n'est composée que d'une structure conditionnelle.
Si n > 1 n>1 , une opération élémentaire (une comparaison) est effectuée lors du test, une autre lors de l'affichage, et on a deux appels récursifs. On a ainsi T ( n ) = 2 T ( n − 1 ) + 2 T(n)=2T(n-1)+2 .
Si n = 1 n=1 , seule la comparaison a lieu donc T ( 1 ) = 1 T(1)=1 .
La complexité T T est donc une suite arithmético-géométrique associée aux constantes a = 2 a=2 et b = 2 b=2 .
D’après le résultat précédent, on a alors T ( n ) = Θ ( 2 n ) T(n)=Θ(2n) . Cet algorithme est donc de complexité exponentielle.
Suites récurrentes linéaires
Cette sous-partie n'est pas conceptuellement très difficile, mais elle demande de la vigilance car certains résultats sont assez techniques.
On distinguera le cas des suites homogènes des suites non homogènes , et l'on verra qu'en pratique ce sont ces dernières qui nous intéresserons le plus.
Commençons naturellement par une définition du concept.
Définition
Une suite réelle ( u n ) n ∈ N (un)n∈ℕ est une suite récurrente linéaire homogène d'ordre deux s'il existe deux réels a a et b b tels que
∀ n ≥ 2 , u n = a u n − 1 + b u n − 2 ∀n≥2, un=aun−1+bun−2
Pour étudier une telle suite, on doit tout d'abord lui associer un polynôme dit caractéristique .
Définition
Soit ( u n ) n ∈ N (un)n∈ℕ une suite récurrente linéaire homogène d'ordre deux associée aux réels a a et b b .
Le polynôme caractéristique de la suite ( u n ) n ∈ N (un)n∈ℕ est par définition le polynôme x 2 − a x − b x2-ax-b .
L'équation caractéristique de la suite ( u n ) n ∈ N (un)n∈ℕ est alors l'équation x 2 − a x − b = 0 x2-ax-b=0 .
Le terme général d'une suite récurrente linéaire homogène d'ordre deux va alors s'exprimer en fonction des racines de son polynôme caractéristique .
On ne fera pas l'insulte au lecteur de lui rappeler comment l'on calcule les racines d'un polynôme du second degré.
Propriété
Soit ( u n ) n ∈ N (un)n∈ℕ une suite récurrente linéaire homogène d'ordre deux et soit Δ Δ le discriminant de son équation caractéristique.
Si Δ > 0 Δ>0 , en notant x 1 x1 et x 2 x2 les deux racines réelles distinctes du polynôme caractéristique, on a
∃ λ , μ ∈ R , ∀ n ∈ N , u n = λ x n 1 + μ x n 2 ∃λ,μ∈ℝ, ∀n∈ℕ, un=λx1n+μx2n
Si Δ = 0 Δ=0 , en notant x x la racine réelle double du polynôme caractéristique, on a
∃ λ , μ ∈ R , ∀ n ∈ N , u n = ( λ n + μ ) x n ∃λ,μ∈ℝ, ∀n∈ℕ, un=(λn+μ)xn
Dans les deux cas, les constantes λ λ et μ μ se déterminent à l’aide de la valeur des deux premiers termes de la suite. On a alors un système linéaire de deux équations à deux inconnues à résoudre.
On ne traitera pas le cas où Δ < 0 Δ<0 car on ne le rencontrera pas lors de nos calculs de complexités.
En un mot, on peut alors montrer que les suites sont alors exprimables à l’aide des fonctions cosinus et sinus . Ou au choix de l’exponentielle complexe .
Appliquons ce resultat sur un exemple célèbre, la suite due au mathématicien italien Leonardo Fibonacci (1175-1250).
Example 1.3. Suite de Fibonacci
La suite de Fibonnaci est la suite récurrente linéaire homogène d'ordre deux définie par
u n = ⎧ ⎪ ⎨ ⎪ ⎩ 0 si n = 0 1 si n = 1 u n − 1 + u n − 2 si n ≥ 2 un={0si n=01si n=1un−1+un−2si n≥2
L'équation caractéristique de cette suite est x 2 − x − 1 = 0 x2-x-1=0 . Son discriminant Δ Δ est égal à 5 5 . Le polynôme caractéristique possède donc deux racines distinctes x 1 = 1 + √ 5 2 x1=1+52 et x 2 = 1 − √ 5 2 x2=1-52 .
D'après le résultat précédent
∃ λ , μ ∈ R , ∀ n ∈ N , u n = λ ( 1 + √ 5 2 ) n + μ ( 1 − √ 5 2 ) n ∃λ,μ∈ℝ, ∀n∈ℕ, un=λ(1+52)n+μ(1−52)n
On détermine alors λ λ et μ μ en se servant de la valeur des deux premiers termes u 0 u0 et u 1 u1 .
On a en effet d'une part u 0 = 0 u0=0 et u 1 = 1 u1=1 d'après la définition de la suite, et d'autre part d'après l'expression du terme général
⎧ ⎨ ⎩ u 0 = λ + μ u 1 = λ ( 1 + √ 5 2 ) + μ ( 1 − √ 5 2 ) {u0=λ+μu1=λ(1+52)+μ(1−52)
On doit donc résoudre le système
⎧ ⎨ ⎩ 0 = λ + μ 1 = λ ( 1 + √ 5 2 ) + μ ( 1 − √ 5 2 ) {0=λ+μ1=λ(1+52)+μ(1−52)
On trouve λ = 1 √ 5 λ=15 et μ = − 1 √ 5 μ=-15 . Finalement, on obtient
∀ n ∈ N , u n = 1 √ 5 ( 1 + √ 5 2 ) n − 1 √ 5 ( 1 − √ 5 2 ) n ∀n∈ℕ, un=15(1+52)n−15(1−52)n
Rappelons maintenant un résultat classique sur les limites, qui nous permettra ensuite d'obtenir l'ordre de grandeur d'une suite récurrente linéaire.
Propriété
Soit x x élément de R ℝ .
On a
Si x > 1 x>1 , lim n → ∞ x n = + ∞ limn→∞xn=+∞ .
Si − 1 < x < 1 -1<x<1 , lim n → ∞ x n = 0 limn→∞xn=0 .
Si x ≤ − 1 x≤-1 , x n xn n'a pas de limite quand n → + ∞ n→+∞ .
Le prochain résultat sera celui qui in fine nous servira lors de nos calculs de complexité.
Propriété
Soit ( u n ) n ∈ N (un)n∈ℕ une suite récurrente linéaire homogène d'ordre deux et soit Δ Δ le discriminant de son équation caractéristique.
On suppose que Δ > 0 Δ>0 et l'on note x 1 x1 et x 2 x2 les deux racines réelles distinctes du polynôme caractéristique.
Si x 1 > 1 x1>1 , et si | x 1 | > | x 2 | |x1|>|x2| , alors u n = Θ ( x n 1 ) un=Θ(x1n) .
Cette propriété signifie moralement que sous certaines conditions x n 1 x1n impose le comportement asymptotique de la suite. Appliquons la à la suite de Fibonacci.
Example 1.4. Ordre de grandeur de la suite de Fibonacci
Pour la suite de Fibonacci, on a montré que Δ = 5 Δ=5 , x 1 = 1 + √ 5 2 x1=1+52 et x 2 = 1 − √ 5 2 x2=1-52 . Les hypothèses de la propriété précédente sont donc vérifiées. Ainsi u n = Θ ( ( 1 + √ 5 2 ) n ) un=Θ((1+52)n) .
La différence avec une suite homogène réside dans la présence d'un terme supplémentaire dans l'équation de récurrence.
Définition
Une suite réelle ( v n ) n ∈ N (vn)n∈ℕ est une suite récurrente linéaire non homogène d'ordre deux s'il existe deux réels a a et b b et une fonction f f de N ℕ dans R ℝ , tels que
∀ n ≥ 2 , v n = a v n − 1 + b v n − 2 + f ( n ) ∀n≥2, vn=avn−1+bvn−2+f(n)
On pourrait donner des résultats très précis sur le terme général d'une telle suite, mais nous nous contenterons de la propriété suivante qui en donne son ordre de grandeur. En un mot, pour étudier une suite non homogène il convient d'étudier la suite homogène associée, i.e. la suite obtenue en "supprimant" la fonction f f dans l'équation de récurrence.
Propriété
Soit ( v n ) n ∈ N (vn)n∈ℕ une suite récurrente linéaire non homogène d'ordre deux associée aux réels a a et b b et à la fonction f f .
On considère ( u n ) n ∈ N (un)n∈ℕ la suite récurrente linéaire homogène d'ordre deux associée aux réels a a et b b . Soit Δ Δ le discriminant de son équation caractéristique. On suppose que Δ > 0 Δ>0 et l'on note x 1 x1 et x 2 x2 les deux racines réelles distinctes du polynôme. caractéristique.
Si x 1 > 1 x1>1 , si | x 1 | > | x 2 | |x1|>|x2| , et si f f est une fonction polynomiale alors v n = Θ ( x n 1 ) vn=Θ(x1n) .
Moralement cela signifie que si elle est polynomiale, la fonction f f n’influe pas sur le comportement de la suite, et que l'ordre de grandeur de la suite non homogène est alors celui de la suite homogène.
Terminons cette partie par un exemple, avec l'implémentation de la suite de Fibonacci et le calcul de sa complexité.
Example 1.5. Complexité de la fonction récursive calculant le terme général de la suite de Fibonacci
Voici une fonction récursive calculant le nième terme de la suite de Finonacci :
def fibonacci (n) :
if n<2 :
return n
else :
return fibonacci(n-1 ) + fibonacci(n-2 )
Si n > 2 n>2 , une opération élémentaire (une comparaison) est effectuée lors du test et une autre (une addition) entre les deux appels récursifs. On a ainsi T ( n ) = T ( n − 1 ) + T ( n − 2 ) + 2 T(n)=T(n-1)+T(n-2)+2 .
Si n = 0 n=0 ou n = 1 n=1 , seule la comparaison a lieu donc T ( 0 ) = T ( 1 ) = 1 T(0)=T(1)=1 .
Dans cet exemple la complexité est donc une suite récurrente linéaire non homogène d'ordre deux.
La suite homogène associée est
u n = ⎧ ⎪ ⎨ ⎪ ⎩ 1 si n = 0 1 si n = 1 u n − 1 + u n − 2 si n ≥ 2 un={1si n=01si n=1un−1+un−2si n≥2
Cette dernière suite est quasiment celle de Fibonacci, seul le premier terme diffère. La relation de récurrence est en tout cas la même que pour la suite de Fibonacci donc (avec les notations précédentes) Δ = 5 Δ=5 , x 1 = 1 + √ 5 2 x1=1+52 et x 2 = 1 − √ 5 2 x2=1-52 .
D'autre part, la fonction f f de la relation de récurrence non linéaire est constante égale à 2 2 donc en particulier polynomiale.
Toute les hypothèses de la propriété précédente sont vérifiées, ainsi T ( n ) = Θ ( ( 1 + √ 5 2 ) n ) T(n)=Θ((1+52)n) . La complexité de cet algorithme est donc exponentielle.
Algorithmes de type "diviser pour régner"
Après les récursivités relativement simples de la partie précédente, nous allons maintenant nous confronter à celles inhérentes à l'utilisation d'une technique de type "diviser pour régner ".
Soyons honnête, ce qui va suivre est délicat et demandera de gros efforts au lecteur.
Commençons par rappeler les trois étapes du paradigme "diviser pour régner " :
Diviser : on divise les données initiales en plusieurs sous-parties .
Régner : on résout récursivement chacun des sous-problèmes associés (ou on les résout directement si leur taille est assez petite).
Combiner : on combine les différents résultats obtenus pour obtenir une solution au problème initial.
Avec ce paradigme, la complexité d’un algorithme traitant des données de taille n n va naturellement dépendre de la complexité du même algorithme avec des données de taille n / b n/b , où b b est le facteur avec lequel on divise le problème en sous-problèmes.
Il va falloir de plus tenir compte de la complexité de l’étape de combinaison des résultats des sous-problèmes.
Avant le cas général, nous allons commencer par étudier un exemple, celui du tri fusion .
Le tri fusion consiste à trier récursivement les deux moitiés de la liste, puis à fusionner ces deux sous-listes triées en une seule. La condition d’arrêt à la récursivité sera l’obtention d'une liste à un seul élément, car une telle liste est évidemment déjà triée.
Nous renvoyons le lecteur à ce chapitre du cours 1ADS pour la description des principaux algorithmes de tri, y compris celui-ci.
Voici donc les trois étapes (diviser, régner et combiner) de cet algorithme :
Diviser la liste en deux sous-listes de même taille (à un élément près) en la "coupant" par la moitié.
Trier récursivement chacune de ces deux sous-listes. Arrêter la récursion lorsque les listes n'ont plus qu'un seul élément.
Fusionner les deux sous-listes triées en une seule.
Example 1.6. Déroulement du tri fusion sur un exemple
On considère la liste suivante de sept entiers :
On la scinde en deux sous-listes en la coupant par la moitié :
Sous-listes que l’on scinde à leur tour :
Sous-listes que l’on scinde à leur tour :
Ces sous-listes sont triées car elles n’ont qu’un élément. On va maintenant les fusionner deux par deux en de nouvelles sous-listes triées :
De nouveau une étape de fusionnnement :
Une dernière fusion :
On a fusionné toutes les sous-listes obtenues lors des appels récursifs, le tri est terminé :
En voici son implémentation en Python :
def fusion (l,first,middle,last) :
i,j = first,middle+1
pro = []
while (i<=middle) and (j<=last):
if l[i]<=l[j]:
pro.append(l[i])
i+=1
else :
pro.append(l[j])
j+=1
while i<=middle:
pro.append(l[i])
i+=1
while j<=last:
pro.append(l[j])
j+=1
for k in range(last-first+1 ):
l[first+k] = pro[k]
def triFusionRec (l,first,last) :
if first<last:
triFusionRec(l,first,(first+last)//2 )
triFusionRec(l,((first+last)//2 )+1 ,last)
fusion(l,first,(first+last)//2 ,last)
def triFusion (l) :
triFusionRec(l,0 ,len(l)-1 )
Bien prendre le temps d'appréhender le fonctionnement de cet algorithme.
Voici maintenant le résultat établissant la complexité du tri fusion. Vu la nature même de celui-ci, le lecteur comprendra facilement qu'il n'y a pas de meilleur ou pire des cas.
Théorème
La complexité du tri fusion est T ( n ) = Θ ( n l o g 2 ( n ) ) T(n)=Θ(nlog2(n)) .
Il s'agit de la meilleure complexité que puisse avoir un algorithme de tri.
L'idée pour démontrer ce résultat est de construire l'arbre des appels récursifs , d'évaluer le coût de chaque sommet, puis d'en faire la somme.
Démonstration
Commençons par remarquer que la procédure de fusion est de complexité linéaire . Plus précisément, pour fusionner deux tableaux de taille n / 2 n/2 , on a une complexité en Θ ( n ) Θ(n) . Dans l’algorithme précédent n n étant la différence entre "last" et "first" et donc n / 2 n/2 la différence entre "middle" et "first" ou "last" et "middle". Cela provient du fait que l'on traite une et une seule fois chaque élément des deux listes à fusionner, et que ce traitement est de complexité constante.
Dans la suite de cette preuve on va supposer que n n est une puissance de 2 2 , cela nous permettra d'avoir des subdivisions qui "tombent juste".
A chaque appel récursif, on subdivise un problème de taille n n en 2 2 sous-problèmes de taille n / 2 n/2 , les 2 2 sous-listes que l'on va trier récursivement. A un niveau j j on aura donc 2 j 2j sous-problèmes. Chacun de ces sous-problèmes sera de taille n / 2 j n/2j , et aura ainsi un coût de l'ordre de n / 2 j n/2j (rappelons que la procédure de fusion est de complexité linéaire).
Le coût d'un niveau j j est donc 2 j × ( n 2 j ) = n 2j×(n2j)=n . C'est en effet le nombre de sous-problèmes du niveau j j multiplié par le coût de chacun d'eux.
Pour mieux visualiser tout cela, voici l'arbre des appels récursifs, sur chaque sommet étant indiqué la taille du paramètre lors de l'appel en question :
On a supposé que n n était une puissance de 2 2 , donc n n est de la forme n = 2 h n=2h où h h est la hauteur de l'arbre. C'est pourquoi h = l o g 2 ( n ) h=log2(n) . Egalité que l'on a utilisée dans la figure précédente.
Le nombre de niveaux est alors égal à h + 1 h+1 (ne pas oublier le niveau initial), i.e. l o g 2 ( n ) + 1 log2(n)+1 . On a de plus déjà constaté que le coût de chaque niveau était égal à n n .
Le coût total est alors égal à la somme des coûts de tous les niveaux, i.e. n × ( l o g 2 ( n ) + 1 ) n×(log2(n)+1) . Q.E.D.
Dans la preuve précédente, on a supposé que n n était une puissance de 2 2 , ce qui a facilité notre étude de l'arbre. Si ce n'est pas le cas, il faut alors l'encadrer entre deux puissances de 2 2 , puis effectuer le raisonnement précédent sur ces deux puissances. On arrive alors relativement facilement au résultat.
Nous ne rentrerons pas dans ces détails car ils n'apportent rien en terme de compréhension profonde.
Le Master Theorem (version simplifiée)
Dans cette sous-partie nous n'allons plus nous limiter au cas du tri fusion, mais nous allons présenter et démontrer un résultat applicable à la majorité des algorithmes de type diviser pour régner .
Nous allons donc nous intéresser à des algorithmes de la forme :
def divideAndConquer (n) :
if n ==1 :
end of processing
else :
divideAndConquer(n//b)
divideAndConquer(n//b)
... a times
divideAndConquer(n//b)
work of cost f(n) for dividing the problem
and merging the solutions to
the subproblems
Un problème de taille n n est ainsi divisé en a a sous-problèmes de taille n / b n/b , avec un coût f ( n ) f(n) pour combiner les résultats de ces sous-problèmes.
Il est donc implicitement supposé que chaque sous-problème a la même taille. Dans le cas contraire le Master Theorem ne peut s'appliquer.
Master Theorem
Soient a ≥ 1 a≥1 , b > 1 b>1 , et T T une fonction vérifiant
T ( n ) = a T ( n b ) + f ( n ) T(n)=aT(nb)+f(n)
Les différents paramètres s'interprètent comme suit :
n n est la taille du problème.
a a est le nombre de sous-problèmes.
n / b n/b est le taille de chaque sous-problèmes.
f ( n ) f(n) est le coût de la subdivision et de la combinaison des solutions des sous-problèmes.
On suppose de plus que f ( n ) = Θ ( n d ) f(n)=Θ(nd) .
Alors
T ( n ) = ⎧ ⎪ ⎪ ⎨ ⎪ ⎪ ⎩ Θ ( n d ) si l o g b ( a ) < d Θ ( n d l o g ( n ) ) si l o g b ( a ) = d Θ ( n l o g b ( a ) ) si l o g b ( a ) > d T(n)={Θ(nd)si logb(a)<dΘ(ndlog(n))si logb(a)=dΘ(nlogb(a))si logb(a)>d
On a également la formulation équivalente suivante
T ( n ) = ⎧ ⎪ ⎪ ⎨ ⎪ ⎪ ⎩ Θ ( n d ) si a < b d Θ ( n d l o g ( n ) ) si a = b d Θ ( n l o g b ( a ) ) si a > b d T(n)={Θ(nd)si a<bdΘ(ndlog(n))si a=bdΘ(nlogb(a))si a>bd
Il n’est pas nécessaire de préciser la base du logarithme dans le deuxième cas, puisque rappelons le toutes les fonctions logarithmes diffèrent juste d’une constante multiplicative.
Comme lors de la démonstration de la complexité du tri fusion, nous allons étudier l'arbre des appels récursifs en faisant la somme des coûts de chaque sommet. Certains arguments seront donc similaires à ceux utilisés dans la sous-partie précédente.
Nous aurons également besoin du petit lemme suivant :
Lemme
Soit q ∈ R q∈ℝ .
Si q < 1 q<1 , on a
r ∑ j = 0 q j ≤ 1 1 − q ∑j=0rqj≤11-q
Si q > 1 q>1 , on a
r ∑ j = 0 q j ≤ q r ( 1 + 1 q − 1 ) ∑j=0rqj≤qr(1+1q-1)
Démonstration du lemme
On sait (voir paragraphe 1.4 du chapitre, 1) que si q ≠ 1 q≠1 , on a
r ∑ j = 0 q j = 1 − q r + 1 1 − q = q r + 1 − 1 q − 1 ∑j=0rqj=1-qr+11-q=qr+1-1q-1
Si q < 1 q<1 , on se sert de la première égalité et du fait que 1 − q r + 1 < 1 1-qr+1<1 . Le résultat en découle immédiatement.
Si q > 1 q>1 , on utilise cette fois la seconde égalité et le fait que q r + 1 − 1 < q r + 1 qr+1-1<qr+1 . On obtient alors
r ∑ j = 0 q j ≤ q r + 1 q − 1 ∑j=0rqj≤qr+1q-1
Il ne reste plus qu'à factoriser par q r qr et à changer la forme de la fraction.
Démonstration du Master Theorem
Dans la suite de cette preuve on va supposer que n n est une puissance de b b , cela nous permettra d'avoir des subdivisions qui "tombent juste".
A chaque appel récursif, on subdivise un problème de taille n n en a a sous-problèmes de taille n / b n/b . A un niveau j j on aura donc a j aj sous-problèmes. Chacun de ces sous-problèmes sera de taille n / b j n/bj , et aura ainsi un coût de l'ordre de ( n / b j ) d (n/bj)d (rappelons que la complexité de la procédure de combinaison est en Θ ( n d ) Θ(nd) ).
Le coût d'un niveau j j est donc a j × ( n b j ) d aj×(nbj)d . C'est en effet le nombre de sous-problèmes du niveau j j multiplié par le coût de chacun d'eux.
Pour mieux visualiser tout cela, voici l'arbre des appels récursifs, sur chaque sommet étant indiqué la taille du paramètre lors de l'appel en question :
On a supposé que n n était une puissance de b b , donc n n est de la forme n = b h n=bh où h h est la hauteur de l'arbre. C'est pourquoi h = l o g b ( n ) h=logb(n) . Egalité que l'on a utilisée dans la figure précédente.
Contrairement au tri fusion, chaque niveau n'a pas le même coût, et l'on ne peut donc avoir le coût total en multipliant le nombre de niveaux par le coût de chacun d'eux.
Pour estimer T ( n ) T(n) on doit ainsi calculer la somme
l o g b ( n ) ∑ j = 0 a j × ( n b j ) d ∑j=0logb(n)aj×(nbj)d
On peut factoriser par n d nd dans cette somme puisque ce terme ne dépend pas de l'indice j j de sommation. On obtient alors l'égalité
l o g b ( n ) ∑ j = 0 a j × ( n b j ) d = n d l o g b ( n ) ∑ j = 0 ( a b d ) j ∑j=0logb(n)aj×(nbj)d=nd∑j=0logb(n)(abd)j
On reconnaît la somme des premiers termes d'une suite géométrique de raison q = a b d q=abd . La valeur de cette quantité va donc déterminer celle de la complexité totale. Plus précisément, on devra distinguer les cas où a b d abd est plus petit, plus grand ou égal à 1 1 .
Cas où a = b d a=bd :
On a alors a b d = 1 abd=1 , et l'on est ramené à une somme de termes constants
n d l o g b ( n ) ∑ j = 0 ( a b d ) j = n d l o g b ( n ) ∑ j = 0 1 = n d × ( l o g b ( n ) + 1 ) nd∑j=0logb(n)(abd)j=nd∑j=0logb(n)1=nd×(logb(n)+1)
D'où
T ( n ) = Θ ( n d l o g ( n ) ) T(n)=Θ(ndlog(n))
Cas où a < b d a<bd :
On utilise la première inégalité du lemme pour obtenir
n d l o g b ( n ) ∑ j = 0 ( a b d ) j ≤ n d 1 1 − a b d nd∑j=0logb(n)(abd)j≤nd11−abd
D'où
T ( n ) = Θ ( n d ) T(n)=Θ(nd)
Cas où a > b d a>bd :
On utilise cette fois la seconde inégalité du lemme. Il vient
n d l o g b ( n ) ∑ j = 0 ( a b d ) j ≤ n d ( a b d ) l o g b ( n ) ( 1 + 1 a b d − 1 ) nd∑j=0logb(n)(abd)j≤nd(abd)logb(n)(1+1abd−1)
Quelques changements d'expressions dues aux règles opératoires usuelles sur les puissances conduisent à
n d b d l o g b ( n ) = n d b − d l o g b ( n ) = n d ( b l o g b ( n ) ) − d ndbdlogb(n)=ndb−dlogb(n)=nd(blogb(n))−d
Or l'on sait que les fonctions logarithmes et exponentielles de base b b sont réciproques l'une de l'autre. Cela implique en particulier que b l o g b ( n ) = n blogb(n)=n
On a alors
n d ( b l o g b ( n ) ) − d = n d n − d = 1 nd(blogb(n))−d=ndn−d=1
Cela prouve que
n d l o g b ( n ) ∑ j = 0 ( a b d ) j ≤ a l o g b ( n ) ( 1 + 1 a b d − 1 ) nd∑j=0logb(n)(abd)j≤alogb(n)(1+1abd−1)
Et donc
T ( n ) = Θ ( a l o g b ( n ) ) T(n)=Θ(alogb(n))
Il ne reste plus qu'à constater que a l o g b ( n ) = n l o g b ( a ) alogb(n)=nlogb(a) . Cela est vrai car les logarithmes dans la base b b de chacun de ces deux termes sont égaux à l o g b ( a ) × l o g b ( n ) logb(a)×logb(n) .
Si n n n'est pas une puissance de b b il faut procéder par encadrement . Comme pour le tri fusion nous ne rentrerons pas dans ces détails.
Example 1.7. Retour sur le tri fusion
Le tri fusion rentre bien sûr dans le cadre du Master Theorem.
A chaque appel récursif, on y subdivise un problème de taille n n en 2 2 sous-problèmes de taille n / 2 n/2 . On a donc a = 2 a=2 et b = 2 b=2 .
De plus la procédure de fusion est de complexité linéaire donc d = 1 d=1 .
On est ainsi dans le cas où a = b d a=bd . La complexité est alors T ( n ) = Θ ( n d l o g ( n ) ) T(n)=Θ(ndlog(n)) , i.e. T ( n ) = Θ ( n l o g ( n ) ) T(n)=Θ(nlog(n)) .
On retrouve bien le résultat démontré sur ce cas particulier au début de cette partie.
Pour terminer cette sous-partie, on propose au lecteur un petit exercice corrigé afin qu'il s'interroge sur les conditions d'application du Master Theorem.
Exercice corrigé
Expliquer dans chacun des cas suivants pourquoi le Master Theorem ne s'applique pas :
T ( n ) = 2 n T ( n 2 ) + n 3 T(n)=2nT(n2)+n3 .
T ( n ) = T ( n 2 ) + T ( n 4 ) + n 2 T(n)=T(n2)+T(n4)+n2 .
T ( n ) = 1 2 T ( n 2 ) + n T(n)=12T(n2)+n .
Display answer
Si l'on se retrouve dans une situation où le Master Theorem ne s'applique pas, il faut considérer l'arbre des appels récursifs et l'analyser en détail. Un peu comme dans la preuve du théorème, mais en prenant bien sûr en compte les facteurs qui ont fait qu'il ne s'appliquait pas.
Interprétation des trois cas du Master Theorem
Livrons nous maintenant à une petite interprétation de chacun des cas du Master Theorem afin de mieux en comprendre leur essence .
Rappelons que le coût d'un niveau j j est proportionnel à a j × ( n b j ) d aj×(nbj)d , quantité que l'on peut mettre sous la forme n d × ( a b d ) j nd×(abd)j .
La constante a a peut être vue comme la vitesse de prolifération des sous-problèmes , et le terme b d bd comme la vitesse de diminition du coût de traitement par sous-problème .
Le fait que l'une de ces vitesses prenne le pas sur l'autre ou au contraire qu'elles se compensent expliquera la complexité finale.
Voici les trois situations possibles :
Cas où a < b d a<bd : le coût d’un niveau est une fonction décroissante de j j . Moralement la complexité sera essentiellement celle de l’appel initial donc en n d nd .
Cas où a = b d a=bd : le coût d’un niveau est une fonction constante de j j et vaut n d nd . La complexité sera alors égale à n d nd multiplié par le nombre de niveau qui est l o g b ( n ) logb(n) .
Cas où a > b d a>bd : le coût d’un niveau est une fonction croissante de j j . Moralement la complexité sera essentiellement celle due aux appels finaux. Elle sera donc de l’ordre du nombre de feuilles de l’arbre des appels récursifs, i.e. n l o g b ( a ) nlogb(a) .
Le Master Theorem (cas général)
Les notations et leur signification seront les mêmes que dans la version dite simple, mais les hypothèses seront moins restrictives , en particulier celles portant sur la complexité de la procédure de subdivision et combinaison.
Nous présentons cette version du théorème par soucis de complétude, mais en général la précédente suffit. D'ailleurs nous n'en ferons pas de démonstration, les "nouveaux" arguments étant essentiellement mathématiques.
Master Theorem
Soient a ≥ 1 a≥1 , b > 1 b>1 , et T T une fonction vérifiant
T ( n ) = a T ( n b ) + f ( n ) T(n)=aT(nb)+f(n)
Les différents paramètres s'interprètent comme suit :
n n est la taille du problème.
a a est le nombre de sous-problèmes.
n / b n/b est le taille de chaque sous-problèmes.
f ( n ) f(n) est le coût de la subdivision et de la combinaison des solutions des sous-problèmes.
Alors
T ( n ) = ⎧ ⎪ ⎪ ⎪ ⎪ ⎨ ⎪ ⎪ ⎪ ⎪ ⎩ Θ ( f ( n ) ) si f ( n ) = Ω ( n d ) avec l o g b ( a ) < d et si a × f ( n b ) ≤ c × f ( n ) avec c < 1 Θ ( n l o g b ( a ) l o g ( n ) ) si f ( n ) = Θ ( n d ) avec l o g b ( a ) = d Θ ( n l o g b ( a ) ) si f ( n ) = O ( n d ) avec l o g b ( a ) > d T(n)={Θ(f(n))si f(n)=Ω(nd) avec logb(a)<d et si a×f(nb)≤c×f(n) avec c<1Θ(nlogb(a)log(n))si f(n)=Θ(nd) avec logb(a)=dΘ(nlogb(a))si f(n)=O(nd) avec logb(a)>d
On trouve ce théorème sous cette forme générale dans l'excellent livre de Thomas H. Cormen (1956-), Charles E. Leiserson (1953-), Ronald L. Rivest (1947-) et Clifford Stein (1965-), "Introduction to algorithms ".
Ne pas se fier au titre, ce livre est bien plus qu'une introduction, c'est une référence .