Navegando em Hierarquias: Árvores
Foco em Árvores de Busca Binária (BSTs), travessias, Heaps (Priority Queues) e Tries (para strings).
Quando vi meu primeiro problema de árvore, tentei resolvê-lo com loops e um monte de \`if/else\` para controlar se ia para a esquerda ou para a direita. Foi um desastre. O código ficou complexo, ilegível e cheio de bugs.
O segredo, que aprendemos na aula passada, é que a recursão é a linguagem nativa das árvores. Cada nó de uma árvore é, em si, a raiz de uma sub-árvore menor. Uma vez que você enxerga um problema de árvore como um problema recursivo, tudo "clica".
Nesta aula, vamos aprender a "falar o idioma das árvores", com foco nas estruturas que mais aparecem nas entrevistas: Árvores de Busca Binária (BSTs), Heaps e Tries.
1. O Básico: Anatomia de uma Árvore
Em computação, uma Árvore é uma estrutura de dados hierárquica que consiste em nós (nodes) conectados por arestas (edges). Diferente das estruturas lineares, ela não tem um "começo" e "fim" sequenciais, mas sim uma estrutura de pais e filhos.
A Peça Fundamental: O Nó
Tudo começa com a definição do nó. Em JavaScript, geralmente é uma classe simples:
class TreeNode {
constructor(val, left = null, right = null) {
this.val = val; // O valor do nó
this.left = left; // Referência para o filho da esquerda (outro TreeNode)
this.right = right; // Referência para o filho da direita (outro TreeNode)
}
}
2. Árvores de Busca Binária (BSTs): A Árvore Ordenada 🌳
A BST é o tipo de árvore mais comum em entrevistas. Ela tem uma regra fundamental que a torna especial: para qualquer nó, todos os valores na sua sub-árvore esquerda são menores que o valor do nó, e todos os valores na sua sub-árvore direita são maiores.
Essa propriedade permite operações de busca, inserção e remoção muito eficientes, com complexidade de tempo média de O(log n) para uma árvore balanceada.
class BinarySearchTree {
constructor() {
this.root = null; // A BST começa vazia
}
insert(val) {
const newNode = new TreeNode(val);
if (!this.root) {
this.root = newNode;
return this;
}
let current = this.root;
while (true) {
if (val === current.val) return undefined; // Não permite valores duplicados
if (val < current.val) {
if (current.left === null) {
current.left = newNode;
return this;
}
current = current.left;
} else {
if (current.right === null) {
current.right = newNode;
return this;
}
current = current.right;
}
}
}
search(val) {
if (!this.root) return false;
let current = this.root;
while (current) {
if (val === current.val) return true; // Encontrou!
if (val < current.val) {
current = current.left;
} else {
current = current.right;
}
}
return false; // Não encontrou
}
}
Travessias (Traversals): Como Visitar os Nós
Como você visita todos os nós de uma árvore? Existem dois estilos principais: Busca em Profundidade (DFS) e Busca em Largura (BFS).
DFS vs. BFS: Qual Usar?
Use DFS (Pre, In, Post-order) quando quiser explorar um caminho até o fim. É mais simples de implementar com recursão e gasta menos memória. Ótimo para problemas de busca de caminho ou quando a ordem da travessia importa (como obter valores ordenados de uma BST).
Use BFS (Level-order) quando quiser encontrar o caminho mais curto entre dois nós. Ele explora "vizinhos primeiro", nível por nível. É a escolha certa para problemas do tipo "menor número de passos para chegar a X".
// --- DFS: Depth-First Search ---
// 1. In-Order (Esquerda, Raiz, Direita) -> Para BSTs, resulta em ordem crescente.
function inOrder(node) {
if (!node) return;
inOrder(node.left);
console.log(node.val);
inOrder(node.right);
}
// 2. Pre-Order (Raiz, Esquerda, Direita) -> Útil para copiar a árvore.
function preOrder(node) {
if (!node) return;
console.log(node.val);
preOrder(node.left);
preOrder(node.right);
}
// 3. Post-Order (Esquerda, Direita, Raiz) -> Útil para deletar nós.
function postOrder(node) {
if (!node) return;
postOrder(node.left);
postOrder(node.right);
console.log(node.val);
}
// --- BFS: Breadth-First Search ---
// 4. Level-Order -> Visita a árvore nível por nível, usando uma Fila.
function levelOrder(root) {
if (!root) return;
const queue = [root]; // Usa uma fila para controlar os nós a visitar
while (queue.length > 0) {
const node = queue.shift(); // Pega o primeiro da fila
console.log(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
}
3. Heaps: A Árvore de Prioridades (Priority Queue) 🏆
Um Heap é um tipo especial de árvore (geralmente binária) onde os dados têm uma propriedade de ordem. Ele não serve para buscas gerais, mas é extremamente eficiente para uma tarefa: encontrar e remover o elemento de maior (ou menor) prioridade rapidamente.
As operações principais, como inserir um elemento ou extrair o mínimo/máximo, são O(log n). É a estrutura de dados por trás do que chamamos de Fila de Prioridade (Priority Queue).
Caso de Uso Clássico: Top 'K' Elementos
4. Tries (Árvores de Prefixo): A Árvore para Strings 🔡
Uma Trie (lê-se "try") é uma árvore otimizada para armazenar e buscar strings. Cada nó representa um caractere, e um caminho da raiz até um nó específico forma um prefixo ou uma palavra completa. Elas são a base para funcionalidades de dicionário e autocomplete.
class TrieNode {
constructor() {
this.children = {}; // Mapeia caracteres para outros TrieNodes
this.isEndOfWord = false; // Flag para marcar o fim de uma palavra
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
// Insere uma palavra na Trie
insert(word) {
let current = this.root;
for (const char of word) {
if (!current.children[char]) {
current.children[char] = new TrieNode();
}
current = current.children[char];
}
current.isEndOfWord = true;
}
// Procura por uma palavra completa na Trie
search(word) {
let current = this.root;
for (const char of word) {
if (!current.children[char]) {
return false; // Caminho não existe
}
current = current.children[char];
}
return current.isEndOfWord; // Retorna true apenas se for uma palavra completa
}
// Verifica se existe alguma palavra com um dado prefixo
startsWith(prefix) {
let current = this.root;
for (const char of prefix) {
if (!current.children[char]) {
return false;
}
current = current.children[char];
}
return true;
}
}
Caso de Uso Clássico: Autocomplete
5. Uma Palavra sobre Árvores Balanceadas (AVL, Red-Black)
O "sonho" do O(log n) de uma BST depende dela ser balanceada. Se você inserir elementos já ordenados (ex: 1, 2, 3, 4, 5) em uma BST simples, ela se tornará uma "árvore degenerada", que na prática é uma Linked List, e as operações caem para O(n).
Fica a Dica 💡
6. A Mágica do Balanceamento: Rotações de Árvore (Nível Hardcore) 🧠
Mencionamos que árvores como AVL e Red-Black se auto-balanceiam. Mas como? A operação fundamental por trás dessa "mágica" é a rotação. Uma rotação é uma operação local que altera a estrutura da árvore para diminuir sua altura, mas — e isso é crucial — preserva a propriedade de uma Árvore de Busca Binária.
Entender o conceito de rotações, mesmo que você não decore o código, é um diferencial gigantesco.
Rotação para a Direita (Right Rotation)
Usada quando uma sub-árvore esquerda se torna muito "pesada" (alta). O filho da esquerda de um nó "sobe" para se tornar o novo pai, e o pai antigo "desce" para se tornar o filho da direita.
y (Rotaciona para a Direita em 'y') x / \ / \ x C ----------------------------------> A y / \ / \ A B B C
function rightRotate(y) {
const x = y.left;
const B = x.right;
// Realiza a rotação
x.right = y;
y.left = B;
// Retorna a nova raiz da sub-árvore
return x;
}
Rotação para a Esquerda (Left Rotation)
É a operação inversa. Usada quando uma sub-árvore direita fica muito pesada. O filho da direita sobe para se tornar o novo pai.
x (Rotaciona para a Esquerda em 'x') y / \ / \ A y ----------------------------------> x C / \ / \ B C A B
function leftRotate(x) {
const y = x.right;
const B = y.left;
// Realiza a rotação
y.left = x;
x.right = B;
// Retorna a nova raiz da sub-árvore
return y;
}
Quando isso é perguntado?
Mão na Massa: Resolvendo o Cenário da Entrevista
Vamos simular um cenário clássico de entrevista para solidificar o conceito.
Passo 1: A Árvore Inicial.
(30)
Passo 2: Inserindo 20 e 10 (Criando o Desbalanceamento).
(30) <-- Fator: -2 (Desbalanceado!) / (20) / (10)
A inserção de 10 na sub-árvore esquerda do filho esquerdo do nó 30 causou o desbalanceamento. Este é o caso "Esquerda-Esquerda" (Left-Left).
Passo 3: A Solução (Rotação para a Direita). Para consertar, aplicamos uma Rotação para a Direita no nó desbalanceado (30).
(20) <-- Nova Raiz / \ (10) (30)
Resultado: O nó 20 "sobe" e se torna a nova raiz. O 30 se torna seu filho direito. A árvore está balanceada e a propriedade da BST continua válida!
Fica a Dica 💡
💊 Pílula Devly
Árvores são sobre hierarquia e relacionamentos. Use uma BST para manter dados ordenados e buscá-los rapidamente. Use um Heap quando a prioridade máxima/mínima é tudo que importa. Use uma Trie para problemas de prefixo com strings. E lembre-se: a linguagem nativa para "falar" com árvores é a recursão.