Tri des segments en Java

1. Introduction

Dans ce didacticiel, nous verrons comment fonctionne le tri par tas et nous le mettrons en œuvre en Java.

  • Heap Sort est basé sur la structure de données Heap. ** Afin de bien comprendre Heap Sort, nous allons tout d’abord creuser dans Heaps et son implémentation.

2. Structure de données en tas

Un tas est une structure de données spécialisée en arborescence . Par conséquent, il est composé de nœuds. Nous assignons les éléments aux nœuds: chaque nœud contient exactement un élément.

En outre, les nœuds peuvent avoir des enfants. Si un nœud n’a pas d’enfant, nous l’appelons feuille.

Ce que Heap rend spécial sont deux choses:

  1. la valeur de chaque nœud doit être ** inférieure ou égale à toutes les valeurs stockées dans son répertoire.

enfants . c’est un arbre complet ** , ce qui signifie qu’il a le moins de hauteur possible

A cause de la 1ère règle, le moins d’éléments sera toujours à la racine de l’arbre .

La manière dont nous appliquons ces règles dépend de l’implémentation.

Les tas sont généralement utilisés pour implémenter des files d’attente prioritaires car Heap est une implémentation très efficace d’extraction du plus petit (ou du plus grand) élément.

2.1. Variantes de tas

Le tas a beaucoup de variantes, toutes diffèrent par certains détails d’implémentation.

Par exemple, ce que nous avons décrit ci-dessus est un Min-Heap, car un parent est toujours inférieur à tous ses enfants . Sinon, nous aurions pu définir Max-Heap, auquel cas un parent est toujours supérieur à ses enfants. Par conséquent, le plus grand élément sera dans le nœud racine.

Nous pouvons choisir parmi de nombreuses implémentations d’arbres. Le plus simple est un arbre binaire. Dans un arbre binaire, chaque nœud peut avoir au plus deux enfants. Nous les appelons enfant gauche et enfant droit .

Le moyen le plus simple d’appliquer la deuxième règle consiste à utiliser un arbre binaire complet. Un arbre binaire complet suit quelques règles simples:

  1. si un noeud n’a qu’un enfant, ce doit être son enfant gauche

  2. seul le noeud le plus à droite au niveau le plus profond peut avoir exactement un

enfant . les feuilles ne peuvent être qu’au plus profond

Voyons ces règles avec quelques exemples:

  1        2      3        4        5        6         7         8        9       10
 ()       ()     ()       ()       ()       ()        ()        ()       ()       ()
        /        \    / \    / \    / \     / \     /      /      / \
        ()         ()   ()  ()   ()  ()   ()  ()    ()  ()    ()       ()       ()  ()
                               /         \      / \     / \    /      / \
                               ()          ()     ()  ()    ()  ()   ()       ()  ()
                                                                            /                                                                            ()

Les arbres 1, 2, 4, 5 et 7 suivent les règles.

Les arbres 3 et 6 violent la première règle, 8 et 9 la deuxième règle et 10 violent la troisième règle.

Dans ce tutoriel, nous allons nous concentrer sur Min-Heap avec une implémentation Binary Tree .

2.2. Insérer des éléments

Nous devrions implémenter toutes les opérations d’une manière qui garde les invariants du tas. De cette façon, nous pouvons construire le tas avec des insertions répétées , nous allons donc nous concentrer sur l’opération d’insertion simple.

Nous pouvons insérer un élément en procédant comme suit:

  1. créer une nouvelle feuille qui est la fente la plus à droite disponible sur la plus profonde

niveler et stocker l’élément dans ce noeud . si l’élément est inférieur à son parent, nous les échangeons

  1. continuez à l’étape 2, jusqu’à ce que l’élément soit inférieur à son parent ou

devient la nouvelle racine

Notez que cette étape 2 ne violera pas la règle Heap, car si nous remplaçons la valeur d’un nœud par un moins, elle sera toujours inférieure à ses enfants.

Voyons un exemple! Nous voulons insérer 4 dans ce tas:

        2
      /\
     /  \
     3     6
   /\
   5   7

La première étape consiste à créer une nouvelle feuille qui stocke 4:

        2
      /\
     /  \
     3     6
   /\  /   5   7 4

Puisque 4 est inférieur à son parent, 6, nous les échangeons:

        2
      /\
     /  \
     3     4
   /\  /   5   7 6

Maintenant, nous vérifions si 4 est inférieur à son parent ou non. Comme son parent est 2, on s’arrête. Le tas est toujours valide et nous avons inséré le numéro 4.

Insérons 1:

        2
      /\
     /  \
     3     4
   /\  /\
   5   7 6   1

Nous devons échanger 1 et 4:

        2
      /\
     /  \
     3     1
   /\  /\
   5   7 6   4

Maintenant, nous devrions échanger 1 et 2:

        1
      /\
     /  \
     3     2
   /\  /\
   5   7 6   4

Puisque 1 est la nouvelle racine, nous nous arrêtons.

3. Mise en œuvre de tas en Java

Puisque nous utilisons un arbre binaire complet, nous pouvons l’implémenter avec un tableau : un élément du tableau sera un nœud de l’arbre. Nous marquons chaque noeud avec les index de tableau de gauche à droite, de haut en bas de la manière suivante:

        0
      /\
     /  \
     1     2
   /\  /   3   4 5

La seule chose dont nous avons besoin est de savoir combien d’éléments nous stockons dans l’arbre. De cette façon, l’index du prochain élément à insérer sera la taille du tableau.

En utilisant cette indexation, nous pouvons calculer l’index des nœuds parent et enfant:

  • parent: (index - 1)/2

  • enfant gauche: 2 ** index 1

  • enfant de droite: 2 ** index 2

Puisque nous ne voulons pas nous préoccuper de la réallocation de tableaux, nous simplifierons encore plus la mise en oeuvre et utiliserons un ArrayList .

Une implémentation de base de l’arbre binaire ressemble à ceci:

class BinaryTree<E> {

    List<E> elements = new ArrayList<>();

    void add(E e) {
        elements.add(e);
    }

    boolean isEmpty() {
        return elements.isEmpty();
    }

    E elementAt(int index) {
        return elements.get(index);
    }

    int parentIndex(int index) {
        return (index - 1)/2;
    }

    int leftChildIndex(int index) {
        return 2 **  index + 1;
    }

    int rightChildIndex(int index) {
        return 2 **  index + 2;
    }

}

Le code ci-dessus ajoute uniquement le nouvel élément à la fin de l’arborescence.

Par conséquent, nous devons parcourir le nouvel élément si nécessaire. Nous pouvons le faire avec le code suivant:

class Heap<E extends Comparable<E>> {

   //...

    void add(E e) {
        elements.add(e);
        int elementIndex = elements.size() - 1;
        while (!isRoot(elementIndex) && !isCorrectChild(elementIndex)) {
            int parentIndex = parentIndex(elementIndex);
            swap(elementIndex, parentIndex);
            elementIndex = parentIndex;
        }
    }

    boolean isRoot(int index) {
        return index == 0;
    }

    boolean isCorrectChild(int index) {
        return isCorrect(parentIndex(index), index);
    }

    boolean isCorrect(int parentIndex, int childIndex) {
        if (!isValidIndex(parentIndex) || !isValidIndex(childIndex)) {
            return true;
        }

        return elementAt(parentIndex).compareTo(elementAt(childIndex)) < 0;
    }

    boolean isValidIndex(int index) {
        return index < elements.size();
    }

    void swap(int index1, int index2) {
        E element1 = elementAt(index1);
        E element2 = elementAt(index2);
        elements.set(index1, element2);
        elements.set(index2, element1);
    }

   //...

}

Notez que puisque nous devons comparer les éléments, ils doivent implémenter java.util.Comparable .

4. Tri des tas

Comme la racine du tas contient toujours le plus petit élément , l’idée derrière le tri de tas est assez simple: supprimez le nœud racine jusqu’à ce que le tas devienne vide .

La seule chose dont nous avons besoin est une opération de suppression, qui maintient le segment de mémoire dans un état cohérent. Nous devons nous assurer que nous ne violons pas la structure de l’arbre binaire ou de la propriété Heap.

  • Pour conserver la structure, nous ne pouvons supprimer aucun élément, à l’exception de la feuille la plus à droite. ** L’idée est donc de supprimer l’élément du nœud racine et de stocker la feuille la plus à droite dans le nœud racine.

Mais cette opération violera très certainement la propriété Heap. Ainsi, si la nouvelle racine est supérieure à l’un de ses nœuds enfants, nous l’échangons avec son plus petit nœud enfant ** . Etant donné que le plus petit noeud est inférieur à tous les autres noeuds enfant, il ne viole pas la propriété Heap.

Nous continuons à échanger jusqu’à ce que l’élément devienne une feuille ou moins que l’ensemble de ses enfants.

Supprimons la racine de cet arbre:

        1
      /\
     /  \
     3     2
   /\  /\
   5   7 6   4

Tout d’abord, nous plaçons la dernière feuille dans la racine:

        4
      /\
     /  \
     3     2
   /\  /   5   7 6

Puis, comme il est plus grand que ses deux enfants, nous l’échangeons avec son plus petit enfant, qui est 2:

        2
      /\
     /  \
     3     4
   /\  /   5   7 6

4 est inférieur à 6, alors nous nous arrêtons.

5. Implémentation du tri de tas en Java

Avec tout ce que nous avons, supprimer la racine (popping) ressemble à ceci:

class Heap<E extends Comparable<E>> {

   //...

    E pop() {
        if (isEmpty()) {
            throw new IllegalStateException("You cannot pop from an empty heap");
        }

        E result = elementAt(0);

        int lasElementIndex = elements.size() - 1;
        swap(0, lasElementIndex);
        elements.remove(lasElementIndex);

        int elementIndex = 0;
        while (!isLeaf(elementIndex) && !isCorrectParent(elementIndex)) {
            int smallerChildIndex = smallerChildIndex(elementIndex);
            swap(elementIndex, smallerChildIndex);
            elementIndex = smallerChildIndex;
        }

        return result;
    }

    boolean isLeaf(int index) {
        return !isValidIndex(leftChildIndex(index));
    }

    boolean isCorrectParent(int index) {
        return isCorrect(index, leftChildIndex(index)) && isCorrect(index, rightChildIndex(index));
    }

    int smallerChildIndex(int index) {
        int leftChildIndex = leftChildIndex(index);
        int rightChildIndex = rightChildIndex(index);

        if (!isValidIndex(rightChildIndex)) {
            return leftChildIndex;
        }

        if (elementAt(leftChildIndex).compareTo(elementAt(rightChildIndex)) < 0) {
            return leftChildIndex;
        }

        return rightChildIndex;
    }

   //...

}

Comme nous l’avons dit précédemment, le tri consiste simplement à créer un segment de mémoire et à supprimer la racine à plusieurs reprises:

class Heap<E extends Comparable<E>> {

   //...

    static <E extends Comparable<E>> List<E> sort(Iterable<E> elements) {
        Heap<E> heap = of(elements);

        List<E> result = new ArrayList<>();

        while (!heap.isEmpty()) {
            result.add(heap.pop());
        }

        return result;
    }

    static <E extends Comparable<E>> Heap<E> of(Iterable<E> elements) {
        Heap<E> result = new Heap<>();
        for (E element : elements) {
            result.add(element);
        }
        return result;
    }

   //...

}

Nous pouvons vérifier que cela fonctionne avec le test suivant:

@Test
void givenNotEmptyIterable__whenSortCalled__thenItShouldReturnElementsInSortedList() {
   //given
    List<Integer> elements = Arrays.asList(3, 5, 1, 4, 2);

   //when
    List<Integer> sortedElements = Heap.sort(elements);

   //then
    assertThat(sortedElements).isEqualTo(Arrays.asList(1, 2, 3, 4, 5));
}

Notez que nous pourrions fournir une implémentation, qui trie in-situ , ce qui signifie que nous fournissons le résultat dans le même tableau que nous avons obtenu les éléments.

De plus, de cette manière, nous n’avons besoin d’aucune allocation de mémoire intermédiaire.

Cependant, cette implémentation serait un peu plus difficile à comprendre.

6. Complexité temporelle

Le tri de tas consiste en deux étapes clés , l’insertion d’un élément et la suppression du nœud racine. Les deux étapes ont la complexité O (log n) .

  • Puisque nous répétons les deux étapes n fois, la complexité globale du tri est de O (n log n) .

Notez que nous n’avons pas mentionné le coût de la réaffectation de tableau, mais comme il s’agit de O (n) , cela n’affecte pas la complexité globale. De plus, comme nous l’avons déjà mentionné, il est possible de mettre en place un tri sur place, ce qui signifie qu’aucune réaffectation de tableau n’est nécessaire.

Il convient également de mentionner que 50% des éléments sont des feuilles et 75% des éléments se trouvent aux deux niveaux les plus bas. Par conséquent, la plupart des opérations d’insertion ne prendront pas plus de deux étapes.

  • Notez que sur les données réelles, Quicksort est généralement plus performant que le tri en tas. Le bon côté des choses est que Heap Sort a toujours la complexité temporelle O (n log n) dans le pire des cas. **

7. Conclusion

Dans ce tutoriel, nous avons vu une implémentation de Binary Heap and Heap Sort.

  • Même si la complexité temporelle est O (n log n) , dans la plupart des cas, ce n’est pas le meilleur algorithme pour les données réelles. **

Comme d’habitude, les exemples sont disponibles à l’adresse over sur GitHub .