Português

Explore os fundamentos das Árvores Binárias de Busca (BSTs) e aprenda a implementá-las em JavaScript. Guia com estrutura, operações e exemplos práticos.

Árvores Binárias de Busca: Um Guia Completo de Implementação em JavaScript

As Árvores Binárias de Busca (BSTs) são uma estrutura de dados fundamental na ciência da computação, amplamente utilizadas para busca, ordenação e recuperação eficientes de dados. Sua estrutura hierárquica permite complexidade de tempo logarítmica em muitas operações, tornando-as uma ferramenta poderosa para gerenciar grandes conjuntos de dados. Este guia fornece uma visão abrangente das BSTs e demonstra sua implementação em JavaScript, atendendo a desenvolvedores em todo o mundo.

Entendendo as Árvores Binárias de Busca

O que é uma Árvore Binária de Busca?

Uma Árvore Binária de Busca é uma estrutura de dados baseada em árvore onde cada nó tem no máximo dois filhos, referidos como filho esquerdo e filho direito. A propriedade chave de uma BST é que, para qualquer nó dado:

Esta propriedade garante que os elementos em uma BST estejam sempre ordenados, permitindo busca e recuperação eficientes.

Conceitos Chave

Implementando uma Árvore Binária de Busca em JavaScript

Definindo a Classe Node

Primeiro, definimos uma classe `Node` para representar cada nó na BST. Cada nó conterá uma `key` para armazenar os dados e ponteiros `left` e `right` para seus filhos.


class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}

Definindo a Classe BinarySearchTree

Em seguida, definimos a classe `BinarySearchTree`. Esta classe conterá o nó raiz e os métodos para inserir, buscar, excluir e percorrer a árvore.


class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  // Os métodos serão adicionados aqui
}

Inserção

O método `insert` adiciona um novo nó com a chave fornecida à BST. O processo de inserção mantém a propriedade da BST, colocando o novo nó na posição apropriada em relação aos nós existentes.


insert(key) {
  const newNode = new Node(key);

  if (this.root === null) {
    this.root = newNode;
  } else {
    this.insertNode(this.root, newNode);
  }
}

insertNode(node, newNode) {
  if (newNode.key < node.key) {
    if (node.left === null) {
      node.left = newNode;
    } else {
      this.insertNode(node.left, newNode);
    }
  } else {
    if (node.right === null) {
      node.right = newNode;
    } else {
      this.insertNode(node.right, newNode);
    }
  }
}

Exemplo: Inserindo valores na BST


const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);

Busca

O método `search` verifica se um nó com a chave fornecida existe na BST. Ele percorre a árvore, comparando a chave com a chave do nó atual e movendo-se para a subárvore esquerda ou direita, conforme apropriado.


search(key) {
  return this.searchNode(this.root, key);
}

searchNode(node, key) {
  if (node === null) {
    return false;
  }

  if (key < node.key) {
    return this.searchNode(node.left, key);
  } else if (key > node.key) {
    return this.searchNode(node.right, key);
  } else {
    return true;
  }
}

Exemplo: Buscando por um valor na BST


console.log(bst.search(9));  // Output: true
console.log(bst.search(2));  // Output: false

Exclusão

O método `remove` exclui um nó com a chave fornecida da BST. Esta é a operação mais complexa, pois precisa manter a propriedade da BST ao remover o nó. Existem três casos a serem considerados:


remove(key) {
  this.root = this.removeNode(this.root, key);
}

removeNode(node, key) {
  if (node === null) {
    return null;
  }

  if (key < node.key) {
    node.left = this.removeNode(node.left, key);
    return node;
  } else if (key > node.key) {
    node.right = this.removeNode(node.right, key);
    return node;
  } else {
    // a chave é igual a node.key

    // caso 1 - um nó folha
    if (node.left === null && node.right === null) {
      node = null;
      return node;
    }

    // caso 2 - o nó tem apenas 1 filho
    if (node.left === null) {
      node = node.right;
      return node;
    } else if (node.right === null) {
      node = node.left;
      return node;
    }

    // caso 3 - o nó tem 2 filhos
    const aux = this.findMinNode(node.right);
    node.key = aux.key;
    node.right = this.removeNode(node.right, aux.key);
    return node;
  }
}

findMinNode(node) {
  let current = node;
  while (current != null && current.left != null) {
    current = current.left;
  }
  return current;
}

Exemplo: Removendo um valor da BST


bst.remove(7);
console.log(bst.search(7)); // Output: false

Percurso em Árvore

O percurso em árvore envolve visitar cada nó na árvore em uma ordem específica. Existem vários métodos comuns de percurso:


inOrderTraverse(callback) {
  this.inOrderTraverseNode(this.root, callback);
}

inOrderTraverseNode(node, callback) {
  if (node !== null) {
    this.inOrderTraverseNode(node.left, callback);
    callback(node.key);
    this.inOrderTraverseNode(node.right, callback);
  }
}

preOrderTraverse(callback) {
  this.preOrderTraverseNode(this.root, callback);
}

preOrderTraverseNode(node, callback) {
  if (node !== null) {
    callback(node.key);
    this.preOrderTraverseNode(node.left, callback);
    this.preOrderTraverseNode(node.right, callback);
  }
}

postOrderTraverse(callback) {
  this.postOrderTraverseNode(this.root, callback);
}

postOrderTraverseNode(node, callback) {
  if (node !== null) {
    this.postOrderTraverseNode(node.left, callback);
    this.postOrderTraverseNode(node.right, callback);
    callback(node.key);
  }
}

Exemplo: Percorrendo a BST


const printNode = (value) => console.log(value);

bst.inOrderTraverse(printNode);   // Output: 3 5 8 9 10 11 12 13 14 15 18 20 25
bst.preOrderTraverse(printNode);  // Output: 11 5 3 9 8 10 15 13 12 14 20 18 25
bst.postOrderTraverse(printNode); // Output: 3 8 10 9 12 14 13 18 25 20 15 11

Valores Mínimo e Máximo

Encontrar os valores mínimo e máximo em uma BST é simples, graças à sua natureza ordenada.


min() {
  return this.minNode(this.root);
}

minNode(node) {
  let current = node;
  while (current !== null && current.left !== null) {
    current = current.left;
  }
  return current;
}

max() {
  return this.maxNode(this.root);
}

maxNode(node) {
  let current = node;
  while (current !== null && current.right !== null) {
    current = current.right;
  }
  return current;
}

Exemplo: Encontrando os valores mínimo e máximo


console.log(bst.min().key); // Output: 3
console.log(bst.max().key); // Output: 25

Aplicações Práticas das Árvores Binárias de Busca

As Árvores Binárias de Busca são usadas em uma variedade de aplicações, incluindo:

Considerações de Desempenho

O desempenho de uma BST depende de sua estrutura. No melhor cenário, uma BST balanceada permite complexidade de tempo logarítmica para operações de inserção, busca e exclusão. No entanto, no pior cenário (por exemplo, uma árvore desbalanceada), a complexidade de tempo pode degradar para tempo linear.

Árvores Balanceadas vs. Desbalanceadas

Uma BST balanceada é aquela onde a altura das subárvores esquerda e direita de cada nó difere em no máximo um. Algoritmos de autobalanceamento, como árvores AVL e árvores Rubro-Negras, garantem que a árvore permaneça balanceada, fornecendo desempenho consistente. Diferentes regiões podem exigir diferentes níveis de otimização com base na carga do servidor; o balanceamento ajuda a manter o desempenho sob alto uso global.

Complexidade de Tempo

Conceitos Avançados de BST

Árvores com Autobalanceamento

Árvores com autobalanceamento são BSTs que ajustam automaticamente sua estrutura para manter o equilíbrio. Isso garante que a altura da árvore permaneça logarítmica, fornecendo desempenho consistente para todas as operações. Árvores com autobalanceamento comuns incluem árvores AVL e árvores Rubro-Negras.

Árvores AVL

As árvores AVL mantêm o equilíbrio garantindo que a diferença de altura entre as subárvores esquerda e direita de qualquer nó seja de no máximo um. Quando esse equilíbrio é rompido, rotações são realizadas para restaurá-lo.

Árvores Rubro-Negras

As árvores Rubro-Negras usam propriedades de cor (vermelho ou preto) para manter o equilíbrio. Elas são mais complexas que as árvores AVL, mas oferecem melhor desempenho em certos cenários.

Exemplo de Código JavaScript: Implementação Completa da Árvore Binária de Busca


class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}

class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  insert(key) {
    const newNode = new Node(key);

    if (this.root === null) {
      this.root = newNode;
    } else {
      this.insertNode(this.root, newNode);
    }
  }

  insertNode(node, newNode) {
    if (newNode.key < node.key) {
      if (node.left === null) {
        node.left = newNode;
      } else {
        this.insertNode(node.left, newNode);
      }
    } else {
      if (node.right === null) {
        node.right = newNode;
      } else {
        this.insertNode(node.right, newNode);
      }
    }
  }

  search(key) {
    return this.searchNode(this.root, key);
  }

  searchNode(node, key) {
    if (node === null) {
      return false;
    }

    if (key < node.key) {
      return this.searchNode(node.left, key);
    } else if (key > node.key) {
      return this.searchNode(node.right, key);
    } else {
      return true;
    }
  }

  remove(key) {
    this.root = this.removeNode(this.root, key);
  }

  removeNode(node, key) {
    if (node === null) {
      return null;
    }

    if (key < node.key) {
      node.left = this.removeNode(node.left, key);
      return node;
    } else if (key > node.key) {
      node.right = this.removeNode(node.right, key);
      return node;
    } else {
      // a chave é igual a node.key

      // caso 1 - um nó folha
      if (node.left === null && node.right === null) {
        node = null;
        return node;
      }

      // caso 2 - o nó tem apenas 1 filho
      if (node.left === null) {
        node = node.right;
        return node;
      } else if (node.right === null) {
        node = node.left;
        return node;
      }

      // caso 3 - o nó tem 2 filhos
      const aux = this.findMinNode(node.right);
      node.key = aux.key;
      node.right = this.removeNode(node.right, aux.key);
      return node;
    }
  }

  findMinNode(node) {
    let current = node;
    while (current != null && current.left != null) {
      current = current.left;
    }
    return current;
  }

  min() {
    return this.minNode(this.root);
  }

  minNode(node) {
    let current = node;
    while (current !== null && current.left !== null) {
      current = current.left;
    }
    return current;
  }

  max() {
    return this.maxNode(this.root);
  }

  maxNode(node) {
    let current = node;
    while (current !== null && current.right !== null) {
      current = current.right;
    }
    return current;
  }

  inOrderTraverse(callback) {
    this.inOrderTraverseNode(this.root, callback);
  }

  inOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.inOrderTraverseNode(node.left, callback);
      callback(node.key);
      this.inOrderTraverseNode(node.right, callback);
    }
  }

  preOrderTraverse(callback) {
    this.preOrderTraverseNode(this.root, callback);
  }

  preOrderTraverseNode(node, callback) {
    if (node !== null) {
      callback(node.key);
      this.preOrderTraverseNode(node.left, callback);
      this.preOrderTraverseNode(node.right, callback);
    }
  }

  postOrderTraverse(callback) {
    this.postOrderTraverseNode(this.root, callback);
  }

  postOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.postOrderTraverseNode(node.left, callback);
      this.postOrderTraverseNode(node.right, callback);
      callback(node.key);
    }
  }
}

// Exemplo de Uso
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);

const printNode = (value) => console.log(value);

console.log("Percurso em ordem:");
bst.inOrderTraverse(printNode);

console.log("Percurso em pré-ordem:");
bst.preOrderTraverse(printNode);

console.log("Percurso em pós-ordem:");
bst.postOrderTraverse(printNode);

console.log("Valor mínimo:", bst.min().key);
console.log("Valor máximo:", bst.max().key);

console.log("Buscar por 9:", bst.search(9));
console.log("Buscar por 2:", bst.search(2));

bst.remove(7);
console.log("Buscar por 7 após remoção:", bst.search(7));

Conclusão

As Árvores Binárias de Busca são uma estrutura de dados poderosa e versátil com inúmeras aplicações. Este guia forneceu uma visão abrangente das BSTs, cobrindo sua estrutura, operações e implementação em JavaScript. Ao entender os princípios e técnicas discutidos neste guia, desenvolvedores de todo o mundo podem utilizar eficazmente as BSTs para resolver uma vasta gama de problemas no desenvolvimento de software. Desde o gerenciamento de bancos de dados globais até a otimização de algoritmos de busca, o conhecimento de BSTs é um recurso inestimável para qualquer programador.

À medida que você continua sua jornada na ciência da computação, explorar conceitos avançados como árvores com autobalanceamento e suas várias implementações irá aprimorar ainda mais sua compreensão e capacidades. Continue praticando e experimentando com diferentes cenários para dominar a arte de usar Árvores Binárias de Busca de forma eficaz.