Implementando uma árvore binária no Kotlin
1. Visão geral
Neste tutorial, implementaremos as operações básicas para uma árvore binária usando a linguagem de programação Kotlin.
Sinta-se à vontade para verificar nossa versão Java deste mesmotutorial.
2. Definição
Na programação, abinary tree is a tree where every node has no more than two child nodes. Cada nó contém alguns dados que chamamos de chave.
Sem perda de generalidade, vamos limitar nossa consideração ao caso em que as chaves são apenas números inteiros.
Portanto, podemos definir um tipo de dados recursivo:
class Node(
var key: Int,
var left: Node? = null,
var right: Node? = null)
Que contém um valor (o campo com valor inteirokey) e referências opcionais a um filho esquerdo e direito que são do mesmo tipo de seu pai.
Vemos que, devido à natureza vinculada, toda a árvore binária pode ser descrita por apenas um nó que chamaremos de nó raiz.
As coisas se tornam mais interessantes se aplicarmos algumas restrições na estrutura da árvore. In this tutorial, we suppose that the tree is an ordered binary tree (também conhecido como árvore de pesquisa binária). Isso significa que os nós estão organizados em alguma ordem.
Supomos que todas as seguintes condições fazem parte deinvariant da nossa árvore:
-
a árvore não contém chaves duplicadas
-
para cada nó, sua chave é maior que as chaves dos nós da subárvore esquerda
-
para cada nó, sua chave é menor que as chaves dos nós da subárvore direita
3. Operações básicas
Algumas das operações mais comuns incluem:
-
Uma procura por um nó com um determinado valor
-
Inserção de um novo valor
-
Remoção de um valor existente
-
E recuperação de nós em certa ordem
3.1. Olho para cima
When the tree is ordered, the lookup process becomes very efficient: se o valor a pesquisar for igual ao do nó atual, a pesquisa acabou; se o valor a pesquisar for maior do que o do nó atual, podemos descartar a subárvore esquerda e considerar apenas a direita:
fun find(value: Int): Node? = when {
this.value > value -> left?.findByValue(value)
this.value < value -> right?.findByValue(value)
else -> this
}
Observe que o valor pode não estar presente entre as chaves da árvore e, portanto, o resultado da pesquisa pode retornar um valornull.
Observe como usamosKotlin keyword when, que é um análogo de Java da instruçãoswitch-case, mas muito mais poderoso e flexível.
3.2. Inserção
Como a árvore não permite chaves duplicadas, é muito fácil inserir um novo valor:
-
se o valor já estiver presente, nenhuma ação será necessária
-
se o valor não estiver presente, ele deverá ser inserido em um nó que tenha o "slot" esquerdo ou direito vago
Portanto, podemos analisar recursivamente a árvore em busca de uma subárvore que deve acomodar o valor. Quando o valor for menor que a chave do nó atual, escolha sua subárvore esquerda, se estiver presente. Se não estiver presente, significa que o local para inserir o valor foi encontrado: este é o filho esquerdo do nó atual.
Da mesma forma, no caso em que o valor é maior que a chave do nó atual. A única possibilidade restante é quando o valor é igual à chave do nó atual: significa que o valor já está presente na árvore e não fazemos nada:
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. Remoção
Primeiro, devemos identificar o nó que contém o valor fornecido. Semelhante ao processo de pesquisa, examinamos a árvore em busca do nó e mantemos a referência ao pai do nó procurado:
fun delete(value: Int) {
when {
value > key -> scan(value, this.right, this)
value < key -> scan(value, this.left, this)
else -> removeNode(this, null)
}
}
Existem três casos distintos que podemos enfrentar ao remover um nó de uma árvore binária. Nós os classificamos com base no número de nós filhos não nulos.
Both child nodes are null Este caso é bastante simples de tratar e é o único em que podemos falhar na eliminação do nó: se o nó for raiz, não podemos eliminá-lo. Caso contrário, é suficiente definir comonull o filho correspondente do pai.
A implementação dessa abordagem pode ser assim:
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 Nesse caso, devemos sempre ter sucesso, pois é o suficiente para “deslocar” o único nó filho para o nó que estamos removendo:
Podemos implementar esse caso diretamente:
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 Este caso é mais complexo, pois devemos encontrar um nó que substituirá o nó que queremos remover. Uma maneira de encontrar esse nó de "substituição" é escolher um nó na subárvore esquerda com a maior chave (com certeza existe). Outra maneira é simétrica: devemos escolher um nó na subárvore direita com a menor chave (ela também existe). Aqui, escolhemos o primeiro:
Depois que o nó de substituição for encontrado, devemos "redefinir" a referência a ele a partir de seu pai. Isso significa que, ao procurar o nó de substituição, devemos retornar seu pai também:
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. Travessia
Existem várias maneiras de como os nós podem ser visitados. Most common are depth-first, breadth-first, and level-first search. Here, we consider only depth-first search que pode ser de um destes tipos:
-
pré-encomenda (visite o nó pai, o filho esquerdo e o filho direito)
-
em ordem (visite o filho esquerdo, o nó pai e o filho direito)
-
pós-pedido (visite o filho esquerdo, o filho certo e o nó pai)
Em Kotlin, todos esses tipos de travessias podem ser feitos de uma maneira bastante simples. Por exemplo, para o percurso de pré-encomenda, temos:
fun visit(): Array {
val a = left?.visit() ?: emptyArray()
val b = right?.visit() ?: emptyArray()
return a + arrayOf(key) + b
}
Observe como o Kotlin nos permite concatenar matrizes usando o operador "+". Essa implementação está longe de ser eficiente: não é recursiva de cauda e, para uma árvore mais profunda, podemos encontrar a exceção de estouro de pilha.
4. Conclusão
Neste tutorial, consideramos como construir e implementar operações básicas para uma árvore de pesquisa binária usando a linguagem Kotlin. Demonstramos algumas construções Kotlin que não estão presentes em Java e que podemos achar úteis.
Como sempre, a implementação completa dos algoritmos acima está disponívelover on Github.