Tri lexicographique

L'ordre lexicographique

Introduction

Nous avons déjà observé que trier des objets suppose que l'on dispose d'une relation d'ordre définie sur ces objets et qui plus est totale. Nous avons majoritairement utilisé l'ordre naturel pour il­lus­trer les algorithmes de tri.

Il n'est pas rare que l'on ait à trier des listes d'objets disposants de plusieurs attributs. Considérons par exemple une liste de couples \((T,P)\) contenant une température \(T\) (en degrés Celsius) et une pression \(P\) (en Pascals). En limitant la précision des mesures à un entier, ces deux attributs sont munis de la relation d'ordre naturel. On peut trier une telle liste selon la température ou la pression, mais dans ce cas l'autre attribut est tout bonnement ignoré dans le tri et ce n'est pas ce que l'on souhaite.

Une première idée consiste à comparer les couples terme à terme, autrement dit on définit une relation \(\mathscr \preceq\) sur l'ensemble des couples \((T,P)\) par : \begin{equation} (T,P)\preceq(T',P')\ \iff\ (T\leqslant T')\ \wedge\ (P\leqslant P'). \end{equation}

On vérifie aisément que cette relation est réflexive, anti-symétrique et transitive, il s'agit de l'ordre produit. Cette relation se généralise à des \(n\)-uplets d'un produit cartésien \(E_1\times E_2\times\cdots\times E_n\) d'en­sem­bles totalement ordonnés \((E_i,\preceq_i)\). Malheureusement cette relation d'ordre n'est que partielle, en effet on ne peut pas comparer les couples \((4,3)\) et \((2,5)\) par exemple. Une autre cons­truc­tion est né­ces­sai­re.

Une réponse possible à ce problème est la relation d'ordre lexicographique. C'est celle que nous utilisons pour ranger les mots dans un dictionnaire ou pour classer les nombres réels quand ils sont représentés à l'aide de leurs développements décimaux. L'idée est naturelle, on part d'une liste \(L\) de \(n\)-uplets d'un produit cartésien \(E_1\times E_2\times\cdots\times E_n\) d'ensembles totalement ordonnés \((E_i,\preceq_i)\) et on trie cette liste une première fois suivant la première composante. On partitionne alors la nouvelle liste \(L\) en sous-listes contiguës selon la valeur de la première composante (leur concaténation est donc la liste \(L\)). On trie alors chacune de ces sous-listes à l'aide d'un algorithme de tri stable suivant la seconde composante. On recommence le même procédé sur chacune de ces sous-listes pour la composante suivante, etc.

Considérons la liste \(L=((2,2,c),(3,2,b),(1,3,c),(3,2,a),(3,1,c),(1,2,a))\) dé­fi­nie sur le produit cartésien \({\mathbb N}\times{\mathbb N}\times{\mathscr A}\) où \({\mathbb N}\) est muni de l'ordre naturel et \({\mathscr A}\) est l'al­pha­bet latin muni de l'ordre alphabétique. UnLe ré­sul­tat dépend de la mé­thode de tri choisie puis­qu'il ex­is­te plu­sieurs tri­plets de mê­me pre­miè­re valeur. premier tri suivant la première composante en­tiè­re pourrait fournir la liste \[\underbrace{(\textcolor{yellow}{1},3,c),(\textcolor{yellow}{1},2,a)}_{A_{\textcolor{yellow}{1}}},\underbrace{(\textcolor{yellow}{2},2,c)}_{B_{\textcolor{yellow}{2}}},\underbrace{(\textcolor{yellow}{3},2,b),(\textcolor{yellow}{3},2,a),(\textcolor{yellow}{3},1,c)}_{C_{\textcolor{yellow}{3}}}.\] Un second tri stable de ces trois listes suivant la deuxième composante pourrait donner : \[\underbrace{\overbrace{(\textcolor{yellow}{1},\textcolor{orange}{2},a)}^{A_{\textcolor{yellow}{1},\textcolor{orange}{2}}},\overbrace{(\textcolor{yellow}{1},\textcolor{orange}{3},c)}^{A_{\textcolor{yellow}{1},\textcolor{orange}{3}}}}_{A_{\textcolor{yellow}{1}}\ \text{triée}},\underbrace{\overbrace{(\textcolor{yellow}{2},\textcolor{orange}{2},c)}^{B_{\textcolor{yellow}{2},\textcolor{orange}{2}}}}_{B_{\textcolor{yellow}{2}}\ \text{triée}},\underbrace{\overbrace{(\textcolor{yellow}{3},\textcolor{orange}{1},c)}^{C_{\textcolor{yellow}{3},\textcolor{orange}{1}}},\overbrace{(\textcolor{yellow}{3},\textcolor{orange}{2},b),(\textcolor{yellow}{3},\textcolor{orange}{2},a)}^{C_{\textcolor{yellow}{3},\textcolor{orange}{2}}}}_{C_{\textcolor{yellow}{3}}\ \text{triée}}.\] Et un dernier tri stable sur la liste \(C_{\textcolor{yellow}{3},\textcolor{orange}{2}}\) suivant la dernière composante (les 4 premières listes ne contiennent plus qu'un élément) achèverait le processus : \[(\textcolor{yellow}{1},\textcolor{orange}{2},a),(\textcolor{yellow}{1},\textcolor{orange}{3},c),(\textcolor{yellow}{2},\textcolor{orange}{2},c),(\textcolor{yellow}{3},\textcolor{orange}{1},c),\overbrace{(\textcolor{yellow}{3},\textcolor{orange}{2},\textcolor{#AAAAFF}{a})}^{C_{\textcolor{yellow}{3},\textcolor{orange}{2},\textcolor{#AAAAFF}{a}}},\overbrace{(\textcolor{yellow}{3},\textcolor{orange}{2},\textcolor{#AAAAFF}{b})}^{C_{\textcolor{yellow}{3},\textcolor{orange}{2},\textcolor{#AAAAFF}{b}}}.\]

Notons dès à présent que la méthode de tri présentée dans l'exemple ci-dessus n'a rien de simple à cause des segmentations successives des listes manipulées. Nous verrons à la section suivante comment faire mieux. D'autre part, l'ordre lexicographique tel qu'il vient d'être présenté n e permet de comparer que deux séquences de même longueur, alors que nous savons comparer des mots d'un dictionnaire ou des nombres réels*grâce à leurs développements décimaux illimités, sans que leurs longueurs soient égales.

Formalisation

On rappelle que si \(\leqslant\) est une relation d'ordre, alors \(\lt\) désigne l'ordre strict associé à \(\leqslant\). On va définir une relation d'ordre sur la réunion de produits cartésiens finis d'ensembles totalement ordonnés. On rappelle que si \(n\) est un entier naturel, on note \([n]:=\ab{1}{n}\).

Soit \((X_i,\,\preceq_i)_{i\in{\mathbb N}}\) une famille dénombrable d'ensembles totalement ordonnés. On définit \begin{equation} \label{eq:defprodinf} X:=\bigcup_{k\in{\mathbb N}}\left(\prod_{i=1}^kX_i\right). \end{equation} Soit \((n,m)\in{\N}^2\) et \(p:=\min\{n,m\}.\) Soit \(x=(x_1,\ldots,x_n)\in X\) et \(y=(y_1,\ldots,y_m)\in X.\)

La relation binaire \(\preceq\) définie sur \(X\) par \(x \preceq y\) si et seulement si \begin{equation} \label{eq:condtl} {\color{#AAF}(n=p)\wedge(\forall i\in[n]\ \;x_i=y_i)} \vee {\color{#BB0}(\exists k\in[p]\ \forall i\lt k\ \;x_i=y_i)\wedge(x_k \lt_k y_k)} \end{equation} est une relation d'ordre total appelée ordre le­xi­co­gra­phi­que.

La condition \((\ref{eq:condtl})\) exprime formellement que \(x\preceq y\) si et seulement \(x\) est un préfixe de \(y\) ou s'il existe un rang \(k\) en deça duquel les termes de \(x\) et \(y\) sont égaux et tel que \(x_k \prec_k y_k\).

Démontrez le théorème dans sa version simplifiée où \(X\) est le produit fini de \(n\) ensembles totalement ordonnés \((X_i,\preceq_i)_{i\in[n]}\).

En pratique, l'ordre lexicographique est souvent utilisé sur des \(X_i\) tous identiques et munis de la même relation d'ordre, par exemple \(X_i=\{0,1,2,\ldots,9\}\) muni de l'ordre naturel pour les dé­ve­lop­pe­ments décimaux illimités dans la représentation des nombres réels, ou encore \(X_i=\{a,b,c,\ldots,z\}\) muni de l'ordre alphabétique. Dans la suite nous nous intéresserons plus particulièrement aux mots d'un langage. Le formalisme est légèrement différent de celui que nous venons de présenter.

Un mot de \(k\) lettres, au sens usuel du terme, étant une suite de \(k\) lettres, il n'y a pas de gros efforts à faire pour transposer le concept dans le langage mathématique. Pour cela, on se donne un ensemble fini \(A=\{a_1,\ldots,a_{q}\}\) appelé alphabet à \(q\) éléments \(a_i\) qui sont les lettres ou les symboles de l'alphabet. Par convention on suppose que cet alphabet est muni d'une relation d'ordre total \(\preceq\) héritée de l'ordre naturel via l'application bijective et croissante \(a\) : \[\forall (i,j)\in{\ab{1}{q}}^2\quad i \leqslant j\ \Leftrightarrow a_i\preceq a_j.\]

Toute séquence \(u\) de symboles de \(A\) définit un mot et on appelle longueur du mot le nombre de ses symboles noté \(|u|\) ou \(\#u\). Pour parfaire le modèle, on ne sépare pas les symboles de la suite et on écrira tout simplement \(tranches\) pour la suite de symboles \(t,r,a,n,c,h,e,s\).

On note \(A^*\) l'ensemble (infini) de tous les mots possibles (qui remplace \(X\) dans la définition \((\ref{eq:defprodinf})\)). On équipe \(A^*\) d'une loi de composition interne, la concaténation que l'on note comme un produit et qui associe à deux mots \(u\) et \(v\) le mot \(u.v\) constitué des lettres de \(u\) suivies par les lettres de \(v\). Cette loi est associative et l'ajout d'un élément neutre \(\epsilon\) appelé mot vide font de \(A^*\) un monoïde.

Si \(A = \{0,1\}\) alors \(A^* = \{\varepsilon, 0, 1, 00, 01, 10, 11, 100, ...\}\). Si \(A=\{a,b,\ldots,z\}\) est l'alphabet latin, la concaténation bon.jour des mots bon et jour est le mot bonjour (qui est différent de \(jourbon,\) la loi n'est bien sûr pas commutative.)

Nous ne fournissons pas d'exemples de comparaisons de deux mots selon l'ordre lexicographique*souvent confondu avec l'ordre alphabétique dans le langage courant, l'expérience du lecteur en la matière devrait suffire. L'objectif de cette section est de comprendre la formalisation à partir de la con­nais­san­ce pratique et non l'inverse.

Un algorithme

La relation d'ordre étant à présent total, on peut toujours comparer deux mots \(u=u_1u_2\ldots u_n\) et \(v=v_1v_2\ldots v_m\) de \(A^*,\) on aura donc \(u\leqslant v\) ou \(v \lt u,\) ce que l'on peut décomposer en 3 situations : \begin{equation*} (u = v)\ \veebar\ (u \lt v)\ \veebar\ (u \gt v). \end{equation*}

La condition \((\ref{eq:condtl})\) suggère comment écrire l'algorithme. La première phase consiste à comparer terme-à-terme les symboles \(u_i\) et \(v_i\) de \(u\) et \(v\) en partant du premier symbole et avancer tant qu'ils sont identiques et qu'il reste des symboles à comparer. Plusieurs cas sont alors possibles en sortant de cette boucle :

  1. On n'a atteint ni la fin de \(u\) ni celle de \(v\). Dans ce cas on peut décider si \(u \lt v\) ou \(v \gt u\) en comparant \(u_i\) et \(v_i\) suivant l'ordre alphabétique.
  2. On a atteint la fin de l'un des deux mots, disons \(u\) (le raisonnement est symétrique si c'est \(v\)). Dans ce cas, \(u\) est préfixe de \(v\) et on a \(u\leqslant v\) et on peut décider si c'est \(u \lt v\) ou \(u=v\) selon qu'on a atteint ou non la fin de \(v\) également.

L'algorithme ci-dessous renvoie l'une des trois valeurs \(-1,\) \(0\) ou \(1\) selon que \(u \lt v,\) \(u=v\) ou que \(u>v,\) respectivement.

ALGORITHME ComparaisonLexico(u,v):{-1,0,+1}
DONNEES
   u, v: listes de caractères
VARIABLES
   i: entier
DEBUT
   i ← 1
   TQ ((i ≤ |u|) ET (i ≤ |v|) ET (u[i] = v[i]) FAIRE
      i ← i + 1
   FTQ   
   SI ((i > |u|) ET (i > |v|)) ALORS
      RENVOYER  0  // u = v
   SINON SI ((i > |u|) OU ((i ≤ |v|) ET (u[i] < v[i]))) ALORS
      RENVOYER -1  // u < v
   SINON SI ((i > |v|) OU ((i ≤ |u|) ET (u[i] > v[i]))) ALORS
      RENVOYER +1  // u > v
   FSI
FIN

L'animation ci-dessous illustre le fonctionnement de l'algorithme, il suffit de saisir les deux mots à comparer.

NB. Il faut noter que la justesse de l'algorithme dépend de l'hypothèse (toujours discutable) que l'interprétation d'une formule conjonctive (resp. disjonctive) s'arrête dès qu'une condition n'est pas satisfaite (resp. satisfaite).

Faites une preuve d'arrêt de cet algorithme. Démontrez que l'algorithme est valide.

Complexité

Nous allons évaluer la complexité de l'algorithme en fonction du nombre de comparaisons entre symboles des deux mots. Notons \(n:=|u|\) et \(m:=|v|\). Dans le meilleur des cas, le premier symbole suffit pour conclure, ainsi \(\check T(n,m)=\Theta(1)\) et dans le pire des cas il faut parcourir tous les symboles du mot le plus court donc \(\hat T(n,m)=\Theta(\min\{n,m\})\).

Le cas moyen, comme toujours, est plus délicat à obtenir. Comme pour les tris comparatifs, l'algorithme repose sur des comparaisons, ici entre symboles des mots, il est donc légitime de les dénombrer pour estimer sa complexité. On suppose que \(n\leqslant m\) et on partitionne l'ensemble \({\Omega}\) des instances de tailles \(n\) et \(m\) en fonction du nombre de symboles comparés pour décider qui, de \(u\) et \(v,\) est le mot le plus petit. Ce nombre de comparaisons entre symboles est compris entre \(1\) et \(n,\) et il faudra être attentif au cas limite pour \(n\) comparaisons.

Notons \({\Omega}_k\) la partie de \({\Omega}\) contenant toutes les instances qui nécessitent \(k\) comparaisons, \(1\leqslant k\leqslant n.\) On a bien sûr, comme toujours avec un tel partitionnement : \begin{equation}\label{eq:compmoyenne} \bar T(n,m)=\frac{1}{|{\Omega}|}\sum_{k=1}^{n}k\,|{\Omega}_k|. \end{equation}

Il est clair que l'ensemble \(A^{\ell}\) des mots de longueur \(\ell\) a pour cardinal \(q^{\ell}\) en supposant que \(|A|=q\). Comme \(\Omega=A^n\times A^m\), le nombre d'instances possibles est égal à

\begin{equation} |\Omega|=q^n\times q^m = q^{n+m}. \end{equation}

Il faut maintenant dénombrer les classes \(\Omega_k\) formant la partition \((\Omega_k)_{k=1}^n\) de \(\Omega\). Pour toutes les valeurs de \(k\in\ab{1}{n-1},\) le raisonnement est le même : s'il y a eu \(k\) comparaisons, c'est que les \(k-1\) premiers symboles des mots \(u\) et \(v\) étaient iodentiques et que le \(k\)ème était différent : \[ \left(\forall i\in\ab{1}{k-1}\ \ u_i=v_i\right)\ \land\ (u_k\neq v_k). \] Pour tout \(k\in \ab{1}{n-1}\) :

  1. Il y a \(q^{k-1}\) façons de fixer les \(k-1\) premières lettres communes à \(u\) et \(v\) ;
  2. Il y a \(q\) façons de fixer la lettre \(u_k\) et \(q-1\) façons de fixer la lettre \(v_k\) puisqu'elles sont différentes ;
  3. Il reste \(q^{n-k}\) façons de compléter les \(n-k\) lettres restantes de \(u\) et \(q^{m-k}\) façons de compléter \(v\).

Par conséquent : \begin{equation}\label{eq:Ek} \forall k\in\ab{1}{n-1}\quad {\color{steelblue}|{\Omega}_k|} = q^{k-1}q(q-1)q^{n-k}q^{m-k}={\color{steelblue}q^{n+m-k}(q-1)}. \end{equation}

Il reste à calculer \(|{\Omega}|_{n}\) qui est un cas particulier, car cette classe couvre deux situations différentes : \(u_k\neq v_k\) ou alors \(u\) est un préfixe de \(v\) et dans ce cas \(u_k = v_k\) et la décision a été prise après les comparaisons entre symboles de \(u\) et \(v\). La première situation se traite exactement comme les précédentes et recouvre donc \(q^{m}(q-1)\) instances. La deuxième recouvre \(q^m\) instances puisque \(v\) est quelconque et \(u\) est entièrement déterminé par les \(n\) premiers symboles de \(v\) : \(\forall i\in\ab{1}{n}\ u_i=v_i\). on a donc \begin{equation} \label{eq:En} {\color{steelblue}|\Omega_n|}=q^m(q-1)+q^m={\color{steelblue}q^{m+1}}. \end{equation}

Vérifiez que la formule de sommation est satisfaite pour la partition \((\Omega_k)_{k=1}^n\) de \(\Omega\), i.e. que \[\sum_{k=1}^n|\Omega_k|=|\Omega|.\]
On part de (\ref{eq:Ek}) et (\ref{eq:En}) pour ce calcul : \begin{align*} \sum_{k=1}^{n}|{\Omega}_k| &= {\color{steelblue}|\Omega_n|} + \sum_{k=1}^{n-1}{\color{steelblue}|{\Omega}_k|} \\ &=q^{m+1} + \sum_{k=1}^{n-1}q^{n+m-k}(q-1)\\ &=q^{m+1} + q^{m}(q-1)\sum_{k=1}^{n-1}q^{n-k}\\ &=q^{m+1} + q^{m}(q-1)\left(\frac{q^n - q}{q- 1}\right)\\ &=q^{m+n} \end{align*}

On peut remplacer les valeurs \(|\Omega_k|\) déterminées en (\ref{eq:Ek}) et (\ref{eq:En}) dans l'expression (\ref{eq:compmoyenne}) pour obtenir \begin{equation}\label{eq:compfinale} \bar T(n,m) = \frac{n}{q^{n-1}} + (q-1)\underbrace{\color{yellow}\sum_{k=1}^{n-1}kq^{-k}}_{\color{yellow}S(q^{-1})} \end{equation}

Et comme on devait s'y attendre, l'expression montre que la complexité ne dépend que de la plus petite des deux longueurs, ici \(n\) par hypothèse. Il reste à évaluer la somme \(\color{yellow}S(q^{-1}).\) On applique ce Lemme :

\[ S(q^{-1})=\frac{q^{n}+n(q-1)-1}{q^{n-1}(q-1)^2}. \]

Donc \begin{align*} \bar T(n,m) &= \frac{n}{q^{n-1}} + \frac{q^{n}+n(q-1)-1}{q^{n-1}(q-1)}\\ &= \frac{q^{n}+2n(q-1)-1}{q^{n-1}(q-1)}\\ &=\frac{q}{q-1}+\frac{2n(q-1)-1}{q^{n-1}(q-1)}\\ \end{align*}

Nous avons supposé que \(n\leqslant m,\) or nous venons de voir que la complexité moyenne ne dépend que de la longueur du mot le plus court, on peut donc conclure que :

La complexité moyenne de la comparaison lexicographique entre deux mots de longueurs \(n\) et \(m\) est donnée par : \begin{equation} \label{trilexasympt} \bar T(n,m) = \frac{q}{q-1}+\frac{2n(q-1)-1}{q^{n-1}(q-1)} \end{equation}

NB. Il faut déduire de l'équation \((\ref{trilexasympt})\) que la moyenne du nombre de comparaisons entre symboles est asymp­to­ti­que­ment égale à \(\frac{q}{q-1}\).

Algorithme du tri lexicographique

Présentation

On reprend les notations du chapitre Ordre lexicographique dans lequel nous avons défini une relation d'ordre totale sur l'ensemble \(A^*\) des mots sur un alphabet fini \(A:=\{a_1,a_2,\ldots,a_{q}\}\). Le tri par dénombrement n'est pas adapté à la situation, entre autres. Nous sommes en mesure d'appliquer les algorithmes de tri que nous avons déjà étudiés en remplaçant une liste de nombres par une liste de mots et l'ordre naturel par l'ordre lexicographique. Cependant, la nature particulière des objets à trier va nous permettre de développer une méthode plus adaptée, sous-entendu, plus rapide.

Supposons pour le moment que la liste de mots à trier contienne uniquement des mots de même longueur \(l\) (nous verrons plus loin comment généraliser le processus à des listes de mots de longueurs différentes). Nous allons appliquer l'algorithme du tri par répartition TriRépartition \(l\) fois sur la liste \(L\) en triant successivement les mots de la liste selon leur \(k\)-ème symbole pour \(k\) variant de \(l\) à \(1\) (voir l'exemple). Commençons par étudier un exemple élémentaire avec la liste de quatre mots \[L=[le,as,os,ai]\] de longueur \(l=2\).

Dans un premier temps nous trions les mots de la liste \(L\) en fonction de leur deuxième symbole. En lisant la table suivante de la gauche vers la droite, on peut suivre l'évolution du contenu de cette liste et des différents casiers \(C_{\alpha}\) (ceux qui ne sont pas utilisés sont omis), au fur et à mesure de l'avancement du tri par répartition. Le symbole \(\color{yellow}\bullet\) indique où va être rangé le prochain mot de la liste :

\begin{align*} &\ 0 & &\ 1 & &\ 2 & &\ 3 & &\ 4 \\ L&=[l{\color{yellow}e},as,os,ai] & L&=[a{\color{yellow}s},os,ai] & L&=[o{\color{yellow}s},ai] & L&=[a{\color{yellow}i}] & L&=[\;]\\ C_{\color{yellow}e} &= [{\color{yellow}\bullet}] & C_e&=[le] & C_e&=[le] & C_e&=[le] & C_e&=[le]\\ C_i &= [\;] & C_i&=[\;] & C_i&=[\;] & C_{\color{yellow}i}&=[{\color{yellow}\bullet}] & C_i&=[ai]\\ C_s &= [\;] & C_{\color{yellow}s}&=[{\color{yellow}\bullet}] & C_{\color{yellow}s}&=[as,{\color{yellow}\bullet}] & C_s&=[as,os] & C_s&=[as,os] \end{align*}
Tri par répartition de la liste \(L=\{le,as,os,ai\}\) suivant la 2ème symbole.

La concaténation des casiers \(C_{\alpha}\) (y compris les casiers vides qui ne sont pas montrés) fournit une nouvelle liste \(L=[le,ai,as,os]\). On recommence de la même façon, mais la comparaison se fait cette fois sur la première symbole :

\begin{align*} &\ 0 & &\ 1 & &\ 2 & &\ 3 & &\ 4 \\ L&=[{\color{#46F}l}e,ai,as,os] & L&=[{\color{#46F}a}i,as,os] & L&=[{\color{#46F}a}s,os] & L&=[{\color{#46F}o}s] & L&=[\;]\\ C_a&=[\;] & C_{\color{#46F}a}&=[{\color{#46F}\bullet}] & C_{\color{#46F}a}&=[ai,{\color{#46F}\bullet}] & C_a&=[ai,as] & C_a&=[ai,as] \\ C_{\color{#46F}l}&=[{\color{#46F}\bullet}] & C_l&=[le] & C_l&=[le] & C_l&=[le] & C_l&=[le]\\ C_o&=[\;] & C_o&=[\;] & C_o&=[\;] & C_{\color{#46F}o}&=[{\color{#46F}\bullet}] & C_o&=[os] \end{align*}
Tri par répartition de la liste \(L=\{le,ai,as,os\}\) suivant la 1ère symbole.

Maintenant que toutes les symboles ont été comparées, la concaténation des casiers fournit la liste triée \[L=[ai,as,le,os].\] Il peut sembler paradoxal de commencer par la dernière symbole plutôt que la première, mais la preuve du lemme suivant fournit l'explication. Pour faciliter la compréhension, quand nous ferons référence à la \((-k)\)-ème symbole d'un mot \(u=u_1u_2\ldots u_l\in A^l,\) il s'agira de la \(k\)-ème symbole en partant de la fin du mot, ainsi \(u[-1]=u[l]\) et plus généralement \(u[-k]=u[l-k+1]\). D'autre part, on désigne par \(u[k:\;]\) le suffixe \(u_ku_{k+1}\ldots u_l\) si \(k\leqslant l\) et le mot vide \(\varepsilon\) si \(k \gt l\). On a donc en particulier \(u[1:\;]=u\).

Soit \(L\) une liste de mots de même longueur \(l \gt 0\). À l'issue des \(l\) tris par répartition de la liste suivant les clés de tri décroissantes \(k=l\) à \(k=1,\) les mots de la liste \(L\) sont triés dans l'ordre lexicographique.
Soit \(k\leqslant l\). Dans cette preuve uniquement, on désignera par \(L_{k}\) la liste obtenue après le \(k\)-ème tri par répartition sur la \((-k)\)-ème symbole et \(S_{k}\) la liste des suffixes \(u[-k:\;]\) des mots de \(L_{k}\). On a \(L_0=L\) et \(S_{l}=L_{l}\). Nous allons prouver par récurrence que la liste \(S_{l}\) est triée. Soit \(P(k)\) le prédicat :
après le \(k\)-ème tri par répartition, la liste \(S_{k}\) est triée

La liste \(S_{1}\) contient uniquement la dernière symbole de chacun des mots de la liste \(L\) après le premier tri par répartition donc \(P(1)\) est vraie puisqu'il s'agit du tri alphabétique. Soit \(k \lt l\). À l'étape \(k+1\) l'algorithme TriRépartition trie les mots de la liste \(L_{k}\) suivant la prochaine symbole à la position \(-(k+1)\). Soit \(u\) et \(v\) deux mots de la liste \(L_k\). Notons \(a_i\) et \(a_j\) leurs \((-(k+1))\)-ème symbole respectivement. Puisque le \((k+1)\)-ème tri par répartition se fait sur cette symbole, trois situations se présentent :

  1. Si \(i \lt j,\) l'algorithme range le mot \(u\) dans le casier \(i\) et le mot \(v\) dans le casier \(j\). Le mot \(u\) précède donc le mot \(v\) après la concaténation des casiers.
  2. Si \(i \gt j,\) le raisonnement est symétrique et le mot \(u\) suit donc le mot \(v\) après la concaténation des casiers.
  3. Si \(i = j\) et \(u[-k\!:\;]\preceq v[-k\!:\;]\) alors \(u\) est placé avant \(v\) dans la liste \(L_k\) d'après l'hypothèse de récurrence \(P(k),\) l'algorithme rangera donc \(u\) avant \(v\) dans le casier \(i\) et \(u\) sera placé avant \(v\) après la concaténation des casiers. Le raisonnement est symétrique si \(v[k\!:\;]\preceq u[k\!:\;]\).
Dans les trois cas, à l'issue du \(k+1\) tri, les suffixes \(u[-(k+1)\!:\;]\) et \(v[-(k+1)\!:\;]\) sont rangés dans la liste \(S_{k+1}\) conformément à la définition de l'ordre lexicographique, donc \(P(k+1)\) est vraie.

À ce stade, nous ne sommes en mesure de trier que des mots de même longueur. Notons \(l\) la longueur maximale des mots de la liste \(L\). Pour généraliser l'algorithme à une liste de mots de longueurs différentes, on commence par répartir les mots de la liste \(L\) dans \(l\) listes \(L_k,\ k\in[1,l]\) suivant leurs longueurs, les mots de longueur \(k\) étant rangés dans la liste \(L_k\). Cette opération est réalisée encore une fois par l'algorithme TriRépartition selon la longueur des mots. À l'issue de ce partitionnement la liste \(L\) est vide.

Le tri consiste toujours à effectuer \(l\) tris par répartition des mots de \(L\) selon les symboles d'indice \(l\) à \(1\). La seule différence avec le tri lexicographique de mots de même longueur consiste, avant d'effectuer le prochain tri par répartition à l'indice \(k,\) à insérer les mots de la liste \(L_k\) au début de la liste \(L\). Ainsi avant le premier tri par répartition sur les symboles à la position \(k=l,\) on insère la liste \(L_l\) à gauche de la liste \(L,\) autrement dit la nouvelle liste \(L\) est obtenue par concaténation de la liste \(L_k\) et de l'ancienne liste \(L\).

Pourquoi faut-il réaliser la concaténation \(L_k.L\) et pas \(L.L_k\) avant chaque tri par répartition ?
Modifiez l'algorithme TriRépartition pour écrire un algorithme Partition qui partitionne la liste \(L\) en listes \(L_k\) des mots de longueurs \(k\) et renvoie la liste \(P\) de ces listes. Calculez la complexité de cet algorithme.
\(L=\,[\)\(]\)

Il ne reste plus à présent qu'à articuler les deux opérations. L'algorithme opère donc en deux phases. La première phase partitionne la liste initiale \(L\) en \(l\) listes \(L_k\) contenant tous les mots de longueur \(k\in [1\!:\!l]\). La seconde phase applique \(l\) fois l'algorithme du tri par répartition selon la \((k)\)-ème symbole des mots, en partant de \(k=l\) jusqu'à \(k=1\).

ALGORITHME TriLexico(@L):liste
DONNEES
   L: liste de mots
VARIABLES
   P: liste la partition en L_k
   k: entier
DEBUT
   P ← Partition(L)
   k ← #P    #P = l
   TQ k > 0 FAIRE
      Concaténer(P[k],L)
      TriRépartition(L,(u,k) ↦ j)  u_k = a_j
      k ← k - 1
   FTQ
FIN

La fonction d'adressage \((u,k)\mapsto{\color{yellow}j}\) utilisée à instruction #12 est celle qui renvoie le numéro \({\color{yellow}j}\) de la \(k\)-ème symbole \(u_k\) du mot \(u,\) i.e. telle que \(u_k=a_{\color{yellow}j}\).

Complexité

On rappelle que \(q:=\#A\). On note \(n:=\#L\) la longueur de la liste à trier, \(n_i:=\#L_i\) la longueur de la liste des mots de longueur \(i\) et \(\ell\) la longueur du mot le plus long. On a \begin{equation}\label{eq:sumlongueurs} \sum_{i=1}^{\ell}\,n_i=n. \end{equation}

L'algorithme Partition demande \(\Theta(n)\) opérations pour calculer la longueur maximale \(m\) (instruction #9) puis \(\Theta(m)\) opérations pour initialiser les \(m\) listes \(L_i\) (instructions #11 à #14). La répartition des \(n\) mots dans les \({\ell}\) casiers a un coût en \(\Theta(n),\) on a donc \begin{equation} T_{\textsf{Partition}}\ \ (n,{\ell})=\Theta(n) + \Theta({\ell}) \end{equation}

L'algorithme TriRépartition a un coût de \(\Theta(q)\) pour initialiser les \(q\) casiers à la liste vide, un coût de \(\Theta(\nu)\) pour répartir les \(\nu\) éléments de la liste d'appel et un coût de \(\Theta(q)\) pour concaténer les \(q\) casiers, soit au total \begin{equation} T_{\textsf{Répartition}}\ \ (\nu,q)=\Theta(\nu) + \Theta(q) \end{equation}

Pour évaluer la complexité de l'algorithme TriLexico, il nous reste à calculer le coût des \({\ell}\) appels successifs à l'algorithme de tri par répartition (instruction #12) pour chacune des \({\ell}\) listes \(L_k\). Le \(k\)-ème appel se fait sur une liste dont la longueur \(\nu\) est égale à \[n_{m}+n_{m-1}+\cdots+n_{m-k+1}\] et donc majorée par la longueur \(n\) de la liste initiale d'après (\ref{eq:sumlongueurs}), soit \(O(n)\). On peut à présent conclure : \begin{align*} T_{\textsf{TriLexico}}\ \ (n,l,q) &=\Theta(n) + \Theta(l)\quad\text{(instruction #8)}\\ &\ +l\Theta(1)\quad\text{(instructions #10, #11 et #13)}\\ &\ +l(O(n)+\Theta(q))\quad\text{(instruction #12)}\\ &=O(ln) + \Theta(lq) \end{align*} Le cardinal de l'alphabet étant constant et la longueur maximale \({\ell}\) des mots étant asymptotiquement négligeable devant la taille de la liste, on peut conclure que

La complexité de l'algorithme du tri lexicographique TriLexico en fonction du nombre \(n\) de mots à trier est \begin{align*} T_{\textsf{TriLexico}}\ \ (n)=\Theta(n). \end{align*}

Travaux pratiques

Écrivez une fonction C PPL(u, v) qui renvoie 0 si \(u=v,\) -1 si \(u \lt v\) et 1 si \(u \gt v\).
Écrivez les fonctions C Partition(L), TriRepartition(L) et TriLexico(L). Écrivez une fonction GenListe(n,l) qui renvoie une liste de \(n\) mots de longueur \(\leqslant l\) choisis aléatoirement. Comparez le temps d'exécution pour trier une liste avec vos fonctions et la fonction de tri de C.