Algorithmique ii - Notations asymptotiques

Généralités

C'est l'étude des fonctions de complexité des algorithmes qui nous amène à introduire des outils d'analyse efficaces et c'est leur comportement asymptotique qui nous éclaire sur les temps de calculs que l'on peut espérer une fois qu'ils seront implantés sur des ordinateurs. Plutôt que calculer l'expression exacte de la fonction de complexité, qui reste souvent hors de portée, on cherche surtout à déterminer son comportement général. La fonction est-elle de type logarithmique, quasi-linéaire, linéaire, quadratique, exponentielle, etc. ?

Le domaine de définition de ces fonctions est tout ou partie de l'ensemble des entiers naturels \({\mathbf N}\). Cependant, pour simplifier des calculs ou pour certaines estimations, on considèrera souvent des fonctions sur le domaine réel. On note dans toute la suite \({\mathcal F}\) l'ensemble des fonctions de \({\mathbf N}\) dans \({\mathbf R}\).

Les notations asymptotiques utilisées pour mesurer et comparer la grandeur des fonctions que nous allons voir (ou revoir) sont des extensions des notations de Landau \(o\) et \(O\) bien connues de l'étudiant dans le cadre des développements limités. C'est D. Knuth qui a largement popularisé les notations désormais classiques \(\Omega\), \(\Theta\), \(O\), pour faire le ménage dans le calcul de complexité.

Notons immédiatement que pour l'analyse de certains algorithmes, il peut être intéressant d'étudier des fonctions à plusieurs variables entières, chaque variable correspondant à la taille d'une partie des données en entrée. Par exemple l'algorithme de Knuth-Morris-Pratt reçoit en entrée deux chaînes de longueurs arbitraires \(n\) et \(m\), on manipule donc des fonctions à deux variables \(f(n,m)\) et les notations asymptotiques sont similaires au cas à une variable. Cependant du point de vue strict de la définition de la complexité, la taille à considérer pour l'entrée est \(n+m\).

Soit \(g\) une fonction de \({\mathcal F}\). On définit respectivement les notations grand omicron et grand oméga : \begin{align} O(g)&:=\{f\in{\mathcal F}\ \ |\ \ {\color{#6464FF}\exists} c>0,\ \exists N\in{\mathbf N},\ \forall n\geq N,\quad 0\leq f(n)\leq cg(n) \}\\ \Omega(g)&:=\{f\in{\mathcal F}\ \ |\ \ {\color{#6464FF}\exists} c>0,\ \exists N\in{\mathbf N},\ \forall n\geq N,\quad 0\leq cg(n)\leq f(n)\}\\ \end{align}

Que disent ces définitions ? Une fonction \(f\in O(g)\), que l'on écrit abusivement \(f=O(g)\) et qu'on lit \(f\) est en grand omicron de \(g,\) est majorée par une constante fois la fonction \(g\) à partir d'un certain rang \(N\). Une fonction \(f\in \Omega(g)\), que l'on écrit abusivement \(f=\Omega(g)\) — qu'on lit \(f\) est en grand omega de \(g\) — est minorée par une constante fois la fonction \(g\) à partir d'un certain rang \(N\). La notation grand Omicron est strictement identique à la notation grand \(O\) de Landau, on dira donc indifféremment \(f\) est en grand omicron de \(g\) ou \(f\) est en grand O de \(g\).

Une fonction peut-être à la fois majorée et minorée par des facteurs d'une fonction de référence \(g\), à partir d'un certain rang, ce que l'on résume par :

Soit \(g\) une fonction de \({\mathcal F}\). On définit la notation grand thêta : \begin{equation} \Theta(g):=\{f\in{\mathcal F}\ \ |\ \ \exists a,b>0,\ \exists N\in{\mathbf N},\ \forall n\geq N,\quad 0\leq ag(n)\leq f(n)\leq bg(n) \}. \end{equation}
Pour plus de clarté, si la fonction \(g\) a une expression algébrique on écrit souvent cette expression à la place de la fonction dans la notation asymptotique. Par exemple \(f=\Theta(n^2)\) si \(g:n\mapsto n^2\) ou encore \(f=O(n\log n)\) si \(g:n\mapsto n\log n\).
Soient \(f\) et \(g\) deux fonctions de \({\mathcal F}\). On a \begin{equation} f=\Theta(g)\ \Longleftrightarrow\ f=O(g)\ \text{et}\ f=\Omega(g). \end{equation}

Encore une fois, on écrit abusivement \(f=\Theta(g)\) au lieu de \(f\in \Theta(g)\) et on lit \(f\) est en grand thêta de \(g.\) Cela signifie que l'ensemble \(\Theta(g(n))\) contient toutes les fonctions que l'on peut coincer entre deux fonctions homothétiques à \(g\) à partir d'un certain rang. Autrement dit pour \(n\) suffisamment grand, les fonctions de \(\Theta(g(n))\) se comportent comme la fonction \(g\) à une constante près.

Démontrez la proposition précédente. Démontrez que \(f=O(g)\Leftrightarrow g=\Omega(f)\).
Démontrez que la fonction définie par \(f(n):=3n^2+1\) est en \(O(n^2)\). Montrez que la fonction \(f(n):=3n^3-2n+1\) est en \(\Omega(n^3)\)
Soit \(f\) une fonction polynomiale de degré \(d\) à coefficient dominant positif. Alors \begin{equation} f=\Theta(n^d). \end{equation}
Démontrez le théorème. Utilisez la proposition 3.
Graphique gauche : \(f=\Theta(g)\). Graphique droit : \(f=\Theta(1)\)

L'expression algébrique d'une fonction et quelques connaissances sur la croissance des fonctions réelles suffisent parfois à mettre en évidence qu'une majoration est grossière, par exemple quand on affirme que la fonction \(n\mapsto 2n+1\) est en \(O(n^3)\). Ce n'est pas la définition de la notation \(O\) qui nous permet d'affirmer que cette estimation est grossière mais la connaissance de la croissance des fonctions monomiales. Ainsi l'écriture \(f=O(g)\) ne précise pas si \(f\) croit beaucoup moins vite que toute constante fois \(g\) asymptotiquement ou si \(f\) s'en approche. La notation petit \(o\) répond partiellement à cette question, quand on écrit \(f=o(g)\), on signifie que :

\[\lim_{n\rightarrow+\infty} \frac{f(n)}{g(n)}=0\]

Pour les mêmes raisons que \(f=O(g)\Leftrightarrow g=\Omega(f)\), on peut définir la notation petit omega par \(f=\omega(g)\Leftrightarrow g=o(f)\). De manière plus rigoureuse on a :

Soit \(g\) une fonction de \({\mathcal F}\). On définit respectivement les notations petit omicron et petit oméga : \begin{align} o(g)&:=\{f\in{\mathcal F}\ \ |\ \ {\color{yellow}\forall} c>0,\ \exists N\in{\mathbf N},\ \forall n\geq N,\quad 0\leq f(n)\leq cg(n) \}\\ \omega(g)&:=\{f\in{\mathcal F}\ \ |\ \ {\color{yellow}\forall} c>0,\ \exists N\in{\mathbf N},\ \forall n\geq N,\quad 0\leq cg(n)\leq f(n)\}\\ \end{align}

Sans une lecture attentive, on pourrait croire à tort qu'il s'agit des mêmes définitions que celles des notations \(O\) et \(\Omega\), mais le quantificateur universel \(\color{yellow}\forall\) remplace ici le quantificateur existentiel \(\color{#6464FF}\exists\).

Le graal en complexité est d'être en mesure de calculer le plus précisément possible la fonction de complexité, ou à défaut d'en connaître le comportement asymptotique et pas uniquement à une cons­tan­te multiplicative près :

On dit que deux fonctions \(f\) et \(g\) de \({\mathcal F}\) sont asymptotiquement équivalentes, ce que l'on note \(f\sim g\) si et seulement si \begin{equation} \forall\epsilon\in{\mathbf R}_+,\exists N\in{\mathbf N},\ \forall n\geq N,\quad \left|f(n)-g(n)\right|<\epsilon g(n). \end{equation}
Ce que l'on écrit souvent comme suit : \begin{equation*} \lim_{n\rightarrow+\infty}\frac{f(n)}{g(n)} = 1. \end{equation*}

Un bon moyen mnemotechnique (attention, en toute rigueur c'est faux) pour se souvenir de la si­gni­fi­ca­tion des notations asymptotiques est résumé par : \begin{align*} f&=O(g) &\Longleftrightarrow && f&\leq g \\ f&=o(g) &\Longleftrightarrow && f&< g\\ f&=\Omega(g) &\Longleftrightarrow && f&\geq g\\ f&=\omega(g) &\Longleftrightarrow && f&> g\\ f&=\Theta(g) &\Longleftrightarrow && f&\simeq g \\ f&\sim g &\Longleftrightarrow && f&= g \\ \end{align*}

Pour conclure cette section, nous pouvons noter que l'emploi des notations asymptotiques permet de se débarrasser, d'une part des perturbations de la fonction de complexité pour les petites tailles des entrées avec l'introduction du rang \(N\), et d'autre part du facteur constant de l'expression de cette fonction. La contrepartie de cette souplesse apparaît surtout sur le facteur constant que l'on appelle facteur caché (ou constante cachée). Imaginons que la fonction de complexité d'un algorithme \(A\) soit \(T_A(n)=\frac{1}{8}n^2-64n+8\) et celle d'un algorithme \(B\) soit \(T_B(n)=1789n+1968\), les deux résolvant le même problème. Avec les écritures asymptotiques, on écrira \[T_A(n)=\Theta(n^2)\qquad T_B(n)=\Theta(n).\] et on privilégiera très logiquement l'algorithme \(B\) à l'algorithme \(A\) ! Pourtant l'algorithme \(B\) ne devient plus performant que l'algorithme \(A\) qu'à partir de la valeur \(n=14\,825.\) Il faut donc en conclure que l'analyse asymptotique des algorithmes est… asymptotique ! Autrement dit, pour des réalisations effectives d'algorithmes, le facteur caché peut avoir une certaine importance et pourra même valider un algorithme a priori mauvais.

Sommes et séries

Les algorithmes que nous allons étudier contiennent systématiquement des boucles (ou des appels récursifs), les calculs de complexité font donc apparaître des quantités qu'il faudra sommer, à savoir le nombre d'instructions que la machine ram doit décoder à chaque passage dans la boucle. Que ce nombre d'instructions varie ou non, ses valeurs successives constituent une suite numérique.

Soit \((u_n)_{n\in{\mathbf N}}\) une suite numérique (ici des nombres réels). On rappelle que la somme \begin{equation} S_n:=\sum_{i=0}^nu_i. \end{equation} des \(n+1\) premiers termes de la suite \((u_n)\) définit elle-même une suite \((S_n)_{n\in{\mathbf N}}\) de nombres réels qu'on appelle la série de terme général \(u_n\). Attention au vocabulaire ! En toute rigueur le terme général de la série \((S_n)_{n\in{\mathbf N}}\) devrait faire référence à \(S_n\) (qui est une somme partielle de la série \((S)\)) puisqu'il s'agit d'une suite, on fait pourtant référence au terme général \(u_n\) de la suite associée.

On dit que \((S_n)_{n\in{\mathbf N}}\) est une série convergente si c'est une suite convergente, i.e. s'il existe un nombre réel \(l\) appelé la limite de la série \((S_n)\), tel que \[\forall \varepsilon >0,\ \exists N\in{\mathbf N}\ \ \mid\ \ \forall n\geq N,\ \ |S_n-l|<\varepsilon\]

Dans le cas contraire, c'est une série divergente. Plus généralement quand une série de terme général \(u_n\) est convergente on peut noter sa limite \begin{equation} \label{serie} \sum_{i=0}^\infty u_i. \end{equation}

Une série \((S_n)\) de terme général \(u_n\) est dite absolument convergente si la série de terme général \(|u_n|\) est convergente. Les deux écritures \begin{equation} \sum_{i=0}^\infty u_i,\quad\text{et}\quad \sum_{i\in{\mathbf N}}u_i \end{equation} ne sont pas équivalentes. La première indique explicitement l'ordre dans lequel on rajoute les termes de la suite dans la somme partielle, et ceci a une importance capitale pour la convergence ou la divergence de la série. L'ordre n'est pas spécifié dans la seconde ce qui laisse entendre qu'il ne change rien à la limite ce qui est faux. Considérons par exemple la suite de terme général \begin{equation} u_i=\begin{cases} 0,&\text{si \(i\) est pair}.\\ 1,&\text{si \(i\) est impair}. \end{cases} \end{equation}

autrement dit, il s'agit de la suite alternée \(010101\ldots\) Dans ce cas précis, il est évident que si l'on décompose la somme sur les indices pairs et impairs, en commençant la sommation sur les indices pairs, on converge vers \(0\), alors que la série diverge. Par contre, pour une série absolument con­ver­gen­te, il y a égalité et l'ordre de la sommation n'a pas d'incidence sur le résultat, on réservera donc la seconde écriture aux séries absolument convergentes.

Une suite peut être divergente sans pour autant tendre vers l'infini (positivement ou négativement). On rappelle qu'une suite \((u_n)\) diverge vers \(+\infty\) si et seulement si elle satisfait l'as­ser­tion suivante : \begin{equation} \forall A\in{\mathbf R}^+,\ \exists N\in{\mathbf N},\ \forall n\geq N,\quad (n\geq N\Rightarrow u_n > A) \end{equation} Par exemple, la série de Grandi de terme général \(u_n=(-1)^n\) est divergente, ses sommes par­tiel­les sont alternativement les deux valeurs \(0\) et \(1\), elle ne diverge manifestement pas vers l'infini.

La propriété de linéarité pour des sommes finies \begin{equation} \sum_{i=1}^n(\lambda u_i+b_i)=\lambda\sum_{i=1}^nu_i+\sum_{i=1}^nb_i \end{equation} est conservée pour des séries convergentes.

[Critère de d'Alembert] Soit \((u_n)_{n\in{\mathbf N}}\) une série à termes positifs non-nuls à partir d'un certain rang. S'il existe un réel \(l\) tel que \begin{equation} \lim_{n\rightarrow\infty}\frac{u_{n+1}}{u_n}=l. \end{equation} Alors la série \((u_n)\) converge si \(l<1\), diverge si \(l>1\). Si \(l=1\) on ne peut pas conclure.
Pour \(n\) entier positif, le \(n\)-ème nombre harmonique est la série de terme général \(\frac{1}{n}\). On le note : \begin{align*} H_n&:=1+\frac{1}{2}+\frac{1}{3}+\frac{1}{4}+\cdots+\frac{1}{n}\\ \end{align*} Il s'agit d'une série divergente (cf. exercice plus loin).

S'il est simple de calculer la somme d'une série arithmétique ou géométrique, il peut être très délicat d'obtenir directement via des formules algébriques l'expression de la somme d'une série arbitraire. On peut parfois deviner l'expression de cette somme sans pour autant être capable de l'obtenir directement. Dans ce cas, on utilise souvent un raisonnement par récurrence pour valider (ou non) l'expression supposée.

On veut montrer que pour tout nombre réel \(b>1\) il existe une constante \(c>0\) (qui dépend donc a priori de \(b\)) telle que \begin{equation} \label{eqcb} {\color{#FF0}\sum_{i=0}^nb^i}\leq cb^n. \end{equation} On ne connaît pas encore la constante, mais le raisonnement par récurrence va nous permettre de la déterminer. On se donne un entier positif \(b\) et on considère le prédicat \(P(n)\) que constitue l'inégalité \((\ref{eqcb})\). Dans une preuve par récurrence, on commence traditionnellement par l'initialisation, mais ici la preuve de l'hérédité va nous fournir des indications sur la constante \(c\) à considérer. On procède comme si l'on connaissait déjà cette constante \(c\) et on calcule la somme au rang \(n+1\) : \begin{equation} \sum_{i=0}^{n+1}b^i=\left({\color{#FF0}\sum_{i=0}^nb^i}\right)+b^{n+1}. \end{equation} L'hypothèse de récurrence nous permet d'appliquer l'inégalité \((\ref{eqcb})\) : \begin{align} \sum_{i=0}^{n+1}b^i&\leq cb^n+b^{n+1}\\ &=\left(\frac{1}{b}+\frac{1}{c}\right)cb^{n+1}\\ &\leq cb^{n+1}\\ \end{align} la dernière inégalité n'est vraie que si \((1/b+1/c)\leq 1\), soit dès que \(c\geq b/(b-1)\). Il suffit à présent de montrer l'initialisation pour la constante \(c:=b/(b-1)\) (par exemple). Pour \(n=0\) on a \[ \sum_{i=0}^0b^i=1 < \frac{b}{(b-1)} \] Notons que comme \(b > 1\), on a \(b/(b-1) < 2\), ce qui prouve au passage qu'il existe une constante indépendante de \(b\) qui satisfait l'inégalité \((\ref{eqcb})\), par exemple \(c=2\). C'est un exemple didactique, dans ce cas particulier il est parfaitement inutile d'avoir recours à une récurrence puisqu'on est en présence d'une suite géométrique de raison \(b\) : \begin{align*} {\color{#FF0}\sum_{i=0}^nb^i}&=\frac{b^{n+1}-1}{b-1}\\ &\leq \frac{b^{n+1}}{b-1}=\left(\frac{b}{b-1}\right)b^n\\ \end{align*}

Une autre méthode très utile pour évaluer une somme consiste à approximer des fonctions monotones croissantes ou décroissantes à l'aide d'une intégrale. On est en effet souvent en mesure d'exprimer le nombre d'instructions à décoder dans une boucle à l'aide d'une fonction qui dépend d'un indice de boucle. En algorithmique cette fonction est quasi systématiquement monotone, ce qui explique la popularité de ce procédé de calcul.

Soit \(f\) une fonction monotone croissante réelle, alors \begin{equation} \label{majint} \int_{m-1}^nf(x)dx \leq {\color{#4CF}\sum_{i=m}^nf(i)} \leq \int_{m}^{n+1}f(x)dx. \end{equation} Dans le cas d'une fonction monotone décroissante, on a un résultat similaire : \begin{equation} \int_{m}^{n+1}f(x)dx\leq\sum_{i=m}^nf(i)\leq\int_{m-1}^{n}f(x)dx. \end{equation}
Nous ne démontrons le résultat que pour une fonction monotone croissante, la preuve est similaire pour une fonction décroissante. Dans la figure ci-dessous, l'aire du rectangle numéro \(i\in[0,n-m]\) de sommets \[(m+i,0),\ (m+i,f(m+i)),\ (m+i+1,f(m+i+1)),\ (m+i+1,0)\] est égale à \(f(m+i)\) puisqu'il est de largeur \(1\). Ainsi la somme des aires de tous les rectangles est égale à la somme dans l'encadrement \((\ref{majint})\). Cette aire est majorée par l'aire sous la portion de courbe \[\{(x,f(x)),\ m\leq x\leq n+1\}\] d'où le résultat.
Majoration et minoration d'une série.

En translatant la courbe d'une unité vers la droite, elle passe sous les sommets des rectangles et on obtient la minoration de façon similaire.

Inversement, pour estimer la surface de l'aire délimitée par la courbe et l'axe des abscisses entre \(m\) et \(n\), on diminue indéfiniment la largeur du rectangle et c'est de cette manière que l'on introduit l'intégrale d'une fonction au sens de Riemann.
Démontrez que la série harmonique n'est pas convergente à l'aide du théorème ci-dessus.

Ordres de grandeur

Comme nous l'avons vu, c'est le comportement asymptotique des fonctions de complexité qui nous intéresse et il est bon de connaître les comportements les plus communs possibles ainsi que le vocabulaire associé. On se donne \(T(n)\) une fonction de complexité où \(n\) désigne la taille de la donnée à traiter. On résume ces comportements ci-dessous avec \(p(n)\) qui désigne une fonction polynomiale (à coefficient dominant positif). Les fonctions sont rangées dans l'ordre (strictement) croissant de leurs complexités :

Notons qu'un algorithme est dit de complexité polynomiale si sa fonction de complexité est majorée par une fonction polynomiale. Ainsi un algorithme de complexité \(O(n\log n)\) est dit de complexité polynomiale car \(\forall n\in{\mathbf N},\ n\log n \leq n^2\), quand bien même la fonction \(n\mapsto n\log n\) n'est pas stricto sensu une fonction polynomiale.

Calculs avec les notations asymptotiques

Nous emploierons très souvent les notations asymptotiques dans des calculs comme s'il s'agissait de fonctions. Il est nécessaire de comprendre l'intérêt de cet abus de langage et des pièges qui en découlent. Par exemple, que signifie l'égalité \(O(n^2)+O(n)=O(n^2)\) ? Il faut remplacer dans le calcul chaque notation asymptotique par une fonction quelconque de sa classe. Ici formellement on se donne deux fonctions \(f_1\) et \(f_2\) telles que : \begin{align*} &\exists c_1\in{\mathbf R}_+^*,\ \exists N_1\in{\mathbf N},\ \forall n\geq N_1,\quad 0\leq f_1(n)\leq c_1n^2\\ &\exists c_2\in{\mathbf R}_+^*,\ \exists N_2\in{\mathbf N},\ \forall n\geq N_2,\quad 0\leq f_2(n)\leq c_2n. \end{align*} En notant \({\color{yellow}N}:=\max\{N_1,N_2\}\), on en déduit que \begin{align*} \forall n\geq N\quad 0\leq f_1(n)+f_2(n) &\leq c_1n^2+c_2n\\ &\leq c_1n^2+c_2n^2\\ &= (c_1+c_2)n^2 \end{align*}

Autrement dit, si on définit \({\color{yellow}c}:=c_1+c_2\), on vient de prouver que \[\exists {\color{yellow}c}\in{\mathbf R}_+^*,\ \exists {\color{yellow}N}\in{\mathbf N},\ \forall n\geq N,\quad 0\leq f_1(n)+f_2(n)\leq cn^2, \] c'est-à-dire \(f_1+f_2=O(n^2)\) ce qui justifie l'écriture \(O(n^2)+O(n)=O(n^2)\). L'exercice suivant montre qu'il faut prendre garde au sens des expressions.
Montrez que si \(k\in{\mathbf R}\) est une constante strictement positive, alors \[k\times O(g(n))=O(g(n))\] mais qu'en revanche \[n\times O(g(n))=O(ng(n)).\] Calculez \(\Theta(n)-\Theta(n)\).

Cas de la notation \(\Theta(1)\)

La notation \(\Theta(1)\) est particulièrement utile à plus d'un titre. Elle sert intensivement pour le calcul de la complexité d'un algorithme quand celui-ci est décrit dans un langage informel comme celui que nous utilisons. Le calcul de la complexité pour la machine RAM se fait en comptant le nombre d'instructions décodées, en revanche dans notre pseudo-langage algorithmique la notion d'instruction n'est pas précisément définie et nous savons qu'une expression du type \(x\leftarrow 3.12 x-6\) a un coût (en nombre de cycles machine sur un modèle bien réel) bien plus important qu'un incrément \(x \leftarrow x +1\) par exemple. Le coût qui était unitaire dans notre modèle ne l'est plus avec notre pseudo langage.

L'introduction de la notation \(\Theta(1)\) permet de nous affranchir de ce problème si l'on est attentif. En effet, une instruction élementaire dans notre pseudo-langage pourrait être traduite en un groupe d'instructions équivalent pour la machine RAM. Une opération d'incrémentation comme \(x \leftarrow x+1\) est directement traduite par l'instruction

INC x
en supposant que \(x\) désigne le registre mémoire associé à la variable \(x\), et avec les mêmes conventions, une instruction comme \(x \leftarrow x+y\) serait traduite par les trois instructions
LOAD x
ADD y
STORE x
De la même manière, une instruction conditionnelle comme SI \(x+2 < y\) ALORS s'écrirait
LOAD x
ADD #2
SUB y
JUML adresse
Dans les trois cas, le nombre d'instructions est borné inférieurement et supérieurement par deux constantes, ici \(1\) et \(4\). On peut donc dire ici qu'une instruction a un coût de \(\Theta(1)\) et on ne se préoccupe plus de l'inflation engendrée par la traduction. Bien entendu la diversité des instructions possibles en pseudo-langage algorithmique et l'absence de grammaire formelle nous empêche d'étudier cette inflation de manière exhaustive, mais nous serons en mesure de déterminer aisément si elles ont un coût en \(\Theta(1)\) ou non.