You are on page 1of 48

Prof: José Matias Pedro

MATERIAL DE APOIO A DISCIPLINA DE ALGORITMO E ESTRUTURA DE DADOS

1
1

Prof: José Matias Pedro

1. Introdução

O curso de Estruturas de Dados discute diversas técnicas de programação, apresentando as estruturas de dados básicas utilizadas no desenvolvimento de software. O curso também introduz os conceitos básicos da linguagem de programação C, que é utilizada para a implementação das estruturas de dados apresentadas. A linguagem de programação C tem sido amplamente utilizada na elaboração de programas e sistemas nas diversas áreas em que a informática atua, e seu aprendizado tornou-se indispensável tanto para programadores profissionais como para programadores que atuam na área de pesquisa. O conhecimento de linguagens de programação por si só não capacita programadores é necessário saber usá-las de maneira eficiente. O projeto de um programa engloba a fase de identificação das propriedades dos dados e características funcionais. Uma representação adequada dos dados, tendo em vista as funcionalidades que devem ser atendidas, constitui uma etapa

fundamental para a obtenção de programas eficientes e confiáveis. A linguagem C, assim como as linguagens Fortran e Pascal, são ditas linguagens

“convencionais”, projetadas a partir dos elementos fundamentais da arquitetura de

von Neuman, que serve como base para praticamente todos os computadores em uso. Para programar em uma linguagem convencional, precisamos de alguma maneira especificar as áreas de memória em que os dados com que queremos trabalhar estão armazenados e, freqüentemente, considerar os endereços de memória em que os dados se situam, o que faz com que o processo de programação

envolva detalhes adicionais, que podem ser ignorados quando se programa em uma linguagem como Scheme. Em compensação, temos um maior controle da máquina quando utilizamos uma linguagem convencional, e podemos fazer programas melhores, ou seja, menores e mais rápidos. A linguagem C provê as construções fundamentais de fluxo de controle necessárias para programas bem estruturados: agrupamentos de comandos; tomadas de decisão (if-else); laços com testes de encerramento no início (while, for) ou no fim (do-while); e seleção de um dentre um conjunto de possíveis casos (switch). C oferece ainda acesso a apontadores e a habilidade de fazer aritmética com endereços. Por outro lado, a linguagem C não provê operações para manipular diretamente objetos compostos, tais como cadeias de caracteres, nem facilidades de entrada e saída: não há comandos READ e WRITE. Todos esses mecanismos devem ser fornecidos por funções explicitamente chamadas. Embora a falta de algumas dessas facilidades possa parecer uma deficiência grave (deve-se, por exemplo, chamar uma função para comparar duas cadeias de caracteres), a manutenção da linguagem em termos modestos tem trazido benefícios reais. C é uma linguagem relativamente pequena e, no entanto, tornou-se altamente poderosa e eficiente.

2
2

Prof: José Matias Pedro

Armazenamento de dados e programas na memória

A memória do computador é dividida em unidades de armazenamento chamadas bytes. Cada byte é composto por 8 bits, que podem armazenar os valores zero ou um. Nada além de zeros e uns pode ser armazenado na memória do computador. Por esta razão, todas as informações (programas, textos, imagens, etc.) são armazenadas usando uma codificação numérica na forma binária. Na representação binária, os números são representados por uma seqüência de zeros e uns (no nosso dia a dia, usamos a representação decimal, uma vez que trabalhamos com 10 algarismos). Por exemplo, o número decimal 5 é representado por 101, pois 1*2 2 + 0*2 1 + 1*2 0 é igual a 5 (da mesma forma que, na base decimal, 456=4*10 2 + 5*10 1 + 6*10 0 ). Cada posição da memória (byte) tem um endereço único. Não é possível endereçar diretamente um bit. Se só podemos armazenar números na memória do computador, como fazemos para armazenar um texto (um documento ou uma mensagem)? Para ser possível armazenar uma seqüência de caracteres, que representa o texto, atribui-se a cada caractere um código numérico (por exemplo, pode-se associar ao caractere 'A' o código 65, ao caractere 'B' o código 66, e assim por diante). Se todos os caracteres tiverem códigos associados (inclusive os caracteres de pontuação e de formatação), podemos armazenar um texto na memória do computador como uma seqüência de códigos numéricos. Um computador só pode executar programas em linguagens de máquina. Cada programa executável é uma seqüência de instruções que o processador central interpreta, executando as operações correspondentes. Esta seqüência de instruções também é representada como uma seqüência de códigos numéricos. Os programas ficam armazenados em disco e, para serem executados pelo computador, devem ser carregados (transferidos) para a memória principal. Uma vez na memória, o computador executa a seqüência de operações correspondente.

Interpretação versus Compilação

Uma diferença importante entre as linguagens C e Scheme é que, via de regra, elas são implementadas de forma bastante diferente. Normalmente, Scheme é interpretada e C é compilada. Para entender a diferença entre essas duas formas de implementação, é necessário lembrar que os computadores só executam realmente programas em sua linguagem de máquina, que é específica para cada modelo (ou família de modelos) de computador. Ou seja, em qualquer computador, programas em C ou em Scheme não podem ser executados em sua forma original; apenas programas na linguagem de máquina (à qual vamos nos referir como M) podem ser efetivamente executados. No caso da interpretação de Scheme, um programa interpretador (IM), escrito em M, lê o programa PS escrito em Scheme e simula cada uma de suas instruções, modificando os dados do programa da forma apropriada. No caso da compilação da linguagem C, um programa compilador (CM), escrito em M, lê o programa PC, escrito em C, e traduz cada uma de suas instruções para M, escrevendo um programa PM cujo efeito é o desejado. Como conseqüência deste processo, PM, por ser um programa escrito em M, pode ser executado em qualquer máquina com a mesma linguagem de máquina M, mesmo que esta máquina não possua um compilador. Na prática, o programa fonte e o programa objeto são armazenados em arquivos em disco, aos quais nos referimos como arquivo fonte e arquivo objeto.

Execução de um programa com linguagem Interpretada.

PS Programa Fonte Dados de Entrada
PS
Programa Fonte
Dados de Entrada
IM Interpretador Saida 3
IM
Interpretador
Saida
3

Prof: José Matias Pedro

Execução de um programa com linguagem Compilada.

PC Programa Fonte
PC
Programa Fonte
Prof: José Matias Pedro Execução de um programa com linguagem Compilada. PC Programa Fonte CM Compilador
CM Compilador
CM
Compilador
Prof: José Matias Pedro Execução de um programa com linguagem Compilada. PC Programa Fonte CM Compilador

PM Programa Objecto

Dados de Entrada
Dados de Entrada

Execução

PM Programa Objecto

Prof: José Matias Pedro Execução de um programa com linguagem Compilada. PC Programa Fonte CM Compilador
Prof: José Matias Pedro Execução de um programa com linguagem Compilada. PC Programa Fonte CM Compilador
Saida
Saida

Devemos notar que, na 1ª Figura ( Execução de programa com linguagem Interpretada), o

programa fonte é um dado de entrada a mais para o interpretador. No caso da

compilação, na 2ª Figura (Execução de um programa com linguagem compilada), identificamos

duas fases: na primeira, o programa objeto é a saída do programa compilador e, na segunda, o programa objeto é executado, recebendo os dados de entrada e gerando a saída correspondente.

  • 1.1. Lógica de Programação

O que é Lógica?

É a arte de pensar corretamente e, visto que a forma mais complexa do pensamento é o raciocínio, a Lógica estuda ou tem em vista a correção do raciocínio. Podemos ainda dizer que a lógica tem em vista a ordem da razão. Isto dá a entender que a nossa razão pode funcionar desordenadamente, pode pôr as coisas de pernas

para o ar. Por isso a Lógica ensina a colocar Ordem no Pensamento. Exemplo:

  • a) Todo o mamífero é animal.

Todo cavalo é mamífero.

Portanto, todo cavalo é animal.

  • b) Todo mamífero bebe leite.

O homem bebe leite.

Portanto, todo homem é mamífero e

animal.

O que é Algoritmo?

Algoritmo é uma seqüência de passos que visam atingir um objetivo bem definido.” Ou

ainda podemos definir, um algoritmo como uma sequência extremamente precisa de instruções que, quando lida e executada por uma outra pessoa, produz o resultado esperado, isto é, a solução de um problema. Esta sequência de instruções é nada mais nada menos que um registro escrito da sequência de passos necessarios que devem ser executados para manipular informações, ou dados, para se chegar na resposta do problema. Em geral um algoritmo destina-se a resolver um problema: fixa um padrão de comportamento a ser seguido, uma norma de execução a ser trilhada, com o objetivo de alcançar a solução de um problema.

Uma receita de bolo de chocolate é um bom exemplo de um algoritmo.

Bata em uma batedeira a manteiga e o açucar. Junte as gemas uma a uma

4
4

Prof: José Matias Pedro

até obter um creme homogêneo. Adicione o leite aos poucos. Desligue a batedeira e adicione a farinha de trigo, o chocolate em pó, o fermento e reserve. Bata as claras em neve e junte- as á massa de chocolate misturando delicadamente. Unte uma forma retangular pequena com manteiga e farinha e leve para assar em forno médio pré-aquecido por aproximadamente 30 minutos. Desenforme o bolo ainda quente e reserve.

Este é um bom exemplo de algoritmo pois podemos extrair caracteristicas bastante interessantes do texto. Em primeiro lugar, a pessoa que escreveu a receita não é necessariamente a mesma pessoa que vai fazer o bolo. Logo, podemos estabelecer, sem prejuízo, que foi escrita por um mas será executada por outro. Outras características interessantes que estão implicitas são as seguintes:

_ as frases são instruções no modo imperativo: bata isso, unte aquilo. São ordens, não sugestões. Quem segue uma receita obedece quem a escreveu; _ as instruções estão na forma sequencial: apenas uma pessoa executa. Não existem ações simultâneas. _ existe uma ordem para se executar as instruções: primeiro bata a manteiga e o açucar; depois junte as gemas, uma a uma, até acabar os ovos; em seguida adicione o leite. _ algumas instruções não são executadas imediatamente, é preciso entrar em um modo de repetição de um conjunto de outras instruções: enquanto houver ovos não usados, junte mais uma gema. Só pare quando tiver usado todos os ovos. _ algumas outras instruções não foram mencionadas, mas são obviamente necess arias que ocorram: é preciso separar as gemas das claras antes de começar a tarefa de se fazer o bolo, assim como é preciso ainda antes quebrar os ovos. _ algumas instruções, ou conjunto de instruções, podem ter a ordem invertida: pode- se fazer primeiro a massa e depois a cobertura, ou vice-e-versa. Mas nunca se pode colocar no forno a assadeira antes de se chegar ao término do preparo da massa.

Algoritmo para fazer um bolo de chocolate.

início

Providencie todos os ingredientes da receita. Providencie uma forma pequena. Ligue o forno em temperatura média. Coloque a menteiga na batedeira. Coloque o açucar na batedeira. Ligue a batedeira. Enquanto um creme homogêneo não for obtido, junte mais uma gema. Adicione aos poucos o leite. Desligue a batedeira. Adicione a farinha de trigo.

Adicione o chocolate em pó. Adicione o fermento. Reserve a massa obtida em um lugar temporário. Execute o algoritmo para obter as claras em neve.

Junte as claras em neve á massa de chocolate que estava reservada. Misture esta massa delicadamente. Execute o algoritmo para untar a forma com manteiga e farinha. Coloque a forma no forno. Espere 30 minutos. Tire a forma do forno. Desenforme o bolo ainda quente. Separe o bolo em um lugar temporário. Faça a cobertura segundo o algoritmo de fazer cobertura. Coloque a cobertura no bolo.

fim.

5
5

Prof: José Matias Pedro

O que é padrão de comportamento?

Imagine a seguinte seqüência de números: 1, 3, 5, 7, 9, 11 .... Para determinar o sétimo elemento da série, precisamos descobrir qual a sua regra de formatação, isto é, seu padrão de comportamento, (2n+1). Para tal, observamos que a série obedece a uma constância; visto que existe uma diferença constante entre cada elemento, a qual pode ser facilmente determinada, somos capazes de determinar o sétimo e qualquer outro termo.

O que é um programa?

Para que um computador possa desempenhar uma tarefa é necessário que esta seja detalhada passo-a-passo, numa forma compreensível pela máquina, utilizando aquilo que se chama de programa. Neste sentido, um programa de computador nada mais

é que um algoritmo escrito numa forma compreensível pelo computador (linguagem de programação). Ou ainda, um programa é a codificação em alguma linguagem formal que garanta que os passos do algoritmo sejam executados da maneira como se espera por quem executa as instruções. E assim também com os algoritmos escritos para computador, você deve especificar todos os passos, para que o computador possa chegar ao objetivo.

Exemplo1:

Dados os números naturais(N) 0, 1, 2, 3, 4, 5, 6, ... passo1: faça N igual a zero passo2: some 1 a N passo3: volte ao passo 2

Exemplo2:

Soma dos primeiros 100 números naturais:

passo1: faça N igual a zero

passo2:

some 1 a N

passo3: se N for menor ou igual a 100

então volte ao passo 2 senão pare.

Nos dois exemplos acima, o primeiro possui repertório bem definido, mas não finito, enquanto que o segundo tem um critério de parada, ou seja, é finito e descreve um padrão de comportamento, ou seja, temos um algoritmo.

Etapas de um programa

Ao montar um algoritmo, precisamos primeiro dividir o problema apresentado em

três fases fundamentais, como se conceitua “Processamento de Dados”:

Entrada
Entrada
Prof: José Matias Pedro O que é padrão de comportamento? Imagine a seguinte seqüência de números:
Processamento
Processamento
Saida
Saida
Prof: José Matias Pedro O que é padrão de comportamento? Imagine a seguinte seqüência de números:

Entrada: São os dados de entrada do algoritmo. Hardwares para entradas de dados: teclado, mouse, microfone, scanner. Processamento: São os procedimentos utilizados para chegar ao resultado final. Saída: São os dados já processados. Hardwares para saída de dados: impressora, monitor, caixa de som

6
6

Prof: José Matias Pedro

  • 1.2. Formas de Representação de Algoritmos

Existem diversas formas de representação de algoritmos, mas não há um consenso

com relação à melhor delas. Dentre as formas de representação de algoritmos mais conhecidas podemos citar:

Descrição Narrativa;

Fluxograma;

Pseudocódigo, também conhecido como Linguagem Estruturada ou Portugol.

Descrição Narrativa

Nesta forma de representação os algoritmos são expressos diretamente em linguagem natural. Como exemplo tem-se os algoritmo seguinte:

Troca de um pneu furado:

Afrouxar ligeiramente as porcas Suspender o carro Retirar as porcas e o pneu

Colocar o pneu reserva Apertar as porcas Abaixar o carro Dar o aperto final nas porcas

Esta representação é pouco usada na prática porque o uso da linguagem natural muitas vezes dá oportunidade a más interpretações, ambigüidades e imprecisões. Vantagens:

  • - O português é bastante conhecido por nós;

Desvantagens:

  • - Imprecisão;

  • - Pouca confiabilidade (a imprecisão acarreta a desconfiança);

  • - Extensão (normalmente, escreve-se muito para dizer pouca coisa).

Fluxograma

É uma representação gráfica de algoritmos onde formas geométricas diferentes implicam ações (instruções, comandos) distintos. Tal propriedade facilita o entendimento das idéias contidas nos algoritmos e justifica sua popularidade. Esta forma é aproximadamente intermediária à descrição narrativa e ao pseudocódigo, pois é menos imprecisa que a primeira e, no entanto, não se preocupa com detalhes de implementação do programa, como o tipo das variáveis usadas.

Terminal indica início e/ou fim do fluxo de um

programa ou sub-programa.

Prof: José Matias Pedro 1.2. Formas de Representação de Algoritmos Existem diversas formas de representação de

fluxo.

Seta de Fluxo de dados indica o sentido do Serve para conectar os símbolos.

Prof: José Matias Pedro 1.2. Formas de Representação de Algoritmos Existem diversas formas de representação de

Entrada- Operação de entrada de dados.

Processamento operação de atribuição. Indica os

7
7

Prof: José Matias Pedro

cálculos a efetuar, ou atribuições de valores.

Prof: José Matias Pedro cálculos a efetuar, ou atribuições de valores. Saída – operação de saída

Saída operação de saída de dados. Apresenta os dados no monitor (ou algum outro dispositivo de saída).

Prof: José Matias Pedro cálculos a efetuar, ou atribuições de valores. Saída – operação de saída

Decisão (a ser tomada) indicando os desvios para outros pontos do fluxo, dependendo do resultado da comparação.

Prof: José Matias Pedro cálculos a efetuar, ou atribuições de valores. Saída – operação de saída

Preparação grupo de operações não incluídas na diagramação (chave que modificará a execução de um determinado programa).

Prof: José Matias Pedro cálculos a efetuar, ou atribuições de valores. Saída – operação de saída

Conector ao receber duas Setas de Fluxo de dados, normalmente após o fechamento dos processos decorrentes de uma decisão.

Prof: José Matias Pedro cálculos a efetuar, ou atribuições de valores. Saída – operação de saída

Conector de seção quando for necessário particionar o fluxograma. Coloca um número idêntico em ambas as seções / páginas, indicando a sua continuação.

Abaixo está a representação do algoritmo de cálculo da média de um aluno sob a forma de um fluxograma.

Inicio N1
Inicio
N1
N2
N2

Media ←((N1+N2)/2)

1 8
1
8

Prof: José Matias Pedro

Media>=7 “Reprovado” “Aprovado”
Media>=7
“Reprovado”
“Aprovado”

Fim

Exemplo de um fluxograma convencional

De modo geral, um fluxograma se resume a um único símbolo inicial por onde a execução do algoritmo começa, e um ou mais símbolos finais, que são pontos onde a execução do algoritmo se encerra. Partindo do símbolo inicial, há sempre um único caminho orientado a ser seguido, representando a existência de uma única seqüência de execução das instruções. Isto pode ser melhor visualizado pelo fato de que, apesar de vários caminhos poderem convergir para uma mesma figura do diagrama, há sempre um único caminho saindo desta. Exceções a esta regra são os símbolos finais, dos quais não há nenhum fluxo saindo, e os símbolos de decisão, de onde pode haver mais de um caminho de saída (usualmente dois caminhos), representando uma bifurcação no fluxo.

Pseudocódigo

De forma semelhante como os programas são escritos. Também chamado de Portugol ou Português Estruturado. A linguagem de Programação mais próxima é o Pascal, com codificação quase idêntica ao Inglês Estruturado. Esta forma de representação de algoritmos é rica em detalhes, como a definição dos tipos das variáveis usadas no algoritmo. Por assemelhar-se bastante à forma em que os programas são escritos, encontra muita aceitação. A forma geral da representação de um algoritmo na forma de pseudocódigo é a seguinte:

algoritmo <Nome_do_Programa> <declaração_de_variáveis> inicio <corpo do algoritmo> FimAlgoritmo Algoritmo é uma palavra que indica o início da definição de um algoritmo em Portugol ou Pseudocódigo. <Nome_do_Programa> é um nome simbólico dado ao algoritmo com a finalidade de distingui-los dos demais.

9
9

Prof: José Matias Pedro

<declaração_de_variáveis> consiste em uma porção opcional onde são declaradas as variáveis globais usadas no algoritmo principal e, eventualmente, nos subalgoritmos. Início e FimAlgoritmo são respectivamente as palavras que delimitam o início e o término do conjunto de instruções do corpo do algoritmo. Abaixo apresentamos exemplo na forma de um pseudocódigo, da representação do algoritmo do cálculo da média de um aluno.

AlgoritmoCalculoMedia:

var n1, n2, media: real inicio leia n1 leia n2 media ((n1 + n2) / 2) se (media >= 7) entao

escreva (“aprovado”)

senao

escreva (“reprovado”)

fimse

FimAlgoritmo

  • 1.3. Estrutura de Controle

Estrutura Seqüencial

É o conjunto de ações primitivas que serão executadas numa seqüência linear de cima para baixo e da esquerda para direita, isto é, na mesma ordem em que foram escritas. Como podemos perceber, todas as ações devem pular uma linha, o que objetiva separar uma ação de outra.

Variáveis; (declaração de variáveis)

início

comando 1 comando 2 comando 3 FimAlgoritmo.

Algoritmo - 01: Criar um programa que efetue a leitura de dois valores

numéricos. Faça a operação de soma entre os dois valores e apresente o resultado obtido. Planejamento

Problema: Calcular a soma de dois números. Objetivo: Apresentar a soma de dois números. Entradas: N1, N2 Saídas: SOMA

Projeto

Cálculos:

SOMA <- N1 + N2

10
10

Prof: José Matias Pedro

Fluxograma: INICIO
Fluxograma:
INICIO
N1 N2
N1
N2
SOMA← N1+N2 SOMA FIM
SOMA← N1+N2
SOMA
FIM

Portugol:

algoritmo Algoritmo01 var

N1 : real N2 : real SOMA : real

inicio

leia N1 leia N2 SOMA <- N1 + N2 escreva SOMA FimAlgoritmo

Estruturas de Seleção ou Decisão

Uma estrutura de decisão permite a escolha de um grupo de ações e estruturas a ser

executado quando determinadas condições, representadas por expressões lógicas, são ou não satisfeitas.

Decisão Simples

Se <condição> entao

{bloco verdade}

Fimse

<condição> é uma expressão lógica, que, quando inspecionada, pode gerar um resultado falso ou verdadeiro.

Se .V., a ação primitiva sob a cláusula será executada; caso contrário, encerra o comando, neste caso, sem executar nenhum comando.

Algoritmo - 02: Ler dois valores numéricos, efetuar a adição e apresentar o resultado caso o valor somado seja maior que 10.

Planejamento

11
11

Prof: José Matias Pedro

Problema: Calcular a soma de dois números e apresentar o resultado com condição. Objetivo: Apresentar o resultado apenas se for maior que 10. Entradas: N1, N2 Saídas: SOMA Projeto

Cálculos e detalhamento:

SOMA <- N1 + N2 Se (SOMA > 10) entao escreva SOMA

Fimse

Portugol

Algoritmo Algoritmo02

Var

N1: Real

N2: Real

SOMA: Real

Inicio

Leia N1 Leia N2 SOMA <- N1 + N2 Se (SOMA > 10) entao Escreva SOMA

Fimse

FimAlgoritmo

Decisão Composta

Se <condição> entao {bloco verdade}

Senao

{bloco falso}

Fimse <condição> é uma expressão lógica, que, quando inspecionada, pode gerar um resultado falso ou verdadeiro.

Algoritmo - 03: Ler dois valores numéricos, efetuar a adição. Caso o valor

somado seja maior ou igual a 10, este deverá ser apresentado somando-se a ele mais 5, caso o valor somado não seja maior ou igual a 10, este deverá ser apresentado subtraindo-se 7.

Planejamento Problema: Calcular a soma de dois números e apresentar o resultado com uma

condição. Objetivo: Apresentar o Resultado se for igual ou maior que 10 adicionando mais 5, se não for igual ou maior que 10 deverá subtrair 7.

Entradas: A, B Saídas: TOTAL Auxiliares: SOMA Projeto Cálculos e detalhamento:

SOMA <- A + B Se (SOMA >= 10) Então TOTAL <- SOMA + 5

12
12

Prof: José Matias Pedro

Senão

TOTAL <- SOMA - 7

Fimse

Portugol

Algoritmo Algoritmo03

Var

A: Real

B: Real

SOMA: Real

TOTAL: Real

Inicio

Leia A

Leia B

SOMA ← A + B

Se (SOMA >= 10) entao

TOTAL ← SOMA + 5

Senao

TOTAL ← SOMA – 7

Fimse Escreva TOTAL FimAlgoritmo

Seleção Múltipla

Esta estrutura evita que façamos muitos blocos se, quando o teste será sempre em

cima da mesma variável. Exemplo:

Se (variavel = valor1) entao

comando1

Senao

Se (variavel = valor2) entao

Comando2

Senao

 

Se (variavel = valor3) entao

 

Comando3

 

Senao

 

Se (variavel = valor4) entao

Comando4

Senao

comando5

Fimse

 

Fimse

Fimse

Fimse Com a estrutura de escolha múltipla, o algoritmo ficaria da seguinte maneira:

escolha variavel caso valor1

comando1

caso valor2

comando2

caso valor3

13
13

Prof: José Matias Pedro

comando3

caso valor4

comando4

outrocaso

comando5

fimescolha

Estruturas de Repetição

Estas estruturas possibilitam que nosso algoritmo seja muito mais enxuto e fácil de se programar. Imagine um algoritmo de fatorial de 8:

variaveis

fat : real;

início

fat <- 8 * 7 fat <- fat * 6 fat <- fat * 5 fat <- fat * 4 fat <- fat * 3 fat <- fat * 2 escreva (fat) FimAlgoritmo. O resultado será o fatorial com certeza, mas, imagine se fosse o fatorial de 250. Ou ainda, o usuário deseja fornecer o número e o algoritmo deve retornar o fatorial, qual número será digitado? Quantas linhas deverão ser escritas? Para isso servem as estruturas de repetição, elas permitem que um determinado bloco de comandos seja repetido várias vezes, até que uma condição determinada seja satisfeita.

Serão estudadas as seguintes estruturas de repetição:

  • - Enquanto - Faca, o teste é realizado no Início do looping

  • - Repita - Ateque, o teste é realizado no Final do looping

  • - Para - de - ate - Passo - faca, cuja Estrutura é utilizada uma variável de Controle.

Enquanto

...

Faca - Estrutura com teste no Início

Esta estrutura faz seu teste de parada antes do bloco de comandos, isto é, o bloco

de comandos será repetido, até que a condição seja F. Os comandos de uma

estrutura enquanto

..

faca poderá ser executada uma vez, várias vezes ou nenhuma

vez. Enquanto < condição > Faça < bloco de comandos > FimEnquanto

Para

...

de

...

ate

...

Passo

...

faca Estrutura com variável de Controle

Nas estruturas de repetição vistas até agora, acorrem casos em que se torna difícil

determinar quantas vezes o bloco será executado. Sabemos que ele será executado

enquanto uma condição for satisfeita - enquanto

satisfeita - repita

até.

A estrutura para

faça,

ou até que uma condição seja

.. passo repete a execução do bloco um

... número definido de vezes, pois ela possui limites fixos:

..

Para <variável> <- <valor> até <valor> passo N faca

14
14

Prof: José Matias Pedro

< bloco de comandos > FimPara

Saber Lógica de Programação é mais importante que saber uma Linguagem de Programação. Sem saber como se planeja um programa, dificilmente poderá ser implantada tanto na Linguagem de Programação mais antiga como nas mais modernas.

2.1. Dados Homogêneos

Uma estrutura de dados, que utiliza somente um tipo de dado, em sua definição é conhecida como dados homogêneos. Variáveis compostas homogêneas correspondem a posições de memória, identificadas por um mesmo nome, individualizado por índices e cujo conteúdo é composto do mesmo tipo. Sendo os vetores (também conhecidos como estruturas de dados unidimensionais) e as matrizes (estruturas de dados bidimensionais) os representantes dos dados homogêneos.

Vetor

O vetor é uma estrutura de dados linear que necessita de somente um índice para que seus elementos sejam endereçados. É utilizado para armazenar uma lista de valores do mesmo tipo, ou seja, o tipo vetor permite armazenar mais de um valor em

uma mesma variável. Um dado vetor é definido como tendo um número fixo de células idênticas (seu conteúdo é dividido em posições). Cada célula armazena um e somente um dos valores de dados do vetor. Cada uma das células de um vetor possui seu próprio endereço, ou índice, através do qual pode ser referenciada. Nessa estrutura todos os elementos são do mesmo tipo, e cada um pode receber um valor diferente [ 3, 21, 4].

Algumas características do tipo vetor([10]):

Alocação estática (deve-se conhecer as dimensões da estrutura no momento

da declaração) Estrutura homogênea

Alocação seqüencial (bytes contíguos)

Inserção/Exclusão

o

Realocação dos elementos

o

Posição de memória não liberada

Vetor

Nota

Exemplo de um vetor

Prof: José Matias Pedro < bloco de comandos > FimPara Saber Lógica de Programação é mais

9.5

7.4

8.1

4.6

4.5

 

1

2

3

4

5

1 2 3 4 5 Índices.

Índices.

A figura acima mostra um vetor de notas de alunos, a referência NOTA[4] indica o valor 4.6 que se encontra na coluna indicada pelo índice 4.

A definição de um vetor em C se dá pela sintaxe: tipo_do_dado nome_do_vetor[ tamanho_do_vetor ].

Programa : Declaração de vetor em C.

int i[ 3]; i[0]= 21; i[1]= 22; i[ 2]= 24;

15
15

Prof: José Matias Pedro

char c[4];

c[0]=’a’;

c[1]=’b’;

c[ 2]=’c’;

c[ 3]=’d’;

Programa: Exemplo de uso de vetores.

/* programa_vetor*/

#include <stdio.h> #define TAMANHO 5 int main (void)

{

int iIndice; int iValorA; int iSoma; int aVetor [TAMANHO]; float fMedia; for (iIndice = 0; iIndice < TAMANHO; iIndice++)

{ printf("Entre com o valor %d:", iIndice + 1); scanf("%d", &iValorA); aVetor[iIndice] = iValorA; } iSoma = 0;

for (iIndice=0; iIndice < TAMANHO; iIndice++)

{ iSoma += aVetor[iIndice]; }

fMedia = (float) iSoma/TAMANHO;

printf ("Media : %f\n", fMedia);

return 0;

}

Lembrete: Caso seja colocada num programa a instrução a [2]++ está sendo dito que a posição do vetor a será incrementada.

2.1.2- Matriz Uma matriz é um arranjo bidimensional ou multidimensional de alocação estática e seqüencial. A matriz é uma estrutura de dados que necessita de um índice para referenciar a linha e outro para referenciar a coluna para que seus elementos sejam endereçados. Da mesma forma que um vetor, uma matriz é definida com um tamanho fixo, todos os elementos são do mesmo tipo, cada célula contém somente um valor e os tamanhos dos valores são os mesmos (em C, um char ocupa 1 byte e um int 4 bytes) [3 , 21, 4]. Os elementos ocupam posições contíguas na memória. A alocação dos elementos da matriz na memória pode ser feita colocando os elementos linha-por linha ou coluna- por-coluna.

Matriz de 2x2

Matriz M

16
16

Prof: José Matias Pedro

i/j 0 1
i/j
0
1

0

1

Sendo C a quantidade de colunas por linhas, i o número da linha e j a posição do elemento dentro linha, é possível definir a fórmula genérica para acesso na memória,

onde Pos ij = endereço inicial + ((i-1) * C * tamanho do tipo do elemento) + ((j-1) * tamanho do tipo do elemento). Uma matriz consiste de dois ou mais vetores definidos por um conjunto de elementos. Cada dimensão de uma matriz é um vetor. O primeiro conjunto (dimensão) é considerado o primeiro vetor, o segundo conjunto o segundo vetor e assim sucessivamente.

A definição de uma matriz em C se dá pela sintaxe:

tipo_do_dado nome_da_matriz[ quantidade_linhas ] [ quantidade_colunas ]

Matriz Letras

1 2 3 4 5 6
1
2
3
4
5
6
1 M A R C O S 2 N A S S E R 3 D
1
M
A
R
C
O
S
2
N
A
S
S
E
R
3
D
O
N
A
L
D
Linhas

Colunas

A matriz LETRAS é composta de 18 elementos (3 linhas e 6 colunas), a referência a MATRIZ[3][3] (onde o primeiro 3 indica a linha e o segundo 3 indica a coluna) retorna

o elemento ’N’; no caso de MATRIZ[2][5] (segunda linha e terceira coluna) irá retornar o elemento ’E’. Como é uma matriz de strings (linguagem C), a chamada a MATRIZ[3] irá reproduzir o valor “DONALD”.

A linguagem C permite ainda trabalhar com matrizes de várias dimensões (matrizes n-dimensionais), embora o seu uso fique mais restrito em aplicações cientíicas face

à sua pouca praticidade de uso. A definição de uma matriz de várias dimensões em C se dá pela sintaxe:

tipo_do_dado nome_da_matriz[tamanho_dimensão_1] [tamanho_dimensão_2]

[tamanho_dimensão_3]

...

[tamanho_dimensão_n]

Exemplo de uso de Matriz com duas dimensões:

/* programa_matriz */

#include <stdio.h> #define DIMENSAO 2

int main (void)

17
17

Prof: José Matias Pedro

{ int iLinha, iColuna; int iDeterminante; int iValorA; int aMatriz [DIMENSAO][DIMENSAO] /* Uma regra que se pode sempre levar em consideração:

para cada dimensão de uma matriz, sempre haverá um laço (normalmente um for). Se houver duas dimensões, então haverá dois laços. */ for (iLinha=0; iLinha < DIMENSAO; iLinha++) { for (iColuna=0; iColuna < DIMENSAO; iColuna++) { printf ("Entre item %d %d:", iLinha + 1, iColuna + 1); scanf ("%d", &iValorA); matriz [iLinha][iColuna] = iValorA; } } iDeterminante = aMatriz[0][0] * aMatriz [1][1] - aMatriz[0][1] * aMatriz [1][0]; printf ("Determinante : %d\n", iDeterminante); return 0; } Exemplo de uso de Matriz com Varias dimensões:

/* programa_matriz */ #include <stdio.h> #define DIM_1 #define DIM_2 #define DIM_3 #define DIM_4 int main (void) { int i,j,k,l; int aMatriz [DIM_1][DIM_2 ][DIM_3 ][DIM_4]; /* Código para zerar uma matriz de quatro dimensões */ for (i=0; i < DIM_1; i++) { for (j=0; j < DIM_ ; j++) { for (k=0; k < DIM_ ; k++) { for (l=0; l < DIM_4; l++) { aMatriz [i][j][k][l] = i+j+k+l; } } } } /* Uma regra que se pode sempre levar em consideração: para cada dimensão de uma matriz, sempre haverá um laço (normalmente um for). Se houver quatro dimensões então haverá quatro laços */ For (i=0; i < DIM_1; i++) { for (j=0; j < DIM_2 ; j++) { for (k=0; k < DIM_3 ; k++)

18
18

Prof: José Matias Pedro

{ for (l=0; l < DIM_4; l++)

{ printf("\nValor para matriz em [%d] [%d] [%d] [%d] = %d", i,j,k,l, aMatriz[i][j][k][l]); } } } }

return 0;

}

Ponteiro

A linguagem C implementa o conceito de ponteiro. O ponteiro é um tipo de dado como

int, char ou float. A diferença do ponteiro em relação aos outros tipos de dados é que uma variável que seja ponteiro guardará um endereço de memória. Por meio deste endereço pode-se acessar a informação, dizendo que a variável ponteiro aponta para uma posição de memória. O maior problema em relação ao ponteiro é entender quando se está trabalhando com o seu valor, ou seja, o endereço, e quando se está trabalhando com a informação apontada por ele.

Operador & e *

O primeiro operador de ponteiro é &. Ele é um operador unário que devolve o endereço na memória de seu operando. Por exemplo: m = &count; põe o endereço na memória da variável count em m. Esse endereço é a posição interna da variável na memória do computador e não tem nenhuma relação com o valor de count. O operador & tem como significado o endereço de. O segundo operador é *, que é o complemento de &. O * é um operador unário que devolve o valor da variável localizada no endereço que o segue. Por exemplo, se m contém o endereço da variável count: q = *m; coloca o valor de count em q. O operador * tem como significado no endereço de. A declaração de uma variável ponteiro é dada pela colocação de um asterisco (*) na frente de uma variável de qualquer tipo. Na linguagem C, é possível definir ponteiros para os tipos básicos ou estruturas. A definição de um ponteiro não reserva espaço de memória para o seu valor e sim para o seu conteúdo. Antes de utilizar um ponteiro, o mesmo deve ser inicializado, ou seja, deve ser colocado um endereço de memória válido para ser acessado posteriormente. Um ponteiro pode ser utilizado de duas maneiras distintas. Uma maneira é trabalhar com o endereço armazenado no ponteiro e outro modo é trabalhar com a área de memória apontada pelo ponteiro. Quando se quiser trabalhar com o endereço

armazenado no ponteiro, utiliza-se o seu nome sem o asterisco na frente. Sendo assim qualquer operação realizada será feita no endereço do ponteiro. Como, na maioria dos casos, se deseja trabalhar com a memória apontada pelo ponteiro, alterando ou acessando este valor, deve-se colocar um asterisco antes do nome do ponteiro. Sendo assim, qualquer operação realizada será feita no endereço de memória apontado pelo ponteiro. O programa seguinte demostra a utilização de ponteiros para acesso à memória.

/* programa_matriz */ #include <stdio.h> int main (void) { int *piValor; /* ponteiro para inteiro */

19
19

Prof: José Matias Pedro

int iVariavel = 27121975

piValor = &iVariavel; /* pegando o endereço de memória da variável */ printf ("Endereco: %d\n", piValor); printf ("Valor : %d\n", *piValor); *piValor = 180119 82 ; printf ("Valor alterado: %d\n", iVariavel); printf ("Endereco : %d\n", piValor);

return 0;

}

Passando variáveis para funções por referência

O ponteiro é utilizado para passar variáveis por referência, ou seja, variáveis que podem ter seu conteúdo alterado por funções e mantêm este valor após o término da função.

Na declaração de uma função, deve-se utilizar o asterisco antes do nome do parâmetro, indicando que está sendo mudado o valor naquele endereço passado como parâmetro. No programa seguinte é visto um exemplo de uma variável sendo alterada por uma função. Programa : ponteiro como referencia em função

/* programa_ponteiro*/

#include <stdio.h> Void soma (int, int, int *);

int main (void)

{

int iValorA;

int iValorB;

int iResultado;

printf ("Entre com os valores:"); scanf ("%d %d", &iValorA, &iValorB); printf("Endereco de iResultado = %d\n", &iResultado);

Soma (iValorA, iValorB, &iResultado) ;/* está sendo passado o endereço de memória

da variável, qualquer alteração estará sendo realizada na memória */

printf ("Soma : %d\n", iResultado); return 0;

}

void soma (int piValorA, int piValorB, int * piResultado)

{

printf("Endereco de piResultado = %d\n", piResultado);

/* o valor está sendo colocado diretamente na memória */

*piResultado = piValorA + piValorB; return;

}

Acesso Linear Supondo que, para um determinado cálculo, é necessario aceder aos lementos de um vetor, o acesso a esses elementos pode ser feito de forma sequencial desde o primeiro até ao ultimo. Esse é um acesso de tipo linear. Um acesso linear tem um número de iterações proporcional ao número de elementos do vetor, ou seja o numero total de intruções executadas é proporcional ao número de elementos de entrada, sendo, por isso, uma função de ordem linear, ou O(n).

20
20

Prof: José Matias Pedro

O código seguinte calcula a soma dos elementos de um vetor de inteiros. Para obter essa soma, têm que se consultar todos os elementos e acomula-los numa variável. O acesso é linear e sequencial, o numero de iterações executadas pela instrução for é igual ao número de elemento do vetor, ou seja, o número total de instruções executadas é proporcional ao número de elemento do vetor.

Soma dos elementos de um vetor

int soma(int v[ ], int tamanho){ int j, total=0; for(j=0; j<tamanho; j++) total +=v[ j ]; return total; }

Em C, o nome de um vetor é uma variavel que contém o endereço da primeira posição do vetor. Se uma função, como a função anterior, recebe o nome de um vetor por parâmetro, recebe apenas a informação sobre o local em memoria onde o vetor começa, mas não recebe informação sobre o local onde o vetor termina ou sobre o comprimento do vetor. Por este motivo, qualquer função em C que recebe um vetor por parâmetro e precise de o consultar, tem que receber também, por parâmetro, o número de elemento do vetor. No cabeçalho da função anterior int soma (int v[ ], int tamanho), a variável v recebe o nome do vetor e a variável tamanho recebe o número de elementos. Segui-se um programa completo que utiliza a função anterior para calcular a soma dos elementos de um vetor.

# include <stdio.h> int soma (int v[ ], int tamanho) { int j, total=0; for (j=0; j<tamanho; j++) total +=v[ j ]; return total; } int main () { int vtr [10]= {1, 4, 9, 16, 25, 36, 49, 64, 81, 100}; print f (“soma: %d\n”, soma(vtr,10)); }

Na função main do código anterior, a função soma é invocada com os parâmetros vtr e 10, que são o nome do vetor e o seu tamanho.

Pesquisa Linear

No caso de se pretender pesquisar um vetor para averiguar se um determinado valor existe nesse vetor, é necessario percorrer o vetor, de forma sistemática para garantir a correção na pesquisa. A forma mais universal de pesquisa de um vetor é a pesquisa linear: percorre-se o vetor do inicio até ao fim e avalia-se, posição a posição, se o valor ai contido é o valor procurado.

21
21

Prof: José Matias Pedro

No exemplo seguinte a função procura recebe, por parâmetro, o vetor, o seu tamanho e o valor a pesquisar; e devolve o valor 1 no caso de encontrar o valor procurado, e o valor 0 no caso contrario. Note-se que é necessario percorrer o vetor até ao fim para se poder afirmar que o valor não existe, ou seja, a instrução return 0 só é invocado após o ciclo for terminar.

# include<stdio.h> int procura (int v [ ], int tam, int valor){ int i; for (i=0; i<tam; i++) if (v [ i] ==valor) return i; return -1; } int main() { int vtr[10]={5,2,-3,7,10,15,-2,12,8,0}; int posicao, valor;

printf (“Insira um numero a pesquisar:”); scanf(“%d”, &valor);

posicao=procura(vtr,10,valor);

if (posicao!=-1) printf(“Existe na posicao:%d\n”, posicao); else printf(“Não existe\n”); }

A função anterior é de ordem linear, ou O(n). Em média, e caso o valor pesquisado exista no vetor, o número de iterações é n/2. Por exemplo, se o vetor tiver 1000 elementos, são necessarias 500 iterações, em média, para se encontrar um determinado elemento, caso exista.

3.Analise de Complexidade

Algoritmo: sequencia de instruções necessarias para a resolução de um problema bem formulado (passiveis de implementação em computador)

Estrategia:

especificar (definir propriedades) arquitectura (algoritmo e estruturas de dados) analise de complexidade (tempo de execução e memoria) implementar (numa linguagem de programação) testar (submeter entradas e verificar observancia das propriedades especificadas)

  • 3.1. Análise de algoritmos

Provar que um algoritmo esta correcto

Determinar recursos exigidos por um algoritmo (tempo, espaco,etc.)

  • comparar os recursos exigidos por diferentes algoritmos que resolvem o mesmo problema (um algoritmo mais eficiente exige menos recursos para resolver o mesmo problema)

22
22

Prof: José Matias Pedro

prever o crescimento dos recursos exigidos por um algoritmo a medida que o tamanho dos dados de entrada cresce.

Que dados usar ?

dados reais: verdadeira medida do custo de execução

dados aleatorios: assegura-nos que as experiências testam o algoritmo e não apenas os dados especificos.

• Caso medio

dados perversos: mostram que o algoritmo funciona com qualquer tipo de dados

Pior caso!

dados benéficos:

Melhor caso

  • 3.2. Complexidade espacial e temporal

Complexidade espacial de um programa ou algoritmo: espaço de memoria que necessita para executar até ao fim. S(n) : espaço de memoria exigido em função do tamanho n da entrada

Complexidade temporal de um programa ou algoritmo: tempo que demora a executar (tempo de execução) T(n) : tempo de execução em função do tamanho n da entrada

Complexidade - vs. Eficiencia

Por vezes estima-se a complexidade para o “melhor caso” (pouco util), o “pior caso” (mais util) e o “caso medio” (igualmente util)

  • 3.2.1. Complexidade temporal

Analise precisa é uma tarefa complicada algoritmo e implementado numa dada linguagem a linguagem é compilada e o programa é executado num dado computador dificil prever tempos de execução de cada instrução e antever optimizações muitos algoritmos são “sensiveis” aos dados de entrada muitos algoritmos não são bem compreendidos

Para prever o tempo de execução de um programa apenas é necessario um pequeno conjunto de ferramentas matematicas

3.2.2. Notação O-grande Para medir a eficiência de um algoritmo, tanto no tempo de execução como no espaço requerido, utiliza-se frequentemente a notação O-grande, que é uma notação matemática utilizada para analizar o comportamento de funções quando o argumento tende para um valor limite particular ou para infinito. T(n) = O(f(n)) (lê-se: T(n) é de ordem f(n)) se e so se existem constantes positivas c e n0 tal que T(n) ≤ c.f(n) para todo o n > n0 De um modo informal, pode dizer-se que f(n) pode ser representada por O(g(n)) se ambas as funções f e g crescem da mesma forma para valores grandes de n, ou seja f e g são proporcionais. Na tabela seguinte apresenta-se uma lista de classes de funções tipicamente utilizadas para analise de algoritmos, por ordem crescente de crescimento de funções.

23
23

Prof: José Matias Pedro

Ordens tipicas da eficiência de algoritmos

Notação

Nome

O(1)

Ordem constante

O(log n)

 

O(n)

Ordem logaritmica Ordem linear

   

O(n.log n) O(n 2 )

Ordem linear-logaritmica Ordem quadrática

O(n 3 )

Ordem cúbica

O(2 n )

Ordem exponencial

O(n!)

Ordem factorial

3.2.3. Ordens de complexidade mais comuns:

Os Algoritmos tem tempo de execução proporcional a:

1 : muitas instruções são executadas uma so vez ou poucas vezes (se isto acontecer para todo o programa diz-se que o seu tempo de execução é constante) log n : tempo de execução é logaritmico (cresce ligeiramente a medida que n cresce; quando n duplica log n aumenta mas muito pouco; apenas duplica quando n aumenta para n 2 ) n : tempo de execução é linear (tipico quando algum processamento e feito para cada dado de entrada; situação optima quando é necessario processar n dados de entrada, ou produzir n dados na saida) n log n : tipico quando se reduz um problema em subproblemas, se resolve estes separadamente e se combinam as soluções (se n é igual a 1 milhao, n log n é perto de 20 milhões) n 2 : tempo de execução quadratico (tipico quando é necessario processar todos os pares de dados de entrada; pratico apenas em pequenos problemas, ex: produto de vectores) n 3 : tempo de execução cubico (para n = 100, n 3 = 1 milhão, ex: produto de matrizes) 2 n : tempo de execução exponencial (provavelmente de pouca aplicação pratica; tipico em soluções de força bruta; para n = 20, 2 n = 1 milhão; se n duplica, o tempo passa a ser o quadrado)

24
24

Prof: José Matias Pedro

Prof: José Matias Pedro Exemplo de analise de complexidade- findmax() Como calcular o tempo de execução

Exemplo de analise de complexidade- findmax()

Como calcular o tempo de execução do algoritmo seguintes:

int findMax(int A[ ], int n int max =A[ 0]; int i =1; While (i<= n-1){

) {

Prof: José Matias Pedro Exemplo de analise de complexidade- findmax() Como calcular o tempo de execução

2 operações

1 operação

n operações

if (A[i]>max)

if (A[i]>max) 2 ops

2 ops

max =A[i];

max =A[i]; 2 ops

2 ops

i=i+1;

i=i+1;

2 ops

}

return max;

return max;

}

n-1 vez

1 operação

Pressupostos (RAM -Random Access Memory):

memória ilimitada e endereços contêm um número arbitrário ou caracteres,

aceder ao conteúdo de um endereço custa uma unidade de tempo.

max= A[0]; 1 leitura de A[0] + 1 atribuição a max.

Determinar complexidade do findMax() Casos possiveis:

caso mais favorável (A[0] é o maior elemento):

t(n) = 2 + 1 + n + 4(n 1) + 1 = 5n operações primitivas

pior caso:

t(n) = 2 + 1 + n + 6(n 1) + 1 = 7n 2

caso médio? difícil de calcular, depende da distribuição do input; usar teoria de probabilidades.

25
25

Prof: José Matias Pedro

Normalmente, olharemos para o pior caso, pois dá-nos um limite superior do tempo de execução.

Calculo do tempo de execução seja a o tempo observado para a operação primitiva mais rápida seja b o tempo observado para a operação primitiva mais lenta então, o pior tempo de execução T(n) será a(7n2) <=T(n) <=b(7n2) ) T(n) limitado por 2 funções lineares

Taxa de crescimento do tempo de execução

Saliente-se que:

o tempo de execução, T(n), pode ser afectado pela alteração do ambiente hardware/software, mas tal não acontece se considerarmos a taxa de crescimento de T(n). Taxa de crescimento de T(n) corresponde

ao crescimento de T(n) quando se aumenta o valor de n.

O exemplo findMax() mostrava T(n) limitado por 2 funções lineares em n, significando que o tempo de execução varia na mesma proporção que n. Logo, diz-se que o crescimento de T(n) é linear.

Como escolher um algoritmo? Tempo de processamento

Um algoritmo que realiza uma tarefa em 10 horas é melhor que outro que realiza em 10 dias.

Quantidade de memória necessária

Prof: José Matias Pedro Normalmente, olharemos para o pior caso, pois dá-nos um limite superior do

Um algoritmo que usa 1MB de memória RAM é melhor que outro que usa 1GB

Estudar número de vezes que as operações são executadas

Exemplo- tempo de processamento Achar o máximo de um vetor

Prof: José Matias Pedro Normalmente, olharemos para o pior caso, pois dá-nos um limite superior do

int vmax(int *vec, int n) {

-

int i;

-

int max = vec[0];

1

for (i = 1; i < n; i++) {

n-1

if (vec[i] > max) {

n-1

max = vec[i];

A<n-1

}

n-1

}

n-1

return max;

1

}

Complexidade: f(n) = n-1 Esse algoritmo é ótimo

Analise de tempo de processamento

Análise de complexidade feita em função de n

  • n indica o tamanho da entrada

26
26

Prof: José Matias Pedro

 

Número de elementos no vetor

Número de vértices num grafo

Número de linhas de uma matriz

Diferentes entradas podem ter custo diferente

  • Melhor caso

  • Pior caso

  • Caso médio

    • 4. Tecnicas Gerais de projecto de Algoritmos

4.1.

Recursividade

Recursão é o processo de definir algo em termos de si mesmo e é, algumas vezes,

chamado de definição circular. Assim, pode-se dizer que o conceito de algo recursivo está dentro de si, que por sua vez está dentro de si e assim sucessivamente, infinitamente. O exemplo a seguir define o ancestral de uma pessoa:

Os pais de uma pessoa são seus ancestrais (caso base);

Os pais de qualquer ancestral são também ancestrais da pessoa inicialmente considerada (passo recursivo). Definições como estas são normalmente encontradas na matemática. O grande apelo que o conceito da recursão traz é a possibilidade de dar uma definição finita para um conjunto que pode ser infinito. Um exemplo aritmético:

O primeiro número natural é zero. O sucessor de um número natural é um número natural. Na computação o conceito de recursividade é amplamente utilizado, mas difere da recursividade típica por apresentar uma condição que provoca o fim do ciclo recursivo. Essa condição deve existir, pois, devido às limitações técnicas que o computador apresenta, a recursividade é impedida de continuar eternamente.

Função para cálculo de Fatorial

Na linguagem C, as funções podem chamar a si mesmas. A função é recursiva se um comando no corpo da função a chama. Para uma linguagem de computador ser

recursiva, uma função deve poder chamar a si mesma. Um exemplo simples é a função fatorial, que calcula o fatorial de um inteiro. O fatorial de um número N é o produto de todos os números inteiros entre 1 e N. Por exemplo,3 fatorial (ou 3!) é 1 * 2 *3 = 6. O programa abaixo apresenta uma versão iterativa para cálculo do fatorial de um número.

Programa: fatorial (versão iterativa) int fatorialc ( int n ) {

int t, f; f = 1; for (t = 1; t<=n; t++) f = f * t

return f;

}

Mas multiplicar n pelo produto de todos os inteiros a partir de n-1 até 1 resulta no produto de todos os inteiros de n a 1. Portanto, é possível dizer que fatorial:

0! = 1 1! = 1 * 0! 2! = 2* 1!

27
27

Prof: José Matias Pedro

3! = 3* 2 ! 4! = 4* 3 ! Logo o fatorial de um número também pode ser definido recursivamente (ou por recorrência) através das seguintes regras (representação matemática):

n! = 1, se n = 0

n! = n * (n-1)! , se n > 0 O programa seguinte mostra a versão recursiva do programa fatorial.

int fatorialr( int n) {

int t, f; /* condição de parada */ if( n == 1 || n == 0) { return 1; } f = fatorialr(n-1)*n; /* chamada da função */ return f;

}

A versão não-recursiva de fatorial deve ser clara. Ela usa um laço que é executado de 1 a n e multiplica progressivamente cada número pelo produto móvel. A operação de fatorial recursiva é um pouco mais complexa. Quando fatorialr é chamada com um argumento de 1, a função devolve 1. Caso contrário, ela devolve o produto de fatorialr(n-1)*n. Para avaliar essa expressão, fatorialr é chamada com n- 1. Isso acontece até que n se iguale a 1 e as chamadas à função comecem a retornar. Calculando o fatorial de 2, a primeira chamada a fatorialr provoca uma segunda chamada com o argumento 1. Essa chamada retorna 1, que é, então, multiplicado por

  • 2 (o valor original e n). A resposta então é 2.

Para melhor entendimento, é interessante ver como o programa é executado internamente no computador. No caso do programa iterativo (programa seguinte) é necessário duas variáveis f e t para armazenar os diversos passos do processamento. Por exemplo, ao calcular fatorial de 6, o computador vai passar sucessivamente pelos seguintes passos (tabela ). Tabela : Cálculo de fatorial de 6

t

f

1

1

2

2

3

6

4

24

5

120

6

720

No programa recursivo nada disto acontece. Para calcular o fatorial de 6, o computador tem de calcular primeiro o fatorial de 5 e só depois é que faz a

multiplicação de 6 pelo resultado (120). Por sua vez, para calcular o fatorial de 5 , vai ter de calcular o fatorial de 4. Resumindo, aquilo que acontece internamente é uma expansão seguida de uma contração:

fatorialr(6)

  • 6 * fatorialr(5)

28
28

Prof: José Matias Pedro

  • 6 * 5 * fatorialr(4)

  • 6 * 5 * 4 * fatorialr(3)

  • 6 * 5 * 4 * 3 * fatorialr(2)

  • 6 * 5 * 4 * 3 * 2 * fatorialr(1)

  • 6 * 5 * 4 * 3 * 2 * 1

  • 6 * 5 * 4 * 3 * 2

  • 6 * 5 * 4 * 6

  • 6 * 5 * 24

  • 6 * 120

720

Quando uma função chama a si mesma, novos parâmetros e variáveis locais são alocados na pilha e o código da função é executado com essas novas variáveis. Uma chamada recursiva não faz uma nova cópia da função; apenas os argumentos são novos. Quando cada função recursiva retorna, as variáveis locais e os parâmetros são removidos da pilha e a execução recomeça do ponto da chamada à função dentro da função.

Números de Fibonacci

Fibonacci (matemático da Renascença italiana) estabeleceu uma série curiosa de números para modelar o número de casais de coelhos em sucessivas gerações. Assumindo que nas primeiras duas gerações só existe um casal de coelhos, a seqüência de Fibonacci é a seqüência de inteiros: 1, 1, 2, 3 , 5 , 8, 13 , 21,3 4, .... No programa seguinte é mostrada uma versão iterativa para calcular o n-ésimo termo da seqüência de Fibonacci.

Programa: Cálculo do n-ésimo termo de Fibonacci (versão iterativa)

int fibc (int n) {

int l, h, x, i; if ( n <=2 ) return 1; l = 0; h = 1; for(i=2 ; i<= n; i++) {

/* Cálculo do próximo número da seqüência. */

}

return h;

x = l; l = h; h = x + l;

} O n-ésimo número é definido como sendo a soma dos dois números anteriores.

Logo, fazendo a definição recursiva:

fib(n) = n se n <=

fib(n) = ib(n- ) + ib(n-1) se n >

A sua determinação recursiva impõe o cálculo direto do valor para dois elementos de base (a primeira e a segunda geração). No programa seguintes é mostrada a versão recursiva para calcular o n-ésimo termo da seqüência de Fibonacci. Programa: Cálculo do n-ésimo termo de Fibonacci (versão recursiva)

int fibr (int n )

29
29

Prof: José Matias Pedro

{

 

if ( n <= ) { return 1; }

/* chama a si próprio 2 vezes!!! */

return fibr (n-1) + fibr (n-2 );

}

Esta solução (programa: Cálculo do n-ésimo termo de Fibonacci (versão recursiva) ) é muito mais simples de programar do que a versão iterativa (programa: Cálculo do

n-ésimo termo de Fibonacci (versão iterativa)). Contudo, esta versão é ineficiente, pois

cada vez que a função fibr é chamada, a dimensão do problema reduz-se apenas uma unidade (de n para n-1), mas são feitas duas chamadas recursivas. Isto dá origem a uma explosão combinatorial e o computador acaba por ter de calcular o mesmo termo várias vezes. Para calcular fibr (5) é necessário calcular fibr (4) e fibr (3). Conseqüentemente, para calcular fibr(4) é preciso calcular fibr (3) e fibr (2). E assim sucessivamente. Este tipo de processamento é inadequado, já que o computador é obrigado a fazer trabalho desnecessário. No exemplo, usando o programa(Cálculo do n-ésimo termo de Fibonacci (versão recursiva)), para calcular fibr(5) foi preciso calcular fibr(4) 1 vez, fibr(3) 2 vezes, fibr(2) 3 vezes e fibr(1) 2 vezes. No programa iterativo (programa:

Cálculo do n-ésimo termo de Fibonacci (versão iterativa), apenas era necessário calcular

fibc(5), fibc(4), fibc(3), fibc(2) e fibc(1) 1 vez.

Dados Heterogêneos

Uma estrutura de dados é chamada de heterogênea quando envolve a utilização de mais de um tipo básico de dado (inteiro ou caractere, por exemplo) para representar uma estrutura de dados. Normalmente, este tipo de dado é chamado de registro. Um registro é uma estrutura de dados que agrupa dados de tipos distintos ou, mais raramente, do mesmo tipo. Um registro de dados é composto por certo número de campos de dados, que são itens de dados individuais. Registros são conjuntos de dados logicamente relacionados, mas de tipos diferentes (numéricos, lógicos, caractere etc). O conceito de registro visa facilitar o agrupamento de variáveis que não são do mesmo tipo, mas que guardam estreita relação lógica. Registros correspondem a conjuntos de posições de memória conhecidos por um mesmo nome e individualizados por identiicadores associados a cada conjunto de posições. O registro é um caso mais geral de variável composta na qual os elementos do conjunto não precisam ser, necessariamente, homogêneos ou do mesmo tipo. O registro é constituído por componentes. Cada tipo de dado armazenado em um registro é chamado de campo. A linguagem C utiliza as estruturas para representar um registro. Com a estrutura definida pode-se fazer atribuição de variáveis do mesmo tipo de maneira simpliicada. Programa: Exemplo de estrutura

struct Funcionario { char nome [40]; struct

{

30
30

Prof: José Matias Pedro

int dia; int mes; int ano; } dataNasc; char departamento[10]; float salario; };

Para se fazer o acesso de um único campo deve-se utilizar o nome da estrutura seguido de um ponto e do nome do campo desejado da estrutura. A linguagem C também permite que seja criado um vetor de estruturas (programa). Programa : Exemplo de uso de estruturas com vetores

/* programa_estrutura */ #include <stdio.h> struct DADO

{

char sNome[40];

int iIdade;

};

int main(void)

{ Struct DADO sDados [5]; /* A estrutura é dividida em duas partes por um ponto (.). Tem-se o nome da estrutura à esquerda e o nome do campo à direita. Neste exemplo, como está sendo manipulado um vetor de estruturas, também tem índice para cada linha do vetor. */ for(iIndice=0;iIndice<5 ;iIndice++) { printf("\nEntre com o Nome ->" ); scanf("%s", &sDados[iIndice].sNome ); printf("Entre com a Idade ->" ); scanf("%d", &sDados[iIndice].iIdade ); } for(iIndice=0;iIndice<5 ;iIndice++) { printf("\n%s tem %d anos", sDados[iIndice].sNome, sDados[iIndice].iIdade); }

return;

} Lembrete: Estruturas são utilizadas para referenciar múltiplos tipos de dados.

Programa: inserção numa lista # include<stdio.h> # include<stdlib.h> # include <string.h> #define MAX 60 #define LINHA 100 Struct aluno { Int numero; Char nome [MAX]; Struct aluno * prox; }; Typedef struct aluno ALUNO; Typedef ALUNO * Paulo;

31
31

Prof: José Matias Pedro

  • 5. Estruturas de Dados Dinâmicos Lineares

5.1.

Listas

As listas são estruturas lineares de tamanho variavel, o que confere uma grande flexibilidade para acomodar novos elementos, mas em contrapartida têm tempo de Acesso relactivamente elevados comparando com as estruturas vetoriais. Os elementos de uma lista são blocos autónomos que contém dados e informação de ligação para os elementos adjacentes. As listas podem ser simplesmente ligadas, contendo apenas o endereço do elemento seguinte; ou duplamente ligadas, contendo o endereço do elemento anterior e do elemento seguinte.

As listas simplesmente ligadas têm uma estrutura mais simples, mas em certos casos, obrigam a processamento mais morosos. Na linguagem C, os vetores podem ser utilizados para representar uma lista, mas a lista também pode ser implementada através de estruturas com alocação dinâmica de memória. Na implementação de listas em C normalmente são utilizadas estruturas que, além de conter as informações necessárias - códigos, nomes etc. -, terão campos adicionais para controle da própria lista. Os campos adicionais de controle de lista são ponteiros para a própria lista (isto é possível através da capacidade da linguagem de definição recursiva). Na manipulação de lista simples é exigida a criação de um ponteiro para manter o início da lista. Considere-se que se pretende criar uma lista de alunos apenas com informação sobre o seu número de alunos e nome. A estrutura de cada elemento da lista pode ter o formato apresentado no código seguinte.

struct aluno { int numero; char nome [60]; Struct aluno* prox; }; Typedef struct aluno ALUNO; No código anterior é criada a estrutura struct aluno com o campo prox que indica qual o proximo elemento da lista. É também criado o nome ALUNO que pode ser usado para substituir o tipo struct aluno

Gestão de uma Lista

como foi explicado anteriormente, uma lista é um conjunto de elementos ligados entre si por um campo que contém o endereço do proximo elemento. Mas faltam ainda resolver os dois problemas seguintes:

Como aceder ao primeiro elemento da lista

Para onde apontar o campo prox do ultimo elemento da lista.

Para aceder ao primeiro elemento da lista é necessário criar uma variavel que armazene o endereço do primeiro elemento. Por outro lado, o campo prox do ultimo elemento tem que conter um valor que indique que não existem mais elementos na lista.

Inserção no inicio da lista

Uma lista é uma estrutura de dados de dimensão variavel, que pode crescer ou

diminuir durante a execução de um programa. Os elementos de uma lista são

32
32

Prof: José Matias Pedro

colocados num espaço de memória fora do espaço atribuido inicialmente ao programa.

Impressão da lista

para imprimir sequencialmente todos os elementos de uma lista, pode percorrer-se a lista do inicio até ao fim e imprimir cada elemento. A solução usual, nestes casos, é utilizar um apontador auxiliar que, de inicio, tem o endereço da cabeça da lista, e depois vai recebendo sucessivamente o endereço do elemento seguinte

inserção no fim da lista

para inserir um elemento no fim de uma lista é necessário percorrer a lista desde o inicio até ao último elemento.

Multiplas listas

A solução anterior foi criada com o fito de ser simples, mas peca por falta de generecidade. Com essa solução, no caso de se pretender trabalhar com duas listas de alunos em simultâneo apontadas, por exemplo, pelas variáveis turma1 e turma2, é necessario duplicar as funções de inserção e impressão e substituir numas a variável cabeça por turma1 e nas outras por turma2. Para resolver este problema de falta de generecidade, deve passar-se a cabeça da lista, por parâmetro, para as funções de inserção e impressão. A função de impressão da lista apenas consulta a cabeça não a modifica portanto não levanta problemas adicionais.

Remoção

A remoção de um elemento de uma lista duplamente ligada é uma operação relactivamente simples de executar. Note-se que é necessário tratar de forma diferente os casos extremos de remoção do primeiro elemento e do último elemento de uma lista.

5.2.

Pilha

Uma das estruturas de dados mais simples é a pilha. Possivelmente por essa razão, é a estrutura de dados mais utilizada em programação, sendo inclusive implementada diretamente pelo hardware da maioria das máquinas modernas. A idéia fundamental da pilha é que todo o acesso a seus elementos é feito através do seu topo. Assim, quando um elemento novo é introduzido na pilha, passa a ser o elemento do topo, e o único elemento que pode ser removido da pilha é o do topo. Isto faz com que os elementos da pilha sejam retirados na ordem inversa à ordem em que foram introduzidos: o primeiro que sai é o último que entrou (a sigla LIFO last in, first out é usada para descrever esta estratégia). Para entendermos o funcionamento de uma estrutura de pilha, podemos fazer uma analogia com uma pilha de pratos. Se quisermos adicionar um prato na pilha, o colocamos no topo. Para pegar um prato da pilha, retiramos o do topo. Assim, temos que retirar o prato do topo para ter acesso ao próximo prato. A estrutura de pilha funciona de maneira análoga. Cada novo elemento é inserido no topo e só temos acesso ao elemento do topo da pilha. Existem duas operações básicas que devem ser implementadas numa estrutura de pilha: a operação para empilhar um novo elemento, inserindo-o no topo, e a operação para desempilhar um elemento, removendo-o do topo. É comum nos referirmos a essas duas operações pelos termos em inglês push (empilhar) e pop (desempilhar).

33
33

Prof: José Matias Pedro

Interface do tipo pilha

Neste capítulo, consideraremos duas implementações de pilha: usando vetor e usando lista encadeada. Para simplificar a exposição, consideraremos uma pilha que armazena valores reais. Independente da estratégia de implementação, podemos definir a interface do tipo abstrato que representa uma estrutura de pilha. A interface é composta pelas operações que estarão disponibilizadas para manipular e acessar as informações da pilha. Neste exemplo, vamos considerar a implementação de cinco operações:

criar uma estrutura de pilha; inserir um elemento no topo (push); remover o elemento do topo (pop); verificar se a pilha está vazia; liberar a estrutura de pilha. O arquivo pilha.h, que representa a interface do tipo, pode conter o seguinte código:

typedef struct pilha Pilha; Pilha* cria (void); Void push (Pilha* p, float v); float pop (Pilha* p); int vazia (Pilha* p); void libera (Pilha* p); A função cria aloca dinamicamente a estrutura da pilha, inicializa seus campos e retorna seu ponteiro; as funções push e pop inserem e retiram, respectivamente, um valor real na pilha; a função vazia informa se a pilha está ou não vazia; e a função libera destrói a pilha, liberando toda a memória usada pela estrutura.

Implementação de pilha com vetor

Em aplicações computacionais que precisam de uma estrutura de pilha, é comum sabermos de antemão o número máximo de elementos que podem estar armazenados simultaneamente na pilha, isto é, a estrutura da pilha tem um limite conhecido. Nestes casos, a implementação da pilha pode ser feita usando um vetor. A implementação com vetor é bastante simples. Devemos ter um vetor (vet) para armazenar os elementos da pilha. Os elementos inseridos ocupam as primeiras

posições do vetor. Desta forma, se temos n elementos armazenados na pilha, o elemento vet[n-1] representa o elemento do topo. A estrutura que representa o tipo pilha deve, portanto, ser composta pelo vetor e pelo número de elementos armazenados.

#define MAX 50 Struct pilha { int n; float vet[MAX];

}; A função para criar a pilha aloca dinamicamente essa estrutura e inicializa a pilha como sendo vazia, isto é, com o número de elementos igual a zero.

Pilha* cria (void) {

Pilha* p = (Pilha*) malloc(sizeof(Pilha)); p->n = 0; /* inicializa com zero elementos */ return p;

}

34
34

Prof: José Matias Pedro

Para inserir um elemento na pilha, usamos a próxima posição livre do vetor. Devemos ainda assegurar que exista espaço para a inserção do novo elemento, tendo em vista que trata-se de um vetor com dimensão fixa.

void push (Pilha* p, float v) {

if (p->n == MAX) { /* capacidade esgotada */ printf("Capacidade da pilha estourou.\n"); exit(1); /* aborta programa */ } /* insere elemento na próxima posição livre */ p->vet[p->n] = v; p->n++;

}

A função pop retira o elemento do topo da pilha, fornecendo seu valor como retorno.

Podemos também verificar se a pilha está ou não vazia.

float pop (Pilha* p) {

float v; if (vazia(p)) { printf("Pilha vazia.\n"); exit(1); /* aborta programa */ } /* retira elemento do topo */ v = p->vet[p->n-1]; p->n--; return v;

}

A função que verifica se a pilha está vazia pode ser dada por:

int vazia (Pilha* p) {

return (p->n == 0);

}

Finalmente, a função para liberar a memória alocada pela pilha pode ser:

void libera (Pilha* p) {

}

free(p);

Implementação de pilha com lista

Quando o número máximo de elementos que serão armazenados na pilha não é conhecido, devemos implementar a pilha usando uma estrutura de dados dinâmica, no caso, empregando uma lista encadeada. Os elementos são armazenados na lista

e a pilha pode ser representada simplesmente por um ponteiro para o primeiro nó da lista. O nó da lista para armazenar valores reais pode ser dado por:

struct no { float info; struct no* prox; }; typedef struct no No;

A estrutura da pilha é então simplesmente:

35
35

Prof: José Matias Pedro

struct pilha { No* prim; };

A função cria aloca a estrutura da pilha e inicializa a lista como sendo vazia.

Pilha* cria (void) {

Pilha* p = (Pilha*) malloc(sizeof(Pilha)); p->prim = NULL; return p;

}

O primeiro elemento da lista representa o topo da pilha. Cada novo elemento é inserido no início da lista e, conseqüentemente, sempre que solicitado, retiramos o elemento também do início da lista. Desta forma, precisamos de duas funções auxiliares da lista: para inserir no início e para remover do início. Ambas as funções retornam o novo primeiro nó da lista.

/* função auxiliar: insere no início */ No* ins_ini (No* l, float v) { No* p = (No*) malloc (sizeof (No)); p->info = v; p->prox = l; return p;

} /* função auxiliar: retira do início */ No* ret_ini (No* l) {

No* p = l->prox; free (l); return p;

}

As funções que manipulam a pilha fazem uso dessas funções de lista:

void push (Pilha* p, float v) {

p->prim = ins_ini(p->prim,v);

} float pop (Pilha* p) {

float v; if (vazia(p)) { printf("Pilha vazia.\n"); exit(1); /* aborta programa */

} v = p->prim->info; p->prim = ret_ini(p->prim); return v; }

A pilha estará vazia se a lista estiver vazia:

int vazia (Pilha* p) {

return (p->prim==NULL);

36
36

Prof: José Matias Pedro

}

Por fim, a função que libera a pilha deve antes liberar todos os elementos da lista.

void libera (Pilha* p) {

No* q = p->prim; while (q!=NULL) { No* t = q->prox; free (q); q = t;

}

 

free(p);

}

A rigor, pela definição da estrutura de pilha, só temos acesso ao elemento do topo.

No entanto, para testar o código, pode ser útil implementarmos uma função que imprima os valores armazenados na pilha. Os códigos abaixo ilustram a implementação dessa função nas duas versões de pilha (vetor e lista). A ordem de impressão adotada é do topo para a base.

/* imprime: versão com vetor */ void imprime (Pilha* p) { int i; for (i=p->n-1; i>=0; i--) printf("%f\n",p->vet[i]);

} /* imprime: versão com lista */ void imprime (Pilha* p) {

No* q; for (q=p->prim; q!=NULL; q=q->prox) printf("%f\n",q->info);

}

5.3.

Filas

Uma fila é um conjunto ordenado de itens a partir do qual se podem eliminar itens

numa extremidade - início da fila - e no qual se podem inserir itens na outra extremidade - final da fila. Ela é uma prima próxima da pilha, pois os itens são inseridos e removidos de cordo

com o princípio de que o primeiro que entra é o primeiro que sai -first in, first out (FIFO). O

conceito de fila existe no mundo real, vide exemplos como filas de banco, pedágios, restaurantes etc. As operações básicas de uma fila são:

insert ou enqueue - insere itens numa fila (ao final).

remove ou dequeue - retira itens de uma fila (primeiro item).

empty - verifica se a fila está vazia.

size - retorna o tamanho da fila.

front - retorna o próximo item da fila sem retirar o mesmo da fila.

A operação insert ou enqueue sempre pode ser executada, uma vez que teoricamente uma fila não tem limite. A operação remove ou dequeue só pode ser aplicado se a fila não estiver vazia, causando um erro de underlow ou fila vazia se esta operação for realizada nesta situação.

37
37

Prof: José Matias Pedro

Filas em C

A exemplo do que ocorre com estrutura em pilha, antes de programar a solução de um problema que usa uma fila, é necessário determinar como representar uma fila usando as estruturas de dados existentes na linguagem de programação. Novamente na linguagem C podemos usar um vetor. Mas a fila é uma estrutura dinâmica e pode crescer infinitamente, enquanto que um vetor na linguagem C tem um tamanho fixo. Contudo, pode-se definir este vetor com um tamanho suficientemente grande para conter a fila.

5.4.

Árvores

Nos capítulos anteriores examinamos as estruturas de dados que podem ser chamadas de unidimensionais ou lineares, como vetores e listas. A importância dessas estruturas é inegável, mas elas não são adequadas para representarmos dados que devem ser dispostos de maneira hierárquica. Por exemplo, os arquivos (documentos) que criamos num computador são armazenados dentro de uma estrutura hierárquica de diretórios (pastas). Existe um diretório base dentro do qual podemos armazenar diversos subdiretórios e arquivos. Por sua vez, dentro dos sub- diretórios, podemos armazenar outros sub-diretórios e arquivos, e assim por diante, recursivamente. A Figura seguinte mostra uma imagem de uma árvore de diretório no Windows 2000.

Prof: José Matias Pedro Filas em C A exemplo do que ocorre com estrutura em pilha,

Figura: Um exemplo de árvore de diretório.

Neste capítulo, vamos introduzir árvores, que são estruturas de dados adequadas para a representação de hierarquias. A forma mais natural para definirmos uma estrutura de árvore é usando recursividade. Uma árvore é composta por um conjunto de nós. Existe um nó r, denominado raiz, que contém zero ou mais sub-árvores, cujas raízes são ligadas diretamente a r. Esses nós raízes das sub-árvores são ditos filhos do nó pai, r. Nós com filhos são comumente chamados de nós internos e nós que não têm filhos são chamados de folhas, ou nós externos. É tradicional desenhar as árvores com a raiz para cima e folhas para baixo, ao contrário do que seria de se esperar.

Prof: José Matias Pedro Filas em C A exemplo do que ocorre com estrutura em pilha,

Sub-árvores

Estrutura de árvore

38
38

Prof: José Matias Pedro

Observamos que, por adotarmos essa forma de representação gráfica, não representamos explicitamente a direção dos ponteiros, subentendendo que eles apontam sempre do pai para os filhos.

Àrvores AVL

As árvores AVL são árvores binarias ordenadas e equilibradas. O nome AVL é

composto pelas iniciais dos apelidos dos matemáticos G.M.Adelson-Velskii e E. M. Landis que propuseram este tipo de estruturas pela primeira vez, em 1962.

Árvores binárias

Um exemplo de utilização de árvores binárias está na avaliação de expressões. Como

trabalhamos com operadores que esperam um ou dois operandos, os nós da árvore para representar uma expressão têm no máximo dois filhos. Nessa árvore, os nós folhas representam operandos e os nós internos operadores. Uma árvore que representa, por exemplo a expressão (3+6)*(4-1)+5 é ilustrada na Figura seguinte.

+ * 5 + - 6 4 1 3 Árvore da expressão: (3+6) * (4-1) +
+
*
5
+
-
6
4
1
3
Árvore da expressão: (3+6) * (4-1) + 5.

Numa árvore binária, cada nó tem zero, um ou dois filhos. De maneira recursiva, podemos definir uma árvore binária como sendo:

• uma árvore vazia; ou • um nó raiz tendo duas sub-árvores, identificadas como a sub-árvore da direita (sad) e a sub-árvore da esquerda (sae). A Figura a seguir ilustra uma estrutura de árvore binária. Os nós a, b, c, d, e, f formam uma árvore binária da seguinte maneira: a árvore é composta do nó a, da subárvore à esquerda formada por b e d, e da sub-árvore à direita formada por c, e e f. O nó a representa a raiz da árvore e os nós b e c as raízes das sub-árvores. Finalmente, os nós d, e e f são folhas da árvore.

39
39

Prof: José Matias Pedro

a b c d e f Exemplo de árvore binária
a
b
c
d
e
f
Exemplo de árvore binária

Representação em C

Análogo ao que fizemos para as demais estruturas de dados, podemos definir um tipo para representar uma árvore binária. Para simplificar a discussão, vamos considerar que a informação que queremos armazenar nos nós da árvore são valores de caracteres simples. Vamos inicialmente discutir como podemos representar uma estrutura de árvore binária em C. Que estrutura podemos usar para representar um nó da árvore?

Cada nó deve armazenar três informações: a informação propriamente dita, no caso um caractere, e dois ponteiros para as sub-árvores, à esquerda e à direita. Então a estrutura de C para representar o nó da árvore pode ser dada por:

struct arv { char info; struct arv* esq; struct arv* dir;

};

Da mesma forma que uma lista encadeada é representada por um ponteiro para o primeiro nó, a estrutura da árvore como um todo é representada por um ponteiro para

o nó raiz.

Como acontece com qualquer TAD (tipo abstrato de dados), as operações que fazem sentido para uma árvore binária dependem essencialmente da forma de utilização que se pretende fazer da árvore. Nas funções que se seguem, consideraremos que existe o tipo Arv definido por:

typedef struct arv Arv;

Como veremos as funções que manipulam árvores são, em geral, implementadas de forma recursiva, usando a definição recursiva da estrutura. Vamos procurar identificar e descrever apenas operações cuja utilidade seja a mais geral possível. Uma operação que provavelmente deverá ser incluída em todos os casos é a inicialização de uma árvore vazia. Como uma árvore é representada pelo endereço do nó raiz, uma árvore vazia tem que ser representada pelo valor NULL. Assim, a função que inicializa uma árvore vazia pode ser simplesmente:

Arv* inicializa(void) {

return NULL; } Para criar árvores não vazias, podemos ter uma operação que cria um nó raiz dadas

a informação e suas duas sub-árvores, à esquerda e à direita. Essa função tem como valor de retorno o endereço do nó raiz criado e pode ser dada por:

Arv* cria(char c, Arv* sae, Arv* sad){ Arv* p= (Arv*) malloc (sizeof (Arv));

40
40

Prof: José Matias Pedro

p->info = c; p->esq = sae; p->dir = sad; return p;

} As duas funções inicializa e cria representam os dois casos da definição recursiva de árvore binária: uma árvore binária (Arv* a;) é vazia (a = inicializa();) ou é composta por uma raiz e duas sub-árvores (a = cria(c,sae,sad);). Assim, com posse dessas duas funções, podemos criar árvores mais complexas.

Àrvores Binárias de Pesquisa

Para a busca em uma árvore binária por um valor especíico deve-se examinar a raiz. Se o valor for igual à raiz, o valor existe na árvore. Se o valor for menor do que a raiz, então deve- se buscar na subárvore da esquerda, e assim recursivamente em todos os nós da subárvore. Similarmente, se o valor for maior que a raiz, então deve-se buscar na subárvore da direita. Até alcançar o nó-folha da árvore, encontrando-se ou não o valor requerido. Esta operação efetua log n operações no caso médio e n no pior caso quando a árvore está desequilibrada; neste caso, a árvore é considerada uma árvore degenerada.

  • 6. Algoritmos de ordenamento

Ordenação é o processo de arranjar um conjunto de informações semelhantes em uma ordem crescente ou decrescente. Especificamente, dada uma lista ordenada i

de n elementos, então: i1 <= i 2 <=

...

<= I n . Algoritmo de ordenação em ciência da

computação é um algoritmo que coloca os elementos de uma dada sequência em uma certa ordem - em outras palavras, efetua sua ordenação completa ou parcial. As ordens mais usadas são a numérica e a lexicográfica. Existem várias razões para se ordenar uma sequência, uma delas é a possibilidade se acessar seus dados de modo mais eficiente.

  • 6.1. Bubble Sort

O algoritmo de ordenação BubbleSort é um método simples de ordenação por troca. Sua popularidade vem do seu nome fácil e de sua simplicidade. Porém, é uma das piores ordenações já concebidas. Ela envolve repetidas comparações e, se necessário, a troca de dois elementos adjacentes.

Inicialmente percorre-se a lista da esquerda para a direita, comparando pares de elementos consecutivos, trocando de lugar os que estão fora de ordem. Atabela abaixo exemplifica o método BubbleSort.

BubbleSort: primeira varredura (tabela nº 1)

Troca

L[1]

L[2]

L[3]

L[4]

L[5]

  • 1 9

com 2

10

7

13

5

  • 2 com 3

  • 9 10

7

13

5

  • 4 7

com 5

  • 9 10

5

13

Fim da Varredura

  • 9 10

7

5

13

Após a primeira varredura ( tabela nº 1 ), o maior elemento encontra-se alocado em sua posição definitiva na lista ordenada. Logo, a ordenação pode continuar no restante da lista sem considerar o último elemento (tabela nº 2 ).

Na segunda varredura, o segundo maior elemento encontra-se na sua posição definitiva e o restante da ordenação é realizada considerando apenas os últimos elementos (7, 9 e 5 ). Logo são necessários elementos - 1 varreduras, pois cada varredura leva um elemento para sua posição definitiva.

41
41

Prof: José Matias Pedro

BubbleSort - segunda varredura

Troca

L[1]

L[2]

L[3]

L[4]

 

L[5]

Troca 1 com 2

   

9

7

10

5

13

Troca 3 com 4

   

7

9

10

5

13

Fim da varredura

   

7

9

5

10

 

13

 

6.2.

QuickSort

 

O algoritmo QuickSort é do tipo divisão e conquista. Um algoritmo deste tipo resolve vários problemas quebrando um determinado problema em mais (e menores)

subproblemas.

 

O algoritmo, publicado pelo professor C.A.R. Hoare em 1962 , baseia-se na idéia simples de partir um vetor (ou lista a ser ordenada) em dois subvetores, de tal maneira que todos os elementos do primeiro vetor sejam menores ou iguais a todos os elementos do segundo vetor. Estabelecida a divisão, o problema estará resolvido, pois aplicando recursivamente a mesma técnica a cada um dos subvetores, o vetor estará ordenado ao se obter um subvetor de apenas 1 elemento. Os passos para ordenar uma sequência S = {a1; a 2 ; a 3 ; …; an} é dado por:

Seleciona um elemento do conjunto S. O elemento selecionado (p) é chamado

de pivô. Retire p de S e particione os elementos restantes de S em seqüências distintas,

L e G. A partição L deverá ter os elementos menores ou iguais ao elemento pivô p,

enquanto que a partição G conterá os elementos maiores ou iguais a p. Aplique novamente o algoritmo nas partições L e G.

Para organizar os itens, tais que os menores fiquem na primeira partição e os maiores na segunda partição, basta percorrer o vetor do início para o fim e do fim para o início simultaneamente, trocando os elementos. Ao encontrar-se no meio da lista, tem-se a certeza de que os menores estão na primeira partição e os maiores na segunda partição.

A figura seguinte, ilustra

o que se passa quando

se faz

a partição

de

um

vetor

com a

sequência de elementos S = { 7, 1, 3, 9, 8, 4, 2, 7, 4, 2, 3, 5 }. Neste caso o pivô é 4, pois é o

valor do elemento que está na sexta posição (e 6 é igual a 1 +12/2).

 

A escolha do elemento pivô é arbitrária, pegar o elemento médio é apenas uma das possíveis implementações no algoritmo. Outro método para a escolha do pivô consiste em escolher três (ou mais) elementos randomicamente da lista, ordenar esta sublista e pegar o elemento médio. Primeira troca: posições 1 e 11. Pivô= 4.

7

 
  • 1 9

  • 3 4

8

 
  • 2 7

 

4

2

  • 3 5

 
 

Segunda troca: posições 4 e 10

 

3

 
  • 1 9

  • 3 4

8

 
  • 2 7

 

4

2

  • 7 5

 
 

Terceira troca: posições 5 e 9

 

3

 
  • 1 2

  • 3 4

8

 
  • 2 7

 

4

9

  • 7 5

 
 

Quarta troca: posições 6 e 7

 

3

 
  • 1 2

  • 3 4

4

 
  • 2 7

 

8

9

  • 7 5

 
 

3

 
  • 1 2

  • 3 2

4

 
  • 4 7

 

8

9

  • 7 5

 

Os precursos cruzaram-se; agora repete-se o procedimento recursivamente, para os subvetores entre as posições 1 e 6 e entre as posições 7 e 12.

42
42

Prof: José Matias Pedro

O QuickSort pode ser implementado pelos algoritmos. O tempo de execução do algoritmo depende do fato de o particionamento ser balanceado ou não, e isso por sua vez depende de quais elementos são usados para particionar. Se o valor do pivô, para cada partição, for o maior valor, então o algoritmo se tornará numa ordenação lenta com um tempo de processamento n 2 . A complexidade é dada por n log2 n no melhor caso e caso médio. O pior caso é dado n 2 comparações.

6.3.

MergeSort

Como o algoritmo QuickSort, o MergeSort é outro exemplo de algoritmo do tipo divisão e conquista, sendo é um algoritmo de ordenação por intercalação ou segmentação. A idéia básica é a facilidade de criar uma seqüência ordenada a partir de duas outras também ordenadas. Para isso, o algoritmo divide a seqüência original em pares de dados, ordena-as; depois as agrupa em sequências de quatro elementos, e assim por diante, até ter toda a seqüência dividida em apenas duas partes. Então, os passos para o algoritmo são:

Dividir uma seqüência em duas novas seqüências.

Ordenar, recursivamente, cada uma das seqüências (dividindo novamente, quando

possível). Combinar (merge) as subseqüências para obter o resultado final.

Nas figuras seguintes podem ser vistos exemplos de ordenação utilizando os passos do algoritmo.

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

Exemplo: Ordenação MergeSort

43
43

Prof: José Matias Pedro

A complexidade do algoritmo é dada por n log n em todos os casos. A desvantagem deste algoritmo é precisar de uma lista (vetor) auxiliar para realizar a ordenação, ocasionando em gasto extra de memória, já que a lista auxiliar deve ter o mesmo tamanho da lista original.

  • 7. Tabela de Dispersão

Neste capítulo, vamos estudar as estruturas de dados conhecidas como tabelas de dispersão (hash tables), que, se bem projetadas, podem ser usadas para buscar um elemento da tabela em ordem constante: O(1). O preço pago por essa eficiência será um uso maior de memória, mas, como veremos, esse uso excedente não precisa ser tão grande, e é proporcional ao número de elementos armazenados. Para apresentar a idéia das tabelas de dispersão, vamos considerar um exemplo

onde desejamos armazenar os dados referentes aos alunos de uma disciplina. Cada aluno é individualmente identificado pelo seu número de matrícula. Podemos então usar o número de matrícula como chave de busca do conjunto de alunos armazenados. Na UMN, o número de matrícula dos alunos é dado por uma seqüência de oito dígitos, sendo que o último representa um dígito de controle, não sendo portanto parte efetiva do número de matrícula. Por exemplo, na matricula 9711234-4, o ultimo dígito 4, após o hífen, representa o dígito de controle. O número de matrícula efetivo nesse caso é composto pelos primeiros sete dígitos: 9711234. Para permitir um acesso a qualquer aluno em ordem constante, podemos usar o número de matrícula do aluno como índice de um vetor vet. Se isso for possível, acessamos os dados do aluno cuja matrícula é dado por mat indexando o vetor vet[mat]. Dessa forma, o acesso ao elemento se dá em ordem constante, imediata. O problema que encontramos é que, nesse caso, o preço pago para se ter esse acesso rápido é muito grande. Vamos considerar que a informação associada a cada aluno seja representada pela estrutura abaixo:

struct aluno { int mat; char nome[81]; char email[41]; char turma; }; typedef struct aluno Aluno;

Como a matrícula é composta por sete dígitos, o número inteiro que conceitualmente

representa uma matrícula varia de 0000000 a 9999999. Portanto, precisamos

dimensionar nosso vetor com dez milhões (10.000.000) de elementos. Isso pode ser feito por:

#define MAX 10000000 Aluno vet[MAX];

Dessa forma, o nome do aluno com matrícula mat é acessado simplesmente por:

vet[mat].nome. Temos um acesso rápido, mas pagamos um preço em uso de memória proibitivo. Como a estrutura de cada aluno, no nosso exemplo, ocupa pelo menos 127 bytes, estamos falando num gasto de 1.270.000.000 bytes, ou seja, acima de 1 Gbyte de memória. Como na prática teremos, digamos, em torno de 50 alunos cadastrados, precisaríamos apenas de algo em torno de 6.350 (=127*50) bytes. Para amenizar o problema, já vimos que podemos ter um vetor de ponteiros,em vez de um vetor de estruturas. Desta forma, as posições do vetor que não correspondem

a alunos cadastrados teriam valores NULL. Para cada aluno cadastrado, alocaríamos

44
44

Prof: José Matias Pedro

dinamicamente a estrutura de aluno e armazenaríamos um ponteiro para essa estrutura no vetor. Neste caso, acessaríamos o nome do aluno de matrícula mat por vet[mat]->nome. Assim, considerando que cada ponteiro ocupe 4 bytes, o gasto

excedente de memória seria, no máximo, aproximadamente 40 Mbytes. Apesar de menor, esse gasto de memória ainda é proibitivo. A forma de resolver o problema de gasto excessivo de memória, mas ainda garantindo um acesso rápido, é através do uso de tabelas de dispersão (hash table) que vamos tratar:

Idéia central

A idéia central por trás de uma tabela de dispersão é identificar, na chave de busca, quais as partes significativas. Na UMN, por exemplo, além do dígito de controle, alguns outros dígitos do número de matrícula têm significados especiais, conforme ilustra a Figura seguinte:

9 7 1 1 2 3 4_4

Indicadores sequenciais

Periodo de ingresso

Ano de ingresso

Numa turma de aluno, é comum existirem vários alunos com o mesmo ano e período de ingresso. Portanto, esses três primeiros dígitos não são bons candidatos para identificar individualmente cada aluno. Reduzimos nosso problema para uma chave com os quatro dígitos seqüenciais. Podemos ir além e constatar que os números seqüenciais mais significativos são os últimos, pois num universo de uma turma de alunos, o dígito que representa a unidade varia mais do que o dígito que representa o milhar. Desta forma, podemos usar um número de matrícula parcial, de acordo com a dimensão que queremos que tenha nossa tabela (ou nosso vetor). Por exemplo, para dimensionarmos nossa tabela com apenas 100 elementos, podemos usar apenas os últimos dois dígitos seqüenciais do número de matrícula. A tabela pode então ser declarada por:

Aluno* tab[100]. Para acessarmos o nome do aluno cujo número de matrícula é dado por mat, usamos como índice da tabela apenas os dois últimos dígitos. Isso pode ser conseguido aplicando-se o operador modulo (%): vet[mat%100]->nome. Desta forma, o uso de memória excedente é pequeno e o acesso a um determinado aluno, a partir do número de matrícula, continua imediato. O problema que surge é que provavelmente existirão dois ou mais alunos da turma que apresentarão os mesmos últimos dois dígitos no número de matrícula. Dizemos que há uma colisão, pois alunos diferentes são mapeados para o mesmo índice da tabela. Para que a estrutura funcione de maneira adequada, temos que resolver esse problema, tratando as colisões. Existem diferentes métodos para tratarmos as colisões em tabelas de dispersão, e estudaremos esses métodos mais adiante. No momento, vale salientar que não há como eliminar completamente a ocorrência de colisões em tabelas de dispersão. Devemos minimizar as colisões e usar um método que, mesmo com colisões, saibamos identificar cada elemento da tabela individualmente. Função de dispersão A função de dispersão (função de hash) mapea uma chave de busca num índice da tabela. Por exemplo, no caso exemplificado acima, adotamos como função de hash a utilização dos dois últimos dígitos do número de matrícula. A implementação dessa

45
45

Prof: José Matias Pedro

função recebe como parâmetro de entrada a chave de busca e retorna um índice da

tabela. No caso d a chave de busca ser um inteiro representando o número de matrícula,essa função pode ser dada por.

int hash (int mat) {

return (mat%100);

}

Podemos generalizar essa função para tabelas de dispersão com dimensão N. Basta

avaliar o modulo do número de matrícula por N:

int hash (int mat) {

return (mat%N);

}

Uma função de hash deve, sempre que possível, apresentar as seguintes propriedades:

• Ser eficientemente avaliada: isto é necessário para termos acesso rápido, pois

temos que avaliar a função de hash para determinarmos a posição onde o elemento se encontra armazenado na tabela.

• Espalhar bem as chaves de busca: isto é necessário para minimizarmos as

ocorrências de colisões. Como veremos, o tratamento de colisões requer um procedimento adicional para encontrarmos o elemento. Se a função de hash resulta em muitas colisões, perdemos o acesso rápido aos elementos. Um exemplo de função de hash ruim seria usar, como índice da tabela, os dois dígitos iniciais do número de matrícula todos os alunos iriam ser mapeados para apenas três ou quatro índices da tabela. Ainda para minimizarmos o número de colisões, a dimensão da tabela deve guardar uma folga em relação ao número de elementos efetivamente armazenados. Como regra empírica, não devemos permitir que a tabela tenha uma taxa de ocupação superior a 75%. Uma taxa de 50% em geral traz bons resultados. Uma taxa menor que 25% pode representar um gasto excessivo de memória.

  • 7.1. Tratamento de colisão

Existem diversas estratégias para tratarmos as eventuais colisões que surgem quando duas ou mais chaves de busca são mapeadas para um mesmo índice da tabela de hash. Nesta seção, vamos apresentar algumas dessas estratégias comumente usadas. Para cada uma das estratégias, vamos apresentar as duas principais funções de manipulação de tabelas de dispersão: a função que busca um

elemento na tabela e a função que insere ou modifica um elemento. Nessas implementações, vamos considerar a existência da função de dispersão que mapeia

o número de matrícula num índice da tabela, vista na seção anterior. Em todas as estratégias, a tabela de dispersão em si é representada por um vetor de ponteiros para a estrutura que representa a informação a ser armazenada, no caso Aluno. Podemos definir um tipo que representa a tabela por:

#define N 100 typedef Aluno* Hash[N];

Vale lembrar que uma tabela de dispersão nunca terá todos os elementos preenchidos (já mencionamos que uma ocupação acima de 75% eleva o número de colisões, descaracterizando a idéia central da estrutura). Portanto, podemos garantir que sempre existirá uma posição livre na tabela.

46
46

Prof: José Matias Pedro

Na operação de busca, considerando a existência de uma tabela já construída, se uma chave x for mapeada pela função de dispersão (função de hash h) para um determinado índice h(x), procuramos a ocorrência do elemento a partir desse índice,

até que o elemento seja encontrado ou que uma posição vazia seja encontrada. Uma possível implementação é mostrada a seguir. Essa função de busca recebe, além da tabela, a chave de busca do elemento que se busca, e tem como valor de retorno o ponteiro do elemento, se encontrado, ou NULL no caso do elemento não estar presente na tabela.

Aluno* busca (Hash tab, int mat) { int h = hash(mat); while (tab[h] != NULL) { if (tab[h]->mat == mat) return tab[h]; h = (h+1) % N; } return NULL;

}

Devemos notar que a existência de algum elemento mapeado para o mesmo índice não garante que o elemento que buscamos esteja presente. A partir do índice mapeado, temos que buscar o elemento utilizando, como chave de comparação, a real chave de busca, isto é, o número de matrícula completo. A função que insere ou modifica um determinado elemento também é simples. Fazemos o mapeamento da chave de busca (no caso, número de matrícula) através da função de dispersão e verificamos se o elemento já existe na tabela. Se o elemento existir, modificamos o seu conteúdo; se não existir, inserimos um novo na primeira

posição que encontrarmos na tabela, a partir do índice mapeado. Uma possível implementação dessa função é mostrada a seguir. Essa função recebe como parâmetros a tabela e os dados do elemento sendo inserido (ou os novos dados de

um elemento já existente). A função tem como valor de retorno o ponteiro do aluno modificado ou do novo aluno inserido.

Aluno* insere (Hash tab, int mat, char* nome, char* email, char turma) { int h = hash (mat); while (tab[h]!= NULL) { if (tab[h] ->mat == mat) break; h = (h+1) % N; } if (tab[h]==NULL) { /* não encontrou o elemento */ tab[h] = (Aluno*) malloc(sizeof(Aluno)); tab[h]->mat = mat; } /* atribui informação */ strcpy(tab[h]->nome,nome); strcpy (tab[h]->email,email); tab[h]->turma = turma; return tab[h];

}

Apesar de bastante simples, essa estratégia tende a concentrar os lugares ocupados

na tabela, enquanto que o ideal seria dispersar.

47
47

Prof: José Matias Pedro

Referências Bibliograficas

Apostila_estrutura_dados, profs. Waldemar Celes e José Lucas Rangel PUC-RIO-Curso de engenharia-2002.

Algoritmos e Estrutura de Dados I, Marcos Castilho, Fabiano Silva Daniel. Agosto de

2015.

Projecto e Analise de algoritmos: Introdução, prof. Humberto Brandão. Introdução a complexidade de algoritmos :Fernando Silva, DCC-FCUP. Estrutura de Dados. Linguagem C: Algoritmos de Ordenação, prof. Paulo R.S.L. Coelho. Tabela de Dispersão, W. Celes e J.L.Rangel. Algoritmo e C: Alexandre Pereira. WEISS, Mark Allen. Data Structures and Problem Solving Using java, 3nd Edition.

Pearson, Addison Wesley, 2006.

DROZDEK, Adam. Data Structures and Algorithms in C++. Thomson Learning lnc,

2005.

SEDGEWICK, Robert. Algorithms in C: parts 1-4, Fundamentals, Data Structures, Sorting, Searching. Addison Wesley, 1998.

SEDGEWICK, Robert. Algorithms in C: Part 5, Graph Algorithms. Addison Wesley,

2002.

48
48