• Aucun résultat trouvé

La complexit´e est l’´etude du nombre d’op´erations n´ecessaires `a l’ach`evement d’un calcul. Une analyse de complexit´e permet donc de se faire une id´ee du temps de cal- cul n´ecessaire `a l’ach`evement d’un programme, en fonction de l’argument qui lui est soumis. En g´en´eral, on compte le nombre d’op´erations ´el´ementaires (additions, multi- plications, soustractions et divisions, comparaisons de valeurs, affectations d’´el´ements de tableau) et/ou le nombre d’appels de fonctions. Par exemple, la fonction successeur demande une seule op´eration, quel que soit son argument. En revanche, la complexit´e de la fonction factorielle d´epend de son argument : elle demande n op´erations pour l’argument n. Plus pr´ecis´ement, il faut n multiplications, n+1 appels r´ecursifs `a la fonc- tion factorielle et n soustractions. Si l’on consid`ere que ces trois types d’op´erations ont des coˆuts voisins, alors la complexit´e de factorielle est de l’ordre de 2n + (n + 1), c’est-`a-dire de l’ordre de 3n. On consid´erera donc que la fonction factorielle a une complexit´e qui augmente au mˆeme rythme que son argument, ce qu’on note O(n) et qu’on prononce « grand-o de n ». Plus pr´ecis´ement, O(n) signifie « un certain nombre de fois » n, plus des termes n´egligeables devant n quand n devient grand, comme par exemple une constante. On ne s’int´eresse en effet qu’`a un ordre de grandeur de la com-

plexit´e : cette complexit´e augmente-t-elle comme l’argument (algorithme lin´eaire), ou comme le carr´e de l’argument (algorithme quadratique), ou comme une exponentielle de l’argument (algorithme exponentiel ) ? Dans le cas de factorielle, on r´esume l’´etude en notant une complexit´e lin´eaire O(n), puisque la complexit´e r´eelle est 3n + 1.

Principe de r´ecurrence

Les ´etudes de complexit´e et les d´efinitions r´ecursives de fonctions reposent sur un raisonnement simple sur les propri´et´es qui concernent les nombres entiers : le principe de r´ecurrence. Nous allons l’expliquer, puis l’utiliser pour d´emontrer des propri´et´es de la fonction hanoi.

Le principe de r´ecurrence s’´enonce informellement ainsi : si une certaine propri´et´e sur les nombres entiers est vraie pour 0 et si la propri´et´e est vraie pour le successeur d’un nombre d`es qu’elle est vraie pour ce nombre, alors cette propri´et´e est vraie pour tous les nombres. Formellement : soit P (n) une propri´et´e qui d´epend d’un entier n. Si les phrases suivantes sont vraies :

1. P (0) est vraie,

2. si P (n) est vraie alors P (n + 1) est vraie, alors P (n) est vraie pour tout n.

Ce principe est en fait ´evident : les deux propri´et´es demand´ees par le principe de r´ecurrence permettent facilement de d´emontrer la propri´et´e P pour toute valeur enti`ere. Par exemple, supposons que P v´erifie les deux propri´et´es et qu’on veuille d´emontrer que P est vraie pour 2. Puisque P est vraie pour 0 elle est vraie pour son successeur, 1. Mais puisque P est vraie pour 1 elle est vraie pour son successeur, donc elle est vraie pour 2. Il est clair que ce raisonnement se poursuit sans probl`eme pour tout nombre entier fix´e `a l’avance.

C’est ce principe que nous avons utilis´e pour r´esoudre le probl`eme des tours de Hanoi :

1. nous avons montr´e que nous savions le r´esoudre pour 0 disque ;

2. nous avons montr´e qu’en sachant le r´esoudre pour n − 1 disques nous savions le r´esoudre pour n disques.

Ces deux cas correspondent exactement aux deux clauses de la fonction hanoi (cas 0 -> et cas n ->). Le principe de r´ecurrence nous prouve donc que nous savons effectivement r´esoudre le probl`eme pour tout n, mˆeme si cela ne nous apparaissait pas clairement au d´epart.

La difficult´e intuitive de ce genre de d´efinitions r´ecursives est d’oser utiliser l’hypoth`ese de r´ecurrence : il faut supposer qu’on sait d´ej`a faire pour n − 1 disques et ´ecrire le programme qui r´esout le probl`eme pour n disques. Dans la proc´edure hanoi, on suppose ainsi deux fois que la fonction saura bien faire toute seule pour n − 1 disques et l’on ne s’occupe que de d´eplacer le gros disque, ce qui semble un travail facile. Finalement, on a l’impression de voir tourner du code que l’on n’a pas ´ecrit, tellement il semble astucieux `a l’ex´ecution.

Notions de complexit´e 33

let rec f = function | 0 -> «solution simple»

| n -> ... f (n - 1) ... f (n - 1) ...;;

On d´emontre en math´ematiques qu’il n’est pas interdit d’appeler f sur d’autres argu- ments que n - 1, pourvu qu’ils soient plus petits que n (par exemple n - 2), mais alors il faut pr´evoir d’autres cas simples (par exemple 1 ->). Un exemple de ce sch´ema de programme est la fonction de Fibonacci d´efinie par :

# let rec fib = function | 0 -> 1

| 1 -> 1

| n -> fib (n - 1) + fib (n - 2);; fib : int -> int = <fun>

# fib 10;; - : int = 89

Remarquez que cette fonction fait effectivement deux appels r´ecursifs sur deux valeurs diff´erentes, mais toutes les deux plus petites que l’argument donn´e.

Complexit´e de la proc´edure hanoi

Il est facile d’´ecrire un programme qui compte le nombre de mouvements n´ecessaires pour r´esoudre le jeu pour n disques : il y a 0 mouvement `a faire pour 0 disque, l’appel `a la proc´edure mouvement produit 1 mouvement et le nombre de mouvements n´ecessaires aux appels r´ecursifs est forc´ement compt´e par la fonction r´ecursive de comptage que nous sommes en train de d´efinir. En effet, on suppose une fois de plus que pour n − 1 la fonction « sait faire » et on se contente de trouver le r´esultat pour n.

# let rec compte_hanoi d´epart milieu arriv´ee = function | 0 -> 0

| n -> compte_hanoi d´epart arriv´ee milieu (n - 1) + 1 + compte_hanoi milieu d´epart arriv´ee (n - 1);; compte_hanoi : ’a -> ’a -> ’a -> int -> int = <fun>

Les arguments contenant les noms des tiges sont bien sˆur inutiles et il suffit d’´ecrire :

# let rec compte_hanoi_na¨ıf = function | 0 -> 0

| n -> compte_hanoi_na¨ıf (n - 1) + 1 + compte_hanoi_na¨ıf (n - 1);; compte_hanoi_na¨ıf : int -> int = <fun>

qu’on simplifie encore en

# let rec compte_hanoi = function | 0 -> 0

| n -> (2 * compte_hanoi (n - 1)) + 1;; compte_hanoi : int -> int = <fun>

# compte_hanoi 3;; - : int = 7 # compte_hanoi 10;; - : int = 1023 # compte_hanoi 16;; - : int = 65535

On devine la propri´et´e suivante : pour tout n, compte_hanoi (n) = 2n− 1. Nous allons

la propri´et´e P par : P (n) est vraie si et seulement si compte_hanoi (n) = 2n− 1. La

proposition P (0) est vraie car compte_hanoi (0) = 0 et 20− 1 = 1 − 1 = 0. Supposons P (n) vraie et montrons qu’alors P (n + 1) est vraie. Pour montrer P (n + 1), il faut d´emontrer

compte_hanoi(n + 1) = 2n+1− 1. Or, d’apr`es la d´efinition de la fonction compte_hanoi, on a :

compte_hanoi(n + 1) = 2 × compte_hanoi ((n + 1) − 1) + 1,

soit compte_hanoi (n + 1) = 2 × compte_hanoi (n) + 1. Mais, par hypoth`ese de r´ecurrence, P (n) est vraie, donc compte_hanoi (n) = 2n−1. En reportant dans l’´egalit´e

pr´ec´edente, on obtient :

compte_hanoi(n + 1) = 2 × (2n− 1) + 1. Mais 2 × (2n− 1) + 1 = 2n+1− 2 + 1 = 2n+1− 1, donc

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

et P (n + 1) est vraie. Il s’ensuit, d’apr`es le principe de r´ecurrence, que P (n) est vraie pour tout n.

Avec ce nouveau r´esultat, nous sommes autoris´es `a red´efinir compte_hanoi comme la fonction qui `a n associe 2n− 1. Pour avoir une id´ee du nombre de mouvements

n´ecessaires pour r´esoudre le probl`eme avec 64 disques, nous sommes oblig´es de faire les calculs en « virgule flottante » car le r´esultat exc`ede de beaucoup la limite sup´erieure des entiers repr´esentables en Caml. Nous reviendrons plus tard sur les nombres en virgule flottante, aussi appel´es nombres flottants (chapitre 8). Pour l’instant il suffit de savoir qu’un nombre flottant est caract´eris´e par le point qui pr´ec`ede sa partie d´ecimale et que les op´erations associ´ees aux flottants sont suffix´ees ´egalement par un point (+., -., *., etc.). Nous impl´ementons donc notre fonction en utilisant la fonction « puissance » des nombres flottants (power).

# let compte_hanoi_rapide n = power 2.0 n -. 1.0;; compte_hanoi_rapide : float -> float = <fun> # compte_hanoi_rapide 64.0;;

- : float = 1.84467440737e+19

Un algorithme correct mais inutilisable

Grˆace `a notre d´emonstration math´ematique, nous avons ´etabli une formule de cal- cul direct du nombre de mouvements n´ecessaires `a la r´esolution du jeu pour n disques. Nous avons ainsi tr`es fortement acc´el´er´e la fonction compte_hanoi. C’´etait indispens- able car notre premi`ere version, la fonction compte_hanoi_na¨ıf, quoique parfaitement correcte d’un point de vue math´ematique, n’aurait pas pu nous fournir le r´esultat pour 64. En effet cette version calcule son r´esultat en utilisant uniquement l’addition. Plus pr´ecis´ement, elle n’ajoute toujours que des 1 : il lui aurait donc fallu faire 264− 1 ad- ditions. Mˆeme en supposant qu’on fasse 1 milliard d’additions par seconde, ce qui est `

a la limite de la technologie actuelle, il aurait fallu, avec le programme de la premi`ere version de compte_hanoi,

Notions de complexit´e 35

# let nombre_de_secondes_par_an = 3600.0 *. 24.0 *. 365.25;; nombre_de_secondes_par_an : float = 31557600.0

# let nombre_d’additions_par_an = nombre_de_secondes_par_an *. 1E9;; nombre_d’additions_par_an : float = 3.15576e+16

# compte_hanoi_rapide 64.0 /. nombre_d’additions_par_an;; - : float = 584.542046091

c’est-`a-dire plus de 584 ann´ees pour achever le calcul ! Nous sommes donc ici en pr´esence d’une fonction qui donne effectivement le bon r´esultat au sens des math´ematiques, mais qui le calcule tellement lentement qu’elle devient inutilisable. `A la diff´erence des math´ematiques, il ne suffit donc pas en informatique d’´ecrire des programmes corrects, il faut encore que leur complexit´e ne soit pas trop ´elev´ee pour qu’ils calculent le r´esultat correct en un temps raisonnable.

La fonction compte_hanoi_na¨ıven´ecessite 2n− 1 additions pour l’argument n. Son

temps de calcul est donc proportionnel `a une puissance (2n) dont l’exposant est son

argument n : l’algorithme est exponentiel. La seconde version utilisant la multiplication n´ecessite n multiplications, l’algorithme est donc lin´eaire. Un algorithme lin´eaire de- mande un temps de calcul qui augmente comme la valeur de son argument (O(n)), ce qui est raisonnable. En effet, cette version nous aurait permis d’obtenir notre r´esultat, puisque pour n = 64 il aurait fallu 64 multiplications seulement. La derni`ere version, quant `a elle, est en temps constant. Elle ne n´ecessite que deux op´erations flottantes quel que soit son argument : c’est l’algorithme id´eal. On retiendra qu’un algorithme expo- nentiel est vite susceptible d’exiger un temps de calcul prohibitif quand son argument augmente.

Date de la fin du monde

Calculons le nombre d’ann´ees n´ecessaires aux moines pour achever leur jeu `a 64 disques. Supposons qu’ils puissent effectuer sans arrˆet, jour et nuit, dix mouvements par secondes, ce qui est vraiment le maximum qu’on puisse exiger de ces pauvres moines. Il leur faudrait alors :

# let nombre_de_mouvements_par_an = nombre_de_secondes_par_an *. 10.0;;

nombre_de_mouvements_par_an : float = 315576000.0

# compte_hanoi_rapide 64.0 /. nombre_de_mouvements_par_an;; - : float = 58454204609.1

soit plus de 58 milliards d’ann´ees. C’est beaucoup plus que la dur´ee de vie estim´ee du Soleil. Il semble donc que l’heure de la fin du monde aura sonn´e tr`es longtemps avant la fin du jeu !

Calcul de la complexit´e de la seconde version

Dans la section pr´ec´edente, nous avons affirm´e que la seconde version de compte_hanoi:

# let rec compte_hanoi = function | 0 -> 0

| n -> 2 * compte_hanoi (n - 1) + 1;; compte_hanoi : int -> int = <fun>

n´ecessitait n multiplications. La d´emonstration en est tr`es simple. Nous noterons Op(compte_hanoi (n)) le nombre d’op´erations n´ecessaires pour effectuer le calcul de compte_hanoi (n) `a l’aide de cette version de compte_hanoi. Nous d´emontrons par r´ecurrence la propri´et´e P (n) d´efinie par : P (n) est vraie si et seulement si Op(compte_hanoi (n)) = n. La propri´et´e P (0) est vraie car Op(compte_hanoi (0)) = 0. Supposons P (n) vraie et montrons qu’alors P (n + 1) est vraie. Pour montrer P (n + 1), il faut d´emontrer Op(compte_hanoi (n + 1)) = (n + 1). Or, d’apr`es le code de la fonction compte_hanoi, quand on a le r´esultat de compte_hanoi (n - 1), il faut faire une multiplication de plus pour obtenir compte_hanoi (n). On a donc : Op(compte_hanoi (n + 1)) = 1 + Op(compte_hanoi (n)) ; mais, d’apr`es l’hypoth`ese de r´ecurrence, Op(compte_hanoi (n)) = n, et donc Op(compte_hanoi (n + 1)) = n + 1. Il s’ensuit que P (n) est vraie pour tout n.

Remarquons pour finir que nous avons calcul´e la complexit´e de hanoi en utilisant la fonction compte_hanoi, dont nous avons dˆu `a nouveau ´etudier la complexit´e, pour l’optimiser (sous peine de ne pas obtenir effectivement la complexit´e de hanoi). Il faut d´ecid´ement r´efl´echir sur les programmes qu’on ´ecrit . . .

3

Programmation imp´erative

O`u l’on apprend que2x + 2x font 4x.

ous mettons en placedans ce chapitre quelques outils indispensables `a la pro- grammation imp´erative. En particulier, nous introduisons la notion de tableau, et l’utilisons pour calculer des identit´es remarquables. Nous serons par exemple en mesure d’´etablir par programme la formule (x + 1)2 = x2 + 2x + 1. En termes sa- vants nous ferons du calcul formel sur des polynˆomes `a une ind´etermin´ee. Si vous savez d´ej`a qu’il y a autre chose dans la vie que la programmation fonctionnelle et que vous connaissez les boucles « for » et « while », vous pouvez sauter ce chapitre.