Complexité algorithmique • De quoi dépend le temps d'exécution d'un programme ? Surtout du nombre d'instructions machine effectivement exécutées, lui-même déterminé par : ... les données à traiter, surtout le volume de ces données ... le code source, surtout les algorithmes utilisés • Pour évaluer l'efficacité en temps de calcul d'un algorithme donné, on veut estimer le nombre d'instructions exécutées en fonction du volume des données • Exemple (à discuter) : public class Pg1 { public static void main(String[] args) { printTable(7); // 1x (?) } private static void printTable(int n) { int i,j; // 1x (?) for(i=1; i<=n; i++) { // 1x; 8x; 7x for(j=1; j<=n; j++) // 7x; 56x; 49x System.out.print(" " + i*j); // 49x; 49x; 49x System.out.println(); // 7x } } // total = 284 instructions } // (plus le "détail" (!!) de print) // 5n2 + 5n + 4 n2 ® dépend de n2 -8- Compter les instructions à exécuter • On s'intéresse au temps de calcul. On suppose qu'il dépend surtout du nombre d'instructions à exécuter • Soit n défini comme la "taille du problème" (p. ex. la taille du tableau à trier) • Pour simplifier la discussion, on supposera que les "instructions élémentaires" sont toutes équivalentes en temps de calcul • Comment calculer le nombre I(n) d'instructions du programme en fct de n • Séquences d'instructions : I(n) = Ia(n) + Ib(n) + Ic(n) { } a; b; c; • Boucles, où a est le nbre d'itérations (de passages effectués) while(test) { corps; } • Appels de méthodes (a+1) *(Itest (n)) I(n) = + a I(n) = 1 + Icorps *(Icorps(n)) de la méthode(n) meth(); -9- Ordre de grandeur, notation O(...) • Le nombre exact d'instructions est peu informatif On cherche à caractériser la croissance de la fonction quand n varie • Pour de grandes valeurs de n, une fonction linéaire finit toujours par être inférieure à une fonction quadratique • (5n2+n) et (4n2+3n) (50n+8) sont de la même famille (fonctions quadratiques) est d'une "meilleure" famille (fonction linéaire) • Ce qui compte, c'est donc la partie dominante ! On ne retient que le terme de plus haut degré, et on ignore les coefficents constants • Exemple : Nombre d'instruction Partie dominante I(n) = 5n2 + 3n + 127 I(n) est en O(n2) • On dit que I(n) est du même ordre de grandeur que f(n) et on note I(n) est en O(f(n)) si $ c, n0 tq " n ³ n0, I(n) £ cf(n) "… finit par être majoré par un multiple de …" • Exemples : 3n3 + 2n + 10 2n + 300n200 log10(n) + 212 0.0001n + log(n) est est est est en en en en O(n3) O(2n) O(ln(n)) O(n) • Sur un ordinateur plus puissant, un algorithme quadratique s'exécutera peut-être 5 fois plus rapidement.... mais il restera quadratique ! - 10 - • Evolution de quelques fonctions de complexité pour des tailles de problèmes... petites plus grandes - 11 - Estimation ou vérification empirique • Soit un programme P qui est censé dépendre du nombre n de données • On peut lancer P avec des données différentes et mesurer les temps de calcul (10, 50, 100, 1000), (T10, T50, T100, T1000) • Pour savoir si Tn est en O(f(n)), on teste si (Tn / f(n)) semble constant • Exemple : Soit Tn = 3n2 +48n +131; Pour m suffisamment grand, (Tm / m2) @ 3 Tn est donc en O(n2) • Mesurer un intervalle de temps en Java : // in class System : static long currentTimeMillis() long ta, tb; ta = System.currentTimeMillis(); doSomething(); tb = System.currentTimeMillis(); System.out.println("Elapsed time = " + (tb-ta)); • Granularité (ms, ns) : il s'en passe des choses, en une milliseconde.... • Quand un programme fait des entrées/sorties, l'hypothèse que toutes les instructions se valent n'est vraiment pas vérifiée • L'analyse de complexité peut porter sur n'importe quelle ressource (p. ex RAM), pas seulement le temps de calcul CPU. - 12 - Exemple • Ici, il semble que l'algorithme soit en O(n ln n) • Attention à la fiabilité des mesures. Deux exécutions successives ne présentent pas forcément le même temps de calcul. Pourquoi ? - 13 - "Au pire des cas", "Au meilleur des cas", "Dans le cas moyen" • Il n'y a pas que des séquences, et des boucles avec un nombre précis de passages ! Attention aux instructions conditionnelles... • Suivant les données, on peut tomber dans différentes instructions... n = myTab.length; if (n % 4 != 0){ a = 0; } else { for(int i=0; i<n; i++) { a = a + i; } } • Ici, on fait un "long" traitement seulement si n est divisible par 4 • Complexité... au pire des cas au meilleur des cas cas moyen O(n) O(1) ¼ O(n) + ¾ O(1) = O((3+n)/4) = O(n) • C'est surtout le "pire des cas" qui nous intéresse (il donne une garantie) • On parle de cas moyen si on fait une moyenne de tous les inputs possibles (ici toutes les valeurs possibles de n). • Définir et calculer la complexité théorique dans le cas moyen n'est pas toujours facile. Parfois, c'est même impossible - 14 - Classement des complexités • Le plus souvent, les complexités tombent dans une des catégories suivantes, classées dans l'ordre : - O(1) - O(ln n) - O(n) constante logarithmique linéaire - O(n ln n) O(n2) O(n3) O(2n) quasi-linéaire quadratique cubique exponentielle • Quand tout est "séparable", les règles sont assez simples : - séquence d'opérations - boucles imbriquées - conditions (if) « additionner les complexités « multiplier les complexités « pire des cas (ignorer le cas favorable) • Mais on ne peut pas toujours analyser les complexités "par morceaux" int i, j, sum=0; for(i=0; i<n; i++) { for(j=0; j<i; j++) sum++; } • Outils mathématiques utilisés : progressions... analyse combinatoire, statistiques... void f(boolean[] t) { int n=t.length; for(int i=0; i<n; i++) while(i<n && t[i]) i++; } 1+2+3+...+n = n*(n+1)/2 est en O(n2) - 15 - Etude de cas 1 : Recherche de sous-séquence maximale • Soit un tableau d'entiers (positifs et négatifs) Problème : trouver la sous-séquence de somme maximale (il y a une sous-séquence spéciale, de longueur nulle, dont la somme == 0) • Exemple : Résultat : {-2, 11, -4, 13, -5, 2} 20 {1, -3, 4, -2, -1, 6} 7 Algorithme 1, en O(n3) • Idée : Tester chaque suite qui commence à la position i et se termine en j public static int maxSubSum1(int[] a){ int maxSum = 0; for(int i=0; i<a.length; i++) // variante algo no 2 : for(int j=i; j<a.length; j++){ int thisSum = 0; int thisSum = 0; for(int j=i;j<a.length;j++){ for(int k=i; k<=j; k++) thisSum += a[j]; thisSum += a[k]; if(thisSum > maxSum) if(thisSum > maxSum) maxSum = thisSum; maxSum = thisSum; } } return maxSum; } Algorithme 2, en O(n2) • Idée : Tester chaque suite qui commence à la position i • Une addition pour passer de {¼, 5,-1,8,4,¼} à {¼, 5,-1,8,4,¼} - 16 - • Les 2 algorithmes précédents sont dits de force brute (brute-force) : ils testent toutes les combinaisons possibles Algorithme 3, en O(n) • Idée : ne tester que des suites qui ne commencent pas par une suite de somme négative. Exemple : {¼, 5,-7,8,4,¼} inutile de tester les suites plus grandes partant de 5 ! public static int maxSubSum3( int[] a ) { int maxSum = 0; int thisSum = 0; for(int j = 0; j< a.length; j++ ) { thisSum += a[j]; if( thisSum > maxSum ) maxSum = thisSum; else if( thisSum < 0 ) { thisSum = 0; // resets the start index ! } } return maxSum; } • Encore faut-il se convaincre que l'algorithme est toujours correct. Penser aux cas dégénérés (tous nuls, tous négatifs, tableau de taille 1 ou 0...) • Si cet algo vous semble évident, sachez qu'on l'a longtemps cherché... ... ou trouvez l'équivalent à 2 dimensions (on cherche toujours) - 17 - Etude de cas 2 : Recherche d'élément dans un tableau • Problème : tester si un tableau contient un élément donné Algorithme 1, pour un tableau quelconque (trié ou non), en O(n) public static boolean isPresent(int[] a, int x) { for(int i=0; i<a.length; i++) if(a[i] == x) return true; return false; } • Cet algorithme est appelé la recherche séquentielle Algorithme 2, pour un tableau trié, en O(ln n) • Idée : Tester si l'élément recherché est - celui du milieu (on l'a trouvé) - dans la moitié gauche (on restreint l'intervalle possible) - dans la moitié droite (on restreint l'intervalle possible) ou si l'intervalle possible est vide (on ne l'a pas trouvé) • Cet algorithme, très utilisé, est appelé recherche binaire ou recherche dichotomique ("qui divise en deux") - 18 - • Codage en Java : public static boolean binarySearch(int[] t, int x) { int low = 0; int high = t.length - 1; int mid; {1, 3, 5, 6, 7, 8, 9} 4? while( low <= high ) { {1, 3, 5, 6, 7, 8, 9} 4? mid = (low + high) / 2; if(t[mid] < x) {1, 3, 5, 6, 7, 8, 9} 4? low = mid + 1; else if( t[mid] > x) {1, 3, 5, 6, 7, 8, 9} 4? high = mid - 1; NON ! else return true; } return false; (trouvez le petit bug !... cf. Java in 2006) } • Cet algorithme en en O(ln n) : le temps de calcul est proportionnel au logarithme de la taille du tableau • La meilleure complexité serait O(1), c'est-à-dire que le temps de calcul est constant, ne dépend pas du tout de la taille du tableau. Il n'y a pas d'algorithme en O(1) pour ce problème - 19 - Complexité logarithmique • Dans l'algorithme de recherche binaire, au départ, l'intervalle des cases à chercher, c'est tout le tableau, soit n cases. • Après une étape, cet intervalle ne contient plus que (n-1)/2 cases • Après deux étapes, la taille de cet intervalle est de nouveau divisé par 2, etc • A la fin, il ne reste plus qu'une case (ou même aucune) • Combien d'étapes a-t-il fallu ? • Le logarithme, c'est l'opération inverse de l'élévation à la puissance 2 * (2 * (2* (2 * (x)))) = 24 * x • Et donc : int x = n; while (x > 1) x = x / 2; // la boucle sera exécutée log2(n) fois • La fonction logarithme est monotone croissante... Mais à long terme, elle grandit de moins en moins. En pratique, elle finit par être presque constante. • Conséquence : avec la recherche dichotomique, on peut traiter pratiquement n'importe quelle taille de problème ! Mais pas avec la recherche séquentielle - 20 - Complexité temporelle vs spatiale • L'analyse de complexité peut porter sur n'importe quelle ressource, pas seulement le temps de calcul CPU. • Complexité temporelle = en temps de calcul CPU • Complexité spatiale = en espace mémoire (on mesure le "pic" de consommation) • Un algorithme peut demander O(n2) en CPU, et O(n) en mémoire. Exemple : int i,j; CharStack s = new CharStack(); for(i=0; i<n; i++) { for(j=0; j<i; j++) s.push( (char)i); while(!s.isEmpty()) s.pop(); } int[] t, u, v; int i,j; t=new int[n]; for (int i=0;i<n;i++) { u=new int[n]; for(j=0; j<n; j++) v=new int[100]; } Remarque sur la complexité de push() • Dans notre implémentation avec tableau, on double le tableau s'il est plein. On peut donc affirmer que, de temps en temps, l'opération push() ne demande pas un temps constant, mais linéaire • On va ici ignorer ce cas, qui arrive "rarement" • A voir en 2ème année (complexité amortie)... - 21 -