126
PONTIFÍCIA UNIVERSIDADE CATÓLICA DE MINAS GERAIS Departamento de Sistemas de Informação – Contagem ALGORITMOS E TÉCNICAS DE PROGRAMAÇÃO III MARCOS ANDRÉ S. KUTOVA 2003

Projeto de Algoritmo

  • Upload
    dracco

  • View
    232

  • Download
    4

Embed Size (px)

DESCRIPTION

Apostila sobre PA

Citation preview

Page 1: Projeto de Algoritmo

PONTIFÍCIA UNIVERSIDADE CATÓLICA DE MINAS GERAIS Departamento de Sistemas de Informação – Contagem

ALGORITMOS E TÉCNICAS DE PROGRAMAÇÃO III

MARCOS ANDRÉ S. KUTOVA

2003

Page 2: Projeto de Algoritmo

2

ÍNDICE

1 OPERAÇÕES COM ARQUIVOS ................................................................................................................... 4 1.1 Classes iostream......................................................................................................................................... 4 1.2 Arquivos texto............................................................................................................................................ 4 1.3 A função open ............................................................................................................................................ 6 1.4 Gravação de objetos ................................................................................................................................... 7 1.5 Ponteiros de posição ................................................................................................................................ 10 1.6 Condições de erro .................................................................................................................................... 11

2 MEMÓRIA SECUNDÁRIA........................................................................................................................... 13 2.1 Discos....................................................................................................................................................... 13 2.2 Organização dos discos............................................................................................................................ 13 2.3 Estimativa de capacidade/espaço necessários .......................................................................................... 14 2.4 Organização de discos por setor............................................................................................................... 15 2.5 Organização de discos por blocos definidos pelo usuário........................................................................ 17 2.6 Overhead (espaço sem dados).................................................................................................................. 18 2.7 Custo de acesso a disco............................................................................................................................ 19 2.8 Disco como gargalho ............................................................................................................................... 22 2.9 Fita magnética.......................................................................................................................................... 23 2.10 A organização física do CD – ROM (Compact Disc, Read-Only Memory) ............................................ 25 2.11 Jornada de um byte .................................................................................................................................. 27 2.12 Gerenciamento de buffer.......................................................................................................................... 27

3 ORGANIZAÇÃO DE ARQUIVOS ............................................................................................................... 29 3.1 Organização dos campos.......................................................................................................................... 29 3.2 Organização dos registros ........................................................................................................................ 32

4 ARQUIVOS SEQUENCIAIS ......................................................................................................................... 34 4.1 Introdução ................................................................................................................................................ 34 4.2 Acesso a um registro................................................................................................................................ 34 4.3 Pesquisa seqüencial.................................................................................................................................. 35 4.4 Pesquisa binária ....................................................................................................................................... 37 4.5 Inclusão de um novo registro ................................................................................................................... 40

5 ORDENAÇÃO DE ARQUIVOS.................................................................................................................... 41 5.1 Introdução ................................................................................................................................................ 41 5.2 Intercalação balanceada de vários caminhos............................................................................................ 42 5.3 Implementação através de seleção por substituição ................................................................................. 43 5.4 Considerações práticas............................................................................................................................. 45

6 ARQUIVOS INDEXADOS............................................................................................................................. 48 6.1 Introdução ................................................................................................................................................ 48 6.2 Índices...................................................................................................................................................... 48 6.3 Tipos de índices ....................................................................................................................................... 49 6.4 Operações................................................................................................................................................. 50

7 ÁRVORES DE BUSCA BINÁRIA ................................................................................................................ 55 7.1 Introdução ................................................................................................................................................ 55 7.2 Operações................................................................................................................................................. 56 7.3 Árvores balanceadas ................................................................................................................................ 61

8 ÁRVORES B.................................................................................................................................................... 65 8.1 Introdução ................................................................................................................................................ 65 8.2 Busca........................................................................................................................................................ 66 8.3 Inserção.................................................................................................................................................... 67 8.4 Remoção .................................................................................................................................................. 67 8.5 Árvore B* ................................................................................................................................................ 69 8.6 Árvore B+ ................................................................................................................................................ 69

Page 3: Projeto de Algoritmo

3

9 ARQUIVOS SEQUENCIAIS INDEXADOS ................................................................................................ 71 9.1 Introdução ................................................................................................................................................ 71 9.2 Índices...................................................................................................................................................... 71 9.3 Área de Extensão (Área de Overflow) ..................................................................................................... 75 9.4 Operações: ............................................................................................................................................... 76

10 ARQUIVOS DIRETOS................................................................................................................................... 78 10.1 Introdução ................................................................................................................................................ 78 10.2 Colisões.................................................................................................................................................... 78 10.3 Exemplo de função hash .......................................................................................................................... 79 10.4 Distribuição dos registros......................................................................................................................... 79 10.5 Previsão da distribuição ........................................................................................................................... 81 10.6 Memória extra necessária......................................................................................................................... 82 10.7 Tratamento de colisões............................................................................................................................. 83 10.8 Operações em arquivos diretos ................................................................................................................ 85

11 ARQUIVOS INVERTIDOS ........................................................................................................................... 86 11.1 Inserção de um registro ............................................................................................................................ 86 11.2 Exclusão de um registro........................................................................................................................... 86 11.3 Atualização de um registro ...................................................................................................................... 87 11.4 Busca utilizando uma combinação de chaves secundárias....................................................................... 87 11.5 Arquivos invertidos.................................................................................................................................. 88 11.6 Comparação entre os tipos de organização dos arquivos ......................................................................... 91

12 COMPRESSÃO DE DADOS ......................................................................................................................... 92 12.1 Introdução ................................................................................................................................................ 92 12.2 Compressão unária................................................................................................................................... 93 12.3 Compressão Elias-Gama .......................................................................................................................... 94 12.4 Run Length Encoding .............................................................................................................................. 95 12.5 Huffman ................................................................................................................................................... 95 12.6 LZ77......................................................................................................................................................... 98 12.7 LZ78....................................................................................................................................................... 102 12.8 LZW....................................................................................................................................................... 103

13 RECONHECIMENTO DE PADRÕES....................................................................................................... 104 13.1 Força bruta ............................................................................................................................................. 104 13.2 Knuth-Morris-Pratt ................................................................................................................................ 105 13.3 Boyer-Moore.......................................................................................................................................... 109 13.4 Aho-Corasick ......................................................................................................................................... 113

14 CRIPTOGRAFIA DIGITAL........................................................................................................................ 116 14.1 Introdução .............................................................................................................................................. 116 14.2 Aplicações.............................................................................................................................................. 116 14.3 Criptografia e seus conceitos ................................................................................................................. 117 14.4 Tipos de criptografia em relação ao uso de chaves ................................................................................ 120 14.5 Autenticação comum e verificação de integridade................................................................................. 124

Page 4: Projeto de Algoritmo

4

1 OPERAÇÕES COM ARQUIVOS

1.1 Classes iostream O arquivo iostream.h contém as classes usadas para impressão em vídeo e leitura do teclado.

O arquivo fstream.h contém as classes usadas para leitura e gravação em disco (e já inclui o arquivo iostream.h)

A classe istream contém funções como get(), getline() e read(), além de outras. Contém ainda a sobrecarga do operador de extração >>.

A classe ostream contém funções como put() e write(), além de outras. Contém ainda a sobrecarga do operador de inserção <<.

A classe iostream é derivada tanto de istream quanto de ostream por herança múltipla. A classe ifstream, ofstream e fstream são utilizadas para leitura, gravação e leitura / gravação respectivamente.

1.2 Arquivos texto Exemplo 1 – gravação em arquivo texto:

#include <fstream.h> void main() { ofstream fout("TESTE.TXT"); fout << "Um grande antídoto para o egoísmo\n"; fout << "é a generosidade... Dê, mesmo que\n"; fout << "isso requeira de você um esforço\n"; fout << "consciente. Pelo fato de partilhar\n"; fout << "tudo o que possui, seu egoísmo se\n"; fout << "abrandará.\n"; }

Page 5: Projeto de Algoritmo

5

Exemplo 2 – leitura em arquivo texto:

#include <fstream.h> void main() { const int MAX=80; char buff[MAX]; ifstream fin("TESTE.TXT"); while( fin ){ fin.getline( buff, MAX ); cout << buff << '\n'; } }

Observações:

• O operador >> considera tanto o espaço em branco quanto o ‘\n’ como o término de uma leitura.

• O método getline() aceita um terceiro argumento indicando o caráter de término de uma leitura (esse caráter não é armazenado no buffer). Se esse argumento não for especificado, será assumido '\n'.

• O método open() pode substituir o construtor na abertura do arquivo. Este método pode conter um segundo argumento para indicar o modo de abertura.

• fin e fout são os nomes dos objetos para manipulação de arquivos e podem ser substituídos por qualquer outro nome.

Exemplo 3 – gravação de um caráter por vez

#include <fstream.h> void main() { ofstream fout("TESTE1.TXT"); char ch; while(cin.get(ch)) // até CTRL-Z fout.put(ch); }

Exercícios:

1) Fazer uma classe que copie um arquivo.

Page 6: Projeto de Algoritmo

6

1.3 A função open Tanto o construtor quanto a função open() podem ser utilizados para abrir um arquivo. Também em ambos os casos, podemos utilizar um segundo argumento indicando o modo de abertura do arquivo. Este modo é definido por bits onde cada um especifica um certo aspecto de abertura do arquivo:

MODO DESCRIÇÃO ios::in Abre para leitura (default de ifstream). ios::out Abre para gravação (default de ofstream). ios::ate Abre e posiciona no final do arquivo. Este modo

trabalha com leitura e gravação. ios::app Grava a partir do fim do arquivo. ios::trunc Abre e apaga todo o conteúdo do arquivo. ios::binary Abre em binário (o default é texto).

MODOS TEXTO E BINÁRIO

Durante a gravação em um arquivo texto, o caráter '\n' é convertido em dois bytes: CR (carriage-return) e LF (line feed) antes de ser gravado. Durante uma leitura de um arquivo texto, o par CR/LF é convertido para um único byte: '\n'. Nas operações com arquivos binários não há essa conversão.

Exemplo 1: contagem de caracteres

// FILCONT.CPP // conta os caracteres no arquivo. #include <fstream.h> #include <stdlib.h> // para exit() void main( int argc, char **argv ) { ifstream fin; char ch; int cont=0; if( argc != 2 ) { cout << "\nForma de uso: c:\>FILCONT nomearq"; exit(1); } fin.open( argv[1], ios::binary ); while( fin.get(ch)) cont++; cout << "\nCONT = " << cont; }

Page 7: Projeto de Algoritmo

7

1.4 Gravação de objetos As funções read() e write() são funções para leitura e gravação de objetos em arquivos. Os exemplos a seguir mostram o emprego dessas funções.

Exemplo 2 – gravação de um objeto no arquivo

// WFILOBJ.CPP // Grava objetos em disco #include <fstream.h> #include <stdio.h> #include <conio.h> class Livro { private: char titulo[50]; char autor[50]; int numreg; double preco; public: void novonome(); }; void Livro::novonome() { cout << "\nDigite o titulo: "; gets( titulo ); cout << "\tDigite o autor: "; gets( autor ); cout << "\tDigite o numero do registro: "; cin >> numreg; cout << "\tDigite o preco: "; cin >> preco; } void main() { ofstream fout("lista.dat"); Livro li; do { li.novonome(); fout.write((char *)&li, sizeof(Livro)); cout << "\nMais um livro (s/n)? “; } while( getche() != 'n'); }

Observações:

• A função novonome() foi utilizada para solicitar ao usuário os dados do novo livro.

• Os dados dos livros foram gravados no arquivo lista.dat.

Page 8: Projeto de Algoritmo

8

Exemplo 3 – leitura de um objeto no arquivo

// RFILOBJ.CPP // Lê objetos do disco #include <fstream.h> #include <stdio.h> class Livro { private: char titulo[50]; char autor[50]; int numreg; double preco; public: void print(); }; void Livro::print() { cout << "\n\tTitulo: " << titulo; cout << "\n\tAutor: " << autor; cout << "\n\tNo.Reg.: " << numreg; cout << "\n\tPreco: " << preco; cout << "\n"; } void main() { ifstream fin("lista.dat"); Livro li; while( fin.read( (char *)&li, sizeof( Livro ) ) ) li.print(); }

Observações:

• A função read() recebe dois argumentos: o primeiro é o endereço do objeto para onde irão os dados lidos e o segundo é o tamanho do objeto em bytes. O endereço do objeto deve ser convertido para um ponteiro char.

• A seção de dados da classe deve ser a mesma tanto na leitura quanto na gravação, mas os métodos podem variar.

Exemplo 4 – leitura e gravação de objetos

// WRFILOBJ.CPP // Grava objetos em disco #include <fstream.h> #include <stdio.h> #include <conio.h>

Page 9: Projeto de Algoritmo

9

class Livro { private: char titulo[50]; char autor[50]; int numreg; double preco; public: void novonome(); void print(); }; void Livro::novonome() { cout << "\nDigite o titulo: "; gets( titulo ); cout << "\tDigite o autor: "; gets( autor ); cout << "\tDigite o numero do registro: "; cin >> numreg; cout << "\tDigite o preco: "; cin >> preco; } void Livro::print() { cout << "\n\tTitulo: " << titulo; cout << "\n\tAutor: " << autor; cout << "\n\tNo.Reg.: " << numreg; cout << "\n\tPreco: " << preco; cout << "\n"; } void main() { fstream fio; Livro li; fio.open("lista.dat", ios::ate | ios::out | ios::in ); do { li.novonome(); fio.write((char *)&li, sizeof(Livro)); cout << "\nMais um livro (s/n)? “; } while( getche() != 'n'); cout << "\nLISTA DE LIVROS DO ARQUIVO"; cout << "\n=========================="; fio.seekg(0); // coloca o ponteiro no inicio do arquivo while( fio.read( (char *)&li, sizeof( Livro ) ) ) li.print(); }

Observações:

• Objetos da classe fstream podem ser utilizados tanto para leitura quanto para gravação.

• Os diversos modos de abertura do arquivo são combinados através de um OR (|).

• Antes de fazermos a leitura, devemos retornar ponteiro de posição do arquivo para o registro inicial utilizando seekg().

Page 10: Projeto de Algoritmo

10

1.5 Ponteiros de posição Cada objeto stream está associado a dois valores inteiros chamados ponteiro de posição corrente de leitura e ponteiro de posição corrente de gravação. Seus valores especificam o número do byte do arquivo onde ocorrerá a próxima leitura ou gravação. Este byte é chamado posição atual. A palavra ponteiro não deve ser confundida com os ponteiros de C++.

A função seekg() permite movimentar a posição corrente de leitura do arquivo para a posição escolhida, enquanto a função seekp() executa a mesma tarefa para a posição corrente de gravação. Ambas têm dois parâmetros, sendo o primeiro o deslocamento em bytes a partir da posição escolhida e o segundo esta posição escolhida, que pode ser:

ios::beg A partir do início do arquivo (default) ios::cur A partir da posição corrente ios::end A partir do fim do arquivo

As funções tellg() e tellp() retornam as posições correntes (em bytes) de leitura e de gravação, sempre a partir do início do arquivo).

O código a seguir mostra uma forma de cálculo do número de registros em um arquivo.

... fin.seekg(0;ios::end); long nrec = (fin.tellg())/sizeof(livro); cout << "\nNumero de registros = " << nrec; ...

Exemplo 5 – localizando registros

// FSEEK.CPP // Lê objetos do disco #include <fstream.h> #include <stdio.h> #include <stdlib.h> class Livro { private: char titulo[50]; char autor[50]; int numreg; double preco; public: void print(); }; void Livro::print() { cout << "\n\tTitulo: " << titulo; cout << "\n\tAutor: " << autor; cout << "\n\tNo.Reg.: " << numreg; cout << "\n\tPreco: " << preco; cout << "\n"; }

Page 11: Projeto de Algoritmo

11

void main() { ifstream fin; Livro li; fin.open("lista.dat"); fin.seekg(0,ios::end); long nrec = (fin.tellg())/sizeof(Livro); cout << "\nNumero de registros = " << nrec; cout << "\n\nInsira o numero do registro: "; cin >> nrec; int posicao = (nrec-1) * sizeof(Livro); fin.seekg(posicao); fin.read((char *)&li, sizeof( Livro ) ) li.print(); }

Observações:

• O primeiro registro é o de número zero, portanto devemos diminuir a posição digitada em uma unidade.

1.6 Condições de erro Os erros na manipulação de arquivos podem ser tratados de duas formas. Na primeira, utilizamos a função rdstate() combinada com os seguintes bits individuais:

ios::goodbit Nenhum bit ativado. Sem erros. ios::eofbit Encontrado o fim de arquivo. ios::failbit Erro de leitura ou gravação. ios::badbit Erro irrecuperável.

O código abaixo mostra um exemplo de teste de fim de arquivo:

... ifstream fin("LISTA.DAT"); if(fin.rdstate() & ios::eofbit) cout << "\nFim de arquivo encontrado."; ...

A função clear() limpa os bits de erro. Ela recebe como parâmetro uma combinação dos bits que se deseja limpar:

clear(ios::eofbit | ios::failbit);

Várias outras funções retornam o status de um bit individual. São elas:

good() Nenhum bit ativado. Sem erros. eof() Encontrado o fim de arquivo. fail() Erro de leitura ou gravação. bad() Erro irrecuperável.

Page 12: Projeto de Algoritmo

12

Exemplo 6 – testes de erro

// FERRO.CPP // Verifica erro de abertura de arquivo #include <fstream.h> void main() { ifstream fin; fin.open("xxx.vvv");

if(!fin) cout << "\nZebra ao abrir o arquivo XXX.VVV\n"; else cout << "\n0 arquivo foi aberto com sucesso\n"; cout << "\nrdstate() = " << fin.rdstate(); cout << "\ngood() = " << fin.good(); cout << "\neof() = " << fin.eof(); cout << "\nfail() = " << fin.fail(); cout << "\nbad() = " << fin.bad(); }

Observações

• O erro retornado por rdstate() é 4. Isto indica que o arquivo não existe. As funções fail() e bad() retornam um valor diferente de zero, visto que um erro ocorreu.

• A função good() retornará zero se algum erro ocorrer, caso contrário, retornará l. A função eof() retornará zero se o fim de arquivo não for encontrado, caso contrário retornará l.

Page 13: Projeto de Algoritmo

13

2 MEMÓRIA SECUNDÁRIA1

2.1 Discos Sempre são muitos lentos quando comparados com RAM. Entretanto nem todos os acessos a disco são igualmente "caros".

Discos são dispositivos de acesso direto, ao contrário de fitas magnéticas e outros dispositivos que permitem apenas acesso serial (por exemplo fita magnética), ou seja, um dado não pode ser lido/escrito antes que todos os anteriores o sejam.

Alguns tipos de disco magnético: discos rígidos(hard disks): alta capacidade a baixo custo discos flexíveis(floppy disks): baratos, lentos, pouca capacidade discos rígidos removíveis: vantagens de ambos.

Discos não magnético: discos ópticos, cada vez mais importantes, serão discutidos em seguida.

2.2 Organização dos discos Informação é gravada em uma ou mais superfícies, e armazenada em trilhas na superfície. Cada trilha é normalmente dividida em setores. Um setor é a menor porção endereçável do disco. Quando uma declaração READ precisa de um byte em particular, o S.O. busca a superfície, trilha e setores corretos, lê um setor inteiro para uma área especial de memória chamada buffer, e encontra o byte necessário neste buffer.

Quando o disco é formado por diversos pratos (disk pack), um cilindro é formado pelo conjunto de trilhas na mesma direção. Uma vantagem da organização em cilindros é que toda a informação contida num cilindro pode ser obtida sem movimentação da cabeça de R/W. Essa movimentação, chamada seeking, é normalmente a parte mais lenta da operação de leitura.

1 Resumo do capítulo 3 do livro File Structures, de Michael Folk, feito pelo professor João Leonardo Ribeiro Neto,

da PUC-MG Coração Eucarístico.

Page 14: Projeto de Algoritmo

14

2.3 Estimativa de capacidade/espaço necessários Discos variam em tamanho entre 2 a 14 polegadas, e em capacidade entre centenas de milhões de bytes a bilhões de bytes. Normalmente as 2 superfícies de um prato são utilizadas, exceto no caso dos pratos superior e inferior. A quantidade de dados armazenados em uma trilha depende de quão densamente os bits podem ser armazenados [qualidade do meio e tamanho da cabeça R/W].

Dada essa organização: capacidade da trilha = nº setores/trilha x nº bytes/setor capacidade do cilindro = nº trilhas/cilindro x capacidade da trilha capacidade do dispositivo = nº cilindro x capacidade do cilindro

Dado o número de bytes de um arquivo, podemos calcular quanto de espaço é necessário para armazená-lo, em termos de setores, trilhas e cilindros.

Page 15: Projeto de Algoritmo

15

Exemplo:

Queremos armazenar 50.000 registros de tamanho fixo num típico disco de 2,1 gigabytes que contém:

512 bytes/setor 63 setores/trilha 16 trilhas/cilindro 4092 cilindros

Quantos cilindros são necessários se cada registro tem 256 bytes ?

2 registros / setor = 25.000 setores. Se em cada cilindro há 63 x 16 = 1008 setores, então o número de cilindros é aproximadamente 25.000 / 1008 = 24,8 cilindros.

É provável que um disco tenha essa quantidade de cilindros disponível, mas não de forma contígua, e o arquivo pode ser espalhando em dezenas ou centenas de cilindros.

2.4 Organização de discos por setor

INTERLEAVING

Os setores de 1 a N estão fisicamente um após o outro?

Nem sempre é possível ler setores adjacentes: o controlador, após ler os dados de um setor, pode precisar processar a informação. Se dois setores logicamente adjacentes também estão fisicamente adjacentes, a controlador poderia perder a leitura do próximo setor e teria que esperar uma outra rotação inteira do disco.

Uma solução é técnica chamada interleaving: vários setores físicos são colocados entre os setores lógicos. Um interleaving factor 5, necessita de 5 rotações para ler, em seqüência, 32 setores.

A tecnologia tem avançado de modo que discos de alta performance oferecem fator de interleaving 1:1.

Isto significa que sucessivos setores estão fisicamente adjacentes, possibilitando a leitura de uma trilha inteira em uma única rotação.

Page 16: Projeto de Algoritmo

16

CLUSTERS

Quando um programa acessa um arquivo, o gerenciador de arquivos do S.O deve associar o arquivo lógico com as suas posições físicas. Para isso o arquivo é visto como uma série de clusters de setores. Um cluster é um número de setores contíguos (contigüidade física depende do interleaving factor).

Considerando que um cluster associado a um arquivo foi encontrado, todos os seus setores podem ser acessados sem necessidade de seeking adicional. Essa organização setores/clusters é gerenciada através de uma File Alocation Table (FAT). Nessa tabela, cada entrada dá a localização física do cluster associado a um certo arquivo lógico.

A vantagem de se ter cluster com um maior número de setores é a possibilidade de ler mais setores sem precisar operações adicionais de seeking.

EXTENTS

Se existe espaço disponível, é possível fazer com que um arquivo seja formado apenas por clusters consecutivos. Nesse caso o arquivo consiste de um extent. Isso é bom, pois o arquivo pode ser buscado com um único seeking. Quando novos clusters são adicionados, o gerenciador de arquivos tenta manter essa situação, mas se não é possível novos extents são adicionados, e o arquivo não mais é formado por um só extent.

Quanto maior o número de extents, mais espalhado está o arquivo pelo disco, e maior é a quantidade de seeking necessários para processá-lo.

1 extent

vários extents

Page 17: Projeto de Algoritmo

17

FRAGMENTAÇÃO

Em geral todos os setores de um dispositivo de disco rígido possuem um mesmo tamanho. O que ocorre se o setor tem 512 bytes e os registros de um arquivo 300 bytes?

Existem 2 alternativas possíveis:

armazena-se um registro por setor. Neste caso, a recuperação de um registro exige a busca de apenas um setor, mas uma área não utilizada é mantida em cada setor. Essa perda de espaço é denominada fragmentação interna.

permite-se que os registros sejam quebrados em diversos setores. Neste caso, não se perde espaço com fragmentação interna, mas podem existir registros cuja recuperação exige a busca de 2 setores.

CLUSTERS E FRAGMENTAÇÃO INTERNA:

Cluster é a menor unidade de espaço que pode ser alocada a um arquivo. Quando ocorre fragmentação nos clusters ?

Exemplo:

Considere clusters com 3 setores de 512 bytes cada, e um arquivo com 1 byte. Quantos bytes de espaço são perdidos? O que podemos concluir?

2.5 Organização de discos por blocos definidos pelo usuário Em alguns sistemas, pode-se organizar as trilhas do disco não por setores, mas por blocos de tamanho variável cujo tamanho é definido pelo usuário.

blocking factor: número de setores por bloco em um arquivo o se tivermos registros de 300 bytes, podemos definir blocos de 300 bytes. Assim: o não teremos mais fragmentação interna. o não teremos mais "quebra" de registro (uso de mais de um setor por registro).

Page 18: Projeto de Algoritmo

18

esquema de endereçamento: cada bloco é normalmente dividido em sub-blocos, e a organização requer a manutenção de algumas informações adicionais para gerenciamento:

o (usualmente) count sub-bloco (número de itens no bloco) o (freqüentemente) key sub-bloco (chave para o último registro)

Quando o esquema key sub-bloco é utilizado, o controlador de disco pode buscar pela chave em operações de I/O. O programa poderia requisitar o controle para buscar um bloco com uma dada chave.

2.6 Overhead (espaço sem dados) O processo de formatação provoca a inclusão de informações extras.

discos organizados por setor: marcas de início/fim de setor, marcas de sincronização, se o setor é válido ou danificado, entre outras, são inseridas no processo de formatação. Esse overhead é invisível para o usuário.

discos organizados por bloco: também contém overheads (outro tipo), alguns dos quais o programador precisa conhecer: marcas de sub-blocos e inter-blocos devem ser adicionadas para cada bloco. Deste modo, um número maior de informação que não é dado pode ser colocada no disco, quando comparado ao esquema de setor.

o vantagem: permite ao programador escolher organização dos dados, de forma a explorar melhor a capacidade do disco.

o desvantagem: exige do programador/SO um conhecimento da organização dos dados.

Page 19: Projeto de Algoritmo

19

Exemplo:

Seja um disco com 20.000 bytes/trilha no qual o overhead/bloco é 300 bytes. Queremos armazenar arquivo contendo registros de 100 bytes.

Quantos registros são armazenados se o fator de blocagem é 10 ?

Se 10 registros de 100 bytes são armazenados por bloco, cada bloco terá 1000 bytes de dado e usará 1300 no total. Então o número de blocos que podem ser colocados numa trilha é dado por:

20.000/1.300 = 15,38 = 15, portanto, 15 blocos, ou 150 registros.

Quantos registros podem ser armazenados se o fator de blocagem é 60?

Se são 60 registros de 100 bytes, cada bloco armazena 6.000 bytes de dados e 6.300 no total. Então o número blocos por trilha fica: 20.000/6.300 = 3, portanto, 3 blocos, ou 180 registros.

Blocos maiores levam a um uso mais eficiente do espaço, pois há um número menor de bytes de overhead, em comparação com o número de bytes ocupados por dados. Mas, nem sempre os blocos maiores garantem ganhos: a fragmentação interna dentro da trilha ocorre, pois, as trilhas têm tamanho fixo e sempre sobra espaço no final.

O que ocorreria com fator de blocagem 97?

O que ocorreria com fator de blocagem 98?

2.7 Custo de acesso a disco

SEEK TIME

Tempo necessário para mover o braço de acesso para o cilindro correto. O tempo depende da distância a ser percorrida pelo braço.

Exemplo:

1. Quando um arquivo é acessado seqüencialmente, e este está armazenado em vários cilindros consecutivos, só haverá necessidade de seeking após todas as trilhas de um cilindro tiverem sido processadas.

2. Ao contrário, no caso extremo de haver dois arquivos, localizados em extremos opostos do disco (um no cilindro mais externo e o outro no mais interno), são acessados alternadamente, o custo de seeking aumenta.

Como geralmente é impossível saber exatamente quantas trilhas serão atravessadas em cada busca, o que se faz é tentar determinar o tempo médio de busca necessário para uma certa operação em arquivo assumindo que as posições iniciais e finais para cada acesso são aleatórias.

Hoje em dia, o tempo médio de busca fornecido pelos fabricantes está abaixo de 10 milisegundo, chegando em discos de alta performance abaixo de 7,5 milisegundos.

Page 20: Projeto de Algoritmo

20

ROTATIONAL DELAY (latência rotacional)

Atraso necessário para a cabeça de leitura do disco chegar ao setor desejado. Como na busca, em geral considera-se a média: aplica-se o tempo gasto para a cabeça de leitura sair de uma posição aleatória e atingir o setor desejado. Na prática, o delay pode ser bem menor que a média. Em geral, discos rígidos trabalham a 5000rpm, que significa uma rotação em 12 milisegundos. Em média, a latência rotacional é a metade de um rotação, ou por volta de 6 milisegundos.

Exemplo:

Um arquivo requer duas ou mais trilhas, existem muitas trilhas disponíveis em um cilindro e o arquivo é escrito seqüencialmente em uma única operação de gravação.

Quando a primeira trilha está completa, a escrita na segunda trilha pode ter início imediato, sem rotational delay. Isso porque o começo da segunda trilha é deslocado exatamente a quantidade necessária para chavear-se da cabeça de leitura atual (da primeira trilha) para a próxima. O rotational delay é virtualmente nulo.

TEMPO DE TRANSFERÊNCIA

Uma vez que o dado está sob a cabeça de leitura, ele pode ser transferido e esse tempo é dado pela fórmula:

Nº de bytes transferidos Tempo de transferência = --------------------------------- X tempo de latência Nº de bytes em um trilha

Se o drive é setorizado, o tempo de transferência de um setor depende do número de setores por trilha.

OS DIFERENTES MODOS DE ACESSO A ARQUIVO PODEM AFETAR OS TEMPOS DE ACESSO

Vamos comparar os tempo de acesso a um dado arquivo considerando acesso seqüencial e acesso aleatório. No primeiro caso, o máximo do arquivo é processado a cada acesso; no segundo caso, apenas um registro pode ser acessado por vez.

Base para cálculos: típico disco rígido Seagate Cheetah de 9 gigabyte seek time minimo: 0.78 mseg / seek time médio: 8 mseg / seek time máximo: 19 mseg rotational delay: 3 mseg taxa de transferência máxima: 6 mseg / por trilha ou 14506 bytes/mseg bytes por setor: 512 setores por trilha: 170 trilhas por cilindro: 16 cilindros: 526

Vamos considerar um arquivo com 8704000 bytes, dividido em 34000 registros de 256 bytes, ou seja, 8704000 / 256 = 34000.

Page 21: Projeto de Algoritmo

21

Em primeiro lugar, precisamos saber a disposição do arquivo no disco:

1. Clusters com 4096 bytes podem suportar 16 registros cada, 4096 / 256.

2. O arquivo pode ser armazenado como uma seqüência de 2125 clusters de 4096 bytes, 8704000 / 4096 = 2125, ocupando 100 trilhas, ou seja, 2125 / 21,25. O valor 21,25 é obtido considerando que se uma trilha tem 170 setores e cada cluster tem 8 setores, então cada trilha armazenam 21,25 clusters.

3. Deste modo, necessitamos de 100 trilhas para armazenar todo o arquivo de 8704 Kbytes.

4. Assumiremos a situação onde as 100 trilhas utilizadas estarão dispersas aleatoriamente sobre a superfície do disco(situação extrema).

Agora estamos prontos para calcular o tempo necessário para ler 8704 Kbytes do arquivo em disco. Primeiro, estimamos o tempo para ler setor por setor em seqüência. Este processo envolve as seguintes operações para cada trilha:

Seek médio: 8 mseg Latência rotacional: 3 mseg Tempo leitura de uma trilha: 6 mseg Total: 17 mseg

Precisamos encontrar e ler 100 trilhas, então Tempo total = 100 x 17 mseg = 1700 mseg = 1,7 segundos

Em seguida, calculamos o tempo necessário para ler os mesmos 34000 registros utilizando aceso aleatório em vez de seqüencial. Este processo envolve as seguintes operações:

Seek médio: 8 mseg Latência rotacional: 3 mseg Tempo de leitura de um cluster (1 / 21,25 x 6 mseg): 0,28 mseg Total: 11,28 mseg Tempo total = 34000 x 11,28 mseg = 9250 mseg = 9,25 segundos.

A diferença no desempenho entre ambos os tipos de acesso é muitíssimo importante. Ler o máximo de informação a cada posicionamento no disco, se possível, é muito melhor do que ficar deslocando a cabeça a cada novo registro a ser lido, fazendo uma nova busca para cada registro. O tempo de busca é muito caro e deve ser minimizado.

EFEITO DO TAMANHO BLOCO EM PERFORMANCE

Uma pesquisa realizada pelo grupo de Berkely fornece dados interessantes relativos a contrapartidas entre tamanho do bloco, fragmentação e tempo de acesso.

observou-se que o tamanho padrão para bloco, em ambientes UNIX típicos, 512bytes, não era muito eficiente, porque arquivos com vários blocos eram espalhados por muitos cilindros.

ao dobrar o tamanho do bloco, o desempenho mais que dobrou. Mesmo assim, atingiu apenas 4% do máximo possível (teoricamente).

concluiu-se que blocos de 4096 bytes davam o maior throughput, mas causavam grande fragmentação interna.

Page 22: Projeto de Algoritmo

22

Tabela: quantidade de espaço perdido em função do tamanho do bloco

ESPAÇO ÚTIL PERDA % ORGANIZAÇÃO

775.2 0.0 Apenas dados, sem separação entre arquivos

807.8 4.2 Apenas dados, arquivos iniciam em faixas de 512 bytes

828.7 6.9 Dados + inodes, blocos de 512 bytes UNIX-FS

866.5 11.8 Dados + inodes, blocos de 1.024 bytes UNIX-FS

948.5 22.4 Dados + inodes, blocos de 2.048 bytes UNIX-FS

1.128.3 45.6 Dados + inodes, blocos de 4.096 bytes UNIX-FS

para conseguir as vantagens associadas a ambos os tamanhos, o grupo de Berkeley implementou um esquema misto no qual blocos de 4096 bytes são utilizados para arquivos grandes, e para arquivos menores esses blocos podem ser divididos em 512 bytes. Nesse esquema até 8 arquivos pequenos podem ser armazenados em um bloco, e a perda de espaço caiu para 12%.

2.8 Disco como gargalho Os discos estão ficando cada vez mais rápidos, mas são muito mais lentos que as redes. Enquanto drives de disco possuem taxa de pico de 5 MB/seg, redes de alto desempenho conseguem transmitir dados a uma taxa de pico de 100 MB/seg.

Isso normalmente significa que um processo é disk-bound, ou seja, CPU e rede têm que esperar muito pelo disco

Várias técnicas tem sido usadas para resolver este problema:

multiprogramação, permitindo que a CPU faça outras coisas enquanto espera, mas nem sempre o processo pode esperar, I/O precisam ser aceleradas.

striping, divisão do arquivo em partes, alocando cada parte em um disco diferente, e fazendo com que cada disco transmita partes do arquivo simultaneamente.

Essa técnica é um tipo de paralelismo. Onde quer que você encontre um gargalo no sistema, considere replicar a origem do gargalo e configure o sistema de modo que essas sejam processadas em paralelo.

Como RAM tem ficado cada vez mais barata, uma outra técnica para resolver o gargalo imposto pelo disco seria ter um RAM disk.

1. porção enorme de RAM configurada para simular o comportamento do disco.

2. normalmente utilizada no lugar de discos flexíveis.

Page 23: Projeto de Algoritmo

23

Uma outra técnica utiliza disk cache: uma porção de memória configurada para conter páginas de dados do disco. Um esquema típico poderia utilizar um cache de 256 bytes. Quando o dado é requisitado, o conteúdo do cache é verificado primeiro para ver se já não contém a informação desejada.

As duas últimas técnicas (RAM disk e Cache) são exemplos de buffering.

2.9 Fita magnética Pertencem à classe de dispositivos que permitem acesso seqüencial muito rápido, porém não permitem acesso direto.

São compactas, resistentes em condições de ambientes variados, fáceis de transportar e mais baratas que discos.

ORGANIZAÇÃO DE DADOS EM FITAS MAGNÉTICAS

Como o acesso é seqüencial, não existe necessidade de guardar endereços na fita, e a posição de um registro é dado por um deslocamento offset relativo ao início do arquivo.

Características da superfície da fita 9 trilhas paralelas (típico) correspondem a um frame 8 bits/byte + bit paridade (em geral, impar) frames são agrupados em blocos de tamanho variado (conforme as necessidades do

usuário) blocos são separados por intervalos entre blocos (interblock gaps), que não contém

informação No caso de paridade impar, 9 zeros são usados para interblock gaps, já que não existe

frame com 9 bits iguais a zero.

Medidas de comparação para drives de fita (normalmente)

densidade da fita = 800, 1600 ou 6250 bits/polegada (bpi) por trilha (pode atingir até 30.000 bpi)

velocidade da fita = 30 a 200 pol/sec (ips) tamanho do intervalo entre blocos = entre 0.3 a 0.75 pol

Nota: fita de 6250 bits por polegada (bpi) com 9 trilhas armazena 6250 bytes/polegada.`Portanto, bpi pode ser utilizado para significar bytes/polegada.

ESTIMATIVA DO TAMANHO DE FITA NECESSÁRIO

Suponha que desejamos armazenar o backup de uma mala direta com 1.000.000 de registros com 100 bytes cada. Se desejarmos armazenar o arquivo em uma fita com 6.250 bpi, que tem um intervalo entre blocos de 0.3 pol., quanto de fita é necessário? O que toma espaço na fita?

Dados e intervalos entre blocos. Para cada bloco de dados existe um intervalo.

Seja: b = tamanho físico do bloco de dados g = tamanho do intervalo n = número de bloco de dados

Então o espaço s necessário para um arquivo é: s = n * (b+ g).

Page 24: Projeto de Algoritmo

24

No exemplo acima: b = tam bloco / densidade da fita = 100/6250 = 0,016 pol

Como precisaremos de n = 1 milhão de blocos (1 registro por bloco) o fator de blocagem (número de registros por bloco físico) escolhido é 1. Portanto, o espaço necessário para o arquivo é:

s = 1.000.000 * (0.016 + 0.3) pol = 1.000.000 * 0.316 pol = 316.000 pol = 26.333 feet

Fitas magnéticas variam de 300 a 3600 feet. O comprimento mais comum é de 2400 pés. Precisaremos de muitas fitas para armazenar nosso backup. Será mesmo?

Mudando para um fator de blocagem de 50 registros/bloco, o número de blocos necessários passa para:

n = (1.000.000/50) = 20.000

Nesse caso, conseguimos fazer o backup numa única fita? (Verifique)

Obviamente, a quantidade de espaço necessário é o mesmo, o que muda é o espaço relativo gasto com intervalos.

Uma medida mais geral do efeito de escolher diferentes tamanhos de bloco é a quantidade de dados armazenados por polegada de fita.

Densidade efetiva de gravação: número de bytes por bloco / número de polegadas por bloco

Quando o fator de blocagem é 1, no exemplo acima, 100 / 0.316 = 316.4 bpi, muito diferente da capacidade normal de 6250 bpi.

ESTIMATIVAS DE TEMPOS DE TRANSMISSÃO

Taxa nominal de transmissão de dados: • taxa nominal = densidade da fita (bpi) x velocidade da fita (ips) • para fita de 6250 bpi e 200 ips, • taxa nominal = 6250 x 200 = 1250 KB/seg • compete com maioria dos drives de disco • taxa efetiva de transmissão:

No exemplo: taxa transmissão = 316.4 x 200 = 63.3 KB/seg

APLICAÇÕES

Apropriadas para armazenamento seqüencial, quando não é necessário acesso direto;

Quando não é necessária a atualização imediata;

Drive de fita Streaming

Usado para transferência de dados (I/O) nonstop, de alta velocidade;

Inapropriado para aplicações envolvendo muitas paradas e inicializações;

Page 25: Projeto de Algoritmo

25

Discos X Fitas

A RAM cada vez mais barata permite a utilização de mais e mais buffers, os quais reduzem as desvantagens das operações de seeking em discos

Os discos estão sendo cada vez mais utilizados mesmo para acesso seqüencial

Fitas streamers cada vez mais utilizadas para backup e afins

2.10 A organização física do CD – ROM (Compact Disc, Read-Only Memory)

LENDO PITS E LANDS

CD-ROMs são gerados a partir de uma matriz. A matriz, que é feita de vidro, tem uma cobertura que é alterada por um feixe de laser. Quando a cobertura é alterada pelo feixe de laser, estes geram saliências (pits) ao longo da trilha. As área sem as alterações causadas pelo laser são chamadas de lands(reentrâncias).

Quando se esta lendo um disco estampado a partir da matriz, um feixe de laser é focado na trilha que esta em movimento. As saliências dispersam a luz do laser e as reentrâncias a refletem de volta ao detector. Esta alternância de padrões de baixa e alta intensidade de luz é o sinal utilizado para reconstruir as informações digitais originais. O esquema de codificação utilizado para estes sinais não é simplesmente representar um pit por 1 e um land por 0. Em vez disto, os 1s são representados pela transição de pit para land e vice versa. Toda vez que a intensidade da luz muda, temos um 1. Os 0s são representados pelo tempo gasto na transição; quando maior a transição mais 0s temos.

Devido, aos limites de resolução do dispositivo ótico, existe pelo menos 2 )s entre um par de 1s. Isto significa que o padrão gerado de 0s e 1s tem que transformado no padrão de 8 bits de 0s e 1s que formam o byte original do dado. Este esquema de transformação é dado pela tabela abaixo, onde um byte de 8 bits é expandido para 14 bits que pode representar os pits e lands do disco, na leitura este processo é revertido.

Uma parte da tabela de codificação EFM (Eigth to Fourteen Modulation)

Valor decimal Bits originais Bits transfomados

0 00000000 01001000100000 1 00000001 10000100000000 2 00000010 10010000100000 3 00000011 10001000100000 . ...... ...... . ...... ...... . ...... .......

CLV (Constant Linear Velocity) EM VEZ DE CAV (Constant Angular Velocity)

Discos magnéticos como os que são usados nas unidades de discos rígidos tem os dados arranjados em círculos chamados trilhas, são divididas radialmente em setores . Utilizando um esquema chamado velocidade angular constante, em inglês Constant Angular Velocity, o disco magnético permanece girando em um mesmo padrão de velocidade, ou seja, as trilhas próximas da periferia do disco movem-se mais rápida do que as trilhas mais próximas do centro. Como os

Page 26: Projeto de Algoritmo

26

setores localizados nas extremidades passam mais rápido pelas cabeças e leitura e gravação, eles precisam ser fisicamente mais largos para reunir a mesma quantidade de dados que os setores mais internos. Este formato desperdiça um grande parte do espaço de armazenamento, mas maximiza a velocidade com a qual os dados podem ser recuperados. Os discos CD ROM utilizam um esquema diferente dos discos magnéticos, em lugar de arranjar diversas trilhas em círculos concêntricos, no CDROM os dados permanecem em uma trilha única que traça uma espiral a partir do centro do disco até sua borda. A trilha ainda é dividida em setores, porém cada setor ocupa o mesmo tamanho físico. Utilizando um método chamado velocidade linear constante, em inglês Constant Linear Velocity, a unidade de disco varia constantemente o padrão de velocidade com que disco está girando, ou seja , à medida que o detector aproxima-se do centro do disco , a velocidade do disco aumenta .O resultado disto é que um disco compacto pode conter mais setores do que um disco magnético e, conseqüentemente mais dados.

CD-ROM - VANTAGENS E DESVANTAGENS

A desvantagem principal é a performance para acesso aleatório. Discos magnéticos têm um tempo médio para acesso aleatório muito menor.

Um CD-ROM lê setenta setores, ou 150 Kbytes de dados por segundo, cerca de 5 vezes a taxa de transferência de uma unidade de disco flexível e em magnitude menor do que um disco rígido de boa performance. O CD-ROM tem capacidade de armazenar mais de 600 Mbytes de dados.

CD-R (CD gravável)

Um laser envia um raio de luz de baixa energia a um CD construído em uma camada relativamente espessa de plástico policarbonato transparente. Sobre o plástico está um a camada de um material tingido, usualmente da cor verde, um afina camada de ouro para refletir o laser, uma camada protetora de verniz e em geral uma camada de um material polímero resistente a arranhões. Pode haver um papel ou uma etiqueta pintada sobre tudo isto. A cabeça de gravação laser segue um sulco em espiral entalhado na camada de plástico. O sulco, denominado um ATIP (do inglês, Absolute Timing In pregroove ) ou temporização absoluta em pré-sulco ), possui um padrão ondulado semelhante ao de uma gravação fonográfica . A freqüência das ondas varia continuamente do início ao fim do sulco. O raio laser reflete-se neste padrão e ao ler a freqüência das ondas, a unidade de CD pode calcular onde a cabeça está localizada à superfície do disco. À medida que a cabeça segue o apita, usa a informação de posicionamento dada pelas ondas do sulco para controlar a velocidade do motor que girar o disco, de modo que a área do disco sob a cabeça esteja sempre se movendo à mesma velocidade. Para tanto, o disco precisa girar mais rápido quando a cabeça se move na direção do cent5ro do disco e mais devagar quando se aproxima da borda. O programa usado para fazer um a gravação em CD envia os dados a serem armazenados no disco em um formato específico, como o ISSO 9096 , que automaticamente corrige erros e cria uma tabela de índice . A tabela é necessária porque não existe algo como a tabela de alocação de arquivos dos discos magnéticos para registrar a localização de um arquivo. A unidade de CD grava a informação enviando pulsos do raio laser de alta energia em uma freqüência de luz de 780 nanômetros. A camada tintada é projetada para absorver a luz a esta freqüência específica. A absorção da energia do laser cria uma marca por uma de três formas; dependendo do projeto do disco. A tintura pode ser descorada; a camada de policarbonato pode ser distorcida; ou a camada tintada pode formar uma bolha. Independentemente de como, a marca é criada, o resultado uma distorção chamada de risca ao longo da trilha espiral. Quando o raio é desligado, não aparecem marca alguma. OS comprimentos para gravar a informação em uma codificação especial que comprime os dados e verifica os erros. A alteração na tintura é permanente, fazendo dos CD’s graváveis um meio do tipo WORM (grava uma vez, lê muitas).

Page 27: Projeto de Algoritmo

27

2.11 Jornada de um byte O que ocorre quando um programa escreve um byte para um arquivo em um disco?

write(arq, &c, 1);

1. O programa pede ao sistema operacional para escrever o conteúdo da variável c na próxima posição do arquivo indicado por arq.

2. O sistema operacional repassa a tarefa para o gerenciador de arquivos, que é um subconjunto do SO.

3. O gerenciador de arquivos busca em uma tabela informações sobre o arquivo, como por exemplo: se está aberto e disponível para uso que tipos de acessos são permitidos a qual arquivo físico corresponde o arquivo lógico arq;

4. O gerenciador de arquivos busca em uma FAT a localização física do setor que deve conter o byte (cilindro, trilha, setor). No caso do caracter ser appended no final do arquivo, o gerenciador precisa saber aonde está o final do arquivo, isto é, a localização física do último setor do arquivo.

5. O gerenciador de arquivos garante que o último setor do arquivo seja armazenado num buffer de I/O do sistema; a seguir o byte é armazenado na sua posição correta no buffer.

6. O gerenciador de arquivos instrui o processador de I/O sobre a posição do byte na RAM, e onde ele deve ser colocado no disco.

7. O processador de I/O formata o dado apropriadamente, e decide o melhor momento de escrevê-lo no disco.

8. O processador de I/O envia o dado para o controlador de disco.

9. O controlador de disco instrui o drive de disco para mover a cabeça de R/W para a trilha correta, espera o setor desejado ficar sob a cabeça, e então envia o byte para o drive de disco o qual deposita, bit-a-bit, o byte na superfície do disco.

10. O byte fica passeando no disco a uma velocidade em torno de 50 a 100 milhas por hora.

2.12 Gerenciamento de buffer A transferência de dados entre a área de dados do programa e a memória secundária requer o uso de buffers.

Buffering implica trabalhar com grande quantidade de dados em RAM de modo a reduzir o número de acessos ao armazenamento secundário. Nesse contexto tratamos de buffers de E/S.

Quantos buffers devemos utilizar?

Exemplo: imagine um sistema com um único buffer realizando, intercaladamente, operações de leitura e gravação.

Por isso a maioria dos sistemas utiliza pelo menos 2 buffers.

Page 28: Projeto de Algoritmo

28

Mesmo no caso de transferência em uma única direção a existência de um único buffer retarda consideravelmente as operações.

Multiple buffering

Também melhora a eficiência da transmissão em uma única direção quando programa é I/O bound (A CPU gasta muito tempo esperando que as operações de I/O sejam executadas)! Se dois buffers são utilizados, e os sistemas permitem sobreposição de tarefas de I/O e CPU, a CPU pode ficar enchendo um buffer enquanto o conteúdo do outro é transmitido para o disco. Quando ambas as tarefas são terminadas, os papéis dos buffers podem ser trocados. Essa idéia pode ser generalizada para múltiplos buffers, e o processo é geralmente gerenciado pelo SO.

move mode:

quando dados são movidos da área de programa para buffers e depois para E/S

locate mode

gerador de arquivos usa locations do programa como buffers para E/S

gerador de arquivos dá ao programa os buffers para área de dados

scatter/gather I/O

quando dados controle e dados puros podem ser automaticamente separados (scatter) ou unidos em operações de E/S.

Page 29: Projeto de Algoritmo

29

3 ORGANIZAÇÃO DE ARQUIVOS

3.1 Organização dos campos Existem várias formas de se armazenar os registros nos arquivos:

• Forçar os registros a um tamanho fixo; • Começar cada campo com um indicador de tamanho; • Colocar um delimitador no final de cada campo para separá-lo do próximo campo; • Usar uma expressão “campo=valor” para identificar cada campo e seu conteúdo.

Método 1 – Campos com tamanho fixo

Essa é a forma como o C++ armazena os dados quando mandamos gravar um objeto. Observe a classe Livro mostrada abaixo.

class Livro { char titulo[41]; // 40 bytes para título char autor[41]; // 40 bytes para autor };

Cada um de seus objetos consumirá 80 bytes em um arquivo. 40 bytes serão gastos apenas com o título, mesmo que ele tenha apenas 10 letras (o que necessitaria de apenas 10 bytes). Os dados seriam armazenados da seguinte forma:

File Structures Michael Folk Managing Gigabytes Ian witten

Exemplo 1 – classe com métodos de escrita e leitura de campos de tamanho fixo:

class Livro { public: char titulo[41]; char autor[41]; int writeFixed(ostream &); int readFixed(istream &); }; int Livro::writeFixed( ostream &fout ) { fout.write( titulo, 40 ); fout.write( autor, 40 ); fout.flush(); if( fout.fail() ) return false; return true; } int Livro::readFixed( istream &fin ) { fin.read( titulo, 40 ); fin.read( autor, 40 ); if( fin.fail() ) return false; return true; }

Page 30: Projeto de Algoritmo

30

Método 2 – Campos com indicador de tamanho

Uma forma de economizarmos espaço no arquivo é começar cada campo com seu indicador de tamanho. Os dados acima seriam armazenados da seguinte forma:

15File Structures12Michael Folk

Exemplo 2 – classe com métodos de escrita e leitura de campos com indicador de tamanho:

class Livro { public: char titulo[41]; char autor[41]; int writeLen(ostream &); int readLen(istream &); }; int Livro::writeLen( ostream &fout ) { int n; n = strlen( titulo ); fout.write( (char *)&n, sizeof( int ) ); // ou fout.put(n); fout.write( titulo, n ); n = strlen( autor ); fout.write( (char *)&n, sizeof( int ) ); fout.write( autor, n ); fout.flush(); if( fout.fail() ) return false; return true; } int Livro::readLen( istream &fin ) { int n; fin.read( (char *)&n, sizeof( int ) ); fin.read( titulo, n ); if( fin.fail() ) return false; // previne contra erro de memória // na linha abaixo titulo[n] = 0; fin.read( (char *)&n, sizeof( int ) ); fin.read( autor, n ); autor[n] = 0; if( fin.fail() ) return false; return true; }

Método 3 – Campos com delimitador

Outra forma de economia de espaço é a utilização de delimitadores indicando o término de cada campo (e o início do próximo). Os dados seriam armazenados da seguinte forma:

File Structures|Michael Folk|

Não poderíamos, entretanto, ter o delimitador no conteúdo do campo.

Page 31: Projeto de Algoritmo

31

Exemplo 3 – classe com métodos de escrita e leitura de campos com delimitador:

class Livro { private: char delimitador; public: char titulo[41]; char autor[41]; int writeDelim(ostream &); int readDelim(istream &); Livro() { delimitador = '|'; } }; int Livro::writeDelim( ostream &fout ) { fout.write( titulo, strlen( titulo ) ); fout.put( delimitador ); fout.write( autor, strlen( autor ) ); fout.put( delimitador ); fout.flush(); if( fout.fail() ) return false; return true; } int Livro::readDelim( istream &fin ) { char d; fin.getline( titulo, 40, delimitador ); fin.getline( autor, 40, delimitador ); if( fin.fail() ) return false; return true; }

Método 4 – Campo com rótulo

Nesta forma, cada campo seria representado através de uma expressão campo=valor, como mostrado abaixo:

titulo=File Structures|autor=Michael Folk|

Esta forma consume muito espaço para registrar cada rótulo e também requer um delimitador.

Obs: o método getline() da classe istream faz leitura de uma string com um indicador de tamanho máximo e com um delimitador:

fin.getline( livro.titulo, 40, '|' );

Método 5 – Campo com marcação

Nesta última forma, cada campo também seria representado através de rótulos (ou melhor, marcas), mas aqui haveria um marca determinando o início do campo e outra determinando seu fim. Essa é a estrutura utilizada nos documentos XML.

Page 32: Projeto de Algoritmo

32

<titulo>File Structures</titulo><autor>Michael Folk</autor>

Esta forma também consume muito espaço para registrar cada campo, mas suas vantagens podem ser percebidas rapidamente ao se estudar XML.

3.2 Organização dos registros Além de nos preocuparmos com a gravação dos campos nos registros, devemos nos preocupar também com a gravação dos registros no arquivo. Existem cinco métodos mais comuns para isso:

• Utilizar registros com um número previsível de bytes (campos com tamanho fixo); • Utilizar registros com um número previsível de campos; • Começar cada registro com um indicador de tamanho; • Utilizar um índice com ponteiro para os registros; • Colocar um delimitador no final de cada registro; • Usar marcações.

Método 1 – Utilizar registros com um número previsível de bytes

Este é o método mais simples e se baseia em campos com tamanho fixo. Quando escrevemos um registro em C++, o programa utiliza esse método.

Método 2 – Utilizar registros com um número previsível de campos

Ao invés de dizermos que o registro tem um número fixo de bytes, podemos dizer que ele tem um número fixo de campos. Assim, a cada n campos, registramos uma mudança de registro.

Método 3 – Começar cada registro com um indicador de tamanho

Para registros que possuem campos com tamanho variável, podemos utilizar um indicador de tamanho para todo o registro. Os campos do registro poderiam ter seus próprios indicadores de tamanho ou utilizarem delimitadores.

Exemplo de funções:

const int MaxBufferSize = 200; int WritePerson( ostream &stream, Person &p ) { char buffer[ MaxBufferSize ]; // cria um buffer de tamanho fixo strcpy( buffer, p.LastName ); strcat( buffer, '|' ); strcat( buffer, p.FirstName ); strcat( buffer, '|' ); strcat( buffer, p.Address ); strcat( buffer, '|' ); strcat( buffer, p.City ); strcat( buffer, '|' ); strcat( buffer, p.State ); strcat( buffer, '|' ); strcat( buffer, p.ZipCode ); strcat( buffer, '|' ); short length = strlen( buffer ); stream.write( &length, sizeof( length ) ); stream.write( &buffer, length ); }

Page 33: Projeto de Algoritmo

33

int ReadVariablePerson( istream &stream, Person &p ) { short length; stream.read( &length, sizeof( length ) ); char *buffer = new char[length+1]; // cria o buffer stream.read( buffer, length ); buffer[ length ] = 0; // termina o buffer com null ... // transfere o buffer para o objeto (metodos 2 ou 3 de campos) ... return 1; }

O tamanho do registro é escrito como um inteiro (do tipo short) para evitar que ele também seja de tamanho variável.

Método 4 – Utilizar um índice com ponteiros para os registros

Um arquivo paralelo contém indicadores para o início de cada registro. Estudaremos isso em aulas futuras.

Métodos 5 – Colocar um delimitador no final de cada registro

Esse método é análogo ao que utilizamos para delimitar os campos. Devemos ter cuidado, entretanto, para usar um delimitador de registro diferente de um delimitador de campo (poderíamos utilizar um \n, por exemplo).

Métodos 6 – Usar marcação

Esse método é utilizado pelos sistemas que trabalham com a linguagem de marcação XML. Todos os campos são “marcados” e não há mais a rigidez da estrutura tradicional dos arquivos. Para mais informações, procure referências sobre XML.

<livro> <titulo>File Structures</titulo> <autor>Michael Folk</autor> </livro>

Exercícios

1. Complete a função de leitura de registro utilizando campos com delimitadores

2. Complete a função de leitura de registro utilizando campos com indicador de tamanho.

3. Faça um programa para escrever registros de pessoas (título, autor, ano) com delimitadores de registros e delimitadores de campos.

4. Inclua no programa do exercício 3 uma rotina para consultar livros por título e por autor.

Page 34: Projeto de Algoritmo

34

4 ARQUIVOS SEQUENCIAIS2

4.1 Introdução A organização de arquivos seqüenciais é a mais conhecida e mais freqüentemente usada. A ordem lógica e física dos registros armazenados em um arquivo seqüencial é a mesma. Os arquivos seqüenciais em disco são armazenados em trilhas dentro de cilindros contidos em tambores que são armazenados em trilhas adjacentes. Já que os registros em arquivos seqüenciais são armazenados em sucessão contínua, acessar o registro N do arquivo (começando no início do arquivo) requer que os registros N-1 também sejam lidos.

Historicamente, os Arquivos Seqüenciais são associados a fitas magnéticas, devido à natureza seqüencial do meio de gravação. Mas os arquivos seqüenciais são também armazenados em dispositivos de acesso aleatório, quando o acesso a sucessivos registros em alta velocidade é um requisito do processamento .

O principal uso dos arquivos seqüenciais é o processamento em série ou seqüencial de registros. Se um mecanismo de leitura/gravação é posicionado para recuperar um registro em particular, então ele pode acessar rapidamente o registro seguinte do arquivo. A vantagem de poder acessar rapidamente o registro seguinte torna-se uma desvantagem quando o arquivo é usado para acessar um outro registro diferente do registro "seguinte". Em média, metade de um arquivo seqüencial tem que ser lido para recuperar um registro.

Os arquivos seqüenciais podem ter chave ou não. Cada registro lógico do arquivo com chave tem um item de dado chamado chave, que pode ser usado para ordenar os registros. Essa chave é então chamada chave de ordenação. Os registros em arquivos seqüenciais sem chave estão ordenados em série, sendo que geralmente cada novo registro é colocado no final do arquivo.

4.2 Acesso a um registro Podemos fazer o acesso a um registro de um arquivo seqüencial de duas maneiras.

ACESSO SERIAL

Consiste na obtenção do registro que segue ao último acessado, na seqüência ditada pela chave de ordenação.Em um arquivo seqüencial, este tipo de acesso é extremamente eficiente por estarem fisicamente armazenados de acordo de acordo com a seqüência na qual são solicitados. Deste modo, na maioria dos acessos, o registro desejado estará presente na memória, por pertencer ao mesmo bloco do seu antecessor.

Ex: Fita Magnética

ACESSO ALEATÓRIO

Caracteriza-se por ser a indicação do registro desejado feita pela especificação de um argumento de pesquisa. Neste caso a seqüência na qual os registros são acessados não mantém necessariamente a relação com a ordenação física do arquivo, podem ocorrer dois casos no acesso aleatório :

CHAVE DE ACESSO <> CHAVE DE ORDENAÇÃO

2 MARINA, Carla; BRITO, Regiane. Arquivos Seqüenciais. Disponível em:

http://www.ufpa.br/sampaio/curso_de_estdados_2/Arquivo_Sequencial/arquivo_sequencial.htm

Page 35: Projeto de Algoritmo

35

Neste caso é utilizada a pesquisa seqüencial, onde é examinado cada registro, até ser localizado ou não, o registro que possui para a chave de acesso um valor igual ao pesquisado.

CHAVE DE ACESSO = CHAVE DE ORDENAÇÃO

Se o argumento estiver armazenado em um dispositivo de acesso serial a vantagem é a constatação mais rápida que o argumento de pesquisa não está no arquivo, quando for o caso. Porém, se o arquivo está armazenada em dispositivo de acesso direto, é utilizado um método de pesquisa, mais eficiente, a Pesquisa Binária. Porém, em um arquivo seqüencial, só existe a pesquisa seqüencial, pois ele não dispõe acesso direto (arquivo texto).

4.3 Pesquisa seqüencial Consiste no exame de cada registro, a partir do primeiro, até ser localizado aquele que possui, para a chave de acesso, um valor igual ao argumento de pesquisa, ou então, ser atingido o final do arquivo, o que significa que o registro procurado não está presente no arquivo.

Passo a Passo:

1. Posicionar-se no início do arquivo; 2. Se registro atual = registro desejado : Sucesso, Terminar ; 3. Se registro atual > registro desejado : Fracasso, Terminar ; 4. Se registro atual < registro desejado, avançar um registro; 5. Se não for final de arquivo, retornar ao passo 2; 6. Fracasso, Terminar.

Por exemplo, para acessar um registro com chave Roberto no arquivo representado pela tabela abaixo, o sistema deveria percorrer todos os outros usuários e comparar sua chave com a chave do registro desejado (Roberto). Assim, o sistema passaria pelos nomes Ana, Bruno e Carla, para somente depois atingir o nome Roberto.

NOME IDADE

1. Ana 19

2. Bruno 23

3. Carla 18

4. Roberto 23

5. Silvio 25

6. Reinaldo 30

Page 36: Projeto de Algoritmo

36

Exemplo 1 – Busca em arquivo seqüencial

#include <fstream.h> class Cliente { public: int codigo; char empresa[61]; char telefone[13]; char endereco[61]; char numero[11]; char complemento[26]; char bairro[26]; char cep[9]; char cidade[26]; char estado[3]; }; void imprime(Cliente c) { cout << "Codigo: " << c.codigo << endl; cout << "Empresa: " << c.empresa << endl; cout << "Telefone: " << c.telefone << endl; cout << "Endereco: " << c.endereco << endl; cout << "Numero: " << c.numero << endl; cout << "Complemento: " << c.complemento << endl; cout << "Bairro: " << c.bairro << endl; cout << "CEP: " << c.cep << endl; cout << "Cidade: " << c.cidade << endl; cout << "Estado: " << c.estado << endl << endl; } void main() { ifstream fin( "clientes.dat", ios::binary ); int leituras = 0; Cliente c; char nome[60]; cout << "Digite o nome para busca: "; gets( nome ); fin.read( (char *)&c, sizeof( Cliente ) ); while( fin ) { leituras++; if( strncmp( c.empresa, nome, strlen( nome ) ) == 0 ) { imprime( c ); break; } fin.read( (char *)&c, sizeof( Cliente ) ); } if( !fin ) cout << "Nome nao encontrado!\n"; cout << "Registros testados: " << leituras << endl; }

Page 37: Projeto de Algoritmo

37

Exemplos de resultados do código acima:

Digite o nome para busca: PAULO Codigo: 125 Empresa: PAULO ROBERTO Telefone: 3134749849 Endereco: AV. BRIGADEIRO E. GOMES Numero: 1086 Complemento: Bairro: NOSSA SENHORA CEP: 30870100 Cidade: BELO HORIZONTE Estado: MG Registros testados: 105

e

Digite o nome para busca: KUTOVA Nome nao encontrado! Registros testados: 808

4.4 Pesquisa binária Na pesquisa binária o primeiro registro a ser consultado é aquele que ocupa a posição média do arquivo. Se a chave do registro for igual ao argumento de pesquisa, a pesquisa termina com sucesso; Caso contrário, ocorre uma das seguintes situações:

a. A chave do registro é maior do que o argumento de pesquisa e o processo de busca é repetido para metade inferior do arquivo;

b. A chave do registro é menor do que o argumento de pesquisa e o processo de busca é repetido para metade superior do arquivo;

A busca é encerrada sem sucesso quando a área de pesquisa, que a cada comparação é reduzida a metade, assumir o comprimento zero.

Passo a Passo:

1. Selecionar todo o conjunto de registros do arquivo; 2. Posicionar-se no meio do conjunto selecionado; 3. Se registro atual=registro desejado: Sucesso, terminar; 4. Se registro atual>registro desejado: selecionar metade superior do arquivo e repetir o

processo -passo(2); 5. Se registro atual<registro desejado: selecionar metade inferior do arquivo e repetir o

processo -passo(2); 6. Se o conjunto selecionado não possuir elementos: Fracasso, terminar.

Por exemplo, para acessar o registro com a chave Sílvio abaixo, devemos inicialmente definir como nosso conjunto os elementos de 1 a 6. Determinamos o elemento do meio (1+6) div 2, que é 3 e o comparamos à Silvio. Como Silvio é maior que Carla, determinamos que o início do próximo conjunto é o registro seguinte ao Carla, ou seja, o registro 4. Determinamos outra vez o elemento do meio (4+6) div 2 e encontramos 5, que é o registro procurado.

Page 38: Projeto de Algoritmo

38

NOME IDADE

1. Ana 19

2. Bruno 23

3. Carla 18

4. Roberto 23

5. Silvio 25

6. Reinaldo 30

Exemplo 2 – busca binária em arquivo ordenado

#include <fstream.h> class Cliente { public: int codigo; char empresa[61]; char telefone[13]; char endereco[61]; char numero[11]; char complemento[26]; char bairro[26]; char cep[9]; char cidade[26]; char estado[3]; }; void imprime(Cliente c) { cout << "Codigo: " << c.codigo << endl; cout << "Empresa: " << c.empresa << endl; cout << "Telefone: " << c.telefone << endl; cout << "Endereco: " << c.endereco << endl; cout << "Numero: " << c.numero << endl; cout << "Complemento: " << c.complemento << endl; cout << "Bairro: " << c.bairro << endl; cout << "CEP: " << c.cep << endl; cout << "Cidade: " << c.cidade << endl; cout << "Estado: " << c.estado << endl << endl; } void main() { ifstream fin( "clientesord.dat", ios::binary ); int leituras = 0; long inicio=0, fim, meio, nrec; Cliente c; char nome[600]; bool achou = false; int i; fin.seekg(0,ios::end); fim = (fin.tellg())/sizeof(Cliente) - 1; meio = (inicio+fim)/2; cout << "Digite o nome para busca: "; gets( nome );

Page 39: Projeto de Algoritmo

39

while( fim >= inicio ) { fin.seekg( meio*sizeof(Cliente), ios::beg ); fin.read( (char *)&c, sizeof( Cliente ) ); cout << meio << ": " << c.empresa << endl; leituras++; if( strncmp( c.empresa, nome, strlen( nome ) ) == 0 ) { cout << "---------------------\n"; imprime( c ); achou = true; break; } if( strncmp( c.empresa, nome, strlen( nome ) ) < 0 ) inicio = meio+1; else fim = meio-1; meio = (inicio+fim)/2; } if( !achou ) cout << "Nome nao encontrado!\n"; cout << "Registros testados: " << leituras << endl; }

Exemplos de resultados do código acima:

Digite o nome para busca: PAULO 403: JOAO FELIPE R. CARVALHO 605: NIKOLAS A. FARIA 706: RONALDO RAIVIL ARRUDA 655: RAQUEL MOREIRA COSTA 630: PAULO ROBERTO --------------------- Codigo: 125 Empresa: PAULO ROBERTO Telefone: 3134749849 Endereco: AV. BRIGADEIRO E. GOMES Numero: 1086 Complemento: Bairro: NOSSA SENHORA CEP: 30870100 Cidade: BELO HORIZONTE Estado: MG

Registros testados: 5

e Digite o nome para busca: KUTOVA 403: JOAO FELIPE R. CARVALHO 605: NIKOLAS A. FARIA 504: MARCELLA BASSANE 453: LANA VILELA DINIZ 428: JOSE RIBEIRO GUIMARAES 440: JULIANA STANCIOLI 446: KATIA CARDOSO 449: KDM - RJ 451: KRIS JULIAO 452: L Nome nao encontrado! Registros testados: 10

Page 40: Projeto de Algoritmo

40

4.5 Inclusão de um novo registro A maneira mais comum de se processar inserções de um registro em um arquivo seqüencial S consiste em montar um arquivo T de transações que contém os registros a serem inseridos ordenados pela mesma chave de ordenação de S. O arquivo T pode ser usado como uma extensão de S , até assumir um tamanho que justifique a efetivação da operação de intercalação quando os arquivos S e T são intercalados, gerando o arquivo A que é uma versão atualizada de S.

A técnica anteriormente descrita é utilizada, pois a inserção de um registro isolado tem um custo proibitivo, pois implicaria no deslocamento de todos os registros com chaves superiores ao que foi inserido.

Outra maneira de se proceder a inserção de um registro é seguindo este procedimento:

1- Posicionar-se no inicio do arquivo. 2- Identificar posição de inserção. 3- Copiar todos os registros, até o local de inserção, para o arquivo auxiliar. 4- Adicionar registro no arquivo auxiliar. 5- Copiar registros restantes para o arquivo auxiliar. 6- Substituir arquivo antigo pelo arquivo auxiliar

Exercício:

1. Desenvolver o algoritmo de inserção ordenada.

Page 41: Projeto de Algoritmo

41

5 ORDENAÇÃO DE ARQUIVOS3

5.1 Introdução

A ordenação externa envolve arquivos compostos por um número de registros que é maior do que a memória interna do computador pode armazenar. Os métodos de ordenação externa são muito diferentes dos métodos de ordenação interna. Em ambos os casos o problema é o mesmo; rearranjar os registros de um arquivo em ordem ascendente ou descendente. Entretanto, na ordenação externa as estruturas de dados têm que levar em conta o fato de que os dados estão armazenados em unidades de memória externa, relativamente muito mais lentas do que a memória principal.

Nas memórias externas, tais como fitas, discos e tambores magnéticos, os dados são armazenados como um arquivo seqüencial, onde apenas um registro pode ser acessado em um dado momento. Esta é uma restrição forte se comparada com as possibilidades de acesso da estrutura de dados do tipo vetor. Conseqüentemente, os métodos de ordenação interna são inadequados para ordenação externa, e então técnicas de ordenação completamente diferentes têm que ser usadas. Existem três importantes fatores que fazem os algoritmos para ordenação externa diferentes dos algoritmos para ordenação interna, a saber:

1. O custo para acessar um item é algumas ordens de grandeza maior do que os custos de processamento na memória interna. O custo principal na ordenação externa está relacionado com o custo de transferir dados entre a memória interna e a memória externa.

2. Existem restrições severas de acesso aos dados. Por exemplo, os itens armazenados em fita magnética só podem ser acessados de forma seqüencial. Os itens armazenados em disco magnético podem ser acessados diretamente, mas a um custo maior do que o custo para acessar seqüencialmente, o que contra-indica o uso do acesso direto.

3. O desenvolvimento de métodos de ordenação externa é muito dependente do estado atual da tecnologia. A grande variedade de tipos de unidades de memória externa pode tornar os métodos de ordenação externa dependentes de vários parâmetros que afetam seus desempenhos. Por esta razão, apenas métodos gerais serão apresentados nesta seção.

Para desenvolver um método de ordenação externa eficiente o aspecto sistema de computação deve ser considerado no mesmo nível do aspecto algorítmico. A grande ênfase deve ser na minimização do número de vezes que cada item é transferido entre a memória interna e a memória externa. Mais ainda, cada transferência deve ser realizada de forma tão eficiente quanto as características dos equipamentos disponíveis permitam.

O método de ordenação externa mais importante é o método de ordenação por intercalação. Intercalar significa combinar dois ou mais blocos ordenados em um único bloco ordenado através de seleções repetidas entre os itens disponíveis em cada momento. A intercalação é utilizada como uma operação auxiliar no processo de ordenar.

A maioria dos métodos de ordenação externa utiliza a seguinte estratégia geral:

1. É realizada uma primeira passada sobre o arquivo, quebrando-o em blocos do tamanho da memória interna disponível. Cada bloco é então ordenado na memória interna.

3 Seção 3.2 do livro “Projeto de Algoritmos” de Nívio Ziviani

Page 42: Projeto de Algoritmo

42

2. Os blocos ordenados são intercalados, fazendo várias passadas sobre o arquivo. A cada passada são criados blocos ordenados cada vez maiores, até que todo o arquivo esteja ordenado.

Os algoritmos para ordenação externa devem procurar reduzir o número de passadas sobre o arquivo. Como a maior parte do custo é para as operações de entrada e saída de dados da memória interna, uma boa medida de complexidade de um algoritmo de ordenação por intercalação é o número de vezes que um item é lido ou escrito na memória auxiliar. Os bons métodos de ordenação geralmente envolvem no total menos do que 10 passadas sobre o arquivo.

5.2 Intercalação balanceada de vários caminhos

Vamos considerar o processo de ordenação externa quando o arquivo está armazenado em fita magnética. Para apresentar os vários passos envolvidos em um algoritmo de ordenação por intercalação balanceada vamos utilizar um arquivo exemplo. Considere um arquivo armazenado em uma fita de entrada, composto pêlos registros com as chaves mostradas abaixo. Os 22 registros devem ser ordenados de acordo com as chaves e colocados em uma fita de saída. Neste caso os registros são lidos um após o outro.

I N T E R C A L A C A O B A L A N C E A D A

Assuma que a memória interna do computador a ser utilizado só tem espaço para três registros, e o número de unidades de fita magnética é seis.

Na primeira etapa o arquivo é lido de três em três registros. Cada bloco de três registros é ordenado e escrito em uma das fitas de saída. No exemplo, são lidos os registros INT e escrito o bloco INT na fita l, a seguir são lidos os registros ERC e escrito o bloco CER na fita 2, e assim por diante, conforme mostrado abaixo. Três fitas são utilizadas em uma intercalação-de-3-caminhos.

Fita 1: I N T A C O A D E Fita 2: C E R A B L A Fita 3: A A L A C N

Na segunda etapa os blocos ordenados devem ser intercalados. O primeiro registro de cada uma das três fitas é lido para a memória interna, ocupando toda a memória interna. A seguir o registro contendo a menor chave dentre as três é retirado e o próximo registro da mesma fita é lido para a memória interna, repetindo-se o processo. Quando o terceiro registro de um dos blocos é lido aquela fita fica inativa até que o terceiro registro das outras fitas também sejam lidos e escritos na fita de saída, formando um bloco de 9 registros ordenados. A seguir o segundo bloco de 3 registros de cada fita é lido para formar outro bloco ordenado de nove registros, o qual é escrito em uma outra fita. Ao final, três novos blocos ordenados são obtidos, conforme mostrado abaixo.

Fita 4: A A C E I L N R T Fita 5: A A A B C C L N O Fita 6: A A D E

A seguir mais uma intercalação-de-3-caminhos das fitas 4, 5 e 6 para as fitas l, 2 e 3 completa a ordenação. Se o arquivo exemplo tivesse um número maior de registros, então vários blocos ordenados de 9 registros seriam formados nas fitas 4, 5 e 6. Neste caso, a segunda passada produziria blocos ordenados de 27 registros nas fitas l, 2 e 3; a terceira passada produziria blocos ordenados de 81 registros nas fitas 4, 5 e 6, e assim sucessivamente, até obter-se um único bloco

Page 43: Projeto de Algoritmo

43

ordenado. Neste ponto cabe a seguinte pergunta: quantas passadas são necessárias para ordenar um arquivo de tamanho arbitrário?

Considere um arquivo contendo n registros (neste caso cada registro contém apenas uma palavra) e uma memória interna de m palavras. A passada inicial sobre o arquivo produz n/m blocos ordenados (se cada registro contiver k palavras, k > l, então teríamos n/m/k blocos ordenados). Seja P uma função de complexidade tal que P(n) é o número de passadas para a fase de intercalação dos blocos ordenados, e seja f o número de fitas utilizadas em cada passada. Para uma intercalação-de-f-caminhos o número de passadas é

P(n) = logf n/m

No exemplo acima, n=22, m=3 e í=3. Logo

P(n) = log3 22/3 = 2.

Considere um exemplo de um arquivo de tamanho muito grande, tal como l bilhão de palavras. Considere uma memória interna disponível de 2 milhões de palavras e 4 unidades de fitas magnéticas. Neste caso P(n) = 5, e o número total de passadas, incluindo a primeira passada para obter os n/m blocos ordenados, é 6. Uma estimativa do tempo total gasto para ordenar este arquivo pode ser obtido multiplicando-se por 6 o tempo gasto para transferir o arquivo de uma fita para outra.

Para uma intercalação-de-f-caminhos foram utilizadas 2f fitas nos exemplos acima. Para usar apenas f + l fitas basta encaminhar todos os blocos para uma única fita e, com mais uma passada, redistribuir estes blocos entre as fitas de onde eles foram lidos. No caso do exemplo de 22 registros apenas 4 fitas seriam suficientes: a intercalação dos blocos a partir das fitas l, 2 e 3 seria toda dirigida para a fita 4; ao final, o segundo e o terceiro blocos ordenados de 9 registros seriam transferidos, de volta para as fitas l e 2, e assim por diante. O custo envolvido é uma passada a mais em cada intercalação.

5.3 Implementação através de seleção por substituição

A implementação do método de intercalação balanceada pode ser realizada utilizando-se filas de prioridades. Tanto a passada inicial para quebrar o arquivo em blocos ordenados quanto a fase de intercalação podem ser implementadas de forma eficiente e elegante utilizando-se filas de prioridades.

A operação básica necessária para formar os blocos ordenados iniciais corresponde a obter o menor dentre os registros presentes na memória interna, o qual deve ser substituído pelo próximo registro da fita de entrada.

A operação de substituição do menor item de uma fila de prioridades implementada através de um heap é a operação ideal para resolver o problema. A operação de substituição corresponde a retirar o menor item da fila de prioridades, colocando no seu lugar um novo item, seguido da reconstituição da propriedade do heap.

Para cumprir esta primeira passada nós iniciamos o processo fazendo m inserções na fila de prioridades inicialmente vazia. A seguir o menor item da fila de prioridades é substituído pelo próximo item do arquivo de entrada, com o seguinte passo adicional: se o próximo item é menor que o que está saindo (o que significa que este item não pode fazer parte do bloco ordenado corrente), então ele deve ser marcado como membro do próximo bloco e tratado como maior do que todos os itens do bloco corrente. Quando um item marcado vai para o topo da fila de

Page 44: Projeto de Algoritmo

44

prioridades, o bloco corrente é encerrado e um novo bloco ordenado é iniciado. A Figura 4 mostra o resultado da primeira passada sobre o arquivo original (Figura 1). Os asteriscos indicam quais chaves na fila de prioridades pertencem a blocos diferentes.

Cada linha da figura acima representa o conteúdo de um heap de tamanho três. A condição do heap é que a primeira chave tem que ser menor do que a segunda e a terceira chaves. Nós iniciamos com as três primeiras chaves do arquivo, as quais já formam um heap. A seguir, o registro I sai e é substituído pelo registro E, que é menor do que a chave I. Neste caso, o registro E não pode ser incluído no bloco corrente: ele é marcado e considerado maior do que os outros registros do heap. Isto viola a condição do heap, e o registro E* é trocado com o registro N para reconstituir o heap. A seguir, o registro N sai e é substituído pelo registro R, o que não viola a condição do heap. Ao final do processo vários blocos ordenados são obtidos. Esta forma de utilizar filas de prioridades é chamada seleção por substituição (vide Knuth, 1973, Seção 5.4.1; Sedgewick, 1988, p.180).

Entra 1 2 3E I N T R N E* T C R E* T A T E* C* L A* E* C* A C* E* L* C E* A L* A L* A C O A A C B A O C A B O C L C O A* A L O A* N O A* A* C A* N* A* E A* N* C* A C* N* E* D E* N* A A N* D A A D A A D D

Cada linha da figura acima representa o conteúdo de um heap de tamanho três. A condição do heap é que a primeira chave tem que ser menor do que a segunda e a terceira chaves. Nós iniciamos com as três primeiras chaves do arquivo, as quais já formam um heap. A seguir, o registro I sai e é substituído pelo registro E, que é menor do que a chave I. Neste caso, o registro E não pode ser incluído no bloco corrente: ele é marcado e considerado maior do que os outros registros do heap. Isto viola a condição do heap, e o registro E* é trocado com o registro N para reconstituir o heap. A seguir, o registro N sai e é substituído pelo registro R, o que não viola a condição do heap. Ao final do processo vários blocos ordenados são obtidos. Esta forma de utilizar filas de prioridades é chamada seleção por substituição (vide Knuth, 1973, Seção 5.4.1; Sedgewick, 1988, p.180).

Para uma memória interna capaz de reter apenas 3 registros é possível produzir os blocos ordenados INRT, ACEL, AABCLO, AACEN e AAD, de tamanhos 4, 4, 6, 5 e 3, respectivamente. Knuth (1973, pp. 254-256) mostra que, se as chaves são randômicas, os blocos ordenados produzidos são cerca de duas vezes o tamanho dos blocos produzidos por ordenação

Page 45: Projeto de Algoritmo

45

interna. Assim, a fase de intercalação inicia com blocos ordenados em média duas vezes maiores do que o tamanho da memória interna, o que pode salvar uma passada na fase de intercalação. Se houver alguma ordem nas chaves então os blocos ordenados podem ser ainda maiores. Ainda mais, se nenhuma chave possui mais do que m chaves maiores do que ela, antes dela, então o arquivo é ordenado já na primeira passada. Por exemplo, o conjunto de registros RAPAZ é ordenado pela primeira passada, conforme ilustrado abaixo.

Entra 1 2 3A A R PZ A R P

P R ZR ZZ

A fase de intercalação dos blocos ordenados obtidos na primeira fase também pode ser implementada utilizando-se uma fila de prioridades. A operação básica para fazer a intercalação-de-f-caminhos é obter o menor item dentre os itens ainda não retirados dos f blocos a serem intercalados. Para tal basta montar uma fila de prioridades de tamanho f a partir de cada uma das f entradas. Repetidamente, substitua o item no topo da fila de prioridades (no caso o menor item) pelo próximo item do mesmo bloco do item que está sendo substituído, e imprima em outra fita o elemento substituído. A figura abaixo mostra o resultado da intercalação de INT com CER com AAL, os quais correspondem aos blocos iniciais das fitas l, 2 e 3 mostrados na Figura 2. Quando f não é muito grande não há vantagem em utilizar seleção por substituição para intercalar os blocos, pois é possível obter o menor item fazendo f - l comparações. Quando f é 8 ou mais, é possível ganhar tempo usando um heap como mostrado acima. Neste caso cerca de log2 f comparações são necessárias para obter o menor item.

Entra 1 2 3A A C IL A C IW C L IE E L IN I L R

L N RT N R

R TT

5.4 Considerações práticas

Para implementar o método de ordenação externa descrito anteriormente é muito importante implementar de forma eficiente as operações de entrada e saída de dados. Estas operações compreendem a transferência dos dados entre a memória interna e as unidades externas onde estão armazenados os registros a serem ordenados. Deve-se procurar realizar a leitura, a escrita e o processamento interno dos dados de forma simultânea. Os computadores de maior porte possuem uma ou mais unidades independentes para processamento de entrada e saída que permitem realizar simultaneamente as operações de entrada, saída e processamento interno.

Knuth (1973) discute várias técnicas para obter superposição de entrada e saída com processamento interno. Uma técnica comum é a de utilizar 2f áreas de entrada e 2 f áreas de saída. Para cada unidade de entrada ou saída são mantidas duas áreas de armazenamento: uma para uso do processador central e outra para uso do processador de entrada ou saída. Para entrada, o processador central usa uma das duas áreas enquanto a unidade de entrada está

Page 46: Projeto de Algoritmo

46

preenchendo a outra área. No momento que o processador central termina a leitura de uma área, ele espera que a unidade de entrada acabe de preencher a outra área e então passa a ler dela, enquanto anuidade de entrada passa a preencher a outra. Para saída, a mesma técnica é utilizada.

Existem dois problemas relacionados com a técnica de utilização de duas áreas de armazenamento. Primeiro, apenas metade da memória disponível é utilizada, o que pode levar a uma ineficiência se o número de áreas for grande, como no caso de uma intercalação-de-f-caminhos para f grande. Segundo, em uma intercalação-de-f-caminhos existem f áreas correntes de entrada; se todas as áreas se tornarem vazias aproximadamente ao mesmo tempo, muita leitura será necessária antes de podermos continuar o processamento, a não ser que haja uma previsão de que esta eventualidade possa ocorrer.

Os dois problemas podem ser resolvidos com a utilização de uma técnica chamada previsão, a qual requer a utilização de apenas uma área extra de armazenamento (e não f áreas) durante o processo de intercalação. A melhor forma de superpor a entrada com processamento interno durante o processo de seleção por substituição é superpor a entrada da próxima área que precisa ser preenchida a seguir com a parte de processamento interno do algoritmo. Felizmente, é fácil saber qual área ficará vazia primeiro, simplesmente olhando para o último registro de cada área. A área cujo último registro é o menor, será a primeira a se esvaziar; assim nós sempre sabemos qual conjunto de registros deve ser o próximo a ser transferido para a área. Por exemplo, na intercalação de INT com CER com AAL nós sabemos que a terceira área será a primeira a se esvaziar.

Uma forma simples de superpor processamento com entrada na intercalação de vários caminhos é manter uma área extra de armazenamento, a qual é preenchida de acordo com a regra descrita acima. Enquanto os blocos INT, CER e AAL da Figura 2 estão sendo intercalados o processador de entrada está preenchendo a área extra com o bloco ACN. Quando o processador central encontrar uma área vazia, ele espera até que a área de entrada seja preenchida caso isto ainda não tenha ocorrido, e então aciona o processador de entrada para começar a preencher a área vazia com o próximo bloco, no caso ABL.

Outra consideração prática importante está na escolha do valor de f, que é a ordem da intercalação. No caso de fita magnética a escolha do valor de f deve ser igual ao número de unidades de fita disponíveis menos um. A fase de intercalação usa f fitas de entrada e uma fita de saída. O número de fitas de entrada deve ser no mínimo dois pois não faz sentido fazer intercalação com menos de duas fitas de entrada.

No caso de disco magnético o mesmo raciocínio acima é válido. Apesar do disco magnético permitir acesso direto a posições arbitrárias do arquivo, o acesso seqüencial é mais eficiente. Logo, o valor de f deve ser igual ao número de unidades de disco disponíveis menos um, para evitar o maior custo envolvido se dois arquivos diferentes estiverem em um mesmo disco.

Sedegwick (1983) apresenta outra alternativa: considerar f grande o suficiente para completar a ordenação em um número pequeno de passadas. Uma intercalação de duas passadas em geral pode ser realizada com um número razoável para f. A primeira passada no arquivo utilizando seleção por substituição produz cerca de n/2m blocos ordenados. Na fase de intercalação cada etapa divide o número de passadas por f. Logo, f deve ser escolhido tal que

f2 > n/2m

Page 47: Projeto de Algoritmo

47

Para n igual a 200 milhões e m igual a l milhão então f = 11 é suficiente para garantir a ordenação em duas passadas. Entretanto, a melhor escolha para f entre estas duas alternativas é muito dependente de vários parâmetros relacionados com o sistema de computação disponível.

Page 48: Projeto de Algoritmo

48

6 ARQUIVOS INDEXADOS

6.1 Introdução A organização de um arquivo deve ser definida em função de três operações básicas:

Inclusão de novos registros;

Alteração de registros existentes;

Pesquisa de registros.

Desde que não haja movimentação de registros dentro do arquivo, eficiência na inclusão é fácil de se obter, basta inserir os novos registros sempre no final do arquivo. Neste caso, entretanto, a pesquisa se tornará extremamente lenta. Para obter um registro com uma chave específica poderia ser necessário realizar uma leitura seqüencial exaustiva do arquivo. Assim, para garantir rapidez na pesquisa, podemos usar uma estrutura de indexação, o que reduziria muito o número de acessos a disco, já que a chave seria procurada nesta estrutura, mantida na memória principal do computador e, não haveria compromisso com a ordem física do arquivo.

O termo chave é utilizado para designar o campo de um registro que será utilizado no processo de localização de um registro dentro de um arquivo. Desta forma, será possível acessar um determinado registro diretamente, sem nos preocuparmos com os registros que o antecedem. Por exemplo, em um cadastro de funcionários, poderá ser utilizado o campo reservado para a matrícula, como sendo a chave para a manipulação do mesmo.

Conceitualmente, um índice é um mapeamento que associa a cada chave, uma referência ao registro que a contém. Assim, dada uma chave de pesquisa como argumento, o índice fornece imediatamente a localização do registro correspondente.

O processo de atualização, considerando a existência do índice, também será rápido. Dada a chave do registro que deve ser alterado, podemos acessá-lo rapidamente e gravar as novas informações. Se um registro tiver que ser removido, basta remover sua referência do índice (remoção lógica). Uma exclusão efetiva (física) ocorreria somente durante o processo de reorganização do arquivo, quando uma cópia dele seria gerada, contendo apenas os registros ainda referenciados no índice.

6.2 Índices Em um arquivo indexado, podem existir tantos índices quantas forem as chaves de acesso aos registros. Um índice consiste de uma entrada para cada registro considerando relevante com relação à chave de acesso associada ao índice. As entradas do índice são ordenadas pelo valor da chave de acesso, sendo cada uma constituída por um par (chave do registro, endereço do registro). A seqüencialidade física das entradas no índice visa a tornar mais eficiente o processo de busca e permitir o acesso serial ao arquivo.

Page 49: Projeto de Algoritmo

49

6.3 Tipos de índices Índice exaustivo – possui uma entrada para cada registro do arquivo.

Número Endereço Número Nome Idade Salário

1000 5 1 1400 Fermt 25 1600

1100 7 2 2450 Luis 24 1000

1400 1 3 1600 Hilda 30 1000

1600 3 4 3100 Sandra 27 1500

2000 8 5 1000 Ada 35 2000

2450 2 6 2700 Marcela 23 1800

2700 6 7 1100 Carlos 31 1600

3100 4 8 2000 Ivan 26 1300

3500 9 9 3500 Tatiana 20 1100

Índice seletivo – possui entradas apenas para um subconjunto dos registros. O subconjunto é definido por uma condição relativa à chave de acesso e/ou a outros atributos do arquivo. Um exemplo de índice seletivo seria o índice dos funcionários estáveis (há mais de 10 anos na empresa) sobre o cadastro geral de funcionários de uma empresa.

Índice indireto – possui entradas que apontam para outro arquivo de índice. Neste segundo índice, haveria a referência aos registros de dados. A figura abaixo mostra um primeiro índice seletivo indireto, um índice exaustivo e o arquivo de dados.

Num. End. Num. End. Num. Nome Idade Salário

1400 3 1 1000 5 1 1400 Fermt 25 1600

2450 6 2 1100 7 2 2450 Luis 24 1000

3500 9 3 1400 1 3 1600 Hilda 30 1000

4 1600 3 4 3100 Sandra 27 1500

5 2000 8 5 1000 Ada 35 2000

6 2450 2 6 2700 Marcela 23 1800

7 2700 6 7 1100 Carlos 31 1600

8 3100 4 8 2000 Ivan 26 1300

9 3500 9 9 3500 Tatiana 20 1100

Se nenhum dos índices existentes sobre um arquivo for exaustivo, é necessária a manutenção de uma tabela de alocação que identifique todos os espaços alocados para o arquivo, para fins de manutenção e administração do arquivo.

O maior problema relacionado com a utilização de arquivos indexados diz respeito à necessidade de atualização de todos os índices, quando um registro é inserido no arquivo. Atualizações nos índices são também necessárias quando a alteração de um registro envolve atributos associados a

Page 50: Projeto de Algoritmo

50

índices. Nos arquivos seqüenciais indexados, a necessidade de alteração dos índices é eliminada pelo uso de áreas de extensão e encadeamento na implementação de inserções; no entanto, esta estratégia não é condizente com a idéia de arquivos indexados, nos quais a manutenção constante dos índices é necessária.

6.4 Operações

BUSCAS

Busca seqüencial – é feita com a utilização de um dos índices, escolhido de acordo com a seqüência desejada. Como as entradas do índice são ordenadas pelo valor da chave de acesso, a entrada que segue à última acessada identifica o próximo registro naquela seqüência.

A busca seqüencial pode ser implementada através de um código como o mostrado abaixo:

... Cliente c; Chave k; ifstream fcli( "clientes.dat", ios::binary ); ifstream fidx( "clientes.idx", ios::binary ); ... while( fidx.read( (char *)&k, sizeof( Chave ) ) ) { fcli.seekg( k.indice * sizeof( Cliente ), ios::beg ); fcli.read( (char *)&c, sizeof( Cliente ) ); ... // processa o registro ... } ...

Busca aleatória – requer a efetivação de uma busca sobre o índice correspondente à chave de acesso usada, para a localização da entrada correspondente ao argumento de pesquisa e obtenção do registro de dados. Normalmente, utiliza-se uma técnica de pesquisa mais eficiente para a busca do registro, como a pesquisa binária (vista anteriormente).

O acesso aleatório é feito a partir do conhecimento da chave do registro. A busca por essa chave no arquivo de índice pode ser seqüencial ou binária.

... cout << "Digite o código: "; cin >> cod; ... // Faz uma busca seqüencial ou binária pela chave // Gravando o registro encontrado no objeto k da classe Chave ... if( k.codigo == cod ) { fcli.seekg( k.indice * sizeof( Cliente ), ios::beg ); fcli.read( (char *)&c, sizeof( Cliente ) ); ... // processa o registro ... } else cout << "Código não encontrado"; // Deve retornar o próximo ? ...

Page 51: Projeto de Algoritmo

51

EXCLUSÃO

Não é possível suprimir um registro do meio de um arquivo sem causar, na maioria das vezes, uma grande movimentação de dados.

Para tornar a exclusão um processo mais eficiente, apenas liberamos a área de dados ocupada pelo registro (através de algum atributo que indique que o registro não está mais disponível) e retiramos do índice a sua referência correspondente. Após isto, como todos os acessos são orientados pelo índice, será como se o registro não mais existisse.

O código a seguir mostra como implementar a exclusão a partir do momento em que o registro já foi localizado.

... fcli.read( (char *)&c, sizeof( Cliente ) ); c.excluido = true; pos = fcli.tellg() – sizeof( Cliente ); fcli.seekp( pos, ios::beg ); fcli.write( (char *)&c, sizeof( Cliente ) ); ... // reconstrói índice ...

A remoção de uma entrada e um índice pode ser implementada de um modo direto, pela retirada física do par (chave + endereço) ou, como alternativa, marcar a entrada correspondente ao registro excluído no campo endereço, com um valor de endereço inválido.

Essa alternativa é importante quando há índices seletivos sobre o arquivo, pois surge o problema de determinar em quais deles o registro é referenciado, para que deles sejam removidas as entradas correspondentes ao registro excluído.

Caso o índice seja reconstruído, não há necessidade de reordenar todo o arquivo. Basta copiar para um arquivo temporário todos as chaves anteriores à do registro excluído e todas as chaves posteriores a ele. Depois, deve-se apagar o arquivo original e renomear o novo arquivo de índices.

Se o primeiro campo do registro for um byte que indique se o registro é válido ou excluído, não será necessário ler, alterar o indicador de exclusão e reescrever todo o registro. Bastará alterar o byte inicial do registro. O código abaixo mostra um exemplo dessa aplicação. Os registros possuem um membro char que é igual a ' ' para os registros válidos e '*' para os registros excluídos.

... // posicionar no início do registro excluído ... fcli.put( '*' ); ... // reconstrói índice ...

Page 52: Projeto de Algoritmo

52

INSERÇÃO

O registro é armazenado em qualquer endereço vago dentro da área alocada para o arquivo, isto é, na área de um registro excluído ou no final do arquivo. Pode-se adotar também a estratégia de manter-se um índice seletivo para endereços vazios. A seguir, seus pares (chave do registro, endereço do registro), relativos às chaves de acesso para as quais existem índices, são inseridos nos índices correspondentes.

Devemos verificar, entretanto, se a chave escolhida para o registro já não está cadastrada no arquivo, pois um arquivo indexado não pode ter dois registros com a mesma chave.

Para um índice seletivo, é necessária uma verificação prévia, para determinar se o registro satisfaz à condição de seleção antes de inserir o seu par.

O código abaixo mostra como pode ser realizada uma busca por um registro excluído e a inserção do novo registro.

... Cliente c, cexc; ... // lê os dados do cliente no objeto c; ... // faz uma busca no índice para ver se a chave já não existe ... fcli.seekg( 0, ios::beg ); while( fcli.read( (char *)&cexc, sizeof( Cliente ) ); if( cexc.excluido == '*' ) { pos = fcli.tellg() – sizeof( Cliente ) ); fcli.seekp( pos, ios::beg ); fcli.write( (char *)&c, sizeof( Cliente ) ); break; } } if( fcli.eof() ) { // nenhum registro excluído foi encontrado fcli.clear(); fcli.write( (char *)&c, sizeof( Cliente ) ); } ...

ALTERAÇÃO

A operação de alteração é feita somente após a verificação da existência do registro. Portanto, há a necessidade de se fazer uma operação de busca pelo registro. Quando este for localizado, basta efetuar a alteração de seus campos.

Se esta alteração implicar em uma mudança da chave de acesso, os arquivos de índice devem ser alterados. É importante, entretanto, verificar se já não existe algum outro registro com a nova chave, pois não pode haver registros com chaves repetidas em um arquivo indexado.

O código abaixo mostra o trecho de um programa de alteração do registro. Foram omitidos, entretanto, os passos necessários quando houver uma alteração de chave.

Page 53: Projeto de Algoritmo

53

... // busca pela chave do registro no índice ... fcli.read( (char *)&c, sizeof( Cliente ) ); ... // altera os dados ... pos = fcli.tellg() – sizeof( Cliente ) ); fcli.seekp( pos, ios::beg ); fcli.write( (char *)&c, sizeof( Cliente ) ); ...

LEITURA EXAUSTIVA

A leitura exaustiva é a leitura seqüencial de todos os registros do arquivo. A seqüência a ser percorrida, entretanto, não é definida pela ordem dos registros no arquivo de dados, mas pela ordem dos registros no arquivo de índice.

O código para a leitura exaustiva é semelhante ao código mostrado para busca seqüencial.

REORGANIZAÇÃO DE UM ARQUIVO

A reorganização de um arquivo indexado é uma operação que deve ser executada periodicamente (não é, entretanto, obrigatória). Ela possui dois objetivos principais: colocar o arquivo ordenado seqüencialmente e remover os espaços dos registros excluídos.

Como vimos anteriormente, o sistema operacional lê vários registros de uma só vez e os armazena em um buffer. Se o arquivo estiver ordenado, os registros carregados no buffer provavelmente serão os próximos a serem acessados (em uma busca seqüencial). A ordenação do arquivo pode, portanto, aumentar a performance do sistema.

Já a remoção dos espaços é necessária para reduzir o tamanho do arquivo, eliminando os espaços ocupados por registros que já foram excluídos. Isso acelerará o processo de localização dos registros, pois haverá menos bytes a percorrer.

RECONSTRUÇÃO DO ÍNDICE

O código abaixo mostra a reconstrução completa de um índice em memória. Nem sempre, entretanto, será possível ordená-lo usando apenas a memória principal. O código executa a ordenação do índice utilizando o método QuickSort.

#include <fstream.h> class Cliente { public: char excluido; int codigo; char empresa[61]; char telefone[13]; char endereco[61]; char numero[11]; char complemento[26]; char bairro[26]; char cep[9]; char cidade[26]; char estado[3]; };

Page 54: Projeto de Algoritmo

54

class Chave { public: int codigo; int indice; }; void Particao( Chave v[], int l, int r, int *i, int *j ) { Chave x, y; *i = l; *j = r; x = v[ (*i+*j)/2 ]; do { while( v[*i].codigo < x.codigo ) (*i)++; while( v[*j].codigo > x.codigo ) (*j)--; if( *i <= *j ) { y = v[*i]; v[*i] = v[*j]; v[*j] = y; (*i)++; (*j)--; } } while( *i <= *j ); } void QuickSort( Chave v[], int l, int r ) { int i, j; Particao( v, l, r, &i, &j ); if( l<j ) QuickSort( v, l, j ); if( i<r ) QuickSort( v, i,r ); } void main() { ofstream fout( "clientes.idx", ios::binary ); ifstream fin( "clientes.dat", ios::binary ); int i; long pos; fin.seekg( 0, ios::end ); pos = fin.tellg()/sizeof(Cliente); fin.seekg( 0, ios::beg ); Chave *k = new Chave[ pos ]; Cliente c; // Leitura pos = 0; i = 0; while( fin.read( (char *)&c, sizeof( Cliente ) ) ) { if( c.excluido != '*' ) { k[i].codigo = c.codigo; k[i].indice = pos/sizeof(Cliente); i++; } pos = fin.tellg(); } // Ordenação QuickSort( k, 0, i-1 ); // Gravação do índice for( pos=0; pos<i; pos++ ) fout.write( (char *)&k[pos], sizeof( Chave ) ); }

Page 55: Projeto de Algoritmo

55

7 ÁRVORES DE BUSCA BINÁRIA

7.1 Introdução Vimos anteriormente que o acesso aleatório em arquivos seqüenciais pode ser muito otimizado se utilizarmos a busca binária. Entretanto, a busca binária não pode ser utilizada nas outras estruturas de arquivos, porque não há como determinar os pontos intermediários. Uma estrutura que elimina essa limitação é a árvore de binária de busca (ABB).

Quando uma árvore binária de busca é empregada, um arquivo com NR registros com chaves primárias ki, onde i=1,2,...,NR, é vista como uma árvore binária satisfazendo às propriedades:

1. Cada nó da árvore deve conter dois ponteiros, pesq e pdir, que apontam para os filhos esquerdo e direito deste nó, respectivamente. Um terceiro campo contém a chave primária do registro e um quarto campo contém os dados do registro ou um ponteiro para ele no arquivo de dados, como mostra a figura abaixo.

Dois tipos possíveis de nós

Por questões de simplicidade, ambos os tipos de nós serão representados sem os dados, como mostra a figura abaixo.

Representação do nó sem os dados

2. Se kj é a chave de um determinado registro, as chaves de todos os registros que ocupam sua sub-árvore esquerda são menores do que kj e as chaves de todos os registros que ocupam sua sub-árvore direita são maiores que kj.

Árvore binária de busca

Fisicamente, esses nós ficam armazenados no arquivo em uma ordem não seqüencial, como mostrada na figura abaixo.

Page 56: Projeto de Algoritmo

56

Uma possível distribuição de uma ABB em um arquivo

Nesse arquivo, o primeiro registro serve para apontar para o nó raiz e também para indicar uma árvore vazia (terá o ponteiro direito com valor -1).

7.2 Operações

ACESSO ALEATÓRIO A UM REGISTRO

A partir do nó raiz, o algoritmo de acesso aleatório deve percorrer a árvore fazendo comparações da chave de busca com a chave de cada nó encontrado. Se a chave de busca for menor que a chave do nó, o algoritmo deve prosseguir pela sub-árvore da esquerda. Se a chave de busca for maior que a chave do nó, o algoritmo deve prosseguir pela sub-árvore da esquerda. Se as chaves forem iguais, então o nó com o endereço do registro foi encontrado. Se um ponteiro for igual a -1, então a busca deve terminar sem sucesso.

long buscaABB( fstream &f, int chave ) { long end; No no; f.seekg( 0, ios::beg ); f.read( (char *)&no, sizeof( No ) ); end = no.pdir; while( end != -1 ) { f.seekg( end * sizeof( No ) ); f.read( (char *)&no, sizeof( No ) ); if( chave == no.chave ) return no.endereco; if( chave < no.chave ) end = no.pesq; else end = no.pdir; } return -1; }

INSERÇÃO DE UM REGISTRO

A inserção de um novo registro é bastante simples se a árvore estiver vazia. Mas se este não for o caso, o algoritmo deverá percorrer a árvore até encontrar um ponteiro com valor -1. O registro dessa posição será atualizado para apontar para o novo registro, que poderá ser incluído no final do arquivo. A figura a seguir mostra a árvore binária de busca após a inclusão de um registro com chave 12.

Page 57: Projeto de Algoritmo

57

Inserção do registro de chave 12

void insertABB( fstream &f, int chave, long endereco ) { long end, pai; No no; f.seekg( 0, ios::beg ); f.read( (char *)&no, sizeof( No ) ); pai = 0; end = no.pdir; while( end != -1 ) { f.seekg( end * sizeof( No ) ); f.read( (char *)&no, sizeof( No ) ); pai = end; if( chave == no.chave ) return; // chave duplicada if( chave < no.chave ) end = no.pesq; else end = no.pdir; } f.seekp( 0, ios::end ); long pos = f.tellp() / sizeof( No ); No novo_no; novo_no.pesq = -1; novo_no.chave = chave; novo_no.endereco = endereco; novo_no.pdir = -1; f.write( (char *)&novo_no, sizeof( No ) ); if( chave < no.chave ) no.pesq = pos; else no.pdir = pos; f.seekp( pai * sizeof( No ) ); f.write( (char *)&no, sizeof( No ) ); }

Page 58: Projeto de Algoritmo

58

EXCLUSÃO DE UM REGISTRO

O algoritmo para exclusão de um registro em uma árvore binária de busca é significativamente mais complicado. Quando um nó é excluído, os ponteiros de seu pai, e possivelmente de seus filhos, deverão ser reajustados para que a estrutura final mantenha as propriedades da árvore binária de busca.

Existem três possíveis casos que dependem do grau do nó que será excluído. Por grau de um nó queremos indicar o número de filhos que ele tem. Os casos são:

Grau = 0. Neste caso, o nó será removido e a árvore continuará sendo uma árvore binária de busca. A figura abaixomostra a nova estrutura obtida quando removemos o nó com chave 9 da árvore da figura anterior.

Árvore após exclusão do nó com chave 9

Grau = 1. Neste caso, a árvore continuará sendo uma árvore binária de busca se o nó em questão for substituído pelo seu único filho. Por substituição queremos dizer que o ponteiro do nó pai será ajustado para que aponte para o único filho do nó excluído. A figura abaixo mostra a estrutura obtida quando excluímos o nó com chave 5 da árvore.

Árvore após exclusão do nó com chave 5

Grau = 3. Neste caso, o nó deve ser substituído pelo seu sucessor em “intra-ordem”. A figura a seguir mostra a árvore após a exclusão do nó 11 da árvore inicial.

Page 59: Projeto de Algoritmo

59

Árvore após o exclusão do nó com chave 11

O algoritmo abaixo faz a exclusão de um nó

void deleteABB( fstream &f, int chave ) { No no, no_pai, no_sucessor, no_pai_do_sucessor; long end, end_pai, end_sucessor, end_pai_do_sucessor; // Encontra o nó a ser excluído f.seekg( 0, ios::beg ); f.read( (char *)&no, sizeof( No ) ); end = no.pdir; if( end == -1 ) return; end_pai = 0; no_pai = no; while( end != -1 ) { f.seekg( end * sizeof( No ) ); f.read( (char *)&no, sizeof( No ) ); if( chave == no.chave ) break; end_pai = end; no_pai = no; if( chave < no.chave ) end = no.pesq; else end = no.pdir; } if( end == -1 ) return; // não encontrou // Testa se não há um filho à esquerda ou à direita if( no.pesq == -1 ) { if( chave < no_pai.chave ) no_pai.pesq = no.pdir; else no_pai.pdir = no.pdir; f.seekp( end_pai * sizeof( No ) ); f.write( (char *)&no_pai, sizeof( No ) ); return; }

Page 60: Projeto de Algoritmo

60

else if( no.pdir == -1 ) { if( chave < no_pai.chave ) no_pai.pesq = no.pesq; else no_pai.pdir = no.pesq; f.seekp( end_pai * sizeof( No ) ); f.write( (char *)&no_pai, sizeof( No ) ); return; } // Encontra seu sucessor em 'intra-ordem' end_sucessor = no.pdir; f.seekg( end_sucessor * sizeof( No ) ); f.read( (char *)&no_sucessor, sizeof( No ) ); end_pai_do_sucessor = end; no_pai_do_sucessor = no; while( no_sucessor.pesq != -1 ) { end_pai_do_sucessor = end_sucessor; no_pai_do_sucessor = no_sucessor; end_sucessor = no_sucessor.pesq; f.seekg( end_sucessor * sizeof( No ) ); f.read( (char *)&no_sucessor, sizeof( No ) ); } // Atualiza os ponteiros no_pai_do_sucessor.pesq = no_sucessor.pdir; f.seekp( end_pai_do_sucessor * sizeof( No ) ); f.write( (char *)&no_pai_do_sucessor, sizeof( No ) ); if( chave < no_pai.chave ) no_pai.pesq = end_sucessor; else no_pai.pdir = end_sucessor; f.seekp( end_pai * sizeof( No ) ); f.write( (char *)&no_pai, sizeof( No ) ); no_sucessor.pesq = no.pesq; if( end_sucessor!=no.pdir ) no_sucessor.pdir = no.pdir; f.seekp( end_sucessor * sizeof( No ) ); f.write( (char *)&no_sucessor, sizeof( No ) ); }

Uma alternativa ao algoritmo acima é, ao invés de atualizar o ponteiro do nó pai do nó sendo excluído para que aponte para o sucessor na intra-ordem deste nó, mover o sucessor para o local do nó sendo excluído e manter os ponteiros do nó pai.

Page 61: Projeto de Algoritmo

61

LISTAGEM EM INTRA-ORDEM

O algoritmo abaixo mostra como percorrer a árvore em intra-ordem. Note que, como a função é recursiva, antes de ser chamada há a necessidade de posicionar o ponteiro no nó raiz.

void inorderABB( fstream &f, long end ) { if( end == -1 ) return; No no; f.seekg( end * sizeof( No ), ios::beg ); f.read( (char *)&no, sizeof( No ) ); inorderABB( f, no.pesq ); cout << no.chave << endl; inorderABB( f, no.pdir ); } void main() { ... long end; No no; f.seekg( 0, ios::beg ); f.read( (char *)&no, sizeof( No ) ); end = no.pdir; cout << "\nLista ordenada:\n"; inorderABB( f, end ); ... }

7.3 Árvores balanceadas A quantidade de acessos ns buscas na árvore binária mostrada na seção anterior depende da forma da árvore. A busca pode ser mais ou menos demorada, dependendo do nó procurado. Nesta seção, veremos um método que permite controlar a altura da árvore, tentando mantê-la balanceada.

Considerando que a altura de uma árvore é a distância máxima de qualquer nó, podemos definir uma árvore AVL4 como uma árvore binária de busca que obedece à seguinte regra: a altura das sub-árvores esquerda e direita de qualquer nó pode ser diferente em apenas uma unidade. As figuras abaixo mostram algumas árvores AVL e algumas árvores que não são AVL.

Árvores AVL

Árvores não AVL

4 O termo AVL vem do nome dos seus criadores: Adelson-Velskii e Landis (1962).

Page 62: Projeto de Algoritmo

62

ROTAÇÕES

Uma rotação é uma operação que transforma uma árvore binária em outra de tal forma que o percurso intra-ordem é preservado. Existem dois tipos desta operação: rotação para esquerda e rotação para direita. As figuras abaixo mostram uma árvore AVL e essa mesma árvore AVL rotacionada para a esquerda.

Árvore AVL

Árvore AVL rotacionada para esquerda

Note as mudanças que ocorreram nessa árvore. O ponteiro pdir do nó raiz passa a apontar para o ponteiro pesq no seu filho direito (antes apontado pelo pdir) e o ponteiro esquerdo pesq desse filho, que agora passa a ser o nó raiz, aponta para o antigo nó raiz, seu antigo nó pai. O algoritmo abaixo mostra as operações de rotacionamento do nó raiz para esquerda e para direita.

void rotate_left( fstream &f ) { No no_cabecalho, no_raiz_antiga, no_raiz_nova; long end_raiz_antiga, end_raiz_nova, auxiliar; f.seekg( 0, ios::beg ); f.read( (char *)&no_cabecalho, sizeof( No ) ); end_raiz_antiga = no_cabecalho.pdir; f.seekg( end_raiz_antiga * sizeof( No ) ); f.read( (char *)&no_raiz_antiga, sizeof( No ) ); end_raiz_nova = no_raiz_antiga.pdir; f.seekg( end_raiz_nova * sizeof( No ) ); f.read( (char *)&no_raiz_nova, sizeof( No ) ); no_raiz_antiga.pdir = no_raiz_nova.pesq; no_raiz_nova.pesq = end_raiz_antiga; no_cabecalho.pdir = end_raiz_nova; f.seekp( 0, ios::beg ); f.write( (char *)&no_cabecalho, sizeof( No ) ); f.seekp( end_raiz_antiga * sizeof( No ) ); f.write( (char *)&no_raiz_antiga, sizeof( No ) ); f.seekp( end_raiz_nova * sizeof( No ) ); f.write( (char *)&no_raiz_nova, sizeof( No ) ); }

Page 63: Projeto de Algoritmo

63

void rotate_right( fstream &f ) { No no_cabecalho, no_raiz_antiga, no_raiz_nova; long end_raiz_antiga, end_raiz_nova, auxiliar; f.seekg( 0, ios::beg ); f.read( (char *)&no_cabecalho, sizeof( No ) ); end_raiz_antiga = no_cabecalho.pdir; f.seekg( end_raiz_antiga * sizeof( No ) ); f.read( (char *)&no_raiz_antiga, sizeof( No ) ); end_raiz_nova = no_raiz_antiga.pesq; f.seekg( end_raiz_nova * sizeof( No ) ); f.read( (char *)&no_raiz_nova, sizeof( No ) ); no_raiz_antiga.pesq = no_raiz_nova.pdir; no_raiz_nova.pdir = end_raiz_antiga; no_cabecalho.pdir = end_raiz_nova; f.seekp( 0, ios::beg ); f.write( (char *)&no_cabecalho, sizeof( No ) ); f.seekp( end_raiz_antiga * sizeof( No ) ); f.write( (char *)&no_raiz_antiga, sizeof( No ) ); f.seekp( end_raiz_nova * sizeof( No ) ); f.write( (char *)&no_raiz_nova, sizeof( No ) ); }

REBALANCEAMENTO

Quando um nó é inserido na árvore, ela pode deixar de ser uma árvore AVL. Uma inserção afetará o balanceamento apenas daqueles nós que estão no caminho do nó raiz até o nó que acabou de ser inserido.

Existem quatro possibilidades de rotação. A rotação que deve ser executada depende da análise das alturas esquerda (hE) e direita (hD) de determinados nós. As possibilidades são:

CASO 1: hE(p) - hD(p) > 1, onde p é o nó desbalanceado

Caso 1.1: Rotação simples para direita hE(u) > hD(u), onde u é o filho esquerdo de p

Caso 1.2: Rotação dupla para direita hE(u) < hD(u)

Page 64: Projeto de Algoritmo

64

CASO 2: hD(p) - hE(p) > 1, onde p é o nó desbalanceado

Caso 2.1: Rotação simples para esquerda hE(z) < hD(z), onde z é o filho direito de p

Caso 2.2: Rotação dupla para esquerda hE(z) > hD(z)

OBS: quando as sub-árvores direita e esquerda tiverem a mesma altura, pode-se utilizar qualquer uma das duas rotações: simples ou dupla.

A figura a seguir mostra um exemplo com rotações simples e dupla em uma árvore AVL. Neste exemplo, a árvore começou apenas com o nó com chave 5 e, em seguida, foram acrescentados os nós com chaves: 4, 2, 8, 7 e 6, nesta ordem.

Exemplo de relabanceamento

A remoção de um nó da árvore também pode exigir um rebalanceamento.

Exercícios:

1. Criar uma árvore AVL vazia e acrescentar a ela os seguintes nós com as seguintes chaves: 50, 70, 20, 30, 25, 27, 12, 10, 8 e 40 (nesta ordem).

2. Remover da árvore criada os nós com as seguintes chaves: 50, 30, 10 e 8 (nesta ordem).

3. Com base nos códigos apresentados, implementar os quatro casos de rotação.

Page 65: Projeto de Algoritmo

65

8 ÁRVORES B

8.1 Introdução

Em muitas aplicações, a tabela considerada é muito grande, de forma que o armazenamento do conjunto de chaves não pode ser efetuado na memória principal, de uma só vez. Nesse caso, torna-se necessária a manutenção da tabela em memória secundária, o que acarreta um dispêndio de significativo para acesso a um nó da tabela.

Para essas aplicações, é de interesse a criação de uma estrutura que minimize o tempo de acesso para busca-s. inserções e remoções nessa tabela.

A árvore B, utilizando o recurso de manter mais de uma chave em cada nó da estrutura, proporciona uma organização de ponteiros tal que as operações mencionadas são executadas rapidamente. Além disso, sua construção assegura que as folhas se encontram todas em um mesmo nível, não importando a ordem de entrada de dados.

As árvores B são largamente utilizadas como forma de armazenamento em memória secundária. Diversos sistemas comerciais de bancos de dados as empregam.

Seja m um numero natural. Uma árvore B de ordem m é uma árvore ordenada que é vazia, ou que satisfaz as seguintes condições:

i) a raiz é uma folha ou tem no mínimo dois filhos;

ii) cada nó diferente da raiz e das folhas possui no mínimo m + 1 filhos:

iii) cada nó tem no máximo 2m + 1 filhos;

iv) todas as folhas estão no mesmo nível.

Um nó de uma árvore B é chamado de página. Uma página armazena diversas chaves. A estrutura apresentada satisfaz ainda a propriedade seguinte:

v) Seja d o numero de chaves em uma página P não folha. Então P tem d+1 filhos. Conseqüentemente, cada página possui entre m e 2m chaves, exceto o nó raiz que possui entre 1 e 2m chaves.

A primeira figura abaixo mostra como pode ser feita a representação de uma página e a segunda figura mostra a representação simplificada de uma página. Na primeira representação, pode-se ver que a página começa com um número n que indica quantas chaves estão armazenadas nela.

Representação de uma página

Page 66: Projeto de Algoritmo

66

Representação simplificada de uma página

A figura abaixo mostra um exemplo de árvore B de ordem m = 1.

Figura 1: Exemplo de árvore B

8.2 Busca

O método empregado na busca de uma chave x numa árvore B é semelhante ao utilizado na busca em uma árvore binária de busca. O mesmo caminhamento é executado na árvore, sendo apenas necessário acrescentar testes relativos às chaves existentes em cada página, que determinam qual o próximo passo do percurso. Observe, na, a busca da chave 18 na árvore B de ordem 1. De início, a chave procurada é comparada com a chave 22, armazenada na raiz. Na próxima página do caminho, deve-se procurar a posição adequada, testando-se o valor 18 com todas as chaves armazenadas no nó até que seja encontrado o ponteiro conveniente para o prosseguimento da busca (ou a chave ser encontrada). O caminho percorrido é representado pela linha tracejada.

Busca da chave 18 em uma árvore B de ordem 1

O algoritmo de busca compara a chave x, a chave procurada, com a chave (ou chaves) do nó-raiz. Sabe-se que, se a chave não se encontra na página em questão, a busca deve prosseguir em um certo filho dessa página, o qual é escolhido observando-se a seguinte propriedade:

“Todos as chaves armazenadas no filho apontado por pj em uma página são menores que a chave kj armazenada nessa página. Todas as chaves armazenadas no filho apontado por pj+1 são maiores que a chave kj.”

O código abaixo mostra a implementação de uma função de busca. Nesse código, o ponteiro p aponta para o nó inicial da busca e a função deve ser chamada com p apontando para a raiz da árvore. Considere para algoritmo, a existência de uma classe chamada Registro que contém dois campos: chave e endereço (do registro no arquivo de dados). Considere também a existência de

58

58

Page 67: Projeto de Algoritmo

67

uma classe chamada Página que contém um campo n que indica quantos registros estão presentes na página e dois vetores: um, de tamanho 2m, para armazenar objetos da classe Registro e outro, de tamanho 2m+1, para armazenar ponteiros para outras páginas; onde m é a ordem da árvore B.

long buscaB( int chave, Pagina *p ) { if( p == NULL ) return -1; int i=0; while( i < p->n-1 && chave > p->r[i].chave ) i++; if( chave == p->r[i].chave ) return p->r[i].endereco; if( chave < p->r[i].chave ) return buscaB( chave, p->p[i] ); else return buscaB( chave, p->p[i+1] ); }

8.3 Inserção

Considere agora o problema de inserir uma nova chave x em uma árvore B. O primeiro passo consiste em executar o procedimento buscaB. Se a chave já existir, o sistema não deve incluí-la novamente. Se a chave não existir e houver espaço suficiente para acrescentá-la na folha, basta fazer isso, mas garantindo que as chaves permanecerão ordenadas.

Entretanto, se não houver espaço suficiente na folha, esta deve ser dividida em duas e a chave do meio (considerando as 2m chaves existentes e também a nova chave) deve ser promovida, ou seja, ela deve ser armazenada na página pai da folha em questão. Se nesta página também não houver espaço para armazenamento da chave promovida, a divisão é repetida até que o problema seja resolvido.

A figura abaixo mostra o efeito da inserção da chave 35 em uma árvore de altura 1 e ordem 2 que já possui quatro chaves. A chave 21 foi promovida e a página foi dividida em duas, cada uma com m chaves, ou seja, duas chaves. A árvore passa a ter altura 2.

Inserção da chave 35

8.4 Remoção

A remoção de uma chave x também requer atenção especial. De início, dois casos, e suas respectivas soluções, devem ser considerados.

• x se encontra em uma folha: a entrada é simplesmente retirada; • x não se encontra em uma folha: x é substituída pela chave y, imediatamente maior.

Observe que y necessariamente pertence a uma folha. A retirada e substituição das chaves pressupõem que elas sejam acompanhadas de sua informação.

Page 68: Projeto de Algoritmo

68

A análise da remoção pode então se restringir ao caso em que esta operação é realizada em uma folha. Quando a chave é retirada, o número de chaves da página pode resultar menor que m (ordem da árvore). Existem dois tratamentos para esse problema, denominados concatenação e redistribuição.

Duas páginas P e Q são chamadas irmãos adjacentes se têm o mesmo pai W e são apontadas por dois ponteiros adjacentes em W. P e Q podem ser concatenadas se são irmãos adjacentes e juntas possuem menos de 2m chaves. A concatenaçâo agrupa as entradas das duas páginas em uma só. Para isto, no nó pai W deixa de existir uma entrada, justamente a da chave que se encontra entre os ponteiros para os irmãos P e Q. Esta chave passa a fazer parte do novo nó concatenado e seu ponteiro desaparece, uma vez que o nó Q é devolvido. Como a soma do número de chaves de P e Q era menor do que 2m, o novo nó tem, no máximo, 2m chaves.

A figura abaixo mostra o efeito da remoção da chave 12. Nesse exemplo, duas páginas foram concatenadas.

Remoção da chave 12

Se a página P e seu irmão adjacente Q possuem em conjunto 2m ou mais chaves, estas podem ser equilibradamente distribuídas: concatena-se P e Q, o que obviamente resulta numa página P grande demais. Imediatamente após, efetua-se uma cisão, considerando-se para isto a página Q já existente. Essa solução, a redistribuiçâo, não é propagável. A página W, pai de P e Q, é modificada, mas seu número de chaves permanece o mesmo. A figura abaixo mostra o resultado da distribuição após a remoção da chave 7. Note que no nó raiz, a chave 22 foi substituída pela chave 24.

Remoção da chave 7

Exercícios:

1. Criar uma árvore B vazia de ordem 2 e acrescentar a ela as seguintes chaves: 20, 10, 40, 50, 30, 55, 3, 11, 4, 28, 36, 33, 52, 17, 25, 13, 45, 9, 43, 8 e 48. (nesta ordem).

2. Remover da árvore criada as seguintes chaves: 45, 30, 28, 50, 8, 10, 4, 20, 40, 55, 17, 33, 11, 36, 3 e 52 (nesta ordem).

Page 69: Projeto de Algoritmo

69

8.5 Árvore B*

Uma árvore B* é uma árvore que possui, além das propriedades da árvore B, a seguinte propriedade:

“Cada página da árvore deve conter no mínimo 2/3 chaves”.

Para conseguir isto, o algoritmo deve executar sempre a redistribuição de chaves entre duas páginas irmãs até ambas ficarem cheias. Somente nesse caso haverá uma divisão de páginas. Mas, ao invés de duas, três páginas com 2/3 chaves serão geradas.

A figura abaixo mostra o resultado das inserções das chaves 30, 33, 35 e 32 a uma árvore B*.

Inserções em uma árvore B*

8.6 Árvore B+

Uma árvore B+ é uma árvore em que todas as chaves são armazenadas nas páginas folha. Esse tipo de árvore facilita a leitura seqüencial da árvore. A figura mostra uma árvore B+ de ordem 1.

Árvore B+

Page 70: Projeto de Algoritmo

70

Observe que na árvore B+, os ponteiros das páginas folha são utilizados de forma diferente dos ponteiros das outras páginas. No caso das páginas folha, o último ponteiro (p2m+1) aponta para a próxima página folha. Os demais ponteiros apontam para os registros nos arquivos de dados.

Nas buscas nas árvores B+, caso a chave seja encontrada em uma página que não seja folha, a busca deve prosseguir pelo ponteiro direito desta chave.

Nas operações de inserção, devemos ficar atentos à necessidade de se repetir as chaves nas páginas não folha. Isto significa que se uma chave for promovida, ela ainda deverá permanecer na página folha.

Finalmente, durante o processo de remoção de chaves, o desenvolvedor do algoritmo pode adotar duas estratégias:

• Manter as chaves removidas nas páginas não folhas. Por exemplo, se na árvore da figura acima a chave 17 fosse removida, ela seria retirada da página folha, mas seria mantida na página pai desta folha. Isso diminui a necessidade constante de redistribuição das chaves.

• Eliminar as chaves das páginas não folhas.

O restante do processo é semelhante ao processo de remoção de chaves em árvores B.

Exercícios:

1. Criar uma árvore B+ vazia de ordem 2 e acrescentar a ela as seguintes chaves: 92, 51, 52, 22, 54, 57, 3, 5, 1, 4, 61, 63, 37, 75, 11, 44, 25, 12 e 10. (nesta ordem).

2. Remover da árvore criada as seguintes chaves: 37, 5, 57, 63 e 92 (nesta ordem).

Page 71: Projeto de Algoritmo

71

9 ARQUIVOS SEQUENCIAIS INDEXADOS

9.1 Introdução Como vimos anteriormente, os arquivos seqüenciais são utilizados para acessos seriais. Mas quando o volume de acessos aleatórios em um arquivo seqüencial torna-se muito grande, surge a necessidade de utilização de uma estrutura de acesso, associada ao arquivo, que ofereça maior eficiência na localização de um registro identificado por um argumento de pesquisa do que os métodos vistos para arquivos seqüenciais.

Um arquivo seqüencial acrescido de um índice (estrutura de acesso) constitui um arquivo seqüencial indexado.

Um índice é formado por uma coleção de pares, cada um deles associando um valor da chave de acesso a um endereço no arquivo. Assim, um índice é sempre específico para uma chave de acesso.

Além do arquivo seqüencial e do índice, um arquivo seqüencial indexado possui áreas de extensão que são utilizadas para a implementação da operação de inserção de registros.

9.2 Índices A finalidade de um índice é permitir rápida determinação do endereço de um registro do arquivo, dado um argumento de pesquisa. O endereço identifica a posição onde está armazenado o registro, na memória secundária.

Usualmente, cada entrada do índice, formada por um par (chave do registro, endereço do registro), ocupa um espaço bem menor do que o registro de dados correspondente, o que faz com que a área ocupada pelo índice seja menor do que aquela ocupada pelos dados. Com isto a pesquisa sobre o índice pode ser feita com maior rapidez do que se fosse feita diretamente sobre o arquivo de dados correspondente. Este fato constitui a justificativa maior para a utilização dos índices.

Veja a figura a seguir, que apresenta o arquivo seqüencial indexado:

NÚMERO ENDEREÇO 100 1 150 2 200 3 250 4 300 5

|---------ÍNDICE---------|

NÚMERO NOME SALÁRIO 1 100 PEDRO 3000 2 150 JOÃO 1500 3 200 MARIA 2500 4 250 CARLA 3000 5 300 MAX 2000

|-----------ÁREA DE DADOS NO DISCO------------|

Page 72: Projeto de Algoritmo

72

Exemplo 1 – Busca seqüencial no arquivo de dados:

#include <fstream.h> #include <dos.h> class Cliente { public: int codigo; char empresa[61]; char telefone[13]; char endereco[61]; char numero[11]; char complemento[26]; char bairro[26]; char cep[9]; char cidade[26]; char estado[3]; }; void imprime(Cliente c) { cout << "Codigo: " << c.codigo << endl; cout << "Empresa: " << c.empresa << endl; cout << "Telefone: " << c.telefone << endl; cout << "Endereco: " << c.endereco << endl; cout << "Numero: " << c.numero << endl; cout << "Complemento: " << c.complemento << endl; cout << "Bairro: " << c.bairro << endl; cout << "CEP: " << c.cep << endl; cout << "Cidade: " << c.cidade << endl; cout << "Estado: " << c.estado << endl << endl; } void main() { struct time t; ifstream fin( "clientes1.dat", ios::binary ); int leituras = 0; Cliente c; int cc; cout << "Digite o codigo: "; cin >> cc; gettime(&t); printf("A hora atual e': %2d:%02d:%02d.%02d\n", t.ti_hour, t.ti_min, t.ti_sec, t.ti_hund); fin.read( (char *)&c, sizeof( Cliente ) ); while( fin ) { leituras++; if( c.codigo == cc ) { imprime( c ); break; } fin.read( (char *)&c, sizeof( Cliente ) ); }

Page 73: Projeto de Algoritmo

73

if( !fin ) cout << "Codigo nao encontrado!\n"; cout << "Registros testados: " << leituras << endl; gettime(&t); printf("A hora atual e': %2d:%02d:%02d.%02d\n", t.ti_hour, t.ti_min, t.ti_sec, t.ti_hund); }

Resultado:

Digite o codigo: 9000 A hora atual e': 3:50:48.30 Codigo: 9000 Empresa: FAC DE DIREITO MILTON CAMPOS Telefone: 3132861538 Endereco: RUA MILTON CAMPOS Numero: 202 Complemento: CX POSTAL 3268 Bairro: VILA DA SERRA CEP: 30130040 Cidade: BELO HORIZONTE Estado: MG Registros testados: 8473 A hora atual e': 3:50:48.85

Como podemos perceber, foram necessários 0,55 segundos para localizar o registro de código 9000. Foram pesquisados 8473 registros.

Exemplo 2 – Busca seqüencial no arquivo de índices:

#include <fstream.h> #include <dos.h> class Cliente { public: int codigo; char empresa[61]; char telefone[13]; char endereco[61]; char numero[11]; char complemento[26]; char bairro[26]; char cep[9]; char cidade[26]; char estado[3]; }; class Chave { public: int codigo; int indice; }; void imprime(Cliente c) { cout << "Codigo: " << c.codigo << endl; cout << "Empresa: " << c.empresa << endl; cout << "Telefone: " << c.telefone << endl; cout << "Endereco: " << c.endereco << endl; cout << "Numero: " << c.numero << endl; cout << "Complemento: " << c.complemento << endl;

Page 74: Projeto de Algoritmo

74

cout << "Bairro: " << c.bairro << endl; cout << "CEP: " << c.cep << endl; cout << "Cidade: " << c.cidade << endl; cout << "Estado: " << c.estado << endl << endl; } void main() { struct time t; ifstream fin( "clientes1.idx", ios::binary ); ifstream fin2( "clientes1.dat", ios::binary ); int leituras = 0; Chave c; Cliente c1; int cc; cout << "Digite o numero: "; cin >> cc; gettime(&t); printf("A hora atual e': %2d:%02d:%02d.%02d\n", t.ti_hour, t.ti_min, t.ti_sec, t.ti_hund); fin.read( (char *)&c, sizeof( Chave ) ); while( fin ) { leituras++; if( c.codigo == cc ) { fin2.seekg( c.indice*sizeof(Cliente),ios::beg); fin2.read( (char *)&c1, sizeof( Cliente ) ); imprime( c1 ); break; } fin.read( (char *)&c, sizeof( Chave ) ); } if( !fin ) cout << "Numero nao encontrado!\n"; cout << "Registros testados: " << leituras << endl; gettime(&t); printf("A hora atual e': %2d:%02d:%02d.%02d\n", t.ti_hour, t.ti_min, t.ti_sec, t.ti_hund); }

Resultado:

Digite o numero: 9000 A hora atual e': 3:56:47.90 Codigo: 9000 Empresa: FAC DE DIREITO MILTON CAMPOS Telefone: 3132861538 Endereco: RUA MILTON CAMPOS Numero: 202 Complemento: CX POSTAL 3268 Bairro: VILA DA SERRA CEP: 30130040 Cidade: BELO HORIZONTE Estado: MG Registros testados: 8473 A hora atual e': 3:56:47.96

Page 75: Projeto de Algoritmo

75

Nesse segundo exemplo, a busca do mesmo registro, de código 9000, gastou apenas 0,05 segundos. Isso mostra como o tamanho do arquivo interfere no tempo de busca.

Um índice pode ser estruturado em vários níveis para tornar a busca mais eficiente. O número de níveis é proporcional ao número de entradas do índice.

Como os registros estão ordenados no arquivo de dados (arquivo seqüencial indexado), pode-se usar uma entrada do índice para cada bloco (registro físico), armazenando no índice o valor da maior chave presente no bloco e o endereço de início do bloco. Dessa forma economiza-se espaço, pois não precisamos de uma entrada do índice para cada registro lógico.

O índice associado à chave de ordenação é chamado de índice primário, os demais são chamados de índices secundários. Os índices normalmente são implementados utilizando-se arvores B ou arvores B+, principalmente devido à facilidade (flexibilidade) de inserção/exclusão de entradas no índice e devido à eficiência das pesquisas em árvores deste tipo (poucos níveis).

9.3 Área de Extensão (Área de Overflow) A área de extensão (também chamada área de overflow) destina-se a conter os registros inseridos, em um arquivo seqüencial indexado, após a criação do arquivo. Ela constitui uma extensão da área principal de dados do arquivo.

As áreas de extensão são necessárias em arquivos seqüenciais indexados, porque nesses não é viável a implementação da operação de inserção de registros do mesmo modo que nos arquivos seqüenciais. Naquele processo, a maioria dos registros muda de endereço, o que obrigaria uma completa alteração nas entradas do índice, a cada atualização do arquivo.

Uma possível implementação de áreas de extensão em um arquivo seqüencial indexado consiste em destinar um em cada registro da área principal um campo de elo para conter o endereço da lista encadeada de seus sucessores (ou antecessores) alocados na área de extensão, conforme a figura:

NÚMERO ENDEREÇO 100 1 150 2 175 2 200 3 250 4 275 4 300 5

|---------ÍNDICE---------|

NÚMERO NOME ELO 1 100 PEDRO - 2 150 JOÃO 10 3 200 MARIA - 4 250 CARLA 20 5 300 MAX -

|-----------ÁREA DE DADOS NO DISCO------------|

NÚMERO NOME ELO 10 175 BILL 3 20 275 NARA 5 30 - 40 - 50 -

|----------------ÁREA DE EXTENSÃO----------------|

Outra alternativa é a implementação de um campo de elo para um bloco de registros.

Page 76: Projeto de Algoritmo

76

Arquivo Seqüencial Indexado com um campo de elo por registro:

Cada registro lógico na área principal de dados possui um campo de elo, que contem o endereço da lista encadeada (e ordenada) de seus antecessores armazenados na área de extensão.

Nas inserções um registro é inserido na lista de extensão de seu sucessor da área principal (a menos que ele seja inserido no final do arquivo, por sua chave ser maior que todas do arquivo).

Arquivo Seqüencial Indexado com um campo de elo por bloco:

Cada bloco de registros na área principal de dados possui um campo de elo, que contem o endereço da lista de extensão do bloco. Sendo que dentro de cada bloco os registros estão ordenados e todos os da área de extensão possuem valor de chave maior do que os que estão no bloco da área principal de dados.

Um registro é inserido no bloco selecionado, e o maior do bloco vai para a área de extensão.

No Arquivo Seqüencial Indexado com um campo de elo por bloco, os registros já armazenados podem mudar de posição (endereço) quando um novo registro é inserido, assim sendo, não deve ser usado quando há índices secundários para o arquivo.

O arquivo seqüencial indexado com um campo de elo por bloco ocupa espaço para a principal de dados menor que a organização com um campo de elo por registro (pois só terá um campo de elo a cada bloco em vez de a cada registro lógico).

A ordenação dentro do bloco é feita em memória, portanto não acarreta grande custo adicional.

9.4 Operações: A busca a um registro pode ser feito de duas formas:

Seqüencial – busca na área de dados e na área de extensão simultaneamente.

Aleatória – busca pelo índice até localizar o bloco do registro desejado e se o registro não está na área de dados, verificar a área de extensão.

A inserção de um novo registro é feita da seguinte forma:

Deve-se buscar pelo índice para determinar o local onde o novo registro deve ser inserido e fazer a inserção de acordo com a organização utilizada:

• Um campo de elo por registro: na lista encadeada de extensão de seu sucessor.

• Um campo de elo por bloco: ordenando os registros e enviando para área de extensão os de maior valor de chave do bloco.

Para se excluir um registro, deve-se localizá-lo e marcá-lo como excluído para posterior liberação do espaço alocado pelo mesmo (na reorganização).

A alteração de valores de campos de um registro é feita diretamente, após a localização deste registro, caso a alteração não seja feita na chave de ordenação e não alterar o tamanho do registro. Caso contrário, o registro é marcado como excluído e inserido novamente após a atualização.

Page 77: Projeto de Algoritmo

77

A leitura exaustiva (todos os registros em ordem) pode ser realizada sobre a área de dados, lembrando de apresentar na ordem os registros da área de extensão, acessíveis a partir do campo de elo da área principal.

Finalmente, a reorganização do arquivo deve ser feita através da leitura exaustiva dos registros e transferência dos mesmos para uma nova área principal de dados, ignorando-se os registros marcados como excluídos e liberando-se toda a área de extensão. Após a transferência de todos os registros (não excluídos) da área principal e da área de extensão para a nova área principal de dados, um novo índice deve ser criado.

A reorganização é necessária devido à degradação de performance provocada por: • grande número de acessos à área de extensão; • desconsideração dos registros marcados como excluídos.

Normalmente ela é indicada quando o arquivo não está sendo utilizado e/ou quando 75% da área de extensão estiver ocupada.

EXERCÍCIOS

1) Completar o código mostrado no exemplo 2 de forma a implementar as operações citadas acima.

2) Na tabela acima, do exemplo, executar as seguintes instruções:

a. Excluir o registro de chave 150 b. Acrescentar (112; Joana) c. Acrescentar (180; Gilberto) d. Excluir o registro de chave 200 e. Acrescentar (170; Roberto) f. Excluir o registro de chave 175 g. Alterar a chave do registro 112 para 185

Page 78: Projeto de Algoritmo

78

10 ARQUIVOS DIRETOS

10.1 Introdução

Os arquivos diretos são arquivos em que os registros são armazenados em endereços determinados com base no valor da chave primária. Esse endereço é obtido através da uma função hash (ou função de dispersão), que é uma função que retorna o endereço de um determinado registro a partir de uma operação matemática com sua chave. Uma função hash normalmente é na forma:

A = h(K)

Onde A é o endereço do registro e K sua chave.

Uma boa função hash deve ter uma distribuição aleatória, ou seja, deve evitar a concentração de registro em torno de um ou mais endereços. Em uma distribuição aleatória, as probabilidades de cada endereço receber um determinado registro são iguais.

Por exemplo, suponha que um programa deva armazenar 75 nomes em um arquivo com espaço para 1000 registros, ou seja, com 1000 endereços. Uma função hash para encontrar o endereço onde deve armazenar cada um desses nomes pode ser feita com base nos três últimos dígitos do resultado da multiplicação do valor ASCII das duas primeiras letras do nome.

Assim, teríamos:

JOÃO 74 x 79 = 5846 endereço: 846 CARLOS 67 x 65 = 4355 endereço: 355 GILBERTO 71 x 73 = 5183 endereço: 183

10.2 Colisões

Freqüentemente, uma função hash acaba retornando o mesmo endereço para dois registros diferentes. Imagine, por exemplo, se desejássemos armazenar o nome IGOR com a função acima. Ela indicaria o endereço 183 para armazenamento desse registro. Entretanto, esse endereço já foi ocupado pelo GILBERTO. Essa repetição de endereços é chamada de colisão.

Achar uma função perfeita, que não gere nenhuma colisão, é praticamente impossível. Portanto, devemos adotar algumas técnicas para minimizar o número de colisões, que são:

Espalhar mais os registros utilizando funções que gerem distribuições aleatórias; Usar mais espaço no arquivo, ou seja, ter mais endereços do que registros (mesmo que vários

endereços fiquem vazios); e Colocar mais de um registro em cada endereço.

Page 79: Projeto de Algoritmo

79

10.3 Exemplo de função hash

Os passos a seguir descrevem uma função hash simples:

1. Representar a chave (12 letras) em uma forma numérica:

GIOVANI = G I O V A N I x x x x x 71 73 79 86 65 78 73 32 32 32 32 32

2. Ajuntar os números dois a dois e somá-los:

7173 + 7986 + 6578 + 7332 + 3232 + 3232 = 35533

O resultado acima é maior do que 32767 (maior valor que uma variável int pode armazenar em um compilador padrão). Para evitarmos que isso aconteça, podemos utilizar o resto da divisão dos resultados das somas intermediárias por um número primo ao invés do próprio resto da divisão. O número primo adotado aqui foi 19937, pois este valor é menor que 32767 – 9090 (O maior valor possível é o valor para o par de letras ZZ).

7173 + 7986 = 15159 15159 mod 19937 = 15159 15519 + 6578 = 21737 21737 mod 19937 = 1800 1800 + 7332 = 9132 9132 mod 19937 = 9132 9132 + 3232 = 12364 12364 mod 19937 = 12364 12364 + 3232 = 15596 15596 mod 19937 = 15596

3. Ajustar o valor para a faixa válida de endereços (de preferência um número primo), através do cálculo do resto da divisão:

A = 15596 mod 101 = 46

Onde 101 é a quantidade de endereços disponíveis no arquivo.

Código para a função acima:

int Hash( char chave[12], int qtdeenderecos ) { int soma = 0; for( int j=0; j<12; j+=2 ) soma = ( soma + 100*chave[j] + chave[j+1] ) % 19937; return soma % qtdeenderecos; }

10.4 Distribuição dos registros

A melhor distribuição possível para os registros seria uma em que não houvesse nenhuma colisão, mas isso é muito difícil. Para uma função ser aceitável, ela deve ter pelo menos uma distribuição aleatória (em que as probabilidades de cada endereço são iguais). A figura abaixo mostra o melhor, o pior e um caso aceitável.

Page 80: Projeto de Algoritmo

80

Existem, entretanto, algumas técnicas para se tentar conseguir uma função hash com distribuição melhor que a aleatória. Algumas dessas regras foram empregadas na função hash do último exemplo.

Examinar as chaves em busca de um padrão (muitas vezes as chaves já possuem um padrão de distribuição que pode ser aproveitado pela função).

Misturar partes da chaves (se for necessário destruir algum padrão natural das chaves). Para conseguir essa mistura, parte (dígitos ou letras) devem ser extraídas das chaves e concatenadas ou somadas com outras partes.

Dividir a chave por um número. O uso do resto da divisão garante que o endereço ficará dentro de uma faixa pré-determinada. Números primos são mais adequados como divisores, pois geram resultados mais “diferentes” (têm menor chance de repetição).

Existem também algumas técnicas para se tentar criar uma função hash que tenha uma distribuição aleatória, caso não seja possível criar uma com distribuição melhor. Essas técnicas são:

Elevar a chave ao quadrado e pegar um grupo de dígitos do meio:

A = h(453) 4532 = 205209 A = 52 (dois dígitos foram escolhidos pois o arquivo possui apenas 100 endereços)

Mudar a chave para outra base:

A = h(453) 45310 = 38211 382 mod 99 = 85 A = 85 (e 99 é a quantidade de endereços no arquivo).

0 1 A 2 B 3 C 4 D 5 E 6 F 7 8 9 Desejável

0 1 A 2 B 3 C 4 D 5 E 6 F 7 8 9 Pior caso

0 1 A 2 B 3 C 4 D 5 E 6 F 7 8 9 Aceitável

Page 81: Projeto de Algoritmo

81

10.5 Previsão da distribuição

O número de colisões geradas por uma função Hash pode ser prevista através da Distribuição de Poisson:

( )!

)(x

eNr

xpNrx −

=

onde: r é o número de registros a armazenar no arquivo, N é o número de endereços disponíveis no arquivo, r/N é o fator de carga do arquivo e x é o número de registros que colidiram em um endereço.

p(x) indica a probabilidade de um endereço ter x registros atribuídos a ele. Por exemplo:

Se N = 1000 e r = 1000, então:

p(0) = 10 . e-1 / 0! = 0,368 = 36,8% p(1) = 0,368 = 36,8% p(2) = 0,184 = 18,4% p(3) = 0,061 = 6,1 % e assim em diante.

Isto significa que há uma probabilidade de 36,8% de um endereço não receber nenhum registro. Há também probabilidades de 36,8% de um endereço receber apenas um registro, de 18,4% do endereço receber dois registros e 6,1% de ele receber três registros (que, obviamente, terão que ser armazenados em outros endereços).

Podemos analisar o resultado de outra forma: o número esperado de endereços com x registros é: N * p(x).

Por exemplo: 1000 * p(0) = 368 1000 * p(1) = 368 1000 * p(2) = 184 e assim em diante.

Podemos dizer também que o número de registros que terão que ser deslocados são: x = 0 se nenhum registro é indicado para armazenamento nestes endereços, nenhum

registro terá que ser deslocado. x = 1 não há colisão. Apenas um registro é armazenado em cada um destes

endereços. x = 2 1000*p(2) = 184 184 registros serão armazenados nestes endereços e

outros 184 terão que ser deslocados. x = 3 1000*p(3) = 61 61 registros serão armazenados nesses endereços e outros

122 terão que ser deslocados.

E assim em diante.

Page 82: Projeto de Algoritmo

82

10.6 Memória extra necessária

Como dito anteriormente, uma das formas de se evitar colisões é aumentar o espaço para armazenamento dos registros. Usar um fator de carga menor ajuda a diminuir o número de colisões, mas, por outro lado, aumenta o tamanho do arquivo.

Se utilizarmos, por exemplo, um arquivo com 1000 endereços para armazenar 500 registros, teremos um fator de carga de 50%. De acordo com a Distribuição de Poisson, teremos também:

Endereços aos quais não foram atribuídos registros = 1000 * p(0) = 1000 * 0,607 = 607

Endereços aos quais foram atribuídos 1 registro = 1000 * p(1) = 1000 * 0,303 = 303

Endereços com colisões:

1000* [ p(2) + p(3) + p(4) + p(5) ] = 1000 * ( 0,0758 + 0,0126 + 0,0016 + 0,0002) = 1000 * 0,0902 = 90

Registros colididos:

1000*p(2) + 2*1000*p(3) + 3*1000*p(4) + 4*1000*p(5) = = 1000 * (0,0758 + 2*0,0126 + 3*0,0016 + 4*0,0002) = = 107

Porcentagem: 107 / 500 = 0,214 = 21,4%

Se o fator de carga é 50%, e cada endereço pode conter somente um registro, podemos esperar cerca de 21,4% de todos os registros sendo armazenados em algum lugar fora da área de endereçamento principal (endereços retornados pela função hash). A tabela abaixo mostra as porcentagens de registros armazenados fora da área principal.

Efeitos do fator de carga na proporção de registros não armazenados na área principal.

Densidade de agrupamento Sinônimos como % dos registros 10 4.8 20 9.4 30 13.6 40 17.6 50 21.4 60 24.8 70 28.1 80 31.2 90 34.1 100 36.8

Page 83: Projeto de Algoritmo

83

10.7 Tratamento de colisões

TRATAMENTO POR OVERFLOW PROGRESSIVO

Nesta forma, o registro que colidir, ou seja, o registro que tiver seu endereço já ocupado por outro registro, deverá ser deslocado para a próxima posição vazia do arquivo. Se o fim do arquivo for atingido, então a busca continuará a partir do início do arquivo e deverá prosseguir até a posição inicial, ou até um endereço vazio ser encontrado.

3 4 Paulo João 5 Roberto Ocupado 6 Joana Ocupado 7 Vazio (João é armazenado aqui) 8 Carla 9

Exclusão – Os registros excluídos devem apenas ser marcados como excluídos, pois assim não quebram a seqüência de overflow. O endereço poderá ser reaproveitado quando necessário.

TRATAMENTO POR BLOCOS

Cada endereço pode armazenar mais de um registro. Se, entretanto, todo o bloco estiver ocupado, o tratamento por overflow é utilizado, ou seja, o registro é armazenado na primeira posição vazia.

0 Marcelo 1 2 Carlos Roberto João 3 Juliana Fernanda Paulo 4 Liliane João 5 Tiago

Nesse caso, o fator de carga passa a ser calculado através da fórmula:

Fator de carga = Nb

r⋅

Onde: r = número de registros a armazenar N = número de endereços b = número de espaços por endereço

TRATAMENTO POR DOUBLE HASHING

Uma segunda função hash é aplicada para gerar um número C relativamente primo ao endereço A retornado pela primeira função hash. C será adicionado a A tantas vezes quantas forem necessárias para encontrar um endereço vazio.

TRATAMENTO POR ENCADEAMENTO PURO

Este tratamento de colisões é semelhante ao tratamento por overflow progressivo. Porém, um ponteiro para o próximo membro de uma lista encadeada é acrescentado aos registros, diminuindo o número de acessos necessários para localizar um registro.

Page 84: Projeto de Algoritmo

84

Endereço inicial

Endereço real Dados

Ponteiro para o próximo membro da lista

20 20 Adão 22 21 21 Bernardo 23 20 22 Carla 25 21 23 Daniel -1 24 24 Érica -1 20 25 Fábio -1

Na figura anterior, os nomes Adão, Carla e Fábio tiveram como endereço inicial a posição 20. Entretanto, apenas o primeiro deles, Adão, foi armazenado ali. Os demais foram armazenados nas próximas posições vazias e ponteiros foram criados para indicar o encadeamento.

Ao utilizarmos esse tratamento de colisões, podemos enfrentar um problema: imagine se precisássemos incluir o nome Roberta, com endereço inicial 22, na lista acima. Esse endereço já foi ocupado pelo nome Carla. E, se acompanharmos o ponteiro desse registro, acompanharemos a lista de registro com endereços iniciais iguais a 20 (e não 22 como seria necessário).

Para resolver esse problema, podemos garantir que o primeiro registro de cada lista encadeada fosse armazenado obrigatoriamente no seu endereço inicial (calculado pela função hash). Isso pode ser feito facilmente com um processo chamado carga de dois passos.

Nesse processo, os registros de um arquivo direto são armazenados em dois passos, como o próprio nome diz. No primeiro passo, apenas os registros iniciais de cada lista encadeada são carregados. Os demais são armazenados em um outro arquivo. No segundo passo, os registros que sobraram são armazenados nos endereços vazios e as listas encadeadas são reconstruídas.

O gerenciamento de inclusões e exclusões nesse arquivo, entretanto, é um processo bastante complicado.

TRATAMENTO POR ENCADEAMENTO COM ÁREAS DE EXTENSÃO

Esse tratamento resolve o problema anterior. Apenas os primeiros registros da lista encadeada são armazenados na área principal de dados. Os demais registros são armazenados em uma área de extensão, como mostra a figura a seguir.

,,, 20 Adão 101 21 Bernardo 102 22 Roberta -1 Área principal 23 24 Érica -1 ,,,

101 Carla 103 102 Daniel -1 Área de extensão 103 Fábio -1 104

,,,

Page 85: Projeto de Algoritmo

85

TRATAMENTO COM ÍNDICES (SCATTER TABLES)

Nesta última forma de tratamento de colisões, dois arquivos são utilizados. O primeiro, serve apenas como índice para o segundo. O segundo é utilizado para armazenar os dados dos registros. A figura a seguir ilustra esse conceito.

,,, ,,,

20 1 1 Adão 3

21 2 2 Bernardo 7

22 4 3 Carla 5

23 4 Roberta -1

24 6 5 Fábio -1

,,, 6 Érica -1 7 Daniel -1 ,,,

10.8 Operações em arquivos diretos Acesso a um registro

• Acesso seqüencial o Pelo conhecimento do valor da chave do próximo registro da seqüência

• Acesso aleatório o Em caso de colisão :

Encadeamento puro : percorrer a lista Encadeamento aberto : procurar nos endereços seguintes

Inserção de um registro • Organização por encadeamento

o Com áreas de extensão insere na 1ª posição livre da área de extensão insere no início da lista encadeada

o Com encadeamento puro insere no endereço calculado, caso esteja livre caso esteja ocupado

• coloca seu endereço no fim da lista de links • Organização por endereçamento aberto

o se o endereço calculado estiver livre então o novo registro é armazenado nele o caso contrário, o novo registro é armazenado no endereço livre mais próximo

Remoção de um registro • Através de uma marca indicando sua exclusão • Através da retirada da lista de colisões • Espaços “vagos” decorrentes de exclusões não são indicação que uma busca por uma

chave terminou. Reorganização do arquivo

• Garante a eficiência de acesso • Possibilidade de re-agrupamento dos registros da mesma lista, quando a organização

inclui áreas de extensão ou trabalha com encadeamento puro • Ocorre a alteração do campo de endereço na tabela de headers em conseqüência do

deslocamento dos registros na área de dados • Possibilita a remoção física dos registros marcados para exclusão • Otimização de espaço

Page 86: Projeto de Algoritmo

86

11 ARQUIVOS INVERTIDOS Até este ponto, a discussão relacionada à organização de arquivos esteve restrita aos métodos de acesso através da chave primária. Mas e se eu preferir achar um livro pelo seu nome, por exemplo “File Structures”, ao invés de seu código 01404610? É exatamente isso que acontece quando vamos a uma biblioteca. Nós utilizamos informações secundárias, como o título, o nome do autor ou o assunto para localizar o código do livro (chave primária).

Assim, poderíamos ter catálogos de títulos, autores e assuntos dos livros que nos interessam. Se eu quisesse procurar pelos livros de Michael Folk, eu consultaria o catálogo de autores ao invés de fazer uma busca seqüencial pelos livros (em toda a biblioteca). Este e os outros campos são chamados de chaves secundárias. Eles são utilizados para a construção de índices que relacionam autores, títulos e assuntos os códigos de livro, isto é, às chaves primárias.

Após encontrarmos a chave primária de um determinado livro, ainda devemos fazer uma busca em outro índice para encontrar o endereço físico do registro (ou do livro). Em alguns casos, pode ser interessante fazer com que a entrada no índice secundário aponte diretamente para o endereço do registro no arquivo, ao invés de conter a chave primária.

11.1 Inserção de um registro Quando há um índice de chave secundária, a adição de um registro ao arquivo implica na adição de uma entrada nesse índice. O processo para fazer isso é semelhante à uma adição de uma entrada no índice primário: os registros devem ser deslocados para frente ou os ponteiros de uma lista encadeada devem ser rearranjados. Assim como nos índices primários, o custo para fazer isso diminui muito se o índice secundário puder ser carregado na memória e operado lá.

Uma diferença importante entre o índice secundário e o índice primário é que o índice secundário pode conter chaves duplicadas. Na figura abaixo, existem três funcionários com idade igual a 20 anos (a idade é a chave secundária neste caso). As chaves duplicadas são agrupadas. Dentro de cada grupo, elas devem ser ordenadas de acordo com a chave primária, que neste caso é o número de matrícula. O motivo disso será explicado mais tarde.

11.2 Exclusão de um registro A exclusão de um registro envolve remover todas as referências ao registro. Assim, remover um registro de um arquivo significa não apenas remover a entrada correspondente no índice primário, mas também todas as entradas nos índices secundários. O problema é que nos índice secundários, da mesma forma que no índice primário, as entradas são mantidas de forma ordenada. Conseqüentemente, excluir uma entrada envolve rearranjar as entradas restantes para eliminar o espaço liberado na exclusão.

Na realidade os índices secundários podem não ser atualizados para economizar processamento. Nesse caso, eles continuariam apontando para chaves primárias que não existem mais no índice primário. Quando ocorrer a busca de uma dessas chaves, o sistema retorna um indicador de registro não encontrado, e a busca no arquivo de dados pode ser dispensada.

Page 87: Projeto de Algoritmo

87

Arquivo de dados Índice secundário:

Matr Nome Idade Salário Idade Matr. 0 1000 ANTÔNIO 25 5000 20 1050

1 1050 AFONSO 20 7000 20 1900

2 2400 CRISTIAN 25 5500 20 2000

3 1850 EDSON 21 5500 21 1850

4 1440 YEDA 22 5000 22 3150

5 3150 SANDRA 22 7000 22 3150

6 2000 FLÁVIA 20 5500 25 1000

7 1900 ROBERTO 20 10000 25 2400

11.3 Atualização de um registro Se o índice secundário apontar para o índice primário, ao invés do endereço do registro no arquivo de dados, este índice primário dispensará mudanças no índice secundário, caso as informações no arquivo de dados sejam alteradas. Isto é, se um registro mudar de posição no arquivo de dados (resultado de uma reorganização de arquivo), mas mantiver sua chave primária, então o índice secundário não precisará ser alterado. O índice secundário só precisará ser alterado caso as mudanças ocorram na chave primária ou na chave secundária.

11.4 Busca utilizando uma combinação de chaves secundárias Uma das aplicações mais importantes para as chaves secundárias envolve o uso de duas ou mais delas em combinação para localizar um determinado conjunto de registros no arquivo de dados. Para mostrar um exemplo de como isso pode ser feito, nós podemos utilizar duas chaves secundárias de um arquivo de uma bilbioteca: autor e título. E, assim, poderemos responder a pedidos como:

Encontre o registro com o código 38358 (acesso pela chave primária);

Encontre todos os registros de Michael Folk (acesso pela chave secundária autor);

Encontre todos os registros chamados File Structures (acesso pela chave secundária título).

O que é mais interessante, entretanto, é que nós também podemos responder a uma combinação de autor e título, como: ‘encontre todos os livros chamados File Structures de Panos Livadas’. Sem as chaves secundárias, este tipo de pedido exigiria uma busca seqüencial por todo o arquivo. Em um arquivo contendo milhares ou centenas de milhares de registros, este seria um processo demorado. Mas, com a ajuda dos índices secundários, isso se torna simples e rápido.

Nós começamos nossa resposta procurando no índice de títulos pela lista de livros chamados ‘File Structures’. Isso retornar os seguintes códigos: 941, 4016 e 9434. Em seguida, buscamos por ‘Panos Livadas’ no índice de autores. Encontraremos 941 e 9407. Finalmente, conhecendo essas duas listas de chaves primárias, fazemos uma comparação e buscamos as chaves que aparecem em ambas. Nesse exemplo, encontraremos apenas 941. Esse código será utilizado em

Page 88: Projeto de Algoritmo

88

uma busca no índice primário para descobrirmos o endereço do registro no arquivo de dados, onde encontremos os demais dados do livro.

Arquivo de dados Índice Primário End. Código Título Autor Código End.

0 4016 File Structures Michael Folk 941 1 1 941 File Structures Panos Livadas 4016 0 2 9407 Computer Organization Panos Livadas 5212 6 3 7096 File Systems Thomas Harbron 7096 3 4 9434 File Structures David Lefkovitz 8104 5 5 8104 Data Management David Lefkovitz 9407 2 6 5212 File Organization Alan Tharp 9434 4

Índice Secundário Título Índice Secundário Autor Título Códigos Título Códigos Computer Organization 9407 Alan Tharp 5212 Data Management 8104 David Lefkovitz 8104 File Organization 5212 David Lefkovitz 9434 File Structures 941 Michael Folk 4016 File Structures 4016 Panos Livadas 941 File Structures 9434 Panos Livadas 9407 File Systems 7096 Thomas Harbron 7096 Repare que apenas um acesso ao arquivo de dados foi necessário para localização do registro (mas vários acessos aos índices também foram necessários).

Usando a capacidade de combinar listas ordenadas rapidamente, nós podemos buscar não só interseções, mas também uniões dessas listas. E como os dados nos índices estão ordenados, nós podemos fazer isso rapidamente (p.ex. através de uma busca binária) e sem ter que ordenar o arquivo de dados. Como os índices ocupam menos espaço que o arquivo de dados, é bem provável que eles possam ser gerenciados na memória, tornando o processo ainda mais rápido.

Para agilizar ainda mais o processo, ao invés de acumularmos as chaves encontradas em cada um dos índices secundários, poderíamos percorrer esses índices simultaneamente, avançando cada hora aquele que tiver a menor chave primária. Assim, encontraríamos as interseções durante o próprio processo de leitura.

11.5 Arquivos invertidos A estrutura para os índices secundários que nós vimos tem duas dificuldades:

Nós temos que rearranjar o índice toda vez que um registro é adicionado ao arquivo.

Se existirem chaves secundárias duplicadas, essa chave será repetida para cada entrada, ocupando mais memória e tornando o processo mais lento.

Page 89: Projeto de Algoritmo

89

Um forma simples de resolver isso é associar um vetor de referências (valores de chaves primárias) a cada chave secundárias. Por exemplo, nós poderíamos usar uma estrutura que permitisse associar três códigos a um nome de autor:

PANOS LIVADAS 941 9407

Dessa forma, não haverá necessidade de se rearranjar o índice toda vez que um registro for inserido. Se houver espaço para outra referência, basta colocá-la na lista. No registro acima, por exemplo, ainda há espaço para mais uma referência.

Mas ainda estamos limitados há uma quantidade pré-determinada de referências. No registro abaixo, por exemplo, não há mais espaço. Todas os três espaços para referências já foram utilizados.

FILE STRUCTURES 941 4016 9434

Além disso, pode ser que o espaço desperdiçado com as referências vazias (fragmentação) seja maior do que o economizado ao não ter que se repetir a chave secundária.

Devemos buscar por uma estrutura que:

Mantenha a característica atrativa de não termos que reorganizar os índices secundários para cada entrada nova no arquivo de dados.

Permita que mais de três referências sejam associadas a cada chave secundária.

Elimine a perda de espaço devido à fragmentação.

E a reposta para isso é a lista invertida. Cada chave secundária leva a um conjunto de chaves primárias, como mostra a figura abaixo. A lista é chamada de invertida porque partimos de um campo do registro para encontrar sua chave primária e, depois, o registro completo. A lista é chamada de lista por contém uma lista de referências de chaves primárias.

A figura abaixo mostra uma situação ideal em que cada chave secundária aponta para uma lista diferente de chaves primárias. Cada uma dessas listas pode crescer da forma que precisar.

Alan Tharp 5212 David Lefkovitz Michael Folk 8104 Panos Livadas 9434 Thomas Harbron 4016 941 9407 7096

Mas como manter um número ilimitado de listas com tamanho diferente sem criar um grande número de pequenos arquivos? A forma mais simples de fazer isso é através de listas encadeadas. Nós poderíamos redefinir nosso índice secundário para que ele consista de registros

Page 90: Projeto de Algoritmo

90

com dois campos – a chave secundária e o endereço da primeira ocorrência na lista invertida em um outro arquivo. Nesse arquivo, cada chave primária estará acompanhada do endereço do próximo elemento da lista, como mostra a figura abaixo. O valor -1 indica o último elemento da lista.

Alan Tharp 0 0 5212 -1 David Lefkovitz 1 1 8104 4 Michael Folk 6 2 941 5 Panos Livadas 2 3 7096 -1 Thomas Harbron 3 4 9434 -1 5 9407 -1 6 4016 -1

Algumas vantagens dessa abordagem são:

A única vez que temos que rearranjar um índice secundário é quando um novo autor é adicionado ou o nome de autor existente é alterado. A inclusão ou exclusão de livros de autores já existentes, implica apenas no ajuste da lista invertida. Para apagarmos todos os livros de um autor, podemos fazer o ponteiro do índice valer -1, indicando que não há livros para ele.

Se ainda assim precisarmos rearranjar o índice secundário, ele conterá menos dados e esse processo será executado mais rapidamente.

Como há menos necessidade de ordenações, podemos tentar manter o índice no disco, deixando a memória para outros dados.

A lista invertida não precisa ser ordenada nunca. Seus registros ficam de acordo com a ordem de chegada.

Como os registros da lista invertida têm tamanho fixo, fica fácil bolar um esquema para ocupar espaços vazios deixados por registros excluídos.

Uma desvantagem é que as chaves primárias associadas a um autor na lista invertida não estarão mais próximas. Uma possível solução para esse problema é criar uma lista encadeada por blocos, que possa periodicamente ser reorganizada. Mas ela consumirá mais espaço do que a lista encadeada simples.

Page 91: Projeto de Algoritmo

91

11.6 Comparação entre os tipos de organização dos arquivos

Arquivo Vantagens Desvantagens

Seqüencial Acessos seqüenciais mais eficientes.

Operações de modificações não são simples.

Seqüencial Indexado

Utilizam índices, que agilizam a consulta por estarem na RAM.

Necessidades de áreas de extensão, que precisam ser reorganizadas.

Indexado Não existem áreas de extensão Registros sem compromisso com armazenamento físico.

Necessidade de atualização do índice após inserção de um registro.

Direto Acesso direto, sem necessidade do índice.

Determinar funções que gerem menor número de colisões

Invertido

Acesso quase direto ao registro após localização da lista invertida. As listas invertidas não precisam ser recriadas se houver mudança de endereço. Busca rápida de combinações de campos.

As (várias) listas devem ser recriadas sempre que um registro for inserido. As listas invertidas devem ser recriadas com alterações em campos que não são chave primária (e também para ela).

Page 92: Projeto de Algoritmo

92

12 COMPRESSÃO DE DADOS5

12.1 Introdução Compressão de dados é o processo de redução do número de bits usados para representar os dados. É um dos resultados mais significativos da teoria da informação, uma área da matemática que trata dos vários modos de se gerenciar e manipular informação. A compressão de dados envolve dois processos: em um deles os dados são comprimidos, ou, codificados, para terem seu tamanho reduzido; no segundo processo eles são descomprimidos, ou decodificados, para voltar ao tamanho original.

Para entender por que a compressão de dados é possível, devemos compreender primeiro que todos os dados podem ser caracterizados através de alguma informação neles contida; isso chama entropia (um termo emprestado da termodinâmica). A compressão é possível, porque a maioria dos dados é representada usando mais bits do que a entropia sugere como sendo a opção adequada. Para avaliar a eficácia da compressão, observamos a proporção do tamanho dos dados comprimidos divididos pelo tamanho original e daí subtraímos 1. Este valor é conhecido como razão da compressão dos dados.

Em um sentido mais amplo, os métodos de compressão de dados são divididos em duas classes: com perdas e sem perdas. Em compressões com perdas, aceitamos alguma perda de precisão em troca de maior proporção de compressão. Isto é aceitável em algumas aplicações tais como gráficos e processamento de som, contanto que a degradação seja cuidadosamente gerenciada. Com freqüência usamos compressão sem perdas, o que nos assegura uma reprodução exata dos dados originais, quando eles são descomprimidos.

Neste capítulo vamos nos concentrar na compressão sem perdas, para a qual existem duas abordagens genéricas: codificação de redundância mínima e método do dicionário. A codificação de redundância mínima alcança a compressão através da codificação de símbolos que aparecem com grande freqüência, usando menos bits do que para aqueles que aparecem com menor freqüência. Métodos baseados no dicionário codificam dados em termos de fichas que tomam o lugar de frases redundantes. Este capítulo trata de:

Operações de bit – Uma parte importante da compressão de dados, porque a maioria dos métodos necessita operar nos dados um bit de cada vez. A linguagem C fornece um número de operadores de bits que podem ser usados para implementar uma classe extensa de operações com bits.

Run Length Encoding – Uma das formas mais simples de compressão. Baseia-se eliminação da repetição dos dados. É utilizado para compactar tanto arquivos texto quanto imagens.

Código Huffman – Uma das formas mais antigas e elegantes de compressão baseada na codificação de redundância mínima. É fundamental à codificação Huffman a construção de uma árvore Huffman, que é usada tanto para codificar como para decodificar os dados. A codificação Huffman não é a forma mais eficaz de compressão, porém funciona de maneira rápida tanto na compressão quanto na descompressão de dados.

LZ77 (Lempel-Ziv-1977) – Um dos métodos fundamentais para a compressão baseada em dicionário. LZ77 usa uma janela deslizante e um buffer look-ahead (de observação à frente) para codificar símbolos em termos de frases encontradas anteriormente nos dados. LZ77 geralmente 5 Notas de aula e Capítulo 14 do livro “Dominando Algoritmos com C” de Kyle Loudon

Page 93: Projeto de Algoritmo

93

resulta em proporções de compressão melhores que a codificação Huffman, porém com tempos de compressão maiores. Contudo, a descompressão de dados é geralmente rápida.

LZ78 (Lempel-Ziv-1978) – Em 1978, os autores do LZ77 modificaram seu algoritmo para eliminar a janela deslizante e, assim, fazer com que o dicionário não se baseasse mais apenas nos símbolos mais recentes.

LZW (Lempel-Ziv-Welch) – Esta é talvez a forma mais comum da família Lempel-Ziv. Neste algoritmo, o dicionário é preenchido inicialmente com todos os símbolos do alfabeto

Algumas aplicações da compressão de dados sem perdas são:

Distribuição de software – O processo de envio de software através de vários meios. Ao distribuir softwares através de meios físicos, tais como compact discs ou fitas magnéticas e disquetes, a redução da quantidade de armazenagem requerida pode trazer redução considerável de custos quando se fala em distribuição de massa.

Arquivamento – Trata-se da coleção de grupos de arquivos organizados em bibliotecas. Normalmente, os arquivos contêm grandes quantidades de dados. Assim, após criar arquivos, freqüentemente os comprimimos.

Computação móvel – Uma área da computação na qual os instrumentos têm normalmente uma quantidade limitada de memória e de armazenagem secundária. A computação móvel geralmente se refere à computação que utiliza instrumentos pequenos e portáteis, tais como calculadoras programáveis, agendas eletrônicas e outros tipos de computadores.

Rede otimizada – A compressão é usada especialmente quando se enviam grandes quantidades de dados através de redes de grande porte. A largura de faixa em certos pontos de uma rede de grande porte é geralmente limitada. Ainda que a compressão e descompressão de dados necessitem de tempo, em muitas aplicações o custo é justificado.

Aplicações embutidas – Uma área da computação similar a computação movei, uma vez que certos instrumentos apresentam memória e armazenagem secundária limitadas. Exemplos de aplicações embutidas são os instrumentos de laboratórios, aviônica (eletrônica da aviação), VCRs, equipamentos de som e outros equipamentos construídos em torno de microcontroles.

Sistemas de bases de dados – Normalmente, grandes sistemas podem ser otimizados através da redução proporcional ao seu tamanho. As bases de dados podem ser comprimidas ao serem registradas ou armazenadas.

Manuais online – Manuais que são acossados diretamente em um computador. Manuais online são geralmente de tamanho considerável, porém muitas seções não são acessadas regularmente. Assim sendo, é comum armazená-las de maneira comprimida e descomprimir as seções somente quando necessário.

12.2 Compressão unária A compressão unária é um método de compressão indicado apenas para números e quando os números a serem compactados são relativamente pequenos.

Usando esse método, um número N será representado através de N bits 1. Mas se vários números consecutivos forem compactados, eles devem ser separados por zeros.

Page 94: Projeto de Algoritmo

94

Exemplos:

3 111 7 1111111 12 111111111111 3 4 1 1110111101 (não se trata de 341, mas de três números distintos)

Como pode ser percebido através dos exemplos, a compressão unária só será eficaz para números iguais ou inferiores a 8.

12.3 Compressão Elias-Gama

A compressão Elias-γ obtém taxas de compressão melhores utilizando uma combinação de compressão unária com representação binária. Para isso, os números compactados com este método são compostos pela concatenação dos bits de três partes:

1) LOG2(X) representa a maior potência de 2 que não excede X, ou seja, 2LOG2(X) ≤ X. Essa parte deve ser compactada com a compressão unária.

2) 0 zero separador entre a primeira e terceira partes.

3) X – 2LOG2(X) Resto de X menos a primeira parte, representado na base binária e com LOG2(X) bits.

Exemplos:

X = 7 LOG2(7) = 2 ............................................................................... 11 Separador ....................................................................................... 0 X – 2LOG2(7) com LOG2(7) bits ................................................. 11 Resultado ....................................................................................... 11011

X = 12 LOG2(12) = 3 ............................................................................. 111 Separador ....................................................................................... 0 X – 2LOG2(12) com LOG2(12) bits .............................................. 100 Resultado ....................................................................................... 1110100

Como é possível perceber pelos exemplos, os números compactados com Elias-γ utilizam 2 * LOG2(X) + 1 bits.

A recuperação de um número compactado com Elias-γ é bem simples. Basta contar a quantidade de bits 1 até encontrar o primeiro zero e, em seguida, converter a mesma quantidade de bits (descartando esse zero separador) da base binária para a decimal e adicionar o resultado da conversão à potencia de 2 do número anterior.

Exemplo:

111100101 número de bits 1 encontrados até o primeiro zero .................. 4 potência de 2 desse número ..................................................... 24 = 16 número binário com a mesma quantidade de bits ................... 0101 conversão desse número para a base decimal ......................... 9 Resultado ................................................................................. 16 + 9 = 25

Page 95: Projeto de Algoritmo

95

12.4 Run Length Encoding

A idéia por trás desta compressão RLE (Run Length Encoding) é a seguinte: se um item d aparece n vezes consecutivas nos dados originais, então essas n ocorrências podem ser substituídas por um par nd. Esta técnica pode ser aplicada tanto à compressão de texto quanto à compressão de imagens.

Uma string “AAABBACCCDDEEEEEF” seria armazenada como ”@3A@2BA@3C@2D@5EF”. O símbolo @ é utilizado para representar o caráter escape. Toda vez que os dados repetidos são substituídos, esse caráter deve ser utilizado para evitar a confusão com os dígitos numéricos.

O RLE é de fácil implementação e de execução rápida, mas que não produz taxas de compressão comparáveis com métodos mais complexos, porém mais lentos.

12.5 Huffman

Uma das mais antigas e mais elegantes formas de compressão de dados é a codificação Huffman, um algoritmo baseado na codificação de redundância mínima. A codificação de redundância mínima sugere que, se conhecemos a freqüência com que os símbolos ocorrem em um conjunto de dados, podemos representar os símbolos de maneira tal que os dados necessitem de menos espaço. A idéia é codificar símbolos que ocorrem em maior freqüência com menos bits do que aqueles que ocorrem com menor freqüência. É importante observar que um símbolo não é necessariamente um caractere do texto: um símbolo pode ser qualquer quantidade de dados que escolhemos, mas vale normalmente um byte.

ENTROPIA E REDUNDÂNCIA MÍNIMA

Para começar, revisemos o conceito de entropia apresentado no começo deste capítulo. Devemos lembrar que cada conjunto de dados tem um conteúdo informativo que é chamado entropia. A entropia de um conjunto de dados é a soma das entropias de cada um de seus símbolos. A entropia S de um símbolo z é definida assim:

Sz = - log2( Pz )

onde Pz é a probabilidade de z ser encontrado nos dados. Se sabemos exatamente quantas vezes z aparece, nos referimos a Pz como a freqüência de z. Como um exemplo, se z aparece 8 vezes em 32 símbolos, um quarto do tempo, a entropia de z é:

- log2(1/4) = 2 bits

Isto quer dizer que usar mais do que dois bits para representar z é mais do que precisamos. Se considerarmos que normalmente o representamos usando oito bits (um byte), vemos que a compressão aqui tem potencial para melhorar muito a representação.

A tabela abaixo apresenta um exemplo de cálculo de entropia de alguns dados, contendo 72 ocorrências de cinco símbolos diferentes. Para fazer isso, somamos as entropias com que cada símbolo contribui. Usando "U" como exemplo, a entropia total para um símbolo é computada da seguinte maneira: uma vez que "U" aparece 12 vezes no total de 72, cada ocorrência de "U" tem uma entropia que é calculada da seguinte maneira:

- log2(12/72) = 2,584963 bits

Page 96: Projeto de Algoritmo

96

Conseqüentemente, uma vez que "U" ocorre 12 vezes nos dados, a sua contribuição para a entropia dos dados é calculada assim:

(2,584963)(12) = 31,01955 bits

Para calcular a entropia total dos dados, somamos as entropias totais, contribuição de cada símbolo. Para fazermos isso com os dados da Tabela 1, temos:

31,01955 + 36,00000 + 23,53799 + 33,94552 + 36,95994 =161,46300 bits

Se o uso de 8 bits para representar cada símbolo resulta em um tamanho de dados de (72)(8) = 576 bits, teoricamente deveríamos poder comprimir estes dados, em até o seguinte:

1 - (161,463000/576) = 72.0%

Símbolo Probabilidade Entropia para cada Ocorrência Entropia Total

U 12/72 2,584963 31,01955

V 18/72 2,000000 36,00000

W 7/72 3,362570 23,53799

X 15/72 2,263034 33,94552

Y 20/72 1,847997 36,95994

A entropia de um conjunto de dados contendo 72 ocorrências de 5 símbolos diferentes

CONSTRUÇÃO DA ÁRVORE

A árvore de Huffman apresenta meios de se aproximar a representação otimizada dos dados baseados em suas entropias. Isto ocorre através da construção de uma estrutura de dados chamada árvore de Huffman, que é uma árvore binária organizada para gerar os códigos Huffman. Os códigos Huffman são os códigos designados aos símbolos nos dados para que se atinja a compressão. Porém, os códigos Huffman resultam em compressões que somente aproximam as entropias dos dados, como você pode ter notado na Tabela 1. As entropias dos símbolos freqüentemente acabam sendo frações dos bits. Uma vez que o número de bits usados nos códigos Huffman não pode ser, na prática, fração, alguns códigos acabam tendo um número ligeiramente excessivo de bits não apropriados.

A figura a seguir ilustra o processo de construção de uma árvore Huffman a partir dos dados da tabela acima. A construção de uma árvore Huffman começa nos nodos de folha e segue para cima. Para começar, colocamos cada símbolo e sua freqüência na sua própria árvore (veja na figura o procedimento 1). A seguir juntamos as duas árvores cujos nodos de raiz têm as menores freqüências e armazenamos a soma das freqüências na raiz da nova árvore (veja na figura o procedimento 2). Este processo é então repetido até que tenhamos uma única árvore (veja na figura o procedimento 5), que é a árvore Huffman final. O nodo de raiz desta árvore contém o número total de símbolos nos dados, e seus nodos de folha contêm os símbolos originais e suas freqüências. Uma vez que a codificação Huffman busca continuamente as duas árvores que parecem mais apropriadas à junção, pode ser considerado um bom exemplo de um algoritmo guloso.

Page 97: Projeto de Algoritmo

97

Construíndo a árvore Huffman dos símbolos e freqüências da tabela

COMPRESSÃO E DESCOMPRESSÃO DE DADOS

A construção de uma árvore Huffman é parte tanto da compressão quanto da descompressão de dados. Para comprimir dados usando uma árvore Huffman, dado um símbolo específico, começamos na raiz da árvore e traçamos um caminho para a folha de símbolo. À medida que descemos pelo caminho, sempre que nos movermos para a esquerda, anexaremos 0 (zero) ao código atual; sempre que nos movermos para a direita, anexaremos 1 (um). Assim, na figura acima, procedimento 6, para determinar o código Huffman para "U", movemo-nos para a direita (1), depois para a esquerda (10) e depois novamente para a direita (101). Os códigos Huffman para todos os símbolos na figura são:

U=101,V=01,W=100,X=00,Y=11

Para descomprimir os dados usando uma árvore Huffman, lemos os dados comprimidos bit após bit. Começando pela raiz da árvore, sempre que encontrarmos 0 nos dados, nos movemos para a

Após fundir a árvore, contém as freqüências 33 e 39 Gerando o código Huffman para o símbolo “U”

Após fundir a árvore contém as freqüências 15 e 18 Após fundir a árvore contém as freqüências 19 e 20

Inicialmente, com cada símbolo em sua própria árvore Após fundir a árvore contém as freqüências 7 e 12

Nós raízes (principais) da árvore binária símbolo freqüência

Page 98: Projeto de Algoritmo

98

esquerda da árvore; sempre que encontrarmos 1, nos movemos para a direita. Uma vez que tenhamos alcançado um nodo de folha, geramos o símbolo ali contido, movemo-nos de volta para a raiz da árvore e repetimos o processo até terminarmos com os dados comprimidos. Descomprimir dados desta maneira é possível, porque os códigos Huffman são livres de prefixo, o que quer dizer que nenhum código é prefixo de outro. Isto nos assegura que, uma vez que tenhamos encontrado uma seqüência de bits que casem com um código, não há ambigüidade quanto ao símbolo que este representa. Por exemplo, note que 01, o código para "V", não é prefixo de nenhum outro código. Desta forma, assim que encontramos 01 nos dados comprimidos, saberemos que o código representa "V".

EFICÁCIA DO CÓDIGO HUFFMAN

Para determinar o tamanho reduzido de dados comprimidos usando a codificação Huffman, calculamos o produto da freqüência de cada símbolo vezes o número de bits em seu código Huffman e então somamos tudo. Assim, para calcular o tamanho comprimido dos dados apresentados na Tabela 1 e Figura 1, temos:

(12)(3) + (18)(2)+ (7)(3) + (15)(2) + (20)(2) = 163 bits

Assumindo que sem compressão cada um dos 72 símbolos seriam representados por 8 bits, para um tamanho total de dados de 576 bits, acabamos com a seguinte proporção de compressão:

1 – (163/576) = 71,7% Mais uma vez considerando o fato de que não podemos considerar bits fracionados na codificação Huffman, em muitos casos este valor não será tão bom quanto a entropia dos dados sugere, ainda que neste caso seja bastante próxima.

Em geral, a codificação Huffman não é a forma de compressão mais eficiente que existe, mas funciona de maneira rápida tanto na compressão quanto na descompressão de dados. Geralmente, o aspecto que consome mais tempo na compressão de dados através da codificação Huffman é a necessidade de escanear os dados duas vezes: uma vez para reunir as freqüências e a segunda para comprimir os dados. A descompressão dos dados é particularmente eficiente porque decodificar a seqüência de bits para cada símbolo requer somente um escaneamento rápido da árvore de Huffman, que é limitada.

12.6 LZ77 LZ77 (Lempel-Ziv-1977) é uma forma de compressão de dados simples, que surpreende pela eficiência. LZ77 aborda o assunto de uma maneira totalmente diferente da codificação Huffman. O LZ77 é um método baseado no dicionário, o que significa que ele tenta comprimir dados através da codificação de strings longas de símbolos, chamadas phrases (frases), como pequenas fichas que formam referências de inclusões em um dicionário. A compressão é alcançada através do uso relativamente pequeno de fichas no lugar de frases longas que aparecem várias vezes nos dados. Assim como na codificação Huffman, é importante perceber que um símbolo não é necessariamente um caractere de texto: um símbolo pode ser uma quantidade qualquer de dados que escolhemos, mas normalmente equivale a um byte.

MANTENDO UM DICIONÁRIO DE FRASES

Os diferentes métodos de compressão baseados em dicionário usam várias abordagens na manutenção de seus dicionários. O LZ77 usa um buffer look-ahead e uma janela deslizante. O LZ77 funciona da seguinte maneira: primeiro carrega uma porção dos dados no buffer look-ahead. Para entender como o buffer look-ahead armazena frases de maneira a formar um

Page 99: Projeto de Algoritmo

99

dicionário, imagine o buffer como uma seqüência de símbolos s1, ..., sn, e Pb como um conjunto de frases construídas a partir dos símbolos. Da seqüência s1, ..., sn, formamos n frases, definidas da seguinte maneira:

Pb={(s1),(s1,s2),....( s1,...,sn}

Isto quer dizer que se o buffer look-ahead contiver os símbolos (A, B, D), por exemplo, as frases no buffer são {(A),(A, B), (A,B,D)}. Uma vez que os dados passam pelo buffer look-ahead, eles se movem na direção da janela e se tornam parte do dicionário. Para entender como as frases são representadas na janela, considere a janela como uma sequência de símbolos s1, ..., sm, e Pb, como um conjunto de frases construídas a partir dos símbolos. Da sequência s1,..., sm formamos o seguinte conjunto de frases: \

PW = { P1, P2, ..., Pm }, onde Pi = { (si), (si, si+1), ..., (si, si+1, ..., sm) }

Assim sendo, se a janela contém os símbolos (A, B, C), as frases na janela, e por conseguinte no dicionário, são {(A), (A, B), (A, B, C), (B), (B, C), (C)}. A idéia principal por trás do LZ77 é buscar continuamente as frases mais longas no buffer look-ahead que coincidam com uma frase que esteja atualmente no dicionário. No buffer look-ahead e na janela deslizante que acabei de descrever, a coincidência mais longa é a frase de dois símbolos (A, B).

COMPRIMINDO E DESCOMPRIMINDO DADOS

A medida que comprimimos dados, duas situações podem ocorrer a qualquer momento entre o buffer look-ahead e a janela deslizante: pode aparecer uma frase coincidente, ou pode não ocorrer coincidência alguma. Quando há pelo menos uma coincidência, codificamos a maior frase como a ficha-frase {phrase token). As ficha-frases contêm três porções de informação: a saída da janela onde começa a combinação, o número de símbolos na combinação e o primeiro símbolo no buffer look-ahead após a combinação. Quando não há combinações, codificamos os símbolos que não casaram como fichas-símbolo (symbol token). As fichas-símbolo simplesmente contêm os próprios símbolos que não casaram, de forma que nenhuma compressão pode ser obtida. Na verdade, veremos que as fichas-símbolo contêm um bit a mais que o próprio símbolo, ocorrendo então uma ligeira expansão.

Uma vez que a ficha adequada tenha sido gerada de forma a codificar um certo número de símbolos n, movemos os símbolos n para fora da janela e os substituímos do outro lado pelo mesmo número de símbolos que foram movidos para fora do buffer look-ahead. A seguir, enchemos novamente o buffer. Este processo mantém a janela atualizada somente com as frases mais recentes. O número exato de frases mantidas pela janela e pelo buffer depende do tamanho destes.

A figura a seguir ilustra a compressão de uma string usando LZ77 com uma janela de 8 bytes e um buffer de 4 bytes. Na prática, o tamanho normal para uma janela deslizante é de 4K (4096 bytes). Os buffers look-ahead são em geral inferiores a 100 bytes.

Para descomprimirmos os dados, decodificamos as fichas mantendo a janela atualizada de maneira análoga ao processo de compressão. À medida que decodificamos cada ficha, copiamos os símbolos codificados nas fichas para as janelas. Sempre que encontramos uma ficha-frase, consultamos a saída apropriada na janela e procuramos a frase com aquela tamanho específico. Quando encontramos uma ficha-símbolo, geramos um único símbolo armazenado na própria ficha. A segunda figura ilustra a descompressão dos dados comprimidos na primeira figura.

Page 100: Projeto de Algoritmo

100

������������������������

������������������������������

������������������������������������������������������������������������������������������������������������������������������������������������������

������������������������������������������������������������������������������������������������������������������

���������������������������������������������������������������������������������������������������������������������������������������������������������������������������

������������������������������������������������������������������������������������������������������������������

Iniciando

A B A B C B A B A B C A D

Depois de não encontrar nenhuma frase ABAB na janela deslizante e codificar A como ficha símbolo A

A B A B C B A B A B C A D A

Depois de não encontrar nenhuma frase BABC na janela deslizante e codificar B como ficha símbolo B

A B A B C B A B A B C A D A B

Após encontrar AB duas posições atrás na janela deslizante e codificar AB como a ficha de frase (6,2,C)

A B A B C B A B A B C A D A B ( 2, 2, C )

Após encontrar BAB quatro posições atrás na janela deslizante e codificar BAB como a ficha de frase (4,3,A)

A B A B C B A B A B C A D A B ( 2, 2, C ) ( 4, 3, A )

Após encontrar BC duas posições atrás na janela deslizante e codificar BC como a ficha de frase (6,2,A)

A B A B C B A B A B C A D A B ( 2, 2, C ) ( 4, 3, A ) ( 6, 2, A )

Após não encontrar D na janela deslizante e codificar D como a ficha símbolo D

A B A B C B A B A B C A D A B ( 2, 2, C ) ( 4, 3, A ) ( 6, 2, A ) D

Não há mais dados no buffer look-ahead

A B A B C B A B A B C A D A B ( 2, 2, C ) ( 4, 3, A ) ( 6, 2, A ) D

Legenda: Janela deslizante Buffer Look-Ahead Dados originais Dados comprimidos

����

��

������

���

����

��

������

���

����

��

������

���

������

���

��

����

����������������������������������������������������������������������������������������������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

����������������������������������������������������������������������������������������������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

����������������������������������������������������������������������������������������������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������

������������������

Comprimindo a string ABABCBABABCAD com LZ77

Page 101: Projeto de Algoritmo

101

������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������

����������������

��������������������������������������������������������������������������������������������������������������������������������������������������������������������������

���������������������������������������������

������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������

����������������������������������������������������������������������������������������������������

������������������������������������������������������������������������������������������������������������������

���������������������������������������������������������������������������������������������������������������������������������������������������������������������������

���������������������������������������������������������������������������������������

������������������������������������������������������������������������������������������������������������������

����������������

���������������������������������������������������������������������������������������������������������������������������������������������������������������������������

Iniciando

A B ( 2, 2, C ) ( 4, 3, A ) ( 2, 2, A ) D

Após decodificar a ficha de símbolo A

A B ( 2, 2, C) ( 4, 3, A ) ( 2, 2, A ) D

Após decodificar a ficha de símbolo B

A B ( 2, 2, C ) ( 4, 3, A ) ( 2, 2, A ) D

Após decodificar a ficha de frase (2, 2, C)

A B A B C ( 4, 3, A ) ( 2, 2, A ) D

Após decodificar a ficha de frase (4, 3, A)

A B A B C B A B A ( 2, 2, A ) D

Após decodificar a ficha de frase (6, 2, A)

A B A B C B A B A B C A D

Após decodificar a ficha de símbolo D

A B A B C B A B A B C A D

Legenda: Janela deslizante Dados restaurados Dados comprimidos

������

���

����

��

������

���

����

��

������

���

����

��

������

���

����

����

��������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

����������������������������������������������������������������������������������������������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

����������������������������������������������������������������������������������������������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

����������������������������������������������������������������������������������������������������������������������������������������������������������������������������

��������������������������������������������������������������������������������������

������������������������������������������������������������������������������������������������������

Descomprimindo a string comprimida na figura 2 usando LZ77

EFICÁCIA DO LZ77

A quantidade de compressão atingida usando o LZ77 depende de alguns fatores, tais como o tamanho escolhido para a janela deslizante, o tamanho do buffer look-ahead e a entropia dos dados. A quantidade de compressão depende basicamente do número de frases que conseguimos casar, assim como a extensão destas. Na maioria dos casos, LZ77 apresenta melhores índices de compressão que o código Huffman, porém os tempos de compressão são consideravelmente mais lentos.

A compressão de dados com o LZ77 é demorada porque gastamos muito tempo procurando frases que casem na janela deslizante. Contudo, em geral, a descompressão de dados com o LZ77 é ainda mais rápida do que a descompressão com o código Huffman, porque cada ficha nos diz exatamente onde ler os símbolos no buffer. Na verdade, acabamos lendo pela janela a mesma quantidade de símbolos que os dados originais.

Page 102: Projeto de Algoritmo

102

No exemplo das figuras acima, podemos calcular a eficácia da compressão da seguinte forma:

Cada ficha-símbolo gastará 9 bits, sendo o primeiro deles utilizado para indicar que se trata de uma ficha-símbolo e os demais para armazenar o próprio símbolo.

Cada ficha-frase gastará 14 bits, sendo 1 bit para indicar que se trata de uma ficha-frase, 3 bits para indicar quantas posições foram recuadas na janela deslizante, 2 bits para indicar o número de símbolos encontrados na janela deslizante e 8 bits para armazenar o primeiro caráter não encontrado na janela.

Assim, teríamos 104 bits no arquivo original (13 bytes * 8 bits) e apenas 69 bits no arquivo compactado. Isto nos dá uma eficácia de compressão de:

1 – 69 / 104 = 0,34 = 34%

Para aumentar a eficácia do algoritmo, podemos utilizar as fichas-frase apenas quando o número de bits dos símbolos encontrados no dicionário (mais um) for maior que o número de bits de uma ficha-frase. No exemplo acima, isso significa que utilizamos uma ficha-frase quando apenas um símbolo for encontrado.

12.7 LZ78 Em 1978, o algoritmo LZ77 foi modificado pelos próprios autores resultando em um novo algoritmo em que não há o conceito de janela deslizante. Neste algoritmo, o dicionário é construído dinamicamente a partir dos últimos símbolos encontrados.

A codificação é feita através dos seguintes passos:

Criar um dicionário vazio.

Criar uma string vazia.

Ler um símbolo no arquivo e adicioná-lo à string.

Buscar a string no dicionário.

Caso seja encontrada, retornar ao passo 3.

Adicionar a string ao dicionário e gravar no arquivo de saída o código no dicionário (índice) da string sem seu último símbolo (aquele que resultou em uma falha na busca do passo 4) e, em seguida, este próprio símbolo.

Se não estiver no fim do arquivo, retornar ao passo 2.

Para exemplificar o resultado dos passos acima, considere a seguinte entrada: ABABCBABABCAD. Seu dicionário e sua saída seriam:

Page 103: Projeto de Algoritmo

103

Posição String Saída1 A A2 B B3 AB 1B4 C C5 BA 2A6 BAB 5B7 CA 4A8 D D

Dicionário e saída usando o LZ78

Cada ficha-frase armazenada no arquivo é, portanto, composta por um número indicando uma posição no dicionário (em que se encontra a string) e por um símbolo que segue essa string no arquivo. As fichas-frase terão tamanho log2(D) + log2(E), onde D é o tamanho do dicionário e E o tamanho do alfabeto. O arquivo também pode conter fichas-símbolo.

Como por ser percebido, o dicionário tem seu tamanho aumentado rapidamente. Isso acontecerá até todas as strings possíveis serem armazenadas ou até não haver mais memória disponível para o dicionário. Neste último caso, basta não acrescentar novos elementos ao dicionário. Outra alternativa é remover todas as suas entradas e reiniciá-lo.

Durante o processo de descompactação, o dicionário vai sendo reconstruído à medida que as fichas são lidas. Para cada ficha do arquivo compactado, é gerada como saída a string do dicionário indicada pelo campo posição da ficha, concatenado com o campo símbolo também presente na ficha. Esse valor é, em seguida, acrescentado ao final do dicionário.

12.8 LZW

O algoritmo LZ78 foi modificado em 1984 por Terry Welch, dando origem ao LZW (Lempel-Ziv-Welch), talvez a variante mais popular da família Lempel-Ziv.

Podemos destacar as duas principais modificações feitas no algoritmo: • O dicionário é inicializado com todos os símbolos do alfabeto; e • Os símbolos armazenados no campo símbolo das fichas frase são substituídos pela

posição destes símbolos no dicionário.

A figura abaixo ilustra o processo para a entrada ABABCBABABCAD:

Posição String Saída1 A2 B3 C4 D5 AB 1 26 ABC 5 37 BA 2 18 BAB 7 29 CA 3 1

4 Dicionário e saída usando o LZW

Page 104: Projeto de Algoritmo

104

13 RECONHECIMENTO DE PADRÕES

13.1 Força bruta

CARACTERÍSTICAS PRINCIPAIS

• Não há fase de pré-processamento; • Sempre avança apenas uma posição; • As comparações podem ser feitas em qualquer ordem; • A busca é de complexidade O(mn); • São esperadas 2n comparações de caracteres.

DESCRIÇÃO

O algoritmo da força bruta consiste em checar, em todas as posições do texto entre 0 e n-m, se uma ocorrência do padrão começa ali ou não. Então, a cada tentativa, ela desloca o padrão exatamente uma posição para a direita.

CÓDIGO

#include <conio.h> int forca_bruta( char *Y, char *X ) { int i, j, m, n; m=strlen( X ); n=strlen( Y ); for( i=0; i<=n-m; i++ ) { j=0; while( j<n && Y[i+j]==X[j] ) j++; if( j==n ) return i; } return -1; } void main () { char X[256], Y[256]; int i, c; cout << "Digite a string: "; gets( Y ); cout << "Digite o padrao: "; gets( X ); cout << "\n\nBusca por KMP\n"; i = KMP( Y, X ); if( i >= 0 ) cout << "A string foi encontrada na posicao: " << i; else cout << "A string nao foi encontrada"; cout << "\nForam realizadas " << c << " comparacoes"; getch(); }

Page 105: Projeto de Algoritmo

105

EXEMPLO

Primeira tentativa:

C A C A C A U 0 1 2 3 4 C A C A U

Segunda tentativa:

C A C A C A U 0

C

Terceira tentativa:

C A C A C A U 0 1 2 3 4

C A C A U

13.2 Knuth-Morris-Pratt

CARACTERÍSTICAS PRINCIPAIS

• Faz as comparações da esquerda para a direita; • A fase de pré-processamento é de complexidade O(m); • A fase de busca é de complexidade O(n+m); • No pior caso serão feitas 3n comparações de caracteres; • A melhor performance é O(n / m).

DESCRIÇÃO

O algoritmo de Knuth-Morris-Pratt procura eliminar alguns testes já realizados, diminuindo o número total de comparações.

Considere uma tentativa na posição j, em que o padrão está posicionado para ser comparado com a cadeia y[j ... j + m-1]. Assuma que a primeira diferença ocorra entre x[i] e y[j+i], com 0 < i < m. Então, x[0 ... i-1] = y[j ... j + i-1] = u e (x[i]=a) ≠ (y[j+i]=b).

Ao deslocarmos, é razoável considerar que um trecho v do padrão seja sufixo de u. Se isto acontecer, não precisamos testar novamente nenhum dos caracteres de v. A figura abaixo ilustra isso:

Page 106: Projeto de Algoritmo

106

É possível deslocar mais de um caráter de uma vez se v for sufixo de u.

DIAGRAMA DE ESTADOS

O reconhecimento de um padrão através do algoritmo Knuth-Morris-Pratt pode ser entendido através de um diagrama de transição de estados, construído em quatro passos. No primeiro passo, construímos a espinha, que representa o reconhecimento correto do padrão. Toda vez que o estado 5 for atingido, o padrão terá sido encontrado no texto.

Espinha da máquina de estados para CACAU

Em seguida, acrescentamos o caminho padrão, que representa a ação para os eventos que não foram definidos separadamente. Esses eventos são representados no diagrama através do símbolo ?.

Acréscimo do caminho padrão

No terceiro passo, construímos o caminho inicial, que será usado quando o caráter reconhecido for o primeiro caráter do padrão.

Acréscimo do caminho inicial

Finalmente, acrescentamos ligações que representam os trechos que são prefixos do padrão. Repare que, no nosso exemplo, quando estamos no estado 4, as letras C e A acabaram de ser reconhecidas. Assim, o reconhecimento da letra C nesse estado, ao invés de levar ao estado inicial, deve levar ao estado 3, que contém o prefixo CAC.

Page 107: Projeto de Algoritmo

107

Devemos, portanto, modificar essa transição do estado, como mostra a figura abaixo.

Diagrama de estados final.

No exemplo, essa última etapa, representando o prefixo só acontece uma vez. O texto CACACAU levaria, de acordo com o diagrama de estados abaixo, à seqüência de estados: 0 1

2 3 4 3 4 5.

CÓDIGO

#include <conio.h> int KMP( char *Y, char *X ) { int i, j, m, n; m=strlen( X ); n=strlen( Y ); // monta o vetor de prefixos int *prefixos = new int[m]; prefixos[0] = 0; i = 1; while( i<m ) { j=0; while( i<m && X[i]==X[j] ) { j++; prefixos[i] = j; i++; } if( j==0 ) { prefixos[i] = 0; i++; } } // faz a busca i=j=0; while( i-j <= n-m ) { while( j<m && Y[i]==X[j] ) { i++; j++; } if( j==m ) return i-m; if( j==0 ) i++; else j=prefixos[j-1]; } return -1; }

Page 108: Projeto de Algoritmo

108

void main () { char X[256], Y[256]; int i, c; cout << "Digite a string: "; gets( Y ); cout << "Digite o padrao: "; gets( X ); cout << "\n\nBusca por KMP\n"; i = KMP( Y, X ); if( i >= 0 ) cout << "A string foi encontrada na posicao: " << i; else cout << "A string nao foi encontrada"; cout << "\nForam realizadas " << c << " comparacoes"; getch(); }

EXEMPLO

Fase de pré-processamento:

Vetor de prefixos:

i 0 1 2 3 4 X[i] C A C A U

prefixos[i] 0 0 1 2 0

Fase de busca:

Primeira tentativa:

C A C A C A U

j = 0 1 2 3 4

C A C A U

Nesse caso, a diferença aconteceu na posição 4, mas podemos considerar que as duas últimas letras casadas (C e A) são prefixo do padrão (CACAU) e não precisamos repetir esses testes. Continuamos a partir da terceira posição (j=2).

Segunda tentativa:

C A C A C A U

j = 2 3 4

C A C A U

Page 109: Projeto de Algoritmo

109

13.3 Boyer-Moore

CARACTERÍSTICAS PRINCIPAIS

• Faz as comparações da direita para a esquerda; • A fase de pré-processamento é de complexidade O(m+σ); • No pior caso serão feitas 3n comparações de caracteres; • A melhor performance é O(n / m).

DESCRIÇÃO

O algoritmo de Boyer-Moore é considerado um dos mais eficientes algoritmos de busca de strings. Versões simplificadas ou completas dele normalmente são implementadas nos mecanismos de localização e substituição dos editores de texto.

O algoritmo busca pelos caracteres do padrão da direita para esquerda, começando pelo último. No caso de uma diferença (ou reconhecimento completo do padrão), ele usa duas funções pré-calculadas para deslocar a janela para a direita. Estas duas funções são chamadas de deslocamento por sufixo bom (ou matching shift) e deslocamento por caráter ruim (ou ocurrence shift).

Assuma que uma diferença ocorreu entre o caráter x[i] = a do padrão e o caráter y[i+j]=b do texto durante uma tentativa na posição j. Então, x[i+1 ... m-1] = y[i+j+1 ... j+m-1] = u e x[i] ≠ y[i+j]. O deslocamento de sufixo bom consiste em alinhar o segmento y[i+j+1 ... j+m-1] = x[i+1 ... m-1] com a ocorrência mais à direita em x que é precedida por um caráter diferente de x[i].

O deslocamento por sufixo bom, u ocorre novamente precedido

por um caráter c diferente de a.

Se não existir tal segmento, o deslocamento consiste em alinhar o mais longo sufixo v de y[i+j+1 ... j+m-1] com um prefixo coincidente de x.

O deslocamento por sufixo bom, apenas um sufixo v de u ocorre novamente em x,

O deslocamento por caráter ruim consiste em alinhar o caráter y[i+j] com a ocorrência mais à direita em x[0 ... m-2]

j

i

Page 110: Projeto de Algoritmo

110

Deslocamento por caráter ruim, b aparece em x.

Se y[i+j] não aparece no padrão x, então nenhuma ocorrência de x em y pode incluir y[i+j], e o início da janela é alinha com o caráter imediatamente após y[i+j], ou seja, y[i+j+1].

O deslocamento por caráter ruim. b não aparece em x.

Note que o deslocamento por caráter ruim por ser negativo, então, para o deslocamento da janela, o algoritmo de Boyer-Moore aplica o maior valor entre o deslocamento por prefixo bom e o deslocamento por caráter ruim.

CÓDIGO

#include <conio.h> // Reconhecimento por Boyer-Moore int buscaBM( char *Y, char *X ) { int i, j, k, m, n; n=strlen( Y ); m=strlen( X ); // monta o vetor de última ocorrência (caráter-ruim) int delta1[256]; for( i=0; i<256; i++ ) delta1[i] = -1; for( i=0; i<m-1; i++ ) delta1[ X[i] ] = i; // monta o vetor de sufixos bons int *sufixos = new int[m]; for( i=0; i<m; i++ ) { k=0; while( i-k>=0 && X[m-k-1] == X[i-k] ) k++; sufixos[i] = k; } // monta o vetor de deslocamentos por sufixos int *delta2 = new int[m]; for( i=0; i<m; i++ ) delta2[i] = m; for( i=0; i<m-1; i++ ) delta2[ m-1-sufixos[i] ] = m-1-i;

Page 111: Projeto de Algoritmo

111

// atualiza o vetor de deslocamentos por sufixos // para considerar a repetição que vai até a posição zero. i=0; while( i<m-1 && delta2[i]==m ) i++; if( delta2[i] < m-1 ) { k = delta2[i]; while( i>0 ) { i--; delta2[i] = k; } } // faz a busca i=0; while( i <= n-m ) { j=m-1; while( j >=0 && Y[i+j] == X[j] ) j--; if( j<0 ) { delete [] sufixos; delete [] delta2; return i; } else i += max( j - delta1[ Y[i+j] ], delta2[j] ); } delete [] sufixos; delete [] delta2; return -1; } void main () { char X[256], Y[256]; int i, c; cout << "Digite a string: "; gets( Y ); cout << "Digite o padrão: "; gets( X ); cout << "\nBusca por Boyer-Moore\n"; i = buscaBM( Y, X ); if( i >= 0 ) cout << "A string foi encontrada na posicao: " << i; else cout << "A string nao foi encontrada"; getch(); }

Page 112: Projeto de Algoritmo

112

EXEMPLO

Fase de pré-processamento:

Vetor delta1 de deslocamento por caráter ruim:

i … A B C D E F G H …

delta1[i] 6 -1 1 -1 -1 -1 5 -1

Vetor delta2 de deslocamento por sufixo bom:

i 0 1 2 3 4 5 6 7 X[i] G C A G A G A G

sufixos[i] 1 0 0 2 0 4 0 7 delta2[i] 7 7 7 2 7 4 7 1

Fase de busca:

Primeira tentativa

G C A T C G C A G A G A G T A T A C A G T A C G

j = 7

G C A G A G A G

Deslocamento de 1: i=0; j=7; caráter ruim = j – delta[ Y[i+j] ] = 1; sufixo bom = delta2[j] = 1

Segunda tentativa

G C A T C G C A G A G A G T A T A C A G T A C G

j = 5 6 7

G C A G A G A G

Deslocamento de 4: i=1; j=5; caráter ruim = j – delta[ Y[i+j] ] = 4; sufixo bom = delta2[j] = 4

Terceira tentativa

G C A T C G C A G A G A G T A T A C A G T A C G

j = 0 1 2 3 4 5 6 7

G C A G A G A G

Casamento do padrão na posição i=5

Page 113: Projeto de Algoritmo

113

13.4 Aho-Corasick6

Neste algoritmo estudamos também um problema particular de busca de padrões em textos. Trata-se do caso em que o padrão é um conjunto finito K de palavras.

Alfred Aho e Margaret Corasick apresentam o algoritmo, tendo como motivação a otimização de um sistema de consulta a um banco de dados de referências bibliográficas. Os resultados em relação aos algoritmos convencionais da época foram excelentes.

Vamos então descrever o problema e o modelo de solução, que envolve a construção de um autômato finito determinístico que representa as palavras em K. Para essa construção foram aplicadas algumas idéias do algoritmo KMP, de Knuth, Morris e Pratt. Esse algoritmo resolve o problema da busca de padrões para o caso em que o padrão é uma palavra.

O PROBLEMA E O MODELO DE SOLUÇÃO DE AHO E CORASICK

Seja K={y1, y2, ..., yk} um conjunto finito de palavras em ∑*, as quais chamaremos de palavras-chave, e x, também em ∑*, uma palavra qualquer que chamaremos de texto. O problema que queremos resolver é localizar e identificar todos os fatores de x que são também palavras-chave. Em outras palavras, queremos saber quais palavras do dicionário K ocorrem no texto x.

Para isso, utilizaremos um autômato que reconhece a linguagem ∑*K. O autômato recebe como entrada o texto x e gera uma saída contendo as posições em x onde alguma palavra-chave aparece como fator. Essa fase é a busca propriamente dita, e sua complexidade de tempo é O(|x|), mas pode depender de |∑|, conforme a implementação da função de transição. Note que esse tempo não depende do número de palavras-chave.

Há também a fase de construção do autômato. Ela é feita em duas etapas, em tempo O(|∑|m) no total, onde m é a soma dos comprimentos das palavras em K. A primeira etapa consiste em construir, a partir do conjunto K, uma máquina de estados muito semelhante a um autômato finito determinístico. Essa construção utiliza as idéias do algoritmo KMP, e pode ser feita em tempo O(m), dependendo da implementação. Em seguida, a partir da máquina de estados, pode-se obter em tempo O(|∑|m) o autômato finito determinístico, que reconhece a linguagem ∑*K.

Podemos ver que o tempo de construir e aplicar a máquina de estados é O(|x|+m). Note que aplicando o algoritmo KMP k vezes com entrada x, uma vez para cada palavra em K, a complexidade total de pior caso seria O(k|x|+m).

O ALGORITMO DE AHO E CORASICK

Como já mencionamos, o algoritmo Aho-Corasick inicialmente constrói um autômato que reconhece a linguagem ∑*K. Essa construção é feita em etapas, a primeira constrói um autômato que reconhece as palavras-chave. Vejamos um exemplo para K={ he, she, hers, his }.

6 Por Leo Kazuhiro Ueda – http://www.linux.ime.usp.br/~cef/mac499-01/monografias/lku/

Page 114: Projeto de Algoritmo

114

Primeira etapa da construção

A partir dessa estrutura, o algoritmo constrói uma máquina de estados semelhante a um autômato. A diferença dessa máquina é que ela possui uma segunda função de transição, chamada falha. Essa função utiliza as mesmas idéias do algoritmo KMP.

A máquina de estados com a função falha

Numa busca do texto na máquina de estados, a transição de falha de um estado é usada quando a transição δ não está definida para esse estado. Ela indica o estado para o qual devemos voltar e repetir a busca. Por exemplo, numa busca no texto “shine”, passaríamos pelos estados nesta ordem: 0,s,3,h,4,i,1,i,8,n,0,e,0 (confira na figura acima). Note que a máquina já é capaz de reconhecer ∑*K, ou seja, pode resolver o problema.

A última fase é a construção de um autômato finito determinístico a partir da máquina de estados.

O autômato que reconhece ∑*K

Page 115: Projeto de Algoritmo

115

O tempo total consumido por essas três fases é O(|∑|m), dependendo da implementação.

Vejamos novamente a busca das palavras do dicionário no texto “shine”, mas desta vez no autômato da figura acima: 0,s,3,h,4,i,8,n,0,e,0. Não há ocorrência de nenhuma palavra-chave no texto “shine”. Podemos notar também que o autômato utiliza um pouco menos transições em comparação com a máquina de estados. Porém, tanto o autômato quanto a máquina utilizam O(|x|) transições numa busca em x (ou seja, a complexidade não muda). Ademais, a quantidade de transições do autômato é bem maior O(|∑|n) contra O(n)).

Page 116: Projeto de Algoritmo

116

14 Criptografia Digital7

14.1 Introdução Com o crescente uso das redes de computadores por organizações para conduzir seus negócios e a massificação do uso da Internet, surgiu a necessidade de se utilizar melhores mecanismos para prover a segurança das transações de informações confidenciais. A questão segurança é bastante enfatizada, principalmente, quando imagina-se a possibilidade de se ter suas informações expostas a atacantes ou intrusos da Internet, que surgem com meios cada vez mais sofisticados para violar a privacidade e a segurança das comunicações. Devido a estas preocupações, a proteção da informação tem se tornado um dos interesses primários dos administradores de sistemas.

Uma das maneiras de se evitar o acesso indevido a informações confidenciais é através da codificação ou cifragem da informação, conhecida como criptografia, fazendo com que apenas as pessoas às quais estas informações são destinadas, consigam compreendê-las. A criptografia fornece técnicas para codificar e decodificar dados, tais que os mesmos possam ser armazenados, transmitidos e recuperados sem sua alteração ou exposição. Em outras palavras, técnicas de criptografia podem ser usadas como um meio efetivo de proteção de informações suscetíveis a ataques, estejam elas armazenadas em um computador ou sendo transmitidas pela rede. Seu principal objetivo é prover uma comunicação segura, garantindo serviços básicos de autenticação, privacidade e integridade dos dados.

Além da criptografia, aparecem também as assinaturas digitais, mecanismos elaborados para garantir a autenticação e integridade de informações, permitindo assim, que se prove com absoluta certeza quem é o autor de um determinado documento, e se este mesmo não foi alterado ou forjado por terceiros.

14.2 Aplicações O uso da criptografia no mundo atual é praticamente imprescindível. Com o uso da Internet, surgiram novas aplicações como o comércio eletrônico e o home-banking. Nestas aplicações, informações confidenciais como cartões de crédito, transações financeiras, etc. são enviadas e processadas em meios não confiáveis. Enquanto meios de comunicações suficientemente seguros para proteger este tipo de informação não surgem, a criptografia aparece como uma boa alternativa para proteção de dados. Com a criptografia e assinatura digital, três características importantes para segurança de informações são alcançadas. São elas:

Privacidade: Proteger contra o acesso de intrusos;

Autenticidade: Certificar-se de que, quem é o autor de um documento é quem diz ser;

Integridade: Proteger contra modificação dos dados por intrusos.

7 Por Fernando Antonio Mota Trinta e Rodrigo Cavalcanti de Macêdo

Page 117: Projeto de Algoritmo

117

14.3 Criptografia e seus conceitos

DEFINIÇÃO

A criptografia representa a transformação de informação inteligível numa forma aparentemente ilegível, a fim de ocultar informação de pessoas não autorizadas, garantindo privacidade.

A palavra criptografia tem origem grega (kriptos = escondido, oculto e grifo = grafia) e define a arte ou ciência de escrever em cifras ou em códigos, utilizando um conjunto de técnicas que torna uma mensagem incompreensível, chamada comumente de texto cifrado, através de um processo chamado cifragem, permitindo que apenas o destinatário desejado consiga decodificar e ler a mensagem com clareza, no processo inverso, a decifragem.

Há duas maneiras básicas de se criptografar mensagens: através de códigos ou através de cifras. A primeira delas procura esconder o conteúdo da mensagem através de códigos predefinidos entre as partes envolvidas na troca de mensagens. Imagine o exemplo onde em uma guerra, um batalhão tem duas opções de ação contra o inimigo: atacar pelo lado direito do inimigo ou não atacar. A decisão depende da avaliação de um general posicionado em um local distante da posição de ataque deste batalhão. É acertado que se for enviado uma mensagem com a palavra "calhau", o exército deverá atacar pela direita; se for enviada uma mensagem com a palavra "araçagy", não deve haver ataque. Com isso, mesmo que a mensagem caia em mãos inimigas, nada terá significado coerente. O problema deste tipo de solução é que com o uso constante dos códigos, eles são facilmente decifrados. Outro problema é que só é possível o envio de mensagens predefinidas. Por exemplo: não há como o general mandar seu exército atacar pela esquerda.

O outro método usado para criptografar mensagens é a cifra, técnica na qual o conteúdo da mensagem é cifrado através da mistura e/ou substituição das letras da mensagem original. A mensagem é decifrada fazendo-se o processo inverso ao ciframento. Os principais tipos de cifras são:

1. Cifras de Transposição: método pelo qual o conteúdo da mensagem é o mesmo, porém com as letras postas em ordem diferente. Por exemplo, pode-se cifrar a palavra "CARRO" e escrevê-la "ORARC";

2. Cifras de Substituição: neste tipo de cifra, troca-se cada letra ou grupo de letras da mensagem de acordo com uma tabela de substituição. As cifras de substituições podem ser subdivididas em:

Page 118: Projeto de Algoritmo

118

1. Cifra de substituição simples, monoalfabética ou Cifra de César: é o tipo de cifra na qual cada letra da mensagem é substituída por outra, de acordo com uma tabela baseada geralmente num deslocamento da letra original dentro do alfabeto. Ela é também chamada Cifra de César devido ao seu uso pelo imperador romano quando do envio de mensagens secretas. César quando queria enviar mensagens secretas a determinadas pessoas, substituía cada letra "A" de sua mensagem original pela letra "D", o "B" pelo "E", etc., ou seja, cada letra pela que estava três posições a frente no alfabeto.

2. Cifra de substituição polialfabética: consiste em utilizar várias cifras de substituição simples, em que as letras da mensagem são rodadas seguidamente, porém com valores diferentes.

3. Cifra de substituição de polígramos: utiliza um grupo de caracteres ao invés de um único caractere individual para a substituição da mensagem. Por exemplo, "ABA" pode corresponder a "MÃE" e "ABB" corresponder a "JKI".

4. Cifra de substituição por deslocamento: ao contrário da cifra de César, não usa um valor fixo para a substituição de todas as letras. Cada letra tem um valor associado para a rotação através de um critério. Por exemplo, cifrar a palavra "CARRO" utilizando o critério de rotação "023", seria substituir "C" pela letra que está 0(zero) posições a frente no alfabeto, o "A" pela letra que está 2 (duas) posições a frente, e assim por diante, repetindo-se o critério se necessário.

FIM DE SEMANA

1234123412341

GKPDEHCWFODRB

FIM DE SEMANA

2121212121212

HJOAFFBTGNCOC

A principal vantagem das cifras em relação aos códigos é a não limitação das possíveis mensagens a serem enviadas, além de ser tornarem mais difíceis de serem decifradas.

As cifras são implementadas através de algoritmos associados a chaves, longas seqüências de números e/ou letras que determinarão o formato do texto cifrado.

CHAVES

O conceito de chave apresentado é um tanto abstrato, mas se pensarmos no criptosistema como um conjunto de algoritmos, as chaves são elementos fundamentais que interagem com os algoritmos para a cifragem/decifragem das mensagens. A figura abaixo ilustra bem esta relação.

Cifragem e decifragem de uma mensagem.

Page 119: Projeto de Algoritmo

119

Para entender melhor o conceito de chave, considere o exemplo de cifras de substituição por deslocamento descrito anteriormente. Nele, o critério utilizado para a cifragem das mensagens não é nada mais que a chave usada pelo algoritmo. No caso também da cifra de substituição simples, poderia-se dizer que o algoritmo de cifragem seria algo do tipo "deslocamento de n letras à frente", onde n seria a chave.

Do ponto de vista do usuário, as chaves de criptografia são similares as senhas de acesso a bancos e a sistema de acesso a computadores. Usando a senha correta, o usuário tem acesso aos serviços, em caso contrário, o acesso é negado. No caso da criptografia, o uso de chaves relaciona-se com o acesso ou não à informação cifrada. O usuário deve usar a chave correta para poder decifrar as mensagens.

Tomando-se ainda a comparação aos sistemas de acesso a computadores, senhas dos serviços descritos acima podem possuir diferentes tamanhos, sendo que quanto maior for a senha de um usuário, mais segurança ela oferece. Assim como estas senhas, as chaves na criptografia também possuem diferentes tamanhos, e também seu grau de segurança está relacionado com sua extensão.

Na criptografia moderna, as chaves são longas seqüências de bits. Visto que um bit pode ter apenas dois valores, 0 ou 1, uma chave de três dígitos oferecerá 23 = 8 possíveis valores para a chave. Sendo assim, quanto maior for o tamanho da chave, maior será o grau de confidencialidade da mensagem.

SEGURANÇA E ATAQUES

A avaliação da segurança de algoritmos está na facilidade ou não com que uma pessoa consegue decifrar mensagens sem o conhecimento da chave de decifragem, ação comumente conhecida por "quebrar o código". As tentativas de se quebrar os códigos de algoritmos são chamadas ataques. A forma mais simples de ataque a algoritmos é o ataque por força bruta, na qual é feita a tentativa de se quebrar o código utilizando-se todas as chaves possíveis, uma após a outra. Esta é a forma mais simples, porém a menos eficiente, às vezes até impossível de ser implementada. No entanto, ataques por força bruta são raramente necessários. Na maioria das vezes, é utilizada uma mistura de matemática e computadores potentes para quebrar códigos, num processo chamado criptoanálise. Os possíveis tipos de ataque usando criptoanálise são:

1. Ataque por texto conhecido: Neste tipo de ataque, o criptoanalista tem um bloco de texto normal e seu correspondente bloco cifrado, com objetivo de determinar a chave de criptografia para futuras mensagens.

2. Ataque por texto escolhido: Neste tipo de ataque, o criptoanalista tem a possibilidade de escolher o texto normal e conseguir seu texto cifrado correspondente;

3. Criptoanálise diferencial: Este ataque, que é uma variação do ataque por texto escolhido, procura cifrar muitos textos bem parecidos e comparar seus resultados.

Todos os sistemas criptográficos possuem níveis diferentes de segurança, dependendo da facilidade ou dificuldade com que os mesmos são quebrados. Só teremos um sistema condicionalmente seguro quando ele for teoricamente inquebrável, ou seja, não importa a quantidade de texto normal ou cifrado a disposição de um criptoanalista, ele nunca terá informação suficiente para se quebrar as cifras ou deduzir as chaves que foram usadas.

Page 120: Projeto de Algoritmo

120

A segurança de um criptosistema não deve ser baseada nos algoritmos que cifram as mensagens, mas sim no tamanho das chaves usadas. Um algoritmo para ser avaliado como forte ou fraco, deve amplamente ser testado contra todos os possíveis tipos de ataques descritos para que sua robustez seja assegurada. Um algoritmo é considerado forte quando é praticamente impossível quebrá-lo em um determinado espaço de tempo em que as informações ainda sejam relevantes e possam ser utilizadas por pessoas não autorizadas.

Geralmente, a maneira mais fácil de se determinar se um algoritmo é forte ou fraco é publicando sua descrição, fazendo com que várias pessoas possam discutir sobre a eficiência ou não dos métodos utilizados. Programas que usam algoritmos proprietários não divulgam sua especificação. Geralmente isto acontece porque a simples divulgação do método revelará também seus pontos fracos. Por esta razão, um criptosistema deve ser tão seguro, que mesmo o autor de um algoritmo não seja capaz de decodificar uma mensagem se não possuir a chave.

14.4 Tipos de criptografia em relação ao uso de chaves Há basicamente dois tipos de criptografia em relação ao uso de chaves. Quando podemos cifrar e decifrar uma mensagem usando a mesma chave tanto para o ciframento quanto para o deciframento, dizemos estar usando um sistema de criptografia por chave simétrica ou chave secreta. Caso estas chaves sejam diferentes, fala-se de um sistema de chaves assimétricas ou chave pública. A seguir, veremos com mais detalhes cada um deles.

CRIPTOGRAFIA POR CHAVE SECRETA

No caso do uso de chave secreta, tanto o emissor quanto o receptor da mensagem cifrada devem compartilhar a mesma chave, que deve ser mantida em segredo por ambos. A figura abaixo ilustra o processo de criptografia por chave secreta, mostrando que a mesma chave que atua para a cifragem da mensagem, é utilizada para sua posterior decifragem.

Processo de criptografia por chave secreta.

Se uma pessoa quer se comunicar com outra com segurança, ela deve passar primeiramente a chave utilizada para cifrar a mensagem. Este processo é chamado distribuição de chaves, e como a chave é o principal elemento de segurança para o algoritmo, ela deve ser transmitida por um meio seguro. Porém, se existe um meio seguro de se enviar a chave, por que não enviar a própria mensagem por este meio? A resposta para esta questão é que meios seguros de comunicação são geralmente caros e mais difíceis de serem obtidos e utilizados, sendo então razoável sua utilização uma única vez, mas não continuamente.

Page 121: Projeto de Algoritmo

121

Existe outro problema no processo de distribuição das chaves. Imaginando-se o caso de três pessoas – A, B e C – que queiram se comunicar utilizando chaves secretas. Serão necessárias 3 (três) chaves: uma compartilhada entre A e B, outra entre A e C, e a última entre B e C, como descrito pela figura abaixo.

Comunicação usando chaves secretas.

Se mais pessoas forem inclusas neste sistema de comunicação, mais chaves serão necessárias. No caso de mais duas pessoas, mais sete chaves serão necessárias. Em geral, se n pessoas

querem se comunicar utilizando chave secreta, serão necessárias chaves, gerando um grande problema para o gerenciamento de chaves entre grandes grupos de usuários.

Uma das tentativas de solucionar o problema da distribuição das chaves secretas foi a criação de um centro de distribuição de chaves(Key Distribution Center - KDC), que seria responsável pela comunicação entre pessoas aos pares. Para isto, o KDC deve ter consigo todas as chaves secretas dos usuários que utilizam seus serviços. Por exemplo, imagine a situação descrita pela figura abaixo, onde A quer mandar uma mensagem secreta para B. Para isto, ele manda a mensagem para o KDC usando sua chave secreta. O KDC recebe esta mensagem, decifrando com a chave secreta de A, depois o KDC a cifra novamente usando agora a chave secreta de B, e a envia para o mesmo.

O maior problema em torno do KDC, é que este constitui um componente centralizado, além de ser gerenciado por pessoas que podem, casualmente, serem corrompidas.

O Centro de Distribuição de Chaves (KDC).

Page 122: Projeto de Algoritmo

122

Em relação ao uso das cifras, os algoritmos de chaves secretas utilizam dois tipos de cifra:

1. Cifras de corrente: quando se cria uma chave aleatória com o mesmo tamanho do texto a ser cifrado, e combina-se a chave com a mensagem a ser enviada.

2. Cifras de Bloco: aceita um grupo de bits ou bloco de dados, podendo ser utilizados em cadeia. São geralmente usados para grandes quantidades de dados.

Principais algoritmos que utilizam chave secretas:

DES – (Data Encription Standard). Baseado num algoritmo desenvolvido pela IBM chamado Lucifer, é o padrão utilizado pelo governo americano para a criptografia de seus dados desde 1978. Em 1981, foi adotado como padrão pela ANSI com o nome de DEA, como tentativa de padronizar procedimentos de cifragem do segmento privado, especialmente instituições financeiras. O DES utiliza cifras de blocos de 64 bits usando uma chave de 56 bits, fazendo uma substituição monoalfabética sobre um alfabeto de 264 símbolos.

Triple-DES – Baseia-se na utilização três vezes seguidas do DES com chaves diferentes.

RC2, RC4 – Algoritmos criados pelo Professor Ronald Rivest, são proprietários da RSA Data Security. Estes algoritmos usam chaves que variam de 1 a 1024 bits de extensão. Com chaves pequenas (menores que 48 bits), são códigos fáceis de serem quebrados, e como são proprietários, não se tem muitas informações sobre sua segurança com chaves extensas. RC2 é uma cifra de bloco, similar ao DES. RC4 é uma cifra de corrente, onde o algoritmo produz uma corrente de pseudo-números que são cifrados através de uma operação lógica XOR com a própria mensagem.

IDEA – Sigla que designa International Data Encryption Algorithm é um algoritmo de cifragem de bloco desenvolvido na Suíça e publicado em 1990. IDEA utiliza uma chave de 128 bits. É um algoritmo que ainda não pode ser conceituado como forte, devido a seu pouco tempo de vida, porém aparenta ser robusto. Sua chave com 128 bits elimina a possibilidade de alguém usar computadores atuais para ataques por força bruta.

Skipjack – Algoritmo secreto desenvolvido pela National Security Agency para uso por civis. É o coração do chip Clipper, desenvolvido pela NSA.

CRIPTOGRAFIA POR CHAVE PÚBLICA

A criptografia por chave pública, também conhecida com criptografia assimétrica, é aquela baseada no uso de pares de chaves para cifrar/decifrar mensagens. As duas chaves são relacionadas através de um processo matemático, usando funções unidirecionais para a codificação da informação. Uma chave, chamada chave pública, é usada para cifrar, enquanto a outra, chamada chave secreta, é usada para decifrar.

Uma mensagem cifrada com uma chave pública só pode ser decifrada pela outra chave secreta com a qual esta relacionada. O processo é ilustrado na figura abaixo. A chave usada para cifrar recebe o nome de chave pública porque ela deve ser publicada e amplamente divulgada pelo seu possuidor, fazendo com que qualquer pessoa possa lhe enviar mensagens cifradas. Já a chave usada para decifrar as mensagens, deve ser mantida em sigilo. Geralmente, os usuários deste tipo de criptografia publicam suas chaves públicas em suas home pages, assinaturas dos E-mails, etc.

Page 123: Projeto de Algoritmo

123

Processo de criptografia por chave pública.

Principais algoritmos que utilizam chave públicas

Diffie-Hellman - Foi o ponto de partida para a criptografia por chave pública, através do artigo chamado "Multi-User Cryptographic Techniques", de Whitfield Diffie e Martin Hellman. A técnica baseia-se na troca de uma chave de cifragem de tal forma que uma terceira parte não autorizada, não tenha como deduzi-la. Cada participante inicia com sua chave secreta e através da troca de informações é derivada uma outra chave chamada chave de sessão, que será usada para futuras comunicações. O algoritmo baseia-se na exponenciação discreta, pois sua função inversa, os logaritmos discretos, é de alta complexidade.

RSA - Desenvolvido por Ronald Rivest, Adi Shamir e Len Adleman, o algoritmo tomou por base o estudo feito por Diffie e Hellman, porém usando outro fundamento matemático para a criação das chaves públicas. Eles utilizaram o fato de que é fácil de se obter o resultado da multiplicação de dois números primos extensos, mas é muito difícil de se obter os fatores primos de um número muito extenso.

Funcionamento do RSA: Escolha dois números primos extensos, p e q (maiores de 10100) Calcule n=p*q e z=(p-1)*(q-1) Escolha um número relativamente primo a z e chame-o de d Escolha e de forma que (e*d) mod z = 1 Para cifrar, calcule C=P e mod n Para decifrar, calcule P=C d mod n Chave pública e, n Chave privada d, n

Processo de cifragem

Page 124: Projeto de Algoritmo

124

Processo de decifragem

Merkle-Hellman - Baseava-se em um jogo matemático chamado Knapsack (Mochila), onde dada uma coleção de itens, verifica-se as possíveis maneiras de armazená-la dentro de um repositório de tamanho fixo, de forma a não sobrar espaço. Foi usado durante muitos anos, porém com a descoberta de uma falha crucial foi inutilizado para fins práticos.

CRIPTOGRAFIA SIMÉTRICA X CRIPTOGRAFIA ASSIMÉTRICA

Analisando os dois métodos, podemos observar que a criptografia por chave pública tem a vantagem sobre a chave privada no sentido de viabilizar a comunicação segura entre pessoas comuns. Com a chave pública também acaba o problema da distribuição de chaves existente na criptografia por chave secreta, pois não há necessidade do compartilhamento de uma mesma chave, nem de um pré-acordo entre as partes interessadas. Com isto o nível de segurança é maior. A principal vantagem da criptografia por chave secreta está na velocidade dos processos de cifragem/decifragem, pois estes tendem a ser mais rápidos que os de chave pública.

14.5 Autenticação comum e verificação de integridade Algumas vezes, particularmente, não há a necessidade de se criptografar documentos. Simplesmente, precisa-se provar quem escreveu o documento e manter as informações desse documento sem modificações. Para esses casos particulares, serviços de autenticação e integridade de dados são requeridos e podem ser realizados por dois mecanismos: Código de Autenticação de Mensagem (Message Authentication Code - MAC) e Assinaturas Digitais. A meta de um MAC ou de uma assinatura digital é tornar possível para a informação ser enviada de uma parte a outra, estando o receptor apto a demonstrar que essa informação de fato veio do remetente que alega tê-la enviado e ainda que essa mesma não foi adulterada na transmissão.

Muitas pessoas confundem MACs e assinaturas digitais com checksums. De fato, ambos provêem tentativas de garantir a detecção de modificações da informação transmitida entre remetente e receptor. A diferença entre as duas técnicas se apresenta quanto aos perigos possíveis que existem para modificar as mensagens.

Um checksum típico é um mecanismo que tem como função encontrar erros que são resultados de ruídos ou outras fontes não intencionais. Por outro lado, uma assinatura digital ou MAC é um checksum criptográfico que é designado para detectar ataques iniciados por fontes intencionais ou acidentais.

Page 125: Projeto de Algoritmo

125

CÓDIGO DE AUTENTICAÇÃO DE MENSAGENS

Códigos de Autenticação de Mensagem são mecanismos usados com sistemas de criptografia simétrica tal como o Data Encryption Standard (DES), com a finalidade de proteger a informação. Quando executado em cima de uma parte da informação, este modo de criptografia da informação gera um valor (pequeno pedaço de dados) que serve como código para o documento.

Se duas pessoas compartilham uma chave simétrica, é possível para uma delas, o remetente, executar o DES em cima dos dados, obtendo dessa forma, o código de autenticação da mensagem, e enviá-la juntamente com os dados. O receptor tem que estar apto para validar o código dos dados que lhe foram enviados e, ele consegue isto realizando a mesma cifragem em cima dos dados recebidos e deve obter esse mesmo código. Se os dados foram adulterados, o receptor não obterá um valor que se iguala com o MAC enviado.

Obviamente que o atacante também pode modificar o MAC da mesma forma que pode modificar os dados. Porém, sem o conhecimento da chave utilizada para criar o MAC, não é possível para este modificar a informação enviada e depois computar um código que corresponda a mesma.

ASSINATURAS DIGITAIS

Uma assinatura digital é um tipo específico de MAC que resulta de sistemas de criptografia assimétrica, o RSA por exemplo, e é usado para proteger a informação. Para assinar uma mensagem, uma função Message Digest (MD) é usada para processar o documento, produzindo um pequeno pedaço de dados, chamado de hash. Uma MD é uma função matemática que refina toda a informação de um arquivo em um único pedaço de dados de tamanho fixo.

Funções MD são mais parecidas com checksums quanto a não receber uma chave como parte de sua entrada. Na verdade, entra-se com os dados a serem "digeridos" e o algoritmo MD gera um hash de 128 ou 160 bits (dependendo do algoritmo, são exemplos: MD4, MD5 e Snefru). Uma vez computada uma message digest, criptografa-se o hash gerado com uma chave privada. O resultado de todo este procedimento é chamado de assinatura digital da informação. A assinatura digital é uma garantia que o documento é uma cópia verdadeira e correta do original.

O motivo para se usar funções message digest está diretamente ligado ao tamanho do bloco de dados a ser criptografado para se obter a assinatura. De fato, criptografar mensagens longas pode durar muito tempo, enquanto que criptografar hashs, que são blocos de dados pequenos e de tamanho fixo, gerados pela MD torna o processamento mais eficiente.

Contudo, a simples presença de uma assinatura digital no documento não quer dizer nada. Assinaturas digitais, como outras convencionais, podem ser forjadas. A diferença é que a assinatura digital pode ser matematicamente verificada. Dado um documento e sua assinatura digital, pode-se facilmente verificar sua integridade e autenticidade. Primeiro, executa-se a função MD (usando o mesmo algoritmo MD que foi aplicado ao documento na origem), obtendo assim um hash para aquele documento, e posteriormente, decifra-se a assinatura digital com a chave pública do remetente. A assinatura digital decifrada deve produzir o mesmo hash gerado pela função MD executada anteriormente. Se estes valores são iguais é determinado que o documento não foi modificado após a assinatura do mesmo, caso contrário o documento ou a assinatura, ou ambos foram alterados. Infelizmente, a assinatura digital pode dizer apenas que o documento foi modificado, mas não o que foi modificado e o quanto foi modificado. Todo o processo de geração e verificação de assinatura digital pode ser visto na figura a seguir, utilizando o algoritmo de criptografia de chave pública RSA.

Page 126: Projeto de Algoritmo

126

Para ser possível que um documento ou uma assinatura adulterada não seja detectada, o atacante deve ter acesso a chave privada de quem assinou esse documento.

O que faz assinaturas digitais diferentes de MACs é que enquanto estes últimos requerem chaves privadas para verificação, assinaturas digitais são possíveis de serem verificadas usando chaves públicas.

A assinatura digital também é valiosa pois pode-se assinar informações em um sistema de computador e depois provar sua autenticidade sem se preocupar com a segurança do sistema que as armazena.

Geração e verificação de assinatura digital. Documento e assinatura digital enviados pela rede.