• Aucun résultat trouvé

5.2 Trouver la paire de points les plus proches . . . 167 5.3 Multiplication rapide . . . 179 5.4 Master Theorem . . . 187 5.5 Calcul du médian . . . 191 5.6 Morale . . . 193 Bibliographie . . . 195 Mots clés et notions abordées dans ce chapitre :

• la paire de points les plus proches

• algorithme de Karatsuba

• complexité définie par formule de récurrence

Master Theorem

5.1 Introduction

Diviser pour régner (divide-and-conqueren Anglais) est une technique permettant de construire des algorithmiques récursifs. La stratégie consiste à découper le problème en sous-problèmes similaires (d’où l’algorithme récursif résultant) dans l’espoir d’affaiblir ou de casser la difficulté du problème initial.

L’expression provient du latin «divide ut regnes» ou «divide et impera», et tire ses origines de l’antiquité. Une stratégie militaire (ou politique) bien connue consiste, afin d’affaiblir un groupe d’individus adversaires, à le diviser en plus petit espérant ainsi les rendre impuissants.

164 CHAPITRE 5. DIVISER POUR RÉGNER Les algorithmes produits ne sont pas forcément les plus efficaces possibles. Ils peuvent même se révéler aussi inefficaces que l’approche naïve. On a déjà vu de tels exemples, les algorithmes récursifs étant parfois franchement inefficaces (cf. sec-tion 2.4). Cependant la technique gagne à être connue puisqu’elle peut mener à des algorithmes non triviaux auxquels on n’aurait peut-être pas pensé sinon.

L’archétype d’un algorithme résultant de cette approche est sans doute le tri-fusion.

On découpe le tableau en deux sous-tableaux que l’on tri chacun récursivement. Ils sont ensuite recombinés (d’où le terme de fusion) pour obtenir un tableau entièrement trié.

Voici un rappel du code : // tri récursif de T[i..j[

void merge_sort(double T[],int i,int j){

if(j-i<2) return; // rien à trier si un seul élément int m=(i+j)/2; // m = milieu de l’intervalle [i..j[

merge_sort(T,i,m); // tri récursif de T[i..m[

merge_sort(T,m,j); // tri récursif de T[m..j[

fusion(T,i,m,j); // fusion T[i..m[ + T[m..j[ -> T[i..j[

}

Il faut noter que c’est en fait la fusion qui trie le tableau. Les appels récursifs n’ont pas pour effet d’ordonner les éléments. Au mieux ils modifient les indicesi etj ce qui virtuellement découpe le tableau en sous-tableaux de plus en plus petits. La fonction de comparaison de deux éléments (l’instruction «T[p]<T[q]?» ci-après1) n’est présente que dans la fonctionfusion()dont le code est rappelé ci-dessous2 :

// fusion de T[i..m[ et T[m..j[ pour donner T[i..j[

void fusion(double T[],int i,int m,int j){

int k=i,p=i,q=m,*r; // r = pointeur vers p ou q while(k<j){ // tant qu’il y a des éléments

if(p==m) r=&q; // T[i..m[ a été traité else if(q==j) r=&p; // T[m..j[ a été traité else r=(T[p]<T[q])? &p : &q; // comparaison

A[k++]=T[(*r)++]; // copie dans A[k] et incrémente p ou q }

memcpy(T+i,A+i,(j-i)*sizeof(*A)); // recopie A[i..j[ dans T[i..j[

}

D’ailleurs, on aurait pu écrire le tri-fusion sans appel récursifs, en fusionnant direc-tement les bons sous-tableau (d’abord ceux de taille deux, puis de taille quatre, etc.).

1. C’est cette instruction qu’il faudrait changer pour effectuer un tri selon une fonction de comparai-sonfcmp()quelconque à laqsort(). En fait, pour une fonction de comparaison absolument quelconque il faudrait utiliser desvoid*et déclarer le tableau commevoid* T[].

2. Traditionnellement on sort les trois conditions de la bouclewhile, pour obtenir trois boucleswhile sans condition, rallongeant d’autant le code. On a préféré ici une présentation succincte du code.

5.1. INTRODUCTION 165 C’est souvent cette façon de faire, l’approche dite bottom-up, en commençant par les feuilles de l’arbre des appels et en remontant, qui donne les meilleures implémenta-tions, car elle évite tous les appels récursifs et autant d’empilements et de dépilements inutiles. Toutes les valeurs des variables i,m,j dans les appels fusion(T,i,m,j) se dé-duisent par calculs directs.

Remarque sur l’implémentation. Dans le code précédent de la fonction fusion(), il est supposé qu’on dispose d’un tableau auxiliaireAde la même taille queT. Bien sûr, on aurait peut aussi mettre en début defusion()undouble *A = malloc(...)et unfree(A) avant de quitter la fonction, rendant peut-être le code plus « propre ». Cependant la fonctionfusion() va être appeléeO(n) fois, soit autant demalloc()et de free()ce qui peut représenter un délais non négligeable sur le temps d’exécution. On peut donc uti-liser un seulmalloc()et un seulfree()comme ceci3 :

static double *A;

// appel à merge_sort() void sort(double T[],int n){

A=malloc(n*sizeof(*A));

merge_sort(T,0,n); // trie T[0..n[

free(A);

}

[Exercice. Implémenter le tri-fusion sous la forme d’une seule fonction non récur-sive, par une approchebottom-up. Optimisez les recopies dans le tableau auxiliaire en échangeant de tableau un niveau sur deux.]

L’approche du tri-fusion est efficace car il est effectivement plus rapide de trier un tableau à partir de deux tableaux déjà triés. Cela ne prend qu’un temps linéaire. Lorsque les sous-tableaux ne contiennent plus qu’un élément, alors les fusions opèrent. Pour analyser le temps consommé par toutes les fusions de l’algorithme, il est plus simple de grouper les fusions selon leur niveaux. Au plus bas niveau (=0) sont fusionnés les tableaux à un élément pour former des tableaux de niveau supérieur (=1). Puis sont fusionnés les tableaux de niveaux i (ou inférieur) pour former des tableaux de niveau i+ 1. Comme la somme totale des longueurs des tableaux d’un niveau donné ne peut pas dépasser n, le temps de fusion de tous les tableaux de niveau i prend O(n) pour

3. Bien sûr, l’utilisation de la variable globaleAn’est pas conseillé si la fonctionsort()a vocation à être exécutée en parallèle par plusieurs processus asynchrones.

166 CHAPITRE 5. DIVISER POUR RÉGNER chaquei. Si l’on découpe en deux parties égales4 à chaque fois, le niveau d’un tableau sera au plusO(logn) puisque sa taille doublera à chaque fusion. Au total la complexité estO(nlogn).

Parenthèse. Au lieu d’analyser le temps d’exécution selon le parcours en profondeur de l’arbre des appels, selon l’ordre de l’exécution donc, on préfère considérer ici le temps d’exé-cution selon un parcours en largeur d’abord (par niveaux). Car peu importe le parcours, ce qui compte c’est le temps total consomé par chaque appel de l’arbre. Dit autrement, la somme des coûts de chaque appel peut être réalisée dans l’ordre que l’on souhaite.

Il est intéressant de remarquer que l’approche du tri-par-sélection, une approche naïve qui consiste à chercher le plus petit élément, de le mettre en tête et de recom-mencer sur le reste, est bien moins efficace :O(n2) comparaisons vs. O(nlogn) pour le tri-fusion. On pourra se reporter au paragraphe 5.2.5 pour la comparaison des com-plexitésn2etnlognen pratique.

Parenthèse. Construire un algorithme de tri de complexité O(nlogn) itératif, donc non basé sur une approche récursive, n’est pas si simple que cela. Le sélection, le tri-par-insertion5, et le tri-par-bulles6 sont des algorithmes itératifs de complexité O(n2). Même le tri-rapide7 est récursif et de complexité O(n2), même si en moyenne la complexité est meilleure. Cf. letableau comparatifdes tris.

Cependant, le tri-par-tas échappe à la règle. Il n’est pas récursif, permet un tri en place, c’est-à-dire qu’il n’utilise pas de mémoire supplémentaire comme dans le tri-fusion, et a une complexitéO(nlogn). Le tableauT desn éléments à trier va servir de support à un tas maximum. La remarque est que les éléments d’un tas de taillek sont rangés dans lesk premières cases deT. Lesnkcases suivantes sont libres pour le stockage des éléments de T qui ne sont pas dans le tas.

Dans une première phase le tableau est transformé en tas maximum. Pour cela on peut ajouter les éléments un à un au tas jusqu’à le remplir. Cela prend un temps deO(nlogn) pour lesninsertions. On peut cependant faire plus rapidement en parcourant les éléments par niveau décroissant, à partir du niveau i = h−1 des parents des feuilles. (Pour ces dernières il n’y a rien à faire.) Puis pour chacun d’eux on corrige son sous-tas en descendant ce père au bon endroit comme lors d’une suppression, en tempshi donc. Cela prend un temps total dePh1

4. Sinn’est pas une puissance de deux, il faut alors remarquer que la complexité en temps de l’al-gorithme ne sera pas plus grande que la complexité de trier m >néléments où cette fois mest une puissance de deux. En effet, on peut toujours, avant le tri, ajoutermnéléments fictifs arbitrairement grand en fin de tableau. Après le tri, lesnpremiers éléments du tableau seront les éléments d’origine et triés. Or il existe toujours une puissance de deuxm[n,2n[, par exemple en choisissantm= 2dlog2ne

. La complexité du tri est alorsO(mlogm) =O(nlogn). NB : Cette discussion concerne l’analyse de la com-plexité. En pratique, il est évidemment hors de question, d’ajouter des éléments dans le cas oùnn’était pas une puissance de deux.

5. On insère chaque élément à sa place dans le début du tableau comme le tri d’un jeu de cartes.

6. On échange les éléments qui ne sont pas dans le bon ordre.

7. On range les éléments par rapport à un pivot, et on recommence dans chaque partie.

5.2. TROUVER LA PAIRE DE POINTS LES PLUS PROCHES 167