Árvore Binária

RESUMO

Uma árvore binária é uma estrutura de dados onde cada nó tem até dois filhos, usados para busca e ordenação eficientes. Na versão efêmera, alterações modificam diretamente a estrutura existente. Na versão persistente, cada modificação cria uma nova árvore compartilhando os nós não alterados, permitindo acesso a versões anteriores sem duplicação completa.

Uma árvore binária é uma estrutura de dados hierárquica composta por nós, onde cada nó tem no máximo dois filhos, geralmente referidos como filho esquerdo e filho direito. Essa estrutura é amplamente utilizada em diversas áreas da ciência da computação, incluindo algoritmos de busca, ordenação e manipulação de dados.

História e Origem

As árvores binárias têm suas raízes nos primeiros trabalhos sobre estruturas de dados e algoritmos. Elas foram formalmente descritas e estudadas em meados do século XX, com aplicações práticas e teóricas sendo amplamente exploradas por cientistas como John von Neumann e Claude Shannon. A simplicidade e eficiência das árvores binárias contribuíram para sua popularidade contínua.

Aplicações

Árvores binárias são utilizadas em várias aplicações, incluindo:

  • Árvores de Busca Binária (BST): Para operações eficientes de busca, inserção e exclusão de elementos.
  • Árvores AVL e Red-Black: Tipos de árvores binárias balanceadas que mantêm o equilíbrio para garantir operações de tempo logarítmico.
  • Árvores de Segmento: Utilizadas em problemas de intervalo.
  • Heap Binário: Utilizado em algoritmos de fila de prioridade e na implementação do algoritmo de Dijkstra.

Funcionamento

Estrutura Básica

Cada nó em uma árvore binária contém:

  • Valor: O dado armazenado no nó.
  • Filho Esquerdo: Referência ao nó filho à esquerda.
  • Filho Direito: Referência ao nó filho à direita.

A árvore começa com um nó raiz e, a partir daí, cada nó pode ter zero, um ou dois filhos.

Operações Básicas

  • Inserção: Adicionar um novo nó na árvore.
  • Busca: Encontrar um nó com um valor específico.
  • Exclusão: Remover um nó da árvore.

Complexidade

A complexidade das operações em uma árvore binária depende de sua altura h:

  • Melhor caso (árvore balanceada): O(log(n))
  • Pior caso (árvore degenerada em lista): O(n)

Implementação

Implementação Efêmera (Mutável)

Python
class Node:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

class BinaryTree:
    def __init__(self):
        self.root = None

    def insert(self, value):
        if self.root is None:
            self.root = Node(value)
        else:
            self._insert(self.root, value)

    def _insert(self, current_node, value):
        if value < current_node.value:
            if current_node.left is None:
                current_node.left = Node(value)
            else:
                self._insert(current_node.left, value)
        else:
            if current_node.right is None:
                current_node.right = Node(value)
            else:
                self._insert(current_node.right, value)

    def search(self, value):
        return self._search(self.root, value)

    def _search(self, current_node, value):
        if current_node is None:
            return False
        if value == current_node.value:
            return True
        elif value < current_node.value:
            return self._search(current_node.left, value)
        else:
            return self._search(current_node.right, value)

Implementação Persistente (Imutável)

Na implementação persistente, cada operação que modifica a árvore retorna uma nova versão da árvore, preservando as versões anteriores. Apenas os nós que foram alterados são recriados, enquanto os nós não afetados são reutilizados.

Python
class PersistentNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

class PersistentBinaryTree:
    def __init__(self, root=None):
        self.root = root

    def insert(self, value):
        new_root = self._insert(self.root, value)
        return PersistentBinaryTree(new_root)

    def _insert(self, node, value):
        if node is None:
            return PersistentNode(value)
        if value < node.value:
            new_left = self._insert(node.left, value)
            # Cria um novo nó com o filho esquerdo atualizado
            return PersistentNode(node.value, new_left, node.right)
        else:
            new_right = self._insert(node.right, value)
            # Cria um novo nó com o filho direito atualizado
            return PersistentNode(node.value, node.left, new_right)

    def search(self, value):
        return self._search(self.root, value)

    def _search(self, node, value):
        if node is None:
            return False
        if value == node.value:
            return True
        elif value < node.value:
            return self._search(node.left, value)
        else:
            return self._search(node.right, value)

Exemplo de Uso e Reaproveitamento de Nós

Vamos ilustrar com um exemplo, detalhando a criação e reutilização de nós:

Python
# Árvore original
pbt = PersistentBinaryTree()
pbt1 = pbt.insert(5)    # Cria o nó raiz 5
pbt2 = pbt1.insert(3)   # Cria o nó 3 e atualiza o nó raiz 5
pbt3 = pbt2.insert(7)   # Cria o nó 7 e atualiza o nó raiz 5

# Nova versão da árvore após inserção de 6
pbt4 = pbt3.insert(6)   # Cria o nó 6, atualiza o nó 7, e atualiza o nó raiz 5

# Verificações para demonstrar a criação e reutilização de nós
print(pbt3.root.value)           # Saída: 5
print(pbt4.root.value)           # Saída: 5 (novo nó raiz, mesmo valor)

print(pbt3.root.left.value)      # Saída: 3
print(pbt4.root.left.value)      # Saída: 3 (mesmo nó filho esquerdo, reutilizado)

print(pbt3.root.right.value)     # Saída: 7
print(pbt4.root.right.value)     # Saída: 7 (novo nó filho direito, mesmo valor)

print(pbt4.root.right.left.value)  # Saída: 6 (novo nó inserido)

Comparações

  • Efêmera (Mutável): Mais simples e eficiente em termos de memória para operações básicas. Alterações afetam a árvore original.
  • Persistente (Imutável): Mantém versões anteriores da árvore, permitindo retroceder a estados anteriores. Pode ser mais complexo e consumir mais memória, mas reaproveita nós para economizar espaço.

Melhores Práticas

  • Utilize árvores binárias balanceadas (como AVL ou Red-Black) para garantir desempenho logarítmico em operações de inserção, busca e exclusão.
  • Evite árvores binárias degeneradas (semelhantes a listas) através de balanceamento ou ao garantir diversidade nos dados de entrada.
  • Considere persistência se precisar de histórico de versões da estrutura de dados.

Referências

  • “Introduction to Algorithms” de Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, e Clifford Stein.
  • “Algorithms” de Robert Sedgewick e Kevin Wayne.
  • “Data Structures and Algorithms in Python” de Michael T. Goodrich, Roberto Tamassia, e Michael H. Goldwasser.

Conclusão

Árvores binárias são estruturas de dados fundamentais com diversas aplicações práticas. Implementações efêmeras são adequadas para a maioria dos casos de uso, enquanto implementações persistentes são úteis quando é necessário manter versões anteriores da estrutura. Entender e utilizar árvores binárias de forma eficaz é crucial para resolver problemas complexos de maneira eficiente.

Gostaria de mais informações?

Se você tem interesse neste assunto ou gostaria de mais informações sobre como a EximiaCo pode ajudar a sua empresa a utilizar a tecnologia para gerar mais resultados, entre em contato conosco.

0
Gostaríamos de ouvir sua opinião!x