Вы находитесь на странице: 1из 40

Algoritmos e Estrutura de Dados III

Plano de ensino da disciplina: 30.705-0

Algoritmos e Estrutura de Dados III

Departamento: 03

Engenharias e Ciência da Computação

Carga horária: 60 horas

30 / 30 horas

Créditos: 04

EMENTA:

Estrutura de dados dinâmicas: tipos de árvores e suas aplicações, implementação de índices utilizando árvores e sua interação com arquivos de dados, tipos de arquivos, compressão, grafos, estudo de implementações.

OBJETIVOS:

Capacitar o aluno na avaliação e no projeto de quaisquer estruturas de dados que utilizam árvores. Operações com arquivos e implementação de índices com árvores, correção e eficiência das implementações.

RELAÇÃO DOS CONTEÚDOS:

- Conceituação de estruturas de dados tipo árvores: tree, tree C, AVL, etc.;

- Estrutura de dados tipo árvores binárias, implementação utilizando TAD com modalidade em ponteiros.

- Conceituação de estrutura de dados tipos árvore B, B+, aplicações de árvore B e B+. Implementações de Índices

- Arquivos x Índices, pesquisa em tabelas: Arquivos sequenciais, sequenciais indexados, indexados e endereço por cálculo (Hashing);

- Técnicas de compressão de arquivos.

- Conceituação de grafos em computadores, aplicações de grafos, implementação de grafos;

BIBLIOGRAFIA BÁSICA (LIVROS TEXTOS):

TENEMBAUM, Aaron M. Estrutura de Dados Usando C. São Paulo: Makron Books do Brasil, 1995. PEREIRA, Sílvio Lago. Estruturas de Dados Fundamentais: Conceitos e Aplicações. São Paulo: Ed. Érica, 1996. VELLOSO, Paulo. Estruturas de Dados. Rio de Janeiro: Ed. Campus, 1991. VILLAS, Marcos Vianna & Outros. Estruturas de Dados. Conceitos e Técnicas de implementação. Rio de Janeiro:

Ed. Campus, 1993.

BIBLIOGRAFIA COMPLEMENTAR (LIVROS REFERENCIADOS):

HOLZNER, Steven. Fundamentos de Estruturas de Dados. 3 ed. Rio de Janeiro: Ed. Campus, 1987. HOROWITZ, Ellis. Fundamentos de Estruturas de Dados. 3 ed. Rio de Janeiro: Ed. Campus, 1987. SZWARCFITER, JAIME LUÍZ. Estruturas de Dados e seus Algoritmos. Rio de Janeiro: Ed. LTC, 1994.

Árvores

Além das filas e pilhas vistas no semestre anterior, que eram estruturas lineares, existem outras formas usadas para representar os dados, que são chamadas genericamente de “não-lineares”. Essa representação permite que sejam feitos outros tipos de relações entre os dados, como por exemplo, relação de hierarquia.

Um dos exemplos mais significativos de estruturas não lineares que representam as relações de hierarquia é a Árvore.

Uma árvore pode ser definida como um conjunto de nós (ou nodos), tais que:

existe um nó denominado raíz da árvore

os demais nós formam subconjuntos distintos S1,S2,

Sm,

onde cada subconjunto desses é uma

árvore. Esses subconjuntos recebem a denominação de sub-árvores.

Um exemplo bem conhecido de relação de estruturação em árvore é a estruturação de um livro, que é subdividido em capítulos, onde cada capítulo é subdividido em seções, que por sua vez, possuem tópicos.

Árvores Árvores Árvores Binárias Conceito Percurso Balanceamento
Árvores
Árvores
Árvores
Binárias
Conceito
Percurso
Balanceamento
Árvores Binárias Conceito Percurso Balanceamento Considere agora o seguinte exemplo de árvore genealógica
Considere agora o seguinte exemplo de árvore genealógica Pedro Maria André Marcelo Rodrigo Márcia Viviane
Considere agora o seguinte exemplo de árvore genealógica
Pedro
Maria
André
Marcelo
Rodrigo
Márcia
Viviane
Cézar
Aide
Cláudio

Analisando essa árvore, podemos concluir:

Essa árvore possui 10 nós distribuídos aleatoriamente. O nodo Pedro é a raiz da árvore, que tem 3 sub-árvores com Maria, André e Marcelo como raízes.

O número de sub-árvores de um nó determina o grau desse nó. Dessa forma, Pedro e Maria têm grau 3, enquanto André tem grau 2 e Marcelo tem grau zero. Nodos que tem grau zero são denomidados terminais ou folhas.

Pergunta: Qual é o grau do nodo Viviane?

Considerando que cada nó tem um grau máximo (número de sub-árvores das quais esse nó é raiz) e que todos os nós possuem o mesmo grau máximo, definimos esse grau como o grau da árvore.

Uma árvore é homogênea quando todos os seus nodos possuem as mesmas características de conteúdo, ou seja, o mesmo tipo de informação; caso contrário ela é então chamada de árvore heterogênea.

Para identificar os nós da estrutura, usamos as denominações da relação de hierarquia existente em uma árvore genealógica. Dessa forma, Pedro é pai de Maria, André e Marcelo que são irmãos entre si. No sentido inverso, Rodrigo, Márcia e Viviane são filhos de Maria e netos de Pedro. Para conhecermos os antepassados de um nodo, basta identificarmos todos os nodos ao longo do caminho entre a raíz e este nodo. Ex: os antepassados de Cláudio são, na ordem: Pedro, Maria e Viviane.

Um conceito importante no estudo de árvores é o conceito de nível, que representa a distância do nodo até a raiz. Por definição, a raiz da árvore tem nível 0. Na figura anterior, os nodos Maria, André e Marcelo têm nível 1, os nodos Rodrigo, Márcia, Viviane, Cézar e Aide tem nível 2, assim por diante. O nodo de maior nível nos fornece a altura (ou profundidade) da árvore.

Pergunta: No exemplo que estamos usando, a árvore tem altura (???)?

Exercício : Tente construir uma árvore com base nas informações abaixo:

- O nodo C tem grau 3.

- O nodo X é neto de C e filho de B.

- O avô de B é A.

- O nodo A tem altura 0 e T tem altura 1.

- Os antepassados de P são A,T e K, que são também antepassados de H.

- T tem grau 2, e um dos seus filhos é o nodo S.

- O nodo G tem 2 sub-árvores que são netos de C.

- D é irmão de G, e é uma folha.

- E e F tem graus 0 e 1 respectivamente e são também netos do nodo C.

- O nodo N tem nível 4.

Representações das Árvores

É comum a utilização da representação “cima-baixo” devido à sua popularidade, porém, uma árvore pode ser representada também de outras maneiras:

Representação natural de uma árvore:

K E F G H I J B C D A
K
E
F
G
H
I
J
B
C
D
A
• Representação por endentação: A B E F C G D H K I J
Representação por endentação:
A
B
E
F
C
G
D
H
K
I
J
Representação por conjuntos
A
B
C
D
H
E
F
G
I
J
K
Representação por nível:

( A (B) (C) (D) )

Árvores Binárias e variantes

Definição: uma árvore binária se caracteriza pelo fato de todos os seus nodos terem no máximo duas sub-árvores, ou seja é uma árvore de grau 2. As duas sub-árvores de cada nó são denominadas sub- árvore esquerda e sub-árvore direita.

Na figura abaixo, temos duas árvores binárias, sendo a da esquerda, incompleta e a da direita completa:

A A B C B C D E F G D F
A
A
B
C
B
C
D
E
F
G
D
F

No segundo caso, cada nodo possui dois descendentes, exceto as folhas, que por definição não tem filhos. Geralmente nos referimos aos filhos de um nodo numa árvore binária como sendo da esquerda ou da direita, de acordo com o posicionamento dele.

Conversão de uma árvore de um grau qualquer para uma árvore binária:

Supomos que temos um nodo A com 3 descendentes diretos, B C e D, etc. Deve-se então fazer o

seguinte:

- A raiz torna-se a raiz de uma árvore binária.

- B torna-se o descendente da esquerda de A

- C torna-se o descendente da direita de B.

- D torna-se o descendente da direita de C.

- E torna-se o descendente da direita de D.

A C B D A C B D H I J E F A B
A
C
B
D
A
C
B
D
H
I
J
E
F
A
B C
D
H
I
J
E
F G

Exercícios: transforme as seguintes árvores em árvores binárias:

Representações alternativas (no computador):

Existem duas maneiras alternativas de representarmos árvores binárias no computador:

- em listas lineares e

- em listas encadeadas.

a) Árvores binárias em listas lineares:

Uma maneira de representar uma árvore binária numa lista linear consiste em declarar um vetor com

tamanho máximo igual ao número máximo de nós da árvore binária.

A representação de árvores binárias na forma de listas lineares é especialmente indicada quando as árvores são completas, pois resulta na utilização máxima do espaço alocado.

A representação na forma de uma lista linear não é indicada em duas situações:

- quando for necessário efetuar operações de inserção e retirada de nodos, pois potencialmente exigirá uma grande movimentação dos dados para conseguir o efeito desejado; e

- quando a árvore afasta-se mais e mais da condição de completa, ou seja, não é indicada em situações onde a estrutura de dados é frequentemente alterada.

Árvore binária completa alocada sequencialmente. Árvore binária incompleta alocada sequencialmente
Árvore binária completa alocada sequencialmente.
Árvore binária incompleta alocada sequencialmente

Nesse tipo de representação, fica difícil saber qual é o ascendente de um átomo e embora é geralmente adequada para a maioria das aplicações, poderá haver sempre desperdício de espaço.

Na alocação encadeada subdividimos o espaço de um nó em três campos, como mostrado abaixo:

nodo

Pos sub-árvore esquerda

Pos sub-árvore direita

Veja abaixo, a representação da seguinte árvore:

Outra forma de representar a árvores seria no seguinte formato

Link esquerdo

Nodo

Link direito

A árvore então, seria apresentada da seguinte forma:

Operadores para árvores binárias:

Donald E. Knuth popularizou três ordens de percurso em árvores binárias: Pré-ordem ou prefixa, simétrica ou infixa e Pós-ordem ou pós-fixa.

ou prefixa, simétrica ou infixa e Pós-ordem ou pós-fixa. Prefixa: - visita a raiz - visita

Prefixa:

- visita a raiz

- visita a sub-árvore à esquerda

- visita a sub-árvore à direita

Infixa:a sub-árvore à esquerda - visita a sub-árvore à direita - visita a sub-árvore à esquerda

- visita a sub-árvore à esquerda

- visita a raiz

- visita a sub-árvore à direita

esquerda - visita a raiz - visita a sub-árvore à direita Posfixa: - visita a sub-árvore

Posfixa:

- visita a sub-árvore à esquerda

- visita a sub-árvore à direita

- visita a raiz

Considerando a árvore apresentada ao lado, veja a seqüência de nodos visitados de acordo com cada ordem de visitação:

Visitação prefixa: A,B,D,E,C,F,G Visitação infixa: D,B,E,A,F,C,G Visitação posfixa: D,E,B,F,G,C,A

A C B D E F G
A
C
B
D
E
F
G

Exercícios:

1) Dada as seguintes árvores, faça para cada uma delas a visitação dos nodos utilizando a ordem prefixa, infixa e pósfixa

A a) C B D E F G H I Árvores de Expressões
A
a)
C
B
D E
F
G
H
I
Árvores de Expressões
A b) C B D E F G H I K J L M
A
b)
C
B
D E
F
G
H
I
K
J
L
M
A c) B C D E F H I G J K L M
A
c)
B
C
D E
F
H
I
G
J
K
L
M

Uma expressão aritmética pode ser armazenada sob a forma de uma árvore binária, onde as raízes armazenariam as operações a serem efetuadas, e as sub-árvores à esquerda e direita armazenariam os operandos a serem usados.

Por exemplo, a expressão a+b deveria então ser apresentada com a seguinte forma:

+ A B
+
A
B

Já no caso da expressão a + b * c, a prioridade dos operadores e a ordem de ocorrência, ambos, devem ser respeitados

+ A * B C
+
A
*
B C

ou seja, ao se percorrer as árvores na forma infixa e posfixa respectivamente teremos:

a,+,b,*,c e

a,b,c,*,+,

que é diferente da expressão b * c + a, que seria representada assim:

+ * A B C
+
* A
B C

Após estas análises, a expressão ((A+B)/C)*(D-E), então, seria representada assim:

* / - + C D E A B
*
/
-
+
C
D
E
A
B

Como dica para resolver uma árvore completa, procure sempre resolver as expressões do centro para fora. No caso anterior, por exemplo, represente:

A + B

/ C

D

*

- E

Exercícios:

Transforme as seguintes expressões para posfixa utilizando árvores binárias para fazer a transformação:

a) (a+b) * (c-d)

b) (a+b*c)*a + (c-d)*3

c) (a+b*c)*a – 4*(5-6+1) + (c-d)*3

d) (a+b-c) – (3-4)*(5-6+1) + (c-d*3)

Árvore binária de pesquisa

Uma árvore binária, cuja raiz armazena o elemento R, é denominada árvore binária de pesquisa se:

todo elemento armazenado na sub-árvore à esquerda é menor do que R, todo elemento armazenado na sub-árvore à direita é maior do que R.

na sub-árvore à esquerda é menor do que R, todo elemento armazenado na sub-árvore à direita

Uma árvore binária de pesquisa, visitada em ordem simétrica/infixa (E,R,D), resulta em uma lista de dados em ordem crescente. Veja os casos abaixo: na árvore Y, todos os elementos da sub-árvore esquerda (a,b,c) são menores que a raiz, e nenhum elemento da sub-árvore direita (e,f,g) é menor que a raiz. Entretanto, ela não é uma árvore binária de pesquisa, pois na sub-árvore da direita, cuja raiz é G, tem uma sub-árvore direita que armazena uma informação menor que a raiz.

X) Ordenada (binária de pesquisa) D B E A C D F
X) Ordenada (binária de pesquisa)
D
B
E
A C
D
F

A árvore abaixo, é binária de pesquisa?

D E A C G B F
D
E
A
C G
B
F
Y) Não ordenada D B G A C E F
Y) Não ordenada
D
B
G
A C
E
F

Formato dos nodos a serem empregados na organização dos dados: Cada nodo precisa armazenar um elemento e referenciar duas sub-árvores:

Trabalho (parte 1):

Implementar as seguintes operações sobre uma A. B. P.:

- Inserção em uma ABP:

Fornecido

- Busca de 1 elemento na ABP:

?

- Remoção de 1 elemento da ABP:

?

- Listagem da árvore na forma:

 

Em nível:

opcional

Prefixa:

?

Infixa:

Fornecido

Posfixa:

?

- Rotina para det. o > elemento da ABP:

?

-

Rotina para det. a altura da ABP:

opcional

Obs:

- as rotinas ? devem ser desenvolvidas pelo aluno.

- As rotinas opcionais valerão meio ponto extra no trabalho

- O programa deve ainda apresentar um menu para que seja selecionada a opção desejada.

Bibliografia Recomendada:

TENEMBAUM, Aaron M. e

PEREIRA, Sílvio do Lago. Estruturas de Dados Fundamentais: Conceitos e Aplicações. SP.: Ed. Érica, 1996.

Estruturas de Dados Usando C. SP.: Ed. McGraw-Hill, 1995.

Código Fornecido (em C++), base para compreensão da manipulação em árvore

# include <iostream>

using namespace std;

class nodo { private:

nodo *esq; char info; nodo *dir; public:

void ins (nodo* *T, char informacao); void infixa (nodo* *T);

};

nodo *P;

void nodo::ins (nodo* *T, char informacao){ cout << *T ; if (*T == NULL) { *T = new nodo; //pode-se verificar aqui se a (*T) == NULL para inserir (*T)->esq = NULL; (*T)->info = informacao; (*T)->dir = NULL; cout << "ins. no " << (*T) <<" "<< (*T)->esq <<" "<< (*T)->info <<" "<< (*T)->dir << endl;

}

else{ if (informacao < (*T)->info) { ins (&((*T)->esq), informacao);

}

else{ ins (&((*T)->dir), informacao);

}

}

}

void nodo::infixa (nodo* *T){ if ((*T) != NULL) { infixa (&(*T)->esq); cout << (*T) << " " << (*T)->esq << " " << (*T)->info << " " infixa (&(*T)->dir);

}

}

int main (void) { nodo *T; T = NULL;

cout <<

&T << " ";

T->ins (&T, 'd');

cout <<

&T << " ";

T->ins (&T, 'c');

cout <<

&T << " ";

T->ins (&T, 'a');

cout <<

&T << " ";

T->ins (&T, 'h');

cout <<

&T << " ";

T->ins (&T, 'e');

cout <<

&T << " ";

T->ins (&T, 'i');

cout <<

&T << " ";

T->ins (&T, 'g');

cout <<

&T << " ";

T->ins (&T, 'f');

}

cout << endl << "Infixa:

T->infixa (&T);

return(0);

" << endl;

<< (*T)->dir << endl;

0 ); " << endl; << (*T)->dir << endl; Apostila de Algoritmos e Estrutura de Dados

a) Inserção em uma A. B. P.:

Inserir um elemento X em uma Árvore Binária de Pesquisa vazia é trivial. Se a árvore não se encontra vazia deve-se fazer o seguinte: Compara-se o elemento a inserir com o elemento raiz da árvore. Se o elemento for menor, o inserimos na sub- árvore esquerda, senão, o inserimos na sub-árvore direita.

Insere (nodo *T , char inform) início se T = NULO então novo(T) T->esq = NULO T->info = inform T->dir = NULO senão se inform < T->info então Insere(T->esq,inform) senão Insere(T->dir,inform) fim_se fim_se

fim

Representação interna de uma árvore binária ( com ponteiros)

Para entendermos o funcionamento do algoritmo de inserção, veja o exemplo de construção de uma árvore binária de pesquisa com o conjunto de números

{17,99,13,1,3,100,400}

raiz

nuloraiz

raiz

17raiz

17 17 17 17 99 13 99 1133 99 100 1 3 400
17
17
17
17
99
13
99
1133
99
100
1
3
400

Análise DETALHADA do procedimento de inserção:

Considere como exemplo a árvore B abaixo, que foi inserida através das linhas de programa apresentadas ao lado dela. Note que para criar a árvore, os nodos devem ser inseridos sempre por nível, do nível 0 ao nível n ou na ordem prefixa, pois para inserir o “C”, por exemplo, já deve ter sido inserido anteriormente o nodo “E”. Para inserir o “F”, já deve ter sido inserido anteriormente o nodo “H”, e assim por diante

E 1C3 H A F I G
E
1C3
H
A
F
I
G

nodo *T;

T = NULL;

T->ins (&T, 'e'); T->ins (&T, 'c'); T->ins (&T, 'a'); T->ins (&T, 'h'); T->ins (&T, 'f'); T->ins (&T, 'i'); T->ins (&T, 'g');

Só para recordar, consideramos um programa que cria e atribui um ponteiro, por exemplo:

char *x;

FFF0

“X”

FFF4

*x='a';

FFF4

a

Neste caso:

x = FFF4

&x=FFF0

e

*x = 'a'

   

Com a análise da estrutura da árvore na memória do computador, após a inserção de todos os nodos, obtém-se uma tabela que é apresentada abaixo.

Para gerar tal tabela, basta incluir a seguinte linha na rotina infixa():

cout << (*T) << " " << (*T)->esq << " " << (*T)->info
cout << (*T) << " " << (*T)->esq << " " << (*T)->info << " "
<< (*T)->dir << endl;
T
*T
(*T)->esq
(*T)->dir
(*T)->info
(& de quem aponta para
o nodo)
que informação tenho
no cpo “esq”
que informação
tenho no cpo “dir”
que informação
tenho no cpo “info”
FFF4
09FE
0A08
0A12
E
09FE
0A08
0A1C
----- (NULL)
C
0A08
0A1C
-----
-----
A
0A01
0A12
0A26
0A30
H
0A12
0A26
-----
0A3A
F
0A29
0A3A
-----
-----
G
0A15
0A30
-----
-----
I
? ? E E Estrutura do Nodo: Estrutura do Nodo: T T FFF4 FFF4 (*T)->esq
?
?
E
E
Estrutura do Nodo:
Estrutura do Nodo:
T
T
FFF4
FFF4
(*T)->esq
(*T)->esq
(*T)->info
(*T)->info
(*T)->dir
(*T)->dir
FFF4 &(*T)
FFF4 &(*T)
1C3
1C3
H
H
09FE *T
09FE *T
1
1
byte
byte
2 bytes
2 bytes
1 byte
1 byte
09FE
09FE
09FF 0A00
09FF 0A00
0A01
0A01
A
A
F
F
I I
0A08
0A08
E E
0A12
0A12
0A08
0A08
0A09 0A0A
0A09 0A0A
0A0B
0A0B
0A12
0A12
0A13 0A14
0A13 0A14
0A15
0A15
G
G
00AA11CC
00AA11CC
C C
- -
0A26
0A26
H H
0A30
0A30
00AA11CC
00AA11CC
0A1D 0A1E
0A1D 0A1E
0A1F
0A1F
0A26
0A26
0A27 0A28
0A27 0A28
0A29
0A29
0A30
0A30
0A31 0A32
0A31 0A32
0A33
0A33
A A
- -
- -
F F
0A3A
0A3A
- -
I I
- -
0A3A
0A3A
0A3B 0A3C
0A3B 0A3C
0A3D
0A3D
- -
G G
- -

b) Pesquisa em uma árvore binária

Se a árvore for nula, nada a fazer, caso contrário, o processo de busca é o mesmo utilizado para inserção.

Void procura (

){

if (*T ==NULL) printf (“Nodo não existe!”); else { if( informacao < (*T)->info) procura ( usando sub-árvore esquerda) else { if (informacao > (*T)->info) procura ( usando sub-árvore direita)

}

}

else

// encontrou

}

// procura para a esquerda

// procura para a direita

c) Retirada em uma A. B. P.:

Se o nodo a ser retirado de uma árvore for uma folha, basta atualizar o link do seu pai para que não aponte mais para o nó

a ser retirado.

No caso de retirada de um nodo que é raiz, deve-se adotar o seguinte procedimento:

- se a raiz não tem filho esquerdo, o filho direito passa a ser a raiz, e vice-versa

-se a raiz tem ambos os filhos, pode-se optar por buscar a > chave esquerda ou a < chave direita para ser a nova raiz:

Exercício: Remover o D:

D B F A C E G
D
B
F
A C
E
G

Análise do procedimento de retirada

Considere como exemplo a árvore B abaixo (a mesma que foi utilizada para explicar o procedimento de

inserção)

E 1C3 H A F I G
E
1C3
H
A
F
I
G

São quatro as possibilidades existentes para retirada. Considere que o nodo a ser retirado pode ser:

a) Um nodo sem descendentes

b) Um nodo sem descendentes na direita mas com descendentes na esquerda.

c) Um nodo sem descendentes na esquerda mas com descendentes na direita.

d) Um nodo com 2 filhos.

No programa, isso pode ser representado por 4 testes de seleção (if)

if

((*T)-> esq != NULL) && ((*T)-> dir ==NULL){ (1 linha)

//somente descendentes na esq.

}

if

((*T)-> esq == NULL) && ((*T)-> dir !=NULL){ (1 linha)

//somente descendentes na dir.

}

if ((*T)-> esq == NULL) && ((*T)-> dir ==NULL){ (1 linha)

//nodo sem descendentes.

}

if

((*T)-> esq != NULL) && ((*T)-> dir != NULL){ (algumas linhas)

//nodo com 2 filhos

}

No caso da retirada de uma folha (A, G ou I), (item a) o procedimento a ser realizado é muito simples:

Simplesmente atribui nulo (NULL) para o ponteiro que está apontando para o nodo a ser retirado e libera-se a memória ocupada anteriormente pelo nodo.

E 1C3 H A F I G
E
1C3
H
A
F
I
G
E 1C3 H F I G
E
1C3
H
F
I
G

No caso da retirada de um nodo com um filho (item b ou c), simplesmente o nodo pai passa a apontar para o neto. Exemplos:

E E 1C3 H 1A3 H A F I F I G G E E
E
E
1C3
H
1A3
H
A
F
I
F
I
G
G
E
E
1C3
H
1C3
H
A
G
I
A
F
I
G

No caso da retirada de um nodo com um ambos os filhos (item d), deve-se criar uma rotina aqui chamada de pega_maior ou Get_max, que deverá pegar o maior elemento da sub-árvore que está sendo excluída:

E E 1C3 H 1C3 H A A F F I I G G
E
E
1C3
H
1C3
H
A
A
F
F
I
I
G
G

Neste caso, como o Get_max deve ser aplicado sobre a sub-árvore esquerda do H, o valor a retornar pela função será o G

E 1C3 G A F I G E 1C3 G A F I
E
1C3
G
A
F
I
G
E
1C3
G
A
F
I

Se, por exemplo, tivesse um F2 “pendurado” na esquerda do “G”, após a retirada do G ele deveria aparecer “pendurado” na direita do “F”

E E 1C3 H 1C3 G A F I A F I G F2 F2
E
E
1C3
H
1C3
G
A
F
I
A
F
I
G
F2
F2

Abaixo é apresentado o algoritmo para exclusão:

Procedure exclusao(

início se (*T ==NULL) exit;

)

// elemento não foi encontrado

fim

se (informacao == T->info) P=t; se (T->esq == NULL)

// elemento encontrado na raiz

T=T->dir

// A raiz não tem filho esquerdo

senão se (T->dir=NULL) T=T->esq senão P = PegaMaior(T->esq); T->info = P->info; fim_se libera(P); fim_se senão

se (informacao < T->info) então exclusao(T->esq,informacao) senão exclusao(T->dir,informacao) fim_se fim_se

// elemento encontrado numa das sub-árvores

d) Listagem em uma Árvore Binária

Conforme visto anteriormente, podemos percorrer uma árvore passando por todos os nodos dela. Existem 4 formas de atravessamento (listagem) de uma árvore:

- Prefixa (?)

- Posfixa (?)

Infixa(

);

início se (T != nil) infixa(t->esq); escreve(T->.info,','); infixa(t->.dir); fim_se

fim

Rotina para pegar o maior elemento de uma árvore ( Pega_maior):

O maior elemento de uma árvore binária ordenada encontra-se sempre no nodo que estiver mais à direita possível na árvore.

Trabalho 1 de Algoritmos e Estrutura de Dados 3

1. Introdução e Objetivos:

Este trabalho tem por objetivo verificar a aplicação de árvores binárias de pesquisa para a resolução de problemas comuns no âmbito da Ciência da Computação.

2. Descrição do trabalho

O trabalho, que poderá ser realizado por duplas ou grupos de até 3 alunos (no máximo) consiste em duas etapas:

a) implementação das rotinas de:

- Inserção em uma ABP

- Busca de 1 elemento na ABP:

- Remoção de 1 elemento da ABP:

- Listagem da árvore na forma prefixa, infixa e posfixa - Rotina para det. o maior elemento da ABP:

b) Aplicação prática da Árvore Binária de Pesquisa:

Dicionário de 1 Segundo de Andy.

Andy está com 9 anos e ficando ambicioso. Ele quer criar um programa que extraia palavras de um texto, até mesmo as palavras com hífens e listar todas as palavras encontradas no texto em ordem alfabética. A entrada para o programa seria um texto com no máximo 500 palavras, terminado com um EOF.

O papel do grupo (vocês) é criar um programa que liste todas as distintas palavras que existirem no texto em ordem alfabética, todas

convertidas em minúsculas. Letras também são consideradas. Palavras como “maçã”, “Maçã”, “MAÇÔ ou “MaÇã” devem ser consideradas como a mesma palavras e serem gravadas no dicionário como “maçã”. E existe ainda um detalhe: se acontecer no texto a separação devido à uma troca de linha, o programa deverá entender a palavra. Vamos ao exemplo.

Arquivo de entrada (entrada.txt):

E então o padre guarda-

va a sua coleção dentro do GUaRDa-RoUpA, fican- do de guarda para que ninguém a roubasse.

Arquivo de saída (saída.txt)

a

coleção

de

dentro

do

e

então

ficando

guarda

guarda-roupa

guardava

ninguém

o

padre

para

que

roubasse

sua

Dica para implementação: deve-se fazer um programa que leia o texto de entrada inserindo cada palavra lida em uma árvore binária de pesquisa, no caso da palavra ainda não se encontrar na árvore. Após o término do arquivo de entrada, deve ser gerado um arquivo de saída com as palavras ordenadas, bastando para isso percorrer a árvore binária de pesquisa de forma infixa.

* O idem b) deste trabalho foi tirado do conjunto de problemas mantidos pela Universidade de Valladolid, sob número 10815.

Exercícios

1 Construa uma árvore com base nas informações abaixo:

- O nodo B tem grau 3.

- O nodo K é neto de B e um de seus filhos é F.

-

O avô de S é

A.

- O nodo K tem um irmão.

- O nodo Pai de T é o nodo U.

- O filho de A, que é nodo irmão de B, tem dois filhos, sendo que um deles é o nodo P.

- O nodo T tem altura 3, mas não é neto de B, tampouco filho de P.

- Os nodos T e C estão no mesmo nível.

- O nodo A tem altura 0, e o nodo D tem altura 1.

- A árvore tem altura 4,

- Os nodos X e Y tem grau 0.

2. Transforme as árvores do exercício 1 para árvore binária.

3. Represente as árvores do exercício 2 em uma estrutura de dados através de 3 vetores: ELOE, NODO e ELOD.

4. Faça o caminhamento prefixo, infixo e pós-fixo para as árvores do exercício 4.

5. Represente a expressão (A*B^C+(2/A)*B-3*C/(2*A)) através de uma árvore, forma infixa.

6. Sejam ABCDEFGHI e DACBFGFIJ as seqüências infixa e prefixa de uma árvore de Busca Binária T,

reconstrua graficamente a árvore T.

7. Construa uma ABB com o conjunto de números:

{100,23,13,15,66,43,134,87,166,786,214,221,7,1880,200}

8. Faça o balanceamento da árvore 7 e apresente-a graficamente

Trabalho (parte 2):

As árvores são ótimas para implementar indexação, pois aceleram o processo de pesquisa de informações. Acesso em disco é sempre mais lento do que acesso em memória. Exclusões e inclusões mantendo os dados de um arquivo ordenado, por exemplo, implicariam na movimentação de uma grande quantidade de dados, sendo um processo excessivamente lento para ser utilizado em aplicações práticas.

A utilização de índices tem por objetivos:

rapidez no processo de inclusão de novos registros

rapidez no processo de pesquisa do arquivo

rapidez no processo de atualização do arquivo

A inclusão em um arquivo de dados será sempre efetuada no final do arquivo, sendo então um procedimento fácil de

implementar. Para garantir então rapidez de pesquisa, uma estrutura de indexação reduziria em muito os acessos a disco.

O índice seria um mapeamento que associa cada chave da árvore de índice a uma chave no arquivo de dados.

A atualização utilizando índices também é muito eficiente. Se um registro deve ser alterado, podemos encontrá-lo rapidamente. Se um registro deve ser removido basta remover a sua referência no índice (remoção lógica). A remoção definitiva do registro do arquivo pode ser feita em um processo de reorganização posterior, realizado esporadicamente.

Uma base de dados indexada é então composta por duas partes básicas: um arquivo de dados e uma estrutura de índice associada. Segue exemplo de uma estrutura de índice utilizando ABP, onde a chave de índice é o campo nome:

T (ponteiro)

Elisa 0 Caio 1 Gilda 11
Elisa
0
Caio
1
Gilda
11
Ana 9
Ana
9
Denis 4
Denis
4
Bia 8
Bia
8

A estrutura do arquivo de dados é apresentada a seguir:

Fábio 12
Fábio
12
Valdo 10
Valdo
10
João 14 Hélio 19
João
14
Hélio
19

Registro

E#

Código

Nome

Telefone

Endereço

Cidade

UF

12

 

9999-2222

R. Goiás, 14

Erechim

RS

4

Caio

520-9091

Av. P. P. de Souza, 124, Ap 301

Erechim

RS

26

 

321-3675

 

Erechim

RS

Elisa RS 26   321-3675   Erechim RS 0 1 Gilda 11 A estrutura da árvore de

0

1

Gilda26   321-3675   Erechim RS Elisa 0 1 11 A estrutura da árvore de indexação

11

A estrutura da árvore de indexação seria:

struct tiponodo

{

struct tiponodo *esq; char nome[10]; int pos; struct tiponodo *dir;

}; typedef struct tiponodo *NODO;

A Implementar:

Implemente o programa agenda que é a manipulação de um arquivo de dados com a estrutura apresentada acima e índice por nome. O programa deve apresentar as seguintes opções:

Incluir

Excluir

Consultar dados pelo Nome da pessoa

Listar em ordem Alfabética

Árvores de busca Multidirecionais

Uma ABP não é viável para implementação em disco, pois a busca de cada nodo requer um acesso a disco. Se agruparmos várias chaves em cada nodo, reduzimos o tempo de pesquisa. Esse agrupamento reduz a altura da árvore. Ex:

ABP

500 420 800 180 480 720 980 150 300 455 499 515 798 850 995
500
420
800
180
480
720
980
150
300
455
499
515
798
850 995
112 190
536
987
110
245
670

A implementação com agrupamento de chaves é chamada Árvore de busca Multidirecional ou Árvore de busca M- vias, e é uma árvore de grau M.

Em uma árvore de ORDEM M, cada nodo tem M ou menos subárvores.

--> Um nodo com 2 chaves = 3 subárvores

(Ordem 3)

--> Um nodo com 3 chaves = 4 subárvores

(Ordem 4)

--> Um nodo com 4 chaves = 5 subárvores

(Ordem 5)

Alguns aspectos das árvores multidirecionais abaixo:

- Na árvore (a), os nodos A,D,E,G são completos e B, C, F e H são incompletos.

- Um nodo com pelo menos uma subárvore vazia é denominado semifolha: (a): b até h. (b): B até G e I até R.

- A árvore (a) e a árvore (b) não são balanceadas, porque diferentemente das ABPs, que podem ter folhas em níveis diferentes, uma Árvore multidirecional só será balanceada se todas as folhas se encontrarem no mesmo nível.

Ordem:
Ordem:

Balanceada(s/n):

Ordem:

Balanceada(s/n):

Ordem:

Balanceada(s/n):

Ordem: Balanceada(s/n) : Inserção em uma árvore multidirecional balanceada de ordem 3 Árvores B Devido ao

Inserção em uma árvore multidirecional balanceada de ordem 3

em uma árvore multidirecional balanceada de ordem 3 Árvores B Devido ao pouco desperdício, as árvores

Árvores B

Devido ao pouco desperdício, as árvores B (ou variações dela) são utilizadas com muita freqüência em sistemas de arquivos reais. Uma árvore B de ordem M é uma árvore de busca multidirecional balanceada que:

- cada nodo tem no máximo M-1 chaves e a raiz tem no mínimo 1 chave

- cada nodo não-raiz tem no mínimo (M-1)/2 chaves ( ordem 11 tem no mínimo 5 chaves /nodo; ordem 4: mínimo 1)

- todas as folhas devem estar no mesmo nível

* Alguns autores consideram que uma árvore de ordem 4 possa ter 4 chaves e 5 filhos em cada nodo. Outros ainda consideram a ordem como n/2, ou seja, uma árvore de ordem 2 possa ter 4 chaves por nodo e 5 filhos. -> Como em Tenembaum 95, de forma coerente, consideraremos Ordem como sendo o Grau máximo da árvore (número máximo de filhos por nodo).

Nível

Mínimo

 

Máximo

Nodos

Chave

 

Nodos

Chaves

0

1

1

1

m-1

1

2

2*q

m

(m-1)*m

2

2* (q+1)

2*q*(q+1)

m

2

(m-1)*m 2

sendo que q = (m-1)/2

Por exemplo, considerando uma árvore de Ordem 4, qual o número mínimo e máximo de nodos que ela pode ter nos níveis 0, 1 e 2 respectivamente? E no total? Mostre graficamente.

Comparando com uma árvore binária qualquer de 3 níveis (0-2), qual é o número máximo de chaves que ela poderia ter?

Inserção em uma árvore B

A seguir, são mostrados alguns passos para inserção em árvores B. Deve-se observar a divisão que ocorre em um nodo quando o número de chaves ultrapassa o número de nodos permitidos. Se a ordem da árvore for par, as n-1 chaves, excluindo a chave do meio devem ser divididas em 2 grupos de tamanhos diferentes: um grupo com tamanho n/2 e outro grupo com tamanho (n-1)/2.O segundo grupo é sempre de tamanho (n-1)/2 independentemente do fato de n ser ímpar ou par, pois quando n é impar, (n-1)/2 será igual a n/2. Como exemplo, considere duas árvores, de ordem 4 e 5 respectivamente.

Veja abaixo, na figura da esquerda, o procedimento de inserção em uma árvore de ordem 5.

o procedimento de inserção em uma árvore de ordem 5. Inserção em uma árvore B de
o procedimento de inserção em uma árvore de ordem 5. Inserção em uma árvore B de

Inserção em uma árvore B de ordem 3:

Tente inserir o nodo 10:

Cada nodo pode ter no máximo 3 filhos

3 6 1 2 4 5 7 8
3
6
1
2
4
5
7
8

Compressão de Dados

1. Para que comprimir?

Ao contrário do que possa parecer, comprimir não é somente reduzir o tamanho de um arquivo: além da compressão utilizada em arquivos para reduzir o espaço físico utilizado por eles, há também a compressão que é utilizada para melhorar a performance em uma trasmissão de dados.

para melhorar a performance em uma trasmissão de dados . A redução do espaço físico é

A

redução do espaço físico é mais comumente utilizada em bancos de dados que, incorporando a compressão no projeto

de

seus registros, permite um significativo ganho em termos de ocupação em disco e velocidade de acesso.

Exemplos (bancos de dados): Dbase, Interbase, Paradox, Postgress

Exemplos (arquivos): Openoffice, Word

Como foi mencionado anteriormente, a utilização mais conhecida da compressão de dados é a redução do espaço ocupado

por arquivos. De fato, esta é aplicação mais comum e mais difundida comercialmente. Afinal, dado um dispositivo restrito de armazenamento que é um disquete, e um grande depositório de arquivos que é o disco rígido, é evidente a necessidade

de muitas vezes realizar-se a redução do tamanho de arquivos para transportá-los ou simplesmente armazená-los.

A compressão também é utilizada para agilizar a transmissão de dados alterando a taxa de transmissão. Se possuímos um

modem que opera a 9600 bps (bits por segundo), é possível que ele transmita como se estivesse a 14400 bps? Sim! Basta que ele permita a compressão de dados transmitidos, ampliando sua capacidade de transferência de informação. Na

verdade, o modem continuará transmitindo a uma taxa de transmissão de 9600 bps, mas a taxa de transferência de informação estará ampliada para 14400 bps. A compressão de dados permite, portanto, o aumento na velocidade de transmissão de dados.

2. Tipos de Compressão

1.1. Compressão Lógica

de dados. 2. Tipos de Compressão 1.1. Compressão Lógica A compressão lógica refere-se ao projeto de

A compressão lógica refere-se ao projeto de representação otimizada de dados. Um exemplo clássico é o projeto de um

banco de dados utilizando seqüências de bits para a representação de campos de dados. No lugar de seqüências de caracteres ou inteiros, utiliza-se bits, reduzindo significativamente o espaço de utilização do banco de dados.

Este tipo de compressão é possível de ser efetivada em campos projetados para representar dados constantes, como datas, códigos e quaisquer outros campos formados por números. A característica lógica da compressão encontra-se no fato dos dados já serem comprimidos no momento do armazenamento, não ocorrendo sua transformação de dados estendidos para

comprimidos. Ex.: Representação de

“01 de Abril de 2003”

: 15 bytes

“01 Abr 2002” : 9 bytes

“01042002”

: 8 bytes

Tem como reduzir ainda mais?????

A

razão da compressão é dada por: tamanho dos dados originais tamanho da cadeia de dados comprimido

2.1. Compressão Física

A compressão física é aquela realizada sobre dados existentes, a partir dos quais é verificada a repetição de caracteres para

efetivar a redução do número de elementos de dados. Existem dois tipos de técnicas para sinalizar a ocorrência de caracteres repetidos:

1. um deles indica o caracter (ou conjunto de caracteres) repetido através da substituição por um caracter especial;

2. outras técnicas indicam a freqüência de repetição de caracteres e representam isto através de seqüências de bits.

de caracteres e representam isto através de seqüências de bits. Apostila de Algoritmos e Estrutura de

3. Compressão Orientada a Caracter

As técnicas de compressão orientadas a caracter não são as mais eficientes ou as mais sofisticadas. Pelo contrário, em geral elas são utilizadas num primeiro nível de compressão multinível, onde os demais níveis podem ser técnicas estatísticas de compressão.

Aqui serão analisados 8 técnicas de compressão orientada a caracter, de modo a dar uma noção das possíveis aplicações deste tipo de compressão.

a) Supressão de Caracteres Nulos ou brancos

Nesta técnica, temos como objetivo comprimir apenas os caracteres nulos ou brancos. Para a compressão, utiliza-se a seguinte seleção de caracteres indicadores: C e N onde C e é um caracter especial (poderia ser o $, por exemplo) e N é o número (em binário) de caracteres brancos repetidos seqüencialmente.

Então, por exemplo, uma seqüência do tipo:

vazio.

Exatamente o que

pode ser comprimida como sendo:

vazio.$8Exatamente o que

o que demonstra uma redução de 6 bytes no trecho de texto apresentado.

Esta técnica pode ser extendida aos números nulos (zero). Ex.: A seqüência ficaria:

“#453João Silva$2789#4”

“000053João Silva

890000”

Outras formas de indicação de compressão podem ser utilizadas, com visto na seção anterior. Como trata-se de uma compressão de brancos, não há a necessidade de explicitação do caracter comprimido.

O algoritmo para a técnica apresentada é muito simples:

1. inicializa-se um contador para o cálculo do número de repetições de brancos;

2. lê-se o caracter do arquivo a comprimir;

3. verifica-se se o caracter é um branco;

4. se for, incrementa-se o contador e verifica-se se o número de caracteres ultrapassou a 255 (limite binário); se verdadeiro, colocam-se os caracteres indicadores no arquivo comprimido e volta-se ao passo 1, caso contrário, volta-se ao passo 2;

5. se não for, verifica-se se o contador é maior que 2, valendo a pena comprimir; se é, colocam-se os caracteres indicadores no arquivo comprimido, e volta-se ao passo 1, se não é, copiam-se os caracteres lidos no arquivo comprimido e volta-se ao passo 1.

O fluxograma para o algoritmo apresentado é mostrado a seguir:

para o algoritmo apresentado é mostrado a seguir: No caso de haver 350 caracteres brancos seguidos,

No caso de haver 350 caracteres brancos seguidos, utilizaria-se $255$95

b) Comprimento de Fileira

Também chamada de Run-length, esta técnica utiliza sempre uma combinação de 3 bytes para representar uma compactação. o formato é C e X N onde C e é o caracter especial, X é o caracter repetido e N é o número (binário) de repetições.

No caso de um caracter ser repetido mais do que 255 vezes, basta repetir a combinação C e X N, onde n será o número de repetições que ainda faltam para comprimir corretamente a cadeia original.

Funcionamento do algoritmo:

1. inicializa-se um contador de caracteres, destinado ao controle de cada caracter, buscando a verificação
1.
inicializa-se um contador de caracteres, destinado ao controle de cada caracter, buscando a verificação de existência de
repetição;
2.
inicializa-se um contador de repetições do caracter procurado;faz-se a leitura do caracter no arquivo a comprimir;
3.
incrementa-se o contador de caracteres;
4.
se o contador de caracteres for igual a 1, o caracter é armazenado e volta-se ao passo 3 para verificação de repetição;
5.
verifica-se se o caracter armazenado é o que procuramos; se for, incrementa-se o contador de repetições;
6.
verifica-se se o contador de repetição é maior ou igual a 4; se for menor, gravam-se os caracteres lidos no arquivo
comprimido e volta-se ao passo 1;
7.
realiza-se a gravação dos caracteres indicadores no arquivo comprimido e volta-se ao passo 1.
contcar = 0
contrep = 1
buffer = ''
procurado = caracter
Lê caracter
contcar = contcar +1
sim
contcar = 1?
coloca caracter no buffer
não
sim
caracter =
contrep = contrep + 1
procurado?
não
sim
contrep>= 4
saida = saida + '@' + procurado + chr(contrep)
não
buffer = caracter
saida = saida + '@' + buffer*contrep
contcar = contrep = 1
Ao terminar a leitura, joga-se o
que restou no buffer para a saída
c)
Mapeamento de Bit

Quando é sabida a existência de múltiplas ocorrências não consecutivas de determinado caracter no arquivo a comprimir, utiliza-se a compressão por mapeamento de bit. Esta técnica utiliza-se de um byte no arquivo comprimido para indicar, através dos bits, a ocorrência do caractere repetido.

Desta forma, caso desejarmos comprimir todos os caracteres a de um texto, devemos indicá-lo no mapa de bits. Cada mapa descreve 8 bytes, um por bit do arquivo manipulado. Portanto, para letra encontrada a cada trecho de 8 bytes, será assinalada no mapa de bits. Por exemplo:

abacate

será descrito como:

M

b bcte

onde M b é o mapa de bits correspondente à compressão do caracter a . Este mapa é composto, para o exemplo, dos bits:

a . Este mapa é composto, para o exemplo, dos bits: onde o primeiro zero indica

onde o primeiro zero indica a presença de um caracter a na primeira posição, valor um em seguida indica um caracter diferente, e assim por diante, até completar o mapa. Convenciona-se, portanto, que o bit 0 indica a presença do caracter a comprimir e o bit 1 a sua ausência.

O

algoritmo para esta técnica necessita do controle do mapa de bits:

1. inicializa-se o mapa de bits colocando todos os bits em zero;

2. inicializa-se o contador;

3. realiza-se a leitura do caracter no arquivo a comprimir;

4. compara-se o caracter lido com o caracter procurado; se forem o mesmo, então vai-se para o passo 6;

5. troca-se para 1 o bit da posição atual;

6. incrementa-se o contador;

7. verifica-se se o contador chegou a 8; se verdadeiro, então grava-se o mapa de bits e os caracteres diferentes do comprimido, e volta-se para o passo 1; senão, volta-se ao passo 3.

A

seguir é apresentado o fluxograma que descreve o algoritmo:

volta-se ao passo 3. A seguir é apresentado o fluxograma que descreve o algoritmo: d) Compactação

d)

Compactação de Meio Byte

Este tipo de compactação é utilizado quando encontramos uma seqüência de bits em comum nos caracteres de determinado arquivo. Um tipo de repetição de bits de grande ocorrência é a que acontece em caracteres numéricos. Por exemplo, se observarmos o conjunto binário da tabela ASCII para os caracteres numéricos, teremos:

da tabela ASCII para os caracteres numéricos, teremos: ou seja, se isolarmos os primeiros 4 bits

ou seja, se isolarmos os primeiros 4 bits de cada byte de caracter numérico, veremos que há uma constância de valores. Estes valores constantes são um exemplo de objetos de compactação de meio byte. A compactação de meio byte propriamente dita consiste na seguinte seqüência de caracteres indicadores:

consiste na seguinte seqüência de caracteres indicadores: onde Ce é o caracter especial, N é o

onde Ce é o caracter especial, N é o número (binário) de caracteres comprimidos e Cn é a metade do caracter comprimido. Na seqüência de caracteres indicadores apresentada são apresentados 5 caracteres porque este é o mínimo para que a compressão seja válida, uma vez que até 4 caracteres ela necessita um número de byte igual ou maior. Observe também que o número de repetições está ocupando apenas meio byte. Isso significa que ele pode indicar apenas até 16 caracteres, ou seja, este formato permite a compactação de uma seqüência de, no máximo, 16 caracteres.

Mas então surge a pergunta: não há como estender este formato para permitir a compactação de uma seqüência maior de caracteres? A resposta é sim, existe a compactação de byte inteiro , onde o número N ocupa 1 byte, ao invés de meio.

Neste formato estendido, o restante dos caracteres indicadores permanecem os mesmos, sendo alterado apenas a representação do número de caracteres. Com a representação de byte inteiro, a capacidade de compressão aumenta para 256 caracteres, o que permite um ganho expressivo em se tratando de longas seqüências de caracteres.

O

algoritmo para implementação da compactação de meio byte é o seguinte:

1. inicializa-se um contador de caracteres;

2. procede-se a leitura do caracter;

3. verifica-se se o caracter possui a seqüência de bits a comprimir;

4. caso esteja dentro do grupo a comprimir, incrementa-se o contador e volta-se ao passo 2;

5. verifica-se se o contador é maior que 4;

6. se não for, gravam-se os caracteres lidos no arquivo comprimido e volta-se ao passo 1, reiniciando o processo;

7. colocam-se os caracteres indicadores no arquivo comprimido e volta-se ao passo 1.

O

fluxograma para a representação do algoritmo é apresentado a seguir.

para a representação do algoritmo é apresentado a seguir. e) Codificação Diatômica Esta técnica de compressão

e) Codificação Diatômica

Esta técnica de compressão permite a representação de um par de caracteres em apenas um caracter especial. Normalmente utilizam-se tabelas com pares de caracteres e sua freqüência de ocorrência em determinado tipo de arquivo. Obviamente procura-se substituir os caracteres de maior freqüência, associando a cada dupla um caracter especial.

Em texto da língua portuguesa, por exemplo, duplas de ocorrência freqüente são a letra a acentuada com til seguido da letra o (ão) e a letra e com acento agudo seguido de um espaço em branco (é ). A cada uma dessas seqüência deve-se atribuir um caracter especial para nos permitir a compactação através da codificação diatômica. Obviamente, estes são apenas dois exemplos de duplas de caracteres, numa tabela normal para compactação utilizam-se mais de 20 duplas para que seja obtida uma compressão razoável.

Para a implementação da codificação diatômica segue-se o seguinte algoritmo:

1. lê-se um par de caracteres;

2. verifica-se sua existência na tabela de pares;

3. se exitente, coloca-se o caracter especial correspondente no arquivo comprimido e volta-se ao passo 1;

4. coloca-se apenas o primeiro caracter no arquivo comprimido;

5. desloca-se o segundo caracter para a primeira posição;

6. lê-se o próximo caracter e volta-se ao passo 2.

6. lê-se o próximo caracter e volta-se ao passo 2. O fluxograma correspondente é o seguinte:

O fluxograma correspondente é o seguinte:

f) Substituição de Padrões

A substituição de padrões é semelhante à codificação diatômica, pois também ocorre a substituição de um conjunto de

caracteres por um caracter especial. PALAVRA => C e

A utilização mais comum para este tipo de compressão é a de arquivos de programas de linguagens de programação. Uma

vez que as linguagens contém diversas palavras que se repetem freqüentemente em programas, utiliza-se esta característica para a sua compressão.

Uma variante da substituição de padrões para permitir a codificação de um maior número de palavras é a utilização de dois caracteres para indicação da ocorrência de determinada palavra:

C N

onde C é um caracter escolhido para indicar a compressão e N é o número (binário) da palavra a substituir. Isso permite a codificação de até 256 palavras reservadas, o que anteriormente era limitado ao número de caracteres especiais que poderíamos utilizar.

Por exemplo, as palavras reservadas

códigos

permitindo uma compressão considerável de um arquivo de programa.

begin e end
begin
e end

da linguagem Pascal poderiam ser, por exemplo, substituídas pelos

$1
$1

e

$2
$2

. As demais palavras reservadas da linguagem também poderiam ser codificadas desta maneira,

Como esta técnica assemelha-se muito à da codificação diatômica, deve tomar como base para a programação o algoritmo

e o fluxograma apresentados para aquela técnica. Desta forma não serão apresentados formas distintas de programação.

4. Compressão Estatística

A idéia da compressão estatística é realizar uma representação otimizada de caracteres ou grupos de caracteres. Caracteres

de maior freqüência de utilização são representados por códigos binários pequenos, e os de menor freqüência são representados por códigos proporcionalmente maiores.

Neste tipo de compressão portanto, não necessitamos saber qual caracter vai ser comprimido, mas é necessário, porém, ter

o conhecimento da probabilidade de ocorrência de todos os caracteres sujeitos à compressão. Caso não seja possível a

tabulação de todos os caracteres sujeitos à compressão, utiliza-se uma técnica adequada para levantamento estatístico dos dados a comprimir, formando tabelas de probabilidades.

Para sabermos como foi concebido este tipo de compressão, veremos na seção seguinte a Teoria da Harmonia, a partir da qual teremos uma noção de como se processa a compressão estatística.

4.1. Teoria da Harmonia

Esta Teoria baseia-se no princípio físico da Entropia. A Entropia é a propriedade de distribuição de energia entre os átomos, tendendo ao equilíbrio. Sempre que um sistema físico possui mais ou menos quantidade de energia que outro sistema físico em contato direto, há troca de energia entre ambos até que atinjam a entropia, ou seja, o equilíbrio da

quantidade de energia existente nos sistemas. Ao atingir o estado de equilíbrio, sabe-se que estes sistemas estão utilizando

o mínimo de energia possível para sua manutenção, e assim se manterão até que outro sistema interaja sobre eles.

Aplicada à informação, a Teoria da Entropia permite a concepção de uma teoria da Harmonia, ou seja, um ponto de equilíbrio onde a informação pode ser representada por uma quantidade mínima de símbolos. Para chegarmos a esta representação ideal, basta que tenhamos a quantidade de símbolos utilizada e a probabilidade de ocorrência deles. Com base nisso, é possível calcular a quantidade média de bits por intervalo de símbolo:

a quantidade média de bits por intervalo de símbolo: havendo n símbolos, cada qual com uma

havendo n símbolos, cada qual com uma probabilidade p . A representação de quantidades em binário é dada pela base 2 do logaritmo. Foi utilizado um valor n log n por sua proporcionalidade entre quantidade de informação e tempo (para mais detalhes, veja bibliografia indicada no capítulo A). Por outro lado, a fórmula apresentada é semelhante à utilizada para verificação da energia de um sistema físico, utilizada na Teoria da Entropia.

Na prática, a fórmula anteriormente apresentada permite-nos verificar se é possível a otimização da quantidade de bits utilizados para representação de determinado conjunto de símbolos. Duas representações podem ser comparadas para a verificação de qual ocupa menos bits em média. Isso nos permite concluir que é possível a criação de um método de compactação construído a partir da probabilidade de ocorrência de símbolos.

De fato, existem técnicas que utilizam a análise da probabilidade para compactação de dados. Estas são chamadas de estatísticas e serão analisadas nas seções seguintes.

4.2. Codificação Huffman

Esta técnica de compressão permite a representação em binário de caracteres a partir de sua probabilidade de ocorrência. Esta representação é gerada por um sistema de decodificação em árvore binária, o que impede a ambigüidade na análise

do código.

A ambigüidade, neste caso, refere-se a uma decodificação que permite a confusão com outros caracteres. Por exemplo,

determinado caracter C1 tem o código binário 01 e outro caracter C2 tem o código 0100, isto implica que, ao verificarmos

a seqüência binária para C2 poderemos estar interpretando como C1, ao serem lidos apenas os bits 01. Por isso, a

codificação Huffman utiliza o projeto em árvore binária para projeto dos bits que representam os caracteres, de forma que permitam uma decodificação única para cada caracter.

A codificação Huffman necessita de que cada caracter tenha um valor de probabilidade de ocorrência. A partir dos

caracteres de menor valor, começa a construção da árvore binária.

Suponha que após a análise estatística de um arquivo texto a ser compactado verificou-se que 91% dos dados contidos no arquivo sejam os caracteres IHFBDEGCA. Um configuração possível para a árvore de huffman então seria:

IHFBD | EGCA 91 % [Tene 95] 53 % I | HFBD 38 % E
IHFBD | EGCA
91 %
[Tene 95]
53 %
I | HFBD
38 %
E | GCA
28 %
HFB | D
E, 25%
GC | A
I, 15%
13 %
HF | B
D, 12%
G | C
A, 15%
H | F
B, 6%
G, 6%
C, 7%
H, 1%
F, 4%

A probabilidade do ramo é a soma das probabilidades das folhas (GC) 13% = 6% + 7% . Os valores binários serão

membros do código formado. Para a codificação dos próximos caracteres, basta continuarmos a construção da árvore.

A probabilidade final da árvore é sempre 1,0, uma vez que necessariamente deve-se atingir 100% das ocorrências de

caracteres, permitindo uma codificação total. Uma vez terminada a árvore, basta a formalização da codificação, que é feita com a leitura dos valores binários, da raiz para as folhas. Os valores binários lidos serão o código do percurso da raiz até a folha correspondente ao caracter que se deseja o código. A tabela de códigos fica a seguinte:

Tabela dos símbolos e freqüência:

Símbolo

Freqüência

Código

A

15

111

B

6

101

C

7

1101

D

12

011

E

25

10

F

4

01001

G

6

1100

H

1

01000

T

15

00

Desta forma, estão codificados os caracteres através da técnica de Huffman. Observe que o caracter de maior freqüência possui o menor valor. Isso é exatamente o objetivo da compressão estatística, uma vez que permite a substituição do caracter de maior ocorrência por apenas um bit. Assim acontece com os demais caracteres, em ordem crescente do número

de bits, conforme a prioridade.

Cabe salientar, por fim, que a codificação Huffman manteve a propriedade de permitir a decodificação direta, não permitindo que os bits da codificação de um caracter confundisse com a de outro.

4.3. Codificação Shannon-Fano

A forma desta técnica tem muitas semelhanças com a de Huffman. Necessita-se de uma tabela com a probabilidade de

ocorrência de cada caracter, e de um procedimento para a codificação em binário. Por outro lado, o procedimento para a codificação, diferentemente de Huffman, baseia-se na divisão de conjuntos de probabilidades para a obtenção do código binário.

Para a codificação, devemos ter os caracteres com suas probabilidades de ocorrência:

ter os caracteres com suas probabilidades de ocorrência: O passo seguinte é ordenar colocando os caracteres

O passo seguinte é ordenar colocando os caracteres de maior probabilidade no topo da tabela, até o menor, na base:

probabilidade no topo da tabela, até o menor, na base: Uma vez feito isso, divide-se a

Uma vez feito isso, divide-se a tabela em dois grupos cuja soma de probabilidades seja igual ou semelhante. No caso da tabela acima, serão obtidos dois grupos, um composto pelo caracter C1 e outro pelos demais. O primeiro grupo recebe como primeiro valor de código o binário 0 e o segundo recebe 1:

primeiro valor de código o binário 0 e o segundo recebe 1: Como para o primeiro

Como para o primeiro grupo não há ambigüidade em termos apenas um bit, vamos resolver o problema do segundo grupo. Para isso, repetimos o procedimento anterior, dividindo em dois subgrupos de probabilidades equivalentes. O caracter C2 forma o primeiro subgrupo e os demais formam o segundo. Mais uma vez vamos colocar 0 para distinguir o primeiro e 1 para o segundo:

colocar 0 para distinguir o primeiro e 1 para o segundo: Finalmente, para resolução da última

Finalmente, para resolução da última duplicidade, repete-se o processo, inserindo o binário 0 na seqüência do código de C1 e 1 para C3:

inserindo o binário 0 na seqüência do código de C1 e 1 para C3: Apostila de

Teoria dos Grafos

A teoria dos grafos estuda problemas computacionais que envolvem objetos conhecidos como grafos Os problemas

tornaram-se célebres porque ocorrem em diversas áreas da computação, da engenharia, e em muitas aplicações industriais.

Parte do material abaixo é um curso de algoritmos para grafos baseado no livro Algorithms in C: Graph Algorithms de R. Sedgewick.

Conceitos básicos

Um digrafo (digraph = directed graph) é composto por um conjunto de nodos e um conjunto de arestas, tal que:

- vértice (nó, ponto): representa uma entidade, como “uma pessoa”, “uma fruta” ou “um pedaço de terra”. - Arestas ou arcos (linha): é a relação que liga 2 nodos, tal como “irmão” que liga 2 “pessoas”.

Notação: G (n,a): onde G é o nome do grafo, n é o número de nodos e n é o número de arestas.

Exemplo: G(4,7) é um grafo com 4 nodos e 7 arestas:

1

3 2
3
2

4

G(4,7) é um grafo com 4 nodos e 7 arestas: 1 3 2 4 Grau =

Grau = 3

é um grafo com 4 nodos e 7 arestas: 1 3 2 4 Grau = 3

Uma boa maneira de especificar um digrafo é exibir seu conjunto de arcos. O Conjunto de arcos do digrafo abaixo é:

b g a d f c
b
g
a d
f
c

a-a, a-b, a-d, a-c, c-d, d-f, f-c, f-g. Nota-se que a-b indica um arco do sentido de a até b.

Digrafo simétrico

O grafo é simétrico quando se cada um dos arcos é anti-paralelo a outro arco. Por exemplo, se existir o arco 0-5, deve

existir o arco 5-0.

Grafo

São os tipos especiais de digrafos que são não orientado. Para simplificação, utilizaremos a partir de agora a notação grafo para ambos (grafos e digrafos).

Grau de um vértice

O grau de entrada e de saída de um vértice em um grafo é determinado pela quantidade de arestas que incidem sobre ele e

a quantidade de arcos que saem dele, respectivamente.

4 3 12 B 15 D G A F C 6 8 3
4
3
12
B
15
D
G
A
F
C
6
8
3

30

Exemplo: os nodos são cidades e os arcos contém a distância entre duas

cidades:

Apostila de Algoritmos e Estrutura de Dados III

Representações computacionais de grafos

Considerando o grafo abaixo:

g 2 6 d 1 4 5 c b 3 f a h e
g
2
6
d
1 4
5
c
b
3
f
a
h
e

As seguintes representações são possíveis:

a) Matriz de adjacência:

1 2 3 4 5 6 1 2 3 4 5 6
1
2
3
4
5
6
1
2
3
4
5
6

Estrutura

Um digrafo será representado por uma struct graph que contém o número de vértices, o número de arcos e a matriz de adjacência do digrafo.

/* A estrutura digraph representa um digrafo. O campo adj é um ponteiro para a matriz de adjacência do digrafo. O campo V contém o número de vértices e o campo A contém o número de arcos do digrafo.*/

struct graph { int V; int A; int **adj;

};

/* Um objeto do tipo Graph contém o endereço de um graph.*/

typedef struct graph *Graph;

/* Pode-se utilizar o nome digraph ou graph conforme a preferência*/

#define digraph graph;

#define Digraph Graph;

b) Matriz de incidência:

A[i,j]=

 

a

b

c

d

e

f

g

h

1

-1

1

-1

-1

0

0

0

0

2

               

3

               

4

               

5

               

6

               

c) Lista de adjacência

Exemplo de Aplicação de grafos

Exemplo de Aplicação de grafos Kaliningrad, antigamente Königsberg é uma cidade do oeste da Rússia, junto

Kaliningrad, antigamente Königsberg é uma cidade do oeste da Rússia, junto ao Rio Pregel. A capital de Kaliningrad, Oblast, é o um grande centro industrial e comercial, ligado por um canal com Baltiysk, um porto no Mar Báltico. O matemático Leonardo euler (1707-1786) deu início ao estudo de grafos ao solucionar o problema das pontes de Konisberg, ao provar que não era possível atravessar todas as pontes usando um único caminho.

O problema das "Pontes de Königsberg ".

"O rio Pregel tem duas ilhas. Estas estão unidas por uma ponte. Uma ilha tem uma ponte que a une a ambas as margens; a outra tem duas pontes para cada margem. Podem os cidadãos de Conisberga atravessar todas as sete pontes num só passeio contínuo? " Ele fundou sem saber a base da teoria dos grafos

Resposta:

Ele fundou sem saber a base da teoria dos grafos Resposta: Se procuramos um caminho contínuo,
Ele fundou sem saber a base da teoria dos grafos Resposta: Se procuramos um caminho contínuo,

Se procuramos um caminho contínuo, sem passar duas vezes pela mesma ponte, a resposta é negativa. Vejamos uma justificação simples: vamos substituir o desenho por um correspondente diagrama, ou seja, um modelo do nosso problema; fazendo corresponder a cada massa de terra um ponto e a cada ponte uma linha, ligando de seguida os pontos apropriados.

A chave do problema reside no fato de cada ponto ou é um ponto de partida, de chegada ou de passagem. Se um ponto tem um número ímpar de caminhos terá de ser um ponto de partida ou de chegada. Para fazermos um caminho contínuo temos de passar por todos os pontos, pelo que os que não são de partida ou de chegada têm de possuir um número par de caminhos, pois se chegamos temos de partir.

No problema colocado temos quatro pontos com um número ímpar de caminhos, quer isto dizer, que temos três pontos que não podem ser de passagem. Logo não é possível efetuar o caminho contínuo proposto.

De um modo geral e intuitivamente, concluímos que:

os habitantes só podiam efectuar um caminho contínuo se todos os pontos tivessem um número par de caminhos;

não tendo todos um número par de caminhos, apenas dois podiam ter um número ímpar, um seria o ponto de partida o outro o de chegada.

Grafos Eulerianos

O estudo de Leunard Euler deu origem à trajetória euleriana:

um caminho completo, que passe por todas as arestas de um grafo, sem retraçar nenhuma aresta. Exemplos

c d a b
c d
a
b
c d e f a b
c
d
e
f
a b

Grafos Hamiltoniano

O matemático irlandês Willian Powan Hamilton, criou uma trajetória um pouco diferente da trajetória euleriana. Deve-se aqui passar por todos os vértices do grafo sem repetir nenhum vértice, começando e terminando no mesmo vértice. Um grafo euleriano é apresentado abaixo:

Um clássico problema hamiltoniano é o problema do caixeiro-viajante. Nesse problema, um caixeiro viajante deve visitar várias cidades e retornar ao ponto de partida. Como podem haver vários ciclos Hamiltonianos, o objetivo aí pode ser encontrar o ciclo de menor peso total. Esse é um algoritmo denominado NP Completo. Não existe algoritmo eficiente para isso. Para 9 cidades, por exemplo, seriam necessárias 8! combinações (o que nos daria 40320 caminhos). Veja o exemplo abaixo com 5 cidades:

d c d a b
d
c d
a
b

Exercícios. Indique para os grafos abaixo se existe pelo menos 1 caminho Euleriano e 1 hamiltoniano para cada um:

c d e f a b
c
d
e
f
a
b
c d g e f a b
c d
g
e f
a b

E

H

E

H

d c d a b
d
c d
a
b
d c d a E H b
d
c
d
a
E
H
b

E

H

b c d d a
b
c
d
d
a

E

H

Algoritmos para grafos + conhecidos e a sua aplicação

a) Dijkstra

Um dos algoritmos mais conhecidos de grafos é o dijkstra. É o algoritmo mais eficiente quando se deseja calcular o melhor caminho para seguir em um grafo ponderado. O funcionamento é o seguinte. Escolhe-se a origem (um vértice qualquer do grafo que representaria no caso uma cidade) e a partir daí, o algoritmo retorna a menor distância desta origem para cada um de todos os outros vértices existentes. Ou seja, se a cidade origem é o nodo 0, por exemplo, o algoritmo monta uma árvore mínima com o menor caminho possível para cada um dos outros nodos. Vejamos o exemplo a seguir, considerando o mapa abaixo:

6

11

0

1

41

0

5

29

3

0

45

3

5

38

5

1

29

5

4

21

4

3

36

1

4

32

4

2

32

2

3

50

1

2

51

1

0

5

Obs.: A explicação do Dikstra será feita em sala de aula, no quadro-negro, apresentando os três vetores: cst, from e parnt

Aplicação do Dijkstra - Maratona de Programação da SBC – ACM ICPC –2006 página 6

Problema C

Países em Guerra

Nome do arquivo fonte: paises.c, paises.cpp, paises.java

No ano 2050, após diversas tentativas da ONU de manter a paz no mundo, explode a terceira guerra mundial. Segredos

industriais, comerciais e militares obrigaram todos os países a utilizar serviços de espionagem extremamente sofisticados,

de forma que em cada cidade do mundo há ao menos um espião de cada país. Esses espiões precisam se comunicar com

outros espiões, com informantes e mesmo com as suas centrais durante as suas ações. Infelizmente não existe uma forma segura de um espião se comunicar em um período de guerra, então as mensagens são sempre enviadas em código para que somente o destinatário consiga ler a mensagem e entender o seu significado.

Os espiões utilizam o único serviço que funciona no período de guerra, os correios. Cada cidade possui uma agência postal onde as cartas são enviadas. As cartas podem ser enviadas diretamente ao seu destino ou a outras agências postais, até que a carta chegue à agência postal da cidade de destino, se isso for possível.

Uma agência postal na cidade A pode enviar diretamente uma carta impressa para a agência postal da cidade B se houver um acordo de envio de cartas, que determina o tempo, em horas, que uma carta leva para chegar da cidade A à cidade B (e não necessariamente o contrário). Se não houver um acordo entre as agências A e B, a agência A pode tentar enviar a carta a quantas agências for necessário para que a carta chegue ao seu destino, se isso for possível.

Algumas agências são interligadas por meios eletrônicos de comunicação, como satélites e fibras ópticas. Antes da guerra, essas ligações atingiam todas as agências, fazendo com que uma carta fosse enviada de forma instantânea, mas durante o período de hostilidades cada país passou a controlar a comunicação eletrônica e uma agência somente pode enviar uma carta à outra agência por meio eletrônico (ou seja, instantaneamente) se ela estiver no mesmo país. Duas agências, A e B, estão no mesmo país se houver uma forma de uma carta impressa enviada de uma das agências ser entregue na outra agência.

O serviço de espionagem do seu país conseguiu obter o conteúdo de todos os acordos de envios de mensagens existentes

no mundo e deseja descobrir o tempo mínimo para se enviar uma carta entre diversos pares de cidades. Você seria capaz

de ajudá-lo?

Entrada

A entrada contém vários casos de teste. A primeira linha de cada caso de teste contém dois inteiros separados por um

espaço, N (1≤ N≤ 500) e E (0≤ E≤ N 2 ), indicando o número de cidades (numeradas de 1 a N) e de acordos de envio de mensagens, respectivamente. Seguem-se, então, E linhas, cada uma com três inteiros separados por espaços, X, Y e H (1≤ X, Y≤ N, 1≤ H≤ 1000), indicando que existe um acordo para enviar uma carta impressa da cidade X à cidade Y , e que tal carta será entregue em H horas.

Em seguida, haverá uma linha com um inteiro K (0≤ K≤ 100), o número de consultas. Finalmente, virão K linhas, cada uma representando uma consulta e contendo dois inteiros separados por um espaço, O e D (1≤ O,D≤ N). Você deve determinar o tempo mínimo para se enviar uma carta da cidade O à cidade D.

O final da entrada é indicado por N = 0.

A entrada deve ser lida da entrada padrão.

Saída

Para cada caso de teste da entrada seu programa deve produzir K linhas na saída. A I-ésima linha deve conter um inteiro M, o tempo mínimo, em horas, para se enviar uma carta na I-ésima consulta. Se não houver meio de comunica¸cão entre

as cidades da consulta, você deve imprimir ”Nao e possivel entregar a carta” (sem acentos).

Imprima uma linha em branco após cada caso de teste.

A saída deve ser escrita na saída padrão.

Exemplo de entrada

Saída para o exemplo de entrada

4

5

0

1 2

5

6

2 1 10

6

3

4

8

0

4

3

7

Nao e possivel entregar a carta

2

3

6

5

10

1

2

Nao e possivel entregar a carta

1

3

0

1

4

4

3

4

1

3

3

1

2 10

2

3

1

3

2

1

3

1

3

3

1

3

2

0

0

Resolução

a) Interpretação

Como muitas vezes já foi falado em sala de aula, a resolução de um problema já começa na sua correta interpretação. Pois bem, 90% de quem ler o enunciado fará uma interpretação errada do problema. Existe uma frase no meio do texto que diz

o seguinte:

Duas agências, A e B, estão no mesmo país se houver uma forma de uma carta impressa enviada de uma das agências ser entregue na outra agência.

Pois bem. Desta forma, independendo da distância, se houver uma ligação de ida e volta entre duas cidades, isso significa que elas estão no mesmo país, e portanto, o custo de tempo de entrega sempre será 0 (zero), pois ela será entregue instantaneamente por meio eletrônico.

b) Entrada

Feita a interpretação correta do exercício, o que não é uma tarefa muito fácil passamos para o próximo passo, que é analisar as entradas do problema

“A entrada contém vários casos de teste. A primeira linha de cada caso de teste contém dois inteiros separados por um espaço, N (1≤ N≤ 500) e E (0≤ E≤ N 2 ), indicando o número de cidades (numeradas de 1 a N) e de acordos de envio de mensagens, respectivamente.”

Bem, neste caso temos cidades (N) e acordos (E) que no algoritmo chamaremos de cidades e acordos para facilitar a compreenção, sendo que os seguintes valores são possíveis para cada uma das variáveis:

int cidades ( 1 até 500), acordos ( 0 até 250.000)

Exemplo de entrada

4

5

“Seguem-se, então, E linhas, cada uma com três inteiros separados por espaços, X, Y e H (1≤ X, Y≤ N, 1≤ H≤ 1000), indicando que existe um acordo para enviar uma carta impressa da cidade X à cidade Y , e que tal carta será entregue em H horas.”

Bem, então considerando o número de acordos (5 para o exemplo), para cada um deles teremos a origem (X), o destino (Y)

e tempo (H).

int origem ( mínimo 1 ), destino (<=500), tempo (entre 1 e 1000)

Exemplo de entrada

4

5

1

2

5

2

1 10

3

4

8

4

3

7

2

3

6

1 5 2 10 8 3 4 7 6
1
5
2
10
8
3
4
7
6

Obs.: conforme explicado anteriormente, as cidades 1 e 2 estão em 1 mesmo país, o mesmo valendo para as cidades 3 e 4.

Em seguida, haverá uma linha com um inteiro K (0≤ K≤ 100), o número de consultas. Finalmente, virão K linhas, cada uma representando uma consulta e contendo dois inteiros separados por um espaço, O e D (1≤ O,D≤ N). Você deve determinar o tempo mínimo para se enviar uma carta da cidade O à cidade D.

int consultas (de 0 a 100)

uma vez que após a entrada anterior, estas variáveis estão disponíveis

//Neste caso, pode-se utilizar a mesma variável origem e destino, para O e D respectivamente,

Exemplo de entrada

4 5 1 2 5 2 1 10 3 4 8 4 3 7 2
4
5
1
2
5
2
1 10
3
4
8
4
3
7
2
3
6
5
1
2
1
3
1
4
4
3
4
1

Saída.: neste caso, convorme o mapa acima, fica fácil definir as saídas:

0

(a cidade 1 e a cidade 2 estão no mesmo país)

6

(existem 6 horas de tempo da cidade 2 p/ a 3)

6

(existem 6 horas de tempo da cidade 2 p/ a 3)

0

(a cidade 4 e a cidade 3 estão no mesmo país)

Nao e possivel entregar a carta

1 0 2 0 6 0 3 4 0
1 0
2
0
6
0
3
4
0

Programa

a) Declaração do Grafo

A estrutura digraph representa um digrafo. O campo adj é um ponteiro para a matriz de adjacência do digrafo. O campo Vertices contém o número de Vértices e o campo A contém o número de arcos do digrafo. */

struct digraph { int V; int A; int adj[501][501];

};

typedef struct digraph *Digraph;

Algumas definições utilizadas:

- #define maxV 501

- #define maxCST 999999

- #define INFINITO 999999

- #define Vertices int

- #define graph digraph

- #define Graph Digraph

>> Para facilitar, optou-se em não utilizar as posições 0 da matriz adj, por isso a definição com 501 espaços, ao invés de 500

b) Variáveis declaradas no programa principal

int main(void){ Digraph D; D= new (struct digraph);

int i,j,cidades,acordos,consultas,Origem,Destino,Tempo; Vertices parnt[501]; // Vetor utilizado no algoritmo de Dijkstra

double cst[501];

// Vetor utilizado no algoritmo de Dijkstra

// O vetor fr[501] “from” é declarado dentro da função Dijkstra

c) Após ler as cidades e acordos deve-se inicializar a estrutura digrap:

cin >> cidades; cin >> acordos; while (cidades){ // Inicialização da estrutura digraph, colocando INFINITO // para todos as posições da matriz de adjacência (adj) for (i=0;i<=cidades;i++) for (j=0;j<=cidades;j++) D->adj[i][j]=INFINITO;

d)

Colocação das distâncias na matriz de adjacência

// Colocação de todas as distâncias entre as cidades (tempos) // para todos as posições da matriz de adjacência (adj)

// IMPORTANTE: Se existir rota de ida e de volta entre 2 // agências, deve-se considerar que elas estão no mesmo país, e // a distância entre elas neste caso deve ser = ZERO (*%)

for (i=0; i<acordos; i++){ cin >> Origem;

cin >> Destino; cin >> Tempo; if (D->adj[Destino][Origem]!=INFINITO) {

D->adj[Origem][Destino]=0;

//*%

D->adj[Destino][Origem]=0;

//*%

}

else

D->adj[Origem][Destino]=Tempo;

}

D->V=cidades+1;

// para vértices irem de 1 até 500, e não de 0 até 499

D->A=acordos;

e) Por fim, as consultas das horas que levaria para chegar a carta de uma cidade à

outra, e a impressão de uma linha em branco no final

cin >> consultas;

for (int i=0; i< consultas; i++){ cin >> Origem; cin >> Destino; Dijkstra (D,Origem,parnt,cst,Destino);

}

cout << endl;

f) algoritmo de dijkstra (adaptado no final para mostrar as mensagens que o

programa exige

void Dijkstra (Digraph G, Vertices s, Vertices parnt[], double cst[], Vertices Destino) { Vertices w, w0, fr[maxV]; for (w = 0; w < G->V; w++) { parnt[w] = -1; cst[w] = maxCST;

}

fr[s] = s; cst[s] = 0.0;

while (1) { double mincst = maxCST; for (w = 0; w < G->V; w++) { if (parnt[w] == -1 && mincst > cst[w]) { mincst = cst[w0=w];

}

}

if (mincst == maxCST) break; parnt[w0] = fr[w0];

for (w = 0; w < G->V; w++) if (cst[w] > cst[w0] + G->adj[w0][w]) { cst[w] = cst[w0] + G->adj[w0][w]; fr[w] = w0;

}

}

// Adaptação para este problema em específico if (cst[Destino] < INFINITO) cout << cst[Destino]; else cout << "Nao e possivel entregar a carta" ; cout << endl;

}

Exercício: implemente o programa acima e com a entrada real de teste (paises.in), confira quanto tempo irá demorar para executar todos os casos de teste.

b) Floyd

Igualmente ao dijkstra, esse algoritmo faz uma matriz de custo do grafo. Ou seja, ele verifica a distância entre cada par de vértices. Se existir uma aresta, o valor que ele coloca naquela posição da matriz é o custo da aresta. Se não existir uma aresta entre o par de vértice, ele coloca o valor .

Em seguida, ele verifica se existe um caminho de menor custo entre cada par de vértices, ao passar por um vértice intermediário. Ou seja, suponha um grafo com 5 vértices. De um modo geral, após montar a matriz de distâncias, ele fará 5 iterações:

1ª. Iteração: descobrir se há caminhos que ficam menores ao passar pelo vértice 1

2ª. Iteração: descobrir se há caminhos que ficam menores ao passar pelo vértice 2

3ª. Iteração: descobrir se há caminhos que ficam menores ao passar pelo vértice 3

4ª. Iteração: descobrir se há caminhos que ficam menores ao passar pelo vértice 4

5ª. Iteração: descobrir se há caminhos que ficam menores ao passar pelo

Grafo exemplo para entrada

2 2 1 3 4 3 4 5 6 3 8 2 20 6 3
2
2
1
3
4
3
4
5
6
3
8
2
20
6
3

a) Matriz de adjacência:

1 2 3 4 5 6 1 2 3 4 5 6
1
2
3
4
5
6
1
2
3
4
5
6

void digraph::floyd (void) { // Para caso real substitua o 6 por v (vértices) for (int k=1; k <= 6; k++) { // k <= D->V for (int i=1; i <= 6; i++) { for (int j=1; j <= 6; j++){ if ( (D->adj[j][k]+D->adj[k][i] < D->adj[j][i]) ) { D->adj[j][i]=D->adj[j][k]+D->adj[k][i];

j,k + k,i < j,i 1,1 + 1,4 < 1,4

} // 2,1 + 1,1 < 2,1-> 2,1 + 1,2 < 2,2-> 2,1 + 1,3 < 2,3-> 2,1 + 1,4 < 2,4

3,1 + 1,4 < 3,4 4,1 + 1,4 < 4,4 5,1 + 1,4 < 5,4 6,1 + 1,4 < 6,4

}// j,k + k,i < j,i

} // 1,1 + 1,1 < 1,1

j,k + k,i < j,i

1,1 + 1,2 < 1,2

3,1 + 1,2 < 3,2 4,1 + 1,2 < 4,2 5,1 + 1,2 < 5,2 6,1 + 1,2 < 6,2

j,k + k,i < j,i 1,1 + 1,3 < 1,3

3,1 + 1,3 < 3,3 4,1 + 1,3 < 4,3 5,1 + 1,3 < 5,3 6,1 + 1,3 < 6,3

} // 3,1 + 1,1 < 3,1

} // 4,1 + 1,1 < 4,1 // 5,1 + 1,1 < 5,1 // 6,1 + 1,1 < 6,1

// 1,2 + 2,1 < 1,1

// 2,2 + 2,1 < 2,1-> 2,2 + 2,2 < 2,2-> 2,2 + 2,3 < 2,3-> 2,2 + 2,4 < 2,4

// 3,2 + 2,1 < 3,1 // 4,2 + 2,1 < 4,1 // 5,2 + 2,1 < 5,1 // 6,2 + 2,1 < 6,1

1,2 + 2,4 < 1,4

1,2 + 2,2 < 1,2

3,2 + 2,2 < 3,2 4,2 + 2,2 < 4,2 5,2 + 2,2 < 5,2 6,2 + 2,2 < 6,2

1,2 + 2,3 < 1,3

3,2 + 2,3 < 3,3 4,2 + 2,3 < 4,3 5,2 + 2,3 < 5,3 6,2 + 2,3 < 6,3

3,2 + 2,4 < 3,4 4,2 + 2,4 < 4,4 5,2 + 2,4 < 5,4 6,2 + 2,4 < 6,4

1,3 + 3,4 < 1,4

// 2,3 + 3,1 < 2,1-> 2,3 + 3,2 < 2,2-> 2,3 + 3,3 < 2,3-> 2,3 + 3,4 < 2,4

// 3,3 + 3,1 < 3,1 // 4,3 + 3,1 < 4,1 // 5,3 + 3,1 < 5,1 // 6,3 + 3,1 < 6,1

// 1,3 + 3,1 < 1,1

1,3 + 3,2 < 1,2

3,3 + 3,2 < 3,2 4,3 + 3,2 < 4,2 5,3 + 3,2 < 5,2 6,3 + 3,2 < 6,2

1,3 + 3,3 < 1,3

3,3 + 3,3 < 3,3 4,3 + 3,3 < 4,3 5,3 + 3,3 < 5,3 6,3 + 3,3 < 6,3

3,3 + 3,4 < 3,4 4,3 + 3,4 < 4,4 5,3 + 3,4 < 5,4 6,3 + 3,4 < 6,4

1,4 + 4,4 < 1,4

// 2,4 + 4,1 < 2,1-> 2,4 + 4,2 < 2,2-> 2,4 + 4,3 < 2,3-> 2,4 + 4,4 < 2,4

// 3,4 + 4,1 < 3,1 // 4,4 + 4,1 < 4,1 // 5,4 + 4,1 < 5,1 // 6,4 + 4,1 < 6,1

// 1,4 + 4,1 < 1,1

1,4 + 4,2 < 1,2

3,4 + 4,2 < 3,2 4,4 + 4,2 < 4,2 5,4 + 4,2 < 5,2 6,4 + 4,2 < 6,2

1,4 + 4,3 < 1,3

3,4 + 4,3 < 3,3 4,4 + 4,3 < 4,3 5,4 + 4,3 < 5,3 6,3 + 4,3 < 6,3

3,4 + 4,4 < 3,4 4,4 + 4,4 < 4,4 5,4 + 4,4 < 5,4 6,4 + 4,4 < 6,4

1,5 + 5,4 < 1,4

// 2,5 + 5,1 < 2,1-> 2,5 + 5,2 < 2,2-> 2,5 + 5,3 < 2,3-> 2,5 + 5,4 < 2,4

// 3,5 + 5,1 < 3,1 // 4,5 + 5,1 < 4,1 // 5,5 + 5,1 < 5,1 // 6,5 + 5,1 < 6,1

// 1,5 + 5,1 < 1,1

1,5 + 5,2 < 1,2

3,5 + 5,2 < 3,2 4,5 + 5,2 < 4,2 5,5 + 5,2 < 5,2 6,5 + 5,2 < 6,2

1,5 + 5,3 < 1,3

3,5 + 5,3 < 3,3 4,5 + 5,3 < 4,3 5,5 + 5,3 < 5,3 6,5 + 5,3 < 6,3

3,5 + 5,4 < 3,4 4,5 + 5,4 < 4,4 5,5 + 5,4 < 5,4 6,5 + 5,4 < 6,4

1,6 + 6,4 < 1,4

// 2,6 + 6,1 < 2,1-> 2,6 + 6,2 < 2,2-> 2,6 + 6,3 < 2,3-> 2,6 + 6,4 < 2,4

// 3,6 + 6,1 < 3,1 // 4,6 + 6,1 < 4,1 // 5,6 + 6,1 < 5,1 // 6,6 + 6,1 < 6,1

// 1,6 + 6,1 < 1,1

1,6 + 6,2 < 1,2

3,6 + 6,2 < 3,2 4,6 + 6,2 < 4,2 5,6 + 6,2 < 5,2 6,6 + 6,2 < 6,2

1,6 + 6,3 < 1,3

3,6 + 6,3 < 3,3 4,6 + 6,3 < 4,3 5,6 + 6,3 < 5,3 6,6 + 6,3 < 6,3

3,6 + 6,4 < 3,4 4,6 + 6,4 < 4,4 5,6 + 6,4 < 5,4 6,6 + 6,4 < 6,4

Exercício: com base na resolução do problema paises.cpp, implemente a resolução de duas outras maneiras:

a) modifique o fonte original que utiliza o dijkstra utilizando classes ao invés de structs (baseie-se no fonte abaixo)

b) implemente a mesma resolução utilizando agora o floyd e compare o tempo de execução dos 2.

// Aplicação do algoritmo de Floyd para encontrar todos os melhores caminhos #include <iostream> #include <iomanip> #define INFINITO 999999 #define Vertex int #define graph digraph

using namespace std; /* A estrutura digraph representa um digrafo. adj é a matriz de adjacência do digrafo. O campo Vertices contém o núm. de Vértices e o campo A contém o núm. de arcos do digrafo */ class digraph { public:

int V; int A; int adj[501][501]; void floyd (void);

};

digraph *D;

/* Um objeto do tipo Digraph contém o endereço de um digraph. */

void digraph::floyd (void) { // Para caso real substitua o 6 por v (vértices)

int main(void){ D= new (class digraph); int i,j,cidades,acordos,consultas,Origem,Destino,Tempo; cin >> cidades;

c) Bellman-Ford

O Algoritmo de Bellman-Ford, igualmente ao Dijkstra, é um algoritmo de busca de caminhos mínimos em um digrafo ponderado, ou seja, cujas arestas têm peso, inclusive negativo. O Algoritmo de Dijkstra resolve o mesmo problema em um tempo muito menor, porém exige que todas as arestas tenham pesos positivos. Portanto, o algoritmo de Bellman-Ford é normalmente usado apenas quando existem arestas de peso negativo.

// Aplicação do algoritmo de Bellman-Ford no problema dos países em guerra #include <iostream> #include <cstdio>

using namespace std;

#define INFINITY 99999999

typedef struct { int source; int dest; int weight; } Edge;

void BellmanFord(Edge edges[], int edgecount, int nodecount, int source, int dest){ int i,j; int distance[501]; for (i = 0; i < nodecount; i++) { if (i == source) distance[i] = 0; else distance[i] = INFINITY;

}

for (i = 0; i < nodecount; i++) { for (j = 0; j < edgecount; j++) { if (distance[edges[j].dest] > distance[edges[j].source] + edges[j].weight){ distance[edges[j].dest] = distance[edges[j].source] + edges[j].weight;

}

}

}

if (distance[dest] < 100000) cout << distance[dest]; else cout << "Nao e possivel entregar a carta"; cout << endl;

}

int main (void){ Edge Arestas[25000]; int cidades, acordos,consultas, org , dest;

while (1){ cin >> cidades; cin >> acordos; if (cidades==0) break;

for (int i=0; i < acordos; i++){ cin >> Arestas[i].source; cin >> Arestas[i].dest; cin >> Arestas[i].weight;

}

for (int i=0; i < acordos; i++){ for (int j=i; j < acordos; j++){ if ((Arestas[j].source==Arestas[i].dest)&&(Arestas[j].dest==Arestas[i].source))

Arestas[i].weight=Arestas[j].weight=0;

}

}

cin >> consultas; for (int i=0; i < consultas; i++){ cin >> org; cin >> dest;

BellmanFord(Arestas,acordos,cidades+1,org,dest);

}

cout << endl;

}

return(0);

}

Exercício: Implemente o Algoritmo de Bellman-Ford, e compare a performance com o Floyd e Dijkstra comparando o tempo necessário para rodar a entrada padrão paises.in.