Big O, comment calculez-vous / approximez-vous cela?

881

La plupart des personnes ayant un diplôme en CS certainement savoir ce que signifie Big O pour . Il nous aide à mesurer l'efficacité d'un algorithme.

Mais je suis curieux, comment calculez- vous ou approximez-vous la complexité de vos algorithmes?

sven
la source
4
Peut-être que vous n'avez pas vraiment besoin d'améliorer la complexité de votre algorithme, mais vous devriez au moins être capable de le calculer pour décider ...
Xavier Nodet
5
J'ai trouvé cela une explication très claire de Big O, Big Omega et Big Theta: xoax.net/comp/sci/algorithms/Lesson6.php
Sam Dutton
33
-1: Soupir, un autre abus de BigOh. BigOh est juste une limite supérieure asymptotique et pourrait être utilisé pour n'importe quoi et n'est pas seulement lié à CS. En parlant de BigOh comme s'il y a un unique , n'a pas de sens (A algorithme en temps linéaire est O (n ^ 2), O (n ^ 3) , etc.). Dire que cela nous aide à mesurer l' efficacité est également trompeur. Aussi, quel est le lien avec les classes de complexité? Si tout ce qui vous intéresse, ce sont les techniques de calcul des durées de fonctionnement des algorithmes, en quoi est-ce pertinent?
34
Big-O ne mesure pas l'efficacité; il mesure à quel point un algorithme évolue avec la taille (il pourrait s'appliquer à autre chose que la taille, mais c'est ce qui nous intéresse probablement ici) - et cela uniquement de manière asymptotique, donc si vous n'avez pas de chance, un algorithme avec un grand "plus petit" - O peut être plus lent (si le Big-O s'applique aux cycles) qu'un autre jusqu'à ce que vous atteigniez des nombres extrêmement élevés.
ILoveFortran
4
Le choix d'un algorithme sur la base de sa complexité Big-O est généralement une partie essentielle de la conception d'un programme. Ce n'est certainement pas un cas d '«optimisation prématurée», qui est en tout cas une citation sélective abusée.
Marquis de Lorne

Réponses:

1481

Je ferai de mon mieux pour l'expliquer ici en termes simples, mais sachez que ce sujet prend quelques mois à mes élèves pour enfin comprendre. Vous pouvez trouver plus d'informations sur le chapitre 2 du livre Structures de données et algorithmes en Java .


Aucune procédure mécanique ne peut être utilisée pour obtenir le BigOh.

En tant que «livre de recettes», pour obtenir le BigOh à partir d'un morceau de code, vous devez d'abord vous rendre compte que vous créez une formule mathématique pour compter le nombre d'étapes de calcul exécutées à partir d'une entrée d'une certaine taille.

Le but est simple: comparer des algorithmes d'un point de vue théorique, sans avoir besoin d'exécuter le code. Plus le nombre d'étapes est petit, plus l'algorithme est rapide.

Par exemple, supposons que vous ayez ce morceau de code:

int sum(int* data, int N) {
    int result = 0;               // 1

    for (int i = 0; i < N; i++) { // 2
        result += data[i];        // 3
    }

    return result;                // 4
}

Cette fonction renvoie la somme de tous les éléments du tableau, et nous voulons créer une formule pour compter la complexité de calcul de cette fonction:

Number_Of_Steps = f(N)

Nous avons donc f(N), une fonction pour compter le nombre d'étapes de calcul. L'entrée de la fonction est la taille de la structure à traiter. Cela signifie que cette fonction est appelée telle que:

Number_Of_Steps = f(data.length)

Le paramètre Nprend la data.lengthvaleur. Maintenant, nous avons besoin de la définition réelle de la fonction f(). Cela se fait à partir du code source, dans lequel chaque ligne intéressante est numérotée de 1 à 4.

Il existe de nombreuses façons de calculer le BigOh. À partir de ce point, nous allons supposer que chaque phrase qui ne dépend pas de la taille des données d'entrée prend un Cnombre de pas de calcul constant .

Nous allons ajouter le nombre individuel d'étapes de la fonction, et ni la déclaration de variable locale ni l'instruction de retour ne dépendent de la taille du datatableau.

Cela signifie que les lignes 1 et 4 prennent chacune un nombre C d'étapes, et la fonction est un peu comme ceci:

f(N) = C + ??? + C

La partie suivante consiste à définir la valeur de l' forinstruction. N'oubliez pas que nous comptons le nombre d'étapes de calcul, ce qui signifie que le corps de l' forinstruction est exécuté Nfois. C'est la même chose que d'ajouter C, Nfois:

f(N) = C + (C + C + ... + C) + C = C + N * C + C

Il n'y a pas de règle mécanique pour compter le nombre de fois où le corps de l'objet forest exécuté, vous devez le compter en regardant ce que fait le code. Pour simplifier les calculs, nous ignorons les parties d'initialisation, de condition et d'incrémentation variables de l' forinstruction.

Pour obtenir le BigOh réel, nous avons besoin de l' analyse asymptotique de la fonction. Cela se fait grosso modo comme ceci:

  1. Supprimez toutes les constantes C.
  2. D' f()obtenir le polynôme dans son standard form.
  3. Divisez les termes du polynôme et triez-les selon le taux de croissance.
  4. Gardez celui qui grossit à l' Napproche infinity.

Notre f()a deux termes:

f(N) = 2 * C * N ^ 0 + 1 * C * N ^ 1

Suppression de toutes les Cconstantes et parties redondantes:

f(N) = 1 + N ^ 1

Puisque le dernier terme est celui qui grossit à l' f()approche de l'infini (pensez aux limites ), c'est l'argument BigOh, et la sum()fonction a un BigOh de:

O(N)

Il existe quelques astuces pour résoudre certaines astuces: utilisez des sommations chaque fois que vous le pouvez.

Par exemple, ce code peut être facilement résolu à l'aide de sommations:

for (i = 0; i < 2*n; i += 2) {  // 1
    for (j=n; j > i; j--) {     // 2
        foo();                  // 3
    }
}

La première chose qu'il fallait vous demander, c'est l'ordre d'exécution de foo(). Bien que l'habituel soit le cas O(1), vous devez interroger vos professeurs à ce sujet. O(1)signifie (presque, principalement) constant C, indépendamment de la taille N.

La fordéclaration sur la phrase numéro un est délicate. Pendant que l'index se termine à 2 * N, l'incrémentation se fait par deux. Cela signifie que la première forn'est exécutée que par Nétapes, et nous devons diviser le nombre par deux.

f(N) = Summation(i from 1 to 2 * N / 2)( ... ) = 
     = Summation(i from 1 to N)( ... )

La phrase numéro deux est encore plus délicate car elle dépend de la valeur de i. Jetez un œil: l'indice i prend les valeurs: 0, 2, 4, 6, 8, ..., 2 * N, et le second fors'exécute: N fois le premier, N - 2 le second, N - 4 le troisième ... jusqu'au stade N / 2, sur lequel le second forn'est jamais exécuté.

Sur la formule, cela signifie:

f(N) = Summation(i from 1 to N)( Summation(j = ???)(  ) )

Encore une fois, nous comptons le nombre d'étapes . Et par définition, chaque sommation doit toujours commencer à un et se terminer à un nombre supérieur ou égal à un.

f(N) = Summation(i from 1 to N)( Summation(j = 1 to (N - (i - 1) * 2)( C ) )

(Nous supposons que foo()c'est O(1)et prend des Cmesures.)

Nous avons un problème ici: lorsque iprend la valeur N / 2 + 1vers le haut, la sommation intérieure se termine par un nombre négatif! C'est impossible et faux. Nous devons diviser la somme en deux, étant le point central du moment iprend N / 2 + 1.

f(N) = Summation(i from 1 to N / 2)( Summation(j = 1 to (N - (i - 1) * 2)) * ( C ) ) + Summation(i from 1 to N / 2) * ( C )

Depuis le moment charnière i > N / 2, l'intérieur forne sera pas exécuté, et nous supposons une complexité d'exécution C constante sur son corps.

Désormais, les sommations peuvent être simplifiées à l'aide de quelques règles d'identité:

  1. Sommation (w de 1 à N) (C) = N * C
  2. Somme (w de 1 à N) (A (+/-) B) = Somme (w de 1 à N) (A) (+/-) Somme (w de 1 à N) (B)
  3. Somme (w de 1 à N) (w * C) = C * Somme (w de 1 à N) (w) (C est une constante indépendante de w)
  4. Somme (w de 1 à N) (w) = (N * (N + 1)) / 2

Appliquer une algèbre:

f(N) = Summation(i from 1 to N / 2)( (N - (i - 1) * 2) * ( C ) ) + (N / 2)( C )

f(N) = C * Summation(i from 1 to N / 2)( (N - (i - 1) * 2)) + (N / 2)( C )

f(N) = C * (Summation(i from 1 to N / 2)( N ) - Summation(i from 1 to N / 2)( (i - 1) * 2)) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2)( i - 1 )) + (N / 2)( C )

=> Summation(i from 1 to N / 2)( i - 1 ) = Summation(i from 1 to N / 2 - 1)( i )

f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2 - 1)( i )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N / 2 - 1) * (N / 2 - 1 + 1) / 2) ) + (N / 2)( C )

=> (N / 2 - 1) * (N / 2 - 1 + 1) / 2 = 

   (N / 2 - 1) * (N / 2) / 2 = 

   ((N ^ 2 / 4) - (N / 2)) / 2 = 

   (N ^ 2 / 8) - (N / 4)

f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N ^ 2 / 8) - (N / 4) )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - ( (N ^ 2 / 4) - (N / 2) )) + (N / 2)( C )

f(N) = C * (( N ^ 2 / 2 ) - (N ^ 2 / 4) + (N / 2)) + (N / 2)( C )

f(N) = C * ( N ^ 2 / 4 ) + C * (N / 2) + C * (N / 2)

f(N) = C * ( N ^ 2 / 4 ) + 2 * C * (N / 2)

f(N) = C * ( N ^ 2 / 4 ) + C * N

f(N) = C * 1/4 * N ^ 2 + C * N

Et le BigOh c'est:

O(N²)
vz0
la source
6
@arthur Ce serait O (N ^ 2) car vous auriez besoin d'une boucle pour lire toutes les colonnes et une pour lire toutes les lignes d'une colonne particulière.
Abhishek Dey Das
@arthur: Cela dépend. C'est O(n)nest le nombre d'éléments, ou O(x*y)xet ysont les dimensions du tableau. Big-oh est "par rapport à l'entrée", cela dépend donc de votre entrée.
Mooing Duck
1
Excellente réponse, mais je suis vraiment coincé. Comment la somme (i de 1 à N / 2) (N) se transforme-t-elle en (N ^ 2/2)?
Parsa
2
@ParsaAkbari En règle générale, la somme (i de 1 à a) (b) est a * b. C'est juste une autre façon de dire b + b + ... (a fois) + b = a * b (par définition pour certaines définitions de multiplication d'entiers).
Mario Carneiro
Pas si pertinent, mais juste pour éviter toute confusion, il y a une petite erreur dans cette phrase: "l'index i prend les valeurs: 0, 2, 4, 6, 8, ..., 2 * N". L'index i monte en fait à 2 * N - 2, la boucle s'arrêtera alors.
Albert
201

Big O donne la limite supérieure de la complexité temporelle d'un algorithme. Il est généralement utilisé en conjonction avec le traitement des ensembles de données (listes) mais peut être utilisé ailleurs.

Quelques exemples de son utilisation dans le code C.

Disons que nous avons un tableau de n éléments

int array[n];

Si nous voulions accéder au premier élément du tableau, ce serait O (1) car peu importe la taille du tableau, il faut toujours le même temps constant pour obtenir le premier élément.

x = array[0];

Si nous voulions trouver un numéro dans la liste:

for(int i = 0; i < n; i++){
    if(array[i] == numToFind){ return i; }
}

Ce serait O (n) car il faudrait tout au plus parcourir toute la liste pour trouver notre numéro. Le Big-O est toujours O (n) même si nous pourrions trouver notre numéro le premier essai et parcourir la boucle une fois parce que Big-O décrit la limite supérieure d'un algorithme (les oméga sont pour la borne inférieure et thêta pour la borne étroite) .

Lorsque nous arrivons aux boucles imbriquées:

for(int i = 0; i < n; i++){
    for(int j = i; j < n; j++){
        array[j] += 2;
    }
}

Il s'agit de O (n ^ 2) car pour chaque passage de la boucle externe (O (n)), nous devons parcourir à nouveau la liste entière, de sorte que les n se multiplient, nous laissant avec n au carré.

C'est à peine gratter la surface, mais lorsque vous arrivez à analyser des algorithmes plus complexes, des mathématiques complexes impliquant des preuves entrent en jeu. J'espère que cela vous familiarise au moins avec les bases.

DShook
la source
Grande explication! Donc, si quelqu'un dit que son algorithme a une complexité O (n ^ 2), cela signifie-t-il qu'il utilisera des boucles imbriquées?
Navaneeth KN
2
Pas vraiment, tout aspect conduisant à n temps au carré sera considéré comme n ^ 2
asyncwait
@NavaneethKN: Vous ne sera pas toujours voir la boucle imbriquée, comme les appels de fonction peuvent faire> O(1)travail eux - mêmes. Dans les API standard C par exemple, bsearchest intrinsèquement O(log n), strlenest O(n)et qsortest O(n log n)(techniquement, il n'a aucune garantie, et le tri rapide lui-même a une complexité de pire cas O(n²), mais en supposant que votre libcauteur n'est pas un idiot, sa complexité de cas moyenne est O(n log n)et il utilise une stratégie de sélection de pivot qui réduit les chances de frapper le O(n²)cas). Et les deux bsearchet qsortpeuvent être pires si la fonction de comparaison est pathologique.
ShadowRanger
95

Bien qu'il soit utile de savoir comment déterminer le temps Big O pour votre problème particulier, connaître certains cas généraux peut vous aider à prendre des décisions dans votre algorithme.

Voici quelques-uns des cas les plus courants, extraits de http://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions :

O (1) - Déterminer si un nombre est pair ou impair; à l'aide d'une table de recherche de taille constante ou d'une table de hachage

O (logn) - Recherche d'un élément dans un tableau trié avec une recherche binaire

O (n) - Recherche d'un élément dans une liste non triée; addition de deux nombres à n chiffres

O (n 2 ) - Multiplier deux nombres à n chiffres par un algorithme simple; addition de deux matrices n × n; tri à bulles ou tri par insertion

O (n 3 ) - Multiplication de deux matrices n × n par un algorithme simple

O (c n ) - Trouver la solution (exacte) au problème du voyageur de commerce en utilisant la programmation dynamique; déterminer si deux instructions logiques sont équivalentes à l'aide de la force brute

O (n!) - Résolution du problème des vendeurs itinérants via la recherche par force brute

O (n n ) - Souvent utilisé au lieu de O (n!) Pour dériver des formules plus simples de complexité asymptotique

Giovanni Galbo
la source
Pourquoi ne pas utiliser x&1==1pour vérifier la bizarrerie?
Samy Bencherif
2
@SamyBencherif: Ce serait une façon typique de vérifier (en fait, juste un test x & 1serait suffisant, pas besoin de vérifier == 1; en C, x&1==1est évalué comme x&(1==1) grâce à la priorité de l'opérateur , donc c'est en fait la même chose que le test x&1). Je pense cependant que vous avez mal interprété la réponse; il y a un point-virgule, pas une virgule. Cela ne signifie pas que vous auriez besoin d'une table de recherche pour les tests pairs / impairs, mais que les tests pairs / impairs et la vérification d'une table de recherche sont des O(1)opérations.
ShadowRanger
Je ne connais pas la revendication sur l'utilisation dans la dernière phrase, mais celui qui fait cela remplace une classe par une autre qui n'est pas équivalente. La classe O (n!) Contient, mais est strictement supérieure à O (n ^ n). L'équivalence réelle serait O (n!) = O (n ^ ne ^ {- n} sqrt (n)).
conditionalMethod
43

Petit rappel: le big O notation est utilisée pour dénoter la complexité asymptotique (c'est-à-dire lorsque la taille du problème augmente à l'infini), et elle cache une constante.

Cela signifie qu'entre un algorithme en O (n) et un en O (n 2 ), le plus rapide n'est pas toujours le premier (bien qu'il existe toujours une valeur de n telle que pour les problèmes de taille> n, le premier algorithme soit le plus rapide).

Notez que la constante cachée dépend beaucoup de l'implémentation!

De plus, dans certains cas, le runtime n'est pas une fonction déterministe du taille n de l'entrée. Prenez le tri en utilisant le tri rapide par exemple: le temps nécessaire pour trier un tableau de n éléments n'est pas une constante mais dépend de la configuration de départ du tableau.

Il existe différentes complexités temporelles:

  • Pire cas (généralement le plus simple à comprendre, mais pas toujours très significatif)
  • Cas moyen (généralement beaucoup plus difficile à comprendre ...)

  • ...

Une bonne introduction est une introduction à l'analyse des algorithmes par R. Sedgewick et P. Flajolet.

Comme vous le dites, premature optimisation is the root of all evilet (si possible) le profilage doit toujours être utilisé lors de l'optimisation du code. Il peut même vous aider à déterminer la complexité de vos algorithmes.

OysterD
la source
3
En mathématiques, O (.) Signifie une borne supérieure et thêta (.) Signifie que vous avez une borne au-dessus et en dessous. La définition est-elle réellement différente dans CS, ou s'agit-il simplement d'un abus courant de notation? Selon la définition mathématique, sqrt (n) est à la fois O (n) et O (n ^ 2), il n'est donc pas toujours vrai qu'il existe un certain n après lequel une fonction O (n) est plus petite.
Douglas Zare
28

En voyant les réponses ici, je pense que nous pouvons conclure que la plupart d'entre nous se rapprochent effectivement de l'ordre de l'algorithme en le regardant et en utilisant le bon sens au lieu de le calculer avec, par exemple, la méthode principale comme nous le pensions à l'université. Cela dit, je dois ajouter que même le professeur nous a encouragés (plus tard) à y penser au lieu de simplement le calculer.

J'aimerais également ajouter comment cela se fait pour les fonctions récursives :

supposons que nous ayons une fonction comme ( code de schéma ):

(define (fac n)
    (if (= n 0)
        1
            (* n (fac (- n 1)))))

qui calcule récursivement la factorielle du nombre donné.

La première étape consiste à essayer de déterminer la caractéristique de performance pour le corps de la fonction uniquement dans ce cas, rien de spécial n'est fait dans le corps, juste une multiplication (ou le retour de la valeur 1).

Donc, la performance pour le corps est: O (1) (constante).

Essayez ensuite de déterminer cela pour le nombre d'appels récursifs . Dans ce cas, nous avons n-1 appels récursifs.

Donc, la performance pour les appels récursifs est: O (n-1) (l'ordre est n, car nous jetons les parties insignifiantes).

Ensuite, mettez ces deux ensemble et vous avez alors les performances pour l'ensemble de la fonction récursive:

1 * (n-1) = O (n)


Peter , pour répondre à vos questions soulevées; la méthode que je décris ici gère cela assez bien. Mais gardez à l'esprit qu'il s'agit toujours d'une approximation et non d'une réponse mathématiquement correcte. La méthode décrite ici est également l'une des méthodes qui nous ont été enseignées à l'université, et si je me souviens bien, elle a été utilisée pour des algorithmes beaucoup plus avancés que la factorielle que j'ai utilisée dans cet exemple.
Bien sûr, tout dépend de la façon dont vous pouvez estimer le temps d'exécution du corps de la fonction et le nombre d'appels récursifs, mais cela est tout aussi vrai pour les autres méthodes.

sven
la source
Sven, je ne suis pas sûr que votre façon de juger de la complexité d'une fonction récursive fonctionnera pour les plus complexes, comme faire une recherche / sommation / quelque chose de haut en bas dans un arbre binaire. Bien sûr, vous pouvez raisonner sur un exemple simple et trouver la réponse. Mais je suppose que vous devriez réellement faire des calculs pour les récursifs?
Peteter
3
+1 pour la récursivité ... Aussi celui-ci est magnifique: "... même le professeur nous a encouragés à réfléchir ..." :)
TT_
Oui, c'est tellement bon. J'ai tendance à penser comme ça, plus le terme à l'intérieur de O (..) est élevé, plus le travail que vous / machine faites est important. Le penser en se rapportant à quelque chose pourrait être une approximation, mais il en va de même pour ces limites. Ils vous indiquent simplement comment le travail à effectuer augmente lorsque le nombre d'entrées augmente.
Abhinav Gauniyal
26

Si votre coût est un polynôme, conservez simplement le terme d'ordre le plus élevé, sans son multiplicateur. Par exemple:

O ((n / 2 + 1) * (n / 2)) = O (n 2 /4 + n / 2) = O (n 2 /4) = O (n 2 )

Cela ne fonctionne pas pour les séries infinies, faites attention. Il n'y a pas de recette unique pour le cas général, bien que pour certains cas courants, les inégalités suivantes s'appliquent:

O (log N ) <O ( N ) <O ( N log N ) <O ( N 2 ) <O ( N k ) <O (e n ) <O ( n !)

Marcelo Cantos
la source
8
sûrement O (N) <O (NlogN)
jk.
22

J'y pense en termes d'informations. Tout problème consiste à apprendre un certain nombre de bits.

Votre outil de base est le concept de points de décision et leur entropie. L'entropie d'un point de décision est l'information moyenne qu'il vous donnera. Par exemple, si un programme contient un point de décision avec deux branches, son entropie est la somme de la probabilité de chaque branche multipliée par le log 2 de la probabilité inverse de cette branche. C'est tout ce que vous apprenez en exécutant cette décision.

Par exemple, une ifdéclaration ayant deux branches, toutes deux également probables, a une entropie de 1/2 * log (2/1) + 1/2 * log (2/1) = 1/2 * 1 + 1/2 * 1 = 1. Son entropie est donc de 1 bit.

Supposons que vous recherchez un tableau de N éléments, comme N = 1024. C'est un problème de 10 bits car log (1024) = 10 bits. Donc, si vous pouvez le rechercher avec des déclarations IF qui ont des résultats tout aussi probables, il devrait prendre 10 décisions.

C'est ce que vous obtenez avec la recherche binaire.

Supposons que vous effectuez une recherche linéaire. Vous regardez le premier élément et demandez si c'est celui que vous voulez. Les probabilités sont de 1/1024 et de 1023/1024. L'entropie de cette décision est 1/1024 * log (1024/1) + 1023/1024 * log (1024/1023) = 1/1024 * 10 + 1023/1024 * environ 0 = environ 0,01 bit. Tu as très peu appris! La deuxième décision n'est pas beaucoup mieux. C'est pourquoi la recherche linéaire est si lente. En fait, c'est exponentiel dans le nombre de bits que vous devez apprendre.

Supposons que vous effectuez l'indexation. Supposons que la table soit pré-triée en un grand nombre de casiers et que vous utilisiez certains de tous les bits de la clé pour indexer directement l'entrée de la table. S'il y a 1024 bins, l'entropie est 1/1024 * log (1024) + 1/1024 * log (1024) + ... pour les 1024 résultats possibles. C'est 1/1024 * 10 fois 1024 résultats, ou 10 bits d'entropie pour cette opération d'indexation. C'est pourquoi l'indexation de la recherche est rapide.

Pensez maintenant au tri. Vous avez N éléments et vous avez une liste. Pour chaque élément, vous devez rechercher où l'élément va dans la liste, puis l'ajouter à la liste. Le tri prend donc environ N fois le nombre d'étapes de la recherche sous-jacente.

Ainsi, les tris basés sur des décisions binaires ayant des résultats à peu près tout aussi probables prennent tous des étapes O (N log N). Un algorithme de tri O (N) est possible s'il est basé sur une recherche d'indexation.

J'ai constaté que presque tous les problèmes de performances algorithmiques peuvent être examinés de cette manière.

Mike Dunlavey
la source
Sensationnel. Avez-vous des références utiles à ce sujet? Je pense que ce truc est utile pour moi de concevoir / refactoriser / déboguer des programmes.
Jesvin Jose
3
@aitchnyu: Pour ce que ça vaut, j'ai écrit un livre couvrant cela et d'autres sujets. Il est depuis longtemps épuisé, mais les copies vont à un prix raisonnable. J'ai essayé de récupérer GoogleBooks, mais pour le moment, il est un peu difficile de déterminer qui détient les droits d'auteur.
Mike Dunlavey
21

Commençons depuis le début.

Tout d'abord, acceptez le principe selon lequel certaines opérations simples sur les données peuvent être effectuées dans le O(1)temps, c'est-à-dire dans un temps indépendant de la taille de l'entrée. Ces opérations primitives en C consistent en

  1. Opérations arithmétiques (par exemple + ou%).
  2. Opérations logiques (par exemple, &&).
  3. Opérations de comparaison (par exemple, <=).
  4. Structurer les opérations d'accès (par exemple, indexation de tableaux comme A [i], ou pointeur suivant avec l'opérateur ->).
  5. Affectation simple telle que la copie d'une valeur dans une variable.
  6. Appels aux fonctions de bibliothèque (par exemple, scanf, printf).

La justification de ce principe nécessite une étude détaillée des instructions machine (étapes primitives) d'un ordinateur type. Chacune des opérations décrites peut être effectuée avec un petit nombre d'instructions machine; souvent, une ou deux instructions seulement sont nécessaires. Par conséquent, plusieurs types d'instructions en C peuvent être exécutées en O(1)temps, c'est-à-dire en un temps constant indépendant de l'entrée. Ces simples comprennent

  1. Les instructions d'affectation qui n'impliquent pas d'appels de fonction dans leurs expressions.
  2. Lisez les déclarations.
  3. Écrivez des instructions qui ne nécessitent pas d'appels de fonction pour évaluer les arguments.
  4. Les instructions jump interrompent, continuent, goto et renvoient expression, où expression ne contient pas d'appel de fonction.

En C, de nombreuses boucles for sont formées en initialisant une variable d'index à une certaine valeur et en incrémentant cette variable de 1 à chaque fois dans la boucle. La boucle for se termine lorsque l'index atteint une certaine limite. Par exemple, la boucle for

for (i = 0; i < n-1; i++) 
{
    small = i;
    for (j = i+1; j < n; j++)
        if (A[j] < A[small])
            small = j;
    temp = A[small];
    A[small] = A[i];
    A[i] = temp;
}

utilise la variable d'index i. Il incrémente i de 1 à chaque fois dans la boucle, et les itérations s'arrêtent lorsque i atteint n - 1.

Cependant, pour le moment, concentrez-vous sur la forme simple de for-loop, où la différence entre les valeurs finales et initiales, divisée par le montant par lequel la variable d'index est incrémentée, nous indique combien de fois nous parcourons la boucle . Ce décompte est exact, sauf s'il existe des moyens de quitter la boucle via une instruction jump; il s'agit dans tous les cas d'une limite supérieure du nombre d'itérations.

Par exemple, la boucle for itère ((n − 1) − 0)/1 = n − 1 times, puisque 0 est la valeur initiale de i, n - 1 est la valeur la plus élevée atteinte par i (c'est-à-dire que lorsque i atteint n − 1, la boucle s'arrête et aucune itération ne se produit avec i = n− 1), et 1 est ajouté à i à chaque itération de la boucle.

Dans le cas le plus simple, où le temps passé dans le corps de la boucle est le même pour chaque itération, nous pouvons multiplier la limite supérieure big-oh pour le corps par le nombre de fois autour de la boucle . À strictement parler, nous devons ensuite ajouter O (1) temps pour initialiser l'index de boucle et O (1) temps pour la première comparaison de l'index de boucle avec la limite , car nous testons une fois de plus que nous faisons le tour de la boucle. Cependant, à moins qu'il soit possible d'exécuter la boucle zéro fois, le temps d'initialiser la boucle et de tester la limite une fois est un terme de faible ordre qui peut être supprimé par la règle de sommation.


Considérez maintenant cet exemple:

(1) for (j = 0; j < n; j++)
(2)   A[i][j] = 0;

Nous savons que la ligne (1) prend du O(1)temps. De toute évidence, nous parcourons la boucle n fois, comme nous pouvons le déterminer en soustrayant la limite inférieure de la limite supérieure trouvée sur la ligne (1), puis en ajoutant 1. Puisque le corps, la ligne (2), prend O (1), on peut négliger le temps pour incrémenter j et le temps pour comparer j avec n, tous deux étant également O (1). Ainsi, le temps de parcours des lignes (1) et (2) est le produit de n et O (1) qui est O(n).

De même, nous pouvons limiter le temps de parcours de la boucle externe composée des lignes (2) à (4), qui est

(2) for (i = 0; i < n; i++)
(3)     for (j = 0; j < n; j++)
(4)         A[i][j] = 0;

Nous avons déjà établi que la boucle des lignes (3) et (4) prend le temps O (n). Ainsi, nous pouvons négliger le temps O (1) pour incrémenter i et pour tester si i <n dans chaque itération, concluant que chaque itération de la boucle externe prend le temps O (n).

L'initialisation i = 0 de la boucle externe et le (n + 1) st test de la condition i <n prennent également O (1) et peuvent être négligés. Enfin, nous observons que nous faisons le tour de la boucle externe n fois, en prenant O (n) temps pour chaque itération, ce qui donne un O(n^2)temps de fonctionnement total .


Un exemple plus pratique.

entrez la description de l'image ici

ajknzhol
la source
Que faire si une instruction goto contient un appel de fonction? Quelque chose comme step3: if (M.step == 3) {M = step3 (done, M); } étape4: if (M.step == 4) {M = step4 (M); } if (M.step == 5) {M = step5 (M); goto step3; } if (M.step == 6) {M = step6 (M); goto step4; } return cut_matrix (A, M); comment serait alors calculée la complexité? serait-ce un ajout ou une multiplication? considérant que l'étape4 est n ^ 3 et l'étape5 est n ^ 2.
Taha Tariq
14

Si vous souhaitez estimer l'ordre de votre code de manière empirique plutôt qu'en analysant le code, vous pouvez coller une série de valeurs croissantes de n et chronométrer votre code. Tracez vos horaires sur une échelle logarithmique. Si le code est O (x ^ n), les valeurs doivent tomber sur une ligne de pente n.

Cela présente plusieurs avantages par rapport à la simple étude du code. D'une part, vous pouvez voir si vous êtes dans la plage où le temps d'exécution s'approche de son ordre asymptotique. En outre, vous pouvez constater qu'un code que vous pensiez être de l'ordre O (x) est vraiment de l'ordre O (x ^ 2), par exemple, en raison du temps passé dans les appels de bibliothèque.

John D. Cook
la source
Juste pour mettre à jour cette réponse: en.wikipedia.org/wiki/Analysis_of_algorithms , ce lien a la formule dont vous avez besoin. De nombreux algorithmes suivent une règle de puissance, si la vôtre le fait, avec 2 points temporels et 2 temps d'exécution sur une machine, nous pouvons calculer la pente sur un tracé log-log. Qui est a = log (t2 / t1) / log (n2 / n1), cela m'a donné l'exposant de l'algorithme dans, O (N ^ a). Cela peut être comparé au calcul manuel à l'aide du code.
Christopher John
1
Salut, belle réponse. Je me demandais si vous connaissiez une bibliothèque ou une méthodologie (je travaille avec python / R par exemple) pour généraliser cette méthode empirique, ce qui signifie comme adapter diverses fonctions de complexité à un ensemble de données de taille croissante et découvrir laquelle est pertinente. Merci
agenis
10

Fondamentalement, la chose qui survient dans 90% des cas consiste simplement à analyser les boucles. Avez-vous des boucles imbriquées simples, doubles ou triples? Le temps de fonctionnement O (n), O (n ^ 2), O (n ^ 3).

Très rarement (sauf si vous écrivez une plate-forme avec une bibliothèque de base étendue (comme par exemple, le .NET BCL ou le STL de C ++), vous rencontrerez tout ce qui est plus difficile que de simplement regarder vos boucles (pour les instructions, tandis que, goto, etc...)

Adam
la source
1
Dépend des boucles.
kelalaka
8

La notation Big O est utile car elle est facile à travailler et cache des complications et des détails inutiles (pour une définition de ce qui est inutile). Une méthode intéressante pour déterminer la complexité des algorithmes de division et de conquête est la méthode de l'arborescence. Supposons que vous ayez une version de quicksort avec la procédure médiane, donc vous divisez le tableau en sous-réseaux parfaitement équilibrés à chaque fois.

Maintenant, construisez un arbre correspondant à tous les tableaux avec lesquels vous travaillez. À la racine, vous avez le tableau d'origine, la racine a deux enfants qui sont les sous-réseaux. Répétez cette opération jusqu'à ce que vous ayez des tableaux d'éléments uniques en bas.

Puisque nous pouvons trouver la médiane en temps O (n) et diviser le tableau en deux parties en temps O (n), le travail effectué à chaque nœud est O (k) où k est la taille du tableau. Chaque niveau de l'arborescence contient (au plus) l'ensemble du tableau, donc le travail par niveau est O (n) (les tailles des sous-réseaux s'additionnent à n, et puisque nous avons O (k) par niveau, nous pouvons l'ajouter) . Il n'y a que des niveaux log (n) dans l'arborescence puisque chaque fois que nous divisons par deux l'entrée.

Par conséquent, nous pouvons limiter la quantité de travail par O (n * log (n)).

Cependant, Big O cache des détails que nous ne pouvons parfois pas ignorer. Envisagez de calculer la séquence de Fibonacci avec

a=0;
b=1;
for (i = 0; i <n; i++) {
    tmp = b;
    b = a + b;
    a = tmp;
}

et supposons simplement que a et b sont des BigIntegers en Java ou quelque chose qui peut gérer des nombres arbitrairement grands. La plupart des gens diraient que c'est un algorithme O (n) sans broncher. Le raisonnement est que vous avez n itérations dans la boucle for et O (1) fonctionne à côté de la boucle.

Mais les nombres de Fibonacci sont grands, le n-ième nombre de Fibonacci est exponentiel en n, donc il suffit de le stocker sur l'ordre de n octets. Effectuer l'addition avec de grands nombres entiers nécessitera une quantité de travail O (n). Donc, la quantité totale de travail effectuée dans cette procédure est

1 + 2 + 3 + ... + n = n (n-1) / 2 = O (n ^ 2)

Donc, cet algorithme fonctionne en temps quadradique!


la source
1
Vous ne devez pas vous soucier de la façon dont les nombres sont stockés, cela ne change pas que l'algorithme se développe à une limite supérieure de O (n).
mikek3332002
8

Moins utile en général, je pense, mais dans un souci d'exhaustivité, il existe également un Big Omega Ω , qui définit une limite inférieure de la complexité d'un algorithme, et un Big Theta Θ , qui définit à la fois une limite supérieure et une limite inférieure.

Martin
la source
7

Décomposez l'algorithme en morceaux pour lesquels vous connaissez la notation du grand O et combinez-les via de grands opérateurs O. C'est le seul moyen que je connaisse.

Pour plus d'informations, consultez la page Wikipedia sur le sujet.

Lasse V. Karlsen
la source
7

Connaissance des algorithmes / structures de données que j'utilise et / ou analyse rapide de l'imbrication d'itérations. La difficulté est lorsque vous appelez une fonction de bibliothèque, éventuellement plusieurs fois - vous pouvez souvent ne pas savoir si vous appelez la fonction inutilement à certains moments ou quelle implémentation ils utilisent. Peut-être que les fonctions de bibliothèque devraient avoir une mesure de complexité / efficacité, que ce soit Big O ou une autre métrique, qui est disponible dans la documentation ou même IntelliSense .

Matt Mitchell
la source
6

Quant à "comment calculez-vous" Big O, cela fait partie de la théorie de la complexité informatique . Pour certains (nombreux) cas spéciaux, vous pourrez peut-être fournir des heuristiques simples (comme la multiplication des nombres de boucles pour les boucles imbriquées), en particulier. quand tout ce que vous voulez, c'est une estimation de la limite supérieure, et cela ne vous dérange pas si elle est trop pessimiste - ce qui, je suppose, est probablement sur quoi porte votre question.

Si vous voulez vraiment répondre à votre question pour n'importe quel algorithme, le mieux que vous puissiez faire est d'appliquer la théorie. Outre l'analyse simpliste du «pire des cas», j'ai trouvé l' analyse amortie très utile dans la pratique.

Suma
la source
6

Pour le 1er cas, la boucle interne est exécutée n-ifois, donc le nombre total d'exécutions est la somme pour ialler de 0à n-1(car inférieur à, non inférieur ou égal) de la n-i. Vous obtenez enfin n*(n + 1) / 2, alors O(n²/2) = O(n²).

Pour la 2e boucle, iest entre 0et ninclus pour la boucle externe; alors la boucle interne est exécutée lorsque jest strictement supérieur à n, ce qui est alors impossible.

Emmanuel
la source
5

En plus d'utiliser la méthode master (ou l'une de ses spécialisations), je teste expérimentalement mes algorithmes. Cela ne peut pas prouver qu'une classe de complexité particulière est atteinte, mais cela peut rassurer que l'analyse mathématique est appropriée. Pour aider avec cette assurance, j'utilise des outils de couverture de code en conjonction avec mes expériences, pour m'assurer que j'exerce tous les cas.

À titre d'exemple très simple, disons que vous vouliez effectuer un contrôle d'intégrité sur la vitesse de tri de la liste du framework .NET. Vous pouvez écrire quelque chose comme ceci, puis analyser les résultats dans Excel pour vous assurer qu'ils ne dépassent pas une courbe n * log (n).

Dans cet exemple, je mesure le nombre de comparaisons, mais il est également prudent d'examiner le temps réel requis pour chaque taille d'échantillon. Cependant, vous devez faire encore plus attention à ne mesurer que l'algorithme et à ne pas inclure les artefacts de votre infrastructure de test.

int nCmp = 0;
System.Random rnd = new System.Random();

// measure the time required to sort a list of n integers
void DoTest(int n)
{
   List<int> lst = new List<int>(n);
   for( int i=0; i<n; i++ )
      lst[i] = rnd.Next(0,1000);

   // as we sort, keep track of the number of comparisons performed!
   nCmp = 0;
   lst.Sort( delegate( int a, int b ) { nCmp++; return (a<b)?-1:((a>b)?1:0)); }

   System.Console.Writeline( "{0},{1}", n, nCmp );
}


// Perform measurement for a variety of sample sizes.
// It would be prudent to check multiple random samples of each size, but this is OK for a quick sanity check
for( int n = 0; n<1000; n++ )
   DoTest(n);
Eric
la source
4

N'oubliez pas de tenir compte également des complexités spatiales qui peuvent également être une source de préoccupation si les ressources mémoire sont limitées. Ainsi, par exemple, vous pouvez entendre quelqu'un vouloir un algorithme à espace constant, ce qui est essentiellement une façon de dire que la quantité d'espace prise par l'algorithme ne dépend d'aucun facteur à l'intérieur du code.

Parfois, la complexité peut provenir du nombre de fois que quelque chose est appelé, de la fréquence à laquelle une boucle est exécutée, de la fréquence d'allocation de la mémoire, etc. est une autre partie pour répondre à cette question.

Enfin, le grand O peut être utilisé pour le pire des cas, le meilleur des cas et les cas d'amortissement où, généralement, c'est le pire des cas qui est utilisé pour décrire la gravité d'un algorithme.

JB King
la source
4

Ce qui est souvent négligé, c'est le comportement attendu de vos algorithmes. Cela ne change pas le Big-O de votre algorithme , mais il se rapporte à la déclaration "optimisation prématurée ..."

Le comportement attendu de votre algorithme est - très stupide - à quelle vitesse vous pouvez vous attendre à ce que votre algorithme fonctionne sur les données que vous êtes le plus susceptible de voir.

Par exemple, si vous recherchez une valeur dans une liste, c'est O (n), mais si vous savez que la plupart des listes que vous voyez ont votre valeur d'avance, le comportement typique de votre algorithme est plus rapide.

Pour vraiment le comprendre, vous devez être en mesure de décrire la distribution de probabilité de votre "espace d'entrée" (si vous devez trier une liste, à quelle fréquence cette liste sera-t-elle déjà triée? À quelle fréquence est-elle totalement inversée? Comment est-il souvent trié?) Ce n'est pas toujours faisable que vous le sachiez, mais parfois vous le savez.

Baltimark
la source
4

bonne question!

Avertissement: cette réponse contient de fausses déclarations voir les commentaires ci-dessous.

Si vous utilisez le Big O, vous parlez du pire des cas (plus sur ce que cela signifie plus tard). De plus, il y a du thêta capital pour le cas moyen et un gros oméga pour le meilleur cas.

Consultez ce site pour une belle définition formelle de Big O: https://xlinux.nist.gov/dads/HTML/bigOnotation.html

f (n) = O (g (n)) signifie qu'il existe des constantes positives c et k, telles que 0 ≤ f (n) ≤ cg (n) pour tout n ≥ k. Les valeurs de c et k doivent être fixes pour la fonction f et ne doivent pas dépendre de n.


Ok, alors maintenant qu'est-ce que nous entendons par les complexités «meilleur cas» et «pire cas»?

Ceci est probablement illustré le plus clairement par des exemples. Par exemple, si nous utilisons la recherche linéaire pour trouver un nombre dans un tableau trié, le pire des cas est lorsque nous décidons de rechercher le dernier élément du tableau, car cela prendrait autant d'étapes qu'il y a d'éléments dans le tableau. Le meilleur cas serait lorsque nous recherchons le premier élément puisque nous aurions terminé après la première vérification.

Le point de toutes ces complexités adjectif-cas est que nous recherchons un moyen de représenter graphiquement la durée d'un programme hypothétique jusqu'à la fin en termes de taille de variables particulières. Cependant, pour de nombreux algorithmes, vous pouvez affirmer qu'il n'y a pas une seule fois pour une taille d'entrée particulière. Notez que cela contredit l'exigence fondamentale d'une fonction, toute entrée ne doit pas avoir plus d'une sortie. Nous proposons donc plusieurs fonctions pour décrire la complexité d'un algorithme. Maintenant, même si la recherche d'un tableau de taille n peut prendre plus ou moins de temps en fonction de ce que vous recherchez dans le tableau et en fonction de n, nous pouvons créer une description informative de l'algorithme en utilisant le meilleur des cas, le cas moyen et les classes les plus défavorables.

Désolé, c'est tellement mal écrit et il manque beaucoup d'informations techniques. Mais j'espère que cela facilitera la réflexion sur les classes de complexité temporelle. Une fois que vous vous êtes familiarisé avec celles-ci, il devient simple d'analyser votre programme et de rechercher des éléments tels que des boucles for qui dépendent de la taille des tableaux et du raisonnement en fonction de vos structures de données. Quel type d'entrée entraînerait des cas triviaux et quelle entrée dans les pires cas.

Samy Bencherif
la source
1
Ceci est une erreur. Big O signifie «borne supérieure» et non le pire des cas.
Samy Bencherif
1
C'est une idée fausse commune que big-O fait référence au pire des cas. Comment O et Ω sont-ils liés au pire et au meilleur des cas?
Bernhard Barker
1
C'est trompeur. Big-O signifie borne supérieure pour une fonction f (n). Omega signifie borne inférieure pour une fonction f (n). Ce n'est pas du tout lié au meilleur ou au pire des cas.
Tasneem Haider
1
Vous pouvez utiliser Big-O comme limite supérieure pour le meilleur ou le pire des cas, mais à part cela, oui, pas de relation.
Samy Bencherif
2

Je ne sais pas comment résoudre ce problème par programme, mais la première chose que les gens font est que nous échantillonnons l'algorithme pour certains modèles dans le nombre d'opérations effectuées, disons 4n ^ 2 + 2n + 1, nous avons 2 règles:

  1. Si nous avons une somme de termes, le terme ayant le taux de croissance le plus élevé est conservé, les autres termes étant omis.
  2. Si nous avons un produit de plusieurs facteurs, les facteurs constants sont omis.

Si nous simplifions f (x), où f (x) est la formule du nombre d'opérations effectuées, (4n ^ 2 + 2n + 1 expliqué ci-dessus), nous obtenons la valeur big-O [O (n ^ 2) dans ce Cas]. Mais cela devrait tenir compte de l'interpolation de Lagrange dans le programme, qui peut être difficile à mettre en œuvre. Et si la vraie valeur big-O était O (2 ^ n), et nous pourrions avoir quelque chose comme O (x ^ n), donc cet algorithme ne serait probablement pas programmable. Mais si quelqu'un me prouve le contraire, donnez-moi le code. . . .

Gavriel Feria
la source
2

Pour le code A, la boucle externe s'exécutera pendant des n+1temps, le temps «1» signifie le processus qui vérifie si i répond toujours à l'exigence. Et la boucle intérieure s'exécute nfois, n-2fois .... Ainsi,0+2+..+(n-2)+n= (0+n)(n+1)/2= O(n²) .

Pour le code B, bien que la boucle interne n'intervienne pas et n'exécute pas foo (), la boucle interne sera exécutée pendant n fois en fonction du temps d'exécution de la boucle externe, qui est O (n)

laynece
la source
1

Je voudrais expliquer le Big-O sous un aspect un peu différent.

Big-O consiste simplement à comparer la complexité des programmes, ce qui signifie à quelle vitesse croissent-ils lorsque les intrants augmentent et non pas le temps exact consacré à l'action.

À mon humble avis dans les formules big-O, il vaut mieux ne pas utiliser d'équations plus complexes (vous pouvez simplement vous en tenir à celles du graphique suivant.) Cependant, vous pouvez toujours utiliser d'autres formules plus précises (comme 3 ^ n, n ^ 3, .. .) mais plus que cela peut parfois être trompeur! Il vaut donc mieux rester aussi simple que possible.

entrez la description de l'image ici

Je voudrais souligner une fois de plus qu'ici nous ne voulons pas obtenir une formule exacte pour notre algorithme. Nous voulons seulement montrer comment il croît lorsque les entrées augmentent et comparer avec les autres algorithmes dans ce sens. Sinon, vous feriez mieux d'utiliser différentes méthodes comme le benchmarking.

HmT
la source