Arbre binaire ou comment préparer un arbre de recherche binaire

Prélude

Cet article est dĂ©diĂ© aux arbres binaires de recherche. RĂ©cemment, j'ai Ă©crit un article sur la compression des donnĂ©es par la mĂ©thode de Huffman. À l'Ă©poque, je n'ai pas beaucoup prĂȘtĂ© attention aux arbres binaires, car les mĂ©thodes de recherche, d'insertion et de suppression n'Ă©taient pas pertinentes. Maintenant, j'ai dĂ©cidĂ© d'Ă©crire un article spĂ©cifiquement sur les arbres. Commençons.

Un arbre est une structure de donnĂ©es composĂ©e de nƓuds reliĂ©s par des arĂȘtes. On peut dire qu'un arbre est un cas particulier d'un graphe. Voici un exemple d'arbre :

Arbre binaire ou comment préparer un arbre de recherche binaire

Ce n'est pas un arbre binaire de recherche ! Tout est sous le cat !

Terminologie

Racine

La racine de l'arbre est le nƓud le plus Ă©levĂ©. Dans cet exemple, il s'agit du nƓud A. Dans un arbre, il ne peut y avoir qu'un seul chemin menant de la racine Ă  n'importe quel autre nƓud ! En rĂ©alitĂ©, tout nƓud peut ĂȘtre considĂ©rĂ© comme la racine de son sous-arbre correspondant.

Parents/enfants

Tous les nƓuds, Ă  l'exception de la racine, ont exactement une arĂȘte reliant vers un autre nƓud. Le nƓud situĂ© au-dessus du nƓud actuel s'appelle le parent de ce nƓud. Le nƓud situĂ© en dessous du nƓud actuel et connectĂ© Ă  lui s'appelle l'enfant de ce nƓud. Prenons un exemple. Si nous prenons le nƓud B, alors son parent sera le nƓud A, et ses enfants seront les nƓuds D, E et F.

Feuille

Un nƓud qui n'a pas d'enfants sera appelĂ© feuille de l'arbre. Dans l'exemple, les feuilles seront les nƓuds D, E, F, G, I, J, K.

C'est la terminologie de base. D'autres concepts seront abordĂ©s plus loin. Donc, un arbre binaire est un arbre dans lequel chaque nƓud aura au maximum deux enfants. Comme vous l'avez devinĂ©, l'arbre de l'exemple ne sera pas binaire, car les nƓuds B et H ont plus de deux enfants. Voici un exemple d'arbre binaire :

Arbre binaire ou comment préparer un arbre de recherche binaire

Les nƓuds de l'arbre peuvent contenir n'importe quelle information. Un arbre binaire de recherche est un arbre binaire dont les propriĂ©tĂ©s suivantes sont caractĂ©ristiques :

  1. Les deux sous-arbres - gauche et droit - sont des arbres binaires de recherche.
  2. Pour tous les nƓuds du sous-arbre gauche d'un nƓud X donnĂ©, les valeurs des clĂ©s de donnĂ©es sont infĂ©rieures Ă  la valeur de la clĂ© de donnĂ©es du nƓud X lui-mĂȘme.
  3. Pour tous les nƓuds du sous-arbre droit d'un nƓud X donnĂ©, les valeurs des clĂ©s de donnĂ©es sont supĂ©rieures ou Ă©gales Ă  la valeur de la clĂ© de donnĂ©es du nƓud X lui-mĂȘme.

ClĂ© une sorte de caractĂ©ristique du nƓud (par exemple, un nombre). La clĂ© est nĂ©cessaire pour pouvoir trouver l'Ă©lĂ©ment de l'arbre correspondant Ă  cette clĂ©. Exemple d'arbre binaire de recherche :

Arbre binaire ou comment préparer un arbre de recherche binaire

Représentation de l'arbre

Au fur et à mesure de l'avancement, je fournirai quelques (possiblement incomplets) extraits de code pour améliorer votre compréhension. Le code complet sera à la fin de l'article.

L'arbre est constituĂ© de nƓuds. La structure d'un nƓud :

public class Node<T> {
    private T data;
    private int key;
    private Node<T> leftChild;
    private Node<T> rightChild;

    public Node(T data, int key) {
        this.data = data;
        this.key = key;
    }
    public Node<T> getLeftChild() {
        return leftChild;
    }

    public Node<T> getRightChild() {
        return rightChild;
    }
//...autres mĂ©thodes du nƓud
}

Chaque nƓud a deux descendants (il est tout Ă  fait possible que les descendants leftChild et/ou rightChild contiennent la valeur null). Vous avez probablement compris que dans ce cas, le nombre data reprĂ©sente les donnĂ©es stockĂ©es dans le nƓud ; key est la clĂ© du nƓud.

Une fois le nƓud compris, parlons des problĂšmes pertinents concernant les arbres. Ici et ci-aprĂšs, par le terme « arbre », je dĂ©signerai un arbre binaire de recherche. La structure de l'arbre binaire :

public class BinaryTree<T> {
     private Node<T> root;

    //méthodes de l'arbre
}

Comme champ de la classe, nous n'aurons besoin que de la racine de l'arbre, car depuis la racine, en utilisant les mĂ©thodes getLeftChild() et getRightChild(), on peut atteindre n'importe quel nƓud de l'arbre.

Algorithmes dans l'arbre

La recherche

Supposons que vous ayez un arbre construit. Comment trouver un Ă©lĂ©ment avec la clĂ© key ? Il faut se dĂ©placer progressivement de la racine vers le bas de l'arbre et comparer la valeur key avec la clĂ© de chaque nƓud : si key est infĂ©rieur Ă  la clĂ© du nƓud courant, passez au descendant gauche du nƓud ; si supĂ©rieur, allez au droit ; si les clĂ©s sont Ă©gales, le nƓud recherchĂ© est trouvĂ© ! Le code correspondant :

public Node<T> find(int key) {
    Node<T> current = root;
    while (current.getKey() != key) {
        if (key < current.getKey())
            current = current.getLeftChild();
        else
            current = current.getRightChild();
        if (current == null)
            return null;
    }
    return current;
}

Si current devient Ă©gal Ă  null, cela signifie que la recherche a atteint la fin de l'arbre (au niveau conceptuel, vous ĂȘtes dans un endroit inexistant de l'arbre — un descendant d'une feuille).

ConsidĂ©rons l'efficacitĂ© de l'algorithme de recherche dans un arbre Ă©quilibrĂ© (un arbre dans lequel les nƓuds sont rĂ©partis de maniĂšre plus ou moins uniforme). Dans ce cas, l'efficacitĂ© de la recherche sera O(log(n)), avec un logarithme en base 2. Regardez : si l'arbre Ă©quilibrĂ© a n Ă©lĂ©ments, cela signifie qu'il y aura log(n) niveaux dans l'arbre. Et dans la recherche, Ă  chaque Ă©tape de la boucle, vous descendez d'un niveau.

Insertion

Si vous avez compris l'essence de la recherche, il vous sera facile de comprendre l'insertion. Il suffit de descendre jusqu'Ă  la feuille de l'arbre (en suivant les rĂšgles de descente dĂ©crites dans la recherche) et de devenir son descendant — Ă  gauche ou Ă  droite, selon la clĂ©. Mise en Ɠuvre :

   public void insert(T insertData, int key) {
        Node current = root;
        Node parent;
        Node newNode = new Node(insertData, key);
        if (root == null)
            root = newNode;
        else {
            while (true) {
                parent = current;
                if (key < current.getKey()) {
                    current = current.getLeftChild();
                    if (current == null) {
                         parent.setLeftChild(newNode);
                         return;
                    }
                }
                else {
                    current = current.getRightChild();
                    if (current == null) {
                        parent.setRightChild(newNode);
                        return;
                    }
                }
            }
        }
    }

Dans ce cas, il faut, en plus du nƓud actuel, conserver des informations sur le parent du nƓud actuel. Lorsque current deviendra Ă©gal Ă  null, la variable parent contiendra la feuille dont nous avons besoin.
L'efficacité de l'insertion sera, comme pour la recherche, de O(log(n)).

Désinstallation

La suppression est l'opĂ©ration la plus complexe Ă  effectuer sur l'arbre. Évidemment, il faut d'abord trouver l'Ă©lĂ©ment que nous allons supprimer. Mais ensuite ? Si l'on se contente d'assigner la valeur null Ă  sa rĂ©fĂ©rence, nous perdrons l'information sur le sous-arbre dont ce nƓud est la racine. Les mĂ©thodes de suppression d'un arbre sont divisĂ©es en trois cas.

Premier cas. Le nƓud à supprimer n'a pas de descendants.

Si le nƓud à supprimer n'a pas de descendants, cela signifie qu'il s'agit d'une feuille. On peut donc simplement assigner la valeur null aux champs leftChild ou rightChild de son parent.

Deuxiùme cas. Le nƓud à supprimer a un descendant.

Ce cas n'est pas trĂšs compliquĂ© non plus. Revenons Ă  notre exemple. Supposons que nous devions supprimer l'Ă©lĂ©ment avec la clĂ© 14. Convenez que puisqu'il est le descendant droit du nƓud avec la clĂ© 10, tout descendant de ce nƓud (dans ce cas, le droit) aura une clĂ© supĂ©rieure Ă  10, il est donc facile de "l'Ă©monder" de l'arbre et de relier directement le parent au descendant du nƓud supprimĂ©, c'est-Ă -dire de relier le nƓud avec la clĂ© 10 au nƓud 13. Il en serait de mĂȘme si nous devions supprimer un nƓud qui est le descendant gauche de son parent. RĂ©flĂ©chissez bien Ă  cela — c'est une analogie exacte.

Troisiùme cas. Le nƓud a deux descendants.

Le cas le plus complexe. Analysons par un nouvel exemple.

Arbre binaire ou comment préparer un arbre de recherche binaire

Recherche du successeur.

Supposons que nous devons supprimer un nƓud avec la clĂ© 25. Qui allons-nous mettre Ă  sa place ? Quelqu'un de ses successeurs (descendants ou descendants des descendants) doit devenir le successeur(celui qui prendra la place du nƓud supprimĂ©).

Comment dĂ©terminer qui doit devenir le successeur ? Il est intuitivement clair que c’est le nƓud dans l’arbre dont la clĂ© est la suivante par ordre croissant aprĂšs celle du nƓud supprimĂ©. L’algorithme est le suivant. Il faut se rendre Ă  son successeur droit (toujours Ă  droite, car il a dĂ©jĂ  Ă©tĂ© dit que la clĂ© du successeur est supĂ©rieure Ă  celle du nƓud supprimĂ©), puis parcourir la chaĂźne des successeurs gauches de ce successeur droit. Dans notre exemple, nous devons passer au nƓud avec la clĂ© 35, puis descendre jusqu'Ă  la feuille en suivant la chaĂźne de ses successeurs gauches — dans ce cas, cette chaĂźne ne contient que le nƓud avec la clĂ© 30. Strictement parlant, nous cherchons le nƓud le plus petit dans l'ensemble des nƓuds supĂ©rieurs au nƓud recherchĂ©.

Arbre binaire ou comment préparer un arbre de recherche binaire

Code de la méthode de recherche du successeur :

    public Node<T> getSuccessor(Node<T> deleteNode) {
        Node<T> parentSuccessor = deleteNode; // parent du successeur
        Node<T> successor = deleteNode; // successeur
        Node<T> current = successor.getRightChild(); // simplement un nƓud "courant"
        while (current != null) {
            parentSuccessor = successor;
            successor = current;
            current = current.getLeftChild();
        }
        // Ă  la sortie de la boucle, nous avons le successeur et le parent du successeur
        if (successor != deleteNode.getRightChild()) { // si le successeur ne coĂŻncide pas avec le successeur droit du nƓud supprimĂ©
            parentSuccessor.setLeftChild(successor.getRightChild()); // alors son parent prend le descendant du successeur pour ne pas le perdre
            successor.setRightChild(deleteNode.getRightChild()); // relions le successeur au successeur droit du nƓud supprimĂ©
        }
        return successor;
    }

Code complet de la méthode delete :

public boolean delete(int deleteKey) {
        Node current = root;
        Node parent = current;
        boolean isLeftChild = false; // En fonction de savoir si le nƓud Ă  supprimer est un enfant gauche ou droit de son parent, la variable boolĂ©enne isLeftChild prendra la valeur true ou false respectivement.
        while (current.getKey() != deleteKey) {
            parent = current;
            if (deleteKey < current.getKey()) {
                current = current.getLeftChild();
                isLeftChild = true;
            } else {
                isLeftChild = false;
                current = current.getRightChild();
            }
            if (current == null)
                return false;
        }

        if (current.getLeftChild() == null && current.getRightChild() == null) { // premier cas
            if (current == root)
                current = null;
            else if (isLeftChild)
                parent.setLeftChild(null);
            else
                parent.setRightChild(null);
        }
        else if (current.getRightChild() == null) { // deuxiĂšme cas
            if (current == root)
                root = current.getLeftChild();
            else if (isLeftChild)
                parent.setLeftChild(current.getLeftChild());
            else
                current.setRightChild(current.getLeftChild());
        } else if (current.getLeftChild() == null) {
            if (current == root)
                root = current.getRightChild();
            else if (isLeftChild)
                parent.setLeftChild(current.getRightChild());
            else
                parent.setRightChild(current.getRightChild());
        } 
        else { // troisiĂšme cas
            Node successor = getSuccessor(current);
            if (current == root)
                root = successor;
            else if (isLeftChild)
                parent.setLeftChild(successor);
            else
                parent.setRightChild(successor);
        }
        return true;
    }

La complexitĂ© peut ĂȘtre approximĂ©e Ă  O(log(n)).

Recherche du maximum/minimum dans l'arbre

Il est évident que pour trouver la valeur minimale/maximale dans l'arbre, il faut parcourir successivement la chaßne des éléments gauche/droit de l'arbre respectivement ; lorsque vous atteindrez une feuille, elle sera l'élément minimal/maximal.

    public Node getMinimum(Node startPoint) {
        Node current = startPoint;
        Node parent = current;
        while (current != null) {
            parent = current;
            current = current.getLeftChild();
        }
        return parent;
    }

    public Node getMaximum(Node startPoint) {
        Node current = startPoint;
        Node parent = current;
        while (current != null) {
            parent = current;
            current = current.getRightChild();
        }
        return parent;
    }

ComplexitĂ© — O(log(n))

Parcours symétrique

Le parcours consiste à visiter chaque nƓud de l'arbre dans le but d'effectuer une certaine action avec lui.

L'algorithme de parcours symétrique récursif :

  1. Effectuer une action avec l'enfant gauche
  2. Effectuer une action avec soi-mĂȘme
  3. Effectuer une action avec l'enfant droit

Code :

    public void inOrder(Node current) {
        if (current != null) {
            inOrder(current.getLeftChild());
            System.out.println(current.getData() + " ");//Vous pouvez mettre tout ce que vous voulez ici
            inOrder(current.getRightChild());
        }
    }

Conclusion

Enfin ! Si j'ai manqué d'expliquer quelque chose ou si vous avez des remarques, n'hésitez pas à les laisser en commentaire. Comme promis, voici le code complet.

Node.java:

public class Node {
    private T data;
    private int key;
    private Node leftChild;
    private Node rightChild;

    public Node(T data, int key) {
        this.data = data;
        this.key = key;
    }

    public void setLeftChild(Node newNode) {
        leftChild = newNode;
    }

    public void setRightChild(Node newNode) {
        rightChild = newNode;
    }

    public Node getLeftChild() {
        return leftChild;
    }

    public Node getRightChild() {
        return rightChild;
    }

    public T getData() {
        return data;
    }

    public int getKey() {
        return key;
    }
}

BinaryTree.java:

public class BinaryTree<T> {
    private Node<T> root;

    public Node<T> find(int key) {
        Node<T> current = root;
        while (current.getKey() != key) {
            if (key < current.getKey())
                current = current.getLeftChild();
            else
                current = current.getRightChild();
            if (current == null)
                return null;
        }
        return current;
    }

    public void insert(T insertData, int key) {
        Node<T> current = root;
        Node<T> parent;
        Node<T> newNode = new Node<>(insertData, key);
        if (root == null)
            root = newNode;
        else {
            while (true) {
                parent = current;
                if (key < current.getKey()) {
                    current = current.getLeftChild();
                    if (current == null) {
                         parent.setLeftChild(newNode);
                         return;
                    }
                }
                else {
                    current = current.getRightChild();
                    if (current == null) {
                        parent.setRightChild(newNode);
                        return;
                    }
                }
            }
        }
    }

    public Node<T> getMinimum(Node<T> startPoint) {
        Node<T> current = startPoint;
        Node<T> parent = current;
        while (current != null) {
            parent = current;
            current = current.getLeftChild();
        }
        return parent;
    }

    public Node<T> getMaximum(Node<T> startPoint) {
        Node<T> current = startPoint;
        Node<T> parent = current;
        while (current != null) {
            parent = current;
            current = current.getRightChild();
        }
        return parent;
    }

    public Node<T> getSuccessor(Node<T> deleteNode) {
        Node<T> parentSuccessor = deleteNode;
        Node<T> successor = deleteNode;
        Node<T> current = successor.getRightChild();
        while (current != null) {
            parentSuccessor = successor;
            successor = current;
            current = current.getLeftChild();
        }

        if (successor != deleteNode.getRightChild()) {
            parentSuccessor.setLeftChild(successor.getRightChild());
            successor.setRightChild(deleteNode.getRightChild());
        }
        return successor;
    }

    public boolean delete(int deleteKey) {
        Node<T> current = root;
        Node<T> parent = current;
        boolean isLeftChild = false;
        while (current.getKey() != deleteKey) {
            parent = current;
            if (deleteKey < current.getKey()) {
                current = current.getLeftChild();
                isLeftChild = true;
            } else {
                isLeftChild = false;
                current = current.getRightChild();
            }
            if (current == null)
                return false;
        }

        if (current.getLeftChild() == null && current.getRightChild() == null) {
            if (current == root)
                current = null;
            else if (isLeftChild)
                parent.setLeftChild(null);
            else
                parent.setRightChild(null);
        }
        else if (current.getRightChild() == null) {
            if (current == root)
                root = current.getLeftChild();
            else if (isLeftChild)
                parent.setLeftChild(current.getLeftChild());
            else
                current.setRightChild(current.getLeftChild());
        } else if (current.getLeftChild() == null) {
            if (current == root)
                root = current.getRightChild();
            else if (isLeftChild)
                parent.setLeftChild(current.getRightChild());
            else
                parent.setRightChild(current.getRightChild());
        } 
        else {
            Node<T> successor = getSuccessor(current);
            if (current == root)
                root = successor;
            else if (isLeftChild)
                parent.setLeftChild(successor);
            else
                parent.setRightChild(successor);
        }
        return true;
    }

    public void inOrder(Node<T> current) {
        if (current != null) {
            inOrder(current.getLeftChild());
            System.out.println(current.getData() + " ");
            inOrder(current.getRightChild());
        }
    }
}

P.S.

Dégénération en O(n)

Beaucoup d'entre vous ont peut-ĂȘtre remarquĂ© : et si nous faisions en sorte que l'arbre devienne dĂ©sĂ©quilibrĂ© ? Par exemple, en ajoutant des nƓuds avec des clĂ©s croissantes : 1,2,3,4,5,6
 À ce moment-lĂ , l'arbre ressemblera Ă  quelque chose de semblable Ă  une liste chaĂźnĂ©e. Et oui, l'arbre perdra sa structure arborescente, et par consĂ©quent, son efficacitĂ© d'accĂšs aux donnĂ©es. La complexitĂ© des opĂ©rations de recherche, d'insertion et de suppression deviendra similaire Ă  celle d'une liste chaĂźnĂ©e : O(n). C'est lĂ  qu'apparaĂźt, selon moi, l'un des principaux inconvĂ©nients des arbres binaires.

Seuls les utilisateurs enregistrés peuvent participer au sondage. Connectez-vous, s'il vous plaßt.

Je ne suis pas sur Habr depuis longtemps, et j'aimerais savoir quels sujets d'articles aimeriez-vous voir plus souvent ?

  • Structures de donnĂ©es

  • Algorithmes (DP, rĂ©cursion, compression de donnĂ©es, etc.)

  • Application des structures de donnĂ©es et des algorithmes dans la vie rĂ©elle

  • Programmation d'applications Android en Java

  • Programmation d'applications web en Java

2 utilisateurs ont voté. 1 utilisateur s'est abstenu.

Source : habr.com

Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS đŸ”„ Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster