Projeto de Algoritmos

45
Projeto de Algoritmos Home | Prefácio | Livros | Sítios WWW | Índice As árvores da computação têm a tendência de crescer para baixo: a raiz fica no ar enquanto as folhas se enterram no chão. — folclore Árvores binárias (Veja o verbete Binary tree na Wikipedia.) Uma árvore binária é uma estrutura de dados mais geral que uma lista encadeada . Este capítulo introduz as operações mais simples sobre árvores binárias. O capítulo seguinte trata de uma aplicação básica. Nós e filhos Uma árvore binária (= binary tree) é um conjunto de registros que satisfaz certas condições. (As condições não serão dadas explicitamente, mas elas ficarão implicitamente claras no contexto.) Os registros serão chamados nós (poderiam também ser chamados células). Cada nó tem um endereço . Suporemos por enquanto que cada nó tem três campos: um número inteiro e dois ponteiros para nós. Os nós podem, então, ser definidos assim: struct cel { int conteudo; /* conteúdo */ struct cel *esq; struct cel *dir; }; typedef struct cel no; /* */ conteudo 999 esq dir

Transcript of Projeto de Algoritmos

Page 1: Projeto de Algoritmos

Projeto de AlgoritmosHome      |      Prefácio      |      Livros      |      Sítios WWW      |      Índice

As árvores da computação têm a tendência de crescer para baixo:a raiz fica no ar enquanto as folhas se enterram no chão.

— folclore

Árvores binárias(Veja o verbete Binary tree na Wikipedia.)

Uma árvore binária é uma estrutura de dados mais geral que uma lista encadeada.  Este capítulo introduz as operações mais simples sobre árvores binárias. O capítulo seguinte trata de uma aplicação básica.

Nós e filhos

Uma árvore binária (= binary tree) é um conjunto de registros que satisfaz certas condições.  (As condições não serão dadas explicitamente, mas elas ficarão implicitamente claras no contexto.)  Os registros serão chamados nós (poderiam também ser chamados células).  Cada nó tem um endereço.  Suporemos por enquanto que cada nó tem três campos:  um número inteiro e dois ponteiros para nós.  Os nós podem, então, ser definidos assim:

struct cel {

int conteudo; /* conteúdo */

struct cel *esq;

struct cel *dir;

};

typedef struct cel no; /* nó */

 

conteudo 999

    esq dir

O campo conteudo é a "carga útil" do nó;  os outros dois campos servem apenas para dar estrutura à árvore.  O campo esq de todo nó contém o endereço de outro nó ou NULL.  A mesma hipótese vale para o campo dir.  Se o campo esq de um nó X é o endereço de um nó Y, diremos que Y é o filho esquerdo de X.  Analogamente, se X.dir é igual a &Y então Y é o filho direito de X.

Page 2: Projeto de Algoritmos

Se um nó Y é filho (esquerdo ou direito) de X, diremos que X é o pai de Y.  Uma folha (= leaf) é um nó que não tem filho algum.

É muito conveniente confundir cada nó com seu endereço.  Assim, se x é um ponteiro para um nó (ou seja, se x é do tipo *no), dizemos simplesmente  "considere o nó x"  em lugar de  "considere o nó cujo endereço é x".

Árvores e subárvores

Suponha que x é (o endereço de) um nó.  Um descendente de x é qualquer nó que possa ser alcançada pela iteração dos comandos   x = x->esq  e  x = x->dir   em qualquer ordem.  (É claro que esses comandos só podem ser iterados enquanto x for diferente de NULL. Estamos supondo que NULL é de fato atingido mais cedo ou mais tarde.)

Um nó x juntamente com todos os seus descendentes é uma árvore binária.  Dizemos que x é a raiz (= root) da árvore.  Se x tiver um pai, essa árvore é uma subárvore de alguma árvore maior.  Se x é NULL, a árvore é vazia.

Para qualquer nó x,  x->esq   é a raiz da subárvore esquerda de  x  e  x->dir  é a raiz da subárvore direita de x.

Endereço de uma árvore e definição recursiva

O endereço de uma árvore binária é o endereço de sua raiz.  É conveninente confundir árvores com seus endereços:  dizemos  "considere a árvore r"  em lugar de  "considere a árvore cuja raiz tem endereço r". 

Essa convenção sugere a introdução do nome alternativo arvore para o tipo-de-dados ponteiro-para-nó:

typedef no *arvore; /* árvore */

A convenção permite formular a definição deárvore binária de maneira recursiva:  um objeto r é uma árvore binária se

1. r é NULL    ou 2. r->esq e r->dir são árvores binárias.

Muitos algoritmos sobre árvores ficam mais simples quando escritos de forma recursiva.

Exercício1. Árvores binárias têm uma relação muito íntima com certas seqüências bem-formadas de

parênteses. Discuta essa relação.

Page 3: Projeto de Algoritmos

2. Árvores binárias podem ser usadas, de maneira muito natural, para representar expressões aritméticas (como ((a+b)*c-d)/(e-f)+g, por exemplo).  Discuta os detalhes.

Varredura esquerda-raiz-direita

Ao contrário de uma lista encadeada, uma árvore binária pode ser percorrida de muitas maneiras diferentes. Uma maneira particularmente importante é a ordem esquerda-raiz-direita. Na varredura e-r-d (= inorder traversal), visitamos

1. a subárvore esquerda da raiz, em ordem e-r-d; 2. depois a raiz; 3. depois a subárvore direita da raiz, em ordem e-r-d.

Na figura abaixo, os nós estão numeradas na ordem da varredura e-r-d.

5 / \ 3 8 / \ / \ 1 4 6 9 / \ \ 0 2 7

Eis uma função recursiva que faz a varredura e-r-d de uma árvore binária r:

// Recebe a raiz r de uma árvore binária.

// Imprime os conteúdos dos nós em ordem e-r-d.

void erd (arvore r) {

if (r != NULL) {

erd (r->esq);

printf ("%d\n", r->conteudo);

erd (r->dir);

}

Page 4: Projeto de Algoritmos

}

É um excelente exercício escrever uma versão iterativa desta função. Nossa versão usa uma pilha p[0..t-1] de endereços e mais um endereço x que é candidato a entrar na pilha; é como se a pilha fosse

p[0], p[1], . . . , p[t-1], x .

A seqüência x, p[t-1], . . . ,  p[0] é uma espécie de "roteiro" daquilo que ainda precisa ser feito:  x representa a instrução "imprima a árvore x"  e  cada p[i] representa a instrução "imprima o nó p[i] e em seguida a árvore p[i]->dir".  Para dimensionar a pilha, suporemos que nossa árvore não tem mais que 100 nós. 

// Recebe a raiz r de uma árvore binária.

// Imprime os conteúdos dos nós em ordem e-r-d.

// Supõe que a árvore não tem mais que 100 nós.

void erd_i (arvore r) {

no *x, *p[100];

int t = 0;

x = r;

while (x != NULL || t > 0) {

// a pilha é p[0..t-1]; o índice do topo é t-1

if (x != NULL) {

p[t++] = x;

x = x->esq;

}

else {

x = p[--t];

printf ("%d\n", x->conteudo);

x = x->dir;

}

Page 5: Projeto de Algoritmos

}

}

Para a árvore sugerida na figura acima, a pilha p evolui como indicado na tabela abaixo. Cada linha da tabela resume o estado de coisas no início de uma iteração: à esquerda estão os nós que já foram impressos; à direita está a pilha x, p[t-1], . . . ,  p[0]. O valor NULL está indicado por N.

5

3 5

1 3 5

0 1 3 5

N 0 1 3 5

0 N 1 3 5

0 1 2 3 5

0 1 N 2 3 5

0 1 2 N 3 5

0 1 2 3 4 5

0 1 2 3 N 4 5

0 1 2 3 4 N 5

0 1 2 3 4 5 8

0 1 2 3 4 5 6 8

0 1 2 3 4 5 N 6 8

0 1 2 3 4 5 6 7 8

0 1 2 3 4 5 6 N 7 8

0 1 2 3 4 5 6 7 N 8

0 1 2 3 4 5 6 7 8 9

0 1 2 3 4 5 6 7 8 N 9

0 1 2 3 4 5 6 7 8 9 N

Exercícios

Page 6: Projeto de Algoritmos

3. Verifique que o código abaixo é equivalente ao da função erd: 4. while (1) {5. while (x != NULL) {6. p[t++] = x;7. x = x->esq;8. }9. if (t == 0) break;10. x = p[--t];11. printf ("%d\n", x->conteudo);12. x = x->dir;13. }

14. Escreva uma função que calcule o número de nós de uma árvore binária. 15. Escreva uma função que imprima, em ordem e-r-d, os conteúdos das folhas de uma árvore

binária. 16. Dada uma árvore binária, encontrar um nó da árvore cujo conteúdo tenha um certo valor k. 17. Escreva uma função que faça varredura r-e-d (= preorder traversal) de uma árvore binária.

[A varredura r-e-d também é conhecida como "busca em profundidade" ou depth-first search.] 18. Escreva uma função que faça varredura e-d-r (= postorder traversal) de uma árvore binária. 19. Discuta a relação entre a varredura e-r-d e a notação infixa de expressões aritméticas.  Discuta

a relação entre a varredura e-d-r e a notação posfixa. (Veja exercício acima. Veja também a seção sobre notação polonesa.)

Primeiro e último nós

Considere o seguinte problema: encontrar o endereço do primeiro nó na ordem e-r-d. É claro que o problema só faz sentido se a árvore não é vazia, ou seja, se r é diferente de NULL. Eis uma função que resolve o problema:

// Recebe uma árvore binária não-vazia r.

// Devolve o primeiro nó da árvore na ordem e-r-d.

no *primeiro (arvore r) {

while (r->esq != NULL)

r = r->esq;

return r;

}

Não é difícil fazer uma função análoga que encontre o último nó na ordem e-r-d.

Exercícios9. Escreva uma versão recursiva da função primeiro.

Page 7: Projeto de Algoritmos

10. Escreva uma função que encontre o último nó na ordem e-r-d.

Altura

A altura de um nó x em uma árvore binária é a distância entre x e o seu descendente mais afastado. Mas precisamente, a altura de x é o número de passos do mais longo caminho que leva de x até uma folha. Os caminhos a que essa definição se refere são os obtido pela iteração dos comandos x = x->esq e x = x->dir, em qualquer ordem.

A altura de uma árvore é a altura da raiz da árvore.  Uma árvore com um único nó tem altura 0. A árvore da figura tem altura 3.

E / \ D I / / \ B G K / \ / \ / A C F H J

Eis como a altura de uma árvore com raiz r pode ser calculada:

// Devolve a altura da árvore binária cuja raiz é r.

int altura (arvore r) {

if (r == NULL)

return -1; // altura de árvore vazia é -1

else {

int he = altura (r->esq);

int hd = altura (r->dir);

if (he < hd) return hd + 1;

else return he + 1;

Page 8: Projeto de Algoritmos

}

}

Qual a relação entre a altura, digamos h, e o número de nós, digamos n, de uma árvore binária?  Resposta:

n-1   ≥   h   ≥   lg(n) ,

onde  lg(n)  denota o  piso  de  log2 n .

n   lg(n)4 25 26 210 364 6100 6128 71000 91024 10

1000000 19

Uma árvore binária com h = n-1 é um "tronco sem galhos": cada nó tem no máximo um filho.  No outro extremo, uma árvore com h = lg(n) é "quase completa": todos os "níveis" estão lotados exceto talvez o último.

H / \ D K / \ / \ B F J L / \ / \ / A C E G I

 

Uma árvore binária é balanceada (ou equilibrada) se, em cada um de seus nós, as subárvores esquerda e direita tiverem aproximadamente a mesma altura. Uma árvore binária balanceada com n nós tem altura próxima de lg(n).

Convém trabalhar com árvores balanceadas sempre que possível. Mas isso não é fácil se a árvore aumenta e diminui ao longo da execução do seu programa.

Page 9: Projeto de Algoritmos

Exercícios11. Desenhe uma árvore binária que tenha conteúdos 1, . . . , 17 e a menor altura possível. Repita

com a maior altura possível. 12. Escreva uma função iterativa para calcular a altura de uma árvore binária. 13. Uma árvore é balanceada no sentido AVL se, para cada nó x, as alturas das subárvores que

têm raízes x->esq e x->dir diferem de no máximo uma unidade. Escreva uma função que decida se uma dada árvore é balanceada no sentido AVL. Procure escrever sua função de modo que ela visite cada nó no máximo uma vez.

Nós com campo pai

Em algumas aplicações (veja seção seguinte) é conveniente ter acesso imediato ao pai de qualquer nó. Para isso, é preciso acrescentar um campo pai a cada nó:

struct cel {

int conteudo;

struct cel *pai;

struct cel *esq;

struct cel *dir;

};

typedef struct cel no;

 

esq pai esq

      999

    esq dir

É um bom exercício escrever uma função que preencha o campo pai de todos os nós de uma árvore binária.

Exercícios14. Escreva uma função que preencha corretamente todos os campos pai de uma árvore binária. 15. A profundidade (= depth) de um nó x em uma árvore binária com raiz r é a distância entre x e

r. Mais precisamente, a profundidade de x é o comprimento do (único) caminho que vai de r até x. Por exemplo, a profundidade de r é 0 e a profundidade de r->esq é 1.  Escreva uma função que determine a profundidade de um nó em relação à raiz da árvore.

16. Escreva uma função que imprima os conteúdos de uma árvore binária com recuos de margem proporcionais à profundidade do nó na árvore. Por exemplo, a árvore

Page 10: Projeto de Algoritmos

555 / \ 333 888 / \ \ 111 444 999

deve se representada assim:

555

333

111

-

-

444

-

-

888

-

999

-

-

onde os caracteres '-' representam NULL.

17. Em que condições uma árvore binária é um max-heap? Escreva uma função que transforme uma árvore binária quase completa em heap.

Nó seguinte e anterior (sucessor e predecessor)

Digamos que x é o endereço de um certo nó de uma árvore binária. Nosso problema:  calcular o endereço do nó seguinte na ordem e-r-d.

Para resolver o problema, é necessário que os níos tenham um campo pai .  Eis uma função que resolve o problema. É claro que a função só deve ser chamada com x diferente de NULL. A função devolve o endereço do nó seguinte a x ou devolve NULL se x é o último nó. (Note que a função não precisa saber onde está a raiz da árvore.)

Page 11: Projeto de Algoritmos

// Recebe o endereço de um nó x. Devolve o endereço

// do nó seguinte na ordem e-r-d.

// A função supõe que x != NULL.

no *seguinte (no *x) {

if (x->dir != NULL) {

no *y = x->dir;

while (y->esq != NULL) y = y->esq;

return y; // *

}

while (x->pai != NULL && x->pai->dir == x) // **

x = x->pai; // **

return x->pai;

}

Comentários: Na linha *, y é o endereço do primeiro nó, na ordem e-r-d, da subárvore que tem raiz x->dir. As linhas ** fazem com que x suba na árvore enquanto for filho direito de alguém.

Exercícios18. Escreva uma função que receba o endereço de um nó x de uma árvore binária e encontre o

endereço do nó anterior a x na ordem e-r-d. 19. Escreva uma função que faça varredura e-r-d usando as funções primeiro e seguinte.

 

 

Page 12: Projeto de Algoritmos

URL of this site: www.ime.usp.br/~pf/algoritmos/1998 | Last modified: Fri Oct 2 07:28:10 BRT 2009 Paulo FeofiloffIME-USP

Data da última atualização: 13/03/2000 copyright Adriano Cruz 1999

Árvores

1. Introdução 2. Definições Básicas 3. Àrvores Binárias 4. Armazenamento de Árvores Binárias 5. Uma Aplicação de Árvores Binárias 6. Percorrendo Árvores Binárias 7. O Algoritmo de Huffman 8. Removendo Nós de Árvores Binárias 9. Árvores Binárias Balanceadas

Page 13: Projeto de Algoritmos

10. Exercícios

Introdução

 

 

Definições Básicas

Árvores são estruturas de dados extremamente úteis em muitas aplicações. Uma árvore é formada por um conjunto finito T de elementos denominados vértices ou nós de tal modo que se T = 0 a árvore é vazia, caso contrário temos um nó especial chamado raiz da árvore (r), e cujos elementos restantes são particionados em m>=1 conjuntos distintos não vazios, as subárvores de r, sendo cada um destes conjuntos por sua vez uma árvore.

A forma convencional de representar uma árvore está indicado na figura aini abaixo. Esta árvore tem nove nós sendo A o nó raiz.

Figura (aini): Uma árvore

Page 14: Projeto de Algoritmos

Os conjuntos das subárvores tem de ser disjuntos tem de ser disjuntos portanto a estrutura indicada na Figura arvn não é uma árvore.

Figura arvn: Estrutura que não representa uma árvore

Se n é um nó da árvore T então Tn indica uma subárvore de T com raiz no nó n. Os nós n1, n2, ..., nk das subárvores de Tn são chamados de filhos de n e n é o pai destes nós, que são nós irmãos. Os nós B e C são filhos de A e nós irmãos. Nós sem filhos como os nós D, H, I, F e G são chamados de folhas. A subárvore da esquerda do nó A tem raiz em B e a subárvore da direita tem raiz em C, isto está indicado pelos dois ramos saindo de A. A ausência de um ramo na árvore indica uma subárvore vazia, como a subárvore da direita do nó B.  O número de de filhos de um nó é chamado de grau de saída deste nó. Por exemplo, o nó C tem grau de saída 3 e o nó E grau 2. Se o nó n é a raiz de uma subárvore Tn e n1 pertence a Tn então n1 é descendente de n e n ancestral de n1. Portanto nós sem descendentes próprios é uma folha. Por exemplo, o nó H é ancestral do nó C e o nó D é descendente do nó A.

Um caminho da árvore é composto por uma seqüência de nós consecutivos (n1, n2, ..., nk-1, nk) tal que existe sempre a relação ni é pai de ni+1. Os k vértices formam k-1 pares e um caminho de comprimento igual a k-1.  O comprimento do caminho entre o nó A e o nó H é 3.

O nível de um nó n pode ser definido do seguinte modo: o nó raiz tem nível 0, os outros nós tem um nível que é maior uma unidade que o nível de seu pai. Na árvore da figura anterior temos nós nos seguintes níveis:

Page 15: Projeto de Algoritmos

nível 0 = A nível 1 = B, C nível 2 = D, E, F, G nível 3 = H, I

A altura de um nó n é o número de nós do maior caminho de n até um de seus descendentes. As  folhas tem altura 1.  

Existem diversas maneiras de representar árvores. Uma representação que reflete a idéia de árvores como conjuntos aninhados é mostrado na figura arvconj abaixo. A figura mostra o mesmo conjunto da figura aini.

Figura (arconj): Árvore representada como conjuntos aninhados.

Uma outra notação que encontramos a toda hora, e que está representada na figura arviden, é a forma identada ou de diagrama de barras. Notar que esta representação lembra um sumário de livro. Os sumários  dos livros são representações da árvore do conteúdo do livro.  

Page 16: Projeto de Algoritmos

Figura (arviden) Árvore e sua representação por barras

Uma outra forma interessante de representar uma árvore é a representação por parênteses aninhados. Da mesma forma que a figura aini representa uma árvore no plano a representação por parênteses representa uma árvore em uma linha. A seqüência de parênteses representa a relação entre os nós da estrutura. O rótulo do nó é inserido à esquerda do abre parênteses correspondente. A árvore representada planarmente pela figura aini pode ser representada em uma linha por

(A (B(D))(C(E(H)(I))(F)(G)))

Esta representação tem importância, por exemplo, no tratamento de expressões aritméticas, já que toda expressão aritmética pode ser colocada nesta forma. Se colocarmos uma expressão nesta forma podemos então representá-la como uma árvore, mostrando como ela seria calculada. Para colocarmos uma expressão em forma de árvore devemos considerar cada operador como um nó da árvore e os seus operandos como as duas subárvores. Considere a expressão C seguinte

A + (B-C)*D%(E*F)

que após receber todos os parênteses fica da seguinte maneira

(A + ((B-C)*(D%(E*F))))

A figura arvexp mostra como fica esta expressão representada por uma árvore.  

Page 17: Projeto de Algoritmos

Figura (arvexp) Uma expressão e sua representação como árvore.

Árvores Binárias

A figura arvbin abaixo mostra um importante tipo de árvore que é a árvore binária. Em uma árvore binária cada nó tem no máximo duas subárvores, e quando há somente uma presente é necessário distinguir entre subárvore esquerda e direita.  Árvores binárias podem ser vistas em diversas situações do cotidiano. Por exemplo, um torneio de futebol eliminatório, do tipo das copas dos países, como a Copa do Brasil, em que a cada etapa os times são agrupados dois a dois e sempre são eliminados metade dos times é uma árvore binária.

Page 18: Projeto de Algoritmos

Figura abin: Árvore binária

Formalmente uma árvore binária pode ser definida como um conjunto finito de nós, que é vazio, ou consiste de um nó raiz e dois conjuntos disjuntos de nós, a subárvore esquerda e a subárvore direita. É importante observar que uma árvore binária não é um caso especial de árvore e sim um conceito completamente diferente. Por exemplo, considere a figura arvbind, note que são duas árvores idênticas, mas são duas árvores binárias diferentes. Isto porque uma delas tem a subárvore da direita vazia e a outra a subárvore da esquerda.

Figura arcbind: Árvores binárias diferentes.

Page 19: Projeto de Algoritmos

Uma árvore estritamente binária é uma árvore binária em que cada nó tem 0 ou 2 filhos. Uma árvore binária cheia é uma árvore em que se um nó tem alguma sub-árvore vazia então ele está no último nível. Uma árvore completa é aquela em se n é um nó com algumas de subárvores vazias, então n se localiza no penúltimo ou no último nível. Portanto, toda árvore cheia é completa e estritamente binária. A Figura arvbcc mostra uma árvore estritamente binária, completa e cheia.

 

Armazenamento de Árvores Binárias

Para armazenar cada nó de uma árvore binária precisamos de uma estrutura que contenha dois ponteiros: um aponta para a subárvore esquerda e outro para a subárvore direita. Naturalmente, devemos ter o(s) campo(s) para armazenar as informações que o nó deve conter. Nos algoritmos que iremos mostrar consideraremos que existe a seguinte definição para a estrutura do nó:

typedef struct sttNo {     tipo inf;     struct sttNo *esq, *dir; } tNo;

A Figura armarv mostra um diagrama de como seria o armazenamento de uma árvore binária. Observar que se desconsiderarmos os campos de informação para armazenar uma árvore com n nós precisamos de 2n+1 unidades de memória.

Page 20: Projeto de Algoritmos

No processo de criar uma árvore precisaremos de três operações importantes: cria_arvore, pos_esq e pos_dir. cria_arvore cria uma árvore binária nova consistindo de um único nó, armazena a informação. e retorna um ponteiro para este nó. Um algoritmo para esta função pode ser o seguinte:

    p = cria_no();     p->info = x;     p->esq = NULL;     p->dir = NULL;     return p;

pos_esq aceita um ponteiro p para uma árvore binária sem filho esquerdo e cria um novo filho esquerdo contendo a informação x. Um possível algoritmo para esta função pode ser:

if (p->left)     puts("Operação ilegal"); else {     q = cria_arvore();     p->left = q; }

Page 21: Projeto de Algoritmos

O algoritmo pos_dir é semelhante a este com a diferença que ele cria um nó a direita.

Uma Aplicação de Árvores Binárias

As árvore binárias são estruturas importantes toda vez que uma decisão binária deve ser tomada em algum ponto de um algoritmo. Vamos agora, antes de passar a algoritmos mais complexos, mostrar uma aplicação simples de árvores binárias. Suponhamos que precisamos descobrir números duplicados em uma lista não ordenada de números. Uma maneira é comparar  cada novo número com todos os números já lidos. Isto aumenta em muito a complexidade do algoritmo. Outra possibilidade é manter uma lista ordenada dos números e a cada número lido fazer uma busca na lista. Outra solução é usar uma árvore binária para manter os números. O primeiro número lido é colocado na raiz da árvore. Cada novo número lido é comparado com o elemento raiz, caso seja igual é uma duplicata e voltamos a ler outro número. Se é menor repetimos o processo com a árvore da direita e se maior com a árvore da esquerda. Este processo continua até que uma duplicata é encontrada ou uma árvore vazia é achada. Neste caso, o número é inserido na posição devida na árvore.  Considere que os números

7 8 2 5 8 3 5 10 4

foram fornecidos pelo usuário, neste caso a árvore binária mostrada na Figura arbus seria construida.

Page 22: Projeto de Algoritmos

O programa arv0300.c mostra este algoritmo. O programa insere os nós na árvore e imprime uma mensagem caso seja fornecido um número que já foi lido antes.

/* programa arv0300.c */ #include<stdio.h> #include<stdlib.h> #include<string.h>

typedef struct stNo {   int info;   struct stNo *esq, *dir; } tNo ;

tNo *cria_arvore( int ); tNo *cria_no( ); void pos_esq (tNo *, int ); void pos_dir (tNo *, int );

void main() {   tNo *raiz, *p, *q;   char linha[80], *numero;   int num;

  gets(linha);   numero = strtok(linha, " "); /* pega o primeiro numero da lista */   num = atoi(numero);   raiz = cria_arvore(num); /* insere na raiz */   numero = strtok(NULL, " ");   while (numero) {     q = raiz; p = raiz;     printf("Li numero %d\n", num); /* le novo numero */     num = atoi(numero);     while (num != p->info && q) { /* procura na arvore */       p = q;       if (num < p->info)  q = p->esq;               /* passa para arvore esquerda */       else  q = p->dir;               /* passa para direita */     }     if (num == p->info)       printf("O numero %d ja existe na arvore.\n", num);     else {  /* vou inserir o numero na arvore */       if (num < p->info)  pos_esq(p, num);       else  pos_dir(p, num);

Page 23: Projeto de Algoritmos

    }     numero = strtok(NULL, " ");   } /* fim do while (numero) */ }

tNo *cria_arvore (int x) {   tNo *p;

  p = cria_no ();   if (p) {     p->info = x;     return p;   }   else {     puts("Faltou espaco para alocar no.");     exit(1);   } }

tNo *cria_no() {   tNo *p;

  if ((p = (tNo *) malloc(sizeof(tNo))) == NULL)     return NULL;   else {     p->esq = NULL; p->dir = NULL;     return p;   }  }  

void pos_esq(tNo *p, int x) {   tNo *q;

  if (p->esq)     puts("Operacao de insercao a esquerda ilegal.");   else {     q = cria_arvore(x);     p->esq = q;   } }  

void pos_dir(tNo *p, int x) {   tNo *q;

Page 24: Projeto de Algoritmos

  if (p->dir)     puts("Operacao de insercao a direita ilegal.");   else {     q = cria_arvore(x);     p->dir = q;   } }  

Percorrendo Árvores Binárias

Uma operação muito comum é percorrer uma árvore binária, o que significa passar por todos os nós, pelo menos uma vez. O conceito de visitar significa executar uma operação com a informação armazenada no nó, por exemplo, imprimir seu conteúdo. Na operação de percorrer a árvore pode-se passar por alguns nós mais de uma vez, sem porém visitá-los.

Uma árvore é uma estrutura não seqüêncial, diferentemente de uma lista, por exemplo. Não existe ordem natural para percorrer árvores e portanto podemos escolher diferentes maneiras de percorrê-las. Nós iremos estudar três métodos para percorrer árvores. Todos estes três métodos podem ser definidos recursivamente e se baseiam em três operações básicas: visitar a raiz, percorrer a subárvore da esquerda e percorrer a subárvore da direita. A única diferença entre estes métodos é a ordem em que estas operações são executadas.

O primeiro método, conhecido como percurso em pré-ordem, implica em executar recursivamente os três passos na seguinte ordem.  

1. Visitar a raiz; 2. Percorrer a subárvore da esquerda em pré-ordem; 3. Percorre a subárvore da direita em pré-ordem.

Para a árvore da Figura arvbinp este percurso forneceria, no caso da visita significar imprimir, os seguintes resultados: F B A D C E H G I. Uma aplicação interessante deste tipo de percurso é aplicá-lo à uma árvore que contenha uma expressão aritmética, a qual foi expandida e recebeu todos os parênteses. Por exemplo, aplicando-se o percurso em pré-ordem à árvore arvexp obtém-se como resultado a expressão em notação polonesa normal, isto é os operandos antes dos operadores. Deste modo o resultado é +A*-BC%D*EF. Observar que esta notação é diferente da notação polonesa reversa, em que os operadores aparecem depois dos operandos.

Um algoritmo recursivo para implementar este modo de percurso pode ser o seguinte:

void pre_ordem ( tipoNo *pt) {     if (pt) {         visita (pt);

Page 25: Projeto de Algoritmos

        pre_ordem (pt->esq);         pre_ordem (pt->dir);     } }

Para percorrer a árvore em ordem simétrica executa-se recursivamente os três passos na seguinte ordem:

1. Percorrer a subárvore da esquerda em ordem simétrica; 2. Visitar a raiz; 3. Percorrer a subárvore da direita em ordem simétrica.

Um algoritmo recursivo para implementar este modo de percurso pode ser o seguinte:

void em_ordem ( tipoNo *pt) {     if (pt) {         em_ordem (pt->esq);         visita (pt);         em_ordem (pt->dir);     } }

Para a árvore da Figura arvbin o percurso forneceria o seguinte resultado A B C D E F G H I.

Este tipo de percurso é muito empregado em árvores binárias de busca. Considere a árvore mostrada na Figura arvbus, que foi gerada como está indicado na seção Uma Aplicação de Árvores Binárias. Caso a árvore seja percorrida em ordem simétrica o resultado seria

Page 26: Projeto de Algoritmos

2 3 4 5 7 8 10

que são os números lidos em ordem crescente sem repetição.

O percurso conhecido como pós-ordem é feito a partir dos três passos na seguinte ordem:  

1. Percorrer a subárvore da esquerda em pós-ordem; 2. Percorrer a subárvore da direita em pós-ordem; 3. Visitar a raiz;

Para a árvore da Figura arvbin o percurso forneceria o seguinte resultado A C E D B G I H F. Um algoritmo recursivo para implementar este percurso poderia ser:

void pos_ordem(tipoNo *pt) { if (pt) { pos_ordem(pt->esq); pos_ordem(pt->dir); visita(pt);}

O percurso em pós-ordem pode ser aplicado no cálculo da altura de uma árvore. Para calcular a altura de uma árvore é necessário calcular o maior caminho da raiz até uma de suas folhas. Deste modo só podemos calcular o comprimento de um caminho a partir de um nó v após percorrermos todos os seus descendentes. O algoritmo mostrado abaixo mostra como fica a implementação da função visita para que ela calcule a altura do nó.

void visita (tNo *p) {      int alt1, alt2;      if (p->esq) alt1 = p->esq->altura;      else alt1 = 0;      if (p->dir) alt2 = p->dir->altura;      else alt2 = 0;      if (alt1>alt2) p->altura = alt1 + 1;      else p->altura = alt2 + 1;      printf("info = %d ", p->info);      printf("altura = %d\n", p->altura); }

As variáveis alt1 e alt2 armazenam a altura das subárvores da esquerda e da direita e o campo altura é um novo campo da estrutura que armazena o nó. A altura de um nó é igual ao maior valor entre as alturas esquerda e direita incrementado de um.

Algoritmo de Huffman

Page 27: Projeto de Algoritmos

Para analisarmos mais uma aplicação de árvores binárias vamos considerar o problema de codificar uma mensagem composta de uma seqüência de símbolos de um alfabeto de n símbolos. Esta mensagem será transformada em uma seqüência de bits, depois de a cada símbolo for atribuído um código binário e os códigos dos símbolos da mensagem forem concatenados.

Considere um alfabeto composto de quatro símbolos A, B, C e D, sendo que a cada um dos símbolos foi atribuído o código indicado a seguir:    

Símbolo Código

A 00

B 01

C 10

D 11

 

A mensagem ABCADCA seria codificada da seguinte maneira 00011000111000, tendo comprimento de 14 bits. O objetivo do algoritmo é criar um código que minimize o comprimento da mensagem. Para criar este código vamos levar em conta a freqüência de cada símbolo na mensagem. A Tabela a seguir mostra a freqüência de cada símbolo na mensagem    

Símbolo Freqüência

A 3

B 1

C 2

D 1

Desta tabela podemos verificar que se atribuirmos ao símbolo A um código binário mais curto que os atribuídos aos símbolos B e D teríamos uma mensagem menor. Isto provém do fato que o símbolo A aparece mais vezes do que os símbolos B e D. Suponha que os seguintes códigos sejam atribuídos aos símbolos    

Símbolo Código

A 0

B 110

C 10

Page 28: Projeto de Algoritmos

D 111

Usando estge código a mensagem ABCADCA ficaria 0110100111100 que requer 13 bits. Em mensagens longas com mais símbolos infrequentes o ganho pode ser maior. Um dos requerimentos deste código é que nenhum código seja prefixo de outro, caso a decodificação seja feita da esquerda para direita.

Para decodificar a mensagem vamos começar da esquerda para a direita, caso o primeiro bit seja 0 o código corresponde ao símbolo A. No caso contrário devemos continuar a examinar os bits restantes. Se o segundo bit for 0 o símbolo é um C, caso contrário examinamos o terceiro bit, um 0 indica um B e D no outro caso.

Do que vomos até agora o algoritmo para encontrar o algoritmo ótimo é o seguinte. Encontre os dois símbolos que aparecem com menor freqüência,  no nosso caso B e D. Atribuímos 0 para B e 1 para D. Combine estes dois símbolos em um BD. Este novo símbolo terá freqüência igual a soma das freqüências de B e D, no caso 2. Temos agora os seguintes símbolos A (3), C (2) e BD (2), os números entre parênteses são as freqüências. Novamente devemos escolher os símbolos de menor freqüência, que são C e BD. Atribuímos o código 0 ao símbolo C e 1 ao BD. Isto siginifica adicionar 1 aos códigos de B e D, que passam a valer 10 e 11 respectivamente. Os dois símbolos são combinados então no símbolo CBD de freqüência 4. Temos agora dois símbolos A (3) e CBD (4). Atribuímos 0 ao símbolo A e 1 ao símbolo CBD. O símbolo ACBD é o único símbolo restante e recebe o código NULL de comprimento 0.  A Figura arvhuf1 mostra a árvore binária que pode ser construída a partir deste exemplo. Cada nó representa um símbolo e sua freqüência.  

Figura arvhuf1: Árvore de Huffman

Page 29: Projeto de Algoritmos

Vamos considerar outro exemplo em que temos  

Removendo Nós de Árvores Binárias

Para remover um nó de uma árvore binária devemos considerar três casos: 1. nó sem filhos; 2. nó com um unico filho; 3. nó com dois filhos.

O  caso de um nó sem filhos é o mais simples e significa apenas ajustar o ponteiro de seu pai. A Figura remov0 ilustra este caso, onde o nó com o valor 8 é removido. No caso do nó ter um único filho a mudança na árvore também é simples significa mover o nó filho daquele será removido uma posição para cima como está ilustrado na Figura remove1, onde o nó com o valor 6 é removido. O caso mais complexo é o do nó com dois filhos. Neste caso devemos procurar o sucessor s (ou antecessor) do nó deverá ocupar este lugar. Este nó (sucessor) é um descendente que está na subárvore da direita do nó e corresponde ao nó mais à esquerda desta árvore. Ele não tem filhos à

Page 30: Projeto de Algoritmos

esquerda e a sua árvore à direita pode ser movida para o lugar de s. A Figura remove2 ilustra o caso de remoção do nó com o valor 12. Observe que o nó 13 (sucessor) assumiu o lugar do nó 12.

Figura remove0: Removendo nó (8) sem filhos.

Figura remov1: Removendo nó (6) com um filho.

Page 31: Projeto de Algoritmos

Figura remov2: Removendo nó (12) com dois filhos.

 O texto abaixo mosta uma rotina que remove nós de uma árvore, que contém números inteiros. O programa completo está em arvremov.c.

tNo *remover (tNo *tree, int num) {      tNo *p,  /* p aponta para o no a ser removido */          *q,  /* q aponta para o pai do no */          *rp, /* rp aponta que ira substituir o no p */          *f,          *s;  /* sucessor do no p */

     p = tree; q=NULL;

     /* procura o no com a chave num, p aponta para o no      e q aponta para o pai do no */      while ( p && p->info != num) {            q = p;            if ( num < p->info)               p = p->esq;            else                p = p->dir;      } /* fim do while */      if (!p) return NULL; /* a chave nao existe na arvore */

Page 32: Projeto de Algoritmos

     /* agora iremos ver os dois primeiros casos, o no tem um filho         no maximo */      if (p->esq == NULL)         rp = p->dir;      else          if (p->dir == NULL)             rp = p->esq;          else {               f=p;               rp = p->dir;               s = rp->esq;   /* s e sempre o filho esq de rp */               while (s != NULL) {                     f = rp;                     rp = s;                     s = rp->esq;               }               /* neste ponto, rp e o sucessor em ordem de p */               if (f != p) {                  /*  p nao e o pai de rp e rp == f->left */                  f->esq = rp->dir;                  /* remove o no rp de sua atual posicao e o                     substitui pelo filho direito de rp                     rp ocupa o lugar de p                  */                  rp->dir = p->dir;               }               /* define o filho esquerdo de rp de modo que rp                  ocupe o lugar de p               */               rp->esq = p->esq;          }      /* insere rp na posicao ocupada anteriormente por p */      if (q == NULL)         tree = rp;      else          if (p == q->esq)             q->esq = rp;          else              q->dir = rp;      free(p);      return rp; }    

Page 33: Projeto de Algoritmos

Árvores Árvores Binárias Balanceadas

Uma árvore binária balanceada, chamada de árvore AVL, é uma árvore binária na qual as alturas das duas subárvores de cada um dos nós nunca diferem em mais de 1. O balanceamento de um nó é igual a diferença entre as suas altura esquerda e  direita. Portanto, cada nó de uma árvore balanceada tem balanceamento igual a -1, 0 ou 1, dependendo da comparação entre as alturas esquerda e direita. Lembrando que a altura de um nó n da árvore é o número de nós do maior caminho de n até um de seus descendentes. As  folhas tem altura 1. Uma árvore binária completa com n>0 nós tem altura mínima, que é igual a 1 + floor(log (n)). A Figura arvbal mostra uma árvore binária balanceada. Os valores dentro do nó são altura do nó e seu balanceamento.

Figura arvbal: Árvore binária balanceada.

Caso a probabilidade de pesquisar uma chave em uma tabela seja a mesma para todas as chaves, uma árvore binária balanceada terá a busca mais eficiente. Infelizmente o método de inserção em árvores binárias apresentado anteriormente não garante que a árvore permanecerá balanceada. Como já vimos a estrutura da árvore depende da ordem em que as chaves são inseridas na árvore. A Figura arvbali mostra possibilidades de inserção na árvore e o que ocorreria com o seu balanceamento. Cada inserção que mantém a árvore balanceada está indicada bor um B e as que desbalanceiam a árvore por um D. Observe que uma árvore se torna desbalanceada quando o nó inserido se torna descendente esquerdo de um nó que tinha anteriormente um balanceamento de 1 ou se ele se tornar descendente direito de um nó que tinha anteriormente balanceamento de -1. Isto é

Page 34: Projeto de Algoritmos

fácil de deduzir, por exemplo, um nó que tinha balanceamento 1 e recebe um descendente direito aumenta sua altura em 1, portanto aumentando o seu desbalanceamento.

Figura arvbali: Árvore balanceada e suas possibilidades de inserção

Observemos uma subárvore que ser tornará desbalanceada quando ocorrer uma inserção. Vamos supor também que este nó tem um balanceamento de 1. Neste caso o desbalanceamento ocorrerá se a inserção ocorrer em um nó da direita. A Figura arvins mostra um exemplo deste caso.

Page 35: Projeto de Algoritmos

Figura arvins: Inserção em árvore binária

Observar que o nó A tem balanceamento 1, isto significa que a subárvore da esquerda tem altura não nula e maior em uma unidade que a da direita. Ao inserirmos um nó na subárvore da direita a sua altura aumenta de um e o balanceamento passa para 2. Observar também que como o nó mais jovem a se tornar desbalanceado é o A, o seu filho pela esquerda tem de ter balalneamento igual a 0.

Para manter a árvore balanceada é necessário que a transformação na árvore de tal modo que:  

1. a árvore permaneça uma árvore de busca binária; 2. a árvore continue a ser uma árvore balanceada.

Para isto vamos definir a operação de rotação em uma árvore. Uma rotação pode ser à direita ou esquerda. A Figura arvrot mostra uma árvore e os resultados dos dois tipos de rotação sobre esta árvore estão mostrados na Figura arvrota.

Page 36: Projeto de Algoritmos

Figura arvrot: Árvore original antes da rotação

Figura arvrota: Efeitos das rotações

Um possível algoritmo para rodar para a esquerda uma árvore enraizada em p é:

Page 37: Projeto de Algoritmos

q = p->direita; temp = q->esquerda; q->esquerda = p; p->direita = temp;  

Para verificar o que fazer em uma árvore T após a inserção de um nó q vamos considerar os vários casos possíveis. Primeiro, se após a inclusão todos os nós se mantiveram regulados, então a árvore se manteve AVL e nada há a fazer. Caso contrário vamos considerar o nó p mais próximo das folhas,  que se tornou desregulado. A escolha de p é única, pois qualquer subárvore de T que se tornou desregulada deve incluir p.

Sejam hd(p) e he(p) as alturas direita e esquerda das subárvores de p, portanto

|hd(p) - he(p)| = 2

pois T era AVL.  

Temos os seguintes casos:

caso 1: hd(p) >he(p)

neste caso q pertence a subárvore esquerda de p. Além disso p possui o filho esquerdo u <> q, senão p não estaria desregulado. Sabe-se também que hd(u) <>he(u), pela mesma razão. Para o caso 1 temos duas possibilidades:

caso 1.1  

Exercícios

1. Escreva um programa que crie uma árvore de busca binária a partir de letras lidas do teclado. O programa deve imprimir a árvore nos três modos de percurso. Solução: arv0301.c

2. Faça uma função que imprima os nós de uma árvore na sequencia de seus níveis. Solução: niveis.c

3. Escreva um programa que leia um arquivo que contem varios numeros inteiros (cada numero em uma linha) e imprima os numeros em ordem crescente (utilize uma arvore para armazenar os numeros na memoria. Solução: ordem.c