Você está na página 1de 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údo

1 Qualidade de Código 1

2 Classes 6

3 Tipos Abstratos de Dados 11


3.1 Definição . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.2 Implementação de um TAD . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.3 Estudo de caso: TAD Vetor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
3.3.1 Exemplo de Cliente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
3.3.2 Uma primeira implementação . . . . . . . . . . . . . . . . . . . . . . . . . . 13

4 Apontadores 16

5 Alocação Dinâmica 19

6 Análise de complexidade 24
6.1 Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
6.2 Complexidade computacional e análise assintótica . . . . . . . . . . . . . . . . . . . 24
6.3 Notação O . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
6.4 Exemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27

7 Recursividade 29
7.1 Definição . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
7.2 Ilustração de uma abordagem recursiva . . . . . . . . . . . . . . . . . . . . . . . . 29
7.3 Caracterı́sticas de algoritmos recursivos . . . . . . . . . . . . . . . . . . . . . . . . 30
7.4 Exemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
7.5 Anatomia de chamadas recursivas . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32

8 Estudo de Caso: TAD Pilha 34


8.1 Definição . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
8.2 Aplicações . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
8.3 Interface da Classe Pilha . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
8.3.1 Implementação do TAD Pilha Utilizando Vetores . . . . . . . . . . . . . . . 35
8.4 Exemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36

9 Estudo de Caso: TAD Fila 38


9.1 Definição . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
9.2 Aplicações . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
9.3 Interface da Classe Fila . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
9.3.1 Implementação do TAD Fila Utilizando Vetores . . . . . . . . . . . . . . . . 39
9.4 Exemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42

1
Tópico 1

Qualidade de Código

Para determinarmos se um programa é bom, diversos fatores devem ser analisados. Entre eles,
alguns dos mais importantes são:
• Do ponto de vista do usuário: se o programa está correto, se é eficiente e se é de fácil
utilização;
• Do ponto de vista da equipe de programadores: se é fácil fazer alterações, seja para correção
de erros ou para atualizações.
A correção do programa depende particularmente do método de desenvolvimento utilizado
e dos testes que foram feitos. Em geral, este fator está intimamente relacionado às técnicas
de programação utilizadas. Os testes devem estar centrados principalmente em determinar o
comportamento do programa em casos extremos e em detectar pontos crı́ticos do código.
Um programa é eficiente quando faz bom uso dos recursos computacionais existentes. Quando
economizamos o tempo de CPU para realizar determinado processamento, dizemos que temos
eficiência de tempo; quando economizamos memória disponı́vel, dizemos que tempo eficiência de
espaço. Como veremos, nem sempre estes dois tipos de eficiência são compativeis, isto é, em
geral devemos escolher qual dos dois será escolhido. Para sabermos se um programa é eficiente,
desenvolveremos métricas 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ão que o programa seja alterado com mais facilidade quando for necessário.
Os elementos que chamam a atenção quando falamos em qualidade do código são a indentação,
os comentários, a documentação, a escolha dos identificadores e modularização.
A indentação permite visualizar as relações de dependência entre as diversas construções do
programa.
Os comentários ajudam a entender algoritmos complexos e a resolver dúvidas que possam
surgir ao ler um fragmento de código.
A documentação é normalmente confundida com comentários. A documentação é um relatório
que explica a lógica 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áveis, funções, classes e
módulos do programa. Nomes de variáveis como x ou a dizem muito pouco (ou quase nada) sobre
a finalidade da variável 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ômio, o uso de uma variável idenficada por x está correto, pois este nome está
diretamente relacionado ao problema em questão. Suponha agora, que estamos desenvolvimento
um programa para identificar se uma data esta correta, o uso de uma variável identificada por x,
neste caso, não é recomendada, pois esta não se relaciona com o problema.

1
Notas de aulas de AED 2

A modularização é a divisão do programa em funções, classes e módulos; ao dividir o programa


em partes, facilitamos sua leitura, visto que é possı́vel desenvolver métodos para a leitura do
programa, via construções de alto nı́vel; por exemplo, ler a função main dá a idéia do que o
programa faz; para conhecermos os detalhes, devemos entender as funções individualmente.
Ainda no item modularização, uma grande dificuldade é descobrir como dividir um programa
em funções, isto é, o que devem ser as funções de um programa, como elas devem ser relacionadas,
etc. Em geral, podemos procurar partes do programa que estão duplicadas e transformá-las em
chamadas a uma nova função, devidamente parametrizada. Também é interessante que partes
distintas do programa fiquem em funções distintas. Tomemos por exemplo um programa que leia
dois vetores de 10 números inteiros e exiba um número que represente a distância de hamming
entre os dois vetores. Mesmo sem saber o que significa a distância de hamming entre dois vetores,
podemos escrever a seguinte função 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ção main já permite ter uma idéia do que o programa faz. A forma
como as etapas do programa são realizadas dependerão de cada uma das funções. Quanto melhor
as funções estiverem escritas, mais fácil será a compreensão. Observe também que a criação da
função leVetor permitiu que eliminássemos código redundante no programa.
É indiscutı́vel que dividir o programa em partes facilita a compreensão do programa e a loca-
lização de erros. Entretanto, é importante que esta divisão siga critérios bem definidos.
Uma técnica interessante é a chamada Programação Defensiva, inspirada livremente na Direção
Defensiva. Ambas seguem a mesma idéia de que devemos nos prevenir dos erros dos outros, de
modo a evitar problemas, sejam na programação ou no trânsito. Nossa definição será bem seme-
lhante à famosa máxima “você deve dirigir para você e para os outros”. No caso da programação,
ao escrever uma função, devemos imaginar que qualquer dado externo passado para a função deve
ser testado; se o seu valor for incompatı́vel com o esperado pela função, então alguma coisa deve ser
feita. Em outras palavras, uma função não deve executar comandos inválidos devido a erros que
não sejam exclusivamente de sua responsabilidade, devendo estar assim prevenida para evitá-los.
Por exemplo, considere o que aconteceria na função abaixo, se o valor zero fosse passado como
segundo parâmetro:
int divide(int x, int y) {
return (x/y);
}

Uma boa regra de programação a ser seguida é: teste todos os valores que a função for
utilizar, sejam parâmetros, entradas do usuário, ou variáveis globais.
Outro ponto importante diz respeito ao grau de dependência entre as funções. Isto recebe o
nome de Acoplamento. Quanto maior for o grau de dependência entre duas funções, maior será o
acoplamento, e mais difı́cil será compreender e modificar uma das funções. Isto porque alterações
simples em uma função podem gerar alterações drásticas na outra.
Vários fatores fazem duas funções estar relacionadas. Entre eles, destacamos:
• Retorno das funções: se uma função F1 chama uma função F2 e utiliza o resultado retornado
por esta, então estaremos estabelecendo uma relação de dependência.
Muitas vezes, esta relação pode ser confusa, como no exemplo a seguir: suponha que a
função posicaoDe seja utilizada para verificar se um elemento x está presente em um vetor
v contendo n elementos; se x estiver presente no vetor, então será retornada a posição da
primeira ocorrência de x, senão, retornaremos o valor -1. Uma implementação possı́vel desta
função é 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ção é utilizar o seu valor de retorno para duas finalidades distin-
tas: (a) informar se o elemento foi encontrado; (b) caso o elemento tenha sido encontrado,
informar a posição de sua primeira ocorrência.

• Parâmetros: se uma função F1 chama uma função F2, então é importante que ao escrever
a função F1, estejamos cientes dos parâmetros que F2 espera. Considere por exemplo que a
função extenso, receba uma data e escreva na tela esta data por extenso; esta função pode
ter vários cabeçalhos 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ão totalmente válidas, e irão depender do programador: no Brasil,
possivelmente utilizarı́amos a primeira forma, mas em outros locais, a segunda forma poderia
ser preferida. Observe então que a quantidade de parâmetros utilizados pode gerar confusões
na utilização da função. Na terceira forma, estamos supondo a existência de uma estrutura
Data que possua campos para dia, mês e ano. Observe que esta forma é mais atraente, visto
que não haverá dúvidas com relação à ordem em que os parâmetros devem ser passados.
Os parâmetros devem ser muito bem escolhidos, pois estes poderão gerar dificuldades ao
tentar entender as relações entre as funções.
• Variáveis Globais: Dada a chamada da função F2 de dentro da função F1, um importante
fator de dependência entre as funções é o uso de globais. Por exemplo, considere o fragmento
de código abaixo:

int x; // Variável global


F2(...) {
...
x = ...; // variável x alterada
...
}
F1(...) {
...
F2(...)
... x ... // uso do valor de x
}

Veja que o valor de x dentro de F1 depende da atribuição feita em F2. Esta dependência é uma
das mais difı́ceis de serem encontradas e são as que causam maiores efeitos ao tentar corrigir
ou fazer alterações de manutenção em um programa. Claro que variáveis globais não devem
ser simplesmente desconsideradas para sempre; entretanto, é importante que haja critérios
bastante rigorosos para que o seu uso não crie dependências maléficas 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 é um programa bastante simples que faz a leitura de um vetor seguida da impressão dos
seus elementos em ordem invertida à de leitura. Para facilitar alterações futuras neste programa,
é aconselhável o uso de funções, 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ção: o uso da variável global tornou as duas funções totalmente
limitadas, isto é, se precisássemos ler e imprimir invertido um outro vetor, digamos u, precisarı́amos
criar novas funções, duplicando um código já existente. [Observe que neste caso o problema não é
v ter sido declarada como global, mas as funções estarem totalmente dependentes desta variável.]
Este programa poderia ser então 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á foi resolvido: funções dependendo de variáveis globais. Agora, se houver a
necessidade de ler um outro vetor e imprimi-lo também invertido, precisaremos somente declará-lo
e chamar as funções com parâmetros apropriados, como mostra o código 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ções. Se tivermos que modificar este programa para trabalhar com vetores de 20 posições,
por exemplo, terı́amos que alterar 4 pontos no código (as duas declarações e os limites dos comandos
for dentro das funções). É interessante, então, definirmos uma constante para o tamanho do vetor.
Observe que a modificação supracitada no programa a seguir requer a alteração de somente um
ponto do programa (a declaração 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ópico 2

Classes

Considere um outro fator de qualidade que nos levará a novos conceitos na programação: se dois
ou mais elementos de um programa possuirem alguma relação conceitual, então estes elementos
deverão estar agrupados no código. Uma forma de seguir esta regra é sempre colocar funções de
uma mesma espécie seguidas ao longo do programa. Por exemplo, se um programa possuir dez
funções, sendo que três fazem operações sobre datas, então faz sentido deixar estas funções em
seqüência ao longo do programa.
Esta é uma técnica importante, que será futuramente estendida para a criação de bibliotecas
de funções. 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álida";


}

Um problema neste programa é o fato de termos vários parâmetros nas funções que são ne-
cessários na representação das datas. Por exemplo, se o programador que escreveu as funções
fosse de alguma nacionalidade em que a ordem da descrição da data é diferente da nossa, então
terı́amos problemas com relação a ordem correta dos parâmetros. Então, é interessante criarmos
uma estrutura que represente uma data e usá-la ao longo do programa. Desta forma, ficaremos
livres de saber a ordem dos parâmetros 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álida";
}

Até agora, a única vantagem que obtivemos foi não precisarmos nos preocupar com a ordem
dos parâmetros. Entretanto, o nı́vel de organização 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ção do conceito de estrutura que nos permitirá englobar todas
as operações relacionadas em uma mesma parte do programa: uma Classe.
Uma Classe é um tipo de dados semelhante a uma estrutura, constituı́das por Atributos e
Métodos. Atributos de classe são semelhantes a campos de estruturas; por exemplo, uma classe
Data poderia conter os atributos dia, mes e ano. Métodos de classe são funções que operam sobre
os atributos.

Todos os direitos reservados aos autores


Notas de aulas de AED 8

Se declararmos uma variável cujo tipo é uma classe, esta variável será composta, assim como
nas estruturas. Haverá uma cópia de cada um dos atributos para cada variável declarada daquela
classe. Por exemplo, se Data for uma classe definida no programa, então a declaração:
Data d1, d2, d3;

criará três variáveis cujo tipo é Data; cada uma destas variáveis possui os campos dia, mes e
ano.
Uma classe é geralmente dividida em duas partes: a parte privativa contém elementos que serão
de uso exclusivo da classe, isto é, não poderão ser acessados por entidades externas à classe; a parte
pública contém os elementos exportados pela classe, isto é, elementos que podem ser utilizados
por entidades externas à classe. Por simplicidade, convencionaremos que todos os atributos são
privativos e todos os métodos são públicos.
Uma classe pode possuir também métodos especiais denominados construtores. Um construtor
é um método especial que possui o mesmo nome da classe e não 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ção 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álida";
}

Neste exemplo, iniciemos com a parte privativa que inicia por private:. Observe que fizemos
uma declaração semelhante aos campos da estrutura Data do exemplo anterior. A partir de
public:, iniciamos a parte pública da classe, que contém os seus métodos.
O primeiro método é o construtor, que é responsável por definir os valores iniciais dos atributos
do objeto. Observe que os valores dos parâmetros são atribuı́dos aos atributos da classe no cons-
trutor. Analisando a primeira linha da função main, vemos a declaração Data d(10,3,2003);.

Todos os direitos reservados aos autores


Notas de aulas de AED 9

Exatamente neste ponto é feita a chamada do construtor. Ao declararmos o objeto d, definimos


quais serão os parâmetros passados para o construtor. Estes valores serão então atribuı́dos aos
atributos dia, mes e ano, de modo que o objeto d representará a data 10/3/2003. É importante
ressaltar que a ordem de atribuição foi definida pela ordem dos parâmetros, não havendo relação
alguma com a ordem em que os atributos foram definidos.
O segundo método utiliza somente o atributo ano. É um método que verifica se o ano arma-
zenado na data é um ano bissexto.
O método imprime é semelhante à função imprime anterior, com a diferença que os valores
de dia, mes e ano são os atributos da classe e não mais os parâmetros. Observe como o método
imprime é chamado na terceira linha da função main. A sintaxe é semelhante a uma chamada
de função, sendo que a utilização do operador “ponto”define que os valores dos atributos durante
a execução do método serão obtidos do objeto para o qual o método foi chamado. Neste caso,
a chamada d.imprime() chama o método imprime e os valores de dia, mes e ano são os valores
destes atributos armazenados em d.
A função main abaixo mostra a utilização de duas datas distintas. É importante ressaltar que
cada objeto possui uma cópia de cada um dos atributos de modo que a chamada d1.imprime()
imprimirá os atributos de d1 e a chamada d2.imprime() imprimirá 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 é o chamado construtor default, que é um
construtor sem parâmetros. Em outras palavras, o construtor default de uma classe é um método
sem parâmetros e sem tipo de retorno e cujo nome é o mesmo da classe. Este construtor especial
serve para iniciar objetos com valores padrão. No exemplo completo abaixo, o objeto d1 é iniciado
com os valores passados como parâmetro para o primeiro construtor e o objeto d2 é iniciado com
o valor padrão 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ção na chamada do construtor é feita pelo compilador por meio da lista de parâmetros.
Ao iniciar d1, utiliza-se o primeiro construtor, pois passamos 3 parâmetros para a sua iniciação;
ao iniciar d2, utiliza-se o segundo construtor, pois nenhum parâmetro foi passado. Se tentássemos
utilizar 1, 2 ou mais de 3 parâmetros, haveria um erro de compilação; o mesmo ocorreria se algum
dos parâmetros passados para d1 fosse incompatı́vel com o tipo int, da mesma forma que ocorre
com chamadas de funções.
Observe que, até agora, não houve ganho algum em poder de expressão da linguagem, isto é, o
que fizemos utilizando classes poderia ter sido feito corretamente utilizando estruturas e funções
independentes. Entretanto, melhoramos a organização do nosso código, pois deixamos todas as
funções em um mesmo local do programa: a classe Data, além de representar uma data, também
agrupa as operações sobre este tipo de dado. Além disso, a atribuição de valores iniciais a atributos
ficou mais simples, pois podemos passá-los diretamente para o construtor, sem a necessidade de
iniciarmos campo a campo.

Todos os direitos reservados aos autores


Tópico 3

Tipos Abstratos de Dados

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

3.2 Implementação de um TAD


Na Programação Orientada por Objetos, dizemos que uma Classe implementa um Tipo Abstrato
de Dados. Em outras palavras, uma classe contém os elementos que representam o tipo, e agrupa
todas as operações realizadas sobre estes elementos.
Ao definirmos um TAD, devemos nos preocupar sempre em definir a interface de comunicação
do cliente com o TAD. Na implementação via classe, isto significa determinar quais serão os
métodos, os seus cabeçalhos e o que cada método faz. Os algoritmos que serão utilizados são
irrelevantes nesta fase. No nosso exemplo de data, os cabeçalhos e uma descrição dos objetivos de
cada método são suficientes para escrevermos algoritmos que utilizem datas.
Na implementação de um TAD, alguns fatores elevam a qualidade do código:
• Ocultamento de informações: é importante que o cliente conheça o menor número possı́vel
de informações a respeito do TAD. Por exemplo, na classe Data, os atributos dia, mes e ano
não podem ser alterados em qualquer cliente, visto que são privativos da classe. Se quisermos
permitir que o cliente altere algum dos campos, será melhor criarmos métodos para fazerem
esta alteração, como mostrado no fragmento de código abaixo:

class Data {
private: int dia, mes, ano;
public: Data(int d, int m, int a) { // Com validaç~
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 é válida
d.mes = 13; // Tentativa de atribuir valor inválido
// é impedida pelo compilador
d.alteraMes(13); // Tentativa de atribuir valor inválido
// é impedida pelo método alteraMes
d.imprime();
}

Suponha agora que, em vez de utilizarmos os três campos para dia, mês e ano, utilizemos
um único campo armazenando o número de dias de diferença 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ão 1/1/2000. Neste caso, se a base for o dia 01/01/2000, então a data 03/02/2003
seria representada por um único número: 1130. Esta representação possui duas vantagens:
economia de espaço, ao utilizarmos um único número no lugar de três, e facilidade para
realizar operações sobre datas, como por exemplo, determinar que dia foi 456 dias antes
de 10/01/2003. Se esta mudança for necessária, o cliente acima continua funcionando sem
problemas, ao passo que se a segunda linha da função main acima fosse permitida, deverı́amos
alterar também o cliente e não somente a classe. Isto tornaria o trabalho de alterar a
representação interna da data muito mais complexo.
• Interface simples e bem definida: o TAD deve ser definido de forma que seja fácil utilizar as
suas funcionalidades. Uma das conseqüências da boa definição da interface é a possibilidade
de alterar os algoritmos sem que isto altere a interface dos métodos. Por exemplo, observe que
podemos alterar o algoritmo da função 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 é que o cliente pode fazer a validação da data indepen-
dente do algoritmo utilizado. Isto significa que se quisermos alterar o algoritmo utilizado
para validação, o cliente não precisará ser alterado.
Outros fatores de qualidade serão discutidos no momento oportuno. Resumindo, chegamos a duas
caracterı́sticas importantes de um Tipo Abstrato de Dados:
• independência da representação interna: não importa se há três números ou somente um
para representar a data, pois isto não irá afetar os clientes; na verdade, os clientes estão
independentes desta decisão.
• independência dos algoritmos: não importa como a data será validada, mas sim qual é a
interface do método, isto é, o que são os parâmetros esperados e o que significa o valor
retornado.

Todos os direitos reservados aos autores


Notas de aulas de AED 13

3.3 Estudo de caso: TAD Vetor


Para exemplificar todo o conteúdo visto até agora, faremos uma implementação do Tipo Abstrato
de Dados Vetor, que armazena números inteiros e possui as seguintes operações:
• Criação: inicializa o vetor como vazio;
• obtemTamanho(): Retorna o número de elementos armazenados no vetor;
• insereNoFinal(x): Insere x na primeira posição vazia do vetor;
• alteraEm(p,x): Atribui x ao elemento da posição p do vetor, caso p seja uma posição válida;
• elementoEm(p): Retorna o valor armazenado na posição p, caso esta seja uma posição
válida; retorna −1 caso contrário;
• posicaoDe(x): Retorna a posição da primeira ocorrência de x no vetor ou o valor −1 caso
x não seja encontrado;
• imprime(): Imprime os elementos do vetor, separados por um espaço.

3.3.1 Exemplo de Cliente


Abaixo, temos um exemplo de função que deve funcionar com a implementação 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ção


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ção que utiliza alocação estática e que não permite que
o vetor possua mais do que 100 elementos. Se não houver possibilidade de inserção do elemento,
exibiremos uma mensagem de erro. O mesmo será feito nas operações alteraEm e elementoEm.
Uma implementação 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ários são o vetor, que está definido estaticamente com 100
elementos, e o número de elementos.
O construtor deverá simplesmente inicializar o atributo numElementos com zero, indicando
que não há elementos armazenados. Pode-se também zerar todas as posições do vetor, mas isto
não é totalmente necessário, pois os demais métodos utilizarão o número de elementos para evitar
acesso a posições inexistentes do vetor.
Vetor::Vetor() {
numElementos = 0;
// Inicializaç~
ao opcional do vetor
for (int i = 0; i < TAMANHO_MAXIMO; i++)
v[i] = 0;
}
O método obtemTamanho deverá retornar o número de elementos presentes no vetor. Pode-se
simplesmente retornar o valor do atributo numElementos, como abaixo:
int Vetor::obtemTamanho() {
return numElementos;
}
A inserção no final do vetor deverá verificar se o vetor está cheio. Se estiver, então não
poderemos fazer a inserção e uma mensagem de erro será exibida. Senão, faremos a inserção na
posição numElementos e este atributo será incrementado em um:
void Vetor::insereNoFinal(int novo_valor) {
if (numElementos == TAMANHO_MAXIMO)
cout << "ERRO: O vetor está cheio!" << endl;
else {
v[numElementos] = int novo_valor;
numElementos++;
}
}
O método alteraEm(p,x) verifica se p é uma posição válida do vetor. Se for, atribui o valor
de x à posição p; senão, exibe uma mensagem de erro:
void Vetor::alteraEm(int pos, int novo_valor) {
if (pos < 0 || pos >= numElementos)
cout << "ERRO: A posiç~
ao " << pos << " n~
ao é válida!" << endl;
else
v[pos] = novo_valor;
}
O método elementoEm(p) funciona de modo semelhante:
int Vetor::elementoEm(int pos) {
if (pos >= 0 && pos < numElementos)
return v[pos];
cout << "ERRO: A posiç~
ao " << pos << " n~
ao é válida!" << endl;
return -1;
}

Todos os direitos reservados aos autores


Notas de aulas de AED 15

O método posicaoDe(x) procura a primeira ocorrência de x no vetor. Se o elemento não for


encontrado, retornará −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étodo imprime() simplesmente faz a impressão 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ópico 4

Apontadores

Durante a execução do programa, as variáveis são armazenadas na memória em endereços especi-


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

Para manipularmos endereços de memória, podemos utilizar dois operadores: endereço de (&)
e conteúdo de (*).
O fragmento de código abaixo declara uma variável inteira (x) e um apontador para inteiros
(ptr) e atribui a ptr o endereço de memória onde x está armazenada:
int x, *ptr;
ptr = &x; // Atribui a ptr o endereço de x

É possı́vel alterar o valor de x via um apontador que contenha o seu endereço. Isto é feito
utilizando o operador conteúdo de (*), como no exemplo abaixo:
int x = 0, *ptr;
ptr = &x; // Atribui a ptr o endereço de x
*ptr = 1; // Atribui 1 à variável cujo endereço está
// armazenado no apontador ptr, isto é, x
cout << x; // Imprime 1 e n~ao 0

16
Notas de aulas de AED 17

Considere o programa abaixo.


#include <iostream.h>
int x = 10;
int * ptr = &x;
void mostrar() {
cout << "Valor de x: " << x << endl;
cout << "Valor de ptr: " << ptr << endl;
cout << "Conteúdo de ptr: " << *ptr << endl;
cout << "Endereço de x: " << &x << endl;
cout << "Endereço de ptr: " << &ptr << endl;
}
void main(void) {
mostrar();
*ptr = 123;
mostrar();
}

A função mostrar está exibindo na seqüência:


• o valor de x;
• o valor de ptr, que é o endereço de x;
• o conteúdo de ptr, que é o valor de x;
• o endereço de x;
• o endereço de ptr.
Considerando que, no programa acima, a variável a seja alocada pelo compilador no endereço
FF50 e p seja alocada pelo compilador no endereço FF54, então a saı́da obtida na tela será:
Valor de x: 10
Valor de ptr: FF50
Conteúdo de ptr: 10
Endereço de x: FF50
Endereço de ptr: FF54
Valor de x: 123
Valor de ptr: FF50
Conteúdo de ptr: 123
Endereço de x: FF50
Endereço de ptr: FF54

Considere agora o programa abaixo:


#include <iostream.h>
void main(void) {
char ch1, ch2, *p, *q;
ch1 = ’A’; p = &ch1; q = &ch2;
*p = *p + 1; // comando 1
*q = *p + 1; // comando 2
*p = ch2 + 1; // comando 3
cout << "Valor de ch1: " << ch1 << endl;
cout << "Valor de ch2: " << ch2 << endl;
}

Lembrando que *p significa variável cujo endereço está armazenado em p, temos que o comando
1 altera o valor de ch1 para ‘B’ (lembre-se que somar 1 a uma variável char que contenha uma
letra é equivalente a obter a letra seguinte). O comando 2 altera o valor de ch2 (apontado por q)
para ‘C’, que é 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 <iostream.h>
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ço de x a apont1. O comando 2 atribui o valor de apont1 a


apont2, isto é, o apontador apont2 conterá, assim como apont1, o endereço da variável 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ópico 5

Alocação Dinâmica

Considere que você esteja escrevendo um programa que leia um valor n indicando o número de
elementos de uma seqüência e, em seguida, leia n números fornecidos pelo usuário, armazenando-os
em um vetor.
Como o valor de n é a princı́pio desconhecido, podemos supor um valor máximo (por exemplo,
100.000 elementos) e impedirmos, no programa, que o usuário forneça um valor de n maior que este
máximo especificado. Entretanto, dois problemas ocorrem: pode ser que o valor máximo ainda
seja pequeno para alguma seqüência de elementos; por outro lado, se a quantidade de elementos
for muito pequena, teremos desperdiçado muito espaço de memória ao reservarmos área para um
vetor muito grande.
Quando declaramos um vetor como mostrado a seguir, o endereço da primeira posição do vetor
e o número de bytes que este vetor ocupa são definidos pelo compilador. Esta alocação é chamada
de Alocação Estática:
int vetor[100]; // Vetor alocado estaticamente

O mesmo ocorre com todas as variáveis declaradas no programa. O compilador define onde a
variável estará armazenada e quantos bytes serão 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ção, pois, somente após o usuário fornecer o valor de n,
saberemos o número de elementos que o vetor deverá possuir. Isto pode ser feito utilizando
Alocação Dinâmica.
Alocar dinamicamente uma variável significa definir, em tempo de execução, o endereço e o
número de bytes que esta variável ocupará.
Para alocarmos dinamicamente um vetor de elementos, declaramos um apontador e atribuı́mos
a este apontador o endereço retornado pelo operador new da linguagem C++.
Há duas formas de alocarmos uma variável dinamicamente:
1. Alocação de uma variável simples:

int * p; // p é um apontador para inteiros


p = new int; // aloca nova área para um inteiro

2. Alocação de um vetor de elementos:

int * p; // p é um apontador para inteiros


int n;
... obtenç~
ao do valor de n ...
p = new int[n]; // aloca área para um vetor de n inteiros

Inicialmente, iremos nos concentrar na segunda forma. A resolução do problema acima pode
ser feita utilizando este recurso linguı́stico da seguinte maneira:

19
Notas de aulas de AED 20

#include <iostream.h>
void main(void) {
int * vetor, n, i;
cout << "Qual é o valor de n? ";
cin >> n;
vetor = new int[n];
for (i = 0; i < n; i++) {
cout << "Elemento na posiç~
ao " << i << ": ";
cin >> vetor[i];
}
... uso do vetor ...
}
Observe que o vetor alocado dinamicamente é manipulado da mesma forma que um vetor
alocado estaticamente, isto é, utilizando [] para indexação. A diferença é que agora declaramos
o vetor como um apontador e utilizamos o operador new para fazermos a alocação.
Quando uma variável é alocada estaticamente, a área de memória que ela ocupa é liberada
automaticamente durante a execução e coincide com o final do escopo da variável. Por exemplo,
uma variável local de função tem a sua área de memória liberada automaticamente quando esta
função retorna; uma variável global deixa de existir quando o programa termina.
Uma variável alocada dinamicamente deixa de existir quando o programador utiliza o operador
delete. Este operador tem por objetivo liberar a área de memória reservada para uma variável.
O programa acima fica assim, com o uso do delete:
#include <iostream.h>
void main(void) {
int * vetor, n, i;
cout << "Qual é o valor de n? ";
cin >> n;
vetor = new int[n]; // Alocaç~
ao de vetor
for (i = 0; i < n; i++) {
cout << "Elemento na posiç~ao " << i << ": ";
cin >> vetor[i];
}
... uso do vetor ...
delete [] vetor; // Libera área de memória
}
O uso dos colchetes entre o delete e a variável apontador é necessário quando liberamos área
de memória reservada para um vetor. Se tivéssemos feito a alocação como uma variável simples,
não utilizarı́amos os colchetes.
É importante salientar que o delete opera sobre a área de memória cujo endereço está no
apontador, e não sobre o apontador. Por exemplo, considere o seguinte fragmento de código:
int * v1, * v2, n;
cout << "Qual é o valor de n? ";
cin >> n;
v1 = new int[n]; // Alocaç~
ao de v1
v2 = v1; // v1 e v2 apontam para o mesmo local
for (i = 0; i < n; i++){
cout << "Elemento na posiç~
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ço de inı́cio na
variável 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

dois apontadores contendo o mesmo endereço de memória. É vital percerbermos que não há dois
vetores, mas somente um vetor, cujo endereço está armazenado em duas variáveis apontadores.
Desta forma, após esta atribuição, tanto faz se utilizarmos o vetor via variável v1 ou via variável
v2.
Além disso, observe que ao fazer a liberação de memória, podemos utilizar o delete com v1
ou com v2; todos os dois apontadores contêm o endereço do mesmo vetor, e esta área de memória
será liberada. É importante salientar que, se fizéssemos a liberação como:
delete [] v1;
delete [] v2;

ocorreria um erro de execução: a primeira linha liberaria a área do vetor e ao tentarmos fazer o
segundo delete, tentarı́amos liberar uma área de memória que não está mais reservada para o
programa (v1 e v2 são o mesmo vetor).
Considere por exemplo que precisemos fazer uma função que aloque dinamicamente um vetor
com o número de elementos especificado como parâmetro da função. O endereço onde o vetor
foi alocado (isto é, um apontador) será retornado pela função. O vetor, após ser alocado, será
preenchido com zeros.
Esta função pode ser escrita como:
// n é o tamanho do vetor a ser alocado
// retornaremos o endereço de memória do vetor, que
// é do tipo (int *).
int * alocaVetor(int n) {
int * v, i;
if (n <= 0) // N~
ao é possı́vel alocar
return NULL; // Endereço zero: marca erro na alocaç~
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ço do novo vetor
}

Façamos um outro exemplo: considere que precisemos ler uma seqüência de números de ponto
flutuante e armazenar esta seqüência em um vetor. Os números que serão inseridos serão sempre
números 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ário digitará diversos números
e que, ao final, ele digitará algum valor inválido para marcar o final da entrada. Por exemplo, se
o usuário digitar 1.2, 3.7, 41.3, 82.3, 7.42, -1, então saberemos que a seqüência desejada é 1.2, 3.7,
41.3, 82.3, 7.42.
Faremos uma função de cabeçalho
double * leSequencia(int & n);

que lerá o vetor e retornará dois valores: o endereço onde o vetor foi alocado, por meio de
return, e o número de elementos lidos, por meio do parâmetro de referência n.
Façamos uma versão inicial desta função, lendo no máximo 100 elementos:
const int MAXIMO_ELEMENTOS = 100;
double * leSequencia(int & n) {
n = 0;
double * v = new double[MAXIMO_ELEMENTOS];
cout << "Qual é 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 é o próximo elemento? ";
cin >> x;
}

Todos os direitos reservados aos autores


Notas de aulas de AED 22

return v;
}

Observe que, se o usuário tentar fornecer mais elementos que o limite especificado, então o
programa cessará a leitura e retornará os 100 primeiros elementos no vetor. Além disso, o número
de elementos é armazenado diretamente no parâmetro de referência n. Um exemplo de cliente
desta função é:
void main(void) {
double * meuVetor;
int numElementos, i;
meuVetor = leSequencia(numElementos);
cout << "Números fornecidos: " << endl;
for (i = 0; i < numElementos; i++)
cout << meuVetor[i] << " ";
cout << endl;
delete [] meuVetor;
}

Neste exemplo, é interessante salientar os seguintes aspectos:

• A chamada de leSequencia atribui a meuVetor o endereço do vetor alocado dentro da função


e a numElementos, o número de elementos lidos;
• A liberação de memória é feita utilizando o apontador que contém o endereço de memória
da área alocada dinamicamente; isto significa que:
– Tivemos que utilizar a variável meuVetor e não a variável v, pois esta é local da função
leSequencia;
– Fazer a liberação de memória dentro da função leSequencia é um erro, pois este vetor
ainda será utilizado ao longo do programa.

Devemos salientar que esta função possui ainda um grave problema: se quisermos armazenar
mais do que 100 elementos, devemos alterar o valor da constante, o que trará os problemas já
discutidos anteriormente.
Para evitarmos tais problemas, utilizaremos uma nova abordagem. Se o número de elementos
ultrapassar o tamanho especificado, então faremos a alocação 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 área de memória que continha inicialmente os elementos.
Esta abordagem, denominada realocação, está implementada na função 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 é 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 é o próximo elemento? ";
cin >> x;
}
return vetor;
}

Nesta nova função, fazemos um teste dentro do while, verificando se o número de elementos já
lidos é igual ao tamanho do vetor; se for igual, então o vetor está cheio e não é possı́vel inserir novos
elementos. Se isto ocorrer, então fazemos a realocação, cujo código está dentro do if, realizando
as seguintes operações:
• Definimos o tamanho do novo vetor: neste exemplo, criamos um novo vetor com o dobro do
tamanho do vetor utilizado, isto é, 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ão 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 área de memória do vetor antigo: este vetor não é mais necessário, pois todos os
elementos já estão em novo vetor, que é um vetor maior que o original.
• Atribuı́mos o valor do novo tamanho à variável tamanho.
• Atribuı́mos o endereço do novo vetor ao apontador que contém o vetor utilizado: esta atri-
buição faz com que a variável vetor que está sendo utilizada ao longo da função contenha
agora o endereço do vetor maior, de modo que as inserções seguintes serão possı́veis.
Agora, podemos fornecer qualquer quantidade de elementos, pois a função não precisa mais
limitar esta quantidade. A única limitação que temos agora é de máquina.

Todos os direitos reservados aos autores


Tópico 6

Análise de complexidade

6.1 Introdução
Normalmente, o mesmo problema pode ter diversas soluções computacionais utilizando diferentes
técnicas de programação, assim como, diferentes lógicas de programação. Uma pergunta surge
então: como devemos fazer para escolher qual solução usar? A resposta para esta questão parece
óbvia. É claro que iremos considerar a solução que seja mais rápida. Mas como determinar
qual solução é mais rápida? Para isto, será necessário implementar programas que resolvam
este problema, seguido de uma verificação de tempo para cada solução. Essa metodologia para
a determinação do programa mais rápido é bastante custosa pois devemos ter um computador,
um compilador, conhecer alguma linguagem de programação, assim como, tempo sobrando para
o seu desenvolvimento. Ainda, essa metodologia é altamente dependente das configurações do
computador usado. Para diminuir o custo deste processo, tentaremos desenvolver uma metodologia
que determinará o custo computacional de um algoritmo, com relação ao tempo mas também ao
espaço utilizado.

6.2 Complexidade computacional e análise assintótica


Dentre os objetivos de ATP2, estamos interessados em resolver problemas da melhor forma
possı́vel, isto é, desenvolver algoritmo cujo custo computacional seja o menor possı́vel (dentro
das limitações e possibilidades oferecidas até o momento) para um dado problema. Em outras
palavras, buscamos programas eficientes. Por exemplo, faça uma função de cabeçalho
int posicaoDe(int x, int * v, int tam);
que retorne a posição da ocorrência do elemento x no vetor v contendo tam elementos distintos.
Caso não exista este elemento, retorne -1.
A primeira solução para essa função 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ção podemos observar que todos os elementos serão sempre pesquisados e verificados,
logo podemos afirmar que serão feitas tam comparações para determinar a posição do elemento.
Mas se o elemento estiver na primeira posição, será necessário comparar todos os elementos com
o elemento a ser pesquisado? Veja a segunda solução

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ção, podemos ter 3 situações crı́ticas: (i) o elemento está na primeira posição do vetor; (ii)
o elemento está na última posição do vetor; e (iii) o elemento não está no vetor. Para a situação (i)
teremos somente uma comparação, já para as outras situações teremos tam comparações. Ainda
é possı́vel melhorar a solução deste problema? A resposta ficará como exercı́cio, assim como,
possı́veis soluções, caso existam.
Como podemos observar, existem diferentes formas para se resolver o problema de localização
de um elemento em um vetor, entretanto nenhuma comparação entre as abordagens foi realizada.
Para compararmos diferentes programas, relativo a sua eficiência, definimos uma medida de grau
de dificuldade de um algoritmo, denominada complexidade computacional. A complexidade
computacional indica quanto esforço é necessário para se aplicar um algoritmo, ou quão custoso
este algoritmo é. Dois diferentes critérios de eficiência podem ser considerados: tempo e espaço.
Aqui, consideraremos somente o tempo.
Quando consideramos tempo, a primeira idéia que temos é: quantos minutos (ou segundos)
são necessários para a execução de um programa? Esta abordagem é muito custosa, visto que
todos os programa devem ser implementados, na mesma linguagem, e executados em máquinas
com as mesmas configurações, e ainda nas mesmas condições de teste. Contrariamente ao uso de
tempo real, a avaliação de eficiência de um algoritmo, dá-se pela utilização de unidades lógicas,
que expressam a relação entre o tamanho n da entrada e a quantidade de tempo f (n) necessária
para processar os dados. A definição de uma unidade lógica está associada a(s) operação(ões) que
se deseja considerar para comparar algoritmos, por exemplo, quantas comparações são realizadas
para localizar um elemento dentro de um vetor? Neste caso, a unidade lógica a ser considerada é
a comparação.
Normalmente, a função para expressar a eficiência de um algoritmo é complexa e cheia de
termos. Quando consideramos grandes quantidades de dados, termos que não modifiquem a
grandeza da expressão devem ser eliminados de forma a simplificar a comparação entre diferentes
funções. Essa medida de eficiência é denominada complexidade assintótica. Veja um exemplo,
considere um algoritmo com a seguinte complexidade computacional

f (n) = n3 + 100n + 10000 (6.1)

onde n é o tamanho da entrada do algoritmo (por exemplo, o número de elementos de um vetor).


Nesta função, observamos que para valores de n pequenos, o terceito termo é o mais importante,
mas para valores de n muito grandes, o segundo e o terceiro termos são quase irrisórios no cálculo
da função. Com isto, podemos concluir que a complexidade assintótica desse algoritmos está em
função do termo n3 .
Vale ressaltar que a avaliação de eficiência de um algoritmo faz sentido quando consideramos
entradas de tamanho muito grande, pois para pequenas entrada, normalmente todos os algoritmos
são rápidos. Por isto, a necessidade do estudo da complexidade assintótica, que ficará mais clara
na próxima seção.

6.3 Notação O
Uma abordagem para o estudo da eficiência de um algoritmo é a análise do pior caso, tentando
determinar a dependência funcional do tempo de execução com o tamanho da entrada. O primeiro

Todos os direitos reservados aos autores


Notas de aulas de AED 26

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

Todos os direitos reservados aos autores


Notas de aulas de AED 27

O(1) A maioria das instruções da maioria dos programas são executados uma
única vez, ou no máximo, poucas vezes. Se todas as instruções de um
programa tivessem esta propriedade, nós dirı́amos que seu tempo de e-
xecução é constante. Essa é a situação que todos os programadores e/ou
projetistas de software devem se esforçar para conseguir.
O(log n) Este tempo de execução normalmente ocorre em algoritmos quando parti-
cionamos um problema grande em pedaços menores de interesse. Quando
isto ocorre nós dizemos que o algoritmo possui tempo de execução lo-
garı́tmico.
O(n) Quando o tempo de execução é linear, a soma de processamento normal-
mente é pequena para cada elemento da entrada.
O(n log n) Este tempo de execução é computado para algoritmos que resolvem um
problema através da sua quebra em subproblemas menores, resolve cada
um destes subproblemas independentemente, e então combina as soluções.
O(n2 ) Este tempo de execução normalmente é computado quando todos os pares
de itens de dados são processados. Quando isto ocorre dizemos que o
tempo de execução é quadrático. Normalmente, algoritmos quadráticos
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ção cúbico.
O(2n ) Poucos algoritmos com tempo de execução exponenciais são apropriados
para usos práticos. Estes algoritmos são chamados de força 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álculo da ordem de complexidade deve ser efeito, então, 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ção de complexidade f (n), tal que f (n) represente o número de comparações
entre os elementos do vetor A. De modo a calcular f (n) iremos utilizar de fi (n) = número de
comparações entre os elementos do vetor A para um dado i.
Dessa forma :
fi (n) = n − i
Logo:
n−1
X
f (n) = 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
n2 n
= − ·
2 2
A partir dessa função de complexidade podemos, também, encontrar a ordem de complexidade,
veja:

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

Todos os direitos reservados aos autores


Tópico 7

Recursividade

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

7.2 Ilustração de uma abordagem recursiva


Imagine que você tenha recentemente aceitado a posição de coordenador de fundos para uma
campanha de combate a fome de sua região. Nesta campanha, você deverá arrecadar 1000 reais
em doações. Devido às regras impostas pelo comitê senior da campanha, cada doação não pode
passar de 1 real por pessoa. Como você deveria proceder?
Obviamente, a primeira solução para este problema é encontrar 1000 pessoas da comunidade,
e pedir a cada uma delas 1 real. Observe que você mesmo deverá pedir esta quantia diretamente
para estas pessoas. Computacionalmente falando, uma função de arrecadação 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ção é baseada em um loop explı́cito. Este tipo de construção é chamada
de solução iterativa. Assumindo que você poderia encontrar as 1000 pessoas, esta solução seria
efetiva, mas ao mesmo tempo muito cansativa. Para evitar este cansaço e ainda para tentar agilizar
o processo, a divisão dessa tarefa em sub-tarefas seria uma possı́vel solução. Por exemplo, ao invés
de pedir o dinheiro para 1000 pessoas, você pediria a ajuda a 10 pessoas, onde cada pessoa deveria
arrecar 100 reais. Partindo desta perspectiva, o problema continua sendo o mesmo, porém com

29
Notas de aulas de AED 30

valores de arrecadação diferentes. Ainda, a tarefa de arrecar 100 reais é teoricamente mais simples
que a tarefa de arrecar 1000 reais.
A essência da abordagem recursiva conduz a aplicar a mesma decomposição em cada estágio
da solução. Então, seguindo a mesma abordagem, cada novo voluntário 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 última
etapa, a estrátegia foi alterada, pois agora foi pedido 1 real de cada doador, e não feita uma
nova sub-divisão do problema. Recursivamente falando, o 1 real representa um caso simples, que
pode ser resolvido diretamente sem uma nova decomposição, este caso é chamado de condição de
parada.
Soluções deste tipo são frequentemente referenciadas como estrátigia dividir para conquistar,
uma vez que eles dependam do particionamente do problema em componentes mais gerenciáveis.
Para representar este algoritmo em uma forma computacional mais sugestiva, é importante
notar que existem diferentes instâncias de um problema similar. No caso especı́fica mostrado
anteriormente, nós 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ção.
Para explorar esta similaridade, nós devemos generalizar o problema à tarefa de arrecadar, não
mais uma soma especı́fica, mas sim uma soma indeterminada de dinheiro, representada por uma
variável n.
A tarefa de arrecar n reais pode ser dividida em dois casos. Primeiro, se n é 1 real, então
nós simplesmente contribuimos com o nosso próprio dinheiro. Alternativamente, nós procueramos
10 volutários para nós ajudar a arrecar os n reais. Entretanto, cada voluntário deverá arrecar
somente um décimo de n reais. Essa estrutura é 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 é tipicamente de um algoritmo recursivo. Observe que mesmo havendo um loop
explı́tico este algoritmo é recursivo pois há uma chamada a própria função. Vamos entender cada
uma das etapas deste algoritmo. A primeira etapa, representada pelo comando if , consiste em
testar se o problema corrente é tão simples que possa ser resolvido sem uma nova decomposição.
Se for verdade, já arrecada 1 real diretamente, se não, o problema deverá ser dividido em sub-
problemas, cada um dos quais é resolvido pela aplicação da mesma estratégia.

7.3 Caracterı́sticas de algoritmos recursivos


Na maioria da vezes, a decisão de usar recursão é sugerida pela natureza do problema. Para ser um
candidato apropriado para a solução recursiva, um problema deve ter três propriedades distintas:
1. Deve ser possı́vel decompor o problema original em instâncias mais simples do mesmo pro-
blema.
2. Uma vez que cada um desses sub-problemas tenha sido resolvido, deve ser possı́vel combinar
estas soluções para produzir uma solução para o problema original.

Todos os direitos reservados aos autores


Notas de aulas de AED 31

3. Como problemas grandes são quebrados, sucessivamente, em problemas menos complexos,


estes sub-problemas devem, eventualmente, tornar-se tão simples que eles possam ser resol-
vidos sem uma outra subdivisão.

Normalmente, procedimentos recursivos tendem a fornecer um modelo conceitual bem natural


quando tendem a implementar problemas matemáticos. Isto é verdade pois muitas definições ma-
temáticas, tais como a fatorial e a Fibonacci, são particularmente elegantes quando expressas na
forma recursiva. As funções recursivas também podem ser implementadas de forma não recursiva,
mas esta implementação é, as vezes, mais complicada pois depende de um profundo entendimento
do problema a ser resolvido. Existem técnicas, que não serão tratadas neste texto, para a conversão
de programa recursivo para um programa não recursivo. Os programas recursivos, embora pos-
suam uma mesma complexidade computacional que suas respectivas soluções não recursivas, são
mais lentos devido às diversas chamadas de funções, que produz um overhead de processamento.

7.4 Exemplos
Nesta seção, serão ilustrados alguns problemas bem como suas soluções recursivas. O primeiro
problema a ser considerado ser a função fatorial, definida pela fórmula:

(
n × (n − 1)! para n ≥ 1
n! = (7.1)
1 caso contrário

Esta definição de fatorial corresponde diretamente a seguinte função 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ásicas de uma programa recursivo: ele
chama a sim mesmo e ele possui uma condição de parada. Por outro lado, não existe mascaração
do fato que este programa nada mais é que um loop for. Também, vale ressaltar que o fatorial
não trabalha com números negativos, logo é fundamental não deixar que exista uma chamada a
esta função quando o valor de n seja negativo. Um solução não recursiva para este problema será

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ção de recorrência é aquela que define os números de Fibonacci
de ordem 2.


F ib(n − 1) + F ib(n − 2)
 para n ≥ 2
F ib(n) = 1 para n = 1 (7.2)

1 para n = 0

Da mesma forma que o fatorial, essa relação de recorrência 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ão representam de forma convincente o poder da recursão, muito
pelo contrário, ele nos convence o quão ineficiente pode ser. O problema na função de fibonacci
é que as chamadas recursivas indicam que as chamadas fibonacci(n-1) e fibonacci(n-2) são com-
putadas independentemente, quando na realidade poderı́amos usar uma para computar a outra.
Assim, podemos usar um procedimento não recursivo para computar os números de fibonacci.
Entretanto, este procedimento precisará armazenar os resultados intermediários. Uma possı́vel
solução é 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á estudado anteriormente, quando ocorre uma chamada de função, várias informações (ir-
relevantes para ATP2) são enviadas para a pilha de execução. Dentre as informações enviadas
para a pilha, o endereço de retorno assim como os parâmetros da função são armazenados na pilha.
Nesta seção, veremos de maneira informal o que acontece quando se tem uma chamada re-
cursiva. Considere o problema de elevar um número x ao expoente n. A seguir é ilustrada uma
definição recursiva deste problema em que o valor n é inteiro não negativo.

(
n x(n−1) × x para n > 0
x = (7.3)
1 para n = 0

Usando esta definição 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ção recursiva pode ser implementada pela função a seguir.

double potencia(double x, int n){


if (n==0) return 1.0;
else return x*potencia(x,n-1);
}

A aplicação indutiva desta função para a chamada potencia(x,4) é ilustrada a seguir.

chamada 1 x4 = x × x3 =x × x × x × x
chamada 2 x × x2 =x × x × x
chamada 3 x × x1 =x × x

Todos os direitos reservados aos autores


Notas de aulas de AED 33

chamada 4 x × x0 =x × 1=x
chamada 5 1

ou alternativamente,

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

Observe que os resultados intermediários, armazenados em algum lugar, são computados das
chamadas mais internas para as mais externas.

Todos os direitos reservados aos autores


Tópico 8

Estudo de Caso: TAD Pilha

8.1 Definição
Uma Pilha representa um conjunto de elementos em que o único elemento visı́vel é o elemento que
foi inserido há menos tempo no conjunto.
As operações sobre Pilhas são as seguintes:
• Construção: 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á-lo;

• vazia: verifica se a pilha está vazia.


A única forma de inserir elementos em uma pilha é via operação empilha e a única forma de
retirar elementos é via operação desempilha.
Para termos acesso ao penúltimo elemento empilhado, devemos desempilhar o último; o mesmo
ocorre com os demais elementos que tiverem sido inseridos anteriormente. Dizemos que uma Pilha
é uma estrutura FIFO (First In, First Out = o primeiro que entra é o primeiro que sai).

8.2 Aplicações
Pilhas são utilizadas em programas de computadores em chamadas de funções e procedimentos e
chamadas recursivas.
Utilizam-se pilhas também no processamento de estruturas aninhadas de profundidade impre-
visı́vel. Nesta situação é necessário garantir que as estruturas mais internas sejam processadas
antes da estrutura que as contenha. A pilha é uma estrutura adequada nesta situação, pois a
ordem de remoção garante que as estruturas mais internas serão processadas antes das estruturas
mais externas.
Podemos utilizar pilhas também em editores de texto e na interpretação de expressões aritmé-
ticas.

8.3 Interface da Classe Pilha


A implementação 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étodos topo e desempilha retornam verdadeiro ou falso, indicando se a operação foi
realizada com sucesso; se retornar falso, então a pilha estava vazia, o que impediu que a operação
fosse feita; se a operação retornar verdadeiro, então a operação foi concluı́da com sucesso.
O valor do topo da pilha é retornado pelo parâmetro de referência valorTopo do método topo.

8.3.1 Implementação 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ém o ı́ndice da primeira posição vazia do vetor, e tamanho que
contém o número de posições alocadas para o vetor. O vetor utilizará crescimento automático, via
método realoca, que também é 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 atri-
buto indTopo com o valor zero:
Pilha::Pilha() {
tamanho = TAMANHO_INICIAL;
vetor = new Elemento[tamanho];
indTopo = 0;
}
O destrutor irá liberar a área de memória reservada para o vetor:
Pilha::~Pilha() {
delete [] vetor;
}
O método vazia retorna verdadeiro se o ı́ndice do topo (indTopo) for igual a zero, ou falso,
caso contrário:
bool Pilha::vazia() {
return (indTopo == 0);
}
O método empilha verifica se o ı́ndice do topo é igual ao tamanho do vetor; se for igual, então
a pilha está cheia e será necessário realocar o vetor; isto é feito chamando-se o método realoca.
Após o teste, e possı́vel realocação, atribui-se o valor a ser empilhado à primeira posição 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étodo realoca é idêntico ao método de mesmo nome definido na classe Vetor.


O método topo verifica se a pilha está vazia; se estiver, retorna falso; se não estiver, atribui o
valor do topo ao parâmetro por referência valorTopo e retorna verdadeiro:
bool Pilha::topo(Elemento & valorTopo) {
if (vazia() == true)
return false;
valorTopo = vetor[indTopo - 1];
return true;
}

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

8.4 Exemplos
As funções abaixo devem funcionar com qualquer implementação de pilha, pois respeitam a assi-
natura 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ém 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ção inicializa empilha os valores de 1 a 10 na pilha. A função 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á os mesmos elementos que havia em P1, mas na ordem inversa. Observe que a
função copia esvazia a pilha P1, pois não é possı́vel acessar um elemento sem retirar todos que
tiverem sido inseridos depois.
Este programa deverá 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ópico 9

Estudo de Caso: TAD Fila

9.1 Definição
Uma Fila representa um conjunto de elementos em que o único elemento visı́vel é o elemento que
foi inserido há mais tempo no conjunto.
As operações sobre Filas são as seguintes:
• Construção: 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á vazia.
A única forma de inserir elementos em uma fila é via operação insere e a única forma de
retirar elementos é via operação retira. Para termos acesso ao segundo elemento inserido, de-
vemos retirar o primeiro; o mesmo ocorre com os demais elementos que tiverem sido inseridos
posteriormente. Dizemos que uma Fila é uma estrutura LIFO (Last In, First Out = o último que
entra é o primeiro que sai).

9.2 Aplicações
Filas em geral são utilizadas para controlar o processamento de elementos na ordem em que
eles aparecerem, como por exemplo, em filas de impressão e em filas de processos do Sistema
Operacional. Utilizamos filas também para implementação de buscas em largura em Grafos e em
Inteligência Artificial.

9.3 Interface da Classe Fila


A implementação do TAD Fila será 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étodos frente e retira retornam verdadeiro ou falso, indicando
se a operação foi realizada com sucesso; se retornar falso, então a fila estava vazia, o que impediu
que a operação fosse feita; se a operação retornar verdadeiro, então a operação foi concluı́da com
sucesso.
O valor da frente da fila é retornado pelo parâmetro de referência valorFrente do método
frente.

9.3.1 Implementação 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ém o ı́ndice onde se encontra o elemento na frente da fila,
numElementos, que armazena o número de elementos inseridos na fila, e tamanho que contém o
número de posições alocadas para o vetor. O vetor utilizará crescimento automático, via método
realoca, que também é privativo da classe.
// Facilita se for necessário 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á liberar a área de memória reservada para o vetor:
Fila::~Fila() {
delete [] vetor;
}
O método vazia retorna verdadeiro se o número de elementos (armazenado em numElementos)
for igual a zero, ou falso, caso contrário:
bool Fila::vazia() {
return (numElementos == 0);
}
O método frente verifica se a Fila está vazia; se estiver, retorna falso; se não estiver, atribui
ao parâmetro por referência 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étodos 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ções e que tenhamos inserido
os números de 1 a 10. Após estas operações, considere que houve 3 retiradas. Para termos menos
trabalho na movimentação dos elementos, podemos supor que o estado do objeto será o seguinte
ao longo da execução (X marca posição livre no vetor):
Inicialmente:
F = fila vazia
vetor: X X X X X X X X X X
indFrente: 0
numElementos: 0
tamanho: 10
Após as inserç~
oes dos números de 1 a 10:
F = 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
vetor: 1 2 3 4 5 6 7 8 9 10
indFrente: 0
numElementos: 10
tamanho: 10
Após as tr^
es retiradas:
F = 4, 5, 6, 7, 8, 9, 10
vetor: X X X 4 5 6 7 8 9 10
indFrente: 3
numElementos: 7
tamanho: 10

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

Todos os direitos reservados aos autores


Notas de aulas de AED 41

tamanho: 10
Após a inserç~
ao do 13:
F = 4, 5, 6, 7, 8, 9, 10, 11, 12, 13
vetor: 11 12 13 4 5 6 7 8 9 10
indFrente: 3
numElementos: 10
tamanho: 10

Agora temos um vetor cheio. A única diferença é a modificação nos conceitos de inı́cio e
término da seqüência no vetor. Neste último exemplo, a seqüência inicia no ı́ndice 3, vai até o
ı́ndice 9 (que é o final do vetor), e depois continua nas posições 0, 1 e 2.
Observe também que se quisermos inserir mais um elemento, a realocação será inevitável, pois
não há mais posições livres no vetor.
Desta forma, o método retira deverá 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á no atributo tamanho, então passaremos este valor para 0. Não podemos esquecer que,
no inı́cio do método, devemos testar se a fila está vazia, pois isto definirá o valor de retorno. O
método abaixo mostra a implementação da técnica explicada:
bool Fila::retira() {
if (vazia() == true)
return false;
indFrente++;
if (indFrente == tamanho)
indFrente = 0;
numElementos--;
return true;
}

O método insere utilizará o mesmo conceito. Inicialmente, verificamos se o número de ele-


mentos da fila é igual ao seu tamanho. Se for igual, será necessário realocar o vetor, chamando-se
o método realoca. Após a verificação, e possı́vel realocação, devemos verificar onde a seqüência
de elementos termina, para inserirmos o elemento na posição seguinte.
Por simplicidade, iniciemos com uma fila com a seguinte configuração:
Uma fila qualquer:
F = 4, 5, 6, 7, 8
vetor: X X X 4 5 6 7 8 X X
indFrente: 3
numElementos: 5
tamanho: 10

Se quisermos inserir outro elemento nesta fila, deveremos fazer a inserção na penúltima posição
do vetor, isto é, na posição 8. Como era de se esperar, esta posição é exatamente igual a indFrente
+ numElementos.
Considere agora que a fila possui a configuração abaixo:
Outra fila qualquer:
F = 4, 5, 6, 7, 8
vetor: 7 8 X X X X X 4 5 6
indFrente: 7
numElementos: 5
tamanho: 10

Observe que esta fila inicia na posição 7, vai até o final do vetor, volta à posição zero e ocupa
até a posição 1. Se aplicarmos a fórmula anterior, obteremos a posição 12, que é a terceira posição
após a última posição do vetor, posição 9. Se subtrairmos então o tamanho do vetor, obteremos
a posição 2, que é exatamente a primeira posição livre.
Unindo todos estes elementos, chegamos à seguinte implementação do método 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étodos poderiam ser escritos em menos linhas, utilizando a operação de resto da divisão:
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ções abaixo devem funcionar com qualquer implementação de fila, pois respeitam a assina-
tura 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ém 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 43

cout << valorFrente << " ";


F2.retira();
}
}

Este programa deverá 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

Você também pode gostar