Dicionários Ordenados e Árvores Balanceadas
Igor Machado Coelho
01/06/2025-03/11/2026
São requisitos para essa aula:
Agradecimentos especiais ao prof. Fabiano Oliveira e prof. Fábio Protti, cujo conteúdo didático forma a base desses slides
Na aula anterior, definimos o Dicionário: um TAD que associa chaves (sem repetição) a valores.
template <typename D, typename K, typename V>
concept DicionarioTAD = requires(D d, K k, V v) {
{ d.tem(k) } -> std::same_as<bool>; // chave existe?
{ d.consulta(k) } -> std::same_as<V>; // valor (pre: tem(k))
{ d.adiciona(k, v) }; // insere ou atualiza
{ d.remove(k) } -> std::same_as<bool>; // removeu?
{ d.tamanho() } -> std::same_as<int>; // num. de chaves
};Com Tabelas de Dispersão, todas essas operações custam O(1) em média. Excelente!
Mas vimos também uma limitação importante…
Uma tabela de dispersão espalha as chaves de propósito. Perguntas sobre a ordem ficam caras:
Na tabela de dispersão, todas custam O(M+n): é preciso olhar tudo!
Lembra do Saco Ordenado? Com o vetor ordenado, essas
perguntas custavam
O(logn),
com buscaMaiorIgual e buscaMaior… mas inserir
e remover custavam
O(n).
Objetivo desta aula: responder às perguntas sobre a ordem e atualizar em O(logn).
Um Dicionário Ordenado é um Dicionário cujas chaves podem ser comparadas, e que oferece:
buscaMaiorIgual(c): posição da primeira chave
≥cbuscaMaior(c): posição da primeira chave
>cExemplo com as chaves B D E F L M N O S:
B D E F L M N O S
^ ^
buscaMaiorIgual('F') buscaMaior('F')
buscaMaiorIgual('G') = buscaMaiorIgual('H') = ... = posição de L
Com elas, construímos as demais perguntas: mínimo
(primeira do percurso), intervalo
[a,b]
(de buscaMaiorIgual(a) até passar de
b),
sucessor de uma chave, etc.
Na STL: lower_bound (buscaMaiorIgual) e
upper_bound (buscaMaior) do std::map. No Java:
ceilingKey e higherKey do
TreeMap.
Ao percorrer um dicionário, o iterador lê a entrada atual: uma vista da chave e uma vista mutável do valor, que ficam dentro do dicionário.
template <typename T> struct Vista {
using Tipo = T;
const T* origem;
T le() { return *origem; }
};
template <typename T> struct VistaMutavel {
using Tipo = T;
T* origem;
T le() { return *origem; }
void altera(T v) { *origem = v; }
};
template <typename K, typename V>
struct Entrada {
Vista<K> chave; // só leitura!
VistaMutavel<V> valor; // leitura e escrita
};São as Vistas da aula de Sacos, agora genéricas
(Vista<int> é a nossa VistaInt).
Vista: alterá-la
quebraria a ordem da estrutura!VistaMutavel: pode ser
alterado no próprio dicionárioNa STL: std::pair<const K, V>&. No Java:
Map.Entry<K, V> (tem setValue, mas não
setKey).
Um Dicionário Ordenado é um Dicionário e um Percorrível de entradas (em ordem de chave), com as duas buscas por ordem:
template <typename D, typename K, typename V>
concept DicionarioOrdenadoTAD =
DicionarioTAD<D, K, V> &&
PercorrivelTAD<D, Entrada<K, V>> &&
std::totally_ordered<K> && // comparáveis
requires(D d, K k) {
// 1a entrada com chave >= k
{ d.buscaMaiorIgual(k) }
-> IteradorTAD<Entrada<K, V>>;
// 1a entrada com chave > k
{ d.buscaMaior(k) }
-> IteradorTAD<Entrada<K, V>>;
};Atenção: o conceito verifica que as operações existem, mas não que o percurso está em ordem: essa invariante o compilador não verifica!
// chaves: B D E F L M N O S
DicionarioOrdenadoTAD<char, float> auto d = ...;
// mínimo: primeira chave do percurso
println("{}",
d.iterador().le().chave.le()); // B
// primeira chave >= 'G'
println("{}",
d.buscaMaiorIgual('G').le().chave.le()); // L
// primeira chave > 'F'
println("{}",
d.buscaMaior('F').le().chave.le()); // LDesafio: escreva buscaMaior(c) usando
apenas buscaMaiorIgual e o iterador.
Alterar valores não muda a estrutura: basta usar a vista mutável do valor, sem invalidar nada.
Inserir ou remover chaves invalida os iteradores
(como no Saco). Mas podemos continuar pela chave:
buscaMaior(c) encontra a entrada seguinte!
buscaMaior(c) custa
O(logn),
o mesmo que o remove: retomar o percurso sai de
graça (assintoticamente)!
| Implementação | adiciona, remove |
consulta |
buscaMaiorIgual |
percurso em ordem |
|---|---|---|---|---|
| tabela de dispersão | O(1) médio | O(1) médio | O(M+n) | ordenar: O(nlogn) |
| vetor ordenado (Saco Ordenado) | O(n) | O(logn) | O(logn) | O(n) |
| árvore de busca (ABB) | O(h) | O(h) | O(h) | O(n) |
| árvore balanceada (esta aula!) | O(logn) | O(logn) | O(logn) | O(n) |
A Árvore Binária de Busca (ABB) já mantém as chaves em ordem: o percurso em-ordem visita as chaves de forma crescente, e as buscas por ordem descem um caminho da raiz. O problema é a altura h, que pode chegar a n…
A solução desta aula: manter a árvore balanceada, com h=O(logn)!
Exemplos de ABB, contendo nós D, E, F, L, M, N e O.
Um tipo importante de Árvore Binária de Busca é a auto-balanceada, que resolve o problema de degeneração da árvore pelo controle de sua altura.
Já vimos que, nesse curso, uma árvore T é c-balanceada se todo nó X de T respeita h(X)≤c⋅s(X): daí temos que h(T)∈O(lgn). Veremos dois tipos de árvores auto-balanceadas para c=2: a AVL e Rubro-Negra.
Na AVL, tal controle é conseguido pelo cálculo de um fator de balanceamento (FB) para cada nó, definido por: altura do filho esquerdo - altura do filho direito. Observe que se o filho não existe, então sua altura será 0 (zero).
Calcule o fator de balanceamento da raiz das quatro árvores abaixo e informe se estão balanceadas:
Solução: 1. 1−3=−2 (não), 2. 0−3=−3 (não), 3. 3−0=3 (não), 4. 2−2=0 (sim), Fig.7 3−2=1 (sim)
Considere uma Árvore Binária de Busca Balanceada (ABBB) com controle
de altura. Dado um nó do tipo NoEnc7, calcule a altura com
base na altura dos filhos. Faça um método auxiliar
get_altura(no), que dá altura zero para um nó vazio:
int get_altura(const NoEnc7* no) { ... }
int calc_altura(const NoEnc7* no) { ... }
Considere uma Árvore Binária de Busca Balanceada (ABBB) com controle
de altura. Dado um nó do tipo NoEnc7, calcule o fator de
balanceamento com método auxiliar get_altura(no) já criado
previamente:
int fb(const NoEnc7* no) { ... }
Dado um nó do tipo NoEnc7, escreva a propriedade
eh_regulado, que retorna verdadeiro caso seu fator de
balanceamento seja no máximo 1 em módulo:
bool eh_regulado(auto* no) { ... }
Existem duas implementações populares de árvores auto-balanceadas para dicionários: AVL e rubro-negra.
Iremos explorar a árvore AVL, por sua simplicidade.
Criada em 1962 pelos russos Georgy Adelson-Velsky e Evgenii Landis, ela consegue manter um balanceamento após uma operação de inserção ou remoção.
Basta calcular o fator de balanceamento em cada nó e, caso esteja desbalanceada, algum tipo de operação de rotação será feita.
Nos próximos slides demonstramos as rotações possíveis.
Ocorre quando os fatores de balanceamento são 2 e 1 (ou 0).
Após rotação à direita, a árvore fica enraizada em Y, com X à esquerda e Y à direita.
Ocorre quando os fatores de balanceamento são -2 e -1 (ou 0).
Após rotação à direita, a árvore fica enraizada em Q, com P à esquerda e R à direita.
Ocorre quando os fatores de balanceamento são 2 e -1. Isso exige duas rotações, uma à esquerda e outra à direita.
Após rotação à esquerda em X, a árvore fica enraizada em Z, mas ainda desbalanceada como 2 1. Uma nova rotação à direita da raiz Z resolve o problema.
Ocorre quando os fatores de balanceamento são -2 e 1. Isso exige duas rotações, uma à direita e outra à esquerda.
Após rotação à direita em R, a árvore fica enraizada em P, mas ainda desbalanceada como -2 -1. Uma nova rotação à esquerda da raiz P resolve o problema.
Considere uma árvore A3 com nós M, D, O, B, F, N, S, E, L. A exclusão de S não acarreta em desbalanceamento. A exclusão de B gera um desbalanceamento no nó D, com fator 0-2=-2 seguido de 0.
Isso indica uma Rotação Simples à Esquerda, no nó D.
Qual o final após a rotação? F substitui D, tornando D seu filho à esquerda e E à direita de D, sendo que o filho à direita de F permanece L. A árvore se torna balanceada.
Dado um nó do tipo NoEnc7 desregulado (com peso à
esquerda), faça uma rotação à direita na raiz de uma subárvore e retorne
a nova raiz da subárvore:
NoEnc7* rotDir(NoEnc7* const raiz) { ... }
. . .
Dado um nó do tipo NoEnc7 regulado ou desregulado,
efetue o balanceamento (se necessário) e retorne a nova raiz da
subárvore:
NoEnc7* balanceia(NoEnc7* const raiz) { ... }
. . .
NoEnc7* balanceia(NoEnc7* const raiz)
// pre(raiz) // C++26
// post(out: eh_regulado(out)) // C++26
{
int f = fb(raiz); if (f > 1) {
if (fb(raiz->esq) < 0)
raiz->esq = rotEsq(raiz->esq);
return rotDir(raiz);
}; if (f < -1) {
if (fb(raiz->dir) > 0)
raiz->dir = rotDir(raiz->dir);
return rotEsq(raiz);
}
return raiz; // sem rotação necessária!
}rebalanceia até a raiz em O(h)auto* rebalanceia(auto* p)
// pre(p)
{
auto* raiz = p;
while (p) {
auto* avo = p->pai;
bool era_esq = avo && avo->esq == p;
p->h = calc_altura(p);
auto* nova = balanceia(p); // rotação já ajusta nova->pai
if (avo) { if (era_esq) avo->esq = nova; else avo->dir = nova; }
raiz = nova;
p = avo;
}
return raiz;
}Relembrando a estrutura de árvore binária com pai, chave-valor e alturas:
struct NoEnc7 {
char chave; // chave de busca
float dado; // valor armazenado
int h; // altura
NoEnc7* esq; // filho esquerdo
NoEnc7* dir; // filho direito
NoEnc7* pai; // pai
};
struct AVL {
NoEnc7* raiz; // raiz da árvore
int N; // número de nós
... // métodos típicos: busca, upsert, remove, ...
};upsertAVLImplementação de “upserção” (inserção, se chave nova, ou atualização, se chave já existe) em árvore binária de busca balanceada não-vazia:
NoEnc7* upsertAVL(char c, float d, NoEnc7* const raiz)
// pre(raiz) // C++26
{
if (c == raiz->chave) { raiz->dado = d; return raiz; }
if (c < raiz->chave) {
if (raiz->esq) raiz->esq = upsertAVL(c, d, raiz->esq);
else raiz->esq =
new NoEnc7{.chave=c,.dado=d,.h=1,.esq=0,.dir=0,.pai=raiz};
} else {
if (raiz->dir) raiz->dir = upsertAVL(c, d, raiz->dir);
else raiz->dir =
new NoEnc7{.chave=c,.dado=d,.h=1,.esq=0,.dir=0,.pai=raiz};
}
raiz->h = calc_altura(raiz); // sempre muda altura até raiz!
return balanceia(raiz);
}auto removeAVL(char c, auto* raiz) {
if(!raiz) return std::tuple{false, raiz}; // árvore vazia
auto* no = ::buscaABB(raiz, c); if(!no) return std::tuple{false, raiz};
auto* alvo = tem_dois_filhos(no) ? sucessor(no) : no; // nó a desligar
no->chave = alvo->chave; no->dado = alvo->dado; // se no==alvo, nada muda
auto [pai, filho] = extrai(alvo); // 'alvo' tem no máximo um filho
delete alvo;
auto* inicio = pai ? pai : filho; // de onde subir rebalanceando
return std::tuple{true, inicio ? rebalanceia(inicio) : inicio};
}Remova o A:
E FB: E = -1
/ \ B = -1, C = -1
B H H = -1, G = +1
/ \ / \ J = -1, K = -1
A C G J
\ / / \
D F I K
\
L
Para analisar a AVL, retomaremos conceitos importantes apresentados em aulas anteriores (de Árvores binárias):
Assuma que T é uma árvore AVL. Para cada nó X de T, afirmamos que s(X)≥⌈h(X)/2⌉.
Para árvores pequenas, a afirmação vale (base). Nó vazio: h=0 e s=0. Nó folha: h=1 e s=1. Suponha que, para toda subárvore AVL de altura menor que h, a afirmação vale (hipótese de indução).
Considere h≥2 (passo indutivo):
Pela definição recursiva, os filhos de T também são AVL, com alturas h−1 e h−2, ou h−1 e h−1.
Ambas são menores que h, então a hipótese se aplica a eles: cada filho X tem s(X)≥⌈(h−2)/2⌉=⌈h/2⌉−1.
Logo s(T)=1+min(s(TE),s(TD))≥1+min(⌈h/2⌉−1,⌈h/2⌉−1)=⌈h/2⌉.
Assim, h(X)≤2⋅s(X) em todo nó, ou seja, a AVL é balanceada. Lembrando que toda árvore balanceada tem altura logarítmica h≤2⋅lg(n+1).
A sequência de Fibonacci é definida por F(0)=0, F(1)=1 e F(k)=F(k−1)+F(k−2), para k≥2:
| k | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| F(k) | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | 55 |
| F(k−1)F(k) | - | 1 | 2 | 1,5 | 1,67 | 1,6 | 1,625 | 1,615 | 1,619 | 1,618 |
Intuição: a razão entre termos consecutivos se estabiliza em torno de 1,618. Ou seja, para k grande, F(k)≈1,618⋅F(k−1): a sequência se comporta quase como uma progressão geométrica, crescendo exponencialmente.
Esse valor é o número de ouro (ou razão áurea), denotado por φ.
Desafio: se F(k) se comporta como uma progressão geométrica rk, quanto vale r?
Propriedade fundamental: φ2=φ+1 (vem do passo 2).
Observação: como a recorrência é linear, qualquer combinação aφk+bψk também a satisfaz.
Para que ela seja F(k), usamos F(0)=0 e F(1)=1. Para k=0: aφ0+bψ0=a+b=0, ou seja, b=−a. Para k=1: aφ+bψ=1, e substituindo b=−a, temos aφ−aψ=1, ou seja, a(φ−ψ)=1.
Como φ−ψ=5, temos a=−b=51, obtendo a fórmula de Binet: F(k)=5φk−ψk≈5φk (pois ∣ψ∣<1).
Seja N∗(h) o menor número de nós de uma AVL de altura h. Desafio: encontre N∗(h).
Uma subárvore mínima de altura h−1, e outra mínima com a menor altura permitida, h−2:
N∗(h)=1+N∗(h−1)+N∗(h−2), com N∗(0)=0 e N∗(1)=1
Somando 1 dos dois lados, N∗(h)+1=(N∗(h−1)+1)+(N∗(h−2)+1) segue a recorrência de Fibonacci, começando em 1=F(2) e 2=F(3). Logo, N∗(h)=F(h+2)−1.
E
/ \
C G
/ \ /
B D F
/
A
Altura 4: F(6)−1=7 nós.
Essas AVLs mínimas são as árvores de Fibonacci: raiz, com árvores de Fibonacci de alturas h−1 e h−2 como filhos. Em todo nó, as alturas dos filhos diferem de exatamente 1, logo são AVL.
Uma AVL com altura h tem n≥N∗(h) nós. Somando 1 dos dois lados e usando Binet:
n+1≥N∗(h)+1=F(h+2)−1+1≈5φh+2, ou seja, n+1≳5φh+2
Temos que o número de nós cresce exponencialmente com a altura, como φh.
Multiplicando por 5 e invertendo o lado: φh+2≲5⋅(n+1)
Aplicando o logaritmo na base φ: h+2≲logφ(5⋅(n+1))=logφ(n+1)+logφ5
Subtraindo 2 dos dois lados:
h≲logφ(n+1)+(logφ5−2)≈1,44⋅lg(n+1)−0,33, ou seja, h∈O(1,44lgn)
pois logφx=lgφlgx, com lgφ1≈1,44, e logφ5−2≈1,67−2=−0,33.
Exemplo: com n=220−1, temos h≲1,44⋅20−0,33≈28,5, ou seja, h≤28, contra 40 de uma árvore 2-balanceada qualquer.
Uma subárvore vazia será chamada Nó Externo (ou Folha Externa).
Desafio: quantas folhas externas existem em uma árvore binária com n nós?
Solução: n+1 (são 2n arestas, e n−1 deles apontam para nós).
Observação: O termo nó externo segue [Knuth97] (árvore binária estendida).
Uma árvore rubro-negra é uma ABB em que cada nó possui uma cor (vermelho ou preto), usada para controlar o balanceamento no lugar da altura.
int h do NoEnc7 por
um bool vermelho (1 bit de informação!)std::map/std::set (libstdc++),
TreeMap (Java) e no kernel LinuxIdeia: o balanceamento é menos rígido que na AVL (árvore um pouco mais alta), em troca de menos rotações nas atualizações.
Além das propriedades de ABB, uma árvore rubro-negra satisfaz:
Exercício: justifique a propriedade 5 usando as propriedades 2, 3 e 4.
Propriedade 5: se N tem só o filho C, o lado vazio de N tem 0 pretos internos. Se C fosse preto, os caminhos por C teriam pelo menos 1 preto interno, violando a propriedade 4. Logo C é vermelho. Pelo mesmo motivo, os filhos de C não podem ser nós internos pretos, e pela propriedade 3 também não podem ser vermelhos. Então C é folha.
Altura negra bh(X): número de nós pretos de um caminho de X até um nó nulo, sem contar X (bem definida pela propriedade 4).
Observações:
M(P)
/ \
D(V) O(P)
/ \ / \
B(P) F(P) N(V) S(V)
/ \ / \ / \ / \
[] [] E(V) L(V) [] [] [] []
/ \ / \
[] [] [] []
[] nó nulo (preto)[]: pretos
B,
[][]: pretos
F,
[][]: pretos
O,
[]Exercício: pinte M de vermelho. E se, em vez disso, pintar D de preto? Quais propriedades deixam de valer?
Muito do que já foi visto se reaproveita:
buscaABB, minimo, sucessor),
pois a cor não interfere na ordemrotDir/rotEsq da AVL, sem as linhas que
atualizam hstruct NoEnc8 {
char chave; float dado;
bool vermelho; // no lugar de 'int h'
NoEnc8* esq; NoEnc8* dir; NoEnc8* pai;
};
bool eh_vermelho(const NoEnc8* no) {
return no && no->vermelho; // nó nulo é preto (propriedade 2)
}Compare com get_altura da AVL: lá o nó nulo tem altura
0; aqui, o nó nulo é preto.
Insira como na ABB (upsertABB) e pinte o novo nó
N
de vermelho: a propriedade 4 continua valendo, mas a 3
pode ser violada. Sejam
P
o pai,
A
o avô e
T
o tio de
N:
O caso 3 pode se repetir O(logn) vezes, mas só recolore. Rotações: no máximo 2 (casos 4 e 5).
Observação: os casos 4 e 5 juntos são exatamente a rotação dupla da AVL!
Antes: G inserido como vermelho, à esquerda de L:
M(P)
/ \
D(V) O(P)
/ \ / \
B(P) F(P) N(V) S(V)
/ \
E(V) L(V)
/
G(V)
L e G vermelhos: viola a propriedade 3! Pai P=L e tio T=E vermelhos, avô A=F.
Caso 3: L e E ficam pretos, F fica vermelho:
M(P)
/ \
D(V) O(P)
/ \ / \
B(P) F(V) N(V) S(V)
/ \
E(P) L(P)
/
G(V)
Agora F e D são vermelhos! Com N:=F: pai P=D vermelho, tio T=O preto, em zigue-zague. Próximo passo: rotação à esquerda em D (caso 4).
Caso 4: rotação à esquerda em D:
M(P)
/ \
F(V) O(P)
/ \ / \
D(V) L(P) N(V) S(V)
/ \ /
B(P) E(P) G(V)
Agora D, F e M estão em linha. Próximo passo: rotação à direita em M (caso 5).
Caso 5: rotação à direita em M; F fica preto e M fica vermelho:
F(P)
/ \
D(V) M(V)
/ \ / \
B(P) E(P) L(P) O(P)
/ / \
G(V) N(V) S(V)
Altura negra 2 em todo caminho. Fim!
Remova como na ABB (removeABB): se o nó tem dois filhos,
troque pelo sucessor, recaindo em um nó
X
com no máximo um filho.
O caso 3 pode se repetir O(logn) vezes, mas só recolore. Rotações: no máximo 3.
Afirmamos que h(X)≤2⋅s(X) para todo nó X (contando nós internos, como na AVL).
Seja p(X) o número de nós pretos internos em um caminho de X até um nó nulo (o mesmo para todos os caminhos, pela propriedade 4), e seja δ=1 se X é vermelho, ou δ=0 se X é preto.
Considere qualquer caminho de X até um nó nulo, com k nós internos:
Aplicando ao caminho mais curto e ao mais longo:
s(X)≥p(X)+δ e h(X)≤2⋅p(X)+δ≤2⋅(p(X)+δ)≤2⋅s(X)
Assim, a rubro-negra é 2-balanceada e, portanto, h≤2⋅lg(n+1). ■
Alternativa (Cormen et al.): por indução, uma subárvore com altura negra bh possui pelo menos 2bh−1 nós internos.
| AVL | Rubro-Negra | |
|---|---|---|
| Informação por nó | altura (int h) |
cor (1 bit) |
| Altura máxima | ≈1,44⋅lg(n+1) | 2⋅lg(n+1) |
| Rotações na inserção | no máximo 1 (simples ou dupla) | no máximo 2 |
| Rotações na remoção | até O(logn) (exemplo anterior!) | no máximo 3 |
| Melhor para | muitas buscas | muitas inserções/remoções |
Ambas garantem busca, inserção e remoção em O(logn). Na rubro-negra, os ajustes ao subir na árvore são feitos majoritariamente por recoloração, que é O(1) por nível.
Conexão com a próxima aula: cada nó preto, junto com seus filhos vermelhos, equivale a um nó de uma Árvore B de ordem 4 (árvore 2-3-4) com até 3 chaves, e bh corresponde à altura dessa Árvore B.
Veremos mais implementações de dicionários, com Tabelas Hash (sem árvores) e com Árvores B (multivias, não binárias!).
Até breve!
Além da bibliografia do curso, recomendamos para esse tópico:
Em especial, agradeço aos colegas que elaboraram bons materiais, como o prof. Fabiano Oliveira (IME-UERJ), e o prof. Jayme Szwarcfiter cujos conceitos formam o cerne desses slides.
Estendo os agradecimentos aos demais colegas que colaboraram com a elaboração do material do curso de Pesquisa Operacional, que abriu caminho para verificação prática dessa tecnologia de slides.
Esse material de curso só é possível graças aos inúmeros projetos de código-aberto que são necessários a ele, incluindo:
Agradecimento especial a empresas que suportam projetos livres envolvidos nesse curso:
Esses slides foram escritos utilizando pandoc, segundo o tutorial ilectures:
Exceto expressamente mencionado (com as devidas ressalvas ao material cedido por colegas), a licença será Creative Commons.
Licença: CC-BY 4.0 2020
Igor Machado Coelho