Les algorithmes de tris
Présentation de quelques algorithmes de tris, réalisés en C, permettant de réordonner les informations contenues dans un tableau d’entiers. Dans cet article nous allons mettre en avant les différences entre les algorithmes et mesurer leur complexité.
Tri par insertion
void Insertion (int tab[], int ntab)
{
int i,j,k,tmp;
for(k=1;k<ntab;k++)
{
tmp=tab[k];
j=k-1;
while(j>=0 && tmp<tab[j])
{
tab[j+1]=tab[j];
j--;
}
tab[j+1]=tmp;
}
}
Complexité en temps
Meilleur cas (tableau déjà trié) : Dans ce cas, chaque élément est comparé une seule fois avec l’élément précédent. La complexité est donc linéaire : O(n).
Pire cas (tableau trié en ordre inverse) : Chaque nouvel élément doit être comparé avec tous les éléments déjà triés et déplacé au début du tableau. La complexité est quadratique : O(n^2).
Cas moyen : En moyenne, chaque élément doit être comparé avec la moitié des éléments déjà triés. La complexité est également quadratique : O(n^2).
Complexité en espace
L’algorithme de tri par insertion est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. La complexité en espace est donc O(1).
Résumé
- Meilleur cas : O(n)
- Pire cas : O(n^2)
- Cas moyen : O(n^2)
- Complexité en espace : O(1)
Le tri par insertion est efficace pour les petits tableaux ou les tableaux qui sont déjà partiellement triés, mais il devient inefficace pour les grands tableaux non triés en raison de sa complexité quadratique en temps dans le pire cas.
Exemple
Tri par sélection
void Selection (int tab[], int ntab)
{
int i,j,k,max,rangmax,tmp;
k=ntab;
while(k>0)
{
max=tab[0];
rangmax=0;
for(j=0;j<k;j++)
{
if(tab[j]>max)
{
max=tab[j];
rangmax=j;
}
}
tmp=tab[k-1];
tab[k-1]=max;
tab[rangmax]=tmp;
k--;
}
}
Complexité en temps
Meilleur cas : Le tri par sélection effectue toujours le même nombre de comparaisons, quel que soit l’état initial du tableau. Pour chaque élément, il parcourt le reste du tableau pour trouver le maximum (ou minimum). La complexité est donc quadratique : O(n^2).
Pire cas : Comme pour le meilleur cas, le tri par sélection effectue toujours le même nombre de comparaisons. La complexité est quadratique : O(n^2).
Cas moyen : De même, le nombre de comparaisons reste constant indépendamment de l’ordre initial des éléments. La complexité est quadratique : O(n^2).
Complexité en espace
Le tri par sélection est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. La complexité en espace est donc O(1).
Résumé
- Meilleur cas : O(n^2)
- Pire cas : O(n^2)
- Cas moyen : O(n^2)
- Complexité en espace : O(1)
Le tri par sélection est simple à implémenter et peut être efficace pour les petits tableaux, mais il devient inefficace pour les grands tableaux en raison de sa complexité quadratique en temps.
Exemple
Tri par échange (ou tri à bulles)
void Echange (int tab[], int ntab)
{
int k,tmp,i;
k=ntab-1;
while(k>0)
{
for(i=0;i<k;i++)
{
if(tab[i]>tab[i+1])
{
tmp=tab[i+1];
tab[i+1]=tab[i];
tab[i]=tmp;
}
}
k--;
}
}
Complexité en temps
Meilleur cas (tableau déjà trié) : Dans ce cas, une seule passe est nécessaire pour vérifier que le tableau est déjà trié. La complexité est linéaire : O(n).
Pire cas (tableau trié en ordre inverse) : Chaque élément doit être comparé et échangé avec tous les autres éléments. La complexité est quadratique : O(n^2).
Cas moyen : En moyenne, chaque élément doit être comparé et échangé avec la moitié des autres éléments. La complexité est également quadratique : O(n^2).
Complexité en espace
Le tri par échange est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. La complexité en espace est donc O(1).
Résumé
- Meilleur cas : O(n)
- Pire cas : O(n^2)
- Cas moyen : O(n^2)
- Complexité en espace : O(1)
Le tri par échange est simple à implémenter et peut être efficace pour les petits tableaux ou les tableaux presque triés, mais il devient inefficace pour les grands tableaux non triés en raison de sa complexité quadratique en temps dans le pire cas.
Exemple
Shakesort
Le tri par Shakesort, également connu sous le nom de tri cocktail, est une variante du tri à bulles qui fonctionne en deux passes : une passe de gauche à droite et une passe de droite à gauche. Cela permet de réduire le nombre de passes nécessaires pour trier le tableau.
void Shakesort(int tab[], int ntab)
{
int g,d,i,tmp;
g=0;
d=ntab-1;
while(d>g)
{
for(i=g;i<d;i++)
{
if(tab[i]>tab[i+1])
{
tmp=tab[i+1];
tab[i+1]=tab[i];
tab[i]=tmp;
}
}
d--;
for(i=d;i>=g;i--)
{
if(tab[i]<tab[i-1])
{
tmp=tab[i];
tab[i]=tab[i-1];
tab[i-1]=tmp;
}
}
g++;
}
}
Complexité en temps
Meilleur cas (tableau déjà trié) : Dans ce cas, une seule passe est nécessaire pour vérifier que le tableau est déjà trié. La complexité est linéaire : O(n).
Pire cas (tableau trié en ordre inverse) : Chaque élément doit être comparé et échangé avec tous les autres éléments dans les deux directions. La complexité est quadratique : O(n^2).
Cas moyen : En moyenne, chaque élément doit être comparé et échangé avec la moitié des autres éléments dans les deux directions. La complexité est également quadratique : O(n^2).
Complexité en espace
Le tri par Shakesort est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. La complexité en espace est donc O(1).
Résumé
- Meilleur cas : O(n)
- Pire cas : O(n^2)
- Cas moyen : O(n^2)
- Complexité en espace : O(1)
Le tri par Shakesort est plus efficace que le tri à bulles pour les tableaux presque triés, car il réduit le nombre de passes nécessaires en triant dans les deux directions. Cependant, il reste inefficace pour les grands tableaux non triés en raison de sa complexité quadratique en temps dans le pire cas.
Exemple
Shellsort
Shellsort est une généralisation du tri par insertion qui permet d’échanger des éléments éloignés. Ce tri utilise une séquence de pas afin de trier les éléments par la suite.
Il existe différents types de séquence dont l’usage impacte la complexité en temps de la fonction de tri.
Ici j’utilise la séquence de Knuth p = (p-1) / 3
La fonction est donc composée de deux parties :
- le calcul du pas
for(p=1;p*3+1<ntab;p=p*3+1) ; - l’utilisation du pas afin d’effectuer le tri
while(p >= 1) { for(i=p; i<ntab; i+=p) { j=i-p; tmp=tab[i]; while(j >= 0 && tmp < tab[j]) { tab[j+p] = tab[j]; j -= p; } tab[j+p] = tmp; } p = (p-1) / 3; }
Fonction complète
void Shellsort(int tab[], int ntab)
{
int p,i,j,tmp;
// calcul du pas
for(p=1;p*3+1<ntab;p=p*3+1)
;
while(p>=1)
{
for(i=p;i<ntab;i+=p)
{
j=i-p;
tmp=tab[i];
while(j>=0 && tmp<tab[j])
{
tab[j+p]=tab[j];
j-=p;
}
tab[j+p]=tmp;
}
p=(p-1)/3;
}
}
La complexité de la fonction Shellsort dépend de la séquence de pas utilisée. Ici la séquence de pas utilisée est la séquence de Knuth, définie par la formule :
[ p_k = 1, 4, 13, 40, 121, \ldots ]
où chaque terme est calculé comme :
[ p_{k+1} = 3 \times p_k + 1 ]
Complexité en temps
Meilleur cas : La complexité en temps dans le meilleur cas pour Shellsort avec la séquence de Knuth est O(n log n).
Pire cas : La complexité en temps dans le pire cas pour Shellsort avec la séquence de Knuth est O(n^(3/2)).
Cas moyen : La complexité en temps dans le cas moyen pour Shellsort avec la séquence de Knuth est généralement considérée comme étant entre O(n log n) et O(n^(3/2)).
Complexité en espace
Shellsort est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. La complexité en espace est donc O(1).
Résumé
- Meilleur cas : O(n log n)
- Pire cas : O(n^(3/2))
- Cas moyen : Entre O(n log n) et O(n^(3/2))
- *Complexité en espace : O(1)
La séquence de Knuth est connue pour améliorer l’efficacité du tri par rapport à d’autres séquences de pas, ce qui rend Shellsort avec cette séquence plus performant que le tri par insertion ou le tri à bulles pour des tableaux de grande taille.
Exemple
Heapsort
Le tri par tas (Heapsort) est un algorithme de tri basé sur la structure de données appelée tas (heap). Le principe du tri par tas repose sur deux étapes principales : la construction d’un tas et l’extraction répétée de l’élément maximal (ou minimal) du tas pour le placer à sa position correcte dans le tableau.
Principe du tri Heapsort
Construction du tas : Construire un tas max (ou min) à partir du tableau non trié. Un tas max est une structure de données où chaque nœud est supérieur ou égal à ses enfants. Cette étape garantit que le plus grand élément du tableau se trouve à la racine du tas.
Extraction répétée : Échanger la racine du tas (le plus grand élément) avec le dernier élément du tableau. Réduire la taille du tas de 1 et rétablir la propriété de tas max en appelant la fonction placer (ou heapify) sur la nouvelle racine. Répéter ce processus jusqu’à ce que tous les éléments soient extraits et placés dans leur position correcte.
Fonction placer : La fonction placer (ou heapify) est utilisée pour rétablir la propriété de tas max après chaque extraction.
Voici le code de la fonction placer :
void placer(int g, int d, int *T)
{
int i,j,x,place_trouvee;
x=T[g];
i=g;
j=2*g+1;
place_trouvee=0;
while((j<=d) && !(place_trouvee))
{
if(j<d)
if (T[j+1]>T[j]) j++;
if(x>=T[j])
place_trouvee=1;
else
{
T[i]=T[j];
i=j;
j=2*i+1;
}
}
T[i]=x;
}
Fonction Heapsort La fonction Heapsort utilise la fonction placer pour trier le tableau.
Voici un exemple de la fonction Heapsort :
void Heapsort(int tab[], int ntab)
{
int d,g,tmp;
g=ntab/2;
d=ntab-1;
while(g>0)
{
placer(g-1,d,tab);
g--;
}
while(d>0)
{
tmp=tab[0];
tab[0]=tab[d];
tab[d]=tmp;
d--;
placer(0,d,tab);
}
}
Complexité en temps
Construction du tas : La construction initiale du tas max se fait en parcourant les éléments du tableau à partir du milieu jusqu’au début et en appelant la fonction placer pour chaque élément. La complexité de la construction du tas est O(n).
Extraction répétée : Après la construction du tas, chaque extraction de l’élément maximal (ou minimal) et la réorganisation du tas prennent O(log n). Comme il y a n éléments à extraire, la complexité totale de cette étape est O(n log n).
Complexité en espace
Le tri par tas est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. La complexité en espace est donc O(1).
Résumé
- Construction du tas : O(n)
- Extraction répétée : O(n log n)
- Complexité totale en temps : O(n log n)
- Complexité en espace : O(1)
Ce type de tri est efficace pour trier de grands tableaux et est souvent utilisé en raison de sa performance stable et de sa faible utilisation de mémoire.
Exemple
Quicksort
Le tri rapide (Quicksort) est un algorithme de tri efficace qui utilise la technique de “diviser pour régner”. Le principe de base du tri rapide repose sur la partition du tableau en sous-tableaux, puis sur le tri récursif de ces sous-tableaux. Voici les étapes principales du tri rapide :
- Choix du pivot : Un élément du tableau est choisi comme pivot. Le choix du pivot peut varier (premier élément, dernier élément, élément médian, etc.).
- Partition : Le tableau est réorganisé de manière à ce que tous les éléments inférieurs au pivot soient placés avant le pivot et tous les éléments supérieurs au pivot soient placés après le pivot. Le pivot est alors à sa position finale dans le tableau trié.
- Tri récursif : Les sous-tableaux formés par les éléments inférieurs et supérieurs au pivot sont triés récursivement en appliquant les mêmes étapes.
Fonctionnement de la partition
La fonction de partition divise le tableau en deux parties autour du pivot.
int partition(int t[], int gauche, int droite)
{
int i,j,k,l,p,tmp;
i=gauche;
j=droite-1;
k=(gauche+droite)/2;
p=t[k];
while(i<j)
{
while((i<k)&&(t[i]<=p)) i++;
while((j>k)&&(t[j]>=p)) j--;
if(i<k && j>k){
tmp=t[i];
t[i]=t[j];
t[j]=tmp;
}
if((j==k)&&(i<k))
{
//decalage du tableau vers la gauche
tmp=t[i];
for(l=i;l<k;l++)
t[l]=t[l+1];
t[k]=tmp;
k--;
p=t[k];
j--;
}
if((i==k)&&(j>k))
{
//decalage du tableau vers la droite
tmp=t[j];
for(l=j;l>k;l--)
if(l>0)
t[l]=t[l-1];
t[k]=tmp;
k++;
p=t[k];
i++;
}
}
return k;
}
La fonction Quicksort utilise la fonction de partition pour diviser le tableau et appelle récursivement Quicksort sur les sous-tableaux :
void Quicksort(int t[],int debut, int fin)
{
int k;
k=partition(t,debut,fin);
if(debut<fin)
{
if(debut+1<k) Quicksort(t,debut,k+1);
if(k<fin-1) Quicksort(t,k+1,fin);
}
}
Complexité en temps
Meilleur cas : Le meilleur cas se produit lorsque le pivot divise toujours le tableau en deux parties égales. La complexité est alors O(n log n), car chaque division du tableau nécessite un temps linéaire O(n) et il y a log n niveaux de récursion.
Pire cas : Le pire cas se produit lorsque le pivot est toujours le plus grand ou le plus petit élément, ce qui se produit souvent si le tableau est déjà trié ou presque trié. La complexité est alors O(n^2), car chaque division du tableau ne réduit la taille du tableau que d’un élément, nécessitant n niveaux de récursion avec un temps linéaire O(n) à chaque niveau.
Cas moyen : En moyenne, le pivot divise le tableau de manière relativement équilibrée. La complexité est alors O(n log n), car la probabilité de diviser le tableau en parties égales est élevée.
Complexité en espace
Quicksort est un algorithme en place, ce qui signifie qu’il ne nécessite pas de mémoire supplémentaire significative en dehors de l’entrée. Cependant, la profondeur de la pile de récursion peut atteindre O(log n) dans le meilleur cas et O(n) dans le pire cas. La complexité en espace est donc O(log n) en moyenne et O(n) dans le pire cas.
Résumé
- Meilleur cas : O(n log n)
- Pire cas : O(n^2)
- Cas moyen : O(n log n)
- Complexité en espace : O(log n) en moyenne, O(n) dans le pire cas
Quicksort est généralement très efficace et est souvent utilisé en raison de sa complexité moyenne de O(n log n) et de sa faible utilisation de mémoire. Cependant, des précautions doivent être prises pour éviter le pire cas, par exemple en utilisant des techniques de choix de pivot plus sophistiquées comme le pivot médian de trois.