algorithm
Notation Big-O
Recherche…
Remarques
Définition
La notation Big-O est au cœur d'une notation mathématique, utilisée pour comparer le taux de convergence des fonctions. Soit n -> f(n)
et n -> g(n)
fonctions définies sur les nombres naturels. On dit alors que f = O(g)
si et seulement si f(n)/g(n)
est borné quand n s'approche de l'infini. En d'autres termes, f = O(g)
si et seulement s'il existe une constante A, telle que pour tout n, f(n)/g(n) <= A
En réalité, la portée de la notation Big-O est un peu plus large en mathématiques, mais pour simplifier, je l'ai réduite à ce qui est utilisé dans l'analyse de la complexité des algorithmes: les fonctions définies sur les à l'infini.
Qu'est-ce que ça veut dire ?
Prenons le cas de f(n) = 100n^2 + 10n + 1
et g(n) = n^2
. Il est clair que ces deux fonctions tendent à l'infini lorsque n tend vers l'infini. Mais parfois, connaître la limite ne suffit pas, et nous voulons également connaître la vitesse à laquelle les fonctions se rapprochent de leur limite. Des notions telles que Big-O permettent de comparer et de classer les fonctions selon leur vitesse de convergence.
Voyons si f = O(g)
en appliquant la définition. Nous avons f(n)/g(n) = 100 + 10/n + 1/n^2
. Depuis 10/n
est 10 lorsque n est égal à 1 et diminue, et que 1/n^2
est égal à 1 lorsque n est égal à 1 et est également diminué, on a F f(n)/g(n) <= 100 + 10 + 1 = 111
. La définition est satisfaite car nous avons trouvé une limite de f(n)/g(n)
(111) et donc f = O(g)
(on dit que f est un Big-O de n^2
).
Cela signifie que f tend vers l'infini approximativement à la même vitesse que g. Cela peut sembler étrange à dire, car nous avons trouvé que f est au plus 111 fois plus grand que g, ou en d'autres termes lorsque g augmente de 1, f augmente d'au plus 111. Il peut sembler croissant que 111 fois plus rapide n'est pas "approximativement la même vitesse". Et en effet, la notation Big-O n'est pas un moyen très précis de classer la vitesse de convergence des fonctions. C'est pourquoi, en mathématiques, nous utilisons la relation d'équivalence lorsque nous voulons une estimation précise de la vitesse. Mais pour séparer les algorithmes dans les grandes classes de vitesse, Big-O est suffisant. Nous n'avons pas besoin de séparer les fonctions qui se développent un nombre de fois plus rapide que les autres, mais uniquement des fonctions qui augmentent infiniment plus rapidement les unes que les autres. Par exemple si on prend h(n) = n^2*log(n)
, on voit que h(n)/g(n) = log(n)
qui tend vers l'infini avec n donc h n'est pas O (n ^ 2), car h pousse infiniment plus vite que n ^ 2.
Maintenant, je dois faire une remarque: vous avez peut-être remarqué que si f = O(g)
et g = O(h)
, alors f = O(h)
. Par exemple, dans notre cas, nous avons f = O(n^3)
et f = O(n^4)
... Dans l’analyse de la complexité de l’algorithme, nous disons fréquemment que f = O(g)
signifie que f = O(g)
et g = O(f)
, ce qui peut être compris comme "g est le plus petit Big-O pour f". En mathématiques, nous disons que ces fonctions sont les Big Thetas les unes des autres.
Comment est-ce utilisé ?
Lorsque nous comparons les performances de l'algorithme, nous nous intéressons au nombre d'opérations effectuées par un algorithme. Cela s'appelle la complexité du temps . Dans ce modèle, nous considérons que chaque opération de base (addition, multiplication, comparaison, affectation, etc.) prend un temps fixe et nous comptons le nombre de ces opérations. Nous pouvons généralement exprimer ce nombre en fonction de la taille de l'entrée, que nous appelons n. Et malheureusement, ce nombre augmente généralement à l'infini avec n (si ce n'est pas le cas, nous disons que l'algorithme est O (1)). Nous séparons nos algorithmes en grandes classes de vitesse définies par Big-O: lorsque nous parlons d'un "algorithme O (n ^ 2)", nous voulons dire que le nombre d'opérations qu'il exécute, exprimé en fonction de n, est un O ( n ^ 2). Ceci dit que notre algorithme est approximativement aussi rapide qu'un algorithme qui ferait un nombre d'opérations égal au carré de la taille de son entrée, ou plus rapide . La partie "ou plus rapide" est là parce que j'ai utilisé Big-O au lieu de Big-Theta, mais généralement, les gens diront Big-O pour Big-Theta.
Lors du comptage d'opérations, nous considérons généralement le pire des cas: par exemple, si nous avons une boucle qui peut s'exécuter au plus n fois et qui contient 5 opérations, le nombre d'opérations que nous comptons est 5n. Il est également possible de considérer la complexité moyenne des cas.
Note rapide: un algorithme rapide est celui qui effectue peu d'opérations, donc si le nombre d'opérations augmente à l'infini plus rapidement , alors l'algorithme est plus lent : O (n) est meilleur que O (n ^ 2).
Nous sommes aussi parfois intéressés par la complexité spatiale de notre algorithme. Pour cela, considérons le nombre d'octets en mémoire occupés par l'algorithme en fonction de la taille de l'entrée, et utilisons Big-O de la même manière.
Une boucle simple
La fonction suivante trouve l'élément maximal dans un tableau:
int find_max(const int *array, size_t len) {
int max = INT_MIN;
for (size_t i = 0; i < len; i++) {
if (max < array[i]) {
max = array[i];
}
}
return max;
}
La taille d'entrée est la taille du tableau, que j'ai appelée len
dans le code.
Comptons les opérations.
int max = INT_MIN;
size_t i = 0;
Ces deux affectations ne sont effectuées qu’une seule fois, c’est donc 2 opérations. Les opérations en boucle sont les suivantes:
if (max < array[i])
i++;
max = array[i]
Comme il y a 3 opérations dans la boucle et que la boucle est effectuée n fois, nous ajoutons 3n
à nos 2 opérations déjà existantes pour obtenir 3n + 2
. Donc, notre fonction prend 3n + 2
opérations pour trouver le max (sa complexité est 3n + 2
). C'est un polynôme où le terme de croissance le plus rapide est un facteur de n, donc il est O (n).
Vous avez probablement remarqué que "l'opération" n'est pas très bien définie. Par exemple, j'ai dit que if (max < array[i])
était une opération, mais en fonction de l'architecture, cette instruction peut compiler par exemple trois instructions: une lecture de mémoire, une comparaison et une branche. J'ai également considéré toutes les opérations comme identiques, même si, par exemple, les opérations de mémoire seront plus lentes que les autres et que leurs performances varieront énormément, par exemple en raison des effets de cache. J'ai aussi complètement ignoré la déclaration de retour, le fait qu'une image sera créée pour la fonction, etc. Finalement, l'analyse de la complexité n'a pas d'importance, car peu importe comment je compte les opérations, cela ne changera que le coefficient. du facteur n et de la constante, le résultat sera toujours O (n). La complexité montre comment l'algorithme s'adapte à la taille de l'entrée, mais ce n'est pas le seul aspect des performances!
Une boucle imbriquée
La fonction suivante vérifie si un tableau a des doublons en prenant chaque élément, puis effectue une itération sur l'ensemble du tableau pour voir si l'élément est présent
_Bool contains_duplicates(const int *array, size_t len) {
for (int i = 0; i < len - 1; i++) {
for (int j = 0; j < len; j++) {
if (i != j && array[i] == array[j]) {
return 1;
}
}
}
return 0;
}
La boucle interne effectue à chaque itération un nombre d'opérations constant avec n
. La boucle externe effectue également quelques opérations constantes et exécute la boucle interne n
fois. La boucle externe elle-même est exécutée n
fois. Donc, les opérations à l'intérieur de la boucle interne sont exécutées n^2
fois, les opérations dans la boucle externe sont exécutées n
fois et l'affectation à i
est effectuée une fois. Ainsi, la complexité sera quelque chose comme an^2 + bn + c
, et comme le terme le plus élevé est n^2
, la notation O(n^2)
est O(n^2)
.
Comme vous l'avez peut-être remarqué, nous pouvons améliorer l'algorithme en évitant de faire les mêmes comparaisons plusieurs fois. Nous pouvons commencer à partir de i + 1
dans la boucle interne, car tous les éléments qui l'ont précédé auront déjà été vérifiés par rapport à tous les éléments du tableau, y compris celui de l'index i + 1
. Cela nous permet de laisser tomber la vérification i == j
.
_Bool faster_contains_duplicates(const int *array, size_t len) {
for (int i = 0; i < len - 1; i++) {
for (int j = i + 1; j < len; j++) {
if (array[i] == array[j]) {
return 1;
}
}
}
return 0;
}
De toute évidence, cette deuxième version fait moins d'opérations et est donc plus efficace. Comment cela se traduit-il par la notation Big-O? Eh bien, le corps de la boucle interne est maintenant exécuté 1 + 2 + ... + n - 1 = n(n-1)/2
fois. C'est toujours un polynôme du deuxième degré, et il en est toujours de même pour O(n^2)
. Nous avons clairement réduit la complexité, puisque nous avons divisé par 2 approximativement le nombre d'opérations que nous effectuons, mais nous sommes toujours dans la même classe de complexité que celle définie par Big-O. Afin de réduire la complexité à une classe inférieure, il faudrait diviser le nombre d'opérations par quelque chose qui tend vers l'infini avec n
.
Un exemple O (log n)
introduction
Considérez le problème suivant:
L
est une liste triée contenant n
entiers signés ( n
étant assez grand), par exemple [-5, -2, -1, 0, 1, 2, 4]
(ici, n
vaut 7). Si L
est connu pour contenir le nombre entier 0, comment pouvez-vous trouver l'index de 0?
Approche naïve
La première chose qui me vient à l'esprit est de lire chaque index jusqu'à ce que 0 soit trouvé. Dans le pire des cas, le nombre d'opérations est n
, la complexité est donc O (n).
Cela fonctionne très bien pour les petites valeurs de n
, mais existe-t-il un moyen plus efficace?
Dichotomie
Considérons l'algorithme suivant (Python3):
a = 0
b = n-1
while True:
h = (a+b)//2 ## // is the integer division, so h is an integer
if L[h] == 0:
return h
elif L[h] > 0:
b = h
elif L[h] < 0:
a = h
a
et b
sont les index entre lesquels 0 doit être trouvé. Chaque fois que nous entrons dans la boucle, nous utilisons un index entre a
et b
et l'utilisons pour affiner la zone à rechercher.
Dans le pire des cas, il faut attendre que a
et b
soient égaux. Mais combien d'opérations cela prend-il? Pas n, car chaque fois que nous entrons dans la boucle, nous divisons la distance entre a
et b
d'environ deux. Au contraire, la complexité est O (log n).
Explication
Note: Lorsque nous écrivons "log", nous entendons le logarithme binaire, ou log base 2 (que nous écrirons "log_2"). Comme O (log_2 n) = O (log n) (vous pouvez faire le calcul), nous utiliserons "log" au lieu de "log_2".
Appelons x le nombre d'opérations: on sait que 1 = n / (2 ^ x).
Donc 2 ^ x = n, alors x = log n
Conclusion
Face à des divisions successives (que ce soit par deux ou par un nombre quelconque), rappelez-vous que la complexité est logarithmique.
O (log n) types d'algorithmes
Disons que nous avons un problème de taille n. Maintenant, pour chaque étape de notre algorithme (que nous avons besoin d'écrire), notre problème initial devient moitié de sa taille précédente (n / 2).
Ainsi, à chaque étape, notre problème devient moitié.
Étape | Problème |
---|---|
1 | n / 2 |
2 | n / 4 |
3 | n / 8 |
4 | n / 16 |
Lorsque l'espace problème est réduit (c.-à-d. Complètement résolu), il ne peut plus être réduit (n devient égal à 1) après avoir quitté la condition de contrôle.
Disons à la kième étape ou au nombre d'opérations:
taille du problème = 1
Mais nous savons à la cinquième étape, notre taille de problème devrait être:
taille du problème = n / 2 k
Du 1 et 2:
n / 2 k = 1 ou
n = 2 k
Prenez le journal des deux côtés
log e n = k log e 2
ou
k = log e n / log e 2
Utilisation de log log x m / log x n = log n m
k = log 2 n
ou simplement k = log n
Maintenant, nous savons que notre algorithme peut fonctionner au maximum jusqu'à log n, d'où la complexité du temps.
O (log n)
Un exemple très simple dans le code pour supporter le texte ci-dessus est:
for(int i=1; i<=n; i=i*2)
{
// perform some operation
}
Alors maintenant, si quelqu'un vous demande si n est 256, combien de pas cette boucle (ou tout autre algorithme qui réduit la taille de son problème en deux) sera exécutée très facilement.
k = log 2 256
k = log 2 2 8 (=> log a a = 1)
k = 8
Un autre très bon exemple pour un cas similaire est l' algorithme de recherche binaire .
int bSearch(int arr[],int size,int item){
int low=0;
int high=size-1;
while(low<=high){
mid=low+(high-low)/2;
if(arr[mid]==item)
return mid;
else if(arr[mid]<item)
low=mid+1;
else high=mid-1;
}
return –1;// Unsuccessful result
}