Apostila De Aed Em C++

  • May 2020
  • PDF

This document was uploaded by user and they confirmed that they have the permission to share it. If you are author or own the copyright of this book, please report to us by using this DMCA report form. Report DMCA


Overview

Download & View Apostila De Aed Em C++ as PDF for free.

More details

  • Words: 16,687
  • Pages: 44
Pontifícia Universidade Católica de Minas Gerais Bacharelado em Sistemas de Informação Algoritmos e Técnicas de Programação II Professores Fabio Tirelo e Silvio Jamil

Conte´ udo 1 Qualidade de C´ odigo

1

2 Classes

6

3 Tipos Abstratos de Dados 3.1 Defini¸c˜ ao . . . . . . . . . . . . . . . 3.2 Implementa¸c˜ ao de um TAD . . . . . 3.3 Estudo de caso: TAD Vetor . . . . . 3.3.1 Exemplo de Cliente . . . . . 3.3.2 Uma primeira implementa¸c˜ao

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

11 11 11 13 13 13

4 Apontadores

16

5 Aloca¸ c˜ ao Dinˆ amica

19

6 An´ alise de complexidade 6.1 Introdu¸c˜ ao . . . . . . . . . . . . . . . . 6.2 Complexidade computacional e an´alise 6.3 Nota¸c˜ ao O . . . . . . . . . . . . . . . . 6.4 Exemplos . . . . . . . . . . . . . . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

. . . .

24 24 24 25 27

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

29 29 29 30 31 32

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Utilizando Vetores . . . . . . . . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

34 34 34 34 35 36

9 Estudo de Caso: TAD Fila 9.1 Defini¸c˜ ao . . . . . . . . . . . . . . . . . . . . . . . . . . 9.2 Aplica¸c˜ oes . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3 Interface da Classe Fila . . . . . . . . . . . . . . . . . . 9.3.1 Implementa¸c˜ ao do TAD Fila Utilizando Vetores . 9.4 Exemplos . . . . . . . . . . . . . . . . . . . . . . . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

38 38 38 38 39 42

. . . . . . . assint´otica . . . . . . . . . . . . . . .

7 Recursividade 7.1 Defini¸c˜ ao . . . . . . . . . . . . . . . . . 7.2 Ilustra¸c˜ ao de uma abordagem recursiva 7.3 Caracter´ısticas de algoritmos recursivos 7.4 Exemplos . . . . . . . . . . . . . . . . . 7.5 Anatomia de chamadas recursivas . . . . 8 Estudo de Caso: TAD Pilha 8.1 Defini¸c˜ ao . . . . . . . . . . . . . . . 8.2 Aplica¸c˜ oes . . . . . . . . . . . . . . . 8.3 Interface da Classe Pilha . . . . . . . 8.3.1 Implementa¸c˜ ao do TAD Pilha 8.4 Exemplos . . . . . . . . . . . . . . .

1

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

. . . . .

T´ opico 1

Qualidade de C´ odigo Para determinarmos se um programa ´e bom, diversos fatores devem ser analisados. Entre eles, alguns dos mais importantes s˜ ao: • Do ponto de vista do usu´ ario: se o programa est´a correto, se ´e eficiente e se ´e de f´acil utiliza¸c˜ ao; • Do ponto de vista da equipe de programadores: se ´e f´acil fazer altera¸c˜oes, seja para corre¸c˜ao de erros ou para atualiza¸c˜ oes. A corre¸c˜ ao do programa depende particularmente do m´etodo de desenvolvimento utilizado e dos testes que foram feitos. Em geral, este fator est´a intimamente relacionado `as t´ecnicas de programa¸c˜ ao utilizadas. Os testes devem estar centrados principalmente em determinar o comportamento do programa em casos extremos e em detectar pontos cr´ıticos do c´odigo. Um programa ´e eficiente quando faz bom uso dos recursos computacionais existentes. Quando economizamos o tempo de CPU para realizar determinado processamento, dizemos que temos eficiˆencia de tempo; quando economizamos mem´oria dispon´ıvel, dizemos que tempo eficiˆencia de espa¸co. Como veremos, nem sempre estes dois tipos de eficiˆencia s˜ao compativeis, isto ´e, em geral devemos escolher qual dos dois ser´a escolhido. Para sabermos se um programa ´e eficiente, desenvolveremos m´etricas para determinar o comportamento do algoritmo em casos cr´ıticos e formas de comparar dois algoritmos que resolvam um mesmo problema. Aqui, estamos interessados nos fatores de qualidade do ponto de vista do programador, visto que estes garantir˜ ao que o programa seja alterado com mais facilidade quando for necess´ario. Os elementos que chamam a aten¸c˜ ao quando falamos em qualidade do c´odigo s˜ao a indenta¸c˜ ao, os coment´ arios, a documenta¸c˜ ao, a escolha dos identificadores e modulariza¸c˜ ao. A indenta¸c˜ ao permite visualizar as rela¸c˜oes de dependˆencia entre as diversas constru¸c˜oes do programa. Os coment´ arios ajudam a entender algoritmos complexos e a resolver d´ uvidas que possam surgir ao ler um fragmento de c´ odigo. A documenta¸c˜ ao ´e normalmente confundida com coment´arios. A documenta¸c˜ao ´e um relat´orio que explica a l´ ogica do programa, detalhando as etapas do desenvolvimento e descrevendo os algoritmos e as estruturas de dados utilizados, sendo a primeira leitura para quem tiver que entender o programa. A escolha dos identificadores diz respeito a quais nomes dar a vari´aveis, fun¸c˜oes, classes e m´odulos do programa. Nomes de vari´ aveis como x ou a dizem muito pouco (ou quase nada) sobre a finalidade da vari´ avel no programa. Mas, dependendo das caracter´ısticas do problema a ser resolvido podem dizer muito. Por exemplo, no desenvolvimento de um programa para calcular o resultado de um polinˆ omio, o uso de uma vari´avel idenficada por x est´a correto, pois este nome est´a diretamente relacionado ao problema em quest˜ao. Suponha agora, que estamos desenvolvimento um programa para identificar se uma data esta correta, o uso de uma vari´avel identificada por x, neste caso, n˜ ao ´e recomendada, pois esta n˜ao se relaciona com o problema. 1

Notas de aulas de AED

2

A modulariza¸c˜ ao ´e a divis˜ ao do programa em fun¸c˜oes, classes e m´odulos; ao dividir o programa em partes, facilitamos sua leitura, visto que ´e poss´ıvel desenvolver m´etodos para a leitura do programa, via constru¸c˜ oes de alto n´ıvel; por exemplo, ler a fun¸c˜ao main d´a a id´eia do que o programa faz; para conhecermos os detalhes, devemos entender as fun¸c˜oes individualmente. Ainda no item modulariza¸c˜ ao, uma grande dificuldade ´e descobrir como dividir um programa em fun¸c˜oes, isto ´e, o que devem ser as fun¸c˜oes de um programa, como elas devem ser relacionadas, etc. Em geral, podemos procurar partes do programa que est˜ao duplicadas e transform´a-las em chamadas a uma nova fun¸c˜ ao, devidamente parametrizada. Tamb´em ´e interessante que partes distintas do programa fiquem em fun¸c˜ oes distintas. Tomemos por exemplo um programa que leia dois vetores de 10 n´ umeros inteiros e exiba um n´ umero que represente a distˆancia de hamming entre os dois vetores. Mesmo sem saber o que significa a distˆancia de hamming entre dois vetores, podemos escrever a seguinte fun¸c˜ ao main: void main(void) { int vetor1[10], vetor2[10], hamming; leVetor(vetor1); leVetor(vetor2); hamming = calculaDistanciaHamming(vetor1, vetor2); cout << "Distancia de hamming = " << hamming << endl; }

Observe que a leitura da fun¸c˜ ao main j´a permite ter uma id´eia do que o programa faz. A forma como as etapas do programa s˜ ao realizadas depender˜ao de cada uma das fun¸c˜oes. Quanto melhor as fun¸c˜oes estiverem escritas, mais f´ acil ser´a a compreens˜ao. Observe tamb´em que a cria¸c˜ao da fun¸c˜ao leVetor permitiu que elimin´ assemos c´odigo redundante no programa. ´ indiscut´ıvel que dividir o programa em partes facilita a compreens˜ao do programa e a locaE liza¸c˜ao de erros. Entretanto, ´e importante que esta divis˜ao siga crit´erios bem definidos. Uma t´ecnica interessante ´e a chamada Programa¸c˜ ao Defensiva, inspirada livremente na Dire¸c˜ao Defensiva. Ambas seguem a mesma id´eia de que devemos nos prevenir dos erros dos outros, de modo a evitar problemas, sejam na programa¸c˜ao ou no trˆansito. Nossa defini¸c˜ao ser´a bem semelhante `a famosa m´ axima “vocˆe deve dirigir para vocˆe e para os outros”. No caso da programa¸c˜ao, ao escrever uma fun¸c˜ ao, devemos imaginar que qualquer dado externo passado para a fun¸c˜ao deve ser testado; se o seu valor for incompat´ıvel com o esperado pela fun¸c˜ao, ent˜ao alguma coisa deve ser feita. Em outras palavras, uma fun¸c˜ ao n˜ao deve executar comandos inv´alidos devido a erros que n˜ao sejam exclusivamente de sua responsabilidade, devendo estar assim prevenida para evit´a-los. Por exemplo, considere o que aconteceria na fun¸c˜ao abaixo, se o valor zero fosse passado como segundo parˆ ametro: int divide(int x, int y) { return (x/y); }

Uma boa regra de programa¸c˜ ao a ser seguida ´e: teste todos os valores que a fun¸ c˜ ao for utilizar, sejam parˆ ametros, entradas do usu´ ario, ou vari´ aveis globais. Outro ponto importante diz respeito ao grau de dependˆencia entre as fun¸c˜oes. Isto recebe o nome de Acoplamento. Quanto maior for o grau de dependˆencia entre duas fun¸c˜oes, maior ser´a o acoplamento, e mais dif´ıcil ser´ a compreender e modificar uma das fun¸c˜oes. Isto porque altera¸c˜oes simples em uma fun¸c˜ ao podem gerar altera¸c˜oes dr´asticas na outra. V´arios fatores fazem duas fun¸c˜ oes estar relacionadas. Entre eles, destacamos: • Retorno das fun¸c˜ oes: se uma fun¸c˜ao F1 chama uma fun¸c˜ao F2 e utiliza o resultado retornado por esta, ent˜ ao estaremos estabelecendo uma rela¸c˜ao de dependˆencia. Muitas vezes, esta rela¸c˜ ao pode ser confusa, como no exemplo a seguir: suponha que a fun¸c˜ ao posicaoDe seja utilizada para verificar se um elemento x est´a presente em um vetor v contendo n elementos; se x estiver presente no vetor, ent˜ao ser´a retornada a posi¸c˜ao da primeira ocorrˆencia de x, sen˜ ao, retornaremos o valor -1. Uma implementa¸c˜ao poss´ıvel desta fun¸c˜ ao ´e a seguinte:

Todos os direitos reservados aos autores

Notas de aulas de AED

3

int posicaDe(int v[], int n, int x) { bool encontrado = false; int i = 0; while (i < n && encontrado == false) { if (v[i] == x) encontrado = true; else i++; } if (encontrado == true) return i; else return -1; }

O maior problema desta fun¸c˜ ao ´e utilizar o seu valor de retorno para duas finalidades distintas: (a) informar se o elemento foi encontrado; (b) caso o elemento tenha sido encontrado, informar a posi¸c˜ ao de sua primeira ocorrˆencia. • Parˆ ametros: se uma fun¸c˜ ao F1 chama uma fun¸c˜ao F2, ent˜ao ´e importante que ao escrever a fun¸c˜ ao F1, estejamos cientes dos parˆametros que F2 espera. Considere por exemplo que a fun¸c˜ ao extenso, receba uma data e escreva na tela esta data por extenso; esta fun¸c˜ao pode ter v´ arios cabe¸calhos poss´ıveis: – void extenso(int dia, int mes, int ano); – void extenso(int ano, int mes, int dia); – void extenso(Data d); As duas primeiras formas s˜ ao totalmente v´alidas, e ir˜ao depender do programador: no Brasil, possivelmente utilizar´ıamos a primeira forma, mas em outros locais, a segunda forma poderia ser preferida. Observe ent˜ ao que a quantidade de parˆametros utilizados pode gerar confus˜oes na utiliza¸c˜ ao da fun¸c˜ ao. Na terceira forma, estamos supondo a existˆencia de uma estrutura Data que possua campos para dia, mˆes e ano. Observe que esta forma ´e mais atraente, visto que n˜ ao haver´ a d´ uvidas com rela¸c˜ao `a ordem em que os parˆametros devem ser passados. Os parˆ ametros devem ser muito bem escolhidos, pois estes poder˜ao gerar dificuldades ao tentar entender as rela¸c˜ oes entre as fun¸c˜oes. • Vari´ aveis Globais: Dada a chamada da fun¸c˜ao F2 de dentro da fun¸c˜ao F1, um importante fator de dependˆencia entre as fun¸c˜oes ´e o uso de globais. Por exemplo, considere o fragmento de c´ odigo abaixo: int x; // Vari´ avel global F2(...) { ... x = ...; // vari´ avel x alterada ... } F1(...) { ... F2(...) ... x ... // uso do valor de x }

Veja que o valor de x dentro de F1 depende da atribui¸c˜ao feita em F2. Esta dependˆencia ´e uma das mais dif´ıceis de serem encontradas e s˜ao as que causam maiores efeitos ao tentar corrigir ou fazer altera¸c˜ oes de manuten¸c˜ao em um programa. Claro que vari´aveis globais n˜ao devem ser simplesmente desconsideradas para sempre; entretanto, ´e importante que haja crit´erios bastante rigorosos para que o seu uso n˜ao crie dependˆencias mal´eficas no programa. Considere o programa abaixo:

Todos os direitos reservados aos autores

Notas de aulas de AED

4

void main(void) { int v[10], i; for (i = 0; i < 10; i++) cin >> v[i]; for (i = 9; i >= 0; i--) cout << v[i] << " "; }

Este ´e um programa bastante simples que faz a leitura de um vetor seguida da impress˜ao dos seus elementos em ordem invertida ` a de leitura. Para facilitar altera¸c˜oes futuras neste programa, ´e aconselh´ avel o uso de fun¸c˜ oes, o que poderia alterar o programa para: int v[10]; void leVetor() { for (int i = 0; i < 10; i++) cin >> v[i]; } void imprimeInvertido() { for (int i = 9; i >= 0; i--) cout << v[i] << " "; } void main(void) { leVetor(); imprimeInvertido(); }

Problema desta implementa¸c˜ ao: o uso da vari´avel global tornou as duas fun¸c˜oes totalmente limitadas, isto ´e, se precis´ assemos ler e imprimir invertido um outro vetor, digamos u, precisar´ıamos criar novas fun¸c˜ oes, duplicando um c´ odigo j´a existente. [Observe que neste caso o problema n˜ ao ´e v ter sido declarada como global, mas as fun¸c˜ oes estarem totalmente dependentes desta vari´ avel.] Este programa poderia ser ent˜ ao rescrito da seguinte forma: int v[10]; void leVetor(int vetor[]) { for (int i = 0; i < 10; i++) cin >> vetor[i]; } void imprimeInvertido(int vetor[]) { for (int i = 9; i >= 0; i--) cout << vetor[i] << " "; } void main(void) { leVetor(v); imprimeInvertido(v); }

Um problema j´ a foi resolvido: fun¸c˜oes dependendo de vari´aveis globais. Agora, se houver a necessidade de ler um outro vetor e imprimi-lo tamb´em invertido, precisaremos somente declar´a-lo e chamar as fun¸c˜ oes com parˆ ametros apropriados, como mostra o c´odigo abaixo: int u[10], v[10]; void leVetor(int vetor[]) { for (int i = 0; i < 10; i++) cin >> vetor[i]; } void imprimeInvertido(int vetor[]) { for (int i = 9; i >= 0; i--) cout << vetor[i] << " "; } void main(void) {

Todos os direitos reservados aos autores

Notas de aulas de AED

5

leVetor(v); leVetor(u); imprimeInvertido(v); imprimeInvertido(u); }

Ainda restam outros problemas: primeiramente, estamos trabalhando somente com vetores de 10 posi¸c˜oes. Se tivermos que modificar este programa para trabalhar com vetores de 20 posi¸c˜oes, por exemplo, ter´ıamos que alterar 4 pontos no c´odigo (as duas declara¸c˜oes e os limites dos comandos ´ interessante, ent˜ao, definirmos uma constante para o tamanho do vetor. for dentro das fun¸c˜ oes). E Observe que a modifica¸c˜ ao supracitada no programa a seguir requer a altera¸c˜ao de somente um ponto do programa (a declara¸c˜ ao da constante): const int tamanho = 10; int u[tamanho], v[tamanho]; void leVetor(int vetor[]) { for (int i = 0; i < tamanho; i++) cin >> vetor[i]; } void imprimeInvertido(int vetor[]) { for (int i = tamanho - 1; i >= 0; i--) cout << vetor[i] << " "; } void main(void) { leVetor(v); leVetor(u); imprimeInvertido(v); imprimeInvertido(u); }

Todos os direitos reservados aos autores

T´ opico 2

Classes Considere um outro fator de qualidade que nos levar´a a novos conceitos na programa¸c˜ao: se dois ou mais elementos de um programa possuirem alguma rela¸c˜ ao conceitual, ent˜ ao estes elementos dever˜ ao estar agrupados no c´ odigo. Uma forma de seguir esta regra ´e sempre colocar fun¸c˜oes de uma mesma esp´ecie seguidas ao longo do programa. Por exemplo, se um programa possuir dez fun¸c˜oes, sendo que trˆes fazem opera¸c˜ oes sobre datas, ent˜ao faz sentido deixar estas fun¸c˜oes em seq¨ uˆencia ao longo do programa. Esta ´e uma t´ecnica importante, que ser´a futuramente estendida para a cria¸c˜ao de bibliotecas de fun¸c˜ oes. Por enquanto, introduziremos o conceito de Classes. Considere o programa abaixo: void imprime(int dia, int mes, int ano) { cout << dia << "/" << mes << "/" << ano << endl; } bool bissexto(int ano) { if (ano % 4 != 0) return false; else if (ano % 100 == 0 && ano % 400 != 0) return false; else return true; } bool valida(int dia, int mes, int ano) { int maiordia; if (mes > 12 || mes < 1) return false; else if (mes == 4 || mes == 6 || mes == 9 || mes == 11) maiordia = 30; else if (mes == 2) if (bissexto(ano)) maiordia = 29; else maiordia = 28; else maiordia = 31; if (dia < 1 || dia > maiordia) return false; return true; } void main(void) { int dia, mes, ano; cout << "Digite a data: "; cin >> dia >> mes >> ano; if (valida(dia, mes, ano)) imprime(dia,mes,ano); else

6

Notas de aulas de AED

7

cout << "Data inv´ alida"; }

Um problema neste programa ´e o fato de termos v´arios parˆametros nas fun¸c˜oes que s˜ao necess´arios na representa¸c˜ ao das datas. Por exemplo, se o programador que escreveu as fun¸c˜oes fosse de alguma nacionalidade em que a ordem da descri¸c˜ao da data ´e diferente da nossa, ent˜ao ter´ıamos problemas com rela¸c˜ ao a ordem correta dos parˆametros. Ent˜ao, ´e interessante criarmos uma estrutura que represente uma data e us´a-la ao longo do programa. Desta forma, ficaremos livres de saber a ordem dos parˆ ametros a serem passados, como ilustra o programa abaixo: struct Data { int dia, mes, ano; }; bool bissexto(int ano) { if (ano % 4 != 0) return false; else if (ano % 100 == 0 && ano % 400 != 0) return false; else return true; } void imprime(Data d) { cout << d.dia << "/" << d.mes << "/" << d.ano << endl; } bool valida(Data d) { int maiordia; if (d.mes > 12 || d.mes < 1) return false; else if (d.mes == 4 || d.mes == 6 || d.mes == 9 || d.mes == 11) maiordia = 30; else if (d.mes == 2) if (bissexto(d.ano)) maiordia = 29; else maiordia = 28; else maiordia = 31; if (d.dia < 1 || d.dia > maiordia) return false; return true; } void main(void) { int dia, mes, ano; cout << "Digite a data: "; cin >> d.dia >> d.mes >> d.ano; if (valida(d)) imprime(d); else cout << "Data inv´ alida"; }

At´e agora, a u ´nica vantagem que obtivemos foi n˜ao precisarmos nos preocupar com a ordem dos parˆametros. Entretanto, o n´ıvel de organiza¸c˜ao do programa foi melhorado. Observe que os dados que representam uma data foram agrupados em uma estrutura do programa, seguindo a regra definida acima. Mostraremos agora uma evolu¸c˜ ao do conceito de estrutura que nos permitir´a englobar todas as opera¸c˜ oes relacionadas em uma mesma parte do programa: uma Classe. Uma Classe ´e um tipo de dados semelhante a uma estrutura, constitu´ıdas por Atributos e M´etodos. Atributos de classe s˜ ao semelhantes a campos de estruturas; por exemplo, uma classe Data poderia conter os atributos dia, mes e ano. M´etodos de classe s˜ao fun¸c˜oes que operam sobre os atributos.

Todos os direitos reservados aos autores

Notas de aulas de AED

8

Se declararmos uma vari´ avel cujo tipo ´e uma classe, esta vari´avel ser´a composta, assim como nas estruturas. Haver´ a uma c´ opia de cada um dos atributos para cada vari´avel declarada daquela classe. Por exemplo, se Data for uma classe definida no programa, ent˜ao a declara¸c˜ao: Data d1, d2, d3;

criar´a trˆes vari´ aveis cujo tipo ´e Data; cada uma destas vari´aveis possui os campos dia, mes e ano. Uma classe ´e geralmente dividida em duas partes: a parte privativa cont´em elementos que ser˜ao de uso exclusivo da classe, isto ´e, n˜ ao poder˜ao ser acessados por entidades externas `a classe; a parte p´ ublica cont´em os elementos exportados pela classe, isto ´e, elementos que podem ser utilizados por entidades externas ` a classe. Por simplicidade, convencionaremos que todos os atributos s˜ao privativos e todos os m´etodos s˜ ao p´ ublicos. Uma classe pode possuir tamb´em m´etodos especiais denominados construtores. Um construtor ´e um m´etodo especial que possui o mesmo nome da classe e n˜ao possui tipo de retorno, tendo por finalidade iniciar os atributos do objeto. No exemplo abaixo, vemos uma classe completa com atributos e um construtor e um exemplo de utiliza¸c˜ ao da classe: class Data { private: int dia, mes, ano; public: Data(int d, int m, int a) { dia = d; mes = m; ano = a; } bool bissexto() { if (ano % 4 != 0) return false; else if (ano % 100 == 0 && ano % 400 != 0) return false; else return true; } void imprime() { cout << dia << "/" << mes << "/" << ano << endl; } bool valida() { int maiordia; if (mes > 12 || mes < 1) return false; else if (mes == 4 || mes == 6 || mes == 9 || mes == 11) maiordia = 30; else if (d.mes == 2) if (bissexto(ano)) maiordia = 29; else maiordia = 28; else maiordia = 31; if (dia < 1 || dia > maiordia) return false; return true; } }; void main(void) { Data d(10,3,2003); if (d.valida()) d.imprime(); else cout << "Data inv´ alida"; }

Neste exemplo, iniciemos com a parte privativa que inicia por private:. Observe que fizemos uma declara¸c˜ ao semelhante aos campos da estrutura Data do exemplo anterior. A partir de public:, iniciamos a parte p´ ublica da classe, que cont´em os seus m´etodos. O primeiro m´etodo ´e o construtor, que ´e respons´avel por definir os valores iniciais dos atributos do objeto. Observe que os valores dos parˆametros s˜ao atribu´ıdos aos atributos da classe no construtor. Analisando a primeira linha da fun¸c˜ao main, vemos a declara¸c˜ao Data d(10,3,2003);. Todos os direitos reservados aos autores

Notas de aulas de AED

9

Exatamente neste ponto ´e feita a chamada do construtor. Ao declararmos o objeto d, definimos quais ser˜ ao os parˆ ametros passados para o construtor. Estes valores ser˜ao ent˜ao atribu´ıdos aos ´ importante atributos dia, mes e ano, de modo que o objeto d representar´a a data 10/3/2003. E ressaltar que a ordem de atribui¸c˜ ao foi definida pela ordem dos parˆametros, n˜ao havendo rela¸c˜ao alguma com a ordem em que os atributos foram definidos. ´ um m´etodo que verifica se o ano armaO segundo m´etodo utiliza somente o atributo ano. E zenado na data ´e um ano bissexto. O m´etodo imprime ´e semelhante ` a fun¸c˜ao imprime anterior, com a diferen¸ca que os valores de dia, mes e ano s˜ ao os atributos da classe e n˜ao mais os parˆametros. Observe como o m´etodo imprime ´e chamado na terceira linha da fun¸c˜ao main. A sintaxe ´e semelhante a uma chamada de fun¸c˜ao, sendo que a utiliza¸c˜ ao do operador “ponto”define que os valores dos atributos durante a execu¸c˜ao do m´etodo ser˜ ao obtidos do objeto para o qual o m´etodo foi chamado. Neste caso, a chamada d.imprime() chama o m´etodo imprime e os valores de dia, mes e ano s˜ao os valores destes atributos armazenados em d. ´ importante ressaltar que A fun¸c˜ ao main abaixo mostra a utiliza¸c˜ao de duas datas distintas. E cada objeto possui uma c´ opia de cada um dos atributos de modo que a chamada d1.imprime() imprimir´ a os atributos de d1 e a chamada d2.imprime() imprimir´a os atributos de d2. void main(void) { Data d1(10,3,2003), d2(3,7,2002); d1.imprime(); d2.imprime(); }

Outra funcionalidade que uma classe pode oferecer ´e o chamado construtor default, que ´e um construtor sem parˆ ametros. Em outras palavras, o construtor default de uma classe ´e um m´etodo sem parˆametros e sem tipo de retorno e cujo nome ´e o mesmo da classe. Este construtor especial serve para iniciar objetos com valores padr˜ao. No exemplo completo abaixo, o objeto d1 ´e iniciado com os valores passados como parˆ ametro para o primeiro construtor e o objeto d2 ´e iniciado com o valor padr˜ ao 1/1/2003. class Data { private: int dia, mes, ano; public: Data(int d, int m, int a) { dia = d; mes = m; ano = a; } Data() { // construtor default dia = 1; mes = 1; ano = 2003; } bool bissexto() { if (ano % 4 != 0) return false; else if (ano % 100 == 0 && ano % 400 != 0) return false; else return true; } void imprime() { cout << dia << "/" << mes << "/" << ano << endl; } bool valida() { int maiordia; if (mes > 12 || mes < 1) return false; else if (mes == 4 || mes == 6 || mes == 9 || mes == 11) maiordia = 30; else if (d.mes == 2) if (bissexto()) maiordia = 29; else maiordia = 28; else maiordia = 31; if (dia < 1 || dia > maiordia) return false; return true;

Todos os direitos reservados aos autores

Notas de aulas de AED

10

} }; void main(void) { Data d1(10,3,2003), d2; d1.imprime(); d2.imprime(); }

A diferencia¸c˜ ao na chamada do construtor ´e feita pelo compilador por meio da lista de parˆametros. Ao iniciar d1, utiliza-se o primeiro construtor, pois passamos 3 parˆametros para a sua inicia¸c˜ao; ao iniciar d2, utiliza-se o segundo construtor, pois nenhum parˆametro foi passado. Se tent´assemos utilizar 1, 2 ou mais de 3 parˆ ametros, haveria um erro de compila¸c˜ao; o mesmo ocorreria se algum dos parˆametros passados para d1 fosse incompat´ıvel com o tipo int, da mesma forma que ocorre com chamadas de fun¸c˜ oes. Observe que, at´e agora, n˜ ao houve ganho algum em poder de express˜ao da linguagem, isto ´e, o que fizemos utilizando classes poderia ter sido feito corretamente utilizando estruturas e fun¸c˜oes independentes. Entretanto, melhoramos a organiza¸c˜ao do nosso c´odigo, pois deixamos todas as fun¸c˜oes em um mesmo local do programa: a classe Data, al´em de representar uma data, tamb´em agrupa as opera¸c˜ oes sobre este tipo de dado. Al´em disso, a atribui¸c˜ao de valores iniciais a atributos ficou mais simples, pois podemos pass´a-los diretamente para o construtor, sem a necessidade de iniciarmos campo a campo.

Todos os direitos reservados aos autores

T´ opico 3

Tipos Abstratos de Dados 3.1

Defini¸ c˜ ao

Um Tipo Abstrato de Dados (TAD) ´e um conjunto de dados munido de opera¸c˜oes sobre estes dados. Por exemplo, o TAD Data representa todas as datas existentes, munidas das opera¸c˜oes usuais sobre datas. Quando um programa utiliza um TAD, ent˜ao dizemos que este programa ´e um Cliente do TAD. Por exemplo, podemos definir a fun¸c˜ao main abaixo, que utiliza o TAD Data; desta forma main ´e cliente do TAD Data. void main(void) { Data d1(10,3,2003), d2; d1.imprime(); d2.imprime(); }

3.2

Implementa¸ c˜ ao de um TAD

Na Programa¸c˜ ao Orientada por Objetos, dizemos que uma Classe implementa um Tipo Abstrato de Dados. Em outras palavras, uma classe cont´em os elementos que representam o tipo, e agrupa todas as opera¸c˜ oes realizadas sobre estes elementos. Ao definirmos um TAD, devemos nos preocupar sempre em definir a interface de comunica¸c˜ao do cliente com o TAD. Na implementa¸c˜ao via classe, isto significa determinar quais ser˜ao os m´etodos, os seus cabe¸calhos e o que cada m´etodo faz. Os algoritmos que ser˜ao utilizados s˜ao irrelevantes nesta fase. No nosso exemplo de data, os cabe¸calhos e uma descri¸c˜ao dos objetivos de cada m´etodo s˜ ao suficientes para escrevermos algoritmos que utilizem datas. Na implementa¸c˜ ao de um TAD, alguns fatores elevam a qualidade do c´odigo: • Ocultamento de informa¸c˜ oes: ´e importante que o cliente conhe¸ca o menor n´ umero poss´ıvel de informa¸c˜ oes a respeito do TAD. Por exemplo, na classe Data, os atributos dia, mes e ano n˜ao podem ser alterados em qualquer cliente, visto que s˜ao privativos da classe. Se quisermos permitir que o cliente altere algum dos campos, ser´a melhor criarmos m´etodos para fazerem esta altera¸c˜ ao, como mostrado no fragmento de c´odigo abaixo: class Data { private: int dia, mes, ano; public: Data(int d, int m, int a) { // Com valida¸ c~ ao ano = a; mes = (m >= 1 && m <= 12) ? m : 1; dia = d; if (! valida()) dia = 1; }

11

Notas de aulas de AED

12

Data() { ... } bool bissexto() { ... } void imprime() { ... } bool valida() { ... } void alteraMes(int m) { if (m >= 1 && m <= 12) mes = m; } }; void main(void) { Data d(10,3,2003); // A data ´ e v´ alida d.mes = 13; // Tentativa de atribuir valor inv´ alido // ´ e impedida pelo compilador d.alteraMes(13); // Tentativa de atribuir valor inv´ alido // ´ e impedida pelo m´ etodo alteraMes d.imprime(); }

Suponha agora que, em vez de utilizarmos os trˆes campos para dia, mˆes e ano, utilizemos um u ´nico campo armazenando o n´ umero de dias de diferen¸ca entre a data desejada e uma data base qualquer (por exemplo, poder´ıamos utilizar como base 1/1/1 ou 1/1/1970 ou ent˜ ao 1/1/2000. Neste caso, se a base for o dia 01/01/2000, ent˜ao a data 03/02/2003 seria representada por um u ´nico n´ umero: 1130. Esta representa¸c˜ao possui duas vantagens: economia de espa¸co, ao utilizarmos um u ´nico n´ umero no lugar de trˆes, e facilidade para realizar opera¸c˜ oes sobre datas, como por exemplo, determinar que dia foi 456 dias antes de 10/01/2003. Se esta mudan¸ca for necess´aria, o cliente acima continua funcionando sem problemas, ao passo que se a segunda linha da fun¸c˜ao main acima fosse permitida, dever´ıamos alterar tamb´em o cliente e n˜ ao somente a classe. Isto tornaria o trabalho de alterar a representa¸c˜ ao interna da data muito mais complexo. • Interface simples e bem definida: o TAD deve ser definido de forma que seja f´acil utilizar as suas funcionalidades. Uma das conseq¨ uˆencias da boa defini¸c˜ao da interface ´e a possibilidade de alterar os algoritmos sem que isto altere a interface dos m´etodos. Por exemplo, observe que podemos alterar o algoritmo da fun¸c˜ao valida sem necessitarmos alterar a sua assinatura: bool valida() { int dias_mes[12] = {31,28,31,30,31,30,31,31,30,31,30,31}; if (mes > 12 || mes < 1) return false; int maiordia = dias_mes[mes - 1]; if (mes == 2 && bissexto()) maiordia++; return (dia >= 1 && dia <= maiordia); }

De qualquer maneira, o importante ´e que o cliente pode fazer a valida¸c˜ao da data independente do algoritmo utilizado. Isto significa que se quisermos alterar o algoritmo utilizado para valida¸c˜ ao, o cliente n˜ ao precisar´a ser alterado. Outros fatores de qualidade ser˜ ao discutidos no momento oportuno. Resumindo, chegamos a duas caracter´ısticas importantes de um Tipo Abstrato de Dados: • independˆencia da representa¸c˜ ao interna: n˜ao importa se h´a trˆes n´ umeros ou somente um para representar a data, pois isto n˜ao ir´a afetar os clientes; na verdade, os clientes est˜ao independentes desta decis˜ ao. • independˆencia dos algoritmos: n˜ao importa como a data ser´a validada, mas sim qual ´e a interface do m´etodo, isto ´e, o que s˜ao os parˆametros esperados e o que significa o valor retornado. Todos os direitos reservados aos autores

Notas de aulas de AED

3.3

13

Estudo de caso: TAD Vetor

Para exemplificar todo o conte´ udo visto at´e agora, faremos uma implementa¸c˜ao do Tipo Abstrato de Dados Vetor, que armazena n´ umeros inteiros e possui as seguintes opera¸c˜oes: • Cria¸c˜ ao: inicializa o vetor como vazio; • obtemTamanho(): Retorna o n´ umero de elementos armazenados no vetor; • insereNoFinal(x): Insere x na primeira posi¸c˜ao vazia do vetor; • alteraEm(p,x): Atribui x ao elemento da posi¸c˜ao p do vetor, caso p seja uma posi¸c˜ao v´alida; • elementoEm(p): Retorna o valor armazenado na posi¸c˜ao p, caso esta seja uma posi¸c˜ao v´alida; retorna −1 caso contr´ ario; • posicaoDe(x): Retorna a posi¸c˜ ao da primeira ocorrˆencia de x no vetor ou o valor −1 caso x n˜ ao seja encontrado; • imprime(): Imprime os elementos do vetor, separados por um espa¸co.

3.3.1

Exemplo de Cliente

Abaixo, temos um exemplo de fun¸c˜ ao que deve funcionar com a implementa¸c˜ao do TAD Vetor: void main(void) { Vetor V; V.insereNoFinal(10); V.insereNoFinal(8); V.insereNoFinal(16); V.insereNoFinal(7); V.insereNoFinal(5); V.insereNoFinal(13); V.imprime(); cout << endl; V.alteraEm(3,19); V.alteraEm(15,9); for (int i = 0; i < V.obtemTamanho(); i++) cout << "Elemento na posicao " << i << ": " << V.elementoEm(i) << endl; cout << endl; V.imprime(); }

3.3.2

Uma primeira implementa¸ c˜ ao

Podemos implementar este TAD como uma classe com a seguinte assinatura: class Vetor { public: Vetor(); int obtemTamanho(); void insereNoFinal(int novo_valor); void alteraEm(int pos, int novo_valor); int elementoEm(int pos); int posicaoDe(int elemento); void imprime(); };

Faremos inicialmente uma implementa¸c˜ao que utiliza aloca¸c˜ao est´atica e que n˜ao permite que o vetor possua mais do que 100 elementos. Se n˜ao houver possibilidade de inser¸c˜ao do elemento, exibiremos uma mensagem de erro. O mesmo ser´a feito nas opera¸c˜oes alteraEm e elementoEm. Uma implementa¸c˜ ao desta classe pode ser a seguinte:

Todos os direitos reservados aos autores

Notas de aulas de AED

14

const int TAMANHO_MAXIMO = 100; class Vetor { private: int v[TAMANHO_MAXIMO], numElementos; public: Vetor(); int obtemTamanho(); void insereNoFinal(int novo_valor); void alteraEm(int pos, int novo_valor); int elementoEm(int pos); int posicaoDe(int elemento); void imprime(); };

Observe que os atributos necess´ arios s˜ao o vetor, que est´a definido estaticamente com 100 elementos, e o n´ umero de elementos. O construtor dever´ a simplesmente inicializar o atributo numElementos com zero, indicando que n˜ao h´ a elementos armazenados. Pode-se tamb´em zerar todas as posi¸c˜oes do vetor, mas isto n˜ao ´e totalmente necess´ ario, pois os demais m´etodos utilizar˜ao o n´ umero de elementos para evitar acesso a posi¸c˜ oes inexistentes do vetor. Vetor::Vetor() { numElementos = 0; // Inicializa¸ c~ ao opcional do vetor for (int i = 0; i < TAMANHO_MAXIMO; i++) v[i] = 0; }

O m´etodo obtemTamanho dever´ a retornar o n´ umero de elementos presentes no vetor. Pode-se simplesmente retornar o valor do atributo numElementos, como abaixo: int Vetor::obtemTamanho() { return numElementos; }

A inser¸c˜ ao no final do vetor dever´a verificar se o vetor est´a cheio. Se estiver, ent˜ao n˜ao poderemos fazer a inser¸c˜ ao e uma mensagem de erro ser´a exibida. Sen˜ao, faremos a inser¸c˜ao na posi¸c˜ao numElementos e este atributo ser´a incrementado em um: void Vetor::insereNoFinal(int novo_valor) { if (numElementos == TAMANHO_MAXIMO) cout << "ERRO: O vetor est´ a cheio!" << endl; else { v[numElementos] = int novo_valor; numElementos++; } }

O m´etodo alteraEm(p,x) verifica se p ´e uma posi¸c˜ao v´alida do vetor. Se for, atribui o valor de x `a posi¸c˜ ao p; sen˜ ao, exibe uma mensagem de erro: void Vetor::alteraEm(int pos, int novo_valor) { if (pos < 0 || pos >= numElementos) cout << "ERRO: A posi¸ c~ ao " << pos << " n~ ao ´ e v´ alida!" << endl; else v[pos] = novo_valor; }

O m´etodo elementoEm(p) funciona de modo semelhante: int Vetor::elementoEm(int pos) { if (pos >= 0 && pos < numElementos) return v[pos]; cout << "ERRO: A posi¸ c~ ao " << pos << " n~ ao ´ e v´ alida!" << endl; return -1; }

Todos os direitos reservados aos autores

Notas de aulas de AED

15

O m´etodo posicaoDe(x) procura a primeira ocorrˆencia de x no vetor. Se o elemento n˜ao for encontrado, retornar´ a −1: int Vetor::posicaoDe(int elemento) { int i = 0; bool encontrado = false; while (encontrado == false && i < numElementos) if (v[i] == elemento) encontrado = true; else i++; return (encontrado) ? i : -1; }

O m´etodo imprime() simplesmente faz a impress˜ao dos elementos do vetor: void Vetor::imprime() { for (int i = 0; i < numElementos; i++) cout << v[i] << " "; cout << endl; }

Todos os direitos reservados aos autores

T´ opico 4

Apontadores Durante a execu¸c˜ ao do programa, as vari´aveis s˜ao armazenadas na mem´oria em endere¸cos especificados pelo compilador. Se imaginarmos a mem´oria como um grande vetor, podemos considerar que as vari´ aveis ocupam posi¸c˜ oes espec´ıficas neste vetor, denominadas endere¸cos de mem´ oria. At´e agora, trabalhamos somente com vari´ aveis cujos endere¸cos foram determinados pelo compilador durante o processo de compila¸c˜ ao. Quando isto ocorre, dizemos que a vari´avel ´e est´atica, ou que ela foi alocada estaticamente. Posteriormente, veremos como criar vari´aveis dinamicamente, isto ´e, durante a execu¸c˜ ao do programa. Como um endere¸co de mem´ oria ´e um n´ umero inteiro, ´e poss´ıvel armazenarmos endere¸cos de mem´oria de vari´ aveis existentes em posi¸c˜oes da mem´oria. Isto ´e o que ocorre quando armazenamos o ´ındice de um vetor em uma vari´ avel em um comando de repeti¸c˜ao que percorre o vetor. Um Apontador ´e uma vari´ avel cujo valor ´e um endere¸co de mem´ oria. Em C++, para criarmos apontadores, devemos inicialmente determinar qual o tipo da vari´avel que ter´a seu endere¸co armazenado. Mesmo sabendo que os endere¸cos de uma vari´avel do tipo char e de uma vari´ avel do tipo double s˜ ao n´ umeros inteiros, o compilador C++ utiliza esta informa¸c˜ao para ajudar a evitar erros cometidos ao confundir os tipos. Para criarmos um apontador que conter´a o endere¸co de uma vari´avel do tipo T, ou em outras palavras, um apontador para alguma informa¸c˜ao do tipo T, utilizamos a sintaxe T * var. Por exemplo, o trecho de c´ odigo a seguir declara trˆes vari´aveis do tipo char (c1, c2 e c5), dois apontadores para o tipo char (c3 e c3), uma vari´avel do tipo int (i2), trˆes apontadores para o tipo int (i1, i3 e i4), uma vari´ avel do tipo float (f2) e um apontador para o tipo float (f1). Observe que o asterisco deve preceder a declara¸c˜ao de cada apontador. char c1, c2, *c3, *c4, c5; int *i1, i2, *i3, *i4; float *f1, f2;

Para manipularmos endere¸cos de mem´oria, podemos utilizar dois operadores: endere¸co de (&) e conte´ udo de (*). O fragmento de c´ odigo abaixo declara uma vari´avel inteira (x) e um apontador para inteiros (ptr) e atribui a ptr o endere¸co de mem´oria onde x est´a armazenada: int x, *ptr; ptr = &x; // Atribui a ptr o endere¸ co de x

´ poss´ıvel alterar o valor de x via um apontador que contenha o seu endere¸co. Isto ´e feito E utilizando o operador conte´ udo de (*), como no exemplo abaixo: int x = 0, *ptr; ptr = &x; // Atribui a ptr o endere¸ co de x *ptr = 1; // Atribui 1 ` a vari´ avel cujo endere¸ co est´ a // armazenado no apontador ptr, isto ´ e, x cout << x; // Imprime 1 e n~ ao 0

16

Notas de aulas de AED

17

Considere o programa abaixo. #include int x = 10; int * ptr = &x; void mostrar() { cout << "Valor de cout << "Valor de cout << "Conte´ udo cout << "Endere¸ co cout << "Endere¸ co } void main(void) { mostrar(); *ptr = 123; mostrar(); }

x: ptr: de ptr: de x: de ptr:

" " " " "

<< << << << <<

x ptr *ptr &x &ptr

<< << << << <<

endl; endl; endl; endl; endl;

A fun¸c˜ ao mostrar est´ a exibindo na seq¨ uˆencia: • o valor de x; • o valor de ptr, que ´e o endere¸co de x; • o conte´ udo de ptr, que ´e o valor de x; • o endere¸co de x; • o endere¸co de ptr. Considerando que, no programa acima, a vari´avel a seja alocada pelo compilador no endere¸co FF50 e p seja alocada pelo compilador no endere¸co FF54, ent˜ao a sa´ıda obtida na tela ser´a: Valor de Valor de Conte´ udo Endere¸ co Endere¸ co Valor de Valor de Conte´ udo Endere¸ co Endere¸ co

x: ptr: de ptr: de x: de ptr: x: ptr: de ptr: de x: de ptr:

10 FF50 10 FF50 FF54 123 FF50 123 FF50 FF54

Considere agora o programa abaixo: #include void main(void) { char ch1, ch2, *p, *q; ch1 = ’A’; p = &ch1; q = *p = *p + 1; // comando *q = *p + 1; // comando *p = ch2 + 1; // comando cout << "Valor de ch1: " cout << "Valor de ch2: " }

&ch2; 1 2 3 << ch1 << endl; << ch2 << endl;

Lembrando que *p significa vari´ avel cujo endere¸co est´a armazenado em p, temos que o comando 1 altera o valor de ch1 para ‘B’ (lembre-se que somar 1 a uma vari´avel char que contenha uma letra ´e equivalente a obter a letra seguinte). O comando 2 altera o valor de ch2 (apontado por q) para ‘C’, que ´e exatamente o valor de ch1 mais um. O comando 2 atribui a ch1 o valor ‘D’. Observe o que acontece no programa abaixo: Todos os direitos reservados aos autores

Notas de aulas de AED

18

#include void main(void) { float x = 0.8, *apont1, *apont2; apont1 = &x; // comando 1 apont2 = apont1; // comando 2 *apont1 = *apont2 / 2; // comando 3 *apont2 = x / 2; // comando 4 cout << "Valor de x: " << x << endl; }

O comando 1 atribui o endere¸co de x a apont1. O comando 2 atribui o valor de apont1 a apont2, isto ´e, o apontador apont2 conter´a, assim como apont1, o endere¸co da vari´avel x. Isto significa que tanto *apont1 quanto *apont2 significam x. Deste modo, o comando 3 atribui 0.4 a x e o comando 4 atribui 0.2 a x.

Todos os direitos reservados aos autores

T´ opico 5

Aloca¸c˜ ao Dinˆ amica Considere que vocˆe esteja escrevendo um programa que leia um valor n indicando o n´ umero de elementos de uma seq¨ uˆencia e, em seguida, leia n n´ umeros fornecidos pelo usu´ario, armazenando-os em um vetor. Como o valor de n ´e a princ´ıpio desconhecido, podemos supor um valor m´aximo (por exemplo, 100.000 elementos) e impedirmos, no programa, que o usu´ario forne¸ca um valor de n maior que este m´aximo especificado. Entretanto, dois problemas ocorrem: pode ser que o valor m´aximo ainda seja pequeno para alguma seq¨ uˆencia de elementos; por outro lado, se a quantidade de elementos for muito pequena, teremos desperdi¸cado muito espa¸co de mem´oria ao reservarmos ´area para um vetor muito grande. Quando declaramos um vetor como mostrado a seguir, o endere¸co da primeira posi¸c˜ao do vetor e o n´ umero de bytes que este vetor ocupa s˜ao definidos pelo compilador. Esta aloca¸c˜ao ´e chamada de Aloca¸c˜ ao Est´ atica: int vetor[100]; // Vetor alocado estaticamente

O mesmo ocorre com todas as vari´aveis declaradas no programa. O compilador define onde a vari´avel estar´ a armazenada e quantos bytes ser˜ao utilizados para conter o seu valor. Um problema como o especificado acima requer que tenhamos a possibilidade de definir o tamanho de um vetor durante a execu¸c˜ao, pois, somente ap´os o usu´ario fornecer o valor de n, saberemos o n´ umero de elementos que o vetor dever´a possuir. Isto pode ser feito utilizando Aloca¸c˜ ao Dinˆ amica. Alocar dinamicamente uma vari´ avel significa definir, em tempo de execu¸c˜ao, o endere¸co e o n´ umero de bytes que esta vari´ avel ocupar´a. Para alocarmos dinamicamente um vetor de elementos, declaramos um apontador e atribu´ımos a este apontador o endere¸co retornado pelo operador new da linguagem C++. H´a duas formas de alocarmos uma vari´avel dinamicamente: 1. Aloca¸c˜ ao de uma vari´ avel simples: int * p; // p ´ e um apontador para inteiros p = new int; // aloca nova ´ area para um inteiro

2. Aloca¸c˜ ao de um vetor de elementos: int int ... p =

* p; // p ´ e um apontador para inteiros n; obten¸ c~ ao do valor de n ... new int[n]; // aloca ´ area para um vetor de n inteiros

Inicialmente, iremos nos concentrar na segunda forma. A resolu¸c˜ao do problema acima pode ser feita utilizando este recurso lingu´ıstico da seguinte maneira:

19

Notas de aulas de AED

20

#include void main(void) { int * vetor, n, i; cout << "Qual ´ e o valor de n? "; cin >> n; vetor = new int[n]; for (i = 0; i < n; i++) { cout << "Elemento na posi¸ c~ ao " << i << ": "; cin >> vetor[i]; } ... uso do vetor ... }

Observe que o vetor alocado dinamicamente ´e manipulado da mesma forma que um vetor alocado estaticamente, isto ´e, utilizando [] para indexa¸c˜ao. A diferen¸ca ´e que agora declaramos o vetor como um apontador e utilizamos o operador new para fazermos a aloca¸c˜ao. Quando uma vari´ avel ´e alocada estaticamente, a ´area de mem´oria que ela ocupa ´e liberada automaticamente durante a execu¸c˜ ao e coincide com o final do escopo da vari´avel. Por exemplo, uma vari´ avel local de fun¸c˜ ao tem a sua ´area de mem´oria liberada automaticamente quando esta fun¸c˜ao retorna; uma vari´ avel global deixa de existir quando o programa termina. Uma vari´ avel alocada dinamicamente deixa de existir quando o programador utiliza o operador delete. Este operador tem por objetivo liberar a ´area de mem´oria reservada para uma vari´avel. O programa acima fica assim, com o uso do delete: #include void main(void) { int * vetor, n, i; cout << "Qual ´ e o valor de n? "; cin >> n; vetor = new int[n]; // Aloca¸ c~ ao de vetor for (i = 0; i < n; i++) { cout << "Elemento na posi¸ c~ ao " << i << ": "; cin >> vetor[i]; } ... uso do vetor ... delete [] vetor; // Libera ´ area de mem´ oria }

O uso dos colchetes entre o delete e a vari´avel apontador ´e necess´ario quando liberamos ´area de mem´oria reservada para um vetor. Se tiv´essemos feito a aloca¸c˜ao como uma vari´avel simples, n˜ao utilizar´ıamos os colchetes. ´ importante salientar que o delete opera sobre a ´area de mem´oria cujo endere¸co est´a no E apontador, e n˜ ao sobre o apontador. Por exemplo, considere o seguinte fragmento de c´odigo: int * v1, * v2, n; cout << "Qual ´ e o valor de n? "; cin >> n; v1 = new int[n]; // Aloca¸ c~ ao de v1 v2 = v1; // v1 e v2 apontam para o mesmo local for (i = 0; i < n; i++){ cout << "Elemento na posi¸ c~ ao " << i << ": "; cin >> v2[i]; } for (i = n - 1; i >= 0; i++) cout << v1[i] << " "; cout << endl; delete [] v2;

Neste exemplo, alocamos um vetor dinamicamente e armazenamos o seu endere¸co de in´ıcio na vari´avel apontador v1. Em seguida, atribu´ımos o valor de v1 a v2; isto faz com que tenhamos Todos os direitos reservados aos autores

Notas de aulas de AED

21

´ vital percerbermos que n˜ao h´a dois dois apontadores contendo o mesmo endere¸co de mem´ oria. E vetores, mas somente um vetor, cujo endere¸co est´a armazenado em duas vari´aveis apontadores. Desta forma, ap´ os esta atribui¸c˜ ao, tanto faz se utilizarmos o vetor via vari´avel v1 ou via vari´avel v2. Al´em disso, observe que ao fazer a libera¸c˜ao de mem´oria, podemos utilizar o delete com v1 ou com v2; todos os dois apontadores contˆem o endere¸co do mesmo vetor, e esta ´area de mem´oria ´ importante salientar que, se fiz´essemos a libera¸c˜ao como: ser´a liberada. E delete [] v1; delete [] v2;

ocorreria um erro de execu¸c˜ ao: a primeira linha liberaria a ´area do vetor e ao tentarmos fazer o segundo delete, tentar´ıamos liberar uma ´area de mem´oria que n˜ao est´a mais reservada para o programa (v1 e v2 s˜ ao o mesmo vetor). Considere por exemplo que precisemos fazer uma fun¸c˜ao que aloque dinamicamente um vetor com o n´ umero de elementos especificado como parˆametro da fun¸c˜ao. O endere¸co onde o vetor foi alocado (isto ´e, um apontador) ser´a retornado pela fun¸c˜ao. O vetor, ap´os ser alocado, ser´a preenchido com zeros. Esta fun¸c˜ ao pode ser escrita como: // n ´ e o tamanho do vetor a ser alocado // retornaremos o endere¸ co de mem´ oria do vetor, que // ´ e do tipo (int *). int * alocaVetor(int n) { int * v, i; if (n <= 0) // N~ ao ´ e poss´ ıvel alocar return NULL; // Endere¸ co zero: marca erro na aloca¸ c~ ao v = new int[n]; // Aloca um vetor de n inteiros for (i = 0; i < n; i++) // Zera o vetor v[i] = 0; return v; // Retorna o endere¸ co do novo vetor }

Fa¸camos um outro exemplo: considere que precisemos ler uma seq¨ uˆencia de n´ umeros de ponto flutuante e armazenar esta seq¨ uˆencia em um vetor. Os n´ umeros que ser˜ao inseridos ser˜ao sempre n´ umeros no intervalo fechado [1, 100], de modo que poderemos utilizar um valor qualquer como sentinela no final da entrada; em outras palavras, considere que o usu´ario digitar´a diversos n´ umeros e que, ao final, ele digitar´ a algum valor inv´alido para marcar o final da entrada. Por exemplo, se o usu´ario digitar 1.2, 3.7, 41.3, 82.3, 7.42, -1, ent˜ao saberemos que a seq¨ uˆencia desejada ´e 1.2, 3.7, 41.3, 82.3, 7.42. Faremos uma fun¸c˜ ao de cabe¸calho double * leSequencia(int & n);

que ler´ a o vetor e retornar´ a dois valores: o endere¸co onde o vetor foi alocado, por meio de return, e o n´ umero de elementos lidos, por meio do parˆametro de referˆencia n. Fa¸camos uma vers˜ ao inicial desta fun¸c˜ao, lendo no m´aximo 100 elementos: const int MAXIMO_ELEMENTOS = 100; double * leSequencia(int & n) { n = 0; double * v = new double[MAXIMO_ELEMENTOS]; cout << "Qual ´ e o primeiro elemento? "; cin >> x; while (n < MAXIMO_ELEMENTOS && x >= 1.0 && x <= 100.0) { v[n] = x; n++; // Salva o valor no vetor e incrementa n cout << "Qual ´ e o pr´ oximo elemento? "; cin >> x; }

Todos os direitos reservados aos autores

Notas de aulas de AED

22

return v; }

Observe que, se o usu´ ario tentar fornecer mais elementos que o limite especificado, ent˜ao o programa cessar´ a a leitura e retornar´ a os 100 primeiros elementos no vetor. Al´em disso, o n´ umero de elementos ´e armazenado diretamente no parˆametro de referˆencia n. Um exemplo de cliente desta fun¸c˜ ao ´e: void main(void) { double * meuVetor; int numElementos, i; meuVetor = leSequencia(numElementos); cout << "N´ umeros fornecidos: " << endl; for (i = 0; i < numElementos; i++) cout << meuVetor[i] << " "; cout << endl; delete [] meuVetor; }

Neste exemplo, ´e interessante salientar os seguintes aspectos: • A chamada de leSequencia atribui a meuVetor o endere¸co do vetor alocado dentro da fun¸c˜ao e a numElementos, o n´ umero de elementos lidos; • A libera¸c˜ ao de mem´ oria ´e feita utilizando o apontador que cont´em o endere¸co de mem´oria da ´ area alocada dinamicamente; isto significa que: – Tivemos que utilizar a vari´ avel meuVetor e n˜ao a vari´avel v, pois esta ´e local da fun¸c˜ao leSequencia; – Fazer a libera¸c˜ ao de mem´ oria dentro da fun¸c˜ao leSequencia ´e um erro, pois este vetor ainda ser´ a utilizado ao longo do programa. Devemos salientar que esta fun¸c˜ ao possui ainda um grave problema: se quisermos armazenar mais do que 100 elementos, devemos alterar o valor da constante, o que trar´a os problemas j´a discutidos anteriormente. Para evitarmos tais problemas, utilizaremos uma nova abordagem. Se o n´ umero de elementos ultrapassar o tamanho especificado, ent˜ao faremos a aloca¸c˜ao de um novo vetor maior que o original, copiaremos todos os elementos para este novo vetor e utilizaremos este novo vetor no lugar do original. Uma vez que o vetor original pode ser substitu´ıdo pelo novo vetor, liberaremos a ´area de mem´ oria que continha inicialmente os elementos. Esta abordagem, denominada realoca¸c˜ ao, est´a implementada na fun¸c˜ao leSequencia abaixo: const int TAMANHO_INICIAL = 100; double * leSequencia(int & n) { int tamanho = TAMANHO_INICIAL, novo_tamanho, i; double * vetor, * novo_vetor; vetor = new double[tamanho]; n = 0; cout << "Qual ´ e o primeiro elemento? "; cin >> x; while (x >= 1.0 && x <= 100.0) { if (n == tamanho) { novo_tamanho = 2 * tamanho; novo_vetor = new double[novo_tamanho]; for (i = 0; i < tamanho; i++) novo_vetor[i] = vetor[i]; delete [] vetor; tamanho = novo_tamanho; vetor = novo_vetor;

Todos os direitos reservados aos autores

Notas de aulas de AED

23

} vetor[n] = x; n++; // Salva o valor no vetor e incrementa n cout << "Qual ´ e o pr´ oximo elemento? "; cin >> x; } return vetor; }

Nesta nova fun¸c˜ ao, fazemos um teste dentro do while, verificando se o n´ umero de elementos j´a lidos ´e igual ao tamanho do vetor; se for igual, ent˜ao o vetor est´a cheio e n˜ao ´e poss´ıvel inserir novos elementos. Se isto ocorrer, ent˜ ao fazemos a realoca¸c˜ao, cujo c´odigo est´a dentro do if, realizando as seguintes opera¸c˜ oes: • Definimos o tamanho do novo vetor: neste exemplo, criamos um novo vetor com o dobro do tamanho do vetor utilizado, isto ´e, o vetor inicia com 100 elementos e passa a 200, 400, 800 elementos, conforme a necessidade; poder´ıamos aumentar de 1 em 1 elemento, ou ent˜ao de 10 em 10, ou outra quantidade qualquer, poder´ıamos aumentar em 20% ou outra quantidade proporcional, etc. • Alocamos o novo vetor com o tamanho especificado. • Copiamos todos os elementos do vetor antigo para o novo vetor. • Liberamos ´ area de mem´ oria do vetor antigo: este vetor n˜ao ´e mais necess´ario, pois todos os elementos j´ a est˜ ao em novo vetor, que ´e um vetor maior que o original. • Atribu´ımos o valor do novo tamanho ` a vari´ avel tamanho. • Atribu´ımos o endere¸co do novo vetor ao apontador que cont´em o vetor utilizado: esta atribui¸c˜ ao faz com que a vari´ avel vetor que est´a sendo utilizada ao longo da fun¸c˜ao contenha agora o endere¸co do vetor maior, de modo que as inser¸c˜oes seguintes ser˜ao poss´ıveis. Agora, podemos fornecer qualquer quantidade de elementos, pois a fun¸c˜ao n˜ao precisa mais limitar esta quantidade. A u ´nica limita¸c˜ao que temos agora ´e de m´aquina.

Todos os direitos reservados aos autores

T´ opico 6

An´ alise de complexidade 6.1

Introdu¸ c˜ ao

Normalmente, o mesmo problema pode ter diversas solu¸c˜oes computacionais utilizando diferentes t´ecnicas de programa¸c˜ ao, assim como, diferentes l´ogicas de programa¸c˜ao. Uma pergunta surge ent˜ao: como devemos fazer para escolher qual solu¸c˜ao usar? A resposta para esta quest˜ao parece ´ claro que iremos considerar a solu¸c˜ao que seja mais r´ ´obvia. E apida. Mas como determinar qual solu¸c˜ ao ´e mais r´ apida? Para isto, ser´a necess´ario implementar programas que resolvam este problema, seguido de uma verifica¸c˜ao de tempo para cada solu¸c˜ao. Essa metodologia para a determina¸c˜ ao do programa mais r´ apido ´e bastante custosa pois devemos ter um computador, um compilador, conhecer alguma linguagem de programa¸c˜ao, assim como, tempo sobrando para o seu desenvolvimento. Ainda, essa metodologia ´e altamente dependente das configura¸c˜oes do computador usado. Para diminuir o custo deste processo, tentaremos desenvolver uma metodologia que determinar´ a o custo computacional de um algoritmo, com rela¸c˜ao ao tempo mas tamb´em ao espa¸co utilizado.

6.2

Complexidade computacional e an´ alise assint´ otica

Dentre os objetivos de ATP2, estamos interessados em resolver problemas da melhor forma poss´ıvel, isto ´e, desenvolver algoritmo cujo custo computacional seja o menor poss´ıvel (dentro das limita¸c˜ oes e possibilidades oferecidas at´e o momento) para um dado problema. Em outras palavras, buscamos programas eficientes. Por exemplo, fa¸ca uma fun¸c˜ ao de cabe¸calho int posicaoDe(int x, int * v, int tam); que retorne a posi¸c˜ ao da ocorrˆencia do elemento x no vetor v contendo tam elementos distintos. Caso n˜ao exista este elemento, retorne -1. A primeira solu¸c˜ ao para essa fun¸c˜ ao seria: int posicaoDe(int x, int * v, int tam){ int pos = -1; for (int i = 0; i < tam; i++) if (v[i] == x) pos = i; return pos; } Nessa solu¸c˜ ao podemos observar que todos os elementos ser˜ao sempre pesquisados e verificados, logo podemos afirmar que ser˜ ao feitas tam compara¸c˜oes para determinar a posi¸c˜ao do elemento. Mas se o elemento estiver na primeira posi¸c˜ao, ser´a necess´ario comparar todos os elementos com o elemento a ser pesquisado? Veja a segunda solu¸c˜ao 24

Notas de aulas de AED

25

int posicaoDe(int x, int * v, int tam){ int pos = -1; for (int i = 0; i < tam; i++) if (v[i] == x) return i; return pos; } nessa solu¸c˜ ao, podemos ter 3 situa¸c˜ oes cr´ıticas: (i) o elemento est´a na primeira posi¸c˜ao do vetor; (ii) o elemento est´ a na u ´ltima posi¸c˜ ao do vetor; e (iii) o elemento n˜ao est´a no vetor. Para a situa¸c˜ao (i) teremos somente uma compara¸c˜ ao, j´ a para as outras situa¸c˜oes teremos tam compara¸c˜oes. Ainda ´e poss´ıvel melhorar a solu¸c˜ ao deste problema? A resposta ficar´a como exerc´ıcio, assim como, poss´ıveis solu¸c˜ oes, caso existam. Como podemos observar, existem diferentes formas para se resolver o problema de localiza¸c˜ao de um elemento em um vetor, entretanto nenhuma compara¸c˜ao entre as abordagens foi realizada. Para compararmos diferentes programas, relativo a sua eficiˆencia, definimos uma medida de grau de dificuldade de um algoritmo, denominada complexidade computacional. A complexidade computacional indica quanto esfor¸co ´e necess´ario para se aplicar um algoritmo, ou qu˜ao custoso este algoritmo ´e. Dois diferentes crit´erios de eficiˆencia podem ser considerados: tempo e espa¸co. Aqui, consideraremos somente o tempo. Quando consideramos tempo, a primeira id´eia que temos ´e: quantos minutos (ou segundos) s˜ao necess´ arios para a execu¸c˜ ao de um programa? Esta abordagem ´e muito custosa, visto que todos os programa devem ser implementados, na mesma linguagem, e executados em m´aquinas com as mesmas configura¸c˜ oes, e ainda nas mesmas condi¸c˜oes de teste. Contrariamente ao uso de tempo real, a avalia¸c˜ ao de eficiˆencia de um algoritmo, d´a-se pela utiliza¸c˜ao de unidades l´ogicas, que expressam a rela¸c˜ ao entre o tamanho n da entrada e a quantidade de tempo f (n) necess´aria para processar os dados. A defini¸c˜ ao de uma unidade l´ogica est´a associada a(s) opera¸c˜ao(˜oes) que se deseja considerar para comparar algoritmos, por exemplo, quantas compara¸c˜oes s˜ao realizadas para localizar um elemento dentro de um vetor? Neste caso, a unidade l´ogica a ser considerada ´e a compara¸c˜ ao. Normalmente, a fun¸c˜ ao para expressar a eficiˆencia de um algoritmo ´e complexa e cheia de termos. Quando consideramos grandes quantidades de dados, termos que n˜ao modifiquem a grandeza da express˜ ao devem ser eliminados de forma a simplificar a compara¸c˜ao entre diferentes fun¸c˜oes. Essa medida de eficiˆencia ´e denominada complexidade assint´ otica. Veja um exemplo, considere um algoritmo com a seguinte complexidade computacional f (n) = n3 + 100n + 10000

(6.1)

onde n ´e o tamanho da entrada do algoritmo (por exemplo, o n´ umero de elementos de um vetor). Nesta fun¸c˜ ao, observamos que para valores de n pequenos, o terceito termo ´e o mais importante, mas para valores de n muito grandes, o segundo e o terceiro termos s˜ao quase irris´orios no c´alculo da fun¸c˜ao. Com isto, podemos concluir que a complexidade assint´otica desse algoritmos est´a em fun¸c˜ao do termo n3 . Vale ressaltar que a avalia¸c˜ ao de eficiˆencia de um algoritmo faz sentido quando consideramos entradas de tamanho muito grande, pois para pequenas entrada, normalmente todos os algoritmos s˜ao r´apidos. Por isto, a necessidade do estudo da complexidade assint´otica, que ficar´a mais clara na pr´oxima se¸c˜ ao.

6.3

Nota¸ c˜ ao O

Uma abordagem para o estudo da eficiˆencia de um algoritmo ´e a an´alise do pior caso, tentando determinar a dependˆencia funcional do tempo de execu¸c˜ao com o tamanho da entrada. O primeiro

Todos os direitos reservados aos autores

Notas de aulas de AED

26

passo no processo ´e definir a no¸c˜ ao de “proporcional a”de forma precisa matematicamente, separando ainda, a an´ alise do algoritmo de uma implementa¸c˜ao em particular. A id´eia ´e ignorar fatores constantes na an´ alise, como vista na complexidade assint´otica. Por exemplo, na maioria dos casos, n´ os desejamos saber se o algoritmo ´e proporcional a n ou a n log n, independentemente se ser´a executado em um micro-computador ou em um super-computador. O artefato matem´atico para tornar esta defini¸c˜ ao precisa ´e chamada de nota¸ c˜ ao O (ou nota¸c˜ao grande O), que ´e usada para especificar a complexidade assint´ otica. Defini¸ c˜ ao 6.3.1 (Nota¸ c˜ ao O) f (n) ´e O(g(n)) se existem n´ umeros positivos c e n0 tais que f (n) ≤ c g(n) para todo n ≥ no Essa defini¸c˜ ao ´e lida da seguinte forma: f ´e O de g se existe um n´ umero positivo c tal que f n˜ao seja maior do c g para n suficientemente grande, isto ´e, para todo n maior que algum n´ umero n0 . Informalmente, essa defini¸c˜ao encapsula a no¸c˜ao de “´e proporcional a”e libera a an´alise dos detalhes de m´ aquinas particular. Al´em disso, a declara¸c˜ao que o tempo de execu¸c˜ao de um algoritmo ´e O(g(n)) ´e independente da entrada do algoritmo. Uma vez que n´os estamos interessados em estudar um algoritmo e n˜ao a entrada nem sua implementa¸c˜ao, a nota¸c˜ao O ´e a maneira u ´til de definir limites superiores sobre o tempo de execu¸c˜ao. A nota¸c˜ ao O tem sido extremamente u ´til e tem ajudado bastante os analistas a classificar os algoritmos pelo desempenho e a guiar os projetistas de algoritmos na pesquisa para o melhor algoritmo para problemas importantes. Entretanto, devemos ser extremamente cuidadosos na interpreta¸c˜ ao dos resultados expressos na nota¸c˜ao O, dentre as raz˜oes para este cuidado podemos citar: (i) por ser um limite superior, o tamanho da entrada poderia ser muito menor que a identificada pela defini¸c˜ ao; (ii) a entrada que provoca o pior caso, pode dificilmente ocorrer na pr´atica; (iii) a constante c ´e desconhecida e n˜ao precisa ser pequena; e (iv) a constante n0 ´e desconhecida e n˜ ao precisa ser pequena. A seguir iremos considerar cada um destes itens em detalhe. A afirma¸c˜ ao que o tempo de execu¸c˜ao de um algoritmo ´e O(g(n)) n˜ao implica que o algoritmo sempre ser´ a t˜ ao longo, esta afirma¸c˜ ao diz somente que a an´alise foi capaz de mostrar que ele nunca tomar´a um tempo maior que g(n). Mesmo se a entrada do pior caso ´e conhecida, pode ser o caso que as entrada na pr´atica conduzam a tempos de execu¸c˜ ao muito inferiores. Por exemplo, um algoritmo de ordena¸c˜ao (ser´a estudado posteriormente) chamada Quicksort possui o tempo de execu¸c˜ao no pior caso O(n2 ), mas ´e possible arranjar a entrada de forma que o tempo de execu¸c˜ao seja proporcional a n log n. As constantes c e n0 impl´ıcitas na nota¸c˜ao O frequentemente escondem detalhes de implementa¸c˜ao que s˜ ao importantes na pr´ atica. Obviamente, dizer que um algoritmo tem um tempo de execu¸c˜ ao O(g(n)) n˜ ao considera nada sobre o tempo de execu¸c˜ao se n for menor que n0 , e c poderia esconder um grande overhead projetado para evitar que o pior caso seja muito ruim. Obviamente, ´e prefer´ıvel um algoritmo que executa em n2 nano-segundos que um algoritmo que executa em log n s´eculos, mas infelizmente, n´os n˜ao poder´ıamos fazer esta escolha com base na nota¸c˜ao O. De forma geral, os algoritmos podem ser classificados de acordo com o seu custo computacional crescente segundo a nota¸c˜ ao O, como veremos a seguir:

Todos os direitos reservados aos autores

Notas de aulas de AED

27

O(1) A maioria das instru¸c˜oes da maioria dos programas s˜ao executados uma u ´nica vez, ou no m´aximo, poucas vezes. Se todas as instru¸c˜oes de um programa tivessem esta propriedade, n´os dir´ıamos que seu tempo de execu¸c˜ ao ´e constante. Essa ´e a situa¸c˜ao que todos os programadores e/ou projetistas de software devem se esfor¸car para conseguir. O(log n) Este tempo de execu¸c˜ao normalmente ocorre em algoritmos quando particionamos um problema grande em peda¸cos menores de interesse. Quando isto ocorre n´ os dizemos que o algoritmo possui tempo de execu¸c˜ao logar´ıtmico. O(n) Quando o tempo de execu¸c˜ao ´e linear, a soma de processamento normalmente ´e pequena para cada elemento da entrada. O(n log n) Este tempo de execu¸c˜ao ´e computado para algoritmos que resolvem um problema atrav´es da sua quebra em subproblemas menores, resolve cada um destes subproblemas independentemente, e ent˜ao combina as solu¸c˜oes. O(n2 ) Este tempo de execu¸c˜ao normalmente ´e computado quando todos os pares de itens de dados s˜ao processados. Quando isto ocorre dizemos que o tempo de execu¸c˜ao ´e quadr´atico. Normalmente, algoritmos quadr´aticos devem ser usados para problemas relativamente pequenos. O(n3 ) Similarmente ao anterior, um algoritmo que processa triplas de itens de dados possui um tempo de execu¸c˜ao c´ ubico. O(2n ) Poucos algoritmos com tempo de execu¸c˜ao exponenciais s˜ao apropriados para usos pr´ aticos. Estes algoritmos s˜ao chamados de for¸ca bruta pois, geralmente, processam n-uplas itens de dados.

6.4

Exemplos

Calcular a ordem de complexidade do seguinte algoritmo : void Ordena(int A[ ], int n) { int i, j, min, x; for (i = 1; i < n; i ++) { min = i; for (j = i + 1; j <= n; j ++) if (A[j − 1] < A[min − 1]) min = j; if (min != i) { x = A[i − 1]; A[i − 1] = A[min − 1]; A[min − 1] = x; } } } O c´alculo da ordem de complexidade deve ser efeito, ent˜ao, da seguinte forma : void Ordena(int A[ ], int n) { int i, j, min, x;

Todos os direitos reservados aos autores

Notas de aulas de AED

28

 for (i = 1; i < n; i ++) {· · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · (n−1)×      min = i; · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · O(1)          for (j = i + 1; j <= n; j ++) · · · · · · · · · (n−i)×           if (A[j − 1] < A[min − 1]) · · · O(1) O(n)     O(1)      min = j; · · · · · · · · · · · · · · · O(1)    if (min != i) { · · · · · · · · · · · · · · · · · · · · · · · · · · ·  O(1)  O(n) O(n2 )        x = A[i − 1]; · · · O(1)          A[i − 1] = A[min − 1]; · · · O(1) O(1) O(1)             A[min − 1] = x; · · · O(1)           }    } } Calcule agora a fun¸c˜ ao de complexidade f (n), tal que f (n) represente o n´ umero de compara¸c˜oes entre os elementos do vetor A. De modo a calcular f (n) iremos utilizar de fi (n) = n´ umero de compara¸c˜ oes entre os elementos do vetor A para um dado i. Dessa forma : fi (n) = n − i Logo: f (n)

=

n−1 X

fi (n) = f1 (n) + f2 (n) + f3 (n) + · · · + fn−1 (n) =

i=1

=

n−1 X

(n − i) = (n − 1) + (n − 2) + (n − 3) + · · · + 1 =

i=1

= = =

(n − 1) + 1 · (n − 1) = 2 n · (n − 1) = 2 n n2 − · 2 2

A partir dessa fun¸c˜ ao de complexidade podemos, tamb´em, encontrar a ordem de complexidade, veja:

O(f (n))

= = = = =

Todos os direitos reservados aos autores

n2 n − )= 2 2 n2 n O( ) + O(− ) = 2 2 n n2 O(max( , − )) = 2 2 n2 O( ) = 2 1 O(n2 ) = 2 O(n2 )

= O(

T´ opico 7

Recursividade 7.1

Defini¸ c˜ ao

Informalmente, recurs˜ ao ´e um processo de resolver um problema grande atrav´es de sua redu¸c˜ao a um ou mais subproblemas que s˜ ao (i) idˆenticos em estrutura ao problema original e (ii) de alguma forma mais simples de resolver. Uma vez que subdivis˜ao original tenha sido feita, a mesma t´ecnica de decomposi¸c˜ ao ´e usada para dividir cada um destes subproblemas em novos subproblemas que s˜ ao igualmente mais simples. Eventualmente, os subproblemas tornam-se t˜ao simples que podem ser resolvidos sem uma nova subdivis˜ao. A solu¸c˜ao completa ´e ent˜ao obtida levando-se em considera¸c˜ ao os componentes resolvidos. Uma defini¸c˜ ao simples para uma fun¸c˜ao recursiva ´e que ela chama a ela mesma (como j´a vimos, uma fun¸c˜ ao definida em termos dela mesma). Ainda, uma fun¸c˜ao recursiva n˜ao pode chamar a ela mesma indefinidamente. Se isto ocorresse, o programa nunca acabaria. Por isto, ´e necess´ario haver uma condi¸c˜ ao de parada.

7.2

Ilustra¸ c˜ ao de uma abordagem recursiva

Imagine que vocˆe tenha recentemente aceitado a posi¸c˜ao de coordenador de fundos para uma campanha de combate a fome de sua regi˜ao. Nesta campanha, vocˆe dever´a arrecadar 1000 reais em doa¸c˜oes. Devido ` as regras impostas pelo comitˆe senior da campanha, cada doa¸c˜ao n˜ao pode passar de 1 real por pessoa. Como vocˆe deveria proceder? Obviamente, a primeira solu¸c˜ ao para este problema ´e encontrar 1000 pessoas da comunidade, e pedir a cada uma delas 1 real. Observe que vocˆe mesmo dever´a pedir esta quantia diretamente para estas pessoas. Computacionalmente falando, uma fun¸c˜ao de arrecada¸c˜ao seria: int arrecada1000(){ int soma = 0; for (int i = 0; i < 1000; i++){ cout << "Arrecadou 1 real da pessoa " << i; soma = soma + 1; } return soma; } Observe que esta fun¸c˜ ao ´e baseada em um loop expl´ıcito. Este tipo de constru¸c˜ao ´e chamada de solu¸c˜ao iterativa. Assumindo que vocˆe poderia encontrar as 1000 pessoas, esta solu¸c˜ao seria efetiva, mas ao mesmo tempo muito cansativa. Para evitar este cansa¸co e ainda para tentar agilizar o processo, a divis˜ ao dessa tarefa em sub-tarefas seria uma poss´ıvel solu¸c˜ao. Por exemplo, ao inv´es de pedir o dinheiro para 1000 pessoas, vocˆe pediria a ajuda a 10 pessoas, onde cada pessoa deveria arrecar 100 reais. Partindo desta perspectiva, o problema continua sendo o mesmo, por´em com 29

Notas de aulas de AED

30

valores de arrecada¸c˜ ao diferentes. Ainda, a tarefa de arrecar 100 reais ´e teoricamente mais simples que a tarefa de arrecar 1000 reais. A essˆencia da abordagem recursiva conduz a aplicar a mesma decomposi¸c˜ao em cada est´agio da solu¸c˜ao. Ent˜ ao, seguindo a mesma abordagem, cada novo volunt´ario que deve arrecadar 100 reais deve encontrar 10 outras pessoas que devam arrecar 10 reais cada. Cada um destes, deve ainda, encontrar 10 outras pessoas que concordem em doar 1 real cada. Observe que nesta u ´ltima etapa, a estr´ ategia foi alterada, pois agora foi pedido 1 real de cada doador, e n˜ao feita uma nova sub-divis˜ ao do problema. Recursivamente falando, o 1 real representa um caso simples, que pode ser resolvido diretamente sem uma nova decomposi¸c˜ao, este caso ´e chamado de condi¸c˜ao de parada. Solu¸c˜ oes deste tipo s˜ ao frequentemente referenciadas como estr´atigia dividir para conquistar, uma vez que eles dependam do particionamente do problema em componentes mais gerenci´aveis. Para representar este algoritmo em uma forma computacional mais sugestiva, ´e importante notar que existem diferentes instˆ ancias de um problema similar. No caso espec´ıfica mostrado anteriormente, n´ os temos tarefas independentes de arrecadar 1000 reais, 100 reais, 10 reais e finalmente, 1 real. Estas tarefas correspondem a diferentes n´ıveis da hierarquia de arrecada¸c˜ao. Para explorar esta similaridade, n´ os devemos generalizar o problema `a tarefa de arrecadar, n˜ao mais uma soma espec´ıfica, mas sim uma soma indeterminada de dinheiro, representada por uma vari´avel n. A tarefa de arrecar n reais pode ser dividida em dois casos. Primeiro, se n ´e 1 real, ent˜ao n´os simplesmente contribuimos com o nosso pr´oprio dinheiro. Alternativamente, n´os procueramos 10 volut´arios para n´ os ajudar a arrecar os n reais. Entretanto, cada volunt´ario dever´a arrecar somente um d´ecimo de n reais. Essa estrutura ´e representada a seguir. const int N_VOLUNTARIOS = 10; int arrecada(int n){ int soma = 0; if ( n == 1){ cout << "Contribua voce mesmo com 1 real \n"; return 1; }else{ for (int i = 0; i < N_VOLUNTARIOS; i++){ soma = soma + arrecada(n/N_VOLUNTARIOS); } return soma; } } Esta estrutura ´e tipicamente de um algoritmo recursivo. Observe que mesmo havendo um loop expl´ıtico este algoritmo ´e recursivo pois h´a uma chamada a pr´opria fun¸c˜ao. Vamos entender cada uma das etapas deste algoritmo. A primeira etapa, representada pelo comando if , consiste em testar se o problema corrente ´e t˜ ao simples que possa ser resolvido sem uma nova decomposi¸c˜ao. Se for verdade, j´ a arrecada 1 real diretamente, se n˜ao, o problema dever´a ser dividido em subproblemas, cada um dos quais ´e resolvido pela aplica¸c˜ao da mesma estrat´egia.

7.3

Caracter´ısticas de algoritmos recursivos

Na maioria da vezes, a decis˜ ao de usar recurs˜ao ´e sugerida pela natureza do problema. Para ser um candidato apropriado para a solu¸c˜ ao recursiva, um problema deve ter trˆes propriedades distintas: 1. Deve ser poss´ıvel decompor o problema original em instˆancias mais simples do mesmo problema. 2. Uma vez que cada um desses sub-problemas tenha sido resolvido, deve ser poss´ıvel combinar estas solu¸c˜ oes para produzir uma solu¸c˜ao para o problema original. Todos os direitos reservados aos autores

Notas de aulas de AED

31

3. Como problemas grandes s˜ ao quebrados, sucessivamente, em problemas menos complexos, estes sub-problemas devem, eventualmente, tornar-se t˜ao simples que eles possam ser resolvidos sem uma outra subdivis˜ ao. Normalmente, procedimentos recursivos tendem a fornecer um modelo conceitual bem natural quando tendem a implementar problemas matem´aticos. Isto ´e verdade pois muitas defini¸c˜oes matem´aticas, tais como a fatorial e a Fibonacci, s˜ao particularmente elegantes quando expressas na forma recursiva. As fun¸c˜ oes recursivas tamb´em podem ser implementadas de forma n˜ao recursiva, mas esta implementa¸c˜ ao ´e, as vezes, mais complicada pois depende de um profundo entendimento do problema a ser resolvido. Existem t´ecnicas, que n˜ao ser˜ao tratadas neste texto, para a convers˜ao de programa recursivo para um programa n˜ao recursivo. Os programas recursivos, embora possuam uma mesma complexidade computacional que suas respectivas solu¸c˜oes n˜ao recursivas, s˜ao mais lentos devido ` as diversas chamadas de fun¸c˜oes, que produz um overhead de processamento.

7.4

Exemplos

Nesta se¸c˜ ao, ser˜ ao ilustrados alguns problemas bem como suas solu¸c˜oes recursivas. O primeiro problema a ser considerado ser a fun¸c˜ ao fatorial, definida pela f´ormula: ( n × (n − 1)! para n ≥ 1 n! = 1 caso contr´ario

(7.1)

Esta defini¸c˜ ao de fatorial corresponde diretamente a seguinte fun¸c˜ao recursiva. int fatorial(int n){ if (n==0) return 1; else return n*fatorial(n-1); } Por um lado, este programa ilustra as caracter´ısticas b´asicas de uma programa recursivo: ele chama a sim mesmo e ele possui uma condi¸c˜ao de parada. Por outro lado, n˜ao existe mascara¸c˜ao do fato que este programa nada mais ´e que um loop for. Tamb´em, vale ressaltar que o fatorial n˜ao trabalha com n´ umeros negativos, logo ´e fundamental n˜ao deixar que exista uma chamada a esta fun¸c˜ ao quando o valor de n seja negativo. Um solu¸c˜ao n˜ao recursiva para este problema ser´a int fatorial_nrec(int n){ int fat = 1; for (int i = 1; i <= n; i++) fat = fat * i; return fat; } Uma segunda bem conhecida rela¸c˜ ao de recorrˆencia ´e aquela que define os n´ umeros de Fibonacci de ordem 2.   F ib(n − 1) + F ib(n − 2) F ib(n) = 1   1

para n ≥ 2 para n = 1 para n = 0

(7.2)

Da mesma forma que o fatorial, essa rela¸c˜ao de recorrˆencia corresponde de forma direta o programa recursivo a seguir

Todos os direitos reservados aos autores

Notas de aulas de AED

32

int fibonacci(int n){ if (n==0) return 1; else if (n==1) return 1; else return fibonacci(n-1)+fibonacci(n-2); } Estes algoritmos recursivos n˜ ao representam de forma convincente o poder da recurs˜ao, muito pelo contr´ ario, ele nos convence o qu˜ ao ineficiente pode ser. O problema na fun¸c˜ao de fibonacci ´e que as chamadas recursivas indicam que as chamadas fibonacci(n-1) e fibonacci(n-2) s˜ao computadas independentemente, quando na realidade poder´ıamos usar uma para computar a outra. Assim, podemos usar um procedimento n˜ao recursivo para computar os n´ umeros de fibonacci. Entretanto, este procedimento precisar´a armazenar os resultados intermedi´arios. Uma poss´ıvel solu¸c˜ao ´e ilustrada a seguir. const int MAX = 100; int fibonacci_nrec(int n){ int fat = 1; int vetor[MAX]; vetor[0] = 1; vetor[1] = 1; for (int i = 2; i <= n; i++) vetor[i] = vetor[i-1] + vetor[i-2]; return vetor[i-2]; }

7.5

Anatomia de chamadas recursivas

Como j´a estudado anteriormente, quando ocorre uma chamada de fun¸c˜ao, v´arias informa¸c˜oes (irrelevantes para ATP2) s˜ ao enviadas para a pilha de execu¸c˜ao. Dentre as informa¸c˜oes enviadas para a pilha, o endere¸co de retorno assim como os parˆametros da fun¸c˜ao s˜ao armazenados na pilha. Nesta se¸c˜ ao, veremos de maneira informal o que acontece quando se tem uma chamada recursiva. Considere o problema de elevar um n´ umero x ao expoente n. A seguir ´e ilustrada uma defini¸c˜ao recursiva deste problema em que o valor n ´e inteiro n˜ao negativo. ( x(n−1) × x para n > 0 x = 1 para n = 0 n

Usando esta defini¸c˜ ao o valor de x4 pode ser computado do seguinte modo. 0 x4 = x × x3 = x × (x × x2 )= x × (x × (x × x1 ))= x × (x × (x × (x × x ))) = x × (x × (x × (x × 1)))= x × (x × (x × (x)))= x × (x × (x × x))= x × (x × x × x)= x × x × x × x De forma direta, esta defini¸c˜ ao recursiva pode ser implementada pela fun¸c˜ao a seguir. double potencia(double x, int n){ if (n==0) return 1.0; else return x*potencia(x,n-1); } A aplica¸c˜ ao indutiva desta fun¸c˜ ao para a chamada potencia(x,4) ´e ilustrada a seguir. chamada 1 chamada 2 chamada 3

x4 = x × x3 x × x2 x × x1

Todos os direitos reservados aos autores

=x × x × x × x =x × x × x =x × x

(7.3)

Notas de aulas de AED

33

x × x0 1

chamada 4 chamada 5

=x × 1=x

ou alternativamente, chamada chamada chamada chamada chamada chamada chamada chamada chamada chamada

1 2 3 4 5 5 4 3 2 1

potencia(x,4) potencia(x,3) potencia(x,2) potencia(x,1) potencia(x,0) 1 x x×x x×x×x x×x×x×x

Observe que os resultados intermedi´arios, armazenados em algum lugar, s˜ao computados das chamadas mais internas para as mais externas.

Todos os direitos reservados aos autores

T´ opico 8

Estudo de Caso: TAD Pilha 8.1

Defini¸ c˜ ao

Uma Pilha representa um conjunto de elementos em que o u ´nico elemento vis´ıvel ´e o elemento que foi inserido h´ a menos tempo no conjunto. As opera¸c˜ oes sobre Pilhas s˜ ao as seguintes: • Constru¸c˜ ao: cria uma pilha vazia; • empilha: insere o elemento no topo da pilha; • desempilha: retira o elemento do topo da pilha; • topo: retorna o elemento do topo da pilha, sem desempilh´a-lo; • vazia: verifica se a pilha est´ a vazia. Au ´nica forma de inserir elementos em uma pilha ´e via opera¸c˜ao empilha e a u ´nica forma de retirar elementos ´e via opera¸c˜ ao desempilha. Para termos acesso ao pen´ ultimo elemento empilhado, devemos desempilhar o u ´ltimo; o mesmo ocorre com os demais elementos que tiverem sido inseridos anteriormente. Dizemos que uma Pilha ´e uma estrutura FIFO (First In, First Out = o primeiro que entra ´e o primeiro que sai).

8.2

Aplica¸ c˜ oes

Pilhas s˜ao utilizadas em programas de computadores em chamadas de fun¸c˜oes e procedimentos e chamadas recursivas. Utilizam-se pilhas tamb´em no processamento de estruturas aninhadas de profundidade imprevis´ıvel. Nesta situa¸c˜ ao ´e necess´ ario garantir que as estruturas mais internas sejam processadas antes da estrutura que as contenha. A pilha ´e uma estrutura adequada nesta situa¸c˜ao, pois a ordem de remo¸c˜ ao garante que as estruturas mais internas ser˜ao processadas antes das estruturas mais externas. Podemos utilizar pilhas tamb´em em editores de texto e na interpreta¸c˜ao de express˜oes aritm´eticas.

8.3

Interface da Classe Pilha

A implementa¸c˜ ao do TAD Pilha pode ser feita por meio da classe com a seguinte assinatura:

34

Notas de aulas de AED

35

typedef int Elemento; // Facilita alterar o tipo do elemento da pilha class Pilha { public: Pilha(); ~Pilha(); bool vazia(); void empilha(Elemento novoElemento); bool topo(Elemento & valorTopo); bool desempilha(); };

Os m´etodos topo e desempilha retornam verdadeiro ou falso, indicando se a opera¸c˜ao foi realizada com sucesso; se retornar falso, ent˜ao a pilha estava vazia, o que impediu que a opera¸c˜ao fosse feita; se a opera¸c˜ ao retornar verdadeiro, ent˜ao a opera¸c˜ao foi conclu´ıda com sucesso. O valor do topo da pilha ´e retornado pelo parˆametro de referˆencia valorTopo do m´etodo topo.

8.3.1

Implementa¸c˜ ao do TAD Pilha Utilizando Vetores

Para implementar o TAD Pilha, incluiremos na classe Pilha os atributos vetor, que armazena os valores da pilha, indTopo, que cont´em o ´ındice da primeira posi¸c˜ao vazia do vetor, e tamanho que cont´em o n´ umero de posi¸c˜ oes alocadas para o vetor. O vetor utilizar´a crescimento autom´atico, via m´etodo realoca, que tamb´em ´e privativo da classe. typedef int Elemento; const int TAMANHO_INICIAL = 10; class Pilha { private: Elemento * vetor; int indTopo, tamanho; void realoca(); public: Pilha(); ~Pilha(); bool vazia(); bool cheia(); void empilha(Elemento novoElemento); bool topo(Elemento & valorTopo); bool desempilha(); };

O construtor aloca o vetor dinamicamente com tamanho TAMANHO_INICIAL e inicializa o atributo indTopo com o valor zero: Pilha::Pilha() { tamanho = TAMANHO_INICIAL; vetor = new Elemento[tamanho]; indTopo = 0; }

O destrutor ir´ a liberar a ´ area de mem´oria reservada para o vetor: Pilha::~Pilha() { delete [] vetor; }

O m´etodo vazia retorna verdadeiro se o ´ındice do topo (indTopo) for igual a zero, ou falso, caso contr´ ario: bool Pilha::vazia() { return (indTopo == 0); }

O m´etodo empilha verifica se o ´ındice do topo ´e igual ao tamanho do vetor; se for igual, ent˜ao a pilha est´ a cheia e ser´ a necess´ ario realocar o vetor; isto ´e feito chamando-se o m´etodo realoca. Ap´os o teste, e poss´ıvel realoca¸c˜ ao, atribui-se o valor a ser empilhado `a primeira posi¸c˜ao do vetor e aumenta em um o valor de indTopo: Todos os direitos reservados aos autores

Notas de aulas de AED

36

void Pilha::empilha(Elemento novoElemento) { if (indTopo == tamanho) realoca(); vetor[indTopo++] = novoElemento; }

O m´etodo realoca ´e idˆentico ao m´etodo de mesmo nome definido na classe Vetor. O m´etodo topo verifica se a pilha est´a vazia; se estiver, retorna falso; se n˜ao estiver, atribui o valor do topo ao parˆ ametro por referˆencia valorTopo e retorna verdadeiro: bool Pilha::topo(Elemento & valorTopo) { if (vazia() == true) return false; valorTopo = vetor[indTopo - 1]; return true; }

O m´etodo desempilha verifica se a pilha est´a vazia; se estiver, retorna falso; se n˜ao estiver, diminui em um o valor de indTopo, indicando que h´a uma posi¸c˜ao livre a mais, e retorna verdadeiro: bool Pilha::desempilha() { if (vazia() == true) return false; indTopo--; return true; }

8.4

Exemplos

As fun¸c˜oes abaixo devem funcionar com qualquer implementa¸c˜ao de pilha, pois respeitam a assinatura da classe: void inicializa(Pilha & P) { for (int x = 1; x <= 10; x++) P.empilha(x); } void transfere(Pilha & P1, Pilha & P2) { Elemento valorTopo; while (P1.vazia() == false) { P1.topo(valorTopo); // obt´ em o valor do topo P1.desempilha(); P2.empilha(valorTopo); } } void main(void) { Pilha P1, P2; Elemento valorTopo; inicializa(P1); P1.desempilha(); P1.desempilha(); P1.desempilha(); P1.empilha(11); transfere(P1,P2); P1.empilha(12); P1.empilha(13); P1.empilha(14); cout << "Pilha P1: "; while (P1.topo(valorTopo) == true) { cout << valorTopo << " "; P1.desempilha(); } cout << "Pilha P2: "; while (P2.topo(valorTopo) == true) {

Todos os direitos reservados aos autores

Notas de aulas de AED

37

cout << valorTopo << " "; P2.desempilha(); } }

A fun¸c˜ ao inicializa empilha os valores de 1 a 10 na pilha. A fun¸c˜ao copia retira todos os elementos de P1 e empilha em P2. Como a pilha inverte os elementos no momento da retirada, a pilha P2 conter´ a os mesmos elementos que havia em P1, mas na ordem inversa. Observe que a fun¸c˜ao copia esvazia a pilha P1, pois n˜ao ´e poss´ıvel acessar um elemento sem retirar todos que tiverem sido inseridos depois. Este programa dever´ a imprimir o seguinte resultado na tela: Pilha P1: 14 13 12 Pilha P2: 11 7 6 5 4 3 2 1

Todos os direitos reservados aos autores

T´ opico 9

Estudo de Caso: TAD Fila 9.1

Defini¸ c˜ ao

Uma Fila representa um conjunto de elementos em que o u ´nico elemento vis´ıvel ´e o elemento que foi inserido h´ a mais tempo no conjunto. As opera¸c˜ oes sobre Filas s˜ ao as seguintes: • Constru¸c˜ ao: cria uma fila vazia; • insere: insere o elemento no final da fila; • retira: retira o elemento da frente da fila; • frente: retorna o elemento da frente da fila; • vazia: verifica se a fila est´ a vazia. A u ´nica forma de inserir elementos em uma fila ´e via opera¸c˜ao insere e a u ´nica forma de retirar elementos ´e via opera¸c˜ ao retira. Para termos acesso ao segundo elemento inserido, devemos retirar o primeiro; o mesmo ocorre com os demais elementos que tiverem sido inseridos posteriormente. Dizemos que uma Fila ´e uma estrutura LIFO (Last In, First Out = o u ´ltimo que entra ´e o primeiro que sai).

9.2

Aplica¸ c˜ oes

Filas em geral s˜ ao utilizadas para controlar o processamento de elementos na ordem em que eles aparecerem, como por exemplo, em filas de impress˜ao e em filas de processos do Sistema Operacional. Utilizamos filas tamb´em para implementa¸c˜ao de buscas em largura em Grafos e em Inteligˆencia Artificial.

9.3

Interface da Classe Fila

A implementa¸c˜ ao do TAD Fila ser´ a por meio da classe com a seguinte assinatura: typedef int Elemento; // Facilita alterar o tipo do elemento da pilha class Fila { public: Fila(); ~Fila(); bool vazia(); void insere(Elemento novoElemento); bool frente(Elemento & valorFrente); bool retira(); };

38

Notas de aulas de AED

39

Assim como na pilha, os m´etodos frente e retira retornam verdadeiro ou falso, indicando se a opera¸c˜ ao foi realizada com sucesso; se retornar falso, ent˜ao a fila estava vazia, o que impediu que a opera¸c˜ ao fosse feita; se a opera¸c˜ao retornar verdadeiro, ent˜ao a opera¸c˜ao foi conclu´ıda com sucesso. O valor da frente da fila ´e retornado pelo parˆametro de referˆencia valorFrente do m´etodo frente.

9.3.1

Implementa¸c˜ ao do TAD Fila Utilizando Vetores

Para implementarmos o TAD Fila, incluiremos na classe Fila os atributos vetor, que armazena os valores da fila, indFrente, que cont´em o ´ındice onde se encontra o elemento na frente da fila, numElementos, que armazena o n´ umero de elementos inseridos na fila, e tamanho que cont´em o n´ umero de posi¸c˜ oes alocadas para o vetor. O vetor utilizar´a crescimento autom´atico, via m´etodo realoca, que tamb´em ´e privativo da classe. // Facilita se for necess´ ario alterar o tipo do elemento da pilha typedef int Elemento; const int TAMANHO_INICIAL = 10; class Pilha { private: Elemento * vetor; int indFrente, numElementos, tamanho; void realoca(); public: Fila(); ~Fila(); bool vazia(); void insere(Elemento novoElemento); bool frente(Elemento & valorFrente); bool retira(); };

O construtor aloca o vetor dinamicamente com tamanho TAMANHO_INICIAL e inicializa os atributos indFrente e numElementos com o valor zero: Fila::Fila() { tamanho = TAMANHO_INICIAL; vetor = new Elemento[tamanho]; indFrente = 0; numElementos = 0; }

O destrutor ir´ a liberar a ´ area de mem´oria reservada para o vetor: Fila::~Fila() { delete [] vetor; }

O m´etodo vazia retorna verdadeiro se o n´ umero de elementos (armazenado em numElementos) for igual a zero, ou falso, caso contr´ ario: bool Fila::vazia() { return (numElementos == 0); }

O m´etodo frente verifica se a Fila est´a vazia; se estiver, retorna falso; se n˜ao estiver, atribui ao parˆametro por referˆencia valorFrente o valor da frente da fila e retorna verdadeiro: bool Fila::frente(Elemento & valorFrente) { if (vazia() == true) return false; valorFrente = vetor[indFrente]; return true; }

Todos os direitos reservados aos autores

Notas de aulas de AED

40

Os m´etodos insere e retira trabalham em conjunto implementando o que chamamos de Fila Circular. Considere que a fila esteja representada por um vetor de 10 posi¸c˜oes e que tenhamos inserido os n´ umeros de 1 a 10. Ap´ os estas opera¸c˜oes, considere que houve 3 retiradas. Para termos menos trabalho na movimenta¸c˜ ao dos elementos, podemos supor que o estado do objeto ser´a o seguinte ao longo da execu¸c˜ ao (X marca posi¸c˜ ao livre no vetor): Inicialmente: F = fila vazia vetor: X X X X indFrente: 0 numElementos: 0 tamanho: 10 Ap´ os as inser¸ c~ oes dos n´ umeros de 1 F = 1, 2, 3, 4, 5, 6, 7, 8, 9, vetor: 1 2 3 4 indFrente: 0 numElementos: 10 tamanho: 10 Ap´ os as tr^ es retiradas: F = 4, 5, 6, 7, 8, 9, 10 vetor: X X X 4 indFrente: 3 numElementos: 7 tamanho: 10

X

X

X

X

X

a 10: 10 5 6 7

8

9 10

5

8

9 10

6

7

X

Observe que n˜ ao ´e necess´ ario movimentar os n´ umeros de 4 a 10 para o in´ıcio do vetor, basta indicar que o ´ındice da frente ´e igual a 3, o que significa que h´a sete elementos iniciando no ´ındice 3 e terminando no ´ındice 9. Entretanto, suponha agora que precisemos inserir o valor 11 na fila. H´a diversas formas de inserirmos este valor na fila. A primeira ´e copiar todos os elementos algumas casas para tr´as e continuarmos inserindo no final da seq¨ uˆencia; isto acarretaria em um custo muito alto para uma simples inser¸c˜ao. Outra alternativa consiste em realocar o vetor e continuar fazendo a inser¸c˜ao no final, como j´a estava sendo feito. Tamb´em isto acarreta em executarmos opera¸c˜oes sup´erfluas, visto que h´a posi¸c˜oes livres (de 0 a 2). A alternativa de menor custo ´e aproveitar o in´ıcio do vetor (que est´a vazio) e imaginar que ele ´e uma continua¸c˜ ao do final. Em outras palavras, imaginaremos que o vetor inicia na posi¸c˜ao 3, onde est´a o primeiro elemento, cont´em elementos at´e a posi¸c˜ao 9, e continua nas posi¸c˜oes de 0 a 2. Isto ´e, estamos considerando que os elementos do vetor est˜ao em ordem, mas esta ordem n˜ao ´e necessariamente o primeiro elemento est´a na posi¸c˜ao zero e os demais nas posi¸c˜oes seguintes. Agora, imaginamos que a seq¨ uˆencia inicia em um ponto qualquer do vetor e pode ultrapassar o limite f´ısico do vetor; entretanto, quando a seq¨ uˆencia ultrapassar este limite, os elementos estar˜ao armazenados no in´ıcio do vetor. Utilizando esta abordagem, considere a inser¸c˜ao dos valores 11, 12 e 13 na fila. Estas opera¸c˜oes gerar˜ao a seguinte configura¸c˜ ao do objeto: Ap´ os a inser¸ c~ ao do 11: F = 4, 5, 6, 7, 8, 9, 10, 11 vetor: 11 X X 4 5 indFrente: 3 numElementos: 8 tamanho: 10 Ap´ os a inser¸ c~ ao do 12: F = 4, 5, 6, 7, 8, 9, 10, 11, 12 vetor: 11 12 X 4 5 indFrente: 3 numElementos: 9

Todos os direitos reservados aos autores

6

7

8

9 10

6

7

8

9 10

Notas de aulas de AED

41

tamanho: 10 Ap´ os a inser¸ c~ ao do 13: F = 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 vetor: 11 12 13 4 5 6 7 indFrente: 3 numElementos: 10 tamanho: 10

8

9 10

Agora temos um vetor cheio. A u ´nica diferen¸ca ´e a modifica¸c˜ao nos conceitos de in´ıcio e t´ermino da seq¨ uˆencia no vetor. Neste u ´ltimo exemplo, a seq¨ uˆencia inicia no ´ındice 3, vai at´e o ´ındice 9 (que ´e o final do vetor), e depois continua nas posi¸c˜oes 0, 1 e 2. Observe tamb´em que se quisermos inserir mais um elemento, a realoca¸c˜ao ser´a inevit´avel, pois n˜ao h´a mais posi¸c˜ oes livres no vetor. Desta forma, o m´etodo retira dever´a sempre aumentar o valor do atributo indFrente em um elemento. Para simular a circularidade, se o valor de indFrente ficar igual ao tamanho do vetor, que est´a no atributo tamanho, ent˜ ao passaremos este valor para 0. N˜ao podemos esquecer que, no in´ıcio do m´etodo, devemos testar se a fila est´a vazia, pois isto definir´a o valor de retorno. O m´etodo abaixo mostra a implementa¸c˜ ao da t´ecnica explicada: bool Fila::retira() { if (vazia() == true) return false; indFrente++; if (indFrente == tamanho) indFrente = 0; numElementos--; return true; }

O m´etodo insere utilizar´ a o mesmo conceito. Inicialmente, verificamos se o n´ umero de elementos da fila ´e igual ao seu tamanho. Se for igual, ser´a necess´ario realocar o vetor, chamando-se o m´etodo realoca. Ap´ os a verifica¸c˜ ao, e poss´ıvel realoca¸c˜ao, devemos verificar onde a seq¨ uˆencia de elementos termina, para inserirmos o elemento na posi¸c˜ao seguinte. Por simplicidade, iniciemos com uma fila com a seguinte configura¸c˜ao: Uma fila qualquer: F = 4, 5, 6, 7, 8 vetor: X indFrente: 3 numElementos: 5 tamanho: 10

X

X

4

5

6

7

8

X

X

Se quisermos inserir outro elemento nesta fila, deveremos fazer a inser¸c˜ao na pen´ ultima posi¸c˜ao do vetor, isto ´e, na posi¸c˜ ao 8. Como era de se esperar, esta posi¸c˜ao ´e exatamente igual a indFrente + numElementos. Considere agora que a fila possui a configura¸c˜ao abaixo: Outra fila qualquer: F = 4, 5, 6, 7, 8 vetor: 7 indFrente: 7 numElementos: 5 tamanho: 10

8

X

X

X

X

X

4

5

6

Observe que esta fila inicia na posi¸c˜ao 7, vai at´e o final do vetor, volta `a posi¸c˜ao zero e ocupa at´e a posi¸c˜ ao 1. Se aplicarmos a f´ ormula anterior, obteremos a posi¸c˜ao 12, que ´e a terceira posi¸c˜ao ap´os a u ´ltima posi¸c˜ ao do vetor, posi¸c˜ ao 9. Se subtrairmos ent˜ao o tamanho do vetor, obteremos a posi¸c˜ao 2, que ´e exatamente a primeira posi¸c˜ao livre. Unindo todos estes elementos, chegamos `a seguinte implementa¸c˜ao do m´etodo insere: Todos os direitos reservados aos autores

Notas de aulas de AED

42

void Fila::insere(Elemento novoElemento) { if (indFrente == tamanho) realoca(); int pos_insercao = indFrente + numElementos; if (pos_insercao >= tamanho) pos_insercao = pos_insercao - tamanho; v[pos_insercao] = novoElemento; numElementos++; }

Estes m´etodos poderiam ser escritos em menos linhas, utilizando a opera¸c˜ao de resto da divis˜ao: void Fila::insere(Elemento novoElemento) { if (indFrente == tamanho) realoca(); int pos_insercao = (indFrente + numElementos) % tamanho; v[pos_insercao] = novoElemento; numElementos++; } bool Fila::retira() { if (vazia() == true) return false; indFrente = (indFrente + 1) % tamanho; numElementos--; return true; }

9.4

Exemplos

As fun¸c˜oes abaixo devem funcionar com qualquer implementa¸c˜ao de fila, pois respeitam a assinatura da classe: void inicializa(Fila & F) { for (int x = 1; x <= 10; x++) F.insere(x); } void transfere(Fila & F1, Fila & F2) { Elemento valorFrente; while (F1.vazia() == false) { F1.frente(valorFrente); // obt´ em o valor da frente F1.retira(); F2.insere(valorFrente); } } void main(void) { Fila F1, F2; Elemento valorFrente; inicializa(F1); F1.retira(); F1.retira(); F1.retira(); F1.insere(11); transfere(F1,F2); F1.insere(12); F1.insere(13); F1.insere(14); cout << "Fila F1: "; while (F1.frente(valorFrente) == true) { cout << valorFrente << " "; F1.retira(); } cout << "Fila F2: "; while (F2.frente(valorFrente) == true) {

Todos os direitos reservados aos autores

Notas de aulas de AED

cout << valorFrente << " "; F2.retira(); } }

Este programa dever´ a imprimir o seguinte resultado na tela: Fila F1: 12 13 14 Fila F2: 4 5 6 7 8 9 10 11

Todos os direitos reservados aos autores

43

Related Documents

Apostila De C
December 2019 11
Aed
May 2020 11
Noche De Verano Aed
October 2019 16