Árvores binárias e percursos
Altura, profundidade, recursão estrutural e ordens de visita.
Uma árvore binária é vazia ou tem uma raiz e duas subárvores binárias, esquerda e direita. A posição de cada filho importa. Não existe, por definição, qualquer ordenação dos valores: uma árvore binária de pesquisa acrescenta essa propriedade na página seguinte.
Esta página fixa vocabulário de forma e percursos antes de usares a ordem das BST. Precisas apenas de recursão e pilha de chamadas das lições de complexidade.
Forma, altura e profundidade
A raiz é o nó sem pai. O pai é o nó diretamente acima; cada nó tem no máximo um pai. Uma folha é um nó sem filhos. A profundidade de um nó é o número de arestas da raiz até ele. A raiz tem profundidade zero. A altura de um nó é o maior número de arestas desse nó até uma folha. Adotamos altura -1 para a árvore vazia e 0 para uma folha. Alguns enunciados contam nós em vez de arestas; nesse caso os valores mudam em uma unidade.
Uma árvore completa tem todos os níveis preenchidos, exceto eventualmente o último, preenchido da esquerda para a direita. Uma árvore perfeita tem todos os níveis preenchidos. Uma árvore estrita tem zero ou dois filhos em cada nó. Estas condições são diferentes; «equilibrada» precisa de uma condição quantitativa sobre a altura.
Numa árvore de altura h, existem no máximo nós à profundidade d e no máximo nós no total. Logo uma árvore com n nós tem altura pelo menos . Uma cadeia de n nós tem altura n-1, o máximo possível.
Recursão estrutural
Para contares nós, o vazio contribui zero e uma raiz contribui um mais os tamanhos das subárvores. Para a altura, o vazio contribui -1 e um nó contribui um mais o máximo das alturas dos filhos. Não somes as alturas, porque um caminho descendente escolhe um filho de cada vez.
A prova é por indução na estrutura. O resultado é correto para o vazio. Se as chamadas devolvem tamanhos corretos para as duas subárvores, somar esses tamanhos com a raiz conta todos os nós uma única vez. A mesma hipótese sobre alturas justifica escolher o caminho mais longo.
Cada nó é visitado uma vez: tempo . A pilha tem no máximo um caminho da raiz à folha: espaço , que pode ser linear numa cadeia. Uma árvore de dimensão grande e desequilibrada pode exceder a pilha de chamadas, por isso usa um percurso iterativo quando os limites o exigem.
Pré-ordem, em-ordem e pós-ordem
A diferença está no momento em que visitas a raiz:
| Percurso | Ordem | Uso frequente |
|---|---|---|
| pré-ordem | raiz, esquerda, direita | descrever a estrutura a partir da raiz |
| em-ordem | esquerda, raiz, direita | obter chaves ordenadas numa árvore de pesquisa |
| pós-ordem | esquerda, direita, raiz | calcular valores dependentes dos filhos; libertar nós |
| por níveis | fila, da raiz para níveis seguintes | profundidades e largura |
Começa na raiz 8. Em pré-ordem escreve-a antes de descer para 4. Em em-ordem suspende 8, visita toda a esquerda e só então a escreve. Em pós-ordem deixa 8 para depois das duas subárvores. Para a árvore desenhada, os percursos são pré-ordem 8 4 2 6 10 9, em-ordem 2 4 6 8 9 10 e pós-ordem 2 6 4 9 10 8. O percurso por níveis é 8 4 10 2 6 9.
#include <algorithm>
#include <iostream>
#include <vector>
enum class Ordem { pre, em, pos };
struct No { int valor, esq, dir; };
const std::vector<No> a{{8,1,2},{4,3,4},{10,5,-1},{2,-1,-1},{6,-1,-1},{9,-1,-1}};
int altura(int p) { return p == -1 ? -1 : 1 + std::max(altura(a[p].esq), altura(a[p].dir)); }
int tamanho(int p) { return p == -1 ? 0 : 1 + tamanho(a[p].esq) + tamanho(a[p].dir); }
void visitar(int p, Ordem ordem, std::vector<int>& r) {
if (p == -1) return;
if (ordem == Ordem::pre) r.push_back(a[p].valor);
visitar(a[p].esq, ordem, r);
if (ordem == Ordem::em) r.push_back(a[p].valor);
visitar(a[p].dir, ordem, r);
if (ordem == Ordem::pos) r.push_back(a[p].valor);
}
int main() {
for (Ordem ordem : {Ordem::pre, Ordem::em, Ordem::pos}) {
std::vector<int> r; visitar(0, ordem, r);
for (std::size_t i = 0; i < r.size(); ++i)
std::cout << (i ? " " : "") << r[i];
std::cout << '\n';
}
std::cout << "nos: " << tamanho(0) << "; altura: " << altura(0) << '\n';
std::cout << "vazia: " << tamanho(-1) << ' ' << altura(-1) << '\n';
}Executa o código para ver a saída e os gráficos.
Os índices dos filhos no programa referem-se a posições válidas do vetor, ou a -1 para ausência. A estrutura fixa não tem ciclos nem nós partilhados entre pais. Se permitisses essas situações, deixaria de ser uma árvore e as recorrências poderiam visitar nós várias vezes ou não terminar.
Calcular propriedades em pós-ordem
Para contar nós com exatamente dois filhos, soma os resultados das subárvores e acrescenta um apenas quando ambas as ligações existem. Uma folha e um nó com um filho contribuem zero. Na árvore da figura, só 8 e 4 contribuem, logo o resultado é 2.
Para decidir se a árvore é perfeita, não basta comparar as alturas dos filhos da raiz. Cada subárvore também tem de ser perfeita. Devolve em cada chamada um resumo com a altura, a perfeição e a contagem de nós com dois filhos:
- O vazio tem altura
-1, contagem zero e é considerado perfeito neste contrato. - Um nó é perfeito se as duas subárvores forem perfeitas e tiverem alturas iguais.
- A sua altura é um mais a maior altura; a contagem soma as dos filhos e a contribuição local.
#include <algorithm>
#include <iostream>
struct No { int valor; const No* esq; const No* dir; };
struct Resumo { bool perfeita; int altura; int binarios; };
Resumo resumir(const No* p) {
if (!p) return {true, -1, 0};
const auto e = resumir(p->esq), d = resumir(p->dir);
return {e.perfeita && d.perfeita && e.altura == d.altura,
1 + std::max(e.altura, d.altura),
e.binarios + d.binarios + (p->esq && p->dir ? 1 : 0)};
}
void mostrar(const No* p) {
const auto r = resumir(p);
std::cout << "perfeita=" << r.perfeita << "; altura=" << r.altura
<< "; dois filhos=" << r.binarios << '\n';
}
int main() {
const No a{1,nullptr,nullptr}, b{3,nullptr,nullptr};
const No folhaDireita{6,nullptr,nullptr};
const No c{2,&a,&b}, umFilho{4,&folhaDireita,nullptr};
const No alturasIguais{5,&c,&umFilho};
mostrar(nullptr); mostrar(&a); mostrar(&c); mostrar(&alturasIguais);
return 0;
}Executa o código para ver a saída e os gráficos.
Os primeiros casos mostram o vazio, uma folha e uma raiz com duas folhas: todos perfeitos, com alturas -1, 0 e 1 e contagens 0, 0 e 1. O último caso tem dois filhos de altura 1, mas a subárvore direita só tem um filho. A altura da árvore é 2 e há dois nós com dois filhos, mas não é perfeita. Esta entrada revela por que a igualdade de alturas só na raiz seria insuficiente.
O argumento é indutivo: duas subárvores perfeitas de igual altura enchem todos os níveis até à mesma profundidade; acrescentar a raiz enche o nível anterior. Se faltar um filho, as alturas das subárvores perfeitas seriam -1 e pelo menos 0, diferentes. O método visita cada nó uma vez, dando de tempo e de pilha. Recalcular a altura por um percurso completo em cada nó pode dar custo quadrático numa cadeia.
Reconstruir e representar
Com valores distintos, pré-ordem e em-ordem determinam a árvore: o primeiro valor da pré-ordem é a raiz; a sua posição na em-ordem separa os valores das subárvores. Os tamanhos dessas regiões separam a pré-ordem restante. Repete o procedimento nas duas regiões.
Para pré-ordem [8,4,2,6,10,9] e em-ordem [2,4,6,8,9,10], a raiz é 8 e a esquerda tem três nós. A pré-ordem esquerda começa em 4; na em-ordem esquerda, 2 fica antes de 4 e 6 depois. A direita começa em 10 e tem 9 à esquerda. Procurar a raiz por percurso linear em cada chamada pode dar custo quadrático; um mapa de posições localiza cada chave sem repetir esses percursos. Com uma tabela hash de consultas esperadas constantes, a reconstrução é esperada linear; com um mapa ordenado, custa O(n log n).
Pré-ordem sozinha não determina uma árvore binária genérica: [1,2] pode ter 2 à esquerda ou à direita. Marcas explícitas de vazio resolvem a ambiguidade; uma folha em pré-ordem torna-se valor, vazio, vazio. Com chaves repetidas, pré-ordem mais em-ordem pode voltar a ser ambígua.
Para contares nós à profundidade k, o vazio contribui zero; com k=0, um nó existente contribui um; caso contrário, soma os resultados dos filhos com k-1. Na árvore desenhada, as folhas são 2, 6 e 9, três no total; a profundidade 1 tem os nós 4 e 10. Contar folhas soma um por folha e zero no vazio. Para k<0, devolve zero. O algoritmo só precisa de visitar até ao nível pedido; o limite de pior caso continua .
Exercícios
As justificações e os programas em C++ são de autoavaliação: compara-os com os critérios da solução. Nas questões numéricas, a verificação automática confirma apenas o valor.
Distinguir altura e níveis
Uma árvore completa tem 10 nós. Qual é a altura, medida em arestas?
Pista
As capacidades até às alturas 2 e 3 são 7 e 15 nós.
Mais uma pista
O décimo nó está no quarto nível, com profundidade 3.
Solução
A altura é 3, porque a forma completa enche os primeiros níveis com 1+2+4=7 nós e os três restantes começam o nível de profundidade 3. Se fossem 10 nós numa árvore arbitrária, a altura poderia variar de 3 a 9.
Erros frequentes
Não respondas 4, que é o número de níveis; não concluas uma altura única sem a hipótese de completude.
Reconstruir com duas ordens
Valores distintos têm pré-ordem [7,3,1,5,9] e em-ordem [1,3,5,7,9]. Desenha a árvore e dá a pós-ordem.
Pista
A raiz é o primeiro valor da pré-ordem.
Mais uma pista
À esquerda de 7 ficam três valores; a raiz desse ramo é 3.
Solução
A raiz 7 tem filhos 3 e 9; 3 tem filhos 1 e 5. A pós-ordem é [1,5,3,9,7], porque a em-ordem separa os membros das subárvores e o seu tamanho separa as regiões da pré-ordem. A conclusão usa valores distintos.
Confere a tua resposta:
- Desenhei raiz 7, filhos 3 e 9, e filhos 1 e 5 de 3.
- Obtive pós-ordem 1,5,3,9,7.
- Separei as subárvores pela em-ordem e usei chaves distintas.
Erros frequentes
Não uses o primeiro valor da em-ordem como raiz; pré-ordem sozinha não distingue filhos esquerdos de direitos.
Contar folhas e nível
Uma árvore tem raiz 8, filhos 4 e 10; 4 tem filhos 2 e 6, e 10 tem apenas o filho esquerdo 9. Os restantes filhos são vazios. Conta as folhas e os nós à profundidade 1, com profundidade zero na raiz. Mostra os retornos da recursão.
Pista
Solução
Para folhas, cada folha devolve 1 e o vazio devolve 0. Em 4, o resultado é
1+1=2; em 10, é 1+0=1; em 8, é 2+1=3. São as folhas 2, 6 e 9. Para
contar à profundidade 1, a chamada em 8 pede profundidade 0 a cada filho:
4 devolve 1 e 10 devolve 1. A soma é 2. Não precisamos de descer aos
filhos desses nós. Com profundidade negativa ou árvore vazia, devolvemos
zero.
Confere a tua resposta:
- Distingui folha de nó interno.
- Contei três folhas na árvore desenhada.
- Mostrei o retorno em cada chamada para k=1.
Erros frequentes
Conferir os filhos e as subárvores
A raiz 10 tem filhos 4 e 20. O nó 4 tem duas folhas, 2 e 6; o nó 20 tem apenas a folha esquerda 15. A árvore é perfeita? Dá altura e número de nós com exatamente dois filhos. Descreve uma função recursiva que calcule as três propriedades num único percurso, sem chamar uma função de altura completa em cada nó. Considera o vazio perfeito, com altura -1.
Pista
Alturas iguais na raiz não dizem se cada subárvore está preenchida.
Solução
As folhas devolvem (perfeita=true,altura=0,binarios=0). O nó 4 devolve
(true,1,1). Em 20, o filho esquerdo tem altura 0 e o direito -1: devolve
(false,1,0). A raiz tem filhos com igual altura, mas a direita não é
perfeita, portanto devolve (false,2,2). Os dois nós com dois filhos são 10
e 4. No vazio, devolve (true,-1,0); em cada nó, perfeição exige os dois
resultados verdadeiros e alturas iguais, altura é um mais o máximo, e a
contagem soma as dos filhos e um se ambos existirem. Cada nó é visitado uma
vez: Θ(n) de tempo, O(h+1) de pilha. Exigir apenas diferença de alturas
no máximo um verificaria outro critério e não a perfeição.
Confere a tua resposta:
- Rejeitei a perfeição, apesar das alturas iguais dos filhos da raiz.
- Obtive altura 2 e dois nós com exatamente dois filhos.
- Combinei perfeição e altura dos filhos em cada nó, com vazio perfeito de altura -1.
- Justifiquei uma visita por nó e espaço O(h+1).