Algoritmos e Estruturas de Dados
Algoritmos e Estruturas de Dados
Algoritmos e Estruturas de Dados
ACH2023 - ALGORITMOS E
ESTRUTURAS DE DADOS I
atualizada em 03/06/2011
Listas Lineares
Sequenciais
Ligadas
Implementação Estática
Implementação Dinâmica
Técnicas Especiais: Cabeça, Sentinela, Circularidade, Encadeamento Duplo
Filas
Implementação Estática
Implementação Dinâmica
Deques (Filas de duas Pontas)
Implementação Dinâmica
Pilhas
Implementação Estática
Implementação Dinâmica
Implementação de Múltiplas Pilhas em um Vetor
Aplicações
Matrizes Esparsas
Listas Generalizadas
A forma mais comum de implementação de uma lista sequencial é através de um vetor de elementos (A) do
tipo REGISTRO de tamanho máximo possível definido pela constante MAX.
typedef struct
{
REGISTRO A[MAX];
int nroElem;
} LISTA;
Os registros contêm campos de informações variadas que são dependentes da aplicação. Por exemplo, um
registro de aluno conteria campos como nome, idade, NroUSP etc. Para efeito de demonstração da busca em
estruturas ordenadas e outras operações de identificação de elementos, definimos também um campo chave
em cada registro.
typedef struct
{
TIPOCHAVE chave;
// outros campos...
} REGISTRO;
Vantagens:
– Acesso direto a qualquer elemento com base no seu índice. O tempo é constante O(1). No entanto, em
muitas aplicações o índice do dado procurado não é conhecido, o que faz desta uma vantagem apenas
relativa.
– Se a lista estiver ordenada pela chave em questão, a busca por uma chave pode ser efetuada através de
busca binária O(lg n), que é excelente.
Desvantagens:
– Mesmo em uma estrutura ordenada, o pior caso de inserção e exclusão (na frente da lista) exige
movimentação de todos os n elementos da lista, ou seja, O(n). Isso pode ser inadequado para aplicações em
que ocorrem muitas atualizações, e é principalmente por causa disso que a lista sequencial é muitas vezes
substituída por uma estrutura de dados mais complexa.
– Implementação estática, exigindo que o tamanho do vetor seja previamente estabelecido. Embora
algumas linguagens de programação (como C) permitam a expansão posterior de um vetor deste tipo, esta
operação requer cópia de áreas de dados inteiras em memória, a um custo O(n) que inviabiliza sua aplicação
no caso geral.
3
// Inicialização da lista sequencial
void inicializarListaSequencial(LISTA *l)
{
l->nroElem = 0;
}
// Busca sequencial em lista COM SENTINELA (vetor criado com MAX+1 posições)
int buscaSent(TIPOCHAVE ch, LISTA l)
{
int i = 0;
l.A[l.nroElem].chave = ch; // sentinela
while(l.A[i].chave < ch) i++;
if((i > (l.nroElem – 1)) || (l.A[i].chave != ch)) return(-1); // não achou
else return(i);
}
5
// Inserção em lista ordenada COM SENTINELA sem duplicação
bool inserirElemListaOrd(TIPOCHAVE ch, LISTA *l)
{
int i = 0;
if(l->nroElem >= MAX) return(false); // lista cheia
l->A[l->nroElem].chave = ch; // sentinela
while(l->A[i].chave < ch) i++;
if((l->A[i].chave == ch) && (i < l->nroElem))
return(false);
else return(inserirElemListaSeq(ch, i, l));
}
// Exclusão
bool excluirElemLista(TIPOCHAVE ch, LISTA *l)
{
int pos, j;
pos = buscaSeq(ch, *l);
if(pos == -1) return(false); // não existe
for(j = pos; j < l->nroElem - 1; j++)
l->A[j] = l->A[j+1];
l->nroElem--;
return(true);
}
Uma lista linear ligada (ou simplesmente lista ligada) é uma lista linear na qual a ordem (lógica) dos
elementos da lista (chamados “nós”) não necessariamente coincide com sua posição física (em memória).
Pode ser implementada de forma estática (usando-se um vetor) ou, em linguagens de programação que
oferecem suporte à alocação dinâmica, com uso de ponteiros.
typedef struct
{
REGISTRO A[MAX];
int inicio;
int dispo;
} LISTA;
Cada registro contém, além dos campos exigidos pela aplicação, um campo prox que contém um índice para
o próximo elemento na série (vazio ou ocupado, conforme descrito a seguir). Um campo prox com valor -1
será usado para designar que o elemento em questão não possui sucessor.
typedef struct
{
TIPOCHAVE chave;
int prox;
} REGISTRO;
Ao ser criada, a lista possui inicio = -1 (que indica que a lista está vazia) e dispo = 0 (ou seja, a primeira
posição do vetor está disponível). Além disso, os campos prox de cada registro (exceto o último) apontam
para o registro seguinte, constituindo uma lista de registros vazios encabeçada por dispo. O campo prox do
último registro recebe o valor -1 indicando que não há mais elementos depois daquele ponto.
A lista está cheia quando não há mais nós disponíveis (i.e., quando dispo == -1). A lista esta vazia quando
não há elemento inicial (i.e., quando inicio == -1).
// Inicialização
void inicializarListaLigadaEstatica(LISTA *l)
{
l->inicio = -1;
l->dispo = 0;
for(int i=0; i < MAX – 1; i++)
l->A[i].prox = i + 1;
l->A[MAX – 1].prox = -1;
}
7
// Exibição da lista
void exibirLista(LISTA l)
{
int i = l.inicio;
while (i > -1) {
printf("%d ", l.A[i].chave); // TIPOCHAVE deve ser int
i = l.A[i].prox;
}
}
// Busca sequencial
int buscaSeqOrd(TIPOCHAVE ch, LISTA l, int *ant)
{
int i = l.inicio;
*ant= -1;
while (i != -1)
{
if(l.A[i].chave >= ch) break;
*ant = i;
i= l.A[i].prox;
}
If(l.A[i].chave==ch) return(i);
if ((i != -1) && (l.A[i].chave==ch)) return i;
else return (-1);
}
O gerenciamento de nós livres e ocupados exige a criação de rotinas específicas para “alocar” e “desalocar”
um nó da lista apontada por dispo. A alocação envolve descobrir o índice de uma posição válida no vetor na
qual novos dados possam ser inseridos, além de retirar esta posição da lista de disponíveis. A desalocação
envolve a devolução de uma posição à lista de disponíveis para que possa ser reutilizada.
As rotinas de alocação/desalocação não devem ser chamadas sem que sejam seguidas da
correspondente inserção/exclusão, pois haverá perda de dados e a estrutura se tornará inconsistente.
9
2.2.2. Listas Ligadas de Implementação Dinâmica
Para evitar a necessidade de definição antecipada do tamanho máximo da estrutura de implementação estática
(i.e., o vetor), podemos tirar proveito dos recursos de alocação dinâmica de memória das linguagens de
programação como C, deixando o gerenciamento de nós livres / ocupados a cargo do ambiente de
programação. Esta técnica constitui à implementação dinâmica de listas ligadas, e requer o uso das funções
disponibilizadas em malloc.h:
# include <malloc.h>
Em uma lista ligada de implementação dinâmica, não há mais uso de vetores. Cada elemento da lista é uma
estrutura do tipo NO, que contém os dados de cada elemento (inclusive a chave) e um ponteiro prox para o
próximo nó da lista. Um nome auxiliar (estrutura) é usado para permitir a auto-referência ao tipo NO que está
sendo definido.
O tipo LISTA propriamente dito é simplesmente um ponteiro inicio apontando para o primeiro nó da
estrutura (ou para NULL no caso da lista vazia). O último elemento da lista possui seu ponteiro prox também
apontando para NULL. Embora não seja necessário o encapsulamento deste ponteiro em um tipo LISTA
(afinal, uma lista é apenas um ponteiro, que pode ser NULL ou não), esta medida será adotada aqui por
questões de padronização em relação aos tipos LISTA anteriores, e também porque alguns dos próximos
tipos agregarão mais informações a esta definição.
typedef struct
{
NO* inicio;
} LISTA;
A alocação e desalocação de nós é feita dinamicamente pelo próprio compilador C através das primitivas
malloc e free, respectivamente.
Via de regra, malloc() é usado em rotinas de inserção ou criação de novos nós, enquanto que free() é usado na
exclusão. Rotinas que não criam ou destroem nós dificilmente precisam usar estas funções.
A única diferença significativa entre as implementações estática e dinâmica de listas ligadas está no fato de
que a implementação estática “simula” uma lista ligada em vetor, e nos obriga a gerenciar as posições livres e
ocupadas. Isso deixa de ser necessário no caso da alocação dinâmica “real”.
11
// Quantos elementos existem na lista
int tamanhoLista(LISTA l)
{
NO* p = l.inicio;
int tam = 0;
while (p)
{
tam++;
p = p->prox;
}
return(tam);
}
// Destruição da lista
void destruirLista (LISTA *l)
{
NO* atual;
NO* prox;
atual = l->inicio;
while (atual) {
prox = atual->prox; // guarda próxima posição
free(atual); // libera memória apontada por atual
atual = prox;
}
l->inicio = NULL; // ajusta início da lista (vazia)
}
O nó sentinela, criado ao final de uma lista, é usado para armazenar a chave de busca e assim acelerar o
respectivo algoritmo, uma vez que reduz o número de comparações necessárias pela metade.
typedef struct
{
NO* inicio;
NO* sentinela;
} LISTA;
13
// Exibição (lista com sentinela)
void exibirLista(LISTA l)
{
NO* p = l.inicio;
while (p != l.sentinela)
{
printf("%d ",p->chave); // chave deve ser int
p = p->prox;
}
}
15
// Anexar um novo elemento à lista com sentinela
void anexarElemLista(TIPOCHAVE ch, LISTA *l)
{
NO* novo;
NO* ant;
ant = ultimoElemLista(*l);
novo = (NO *) malloc(sizeof(NO));
novo->chave = ch;
novo->prox = l->sentinela;
if(ant == NULL) l->inicio = novo;
else ant->prox = novo;
}
O nó cabeça, criado no início de uma lista, é usado para simplificar o projeto dos algoritmos de inserção e
exclusão. Pode também armazenar a chave de busca (funcionando como nó sentinela) quando a lista for
circular.
A circularidade é em geral exigência da aplicação (e.g., que precisa percorrer continuamente a estrutura) mas
pode também facilitar inserções e exclusões quando combinada com uso de um nó cabeça.
17
// Posição da chave de busca na lista ordenada
NO* buscaSeqOrd(TIPOCHAVE ch, LISTA l, NO* *ant)
{
NO* p = l.cabeca->prox;
*ant = l.cabeca;
l.cabeca->chave = ch; // usa cabeça como sentinela
while(p->chave < ch)
{
*ant = p;
p = p->prox;
}
if((p != l.cabeca) && (p->chave == ch) ) return(p);
else return(NULL);
}
Quando necessitamos percorrer a lista indistintamente em ambas as direções, usamos encadeamento duplo
(ligando cada nó ao seu antecessor e ao seu sucessor).
19
2.3. Filas
Filas são listas lineares com disciplina de acesso FIFO (first-in, first-out, ou, primeiro a entrar é o primeiro a
sair). Sua principal aplicação é o armazenamento de dados em que é importante preservar a ordem FIFO de
entradas e saídas.
O comportamento de fila é obtido armazenando-se a posição das extremidades da estrutura (chamadas aqui
de fim e início), e permitindo entradas apenas na extremidade “fim” e retiradas apenas na extremidade
“início”.
A implementação pode ser estática (usando um vetor circular) ou dinâmica (com ponteiros) sem diferenças
significativas em termos de eficiência, uma vez que estas operações só podem ocorrer nas extremidades da
estrutura.
typedef struct {
NO* inicio;
NO* fim;
} Fdinam;
typedef struct
{
int inicio;
int fim;
RegistroEstat A[MAX];
} Festat;
21
// Retirar um item da frente ou retornar -1 se vazia
TIPOCHAVE sairFestat(Festat *f)
{
if(f->inicio < 0) return(-1);
int ch = f->A[f->inicio].chave;
if(f->inicio != f->fim)
f->inicio = (f->inicio + 1) % MAX;
else
{
f->inicio = -1;
f->fim = -1;
}
return(ch);
}
Deques são filas que permitem tanto entrada quanto retirada em ambas extremidades. Neste caso não faz mais
sentido falar em início e fim de fila, mas simplesmente início1 e início2.
Implementações estáticas e dinâmicas são possíveis, mas a dinâmica é mais comum, tirando proveito do
encadeamento duplo para permitir acesso a ambas extremidades em tempo O(1).
typedef struct
{
NO* inicio1;
NO* inicio2;
} DEQUE;
// Inicialização do deque
void inicializarDeque(DEQUE *d)
{
d->inicio1 = NULL;
d->inicio2 = NULL;
}
23
2.5. Pilhas
Pilhas são listas lineares com disciplina de acesso FILO (first-in, last-out, ou, o primeiro a entrar é o último a
sair). Da mesma forma que as filas, sua principal aplicação é o armazenamento de dados em que é importante
preservar a ordem (neste caso, FILO) de entradas e saídas.
A pilha armazena apenas a posição de uma de suas extremidades (chamada topo), que é o único local onde
são realizadas todas as operações de entrada e saída. A operação de entrada de dados (sempre no topo da
pilha ) é chamada push e a retirada (também sempre do topo) é chamada pop.
A implementação pode ser estática (usando um vetor simples) ou dinâmica (com ponteiros) sem diferenças
significativas em termos de eficiência, uma vez que a estrutura só admite estas operações no topo da
estrutura.
typedef struct
{
NO* topo;
} Pdinam;
typedef struct
{
int topo;
RegistroEstat A[MAX];
} PESTAT;
25
// Retirar do topo ou retornar -1 se vazia
TIPOCHAVE pop(Pestat *p) {
if(p->topo < 0) return (-1);
TIPOCHAVE ch = p->A[p->topo].chave;
p->topo--;
return(ch);
}
Duas pilhas de implementação estática que não necessitam de toda sua capacidade simultaneamente podem
ser representadas economicamente em um único vetor compartilhado.
As pilhas são posicionadas nas extremidades do vetor e crescem em direção ao centro. Supondo-se um vetor
com posições indexadas de 0 até MAX-1, o topo da primeira pilha é inicializado com -1 e o topo da segunda
é inicializado com MAX, correspondendo as situações de pilha vazia de cada estrutura. As duas pilhas estão
simultaneamente cheias quando não há mais posições livres entre elas, ou seja, quando (topo2 - topo1 == 1).
typedef struct
{
int topo1;
int topo2;
TIPOCHAVE A[MAX];
} PILHADUPLA;
O caso geral de representação estática de 'NP' pilhas compartilhando um único vetor envolve o controle
individual do topo de cada pilha e também da sua base. Cada topo [k] aponta para o último elemento efetivo
de cada pilha (i.e., da mesma forma que o topo de uma pilha comum) mas, para que seja possível diferenciar
uma pilha vazia de uma pilha unitária, cada base [k] aponta para o elemento anterior ao primeiro elemento
real da respectiva pilha.
Uma pilha k está vazia se base[k] == topo[k]. Uma pilha [k] está cheia se topo[k] == base[k+1], significando
que a pilha [k] não pode mais crescer sem sobrepor-se a pilha [k+1].
27
A especificação da estrutura inclui as NP pilhas reais (numeradas de 0 até NP-1) e mais uma pilha 'extra'
(fictícia) de índice NP cuja função é descrita a seguir.
typedef struct
{
int base[NP+1]; // pilhas [0..NP-1] + pilha[NP] auxiliar
int topo[NP+1];
TIPOCHAVE A[MAX];
} PILHAMULTIPLA;
Na inicialização da estrutura, cada topo é igualado a sua respectiva base, o que define uma pilha vazia. Além
disso, para evitar acúmulo de pilhas em um mesmo ponto do vetor (o que ocasionaria grande movimentação
de dados nas primeiras entradas de dados), todas as NP+1 pilhas são inicializadas com suas bases distribuídas
a intervalos regulares ao longo da estrutura.
A pilha[0] fica na posição –1. A pilha[NP] - que é apenas um artifício de programação - fica
permanentemente na posição MAX-1, servindo apenas como marcador de fim do vetor (i.e., esta pilha jamais
será afetada pelas rotinas de deslocamento). A pilha extra será sempre vazia e será usada para simplificar o
teste de pilha cheia, que pode se basear sempre na comparação entre um topo e a base da pilha seguinte, ao
invés de se preocupar também com o fim do vetor.
A utilidade da pilha „fictícia‟ de índice [NP] fica claro na rotina a seguir, a qual não precisa se preocupar com
o caso especial em que k é a última pilha real (i.e., quando k == NP-1).
A inclusão de um novo elemento no topo de uma pilha k deve considerar a existência de espaço livre e, se for
o caso, movimentar as estruturas vizinhas para obtê-lo. No exemplo a seguir, o programa tenta deslocar todas
as pilhas à direita de k em uma posição para a direita. Se isso falhar, tenta deslocar todas as pilhas da
esquerda (inclusive k) uma posição para a esquerda. Se isso ainda não resultar em uma posição livre no topo
de k, então o vetor está totalmente ocupado e a inserção não pode ser realizada.
Observe que este é apenas um exemplo de estratégia possível. Um procedimento talvez mais eficiente poderia
tentar primeiro deslocar apenas a pilha k+1 para direita. Apenas quando isso falhasse poderia então tentar
deslocar apenas as pilha k-1 e a pilha k para a esquerda, e só em caso de última necessidade tentaria
deslocaria várias pilhas simultaneamente como feito acima.
29
2.6. Matrizes Esparsas
Uma matriz esparsa é uma matriz extensa na qual poucos elementos são não-nulos (ou de valor diferente de
zero). O problema de representação destas estruturas consiste em economizar memória armazenando apenas
os dados válidos (i.e., não nulos) sem perda das propriedades matriciais (i.e., a noção de posição em linha e
coluna de cada elemento). As implementações mais comuns são a representação em linhas ou por listas
cruzadas.
A forma mais simples (porém não necessariamente mais eficiente) de armazenar uma matriz esparsa é na
forma de uma tabela (na verdade, uma lista ligada) de nós contendo a linha e coluna de cada elemento e suas
demais informações. A lista é ordenada por linhas para facilitar o percurso neste sentido.
A matriz será acessível a partir do ponteiro de início da lista ligada que a representa.
typedef struct
{
NO* inicio;
} MATRIZ;
Por outro lado, a representação por linhas não apresenta bom tempo de resposta para certas operações
matriciais. Em especial, percorrer uma linha da matriz exige que todas as linhas acima dela sejam percorridas.
Pior do que isso, percorrer uma coluna da matriz exige que a matriz inteira (ou melhor, todos os seus
elementos não-nulos) seja percorrida. Considerando-se que matrizes esparsas tendem a ser estruturas de
grande porte, em muitas aplicações estes tempos de execução podem ser inaceitáveis.
Com um gasto adicional de espaço de armazenamento, podemos representar uma matriz esparsa com tempo
de acesso proporcional ao volume de dados de cada linha ou coluna. Nesta representação, usamos uma lista
ligada para cada linha e outra para cada coluna da matriz. As listas se cruzam, isto é, compartilham os
mesmos nós em cada intersecção, e por este motivo cada nó armazena um ponteiro para a próxima linha
(abaixo) e próxima coluna (à direita).
1 2 3 4 5 6 7 8
2 A B
3
4 C
5 D
6 E F G H
7
31
O acesso a cada linha ou coluna é indexado por meio de um vetor de ponteiros de linhas e outro de colunas.
Cada ponteiro destes vetores indica o início de uma das listas da matriz.
typedef struct
{
NO* lin[MAXLIN+1]; // para indexar até MAXLIN
NO* col[MAXCOL+1]; // para indexar até MAXCOL
} MATRIZ;
Para uma matriz de MAXLIN x MAXCOL elementos, dos quais n são não-nulos, a representação por listas
cruzadas será vantajosa (do ponto de vista da complexidade de espaço) sempre que:
// Inicialização
void inicializarMatriz(MATRIZ *m)
{
int i;
for(i=1; i <= MAXLIN; i++)
m->lin[i] = NULL;
for(i=1; i <= MAXCOL; i++)
m->col[i] = NULL;
}
}
33
2.6.3. Casos Especiais: Matrizes Esparsas de Distribuição Regular
Se a matriz possui não-nulos distribuídos de forma regular (e.g., em torno da diagonal principal) é mais
conveniente elaborar uma fórmula de endereçamento para estes dados e mantê-los em um vetor V de n
posições, onde n é a quantidade de não-nulos a armazenar.
(c) endereçamento:
Triangular superior ordenada por colunas, m[i, j ] == v[(i2 - i) / 2 + j]
Triangular inferior ordenada por linhas, m[i, j ] == v[(j2 - j) / 2 + i]
// Inicialização
void inicializarLista(NO* *l)
{
*l = NULL;
}
35
// Quantidade de nos na lista
int contarNos(NO* p)
{
int nos = 0;
while (p)
{
nos++;
if( p->tag == inicioLista)
nos = nos + ContarNos(p->sublista);
p = p->prox;
}
return(nos);
}
37
3. Listas Não Lineares
Quando existe mais de um caminho possíveis pela estrutura, esta é dita não linear. Exemplos clássicos de
estruturas deste tipo são as árvores e grafos (estes estudados em Algoritmos e Estruturas de Dados II).
3.1. Árvores
Uma árvore é um conjunto de nós composto de um nó especial (chamado raiz) e conjuntos disjuntos de nós
subordinados ao nó raiz que são eles próprios (sub)árvores.
Terminologia
Grau de um nó: a quantidade de subárvores do nó;
Grau de uma árvore: grau máximo dentre todos os nós da estrutura;
Folhas de uma árvore: nós de grau zero;
Filhos de x: raízes das subárvores de x; x é o nó pai de seus filhos;
Ancestrais de x: todos nós no caminho desde a raiz até x.
Nível de x: a raiz é nível 1; se um nó está no nível n, seus filhos estão no nível n+1;
Altura de um nó folha é sempre 1;
Altura de um nó não folha: a altura máxima dentre todas suas subárvores + 1;
Altura de uma árvore é a altura de sua raiz.
Uma árvore de grau m é dita m-ária. Árvores m-árias são de difícil representação e manipulação (por
exemplo, a definição de muitos ponteiros em cada nó representa um grande desperdício de espaço ocupado
por ponteiros NULL). Por este motivo, árvores m-árias são geralmente representadas por árvores binárias
(veja definição a seguir) sem perda de propriedades.
Em computação, árvores (e especialmente árvores binárias) são usadas para armazenar dados (chaves e outros
campos de informação) em seus nós da mesma forma que listas sequenciais e listas ligadas.
Uma vez que árvores m-árias não definem uma ordem específica entre as subárvores de cada nó, a conversão
de árvore m-ária para binária é trivial: basta eleger um filho qualquer como raiz da subárvore esquerda, e os
demais como subárvore direita e seus descendentes. O procedimento de conversão compreende dois passos:
A árvore assim resultante pode ser redesenhada na posição correta (preservando-se as ligações esquerda e
direita estabelecidas) formando uma árvore binária.
Passo (b): ligações pai-filho são removidas, exceto a primeira de cada grupo
39
Árvore binária resultante (as ligações pai-filho originais são mantidas)
Propriedades
i-1
- O número máximo de nós possíveis no nível i é 2
– A altura mínima de uma árvore binária com n > 0 nós é 1 + chão( log 2 n )
Exemplos de árvores completas. Seus nós não podem ser redistribuídos formando
uma árvore de altura menor do que estas.
41
- Uma árvore binária de altura máxima para uma quantidade de n nós é dita assimétrica. Neste caso, a altura
é h = n e seus nós interiores possuem exatamente uma subárvore vazia cada.
Exemplo de árvore cheia – possuindo o número máximo de nós para a sua altura.
Na representação estática, o vetor deve ser grande o suficiente para conter o número máximo possível de nós
para a altura máxima hmax estabelecida, ou seja, deve ter no mínimo 2hmax – 1 posições. Isso é necessário
porque mesmo os nós não existentes terão seu espaço reservado no vetor, já que a posição relativa de
qualquer pai ou filho no vetor é fixa, definida em função das posições de seus antecessores.
Os nós da árvore são dispostos ao longo do vetor em níveis, da esquerda para a direita, começando pela raiz
(que ocupa a primeira posição). Como os nós inexistentes deixam posições vazias no vetor, algum tipo de
controle deve ser feito para diferenciar posições livres e ocupadas (e.g., com uso de um campo booleano).
Uma vez que a raiz ocupa a primeira posição do vetor, os outros nós têm suas posições definidas como segue:
Implementação Dinâmica
typedef struct estrutura
{
TIPOCHAVE chave;
estrutura *esq;
estrutura *dir;
} NO;
43
// Verificar se árvore é vazia
bool arvoreVazia(NO* raiz)
{
if(!raiz) return(true);
else return(false);
}
Algoritmos não-recursivos
Os percursos básicos em árvore binária (em especial, o de pré-ordem e o em ordem) podem ser obtidos com
uma implementação não recursiva usando uma estrutura auxiliar do tipo pilha.
45
Algoritmos recursivos
Os percursos básicos em árvore binária são facilmente obtidos com uma implementação recursiva.
void preOrdem(NO* p)
{
if(p)
{
visita(p);
preOrdem(p->esq);
preOrdem(p->dir);
}
}
void emOrdem(NO* p)
{
if(p)
{
emOrdem(p->esq);
visita(p);
emOrdem(p->dir);
}
}
void posOrdem(NO* p)
{
if(p)
{
posOrdem(p->esq);
posOrdem(p->dir);
visita(p);
}
}
Em altura: DGHFBECA
Em nível: ABCDEFGH
Os algoritmos para estes percursos não são recursivos. O percurso em nível, por exemplo, é mais facilmente
obtido com uma estrutura auxiliar do tipo fila, usada para armazenar os filhos de cada nó visitado e então
visitá-los na ordem em que são retirados da mesma.
47
Algoritmos em Árvore Binária Comum
// Retorna o nível de uma chave (que deve ser encontrada)
void travessia(NO* p, int *niv, TIPOCHAVE ch, bool *achou)
{
if(p)
{
*niv = *niv + 1;
if(p->chave == ch)
*achou = true;
else
{
travessia(p->esq, niv, ch, achou);
if((!*achou))
travessia(p->dir, niv, ch, achou);
if((!achou))
*niv = *niv - 1;
}
}
}
49
Árvores de Busca Binária (ABB)
Da mesma forma que no caso das listas lineares de implementação estática e dinâmica, talvez a aplicação
mais importante de árvores binárias seja seu uso como tabelas para armazenamento de dados de forma
eficiente. Para este propósito, os dados contidos na estrutura são ordenados de forma a viabilizar a busca
binária de suas chaves.
Uma árvore de busca binária é uma árvore binária em que todos nós apresentam a seguinte propriedade: dado
um nó p da estrutura, todos os descendentes esquerdos de p têm um valor de chave menor do que a chave de
p, e todos os descendentes direitos de p têm um valor de chave maior do que a chave de p. A estrutura
obviamente não pode armazenar chaves repetidas, e seu formato depende inteiramente da ordem em que as
chaves são inseridas. Por este motivo, quanto menor a altura de uma árvore de busca melhor o seu
desempenho, pois o caminho para encontrar uma chave qualquer será o mais curto possível.
A possibilidade de operar busca binária (em tempo logarítmico, como fazemos em um vetor) aliada às
vantagens da implementação dinâmica (i.e., inserção e exclusão em tempo constante) são as principais razões
do grande êxito das árvores de busca binária como estruturas de armazenamento de tabelas de chaves, e,
consequentemente, do seu uso generalizado na computação.
// Inicializacao - sentinela
void inicializar(NO* *raiz, NO* sentinela)
{
*raiz = sentinela;
}
A eficiência da busca em uma ABB com ou sem sentinela é determinada pela altura da estrutura. Uma árvore
completa (de altura mínima da ordem lg n) permite uma busca de pior caso O(lg n), ou seja, tão eficiente
quanto à busca binária em vetor. Por outro lado, uma árvore assimétrica (de altura máxima da ordem n) exige
uma busca de pior caso O(n), ou seja, o mesmo que em uma lista ligada.
Árvores completas são entretanto difíceis de manter, ou seja, garantir que uma ABB continue sendo completa
depois de cada inserção ou exclusão pode exigir a movimentação de todos os nós da estrutura em tempo O(n),
ou seja, o mesmo problema que era verificado em listas sequenciais (vetores). No exemplo a seguir a inserção
da chave 0 demonstra porque restaurar a condição de árvore completa é inviável na prática.
51
Inserção da chave 0 (à esquerda) e reorganização de todos os n nós
para manter árvore completa (à direita).
Existem muitos tipos de árvores balanceadas. Dentre as mais difundidas, e que serviram de base para vários
outros modelos, estão as árvores balanceadas em altura, ou árvores AVL (dos criadores Adelson-Velskeii &
Landis) propostas em 1962. Uma árvore é dita AVL se todos os seus nós são AVL. Um nó é dito AVL se as
alturas de sua subárvore direita (hD) e esquerda (hE) não diferem em mais de uma unidade em módulo, ou
seja:
| hD – hE | < = 1
A diferença entre a altura da subárvore direita e esquerda é chamada fator de balanceamento do nó AVL.
Como o custo de cálculo deste fator é computacionalmente elevado, em geral o nó possui um campo para
armazenar este tipo de informação, a qual deve ser atualizada pelos algoritmos de atualização da estrutura.
Se houver algum nó p desregulado no caminho entre x e a raiz, este deve sofrer uma operação de rotação para
que seu equilíbrio seja restaurado (e para que a árvore volte a ser AVL). A operação de rotação gira em torno
de p, e somente esta subárvore é afetada.
53
Nas rotações simples (LL e RR), existe um nó u que é filho de p (na direção de x). Este u passa a ser a nova
raiz da subárvore afetada. Nas rotações duplas (LR e RL), o nó u possui ainda um filho esquerdo ou direito
denominado v, também localizado em direção ao nó x, que passa a ser a nova raiz da subárvore em questão
(ou seja, substituindo p). A figura a seguir ilustra a modificação da estrutura após cada uma das rotações.
55