Implémentation d'un arbre binaire dans Kotlin
1. Vue d'ensemble
Dans ce didacticiel, nous allons implémenter les opérations de base pour un arbre binaire à l'aide du langage de programmation Kotlin.
N'hésitez pas à consulter notre version Java de ces mêmestutorial.
2. Définition
En programmation, unbinary tree is a tree where every node has no more than two child nodes. Chaque nœud contient des données que nous appelons une clé.
Sans perte de généralité, limitons notre examen au cas où les clés ne sont que des nombres entiers.
Donc, nous pouvons définir un type de données récursif:
class Node(
var key: Int,
var left: Node? = null,
var right: Node? = null)
Cela contient une valeur (le champ de valeur entièrekey) et des références facultatives à un enfant gauche et droit qui sont du même type que leur parent.
Nous voyons qu'en raison de la nature liée, toute l'arborescence binaire peut être décrite par un seul nœud que nous appellerons un nœud racine.
Les choses deviennent plus intéressantes si nous appliquons certaines restrictions à la structure arborescente. In this tutorial, we suppose that the tree is an ordered binary tree (également appelé arbre de recherche binaire). Cela signifie que les nœuds sont disposés dans un ordre quelconque.
Nous supposons que toutes les conditions suivantes font partie desinvariant de notre arbre:
-
l'arbre ne contient pas de clés dupliquées
-
pour chaque nœud, sa clé est supérieure aux clés de ses nœuds de sous-arborescence de gauche
-
pour chaque nœud, sa clé est inférieure à celle de ses nœuds de sous-arborescence de droite
3. Opérations de base
Certaines des opérations les plus courantes comprennent:
-
Une recherche d'un noeud avec une valeur donnée
-
Insertion d'une nouvelle valeur
-
Suppression d'une valeur existante
-
Et la récupération des noeuds dans un certain ordre
3.1. Chercher
When the tree is ordered, the lookup process becomes very efficient: si la valeur à rechercher est égale à celle du nœud actuel, alors la recherche est terminée; si la valeur à rechercher est plus grande que celle du nœud actuel, nous pouvons rejeter le sous-arbre de gauche et ne considérer que celui de droite:
fun find(value: Int): Node? = when {
this.value > value -> left?.findByValue(value)
this.value < value -> right?.findByValue(value)
else -> this
}
Notez que la valeur peut ne pas être présente parmi les clés de l’arbre et que le résultat de la recherche peut donc renvoyer une valeurnull.
Notez comment nous avons utilisé leKotlin keyword when qui est un analogue Java de l'instructionswitch-case mais beaucoup plus puissant et flexible.
3.2. Insertion
Comme l’arborescence n’autorise aucune clé dupliquée, il est assez facile d’insérer une nouvelle valeur:
-
si la valeur est déjà présente, aucune action n'est requise
-
si la valeur n'est pas présente, elle doit être insérée dans un nœud ayant un «emplacement» vide à gauche ou à droite
Donc, nous pouvons analyser récursivement l’arbre à la recherche d’un sous-arbre qui devrait contenir la valeur. Lorsque la valeur est inférieure à la clé du nœud actuel, choisissez son sous-arbre de gauche s'il est présent. S'il n'est pas présent, cela signifie que l'emplacement pour insérer la valeur est trouvé: il s'agit de l'enfant de gauche du nœud actuel.
De même, dans le cas où la valeur est supérieure à la clé du noeud actuel. La seule possibilité qui reste est lorsque la valeur est égale à la clé du nœud actuel: cela signifie que la valeur est déjà présente dans l'arborescence et que nous ne faisons rien:
fun insert(value: Int) {
if (value > this.key) {
if (this.right == null) {
this.right = Node(value)
} else {
this.right.insert(value)
}
} else if (value < this.key) {
if (this.left == null) {
this.left = Node(value)
} else {
this.left.insert(value)
}
}
3.3. Suppression
Premièrement, nous devrions identifier le noeud qui contient la valeur donnée. Comme pour le processus de recherche, nous parcourons l’arborescence à la recherche du nœud et maintenons la référence au parent du nœud recherché:
fun delete(value: Int) {
when {
value > key -> scan(value, this.right, this)
value < key -> scan(value, this.left, this)
else -> removeNode(this, null)
}
}
Nous pouvons être confrontés à trois cas distincts lors de la suppression d'un nœud d'un arbre binaire. Nous les classons en fonction du nombre de nœuds enfants non nuls.
Both child nodes are null Ce cas est assez simple à gérer et c'est le seul dans lequel on peut échouer à éliminer le nœud: si le nœud est un nœud racine, on ne peut pas l'éliminer. Sinon, il suffit de mettre ànull l’enfant correspondant du parent.
La mise en œuvre de cette approche pourrait ressembler à ceci:
private fun removeNoChildNode(node: Node, parent: Node?) {
if (parent == null) {
throw IllegalStateException("Can not remove the root node without child nodes")
}
if (node == parent.left) {
parent.left = null
} else if (node == parent.right) {
parent.right = null
}
}
One child is null, the other is not null Dans ce cas, nous devrions toujours réussir car il suffit de «décaler» le seul nœud enfant dans le nœud que nous supprimons:
Nous pouvons implémenter ce cas directement:
private fun removeSingleChildNode(parent: Node, child: Node) {
parent.key = child.key
parent.left = child.left
parent.right = child.right
}
Both child nodes are not null Ce cas est plus complexe car nous devrions trouver un nœud qui doit remplacer le nœud que nous voulons supprimer. Une façon de trouver ce nœud «de remplacement» consiste à choisir un nœud dans la sous-arborescence de gauche avec la plus grande clé (celle-ci existe). Une autre façon est symétrique: nous devrions choisir un nœud dans la sous-arborescence de droite avec la plus petite clé (elle existe aussi). Ici, nous choisissons le premier:
Une fois que le nœud de remplacement est trouvé, nous devons «réinitialiser» la référence à ce dernier depuis son parent. Cela signifie que lors de la recherche du nœud de remplacement, nous devons également renvoyer son parent:
private fun removeTwoChildNode(node: Node) {
val leftChild = node.left!!
leftChild.right?.let {
val maxParent = findParentOfMaxChild(leftChild)
maxParent.right?.let {
node.key = it.key
maxParent.right = null
} ?: throw IllegalStateException("Node with max child must have the right child!")
} ?: run {
node.key = leftChild.key
node.left = leftChild.left
}
}
3.4. Traversée
Il existe différentes manières de visiter les nœuds. Most common are depth-first, breadth-first, and level-first search. Here, we consider only depth-first search qui peuvent être de l'un de ces types:
-
pré-commande (visitez le noeud parent, puis l'enfant de gauche, puis l'enfant de droite)
-
in-order (visitez l'enfant de gauche, puis le noeud parent, puis l'enfant de droite)
-
post-order (visiter l'enfant de gauche, puis l'enfant de droite, puis le noeud parent)
À Kotlin, tous ces types de parcours peuvent être réalisés de manière assez simple. Par exemple, pour la traversée de pré-commande, nous avons:
fun visit(): Array {
val a = left?.visit() ?: emptyArray()
val b = right?.visit() ?: emptyArray()
return a + arrayOf(key) + b
}
Notez que Kotlin nous permet de concaténer des tableaux en utilisant l’opérateur “+”. Cette implémentation est loin d’être efficace: elle n’est pas récursive et, pour un arbre plus profond, nous risquons de rencontrer l’exception de débordement de pile.
4. Conclusion
Dans ce tutoriel, nous avons examiné comment construire et mettre en œuvre des opérations de base pour un arbre de recherche binaire utilisant le langage Kotlin. Nous avons démontré quelques constructions Kotlin qui ne sont pas présentes en Java et que nous pourrions trouver utiles.
Comme toujours, l'implémentation complète des algorithmes ci-dessus est disponibleover on Github.