Preview only show first 10 pages with watermark. For full document please download

Caderno1

Teoria da disciplina Computação para Automação. Algoritmos e Estrutura de Dados. Parte 1. Fundamentos.

   EMBED


Share

Transcript

Universidade Regional do Noroeste do RS Departamento de Tecnologia Algoritmos e Estruturas de Dados Parte I  Fundamentos Marcos César C. Carrard Apresentação Este trabalho tem o objetivo de introduzir os conceitos básicos de algoritmos e suas formas de análise. A razão disto vem da necessidade de tratarmos estruturas de dados  que é a continuidade do mesmo, sob a ótica do trabalho eficiente e eficaz de cada uma delas. Isto representa uma certa novidade em materiais que tratam do assunto “Estruturas de Dados”, pois a maioria deles o faz sob o ponto de vista dos algoritmos e princípios de funcionamento de cada uma das estruturas apresentadas. Sabemos, entretanto, que existem várias organizações de dados possíveis para os mais variados casos. Assim sendo, qual é a melhor delas? Qual eu vou escolher para solucionar o meu problema? Como ela faz esta solução? Desta forma e para tentar solucionar estas questões postas é que necessitamos entender melhor o que é, como funciona e se comporta um algoritmo. Este é o escopo e o objetivo deste material. Finalmente, por ser um material em constante atenção e experimentação, caso o leitor localize algum incorreção, melhoria ou tenha qualquer comentário, entre em contato comigo pelo e-mail abaixo. Marcos Carrard [email protected] 2 Índice Capítulo 1 − Algoritmos 1.1 Definição 1.2 Características 1.3 Formas de Apresentação 1.3.1 Iteração vs. Recursão 1.4 Exercícios 4 5 7 8 11 Capítulo 2 − Análise de Eficiência 2.1 Introdução 2.2 Analisando algoritmos 2.2.1 Algoritmos vs. Problemas 2.2.2 Estudo de casos 2.3 Comportamento Assintótico 2.3.1 Introdução 2.3.2 Crescimento de funções 2.3.3 Complexidade assintótica 2.4 Técnicas de Análise 2.4.1 Introdução 2.4.2 Análise de algoritmos iterativos 2.4.3 Processo de análise 2.4.4 Considerações gerais 2.5 Exercícios 13 15 16 17 19 19 19 22 25 25 25 26 32 33 Capítulo 3 − Relações de Recorrência 3.1 Introdução 3.2 Definição 3.3 Métodos de Solução 3.3.1 Introdução 3.3.2 Método de história completa 3.3.3 Método mestre 3.3.4 Considerações gerais 3.4 Exercícios 37 37 40 40 41 48 51 51 Anexo I - Descrição da Linguagem Bibliografia 53 3 59 1 Algoritmos 1.1 Definição O termo algoritmo tem uma origem interessante. Algumas vezes pensamos que alguém tentou escrever a palavra logaritmo e trocou as letras iniciais. Podemos, também, encontrar alguma afinidade com a palavra “algarismo” em seu significado original, ou seja, um processo para fazer operações aritméticas utilizando numerais arábicos. Mesmo assim, a origem ainda é dúbia. Talvez a versão mais coerente para a origem desta palavra, advinda da história remota da matemática, é atribuída ao nome de um famoso matemático persa, Abu Ja’far Mohammed ibn Mûsâ al-Khowârizmî, a aproximadamente 800 anos antes da era Cristã. Al-Khowârizmî escreveu um célebre livro chamado “Kitab al jabr w’al-muqabala” (Regras de Restauração e Redução). Embora esta obra não possua o cunho algébrico, a matemática moderna atribuí também a ela a origem do termo “álgebra”. Com o passar dos anos, várias conotações do termo algoritmo, algumas bastante errôneas, foram surgindo e sendo atribuídas a vários métodos e procedimentos. Na década de 50, neste século, ele foi associado, com freqüência, ao Algoritmo de Euclides que descrevia um processo para encontrar o máximo divisor comum entre dois números e se encontrava em sua obra Os Elementos, livro 7, proposições 1 e 2. Esta obra constituiu o principal texto no estudo de geometria por cerca de 20 séculos. Segundo definições atuais, encontradas em dicionários, e advindas das definições anteriores unidas a outras, podemos definir algoritmo da seguinte forma: 4 Algoritmo é uma seqüência finita de passos bem definidos que, partindo de informações fornecidas previamente, produz um resultado que é a solução de um determinado problema. Hoje em dia, dizer que um problema tem solução algorítmica significa, mesmo que informalmente, que um programa de computador pode ser escrito em alguma linguagem e irá produzir a resposta correta para qualquer entrada válida fornecida se lhe for dado o tempo de máquina e a memória que vier a necessitar. 1.2 Características Determinar que um problema pode ser transcrito para um algoritmo e posteriormente para um programa e quais são os requisitos necessários a sua operação compõem uma área relativamente recente na computação denominada de Computabilidade. Fundamentalmente, podemos determinar dois grandes grupos de estudo dentro desta área. Em primeiro lugar, temos aquele que trata de determinar se o problema tem ou não uma solução algorítmica. Sua denominação é teoria dos algoritmos. Depois vamos encontrar o estudo do comportamento e requisitos do algoritmo para chegar a solução correta do problema. A esta dá-se o nome de análise de algoritmos. Embora a teoria que acompanha a computabilidade tenha implicações óbvias na ciência da computação, o simples conhecimento de que um problema pode vir a ter solução algorítmica não implica que esta seja de uso prático, fácil ou útil. Considere, por exemplo, um jogo de xadrez, mesmo em situação inicial, e um algoritmo que deve propor um movimento. Se este último foi estritamente correto ele deverá analisar todas as situação, de acordo com as regras do jogo, que podem vir a responder cada possível movimento. À cada uma destas, a análise deve ser repetida, e assim por diante. Com o tabuleiro completo  todas as peças  e no princípio da partida, existem cerca de 1050 possíveis estimativas a serem analisadas. Com a ajuda de um computador extremamente rápido, esta tarefa pode demorar dias, meses ou até anos. Desta forma, um algoritmo deve seguir alguns critérios gerais que permitirão, em uma fase inicial e de forma um tanto quanto inocente, estabelecer características importantes sobre ele. Segundo Horowitz e Sahni ( [HS82] ), as presenças mais marcantes neste grupo são: limitação, definição, entradas, saídas e eficiência. Observe, mesmo antes de maiores detalhes, que os membros deste grupo podem gerar maiores e mais precisas subdivisões. Em primeiro lugar, um algoritmo deve, necessariamente, possuir um número finito de passos. Não existe, pelo menos a primeira vista, muita lógica em produzirmos um algoritmo que não tenha um final previsto. Serve como regra 5 geral no projeto de algoritmos deixarmos o mais explícito possível e de forma certificada a maneira pela qual ele alcança o resultado esperado, mesmo que o número de passos a serem executados para isto seja infinitamente grande. Por outro lado, todos os passos ou operações a serem utilizados na descrição do algoritmo devem ser claramente definidas e, em hipótese alguma, devem dar margem para dupla interpretação. Neste ponto, quando transcrevemos um algoritmo para o seu respectivo programa, encontramos o serviço facilitado pelas linguagens de programação que, em sua proposição formal, não permitem que uma mesma instrução seja executada diferentemente. Esta característica garante um ponto fundamental da análise de algoritmos que diz que um algoritmo, quando executado várias vezes para uma mesma composição de dados de entrada, deve produzir sempre o mesmo resultado como saída. Todo algoritmo deve produzir algum resultado para o problema ao qual ele se destina. Mas para que isto aconteça, deve ser fornecidos a ele uma certa quantidade de dados de entrada. Estas podem ser determinadas a cada nova “execução” do algoritmo ou estarem explicitamente determinadas em seu interior, mas em ambos os casos, devem ser claramente definidas. Por exemplo, o algoritmo de Euclides é capaz de encontrar o máximo divisor comum de dois números inteiros positivos dados. Observe as palavras grifadas. Elas formalizam, sem margem de dúvidas, com que tipo de entrada estamos lidando. Em geral, ao definirmos um algoritmo devemos informar o conjunto ou subconjunto de dados que ele é capaz de processar. Finalmente, como pode ser claramente observado no exemplo citado anteriormente de um jogo de xadrez, um algoritmo deve ser eficiente, ou seja, deve atender a certos critérios que tornem o seu uso prático em situações reais. Resumindo, o que procuramos encontrar não são apenas algoritmos, mas sim bons algoritmos. O problema é quantificar este “bom”. Observe que existe uma distinção clara entre um algoritmo e um programa de computador para um certo problema. O programa não está obrigado formalmente a atender a característica da limitação de passos, o que já não ocorre com o algoritmo. Como exemplo desta situação encontramos os programas que compõem o sistema operacional de uma determinada máquina. Estes programas, depois de postos em execução, continuam ou esperam indefinidamente por uma determinada condição ou ocorrência para realizarem o seu trabalho. Uma vez terminado este, eles retornam a situação de espera. Neste grupo está um escalonador de processos (schedulling) que periodicamente fica substituindo o processo (programa) que esta ocupando a unidade central de processamento da máquina. Ele pára o seu trabalho somente quando o equipamento é desligado. Um gerenciador de memória do sistema operacional também se enquadra em um caso equivalente a este. 6 1.3 Formas de apresentação Os algoritmos são geralmente expressos em uma linguagem algorítmica. Estas podem possuir tão alto-nível quanto venha a ser necessário. Saliente-se que, embora um algoritmo possa ser projetado para uma grande variedade de aplicações, eles são universalmente representados em forma aproximada a um programa. Vários autores tem projetado suas próprias “linguagens” ou “pseudolinguagens” tais que estas venham a atender suas necessidades. Observe a figura 1.1 para um exemplo destas em um algoritmo que faz o produto de duas matrizes n x n. Neste livro não vamos ser diferentes. Você encontrará uma descrição da linguagem a ser utilizada no mesmo  muito próxima do Pascal e Modula  no Apêndice A. para i ← 1 até n faça para j ← 1 até n faça cij ← 0 para k ← 1 até n faça cij ← cij + aik ⋅ bkj para i := 1,...., n faça para j := 1,...., n faça cij := 0 para k := 1,...., n faça cij := cij + aik ⋅ bkj (a) Szwarcfiter e Markenzon ( [SM94] ) for i ← 1 to n by 1 do for j ← 1 to n by 1 do c(i,j) ← 0 for k ← 1 to n by 1 do c(i,j) ← c(i,j) + a(i,k) ⋅ b(k,j) (b) Terada ( [Ter91] ) 1 set i ← 1 2 set j ← 1 set c[i,j] ← 0 3 set k ← 1 c[i,j] ← c[i,j] + a[i,k] ⋅ b[k,j] if k <= n then go to 3 if j <= n then go to 2 if i <= n then go to 1 (d) Knuth ( [Knu73] ) (c) Horowitz e Sahni ( [HS82] ) Figura 1.1: Algoritmos para o produto de duas matrizes n x n em várias linguagens algorítmicas. O uso de uma linguagem de alto-nível para expressar um algoritmo torna facilitada a tarefa de obtenção de características desejáveis como simplicidade, legibilidade e exatidão. Entretanto, certas avaliações quanto a eficiência podem não ser tão apuradas quanto em uma linguagem de nível mais baixo. Alguns detalhes importantes podem ficar escondidas por processos de abstração de dados. Por exemplo, em algumas situações desejamos contar quantas multiplicações um algoritmo realizou para avaliar o produto de duas matrizes 7 n x n. Se este algoritmo expressa este produto elemento à elemento, como na figura 1.1, é relativamente fácil contá-las. Agora suponha que estamos utilizando uma linguagem que fornece um operador que faz a multiplicação diretamente. Evidentemente, as operações unitárias ainda ocorrem, em um nível mais baixo da linguagem, mas estão mascaradas pela abstração de dados. A partir do momento em que venhamos a formalizar a expressão dos algoritmos pela nossa linguagem local (embora isto aconteça nos demais casos), existem duas formas padronizadas para realizar esta tarefa: projeto de algoritmos iterativos e projeto de algoritmos recursivos. 1.3.1 Iteração vs. recursão Um pequena quantidade de algoritmos são executados do seu início ao final em um único passo, sem que partes do programa sejam reutilizadas. Uma parte deste programa é frequentemente representada na forma de uma iteração ou laço  de onde origina o nome, tal que algumas declarações podem ser repetidas. Veja o algoritmo 1.1 para encontrar o fatorial de um número n (n!). É fácil verificar que o algoritmo 1.1 executa n operações de multiplicação (descritas no laço para). Este mesmo algoritmo pode ser agora descrito em outro que chama a ele mesmo direta ou indiretamente. Procedimentos ou funções podem incluir chamadas a elas mesmo, caracterizando uma recursão. Um algoritmo nesta forma é dito recursivo. Quando procedimentos ou funções chamam outros procedimentos ou funções que por sua vez invocam os originais, ocorre uma recursão indireta. Caso a chamada recursiva seja do procedimento ou função para ela própria, sem passar por outros intermediários, ocorre uma recursão direta. A literatura mostra que para qualquer algoritmo descrito na forma interativa existe um algoritmo de idêntica conduta na sua forma recursiva e vice-versa ( [Baa86] ). Como exemplo veja o algoritmo 1.2 que expressa o cálculo do fatorial de forma recursiva direta. procedimento fatorial( n: inteiro ): inteiro; var i : inteiro; início fatorial := 1; if n > 1 then para i := 1 até n faça fatorial := fatorial * i; fim Algoritmo 1.1 - Forma iterativa para cálculo do fatorial. 8 procedimento fatorial( n: inteiro ): inteiro; início se n <= 1 então fatorial := 1; senão fatorial := n * fatorial( n-1 ); fim Algoritmo 1.2 - Forma recursiva para cálculo do fatorial. Mesmo que, de modo geral, todos os algoritmos possam ser expressados em ambas as formas, a recursividade apresenta algumas vantagens claras. Os algoritmos recursivos são mais concisos que os seus correspondentes interativos. Da mesma forma, existe uma ligação direta entre um algoritmo recursivo e uma prova por indução matemática o que facilita de sobremaneira o projeto e verificação de algumas de suas propriedades na análise de algoritmos ( [Knu73], [Man88], [Man89], [CLR91] e [AV92] ). Vamos observar outro exemplo clássico de algoritmo recursivo, se bem que a sua implementação interativa não seja tão trivial. Este é o problema da Torre de Hanói e consiste de três pinos A, B e C, conhecidos como origem, destino e temporário, e n discos de tamanhos diferentes empilhados na torre A. Estes discos estão dispostos de tal forma que não existe um disco maior sobre um menor. Se numerarmos os discos tal que o menor receba o valor 1, o seguinte 2 e assim por diante até que o maior recebe n, existe uma restrição dizendo que para cada dois discos consecutivos, o número daquele que estiver em cima é sempre menor que o que está em baixo. A tarefa pedida pelo problema é realizar um certo número de movimentos, um de cada vez e respeitando a restrição dada acima, que levem todos os discos da torre A (origem) para a torre B (destino). Neste processo a torre C pode ser utilizada para o armazenamento temporário dos discos. Vamos considerar o problema por partes. Inicialmente, vejamos o caso onde temos apenas um disco a ser movido, ou seja, n = 1. Aqui a solução é simples, basta levá-lo da sua posição (origem) ao destino desejado e nenhuma das regras vai ser infringida. Retornando ao problema inicial, com n discos, sabemos que se nós conseguirmos deixar apenas um disco na torre A, ele poderá ser movido sem problemas. Mas o que faremos com os demais? Considere o problema na ordem inversa. Suponha que nós sabemos (não interessa como) retirar os n-1 discos empilhados sobre o maior deles e levá-los para a torre temporária, conforme a figura 1.2a. Neste caso, resta-nos duas operações finais: mover o n-éssimo disco da origem para o destino (figura 1.2b) e mover os n-1 discos que agora estão no temporário para sobre o n-éssimo na torre destino (figura 1.2c). Feito isto o problema terá encontrado uma solução. 9 Figura 1.2 - Evolução dos movimentos da Torre de Hanói. Ficou apenas uma dúvida: como mover os n-1 discos que estão sobre o maior para a torre temporária e depois para a torre destino? Observe que o mesmo processo descrito acima pode ser aplicado a este grupo de disco, basta considerá-los como um novo problema onde o número total de discos é n-1 e a nomenclatura das torres é trocada: C é a origem, B o destino e A é a temporária do novo problema. Nenhuma das regras, principalmente a do tamanho dos discos, vai ser violada, pois o disco que está na torre A ou B, dependendo do caso, é o maior de todos. procedimento hanoi( a, b, c: caracter; n: inteiro ) início se n = 1 então escrever( “Mover disco da torre ”, a, “ para a torre ”, b ); senão hanoi( a, c, b, n-1 ); escrever( “Mover disco da torre ”, a, “ para a torre ”, b ); hanoi( c, b, a, n-1 ); fim fim Algoritmo 1.3 - Solução para o problema da Torre de Hanói. 10 Isto nos permite construir um algoritmo recursivo (algoritmo 1.3) que soluciona do problema. E, mesmo sem termos maiores conhecimentos, acabamos de realizar uma prova por indução matemática. Veja abaixo o resultado para a chamada ao algoritmo 1.3 de hanoi( ‘A’, ‘B’, ‘C’, 3): Mover disco da torre A para a torre B Mover disco da torre A para a torre C Mover disco da torre B para a torre C Mover disco da torre A para a torre B Mover disco da torre C para a torre A Mover disco da torre C para a torre B Mover disco da torre A para a torre B O.k. 1.4 Exercícios 1. Encontre a formulação proposta e codifique o algoritmo de Euclides para encontrar o máximo divisor comum de dois números inteiros positivos. 2. Escreva os números de 1 a 100 em diferentes cartões, misture-os e torne a recolocá-los em ordem crescente. Repita o processo para a ordem decrescente. Tente agora formalizar o algoritmo que foi utilizado para realizar estas tarefas. 3. O texto descreve uma diferença básica entre algoritmos e programas. Cite outros programas que estão nesta mesma situação. 4. Considere a lista de número abaixo. Sua tarefa é apagar a menor quantidade deles de maneira que os demais fiquem em ordem crescente. Repita depois o processo para a ordem decrescente. 09 44 32 12 07 42 34 92 35 37 41 08 20 27 83 64 61 28 39 93 29 17 13 15 55 21 66 72 23 73 99 01 02 88 77 03 65 82 84 62 05 11 74 68 76 78 67 75 69 70 04 19 57 49 5. Escreva um algoritmo que encontre uma combinação para pagar uma quantia solicitada com os valores (podem ser suas moedas) de R$15,00, R$23,00, R$29,00, R$41,00 e R$67,00. A combinação deve ser a menor possível. 6. Considere uma matriz quadrada de n elementos, com os valores dos elementos ordenados crescentemente em suas linhas e colunas. Proponha uma algoritmo para encontrar um determinado elemento nesta estrutura da forma mais rápida possível. 11 7. Descreva um algoritmo interativo que seja capaz de realizar a busca por um determinado nome em um catálogo telefônico. 8. Para o mesmo problema descrito no exercício anterior, projete um algoritmo recursivo que encontre a solução desejada. 9. Escreva um algoritmo capaz de expressar o Triângulo de Pascal abaixo: 1 1 1 1 1 1 2 3 4 1 3 6 1 4 1 1 5 10 10 5 1 : : : : : : 10. Como você armazenaria o Triângulo de Pascal acima em um computador? Caso você obtenha mais do que uma solução, ordene-as de acordo com a quantidade de espaço utilizado e a rapidez de acesso. 12 2 Análise de Eficiência 2.1 Introdução Tendo uma idéia mais clara sobre a forma e a função de um algoritmo, precisamos obter maiores detalhes sobre ele, ou seja, vamos analisá-lo melhor. Se nós desejamos escrever um algoritmo (ou programa) que será utilizado uma única vez e com uma quantidade pequena de dados, obviamente vamos optar por aquela maneira que parece ser mais fácil de ser concretizada. De maneira geral, os algoritmos simples são sempre desejáveis, pois além de serem de implementação mais fácil, estão menos sujeitos a erros do que outros mais complexos. A verdade é que, no dia-a-dia da ciência da computação, a realidade é diversa desta. Seja pelo crescente nível de exigência ou por outras razões, os nossos problemas são cada vez mais complexos e devem trabalhar com uma quantidade de dados muito grande. Isto, obviamente, exige algoritmos (ou programas) mais complexos. Uma outra característica desejável para nossos algoritmos é a sua clareza. Sempre que possível, nós deveríamos ser capazes de olhá-los e entendêlos com facilidade. Por outro lado, a prática e a experiência nos mostram que, quanto mais complexo um algoritmo é, também é menos claro na mesma proporção. Esta relação também existe quando falamos de eficiência de algoritmos. Em geral, existem algumas regras, também ditadas pela experiência, para minimizar os efeitos citados acima. A primeira delas é possuir sempre uma boa documentação do seu algoritmo, envolvendo descrições, provas e observações que forem necessárias. Em segundo lugar, comente todas aquelas linhas do seu algoritmo e/ou programa que não tenham significado claro. 13 Finalmente, utilize nomes de variáveis, funções e procedimentos que tenham ligação com a tarefa a ser desempenhada pelo mesmo. Isto facilita a leitura do seu código. Quando um algoritmo tem uso extensivo ou trabalha com uma grande quantidade de dados, entra em cena uma terceira característica desejável: a eficiência. Em geral, programadores associam eficiência com o tempo que um algoritmo gasta para executar a sua tarefa. Na realidade, dependendo da sua aplicação, a eficiência pode e deve levar em consideração outros detalhes além do tempo, como a quantidade de memória gasta durante a execução ou, sendo mais geral, o espaço de armazenamento gasto; em um sistema distribuído, o tráfego de dados gerado é importante. Em geral, vários outros exemplos podem ser citados, todos eles ligados ao consumo de algum tipo de recurso do sistema em uso. De qualquer forma, vamos aplicar técnicas de análise de algoritmos para medir estes níveis de eficiência. Em outras palavra, analisar um algoritmo ou programa significa determinar quais são os recursos necessários à sua execução. E, por recursos entendemos todos os itens que devem ser postos a disposição do algoritmo para a conclusão da tarefa. Como exemplo, então, citamos o tempo de CPU, espaço de armazenamento, dados transmitidos pela rede e outros. Da mesma forma que as demais bibliografias sobre o assunto, vamos nos referir ao tempo de execução do algoritmo daqui para a frente com unidade base de avaliação, a menos que seja mencionado o contrário. Entretanto, vale salientar que as técnicas aplicadas para tal análise são válidas e úteis para avaliar qualquer outro recurso, seja dentre aqueles citados ou não. Antes de mais nada, vamos responder a uma questão: por que analisar algoritmos? Existe uma razão principal para esta tarefa: a partir dela vamos poder estabelecer quantidade representativas do algoritmo que irão permitir avaliar a sua performance sem que o mesmo precise ser implementado em um computador. Isto parece desnecessário, mas não é. Considere o caso de dois algoritmos A e B, sendo que o algoritmo A é extremamente eficiente quanto ao tempo e o algoritmo B não. Suponha agora que o algoritmo A será codificado em um computador antiquado e lento, enquanto que o algoritmo B será implementado em uma máquina de última geração. Será possível comparar os tempos de execução de ambos? Obviamente que não. Mais além, não precisamos usar duas máquinas tão distintas, mas dois compiladores diferentes, de uma mesma linguagem, em uma mesmo computador. Basta que um deles seja capaz de gerar um código executável um pouco mais aprimorado para que a tarefa de comparar os algoritmos seja inútil. Sabendo então que estamos medindo a eficiência de dois algoritmos distintos, sob os mesmos parâmetros de avaliação, estaremos também capacitados a compará-los e determinar sob quais condições um trabalha melhor do que o outro. 14 Existe ainda uma justificativa teórica para a análise de algoritmos, pois ela pode estabelecer se o problema em questão é difícil ou não de ser solucionado e quais são os seus limites reais. Desta forma, pesquisas podem ser realizadas constantemente na busca por algoritmos e soluções mais eficientes que resolvam corretamente o problema. Formalmente, análise de algoritmos objetiva determinar o nível de complexidade do mesmo e classificar problemas com complexidades similares. O que realmente isto é e como realizá-lo é o que vamos ver durante este capítulo. É importante salientar que o estudo teórico aprofundado sobre este tema, por mais que fundamental para um perfeito entendimento do conteúdo abordado, está fora do escopo deste trabalho. Para complementação, pode-se recorrer à referência bibliográfica existente no final ([AHU74], [AU92], [Baa88], [CLR91], [CJ79], [Knu73], [Man89], entre outros abordam o tema de forma bastante detalhada). Antes de iniciarmos o estudo deste tema, é necessário estabelecer a relação existente entre este assunto e o tema em estudo: estruturas de dados. Estruturas de dados nada mais são do que regras transformadas em algoritmos que manipulam informações. Bem, esta manipulação deve ser feita de forma correta e eficiente, sem contar que frequentemente necessitamos comparar algoritmos que manipulam a mesma ou semelhantes estruturas. É para isso que um bom entendimento de análise de algoritmos vem ao nosso auxílio. 2.2 Analisando Algoritmos Segundo Paulo Azeredo (Aze[92]) e em consonância ao que já foi dito: “Analisar um algoritmo consiste em estudar o seu comportamento frente a diferentes tamanhos e valores de entrada”. Podemos então dizer que necessitaremos saber quantas operações o algoritmo em questão faz para uma entrada em particular. Esta pode ser uma boa definição para comportamento do algoritmo. Analisando este tema sob o aspecto de operações realizadas para que o algoritmo produza a saída desejada, poderemos deduzir um tempo aproximado (ou gasto aproximado de recursos) em uma determinada máquina, bastando conhecer o comportamento desta frente as operações que serão feitas. Isto ficará mais evidente na seção que tratar das técnicas de análise. Veja que, fazendo-se a análise desta forma não é possível se levar em conta características como a linguagem utilizada para a sua codificação, o compilador, metodologia de programação, dentre outros. Estes fatores podem, entretanto, ser considerados secundários se todos os algoritmos analisados aqui obedecerem a um mesmo critério de análise. 15 2.2.1 Algoritmos vs. Problemas É importante salientar ainda que esta análise pode ser realizada de duas maneiras: podemos analisar algoritmos e podemos analisar problemas. Algoritmos e problemas são diferentes? Sim, eles são. Algoritmos são soluções ou modalidades de soluções para um dado problema. Por exemplo, considere o seguinte caso no qual temos que encontrar um dado valor em um vetor de tamanho n com os seus elementos em ordem crescente de valor (este é o problema). Uma solução poderia ser percorrer o vetor do inicio ao final e, para cada elemento, verificar se é o que procuramos. Esta será a solução ou algoritmo 1. Outra forma seria dividir o vetor ao meio e, sabendo que ele está ordenado, realizar a busca somente em uma das suas metades, inferior ou superior, de acordo com o elemento que buscamos. Para isso basta testar o elemento buscado com aquele que estiver no meio do vetor. Este trabalho pode ser repetido até que encontremos o valor ou determinamos a sua ausência. Está é a solução ou algoritmo 2. Já que estas duas coisas são diferentes, elas devem ser analisadas ou terem suas análises vistas de forma diferenciada. Enquanto que devemos ver a análise de algoritmos como um resultado concreto sobre a eficiência de uma determinada solução, a análise de um problema, na maioria das situações, apenas propõem um referencial teórico sobre o mesmo. Isto faz com que a análise de um problema seja uma tarefa árdua, penosa e, principalmente, com alto grau de subjetividade. Quando analisamos um problema, estamos buscando estabelecer limites dentro dos quais as soluções propostas (algoritmos) devem estar enquadradas. Se isto não está acontecendo, o mínimo a se fazer é trabalhar esta solução de forma a melhorá-la e trazê-la aos limites encontrados ao problema. Um pouco mais concretamente, suponha a existência de um certo problema P, para o qual, após exaustiva análise, determinou-se um limite de gasto de recursos computacionais da ordem de X. Por outro lado, o algoritmo que foi desenvolvido para solucionálo, gasta recursos da ordem de X2. Bem, é muito fácil perceber que existe uma grande diferença entre este dois fatores e o algoritmo pode e deve ser melhorado. Este raciocínio também pode (e deve) ser aplicado para um limite mínimo. Se foi provado um dado limite mínimo para o problema (como no caso acima) e um algoritmo gasta menos tempo do que isto para solucioná-lo, duas coisa podem estar acontecendo: a algoritmo tem algum erro e não produz o resultado corretamente para todos os casos; ou, a análise do problema não foi bem feita. Mas uma vez, a tarefa de análise propicia referências valiosas para o trabalho futuro. Esta lógica também serve para trabalhar com um limite superior do problema (veja [CLR91]). Os limites mencionados no parágrafo anterior são de fundamental importância para o trato analítico de algoritmos e problemas. Conhecidos com 16 limites inferior e superior, eles fazem com que a subjetividade de um processo de análise fique sujeita a intervalos muito claros. Suponha que foram determinados dois limites para um problema: 10 como limite inferior e 50 como superior (estes valores são hipotéticos). Isto significa dizer que este problema nunca poderá ser solucionado com consumo de recursos menor do que 10 e maior do que 50. Se fosse dito que o problema acima tem intervalos limites entre 1 e 100, não estaria errado pois este intervalo engloba o primeiro dado no parágrafo anterior. Perceba, entretanto, a tamanha diferença de realidade deste problema (supondo que o primeiro intervalo esteja correto), pois não tendo uma análise ajustada ao seu comportamento real, estaremos inviabilizando o uso prático dos resultados obtidos, que são verdadeiros, mas não são fieis. Resumindo, neste processo devemos sempre buscar a análise mais justa possível, mesmo que já tenhamos encontrado limites que “pareçam” bons. 2.2.2 Estudo de casos Por mais que, ao expressarmos um algoritmo tenhamos clareza da sua ação e seus passos sejam extremamente bem definidos, o comportamento nem sempre segue esta regra. Vamos analisar um pequeno exemplo, expresso pelo algoritmo 2.1, que faz a busca de um valor informado dentro de um vetor de N números inteiros. Este algoritmo percorre sequencialmente este vetor e, ao encontrar o elemento desejado, encerra sua busca. Ele necessita olhar todas as posições do vetor para determinar se o elemento está ou não presente. Nenhuma informação a respeito do conteúdo do vetor é conhecida, com por exemplo a ordenação deste, e aproveitada. Vamos então, utilizando este algoritmo, simular algumas situações de busca. Todas estas situações vão utilizar o vetor abaixo como elemento de pesquisa: 15 5 13 10 1 0 8 7 3 9 1 2 3 4 5 6 7 8 9 10 Em primeiro lugar, vamos procurar neste vetor o valor X = 1. Após algum trabalho (5 iterações do laço enquanto, para sermos exatos), o valor 1 será encontrado na posição 5 do vetor. Se formos buscar o valor X = 9, ele será encontrado, após 10 iterações, na décima posição do vetor. Para o valor X = 15, bastará uma única iteração. Finalmente, quando procurarmos o valor X = 25, 17 percorreremos todo o vetor e verificaremos que ele não existe (após 11 iterações do laço). procedimento busca( v: vetor[1..N] de inteiro; x: inteiro ): inteiro var i: inteiro; início i := 1; enquanto i <= N e v[i] <> x faça i := i + 1; fim se i > N então retornar -1; /* Não achou o elemento buscado */ senão retornar i; /* Encontrou na posição i */ fim fim Algoritmo 2.1 - Busca em um vetor não ordenado. É fácil perceber que um mesmo algoritmo, com o mesmo vetor de trabalho, executa de forma diferenciada a sua tarefa sob a ótica do trabalho realizado. Como isto pode ser explicado? Na verdade estamos falando de casos de um algoritmo. Todo e qualquer problema e/ou algoritmo apresenta situações diversas conhecidas como os seus casos de execução. Em particular, três destes casos são do nosso interesse direto. Vejamos: a) Melhor caso: é aquela situação, dentre todas as possíveis, onde o algoritmo (ou problema) realiza a sua tarefa com menor consumo de recursos possível, ou seja, onde ele menos trabalha. No exemplo acima, isto acontece quando o valor buscado se encontra na primeira posição do vetor, pois com apenas uma verificação o trabalho termina. b) Pior caso: é a situação inversa ao melhor caso, onde o trabalho realizado é o maior dentre todos os possíveis. No exemplo, isto ocorrerá sempre que o valor desejado não estiver no vetor, pois teremos que verificar todo ela para darmos esta garantia. c) Caso médio: este caso é a média de todos os casos conhecidos para o problema ou algoritmo em questão. Um comentário especial deve ser feito par ao caso médio. Como ele é a média dos casos conhecidos, o universo de amostragem deve ser abrangente o suficiente para permitir uma boa expressão de “trabalho médio”. Por exemplo, se 18 para a busca em vetor com algoritmo 2.1 conhecemos somente os dois casos extremos: uma iteração para o melhor caso; e, uma iteração a mais do que o 1 + (n + 1) n + 2 tamanho do vetor (N+1) para o pior caso. Assim, a média seria = 2 2 e, com isso, estamos afirmando que, em qualquer conjunto de casos, teremos este trabalho, o que nem sempre é verdade. Então, para que a análise de caso médio tenha validade, devemos conhecer uma quantidade “razoável” de casos. É este conhecimento prévio que torna esta análise a mais difícil dentre as possíveis pois, na maioria das vezes, não sabemos nada sobre o algoritmo ou problema além do que ele se propõem a solucionar. 2.3 Comportamento Assintótico de Funções 2.3.1 Introdução O que funções matemáticas e análise de algoritmos tem em comum? Provavelmente mais do que a maioria dos programadores tenha idéia. Neste item, repousa um dos elos de ligação entre a informática e elementos do cálculo matemático (principalmente cálculo diferencial, veja [CLR91]). 2.3.2 Crescimento de funções Todos nós, sem exceção, em algum momento, aprendemos e fizemos gráficos cartesianos com funções matemáticas dadas. Poucos, entretanto, pararam para analisar estes gráficos. Figura 2.1 - Gráficos de funções no Plano Cartesiano. 19 Vamos considerar o caso expresso na figura 2.1, onde existem duas funções f(x) = 2x + 1 e g(x) = x2. O processo de desenho é simples e conhecido de todos: se atribuem valores ao elemento x das funções, encontra-se o valor da função para cada valor atribuído e forma-se um par de coordenadas cartesianas com ambos. Da marcação destes pares no gráfico, resulta o desenho da função. Veja a baixo uma pequena parcela destes intervalos e seus respectivos valores: x f(x) = 2x + 1 g(x) = x2 0 1 0 1 3 1 2 5 4 3 7 9 4 9 16 5 11 25 : : : 10 21 100 : : : 30 61 900 : : : Observe atentamente que os valores resultantes da função g(x) são, inicialmente, menores do que aqueles da função f(x) (até x=2). Após um ponto, além deles serem maiores, a taxa proporcional de crescimento da função g(x) é muito maior. Isto nos permite dizer que a função g(x) cresce muito mais rapidamente do que a função f(x). Recordando agora noções de cálculo diferencial, vemos que o cálculo de derivadas de funções nos dá uma interpretação análoga. Sabemos que, ao derivarmos uma função em x, conhecemos qual o seu sentido de crescimento (se positivo ou negativo), analisando os pontos de interseção obtidos. Derivando novamente (encontrando a segunda derivada), encontramos a taxa deste crescimento. Se fizermos isto com o exemplo dado antes, veremos que, mais uma vez, a função g(x) tem taxa de crescimento maior. Veja: f(x) = 2x +1 g(x) = x2 f’(x) = 2 g’(x) = 2x f”(x) = 0 g”(x) = 2 Muito bem, suponha agora que as funções f(x) e g(x) representam o trabalho realizado por dois algoritmos F e G, respectivamente, para uma entrada de tamanho x. Qual dos dois você escolheria para ser implementado? Obviamente o algoritmo F pois ele trabalha menos do G a medida que a entrada 20 aumenta de tamanho ( o seu gráfico nos disse isto quando comparamos com a função g(x)). É neste ponto que entra o uso de funções no trato com algoritmos. Todo o nosso processo de análise culminará em uma função que irá expressar o comportamento do algoritmo sob determinados parâmetros. Desta forma, ao compararmos as funções entre si por taxa de crescimento, estaremos comparando os algoritmos e determinando aquele que trabalhará mais ou menos de acordo com a situação. Quando estamos comparando funções de acordo com a sua taxa de crescimento, podemos observar que são os termos de maior grau do polinômio formador da função que determinam a parte principal da sua curva. Os termos de menor grau e constantes multiplicativas, importantes na determinação do valor nominal da função, aqui contribuem apenas para a posição gráfica daquela. Desta forma, é possível criar uma pequena tabela onde as funções são organizadas por tipo, em ordem crescente de taxa de crescimento. Veja: Função Constantes Descrição São funções que tem valor constante independente da variável x São funções expressas através de logaritmos Exemplos f(x) = 1 f(x) = 3 f(x) = 10 Logarítmicas f(x) = log x f(x) = 3log x f(x) = log( x+1) Lineares São as funções expressas f(x) = 2x + 1 com equações de grau 1 e f(x) = 4x que formam uma reta no f(x) = x + 2 gráfico cartesiano Quadráticas São equações de grau 2 f(x) = x2 (segundo grau) e formam f(x) = 3x2 + 2x + 1 uma parábola no gráfico f(x) = 4x2 + 2 Cúbicas Equações de terceiro grau f(x) = x3 + 2x f(x) = 3x3 f(x) = 2x3 + x2 + 2 Quárticas Equações de quarto grau f(x) = 2x4 + 3x + 1 f(x) = x4 + x3 : : : : : : : : Obs: Continua com as funções aparecendo na ordem crescente de grau. Exponencial de São funções onde a base da f(x) = 2x base 2 exponencial é 2 f(x) = 2x+1 + 2 f(x) = 2x + x2 21 Exponencial de São funções onde a base da f(x) = 3x base 3 exponencial é 3 f(x) = 3x + 2x f(x) = 32x + 1 : : : : : : : : Obs: Continuam as funções em ordem crescente de base da exponencial Algumas observações são importantes sobre este tema. Em primeiro lugar, não trabalharemos com funções de crescimento negativo, pois não há nenhum algoritmo que trabalhe menos a medida que seus dados aumentam de tamanho. Depois, estamos interessados nos valores de função no primeiro quadrante do plano cartesiano, pois teremos, no mínimo, um número maior ou igual a zero de tamanho de entrada, nunca negativo. Finalmente, uma observação fundamental: entre cada grupo de funções da tabela acima podem ser inseridos os grupos inferiores. Por exemplo, entre as funções lineares e as quadráticas, estão aquelas com um termo composto de um elemento linear e um elemento logarítmico: f(x) = x log x. Como estes termos são multiplicativos e não são constantes, eles não podem ser desconsiderados na classificação. O mesmo raciocínio vale para os demais quadros da tabela. Para outros detalhes, consulte a bibliografia [CLR91], [AU92], [Knu73], [Man89], entre outros. Vamos agora realizar um exercício como exemplo desta classificação. Exercício: Classifique as funções a seguir por taxa de crescimento: x2 1 5 x log x 2x + 1 log log x x2 log x 2xx2 2x 3x + 1 3xlog x2 log x2 Resposta: 1, 5, log log x, log x2, 2x + 1, x log x, x2, x2 log x, 2x, 2xx2, 3x + 1, 3xlog x2 Existe uma procedimento que pode ser adotado para fazer a classificação ou comparação entre funções. Em primeiro lugar, tente fazer isto utilizando a tabela acima, onde a solução é direta. Se não foi possível, tente desenhar o gráfico das funções, mas faça isso para um intervalo grande o suficiente de x para que as funções esteja bem definidas no plano. Em último lugar, tente calcular as derivadas das funções e compare os resultados. 2.3.3 Classificação assintótica Pelo que já foi dito, aprece óbvio que, ao compararmos algoritmos através de suas funções, não estamos interessados em um valor específico da 22 variável x, mas sim em quanto esta função cresce a medida que este x aumenta. Assim sendo, o uso de sinais matemáticos convencionais (maior, menor, igual, etc...) não se adequa ao caso, pois estes são muito restritos. Por exemplo, analise o caso citado como exemplo na figura 2.1. Até o valor x = 2, a função f(x) tem um valor nominal maior do que a função g(x). Assim, como vamos dizer que f(x) é menor do que g(x)? É uma imprecisão e uma incoerência matemática. Para solucionar este problema utiliza-se uma classificação alternativa. Esta classificação lança mão de cinco classes assintóticas que comparam funções através da sua taxa de crescimento. Estas classes estão listadas abaixo: a) Classe O  Oh Grande Esta classe estabelece uma relação análoga ao sinal matemático “menor ou igual que” enquanto taxa de crescimento. Assim, dizer que f(x) ∈ Ο (g(x)) ou f(x) = Ο (g(x)) (lê-se: f(x) pertence ou é Oh grande de g(x)), significa dizer que a função f(x) tem taxa de crescimento menor ou, no máximo, igual à função g(x). b) Classe Ω  Ômega Grande Da mesma forma, esta classe substitui a relação convencional “maior ou igual que” sob a ótica do crescimento de funções. Quando classificamos uma função f(x) como pertencendo à classe Ω de outra função g(x), na forma f(x) ∈ Ω(g(x)) ou f(x) = Ω(g(x)), estamos nos referindo ao fato da função f(x) crescer igual ou mais do que a função g(x). c) Classe Θ  Theta Procede-se como as anteriores, mas estabelecendo uma relação de igualdade, ou seja, ambas as funções tem taxa de crescimento equivalente. A relação deve ser escrita também como as anteriores: f(x) ∈ Θ(g(x)) ou f(x) = Θ(g(x)). d) Classe o  Oh Pequeno Tem a mesma implicação da classe O, mas não admite a igualdade entre as duas funções. Ou seja, as funções não podem ter a mesma taxa de crescimento. Expressa-se da seguinte forma: f(x) ∈ o(g(x)) ou f(x) = o(g(x)). Neste caso, a função f(x) cresce menos do que a função g(x). e) Classe Ω  Ômega Pequeno Semelhante a anterior, acompanha a relação convencional “maior”, sem admitir a igualdade entre as duas funções quanto ao crescimento destas. Deve ser expressada como segue: f(x) ∈ Ω(g(x)) ou f(x) = Ω(g(x)), onde estamos dizendo que a função f(x) cresce mais do que a função g(x). 23 É importante salientar que, quando classificamos uma função com as classes assintóticas, nem sempre precisamos duas delas. Não há nenhum problema de utilizarmos uma das envolvidas de forma fixa, como por exemplo: f(x) ∈ O( x2 ) ou f(x) = O( x2 ) No caso acima estamos apenas dizendo que a função f(x) tem um limite superior de crescimento dado por x2, pois cresce menos ou igual a este valor. Na verdade podemos e devemos utilizar esta notação para estabelecer os limites inferior e superior de um algoritmo, conforme descrito no item 2.2.1. Uma outra observação a ser feita é quanto ao sinal utilizado para estabelecer a relação entre a função e a classe. Nos casos mostrados foram utilizados ∈ e =. Ambos poder aparecer, ficando a critério de cada um a escolha daquele que for mais familiar. Não há nenhum problema quanto ao uso do sinal de igualdade (=) neste caso, pois ele tem o significado de atribuição e não de uma relação numérica. Veja agora uma pequena lista de exemplos de funções e suas classificações assintóticas admissíveis: f(x) 2x + 3 g(x) 2 x Classificação 2 Observações 2x + 3 = 0( x ) Veja que todas estas x = Ω( 2x + 3 ) classificações são 2 2 2x + 3 = o( x ) possíveis x2 = Ω( 2x + 3 ) x2 + 2x x2 + 5 x2 + 2x = Θ(x2 + 5) Observe que, enquanto taxa de crescimento, estas funções são iguais. log x x log x log x = O( x log x ) Veja que todas estas x log x = Ω( log x ) classificações são log x = o( x log x ) possíveis x log x = Ω( log x ) Observe ainda que, na maioria dos casos, serão admissíveis várias classificações para uma mesma função. Nestes casos, sempre que possível, utilize a classificação mais justa e apertada (próxima do real) que conseguir. Isto facilitará a tarefa de escolha e comparação entre os algoritmos. 24 2.4 Técnicas de Análise 2.4.1 Introdução Agora que já temos conhecimentos sobre a análise de algoritmos e formamos um referencial teórico para o caso, vamos estudar formas práticas de realizarmos esta análise. Como vimos no capítulo anterior, existem duas formas de se expressar um algoritmo: iterativa e recursivamente. Bem, estes dois tipos de algoritmos necessitarão de análises separadas. A razão disto é que, no algoritmo recursivo, nunca temos clareza do momento no qual a recursão termina e fica muito difícil quantificar o gasto de recursos. Assim, primeiramente veremos como analisar algoritmos iterativos e depois, no próximo capítulo, ao estudarmos relações de recorrência estudaremos as formas de análise um algoritmo recursivo. 2.4.2 Análise de algoritmos iterativos Antes de mais nada, vamos retomar uma questão já abordada: o que queremos analisar? Como havíamos visto, o processo de análise visa a quantificação dos recursos computacionais gastos durante a execução da tarefa proposta. Por recursos podemos entender tudo que é caro ao processo. Para aprendermos as técnicas de análise, vamos especificar melhor isto. Voltemos ao exemplo dado no algoritmo 2.1, que faz a busca de um elemento dentro de um vetor. O que neste algoritmo caracteriza o consumo de recursos? Olhando de forma mais detalhada, observaremos que todas as operações realizadas gastam algum tipo de recurso do sistema. Como são, desta forma, as operações realizadas pelo algoritmo que consomem os recursos, resta-nos perguntar quais são elas? Neste caso, detalhando o procedimento, veremos que estas operações são as comparações, atribuições, somas e indexações ao vetor de trabalho. Estas últimas por fazerem, além do cálculo de posicionamento, um acesso à memória que é lento. Para efeito deste trabalho, utilizaremos cinco tipos ou grupos de operações básicas para análise: a) Somas e subtrações (+ -); b) Multiplicações e divisões (* /); c) Atribuições (:=); d) Testes (se, enquanto,...); 25 e) Indexações de vetor ([]). Perceba que o processo de análise não é rígido, ou seja, se necessitar analisar outro tipo qualquer de operação, o processo a ser descrito é análogo. Da mesma forma, muito autores não caracterizam a indexação como uma operação computável e, em alguns casos, até admissível, por entender que ela pode ser traduzida em operações mais básicas, com somas, atribuições e testes. Não procederemos assim por que tal grau de detalhismo não acrescenta ganhos substanciais ao processo como veremos a seguir. Uma vez que sabemos o que desejamos analisar, precisamos responder a uma única questão adicional: quantas vezes o algoritmo em análise executa cada uma destas operações listadas? Nesta questão está posto todo o segredo do processo analítico de algoritmos. Basta respondê-la que saberemos o comportamento daquele. Esta forma de proceder apresenta uma vantagem adicional já citada: é independente das condições de implementação. Sabendo estas quantidades, para cada operação, bastará conhecer o tempo de cada operação em uma máquina ou linguagem específica para sabermos o desempenho real com uma fidelidade bastante aproximado. 2.4.3 Processo de análise Este trabalho apresenta uma técnica particular de análise baseada (e muito) naquela tradicional da bibliografia da área, acrescida da experiência adquirida nas disciplinas ministradas sobre este assunto. Esta técnica é, necessariamente, dividida em pequenos passos que, se forem levados ao final, produzirão no resultado correto. Antes de mais nada, é fundamental uma boa compreensão do algoritmo. Esse é o primeiro passo. É impossível análise qualquer coisa que não se conheça bem e, para conseguir isto, a melhor alternativa continua sendo um teste de mesa bem feito. Ou seja, execute o algoritmo com lápis e papel para entender claramente a sua lógica. Lembre-se de que nem sempre o algoritmo em análise foi feito por você. Agora, partindo do pressuposto que o passo anterior foi realizado, veremos a próxima etapa. Em primeiro lugar, como já vimos antes, os algoritmos realizam procedimentos diferentes para cada um dos seus casos e, em especial, para o melhor e pior caso. Como conhecemos estes casos (ou devemos conhecêlos  também para isso foi realizada a etapa citada no parágrafo anterior) e não os demais, vamos trabalhar somente com eles. Assim, a próxima etapa será avaliar quantas vezes passaremos por cada uma das linhas do algoritmo em cada um dos casos. Isto é feito para facilitar a 26 tarefa de quantização das operações, que é o próximo passo. Utilizando o exemplo mencionado antes, uma boa alternativa é formar uma tabela, com as linhas do algoritmo formando as linhas da tabela e as quantidades serão as colunas. Veja a tabela 2.1. No 1 2 3 4 5 6 7 8 9 Algoritmo i := 1; enquanto i <= N e v[i] <> x faça i := i + 1; fim se i > N então retornar -1; Senão retornar i; Fim Passagens no melhor caso 1 1   1   1  Passagens no pior caso 1 N+1 N  1 1    Tabela 2.1 - Contagem de passagem pela linhas do algoritmo de busca. Observe aqui uma particularidade dos controladores de laço (enquanto, para, repita,....). Eles, no pior caso, executam uma vez mais do que o tamanho do seu escopo, por necessitarem “estourar” o seu limite. No caso acima, no laço enquanto, o final da iteração de pior caso se dará quando a variável i invalidar a primeira das condições ( i <= N ). Como o incremento de i é linear, isto acontecerá quando i = N + 1. Já a parte interna do laço é executada uma vez menos do que o controlador, pois ela não tem uso quando for verificado o estouro (observe isso no seu teste de mesa). Da mesma forma, os comandos fim e senão são pró-formas, pois apenas informam ao controlador do algoritmo pontos de continuidade. Continuando o nosso processo de análise, o próximo passo será avaliar, em cada uma das linhas do algoritmo, quantas operações básicas serão realizadas em cada caso. Isto está posto na tabela 2.2, para a qual se fazem necessários alguns comentários: a) Na linha 1, ocorre somente uma atribuição em ambos os casos, já que ela somente inicializa a contagem de posições e não tem influência direta no algoritmo; b) O laço enquanto (linha 2), realiza dois testes, i <= N e v[i] <> x, a cada passagem. Este passo executa ainda uma indexação de vetor a cada passagem. Para o pior caso ocorre uma pequena alteração no comportamento do laço, 27 28 pois na última iteração será realizado somente o primeiro dos testes que, ao ser falso, encerrará a execução desta parte. Note que nem todas as linguagens (quando da implementação), procedem desta forma e, se desejar, contabilize ambos os testes; c) Na parte interna do laço (linha 3), ao se incrementar a variável i, é realizada uma soma e uma atribuição; d) Na quarta linha (fim) não deve ser contabilizada pois, conforme foi dito, não toma tempo de execução. O comando senão (linha 7) apresenta a mesma característica e não representa um teste adicional; e) A quinta linha realiza um teste e, após este, retorna um dado valor. Note que o teste vale para os dois casos, mas somente uma das partes internas do mesmo é executa para cada um deles. Foi ainda considerado que o retorno do valor pressupõem a sua atribuição para alguma variável interna e esta deve ser contada. A parte final do processo se dá ao totalizarmos as quantidades. Por exemplo, neste caso, faremos 2 atribuições, 3 testes e 1 indexação no melhor caso e N somas, N+2 atribuições, 2N+2 testes e N indexações para o pior caso, ambos em um vetor de N elementos. Na tabela 2.3 é realizada a análise do método de ordenação bolha (veja [Aze96]) para um vetor de N elementos inteiros. Como todo o método de ordenação, o melhor caso para o bolha é encontrar o vetor já organizado na ordem desejada (já ordenado), enquanto que o pior caso ocorre quando os elementos do vetor estão na ordem inversa a desejada. Por este exemplo ser mais complexo, faça o seu teste de mesa, analise as quantificações e observe com atenção as características listadas a seguir. Em primeiro lugar vamos analisar o melhor caso: a) Na linha 1 o algoritmo passa uma única vez; b) Na linha 2 serão duas passadas, pois existe uma inicial e, dentro esta, quando da passagem completa pelo vetor sem fazer nenhuma troca, ocorrerá a verificação para o encerramento do laço já que a variável flag continua falsa; c) Internamente ao laço, as linha serão executadas apenas uma vez; d) Observe agora a linha 4. Por força do laço enquanto, chegaremos nela 1 única vez. Por outro lado, nesta única vez, ela irá executar N vezes, pois também é um laço. Assim, o número total de passagens é a multiplicação das duas, ou seja, N; e) Ainda na linha 4, vamos olhar o laço para internamente. Na primeira iteração, ele atribui o valor inicial à variável de controle e verifica se não houve estouro do limite superior. Nas demais, ele soma 1 à variável (i := i+1) e verifica o 29 30 estouro. Desta forma, veja uma pequena simulação deste laço variando de 1 até 5: 1a vez 2a vez 3a vez 4a vez 5a vez 6a vez => => => => => => i := 1 i := i + 1 i := i + 1 i := i + 1 i := i + 1 i := i + 1 => => => => => => testa se i <= 5 testa se i <= 5 testa se i <= 5 testa se i <= 5 testa se i <= 5 testa se i <= 5 => => => => => => O.k. O.k. O.k. O.k. O.k. falso e fim Veja então que, além de executar uma vez a mais a linha controladora do laço, o número de tarefas executadas pode ser diferente. É por essa razão que as colunas da tabela, para a contagem de operações desta linha apresentam valores diferentes; f) Obviamente, dentro do laço para, o número de execuções será de uma vez menos que ele. Para o pior caso, a situação é um pouco mais complexa. Observe os comentário a respeito da análise feita: a) A primeira linha, assim como no melhor caso, será executada uma única vez na inicialização da variável de controle; b) Na linha 2, ocorrerão tantas passagens quantas forem as trocas feitas no vetor. Nesta caso, a primeira passagem colocará o maior elemento na sua posição. A segunda varredura colocará o segundo maior elemento na posição, e assim por diante. Desta forma, serão necessárias N-1 passagens para posicionar todos os elementos (veja que não são N, pois a última passagem, ao trocar o penúltimo maior, já colocará o menor elemento na sua posição) e uma a mais para certificar que estão todos ordenados, sendo que nenhuma troca será feita. Portanto, serão N passadas no laço enquanto; c) Dentro do laço enquanto, toda a linha será executada 1 vez menos do que o controlador do laço. Portanto, N-1 vezes; d) Considere agora o laço para da linha 4. Ele, de forma isolada, seria executado N vezes, conforme visto nos comentários da análise de melhor caso. Como, entretanto, ele está dentro do laço enquanto, será executado, como qualquer uma das outras linhas, N-1 vezes. Portanto, o número total de execuções será a multiplicação das duas, ou seja, N(N-1) vezes; e) A linha 5 teve o mesmo raciocínio da linha 4. Ela seria executada N-1 vezes por estar dentro do laço para. Já pelo laço enquanto, outro tanto. Assim o total é a multiplicação de ambas: (N-1)(N-1) vezes; f) Dentro do teste, nas linhas 6 à 9, foi aplicada outra técnica. No pior caso, na primeira vez (laço enquanto) serão necessárias N-1 trocas para posicionar o maior elemento (veja o teste de mesa). Na segunda vez, o número de trocas 31 cai uma unidade, pois não haverá troca com o último elemento. Na terceira vez, serão N-3 trocas e assim sucessivamente até que, na última vez, haverá uma só troca. Portanto o número total de trocas e, consequentemente, de execução destas linhas será ( N − 1) + ( N − 2) + ( N − 3)+"+3 + 2 + 1 = N ( N + 1) . O 2 resultado do somatório pode ser obtido das propriedades dos somatórios ou através da soma dos termos de uma progressão aritmética  PA. Mais uma vez, totalizam-se as colunas para saber a quantidade final de operações realizadas em cada um dos casos. A coluna que informa o número total de passagens em cada uma das linha não tem porquê ser totalizada. Da mesma forma, não há razão para termos uma coluna destinada as operações de multiplicação e divisão, pois elas não estão presentes no algoritmo. 2.4.4 Considerações gerais Todo o processo de análise é meticuloso e detalhista. Para facilitá-lo ou torná-lo mais objetivo existem algumas considerações (ou regras) importantes. Veja: a) Contabilize apenas as linhas que realizam operações. Assim, comentários e os comandos fim e senão, não representam gastos; b) Sempre que houverem laços aninhados (um dentro do outro), lembre-se de que o(s) mais externo(s) faz(em) que o(s) interno(s) seja(m) executado(s) tantas vezes quanto qualquer outra linha interna. Desta forma, multiplique os totais individuais tantas vezes quantas necessário; c) Tome cuidado com os controladores de laço. Abra-os e analise o que acontece internamente a cada um deles; d) Quando existir, em um algoritmo, uma chamada a um procedimento ou outro algoritmo, analise este primeiro e substitua os totais encontrados nas respectivas colunas do algoritmo original, pois estes são os custos daquela linha; e) Nos testes, com cláusulas então e senão, lembre-se que apenas uma delas é executada a cada momento; f) Se você quiser a complexidade assintótica do algoritmo, classifique somente a expressão obtida no total da coluna; g) Propriedades matemáticas, como funções, somatórios e progressões são importantes para totalizar corretamente as operações. Procure um bom livro de matemática para auxiliá-lo; 32 h) Somente analise os algoritmo e casos que você conheça bem. Antes da análise é importante realizar um teste de mesa completo no algoritmo; i) Nunca some a linha dos totais, pois ela só tem sentido individualizada; j) Operações mais complexas devem sempre ser detalhadas e analisadas na sua composição. Por exemplo, laços, raiz quadrada, etc.. Estas operações, quando não for possível detalhar, devem ser quantificadas em uma nova coluna e informadas em separado; k) Existem algoritmos que tem o melhor e o pior casos iguais, como, por exemplo, a soma ou multiplicação de duas matrizes. Isto não significa que eles tenha um caso só. Eles tem ambos (só que iguais) e devem ser analisados em separado; l) Finalmente, em alguns casos não será necessário nem interessante fazer uma análise tão detalhada quanto a proposta. Pode ser que saber somente quantas trocas ou testes ou outra operação qualquer será feita, sem esmiuçar em demasia. Isto é muito frequente na bibliografia, inclusive para a análise de um problema. 2.5 Exercícios 1. Por que crescimento de funções, uma ferramenta matemática, é fundamental para a análise de algoritmos? 2. Escreva e analise um algoritmo que solucione um polinômio genérico na forma P(x) = A0 + A1X + A2X2 + ..... + AnXn. É possível melhorá-lo? Analise outras formas possíveis de solução para este caso. 3. É possível diminuir o número de passos do algoritmo de ordenação pelo método de bolha descrito na tabela 2.3? Esta redução, se obtida, também diminui a complexidade assintótica? 4. Segundo Paulo Carvalho (Algoritmos Geométricos para Computação Gráfica  SIBGRAPI, 1993): “É importante frisar que as notações O e Ω dão apenas estimativas, que podem depender das condições de análise do algoritmo. Assim, não há nenhuma contradição de um algoritmo ter complexidades O(n2) e O(n), pois ter complexidade O(n) implica em ter complexidade O(n2)”. Explique esta afirmação. 5. Classifique as funções abaixo por taxa de crescimento: 100n + log n n0.5 log n n log n n + log2 n log5 n log n2 n2 log-1 n n2n 22n 33 n log2 n 3n log log n 6. Utilize as funções acima e classifique-as entre si com as classes assintóticas. 7. Escreva e analise um algoritmo iterativo que faça uma pesquisa binária em um vetor de n elementos inteiros. 8. Marque como verdadeiro ou falso as afirmações abaixo e justifique cada uma delas: ( ) Se a complexidade de um algoritmo para o melhor caso é f(n) , então o número de passos que ele efetua, para qualquer entrada é Ω(f(n)). ( ) Se a complexidade de um algoritmo para o pior caso é f(n) , então o número de passos que ele efetua, para qualquer entrada é Θ(f(n)). ( ) A complexidade de melhor caso para um algoritmo é necessariamente maior do que qualquer limite inferior para o problema. ( ) Se duas funções f(n) e g(n) são crescentes e f(n) = O(h(n)) e g(n) = O(v(n)), então f(n) + g(n) = O( h(n) + v(n) ). ( ) Se duas funções f(n) e g(n) são crescentes e f(n) = O(h(n)) e g(n) = O(v(n)), então f(n) - g(n) = O( h(n) - v(n) ). ( ) O melhor caso de um algoritmo é sempre menor que seu caso médio que, por sua vez, é menor do que o pior caso. ( ) Se a complexidade de melhor caso de um problema é f(n), então deve existir um algoritmo que o soluciona em tempo O(f(n)). 9. Preencha as lacunas das afirmações abaixo: a) Se f(n) = Θ(g(n)) e g(n) = Θ(h(n)), então f(n) = ______(g(n)). b) Se f(n) = O(g(n)) e g(n) = O(h(n)), então f(n) = ______(g(n)). c) Se f(n) = Ω(g(n)) e g(n) = Ω(h(n)), então f(n) = ______(g(n)). d) Se f(n) = o(g(n)) e g(n) = o(h(n)), então f(n) = ______(g(n)). e) Se f(n) = Ω(g(n)) e g(n) = Ω(h(n)), então f(n) = ______(g(n)). f) f(n) = _____(f(n)). g) f(n) = Θ(g(n)) se, e somente se, g(n) = ______(f(n)). h) f(n) = O(g(n)) se, e somente se, g(n) = ______(f(n)). i) f(n) = Ω(g(n)) se, e somente se, g(n) = ______(f(n)). 34 10. Considere duas matrizes A e B, quadradas de ordem n, e três algoritmos assim descritos: • O algoritmo X deve fazer a soma das duas matrizes; • O algoritmo Y deve fazer a multiplicação das duas matrizes; • O algoritmo Z recebe um valor f (booleano) como parâmetro e, se f for verdadeiro, faz uma chamada ao algoritmo X. Caso contrário, executa o algoritmo Y. Com estas definições, responda as seguintes questões: a) Escreva os três algoritmos. b) Analise o melhor caso, o caso médio e o pior caso dos algoritmos X e Y. c) Analise o melhor e o pior caso do algoritmo Z. d) Considerando a existência de uma quantidade p que representa a probabilidade de que a variável f seja verdadeira, responda qual é o caso médio do algoritmo Z. 11. Escreva e analise um algoritmo que busca o menor e o maior elemento dentro de um vetor de n elementos. Este algoritmo deve executar esta tarefa em uma única passagem no vetor. 12. Escreva e analise um algoritmo que, dada uma matriz quadrada de ordem n, encontre a inversa desta (utilize a regra dos cofatores para inverter a matriz). 13. Analise os algoritmos de ordenação descritos a seguir. Todos eles trabalham com um vetor de n elementos inteiros. a) procedimento ordenaA( var v:vetor[1..N] de inteiros ) var i, j, min, aux: inteiros; início para i := 1 até N-1 faça min := i; para j := i+1 até N faça se v[j] < v[min] então min := j; aux := v[i]; v[i] := v[min]; v[min] := aux; fim fim 35 b) procedimento ordenaB( var v:vetor[1..N] de inteiros ) var i, j, aux: inteiros; início para i := 2 até N faça aux := v[i]; j := i – 1; enquanto j > 0 e v[j] > aux faça v[j+1] := v[j]; j := j – 1; fim v[j+1] := aux; fim fim c) procedimento ordenaC( var v:vetor[1..N] de inteiros ) var i, j, aux: inteiros; início para i := 2 até N faça aux := v[i]; j := i; v[0] := aux; /* Sentinela */ enquanto v[j-1] > aux faça v[j] := v[j-1]; j := j – 1; fim v[j] := aux; fim fim 36 3 Relações de Recorrência 3.1 Introdução No capítulo anterior, verificamos a razão e forma de analisarmos algoritmos. Entretanto, nada foi mencionado sobre como realizamos esta análise em algoritmos recursivos, direta ou indiretamente. Há uma diferença fundamental entre analisarmos algoritmos iterativos e algoritmos recursivos. No primeiro caso, a metodologia proposta é baseada em uma contagem (quantificação) do número de operações realizadas por cada linha do algoritmo. Já no caso recursivo, isto é muito difícil de ser feito desta forma direta, uma vez que não conhecemos com precisão o número de vezes que executamos as chamadas recursivas e, consequentemente, as execuções de cada linha. É justamente neste horizonte e para a solução (ou para facilitação da solução) deste problema que lançamos mão de uma outra ferramenta conhecida como relação de recorrência. 3.2 Definição A definição e a utilização de relações de recorrência extrapola o trato de algoritmos. Na verdade ela é uma ferramenta matemática para explicar funções em série. Vamos considerar uma exemplo! Possivelmente todos já ouviram falar de uma série matemática conhecida como Série de Fibionacci ou Números de Fibionacci. Ela é assim definida: 37 • o primeiro número da série é 1; • o segundo número é 1; • a partir do terceiro, cada número é resultado da soma dos dois anteriores. Segundo esta definição, a sequência é assim disposta: 1 1 2 3 5 8 13 21 34 55 89 ...... Sendo o primeiro e o segundo números 1, o terceiro é a soma de ambos, ou seja, 2. Uma vez conhecido este terceiro valor, a sua soma com o segundo resulta no quarto deles. Seguindo este procedimento, sucessivamente, serão obtidos os demais valores. Esta série pode ser expressa, de forma mais precisa e matemática, com a seguinte expressão:  F (1) = 1   F ( 2) = 1  F (n) = F (n − 1) + F (n − 2)  Perceba que esta definição é idêntica aquela dada acima e aplica-se da mesma forma na busca dos valores: F(1) = 1 F(2) = 1 F(3) = F(2) + F(1) = 1 + 1 = 2 F(4) = F(3) + F(2) = 2 + 1 = 3 F(5) = F(4) + F(3) = 3 + 2 = 5 : : Bem, a Série de Fibionacci é uma relação de recorrência. Sabendo disto, vejamos a sua definição formal: Relação de Recorrência é uma equação ou inequação que descreve uma função ou série numérica utilizando-se dela própria na definição. Um outro exemplo bastante comum é o cálculo do fatorial de um número inteiro. Ele é definido matematicamente como: 1!= 1  n! = n ⋅ (n − 1)!  F (1) = 1   F (n) = n ⋅ F (n − 1) ou Não é muito complicado perceber uma conexão entre esta forma de expressar uma relação de recorrência e a sua solução via algoritmos recursivos. 38 Basta comparar a definição do fatorial acima com o algoritmo da figura 1.2 para ver que este é a implementação daquela. Sempre, nestes casos, os elementos conhecidos da relação (caso base) são utilizados para terminar as chamadas recursivas. Por exemplo, F(1)=1 no caso do fatorial. Já a definição recorrente ( F(n) = n . F(n-1) ) é justamente o elemento principal da chamada recursiva. Se este raciocínio é válido neste sentido, da relação de recorrência ao algoritmo, o inverso também é. Ou seja, dado um algoritmo, é possível encontrar a relação de recorrência capaz de descrevê-lo. procedimento busca_binária( v: vetor[1..N] de inteiros; x,min,max: inteiro ): inteiro variáveis meio: inteiro; início se max < min então /* Não encontrou o elemento */ retorna –1; meio := ( min + max ) / 2; se v[meio] = x então retorna meio; /* Encontrou o elemento na posição meio */ se x < v[meio] então retorna busca_binária( v, x, min, meio-1 ); senão retorna busca_binária( v, x, meio+1, max ); fim fim Algoritmo 3.1 - Busca binária em um vetor ordenado. Considere, por exemplo, o caso do algoritmo 3.1. Neste algoritmo é buscado um valor em um vetor de números inteiros ordenados crescentemente através do método de busca binária, ou seja, encontra-se o meio do vetor; se este meio é o elemento procurado, a busca termina; caso contrário, se o elemento buscado é menor do que aquele presente no meio do vetor, ele somente poderá estar na parte inferior do mesmo; senão, na parte superior. A busca irá terminar quando não tivermos mais intervalos de valores a pesquisar (max < min). Para simplificar, vamos contar somente o número de passagens em cada uma das linhas válidas. Em cada chamada ao procedimento, as linhas serão executadas apenas uma vez, com exceção daquelas dentro dos testes, que podem não ser executadas. Supondo que não seja encontrado o elemento, será realizado o primeiro teste, o cálculo da variável meio, o segundo e o terceiro testes. Desta forma, serão quatro linhas executadas ao todo. 39 Na última chamada recursiva, quando o valor é determinado como não presente no vetor pois o intervalo de trabalho não tem valores válidos, somente um teste será realizado. Assim, podemos descrever este algoritmo na seguinte forma recorrente: T (1) = 1 T (n) = T ( n ) + 4  2 Ou seja, o trabalho para solucionar o problema com um elemento na entrada (análogo ao caso onde não há nenhum mais a pesquisar), será de 1 operação  teste de verificação do final. Se o problema tiver uma entrada maior, serão necessárias 4 operações (testes e cálculo do meio) e uma chamada recursiva para o mesmo problema, só que com uma entrada equivalente a metade da original. Assim, é sempre possível extrairmos uma relação de recorrência de um algoritmo recursivo. O ponto de término da recursão será o caso base da recorrência. O número de chamadas recursivas aparece na definição recorrente e o trabalho adicional, medido por operações se necessário, no acréscimo à esta definição. Veja mais um exemplo: T (1) = 2 T (n) = 2T ( n ) + n  2 Se esta relação de recorrência representa um algoritmo, ele necessita de duas operações para concluir o caso óbvio (não é possível dizer que tipo de operação). Não sendo este caso, ele divide o problema pela metades (T(n/2)) e executa recursivamente o algoritmo para estas duas metades (2T(n/2)). Ele ainda faz um trabalho adicional de n operações, que pode ocorrer antes, entre ou depois das duas chamadas recursiva (para a quantificação é indiferente o momento no qual isso acontece). 3.3 Métodos de Solução 3.3.1 Introdução Uma vez compreendido o conceito e a função de uma relação de recorrência, verificamos que, no nosso caso, ela representa o trabalho ou o comportamento de um algoritmo recursivo. Resta-nos então buscar a solução desta relação. Por solução se entende o total geral de operações realizadas por ela, em todas as chamadas e representará o trabalho total realizado pelo algoritmo. 40 Existem na bibliografia da área ([AU92], [Baa88], [CLR91], [Knu73], [Man89], entre outros) três formas de se buscar esta solução: por substituição ou prova inteligente; por história completa; e, pelo método mestre. A primeira das alternativas, por prova inteligente, é a mais abrangente das três propostas, mas pressupõem o uso de uma ferramenta matemática conhecida como indução. Como este assunto está fora do escopo deste texto, serão apresentados e analisados os dois outros métodos. Na bibliografia citada acima você encontrará este método indutivo com detalhes; em especial em [AU92] e [Man89]. 3.3.2 Método de história completa Como foi visto, uma relação de recorrência, assim como um algoritmo recursivo, apresenta uma dificuldade adicional na busca da solução, pois não conhecemos o seu ponto de parada, ou seja, quando acontece a última chamada recursiva. Veja, se soubermos o número de vezes que uma recorrência é executada, teremos apenas que somá-la para encontrar o resultado final. O problema reside exatamente neste fato, não sabemos com clareza como isso acontece. A solução de uma recorrência por história completa trabalha justamente neste horizonte. O objetivo é “abrir” a recorrência tentando deduzir coisas sobre ela de forma a chegarmos ao total. Ou seja, tenta-se mostrar a sua história. Vamos utilizar como primeiro exemplo a seguinte relação: T (1) = 1 T ( n) = 2T ( n ) + n  2 (3.1) Supondo uma entrada ou um dado de tamanho n, no nível mais alto a recursão trabalhará o equivalente a este n acrescida de duas chamadas recursivas a ela mesma com uma entrada de metade do original. Perceba que cada uma destas chamadas recursivas, no primeiro nível, terá a forma: T ( n ) = 2T ( n ) + n 2 4 2 Seguindo o mesmo raciocínio, T ( n ) = 2T ( n ) + n 4 8 4 T ( n ) = 2T ( n ) + n 8 16 8 Então, da relação 3.1 pode ser feita a seguinte leitura (perfeitamente correta): ao receber uma dada entrada (independente do tamanho), ela trabalha 41 duas vezes, de forma análoga, com uma entrada com tamanho de metade da original; após, (poderia ter sido antes), realiza um trabalho adicional equivalente (da mesma ordem) ao que entrou. Segundo esta idéia, esta recorrência poderia ser escrita assim: T ( n ) = 2T ( n ) + n 2 ou, sabendo que T ( n ) = 2T ( n ) + n , na forma: 2 4 2 ( ) T ( n ) = 2 2T ( n ) + n + n 4 2 E assim, este desmembramento pode continuar indefinidamente: ( ( ( ( ( ) ) T (n ) = 2 ⋅ 2 ⋅ 2T ( n ) + n + n + n 8 4 2 n n T (n ) = 2 ⋅ 2 ⋅ 2 ⋅ 2T ( )+ +n +n +n 16 8 4 2 : : ) : ) ) : Obviamente esta forma de desenho da recorrência ou da sua história é muito confusa e na acrescenta à busca da solução, pelo menos a primeira vista. Desta forma, este método de solução faz a descrição da relação na forma de uma árvore. Veja: T ( n ) = 2T ( n ) + n 2 n T (n 2 T (n ) 2 ) Nesta árvore nós temos o trabalho adicional como elemento centralizador. A partir deste elemento ocorrem tantas ramificações quantas as chamadas recursivas da recorrência. Neste caso serão duas delas (2T(n/2)), sendo cada uma delas de T(n/2). A mesma forma de procedimento deve ser utilizada para cada uma das ramificações. Como T ( n ) = 2T ( n ) + n 2 4 2 n T (n 4 ) 2 T (n 4 ) ele pode ser levado para a primeira árvore e ela aumentará um nível. Veja o resultado: 42 n T (n 2 ) n T (n 2 n ) T (n 4 ) n 2 T (n 4 ) T (n 4 ) 2 T (n 4 ) Agora, se ampliarmos sempre este raciocínio encontraremos, para este caso, a árvore apresentada na figura 3.1. Esta forma de desenho da recorrência ainda não apresenta a solução da mesma, mas fornece importantes detalhes sobre o seu comportamento. Figura 3.1 – Árvore da história completa da recorrência 3.1. Em primeiro lugar, perceba que necessitamos somar todo o conteúdo desta árvore para sabermos a solução da recorrência. Neste ponto esbarramos no mesmo problema inicial com a relação original: o final da execução. Aqui, entretanto, está mais fácil se chegar a uma dedução sobre o tema. Antes disso, vamos somar cada um dos níveis desta árvore. O primeiro nível, por ter comente n, tem este como soma. O segundo, por sua vez, também soma n. O 43 mesmo acontece com o terceiro nível. A árvore completa, com seus níveis somados está na figura 3.2. Este desenho permite deduzir que todos os níveis desta árvore somam um trabalho equivalente a n. Figura 3.2: Árvore de história completa com somatório dos níveis Sabemos agora que a mesma recorrência 3.1 pode ser escrita como: X T ( n) = n + n + n + n + " + n = ∑ n i =1 Fica somente uma questão ainda a ser respondida: quantas vezes isto acontece? Ou então, quantos níveis tem está árvore? Ou ainda, quanto é o valor do X no somatório acima? Para isso basta analisar um pouco melhor a recorrência e lembrar (ou relembrar) algumas propriedades básicas da matemática. Note que, em cada um dos níveis da relação estamos dividindo o elemento do nível sempre por dois. Segundo a definição desta relação, a divisão deve parar quando o tamanho da entrada chegar até 1  que é o caso óbvio. Note que, se o final for um nível acima, com T(2) = x, basta diminuir um do valor encontrado e assim por diante. Assim, a nossa pergunta fica resumida a seguinte: quantas divisões serão necessárias em n, pelo valor 2, até que ele chega a 1? Para encontrar a resposta, vamos supor, sem perda de correção, que n é potência de 2. Assim, esta divisão é exata e pode ser expressada de outra forma: quantas potências de 2 serão necessárias para chegar até n? Ou seja, 44 2x = n ? Segundo o estudo de logaritmos sabemos que a resposta desta questão é x = log 2 n . Este mesmo raciocínio funciona ao expandirmos o valor de n para qualquer número que não seja, necessariamente, uma potência de 2. A única diferença nestes caso esta no fato do número de divisões não ser inteiro, o que não apresenta nenhum problema. De forma mais simplista possível, podemos formular uma pequena regra para este tipo de caso: sempre que dividirmos sucessivamente um valor qualquer por 2, a divisão chegará até 1 após log2 n execuções. Para ser mais genérico ainda, se a divisão for por outro valor qualquer, além de 2, podemos utilizar o mesmo procedimento, apenas a base do logaritmo irá mudar. Finalmente, se ocorreram log2 n divisões, deduzimos que a árvore que estamos analisando tem log2 n níveis, somando n cada um deles. Assim, podemos reformular a totalização como: T ( n) = n + n + n + n + " + n = log 2 n ∑ n = n log i =1 2 n Este é o total de trabalho realizado pelo algoritmo descrito através da recorrência 3.1. Figura 3.3 – Árvore da história completa da recorrência 3.2. 45 Vamos agora para um segundo exemplo dado pela relação a seguir e tendo como história da árvore da figura 3.3. A relação é: T (1) = 1 T (n) = 2T ( n ) + 1  3 (3.2) Veja que está relação executa um trabalho adicional sempre constante, para qualquer tipo de tamanho de entrada. Desta forma: T (n) = 2T ( n ) + 1 3 T ( n ) = 2T ( n ) + 1 3 9 n n ) +1 T ( ) = 2T ( 9 27 : : : Então, segundo a descrição já feita, a recomposição desta recorrência é dada por: T (n) = 1 + 2 + 4 + 8 + 16 + " = log 3 n ∑2 i = 2 log 3 n+1 − 1 i =0 Como já foi visto, o número de divisões por 3 necessárias para conduzir o valor n até 1 é equivalente ao logaritmo do dividendo na base do divisor, ou seja, log3 n . Como é mais usual, quando se tratando de algoritmos, o uso de logaritmos na base 2 (binária), pode ser feita uma conversão de base: log 3 n = log 2 n log 2 3 Uma outra observação é importante quanto ao somatório encontrado. É óbvio que este somatório é de potência consecutivas de 2 (visto na árvore) e que o final deste somatório acontece no nível de número log3 n . Entretanto, de onde surgiu o resultado do somatório? Este vem de mais uma propriedade da matemática que trata do estudo de somatório. Esta propriedade diz que a soma de potências consecutivas de 2 é dada pela potência seguinte a última diminuída de 1 unidade. Ou seja: x ∑2 i = 2 x +1 − 1 i=0 Finalmente, vamos para um último exemplo: 46 T (1) = 1 T ( n) = T ( n ) + T ( 2n ) + n  3 3 (3.3) Figura 3.4 – Árvore da história completa da recorrência 3.3. Neste caso existem ainda duas ramificações na árvore, uma vez que são duas chamadas recorrentes à função. Acontece apenas que ambas são diferentes. Veja a figura 3.4. Nesta, a ramificação à esquerda de cada nó representa a chamada T(n/3) e à direita a chamada T(2n/3). Segundo esta árvore, o resultado final é: T (n) = n + n + n + " + n = log 3 n ∑ n = n log i =1 = n⋅ 3 n= log 2 n 1 = ⋅ n log 2 n = 0.63n log 2 n log 2 3 log 2 3 Para encerrarmos este método, já é possível caracterizar a forma de procedimento adotada: a) Em primeiro lugar, desenha-se a recorrência em forma de árvore. Isto faz com que a visualização de todo o comportamento da mesma fique facilitada; b) Após, somam-se os níveis desta árvore. Com isto buscamos deduzir o comportamento de todos os níveis existentes. Obviamente o desenho feito deve conter níveis na árvore o suficiente para permitir uma dedução correta; 47 c) Uma vez conhecido o comportamento de cada nível, basta saber quantos eles são para conseguir totalizar a estrutura; d) Lembre-se que a resposta somente será válida se o somatório dos níveis for eliminado. Deve-se apresentar uma valor total e final. Para isso é importante lembrar e recorrer a conceitos básicos da matemática como aqueles vistos e mencionados antes. 3.3.3 Método mestre Este é o método mais fácil e direto de todos. Em geral, recomenda-se que sempre se busque a solução com ele. Se isto não for possível, tenta-se então utilizar história completa. Existem duas formas de expressar este método, sendo a segunda delas um caso especial (ou simplificação) da primeira. A primeira forma apresenta uma solução para as recorrências que tenham o seguinte formato: T (1) = 1 T ( n) = aT ( n ) + f ( n)  b onde a ≥ 1, b > 1 e f(n) deve ser uma função positiva. Para a segunda forma, as recorrências devem ter o seguinte formato: T (1) = 1 T ( n) = aT ( n ) + cn k  b onde a ≥ 1, b ≥ 2 e {c,k} > 0. Estando a recorrência enquadrada em uma destas formas, este método, depois de algumas verificações, apresenta um resultado direto da mesma. Este resultado é apresentado na tabela 3.1. Este método nada mais é, então, do que a verificação do enquadramento da recorrência em algumas condições pré-determinadas. Caso estas condições sejam satisfeitas, a resposta é fornecida. Na tabela 3.1, estas condições estão expressas nas duas colunas intermediárias e a resposta é dada na última das colunas. Por simplicidade, busca-se sempre enquadrar a recorrência no segundo formato. Caso isto não seja possível, verifica-se então o primeiro deles. Vamos agora analisar alguns exemplos: Exemplo 1 48 T (1) = 1 T ( n) = 2T ( n ) + 2n  2 Este caso é análogo ao segundo formato, com: a=2 Formato T (1) = 1 T ( n) = aT ( n ) + f ( n)  b b=2 c =2 Condições de enquadramento da recorrência a≥1 b>1 f(n) positiva T (1) = 1 T (n) = aT ( n ) + cn k  b k=1 Condições da solução Resultado T(n) = f ( n ) = Ο ( n log b a − β ) Θ(n logb a ) com β > 0 f ( n ) = Θ ( n log b a ) a≥1 b≥2 {c,k} > 0 f ( n ) = Ω ( n log b a + β ) Θ(n logb a log n) Θ( f (n)) com β > 0 e a ⋅ f ( n ) ≤ α ⋅ f (n) b para alguma α < 1 a > bk Ο(n log b a ) a = bk Ο(n k log n) a < bk Ο(n k ) Tabela 3.1 – Método mestre Testamos então a e bk para verificar qual o enquadramento deste caso. Como a = bk (2 = 21), a solução desta recorrência é: T (n) = Ο(n log n) Exemplo 2 T (1) = 1 T ( n) = 4T ( n ) + log n  2 Este caso não se enquadra no segundo formato, pois a função final (log n) não tem o formato cnk solicitado. Assim, utilizamos o primeiro formato com: a=4 b=2 49 f(n) = log n O primeiro passo agora é encontrarmos logb a : log b a = log 2 4 = 2 Com ele podemos testar as outras condições internas do caso. Aqui vale um comentário quanto as constantes α e β. Estas devem ser “encontradas”. Caso exista uma delas que valide a condição pedida, o teste pode ser considerado verdadeiro. Nos três testes deste caso, a verificação inicial é feita com a função f(n). Como este é log n e o elemento de comparação é β, buscamos saber se: n log b a , independente (ainda) do log n ? n2 Obviamente log n < n2. Assim, estamos enquadrados no primeiro dos sub-casos deste formato de relação. Necessitamos apenas encontrar um valor para β que continue validando o teste acima. Como a comparação entre ambos é por taxa de crescimento, qualquer β no intervalo ]0;2[ faz com a afirmação seja verdadeira. Veja, por exemplo, como fica para um β = 1: log n = O(n2-1) log n = O(n) O.k. Com todas estas condições e testes verificados e satisfeitos, determina-se que a solução da relação de recorrência deste exemplo é: T ( n) = Θ( n 2 ) Exemplo 3 T (1) = 1 T (n) = 3T ( n ) + n log n  4 Novamente esta é uma relação de recorrência do primeiro dos casos, com: a=3 b=4 f(n) = n log n e, log b a = log 4 3 = 0.793 Agora, comparando f(n) com n log b a veremos que: n log n > n0.793 50 Assim, estamos no terceiro sub-caso e a condição agora é dupla: precisamos de um β e um α. Primeiro o β. Para ele, qualquer valor no intervalo ]0;0.2] funciona. Por exemplo, β = 0.2: n log n = Ω(n0.793+0.2) n log n = Ω(n0.993) O.k. Validando esta condição, necessitamos ainda encontrar um valor para α que valide a segunda condição. Assim: n n af ( n ) = 3 ⋅ f ( n ) = 3 ⋅ ⋅ log  b 4 4 4 e, cf (n) = c ⋅ n log n Neste caso, um valor para c = 3/4 resolve o problema. Veja: n n 3 3 ⋅ ⋅ log  ≤ ⋅ n log n 4  4 4 Assim, verificadas ambas as condições, a solução final é: T (n) = Θ(n log n) 3.3.4 Considerações finais Como já foi dito, o método mestre é o mais simples deles. Busque sempre a solução por ele e, caso não seja possível, tente por história completa. Se este último não funcionar também, somente então, tente o método indutivo da suposição inteligente. Observe ainda que os métodos nem sempre apresentam o mesmo resultado. Tome como exemplo o métodos que analisamos. Neles verificamos que o método mestre já fornece a classificação assintótica da recorrência, enquanto que, por história completa, obtemos o final do somatório somente. Entre os dois pode haver uma diferença razoável, uma vez que a classificação assintótica, como vimos, faz uma aproximação por taxa de crescimento das funções. Finalmente, se quiser obter a classificação assintótica através do método de história completa, basta classificar o resultado final de acordo com a sua taxa de crescimento. 3.4 Exercícios 51 1. Solucione as relações de recorrência a seguir através de todos os métodos possíveis: T (1) = 1 a)  T ( n) = 3T ( n 2 ) + n b) T (1) = 1 T ( n) = 2T (n − 1) + n T ( 2) = 2 c)  n T ( n) = 3T ( 3 ) + 2n T (1) = 1 d)  2n T ( n) = T ( 3 ) + 2n T (1) = 1 e)  2 T ( n) = 2T ( n 2 ) + n f) T (1) = 1 T (n ) = T (n − 1) + n T (1) = 1 g)  T ( n) = 2T ( n 2 ) + n log n T (1) = 1 h) T ( 2) = 4  T ( n) = 8T ( n − 1) − 15T (n − 2)  T (1) = 1 i)  3 T ( n) = 2T ( n 2 ) + n T (1) = 1 j)  2 n T ( n) = 7T ( 3 ) + n k) T (1) = 1 T ( n) = T ( n ) + 1 2. Encontre algoritmos recursivos, extraia e solucione as relações de recorrência que os descrevem. 3. Explique as relações de recorrência do exercício 1 sob a ótica do comportamento ou trabalho do algoritmo que elas descrevem. 52 Anexo I Descrição da Linguagem 1 Palavras reservadas até enquanto fim não procedimento se caracter então início novo programa senão de estrutura inteiro ou real var e faça libera para retornar vetor 2 Tipos de dados Tipo Descrição Sintaxe caracter Dados alfanuméricos Nome: caracter; dentro do admissível na tabela ASCII inteiro Dados numéricos Nome: inteiro; inteiros sem limite de valor real Dados numéricos reais Nome: real; sem limite de valor booleano Valores da álgebra Nome: booleano; booleana: verdadeiro ou falso; 53 nulo Tipo de dado especial Nome_variável := nulo; para designar apontadores que não referenciam nenhuma posição de memória 3 Vetores e matrizes Tipo vetor Descrição Sintaxe Vetores e matrizes nome: vetor [início...final] de qualquer dimende tipo_dado; são nome: vetor [início...final [, início...final,...] de tipo_dado; 4 Estruturas Tipo estrutura Descrição Cria um tipo de dado com um agrupamento de tipos existentes Obs: É possível criar uma matriz ou vetor a partir de uma estrutura ou utilizar estruturas de forma aninhada. Sintaxe estrutura nome_estrutura nome: tipo_dado; nome: tipo_dado; : : : : : fim 5 Operadores Tipo + Descrição Soma dois numéricos. Sintaxe valores valor1 + valor2 54 - Subtrai dois numéricos valores valor1 – valor2 * Multiplica dois valores valor1 * valor2 numéricos. / Divide dois numéricos. := Atribuição . Operador para acesso aos nome_estrutura.dado elementos internos de uma estrutura ^ Declaração de apontado- nome: ^tipo_de_dado; res. Tipo de dado pode ser qualquer um da tabela do item 2 exceto o tipo nulo. = Teste de igualdade entre valor1 = valor2 dois valores. O resultado é do tipo booleano. <> Teste de diferença entre valor1 <> valor2 dois valores. O resultado é do tipo booleano. < Teste de menor que entre valor1 < valor2 dois valores. O resultado é do tipo booleano. > Teste de maior que entre valor1 > valor2 dois valores. O resultado é do tipo booleano. <= Teste de menor ou igual valor1< = valor2 que entre dois valores. O resultado é do tipo booleano. >= Teste de maior ou igual valor1 >= valor2 que entre dois valores. O resultado é do tipo booleano. [] Indexação de vetores. O nome_vetor[valor] valor utilizado deve ser inteiro. 55 valores valor1 / valor2 valor1 := valor2 ou expressão % Resto da divisão entre valor1 % valor2 dois valores inteiros. e Operador lógico e. Os valor1 e valor2 valores envolvidos devem ser booleanos. ou Operador lógico ou. . Os valor1 ou valor2 valores envolvidos devem ser booleanos. não Operador lógico não. . Os não valor1; valores envolvidos devem ser booleanos. 6 Controladores de laço Tipo Descrição Sintaxe Para Varia incondicional- para nome := início até final mente a variável de faça controle do valor (comandos) inicial até o final. Estes valores devem fim ser inteiros. enquanto Executa os comandos enquanto condição faça internos enquanto a (comandos) condição posta for verdadeira. fim 7 Testes Tipo se ... então Descrição Sintaxe Teste lógico que se condição então executa as linhas (comandos) internas somente se a condição for aceita. fim 56 se ... então.. Teste lógico que se condição então senão... apresenta um conjunto (comandos) de comandos para quando a condição é senão aceita (então) e outro para o caso contrário (comandos) (senão). fim 8 Alocação de memória Tipo Descrição Sintaxe novo Aloca espaço de me- nome := novo tipo_dado; mória para a variável descria de acordo com o seu tipo de dado libera Libera o espaço de libera nome; memória alocado com o comando novo 9 Procedimentos e programas programa nome_programa; var (lista de variáveis globais) procedimento nome( var lista_de_parâmetros ): tipo_dado_retorno; var (variáveis locais ao procedimento) início (comandos) retornar dado_retorno; fim (lista de procedimentos)  Isto é um comentário /* Aqui inicia o programa principal */ início (comandos do programa principal) fim 57 Obs: O comando retornar força o final do procedimento. Se houver algum valor de retorno definido no cabeçalho do procedimento, ele deve vir logo após este comando. A presença da palavra reservada var antes dos elementos no cabeçalho do procedimento caracteriza a passagem de parâmetros por referência. A omissão desta palavra é sempre entendida como sendo uma passagem de parâmetros por valor. 10 Procedimentos pré-definidos Tipo Descrição Sintaxe ler Ler um valor para a ler( nome [,nome2,..] ); variável descrita a partir da entrada escrever Escreve na saída os escrever( valores parâmetros passados mensagens ); 58 ou Bibliografia [AHU74] Alfred Aho, John Hopcroft and Jeffrey Ullman. The Design and Analysis of Computer Algortihms. Addison-Wesley, 1974. [AHU83] Alfred Aho, John Hopcroft and Jeffrey Ullman. Data Strucutres and Algorithms. Addison-Wesley, 1983. [Amm88] Leendert Ammeraal. Programs and Data Structures in C. John Wiley & Sons, 1988. [AU92] Alfred Aho and Jeffrey Ullman. Foundations of Computer Science. Computer Science Press, 1992. [Aze96] Paulo Azeredo. Métodos de Classificação de Dados e Análise de suas Complexidades. Campus, 1996. [Baa88] Sara Baase. Computer Algorithms: Introduction to Design and Analysis, 2nd edition. Addison-Wesley, 1988. [CLR91] Thomas Cormer, Charles Leiserson and Ronald Rivest. Introduction to Algorithms. McGraw-Hill, 1991. [CM94] Ruy Campelo and Nelson Maculan. Algoritmos e Heurísticas: Desenvolvimento e Avaliação de Performance. EDUFF, 1994. [EW__] Jeffrey Esakov and Tom Weiss. Data Structures: an Advanced Approach Using C. Prentice-Hall, ____. [GJ79] M. Garey and D. Johnson. Computer and Intractability: A Guide to the Theory of NP-Completeness. W. Freeman, 1979. [Hel92] Gilbert Held. Compressão de Dados: Técnicas e Aplicações. Érica, 1992. [HS82] Ellis Horowitz and Sartaj Sahni. Fundamentals of Data Structures. Computer Science Press, 1982. [Kel89] Stan Kelly-Bootle. Mastering Turbo C, 2nd edition. Sibex, 1989. [Knu73] Donald Knuth. The Art of Computer Programming: Fundamentals Algorithms. Addison-Wesley, 1973. [Knu75] Donald Knuth. The Art of Computer Programming: Sorting and Searching. Addison-Wesley, 1975. [Knu81] Donald Knuth. The Art of Computer Programming: Seminumerical Algorithms. Addison-Wesley, 1981. 59 [Man88] Udi Mamber . Using Induction to Design Algorithms. Communications of the ACM, 31 (11), 1300-1313, 1988. [Man89] Udi Mamber. Introduction to Algorithms: A Creative Approach. Addison-Wesley, 1989. [Rab92] Márcia Rabuske. Introdução à Teoria dos Grafos. Editora da UFSC, 1992. [Sam90] Hanan Samet. The Design and Analysis of Spatial Data Structures. Addison-Wesley, 1990. [Sch90] Herbert Schildt. C - The Complete Reference. McGraw-Hill, 1990. [Sed88] Robert Sedgewick. Algorithms, 2nd edition. Addison-Wesley, 1988. [SM94] Jayme Szwarcfiter and Lilian Markenzon. Estruturas de Dados e seus Algoritmos. LTC, 1994. [Swa91] Joffre dan Swait Jr. Fundamentos Computacionais: Algoritmos e Estruturas de Dados. Makron Books, 1991. [Szw86] Jayme Szwarcfiter. Grafos e Algoritmos Computacionais, 2a edição. Campus, 1986. [TA86] Aaron Tenenbaun and Moshe Augenstein. Data Structures Using Pascal, 2nd edition. Prentice-Hall, 1986. [Tan94] Andrew Tanenbaum. Redes de Computadores. Campus, 1994. [Ter91] Routo Terada. Desenvolvimento de Algoritmos e Estruturas de Dados. Makron Books, 1991. [TS84] Jean-Paul Tremblay and Paul Sorenson. An Introduction to Data Structures with Applications, 2nd edition. McGraw-Hill, 1984. [VF*93] Marcos Villas, Andréa Ferreira, Patrick Leroy, Cláudio Miranda and Christine Bockman. Estruturas de Dados: Conceitos e Técnicas de Implementação. Campus, 1993. [WB__] Benjamen Wah and C. Ramamoorthy. Theory of Algorithms and Computation Complexity with Applications to Software Design. Handbook of Software Engineering, ___. [Ziv93] Nívio Ziviani. Projeto de Algoritmos com Implementações em Pascal e C. Pioneira, 1993. 60