Chapitre 4 : La Méthode Diviser et Régner 1. Introduction La stratégie diviser-et-régner consiste : a. à briser un problème en sous-problèmes plus simples de même type, b. à trouver d’une manière indépendante la solution de chacun de ces sous-problèmes c. ensuite à fusionner les résultats obtenus pour apporter une solution au problème posé. Il s'agit donc d'une démarche essentiellement récursive. Les algorithmes de ce type apparaissent comme composés de deux algorithmes; le premier partage le problème en sous-problèmes, le second algorithme fusionne les résultats partiels en le résultat global. Cette approche conduit, de façon naturelle (mais pas nécessairement), à un algorithme récursif. Seulement, la question qui pourrait se poser est de savoir comment obtenir la solution des sousproblèmes. La réponse est d’appliquer la même approche diviser-pour-regner descendante et ce, jusqu’à ce que le problème deviennent trivial. Il est utile de signaler que pour que l’approche diviser-et-regner puisse conduire à un algorithme récursif, il est nécessaire que les sous-problèmes résultants soient de même type au problème initial. Si ce n’est pas le cas, on peut quand même considérer qu’on utilise une approche diviseret-régner, mais sans récursivité. Le graphe résultant de cette technique est comme suit : 1 Problème P1 Sous-problème P11 Sous-problème P111 Sous-problème P12 Sous-problème P112 Sous-problème P1k Sous-problème P1k1 Sous-problème P1k2 Sous-solution P1k Sous-soulition P11 Solution problème P1 2 2. Déterminer le moment de cesser les appels recursifs Dans la mise en oeuvre directe de la stratégie diviser-et-régner avec récursivité, on cesse les appels récursifs (la décomposition en sous-problèmes) lorsque le problème à résoudre est vraiment trivial, c’est-à-dire qu’il ne peut plus être décomposé ou qu’il n’est plus intéressant de le décomposer encore (cela est vrai pour les problèmes typiquement de taille 1). En théorie les surcoûts (overhead) associés à la gestion des appels récursifs (par exemple, l’allocation et copie des arguments de la fonction dans la pile) peuvent être ignores. En pratique, ces surcoûts peuvent devenir significatifs et il peut devenir plus efficace, à partir d’une certaine taille de problème, d’utiliser une approche asymptotiquement moins efficace, mais avec un coefficient plus faible au niveau des surcoûts. La stratégie diviser-et-régner avec seuil (avec coupure) devient donc, en gros, la suivante (en supposant une décomposition dichotomique) : L’algorithme générique de diviser-etrégner peut être résumé come suit : Solution resoudre_dpr( Probleme p ) { if (taille(p) <= TAILLE_MIN) return( resoudre_algo_simple(p) ); // Solution non-récursive else { // décompser le problème p en sous-problèmes p1, p2, …, pk; (p1, p2, …pk) = decomposer(p); // Appels récursifs solution s1 = resoudre_dpr(p1); solution s2 = resoudre_dpr(p2); // etc … solution sk = resoudre_dpr(pk); return( combiner(p, p1, p2,.., pk) ); } La sélection de la valeur seuil (threshold) à partir de laquelle la récursivité doit se terminer dépend de nombreux facteurs, comme par exemple : • Mise en oeuvre exact de l’algorithme. • Langage et compilateur utilises. • Machine et système d’exploitation sur lesquels s’exécute le programme. • Données sur lesquelles le programme est exécuté. En d’autres mots, l’analyse théorique de l’algorithme ne suffit plus. On doit plutôt utiliser diverses techniques et outils plus pratiques, par exemple, on pourrait exécuter le programme avec différentes données et mesurer son temps d’exécution, utiliser l’outil profile (sur Unix/Linux) pour déterminer les fonctions et procédures les plus fréquemment appelées et déterminer les points chauds du programme, etc. 3 3. Diviser et régner et la récursivité : Pour que l’approche diviser-et-régner avec récursivité conduise à une solution efficace, il ne faut pas que l’une ou l’autre des conditions suivantes survienne : 1. Un problème de taille n est décomposé en deux ou plusieurs sous-problèmes eux- même de taille presque n (par exemple de taille n − 1). 2. Un problème de taille n est décomposé en n sous-problèmes de taille n/c (pour une constante c ≥ 2). Rappelons aussi que l’approche diviser-et-régner avec recursivité ne peut être utilisée que si les sous-problèmes sont de même type que le problème initial (par exemple, le tri d’un tableau se fait en triant ses sous-tableaux). Toutefois, dans de nombreux cas, on peut dire qu’une approche diviser-pour-regner est utilisée et ce, même si aucune récursivité n’est nécessaire. Dans le reste de ce chapitre, il sera question de l’application de cette méthode sur des exemples divers afin de mieux l’apprécier et voir également comment approcher la résolution d’un problème d’une manière récursive 4. Applications 4.1. Problème de recherche binaire : le problème de la recherche dichotomique consiste à rechercher un élément dans un tableau de n éléments, procédant de la manière suivante : 1. on partitionne le tableau en deux parties égales 2. si l‘élément du milieu est celui qu’on cherche, on arrête la recherche 3. sinon, si l‘élément du milieu est supérieur à celui qu’on cherche, alors, comme le tableau est déjà trié, ce dernier ne peut être que dans la partie gauche de l’élément du milieu 4. autrement l‘élément du milieu est inférieur supérieur à celui qu’on cherche. Dans ce cas, ce dernier ne peut être que dans la partie droite de l’élément du milieu. Le cas de base est celui où l’espace de recherche est nul. Remarque : Nous avons discuté, dans le premier chapitre, une autre méthode pour résoudre ce problème : la méthode séquentielle. Cette méthode ignore le fait que les éléments du tableau sont déjà triées. Elle consiste donc à balayer le tableau du début à la fin jusqu’à éventuellement trouver l’élément en question. Nous avons vu que la complexité de cette méthode, dans le pire cas et cas moyen, est en O(n). Implantation int recherche(int *tab, int c,s,u){ if (sup < inf) return(‘erreur’) else { 4 milieu = (s-u+1) div 2; if (c = = tab[milieu]) return(milieu); else if (c < tab[milieu]) return( recherche(tab, c, milieu –1,u); else return( recherché(tab,c,s,milieu+1); } Complexité: Voyons de plus prés le pire cas: si le paramètre n est pair, il y a respectivement n/2 éléments précédant et (n/2 –1) éléments suivant l’élément du milieu. En revanche, si n est impair, il a (n-1)/2 éléments précédant et suivant l’élément du milieu. Autrement dit, dans le pire, l’espace de recherche est réduit à chaque itération à n / 2 éléments. Si t(n) représente la complexité , alors nous avons : 1; n = 1 t ( n) = t n / 2 + b, n > 1 On a vu au chapitre 2 que la solution de cette équation est : t (n) = log n + b Concernant la complexité en moyenne, en principe, nous l’avons discutée lors du chapitre 2 et des travaux dirigés. Je vous suggère de la refaire tout maintenant. 4.2.Problèmes de tri Dans cette section, deux méthode de tri sont présentées pour illustrer la technique de diviser-etrégner. Rappelons, avant, la définition du problème de tri. Définition: Le problème de tri consiste à ordonner un ensemble d’éléments dans un ordre croissant ou décroissant selon une clef propre à ces éléments muni d’un ordre total. 4.2.1. Le tri par fusion Cet algorithme fournit un exemple clair de cette stratégie. L'algorithme fonctionne comme suit : - on partitionne la liste en deux demi-listes; - on trie chaque demi-liste; - on fusionne les deux demi-listes triées. Le cas de base de l'algorithme est celui où la liste à trier ne comporte qu'une clé. L'écriture de l'algorithme dépend de la structure informatique choisie pour représenter les données; nous supposons que l'on représente les listes par des tableaux. On pourrait tout aussi bien utiliser des listes chaînées. Dans une première version qui est un pseudo-algorithme on 5 fusionne deux listes triées en une troisième. Ceci a l'inconvénient de créer des listes en pagaille et par une transformation de programme on crée la liste fusionnée en place, moyennant l'utilisation d'une liste auxiliaire. en pratique il suffit de pouvoir fusionner deux listes triées représentées par des sous-intervalles d'un tableau, ces sous-intervalles étant contigus de la forme [i,j[ et [j,k[. Remarque : Cet algorithme divise en deux parties égales le tableau de données en question. De plus, la fusion doit tenir compte du fait que ces parties soient déjà triées. Implantation void mergesort(Elem* array, Elem* temp, int left, int right) { int i, j, k, mid = (left+right)/2; if (left == right) return; mergesort(array, temp, left, mid); // la première moitié mergesort(array, temp, mid+1, right);// Sort 2nd half // l’opération de fusion. Premièrement, copier les deux moitiés dans temp. for (i=left; i<=mid; i++) temp[i] = array[i]; for (j=1; j<=right-mid; j++) temp[right-j+1] = array[j+mid]; // fusionner les deux moités dans array for (i=left,j=right,k=left; k<=right; k++) if (key(temp[i]) < key(temp[j])) array[k] = temp[i++]; else array[k] = temp[j--]; } 6 Complexité: La complexité est donnée par la relation suivante: n étant le nombre d’éléments dans le tableau. Exercice: pourquoi les fonctions plancher et plafond comme paramètres dans T(.)? Question : Peut-on parler des trois différentes complexités pour cet algorithme? pour un Dans le but de simplifier la résolution de l’équation 15.10, nous supposons que entier k ≥ 0 . En remplaçant O(n) par n, on obtient (en principe, on doit la remplacer par cn): 7 La résolution par substitution donne ce qui suit : : La complexité temporelle de tri par fusion est donc en O(n log n) . B. Le tri rapide La stratégie de l’algorithme de tri rapide (quicksort) consiste, dans un premier temps, à diviser le tableau en deux parties séparées par un élément (appelé pivot) de telle manière que les éléments de la partie de gauche soient tous inférieurs ou égaux à cet élément et ceux de la partie de droite soient tous supérieurs à ce pivot (dans l’algorithme donné ci-dessus, la partie qui effectue cette tâche est appelée partition). Ensuite, d’une manière récursive, ce procédé est itéré sur les deux parties ainsi crées. Notez que qu’au départ, le pivot est choisi dans la version de ci-dessus, comme le dernier élément du tableau. Implantation void tri_rapide_bis(int tableau[],int debut,int fin){ if (debut<fin){ int pivot=partition(tableau,debut,fin); tri_rapide_bis(tableau,debut,pivot-1); tri_rapide_bis(tableau,pivot+1,fin); } } void tri_rapide(int tableau[],int n) { tri_rapide_bis(tableau,0,n-1); } void echanger(int tab[], int i, int j) { int memoire; memoire=tab[i]; tab[i]=tab[j]; tab[j]=memoire; } 8 int partition(int tableau[], int deb, int fin){ int pivot = tableau[fin]; int i = debut ; j = fin; do{ do i++ while (tableau[i] < pivot) do j-while (tableau[j] > pivot) if (i < j) echanger (tableau,i,j) } while(i < j) tableau[deb] = a[j]; tableau[j] = pivot; return(j) } Choix du pivot : Le choix idéal serait que ça coupe le tableau exactement en deux parties égales. Mais cela n’est pas toujours possible. On peut prendre le premier élément. Mais il existe plusieurs autres stratégies! Partitionnement : on parcourt le tableau de gauche à droite jusqu'à rencontrer un élément supérieur au pivot on parcourt le tableau de droite à gauche jusqu'à rencontrer un élément inférieur au pivot 9 on échange ces deux éléments et on recommence les parcours gauche-droite et droite-gauche jusqu'à a avoir : il suffit alors de mettre le pivot à la frontière (par un échange) Exemple [26 5 37 1 61 11 59 15 48 19] [11 5 19 1 15] 26 [59 61 48 37] [ 1 5] 11 [19 15] 26 [59 61 48 37] 1 5 11 [19 15] 26 [59 61 48 37] 1 5 11 15 19 26 [59 61 48 37] 1 5 11 15 19 26 [48 37] 59 [61] 1 5 11 15 19 26 37 48 59 [61] 1 5 11 l5 l9 26 37 48 59 10 6l Complexité À l’appel de QSORT (1,n), le pivot se place en position i. Ceci nous laisse avec un problème de tri de deux sous parties de taille i-1 et n-i.. L’algorithme de partition clairement a une complexité au plus de cn pour une constante c. T (n) = T (i − 1) + T (n − i ) + cn T (1) = 1 Voyons les trois cas possibles de complexité: Cas défavorable Le pivot est à chaque fois le plus petit élément. La relation de récurrence devient T(n) = T(n – 1) + cn (pourquoi ?) T(n -1) = T(n - 2) + c(n - 1) T(n - 2) = T(n - 3) + c(n - 2) ... ……………. T(2) = T(1) + c(2) En ajoutant membre à membre, on obtient : T(n) = O( n 2 ) 11 Cas favorable Dans le meilleur des cas, le pivot est, à chaque fois, situé au milieu de la parti à trier. T(n) = 2T(n/2) + cn Ce développement s’arrête dès qu’on atteint T(1). Autrement dit, dès que n / 2k = 1 n = 2 k ⇒ k = log n Solution finale: T(n) = O(n log n) Question : déterminer la relation de récurrence exacte de la complexité dans ce cas de figure et résoudre l’équation qui en résulte à l’aide de la méthode de l’équation secondaire? Complexité dans le cas moyen Nous savons que t (n) = t (i − 1) + t (n − i ) + cn (voir plus haut). Supposons que toutes les permutations des éléments du tableau soient équiprobables. Autrement dit, la probabilité que la case i soit choisie comme pivot est 1/n. Comme i peut varier entre 1 et n, alors on obtient facilement la récurrence suivante: t (n) = cn + 1 n ∑ t (i − 1) + t (n − i) n i =1 12 Il n’est pas difficile de constater que cette équation ne peut être résolue par l’une des méthode discutées au chapitre 2. Néanmoins, on peut contourner cette difficulté comme suit : Comme n n i =1 i =1 n 2 ∑ t (i − 1) = ∑ t (n − i) , on obtient la relation: t (n) = cn + n ∑ t (i) qui peut s’écrire aussi: i =1 n (1) nt (n) = cn 2 + 2∑ t (i ) i =1 qui est aussi vraie pour n+1. Autrement dit, (2) n +1 (n + 1)t (n + 1) = c(n + 1) 2 + 2∑ t (i ) i =1 en soustrayant (1) de (2), on obtient: (n + 1)t (n + 1) − nt (n) = c(2n + 1) + 2t (n + 1) (n − 1)t (n + 1) − nt (n) = c(2n + 1) 2 1 (2n + 1) t (n + 1) t (n) = c( − ) − =c n(n − 1) n −1 n n n −1 Cette équation est aussi vraie pour n, n-1, n-2, …, 4 t (n) t (n − 1) 2 1 − ) = c( − n −1 n − 2 n − 2 n −1 t (n − 1) t (n − 2) 2 1 − = c( − ) n−2 n−3 n−3 n−2 t (n − 2) t (n − 3) 2 1 − = c( − ) n−3 n−4 n−4 n−3 ……… t (3) t (2) 2 1 − = c( − ) 2 1 1 2 En additionnant membre à membre pour n, n-1,…., on obtient après simplifications: t ( n ) t ( 2) 1 1 1 − = c + + ... + 1 + c1 − n −1 1 n−2 n−3 n −1 13 Or, on sait que : log(n − 2) < 1 + 1 1 + ... + < log(n − 1) 2 n−2 on obtient donc : 1 t (n) < c(n − 1) log(n − 1) + 1 − + t (2) n −1 t(2) étant une constante, on obtient : t (n) = O(n log n) . 4.3. Multiplication de matrices On sait que chaque élément de la matrice C = A × B de dimension n par n est donnée par l’expression ci-dessous. Ce produit est une matrice de dimension n par n. n C ij = ∑ Aik × Bkj k =1 Comme la matrice C possède donc n2 éléments, il est clair que le nombre de multiplications engendrées en effectuant ce produit comme ci-dessus est en n3. Maintenant, essayons de subdiviser la matrice A et B en sous matrices de dimension n/2 par n/2 comme suit : . Les éléments C11 , C12 , C 21 et C 22 , en utilisant la même formule que ci-dessus sont donnés comme suit : C11 = A11 × B11 + A12 × B21 C12 = A11 × B12 + A12 × B22 C 21 = A21 × B11 + A22 × B21 C 22 = A21 × B12 + A22 × B22 14 Même en procédant comme ci-dessus, en fait, rien ne change sur le plan de la complexité, puisque le nombre de multiplications reste toujours en n 3 . Exercice : montrer que la complexité temporelle de l’algorithme utilisant cette subdivision d,une manière répétitive est en O( n 3 ). Maintenant procédons d’une autre manière, en calculant avant les sept produit suivant. Il est facile de montrer que nous avons bien De cette manière, il est facile de constater que seule 7 multiplications de matrice de dimension n/2 par n/2 sont utilisées au lieu de 8 comme c’est le cas dans la stratégies de ci-dessus. L’algorithme ci-dessous utilise deux fonctions: addmat pour additionner deux matrices et submat pour soustraire deux matrices. Il est clair que la soustraction et l’addition de deux matrice se fait en O( n 2 ). void funtion prodmat(*matrice A,B; int N) { if N = 1 prodmat = A * B; // multiplication de deux nombres else { découper en 4 A la matrice A et 4 B la matrice B; P1 = prodmat(A11, submat(B12,B22,N/2); P2 = prodmat(addmat(A11,A12,N/2), B22,N/2); …………….. P7 = prodmat(submat(A11,A21,N/2),addmat(B11,B12,N/2),N/2); C11 = submat(Addmat(Addmat(P4,P5,N/2),P6,N/2),P1,N/2); …….. ………….. …………………. C22 = submat(Addmat(Addmat(P1,P5,N/2),P7,N/2),P3,N/2); } } 15 Complexité de l’algorithme : la complexité de l’algorithme utilisant ce procédé de calculs est n log 7 log 2 = n 2.81 Preuve : Soit t(n) la complexité de la fonction prodmat. Cette fontion fait 7 appels à elle-même, mais en dimension n/2; et 18 appels à addmat et submat en dimension n/2. Le fait d’appeler ellemême en dimension n/2 correspond à t(n/2). Comme on l’a remarqué ci-dessous, la complexité d’une addition/soustraction de matrice se fait en O( (n / 2) 2 ). Autrement dit, t(n) est donné par la relation suivante : b; n = 1 t ( n) = 2 7t (n / 2) + an , n > 1 Résolvant cette équation par l’une des méthodes du chapitre 2, en supposant que n est une puissance de 2, on obtient : t (n) = O(7 log 2 n ) = O(n log 2 7 ) = O(n 2.81 ) 4.4. Multiplication de grand nombres Alors que la multiplication élémentaire ne coûte guère plus chère qu’une addition dans la plupart des ordinateurs, il n’en est plus de même lorsque les opérandes deviennent de plus en plus grands. Il n’est pas difficile de voir que l’algorithme classique (qu’on nous appris à l’école primaire) se fait en temps quadratique. Il en est de même de l’algorithme de multiplication à la russe (documentez-vous sur cet algorithme pour plus de détails). Dans cette section, nous allons voir qu’il est possible en effet de faire mieux que cette complexité. Avant de procéder, prenons l’exemple suivant pour illustrer la méthode de diviser-et-régner que nous présentons plus loin: Proposons-nous de faire la multiplication de 981 par 1234. Avant, mettons à la même grandeur ces deux nombres, soit : 0981 et 1234. Ensuite, on divise ces nombres en deux parties. Ainsi : 0981 va donner w = 09 et x = 81 1234 va donner y = 12 et z = 34 Par conséquent, le produit est calculé comme suit : 981 × 1234 = (10 2 w + x)(10 2 y + z ) 981 × 1234 = 10 4 wy + 10 2 ( wz + xy ) + xz 981 × 1234 = 1080000 + 127800 + 2754 = 1210554 16 Comme on peut le constater, nous avons effectuer 4 multiplications de parties, soit: wy, wz, xy et xz. Considérons maintenant le produit suivant : r = ( w + x) × ( y + z ) = wy + ( wz + xy) + xz Après une seule multiplication des parties, on obtient la somme de toutes les parties pour calculer notre produit. Cela nous amène au calcul suivant : p = wy = 09 × 12 = 108 q = xz = 81 × 34 = 2754 r = ( w + x) × ( y + z ) = 90 × 46 = 4140 Et finalement, on a ce qui suit : 981 × 1234 = 10 4 p + 10 2 (r − p − q) + q 981 × 1234 = 1080000 + 127800 + 2754. Autrement dit, la multiplication de 981 par 1234 peut être réduite à 3 multiplications de deux nombres ( 09 × 12 , 81× 34 et 90 × 46 ) ensemble avec des un certain nombre de décalages (puissance de 10), des additions et des soustractions. Il est utile de signaler que pour les grands nombres, le coût des additions, soustractions et décalages devient négligeable devant celui d’une multiplication. Il apparaît donc la réduction de quatre multiplications à trois multiplications nous permet de gagner 25% de temps nécessaire à la multiplications de grands nombres. L’algorithme diviser-et-régner suggère donc de séparer chacun des deux nombres u et v à multiplier en deux section aussi égales que possible : u = 10 s w + x et v = 10 s y + z. Bien entendu, n nous avons bien 0 ≤ u , z < 10 s et s = 2 Le produit qui nous intéresse est donc : u × v = 10 2 s wy + 10 s ( wz + xy ) + xy Bien entendu, comme on l’a vu plus haut, si on ne fait attention, on obtient la fonction suivante : long int function multip(int u,v){ n = plus petit entier telle que u et v soient de taille n 17 si n petit alors { multiplier u et v par l’algorithme classique return le résultat } sinon { s = n div 2; w = u div 10s; x = u % 10s; y = v div 10s; z = v % 10s; return(multip(w,y) × 102s + (multip( w,z)+multip(x,y))) × 10s +multip(x,z)) } } //fin de la fonction Si on procède comme cela, il n’est pas difficile de montrer que la complexité de cette fonction est O(n2). L’astuce nous permettant d’aller plus rapidement consiste, comme nous l’avons vu précédemment, de calculer wy, wz + xy et xy en faisant moins de 4 multiplications de demilongueur, quitte à faire plus d’additions/soustractions. Soit donc le produit : r = ( w + x) × ( y + z ) = wy + ( wz + xy) + xz Cette astuce suggère donc la fonction suivante : long int function multip(int u,v) { n = plus petit entier telle que u et v soient de taille n si n petit alors { multiplier u et v par l’algorithme classique return le résultat } sinon { s = n div 2; w = u div 10s; x = u % 10s; y = v div 10s; z = v % 10s; q = multip(x,z); r = multip(w+x,y+z); p = multip(x,z); return(p × 102s + (r – p-q) × 10s +q } } //fin de la fonction 18 Complexité de cette fonction : on fera ce calcul en classe !!! on doit trouver la solution : t (n) = O(n log 3 ) = O(n1.59 ) 4.5. Problème du maximum et minimum Le problème consiste à déterminer le minimum et le maximum d’un tableau de n éléments. Supposons que le nombre de comparaisons entre les éléments du tableau est pris comme unité pour mesurer la complexité d’un algorithme. Une solution facile consiste à balayer tous les éléments pour trouver le minimum et une deuxième fois pour trouver le maximum. À chacune des deux fois, il est facile de voir que (n-1) comparaisons sont effectuées. Par conséquent, cet algorithme va induira 2(n-1) = 2n-2 comparaisons. L’approche diviser-et-régner pour ce problème consiste à diviser le tableau en deux parties plus de n/2 éléments (nous pouvons supposer que n est une puissance de 2). L’algorithme va alors trouver le maximum et le minimum pour chacune des deux parties, d’une manière récursive. Ensuite, une fois cette partie terminée, on détermine le minimum et le maximum global en comparant entre eux les deux maximums et les deux minimums ainsi trouvés. void minimax(tab S; int i,j,max,min) \{ int min1,min1, max1, max2, milieu; if (i-j = 0) { // un seul élément min = S[i]; max = S[i]; } else if (i-j ==1) {// deux elements if S[i] < S[j] { min = S[i]; max = S[j]; } else { min = S[j]; max = S[i]; } } // fin du if else { milieu = (i+j-1) div 2; minimax(S,i,milieu,min1,max1); minimax(S,milieu+1,j,min2,max2); if min1 > min2 min = min2; else min = min1; if max1 < max2 max = max2; else max = max1; 19 } t(n), le nombre de comparaisons l’algorithme ci-dessus, est alors comme suit : 1; n = 2 t ( n) = 2t (n / 2) + 2, n > 2 On obtient alors la solution suivante: t (n) = 3n / 2 − 2 . Exercice: obtenez la relation de récurrence exacte de la fonction ci-dessus. Ensuite, résoudre cette équation à l’aide de la méthode d’induction. 20