Complexidade de Algoritmos Eficiência Assintótica Roberta Geneci Neves Weber Rafael Coninck Teigão Fundamentos de Engenharia de Software Programa de Pós-Graduação em Informática Aplicada Pontifı́cia Universidade Católica do Paraná [email protected] [email protected] Maio de 2005 Resumo O presente documento faz uma explanação sobre complexidade de algoritmos. Nele será abordada uma metodologia para a realização da análise de eficiência assintótica de algoritmos recursivos e não-recursivos, com ênfase para a notação O, além de uma apresentação ilustrativa das principais funções de complexidade. 1 1 Introdução Para um dado problema com solução computacional, é possı́vel criar uma infinidade de algoritmos que o resolvam. Como podemos definir se um algoritmo é melhor que o outro? Assumindo que todos sejam eficazes1 , devemos, então verificar qual é o mais eficiente. Eficiência pode ser definida como a capacidade de se atingir um resultado correto, utilizando a menor quantidade de recursos e tempo possı́vel. Ou seja, para que um algoritmo seja mais eficiente que outro, ele deve conseguir o resultado correto em menor tempo e/ou utilizando menos espaço (como memória e disco rı́gido). Como atualmente os recursos espaciais podem ser conseguidos de forma barata (em comparação ao preço de uma ou duas décadas atrás e ao custo de um processador), fica clara a importância de se analisar outro fator quando se compara dois algoritmos: o tempo 2 de execução, como em [Koerich, 2005]. Uma das formas de se analisar o tempo de execução de um algoritmo é conhecida como análise assintótica, ou cáculo da eficiência assintótica, em que se procura encontrar uma tendência no tempo de execução, quando o volume de dados de entrada do problema tende ao infinito. Neste documento, abordaremos esta forma de análise, e mostraremos, com exemplos, como ela pode ser aplicada. 2 Processo de Análise Quando é considerado todo o conjunto de entradas válidas para um algoritmo, percebe-se que em muitos casos não é viável testar todos os elementos deste conjunto. E isso é especialmente verdade para os casos em que a entrada tende ao infinito. Mas como, então, comparar dois algoritmos com um conjunto muito grande de entradas? Para isso, foi criado um processo de análise, fundamentado em métodos matemáticos, que fornece uma modelagem comportamental que permite comparar estes algoritmos. Outra vantagem clara de se possuir um modelo matemático é a separação do algoritmo da implementação. Um bom algoritmo mal implementado pode ter um resultado inferior a outro menos eficiente, porém bem implementado. O processo de análise envolve 3 etapas: • identificar o conjunto de entrada; • identificar a operação base, que será executada para cada elemento da entrada; e • definir que tipo de entrada será estudado. A definição do tipo da entrada é muito importante, pois define como o processo será conduzido. Depedendo deste tipo, estaremos estudando: • o pior caso (notação O); • o caso médio (notação Θ); ou • o melhor caso (notação Ω). Estes casos serão vistos a seguir3 . Para ilustrá-los, considere o seguinte exemplo: 1 resolvem o problema de maneira correta a palavra ”tempo” é utilizada neste documento como o número de passos necessários para se resolver um algoritmo. 3 as notações o e ω não serão tratadas neste documento 2 2 Dado o vetor de caracteres abaixo, deseja-se buscar três caracteres diferentes: ”A”, ”D” e ”K”. Sendo um algoritmo de busca sequencial, como o algoritmo 1, que compara um caractere após o outro, do primeiro ao último, este algoritmo terá o seu pior caso para encontrar a letra ”D”, melhor caso para a letra ”K”, e caso médio para a letra ”A”. K E T G U O P A M G L Q Z C V D Algoritmo 1 Busca sequencial pelo elemento e no vetor vet de tamanho n 1: PARA i = 1 até n FAÇA 2: SE e == vet[i] ENTÃO 3: pare; 4: FIM SE 5: FIM PARA 2.1 Notações Θ e Ω A notação Θ, o caso médio, pode ser definida matematicamente pela equação 1, em que c 1 e c2 são constantes positivas, n é um elemento do conjunto da entrada, n0 é o primeiro elemento em que este comportamento se apresenta e g(n) é a função que relaciona a entrada ao tempo de execução. Θ(g(n)) = {f (n) : ∃ c1 ∧ c2 | ∀n ≥ n0 , 0 ≤ c1 g(n) ≤ f (n) ≤ c2 g(n)} (1) A figura 1 representa esta equação graficamente. c2.g(n) f (n) c1.g(n) n n0 Figura 1: Representação gráfica de f (n) = Θ(g(n)). Esta notação representa o comportamento do algoritmo no caso médio e Θ(g(n)) define a classe das funções f (n) que crescem na mesma taxa de g(n). Isto é, para o caso da busca sequencial, quando o número de caracteres no vetor tende ao infinito, o tempo para se encontrar um elemento qualquer neste vetor é, em média f (n) = Θ(g(n)). Já a notação Ω, o melhor caso ou limite assintótico inferior, pode ser definida matematicamente pela equação 2 na próxima página. 3 Ω(g(n)) = {f (n) : ∃ c | ∀n ≥ n0 , 0 ≤ cg(n) ≤ f (n)} (2) A figura 2 representa esta equação graficamente. f (n) c.g(n) n n0 Figura 2: Representação gráfica de f (n) = Ω(g(n)). Ω(g(n)) define a classe das funções f (n) que crescem pelo menos tão rapidamente quanto g(n). Voltando ao algoritmo 1 na página precedente, vamos aplicar o processo de análise para encontrar f (n) = Θ(g(n)) e f (n) = Ω(g(n)): 1. Identificar o conjunto de entrada: A entrada mais relevante é o vetor vet, pois é a única cujo tamanho influencia o tempo de execução. O elemento e também é interessante para podermos definir os casos médio e melhor. 2. Identificar a operação base: Normalmente a operação base é a que se encontra no loop mais interno, ou seja, a operação ”SE”. 3. Definir o tipo de entrada que será estudado: vamos estudar os casos em que o elemento e está na primeira posição do vetor (melhor caso) e ao redor da metade (caso médio). Para encontrar f (n), deve-se definir quantas vezes a operação base é executada para uma entrada de tamanho n. Neste caso, a operação ”SE” é executada exatamente uma vez para cada elemento. Assim: f (n) = n (3) Agora, deve-se encontrar g(n). Para o caso médio, o elemento e estará na metade do vetor: a n vezes. operação base ”SE” será executada 2 n (4) g(n) = 2 No cáculo de eficiência assintótica, apenas os termos de mais alta ordem são considerados (i.e. se g(n) = n2 + n + c, apenas o termo n2 seria considerado). As constantes também são desconsideradas. Assim: 1 g(n) = ∗ n = n (5) 2 4 Para provar que f (n) = Θ(n), a equação 1 deve ser satisfeita. ∀n ≥ n0 , 0 ≤ c1 n ≤ n ≤ c2 n 1 sendo c1 = ∧ c2 = 2 2 n (÷n) 0 ≤ ≤ n ≤ 2n 2 1 0≤ ≤1≤2 (6) 2 As desigualdades da equação 6 são verdadeiras, então, f (n) = Θ(n). Quando considera-se o melhor caso, o elemento e estará na primeira posição do vetor: a operação base ”SE” será executada apenas 1 vez. g(n) = 1 (7) Para provar que f (n) = Ω(1), a equação 2 deve ser satisfeita. ∀n ≥ n0 , 0 ≤ c.1 ≤ n sendo c = 1 ∧ n0 = 1 ∀n ≥ 1, 0 ≤ 1 ≤ n sendo n = 1 0≤1≤1 (8) Como as desigualdades da equação 8 são verdadeiras, então, f (n) = Ω(1). Para qualquer notação, quando g(n) = constante (e.g. g(n) = 1), diz-se que o tempo de execução é constante. 2.2 Notação O A notação O 4 apresenta o comportamento do algoritmo em seu pior caso ou limite assintótico superior. É, possivelmente, a mais utilizada, pois em vários problemas, o tempo para o pior caso pode ser inaceitável, mesmo quando o tempo do caso médio for razoável. n Por exemplo, suponha um algoritmo cujo caso médio é Θ(n3 ) e o pior caso é O(2n ). Considerando 1000 n = 1000, o valor 21000 faria que, quando um processamento se aproximasse do pior caso, o algoritmo iria levar centenas de milhões de anos para obter um resultado. Por isso é muito importante que se conheça o valor da notação O(g(n)) para um dado algoritmo. A notação O(g(n)) pode ser definida matematicamente pela equação 9. O(g(n)) = {f (n) : ∃ c | ∀n ≥ n0 , 0 ≤ f (n) ≤ cg(n)} (9) A figura 3 na próxima página representa esta equação graficamente. O(g(n)) define a classe das funções f (n) que crescem não mais rapidamente que g(n). Aplicando-se, novamente, o processo de análise no algoritmo 1 na página 3, e considerando f (n) = n (equação 3 na página precedente), tem-se o pior caso quando percorre-se todos os elementos do vetor. g(n) = n (10) Para provar que f (n) = O(n), a equação 9 deve ser satisfeita. ∀n ≥ n0 , 0 ≤ n ≤ cn sendo c = 1 ∧ n0 = 1 ∀n ≥ 1, 0 ≤ n ≤ n sendo n = 1 0≤1≤1 4 lê-se ”O”-grande ou big-Oh 5 (11) c.g(n) f (n) n n0 Figura 3: Representação gráfica de f (n) = O(g(n)). Como as desigualdades da equação 11 são verdadeiras, então, f (n) = O(n). Nota-se, neste caso, que g(n) é, coincidentemente, igual para as notações O e Θ. 3 Algoritmos Não-Recursivos Quando é aplicado o método apresentado na seção 2 para algoritmos não-recursivos, procura-se, após encontrar a operação base, construir um somatório que represente a quantidade de vezes que essa operação é executada, em função do tamanho da entrada. A função f (n) é encontrada com a simplificação deste somatório. Então, no caso do algoritmo de busca sequencial, tem-se: f (n) = n X 1 i=1 simplificando f (n) = n − 1 + 1 f (n) = n 4 (12) Algoritmos Recursivos Algoritmos recursivos são aqueles que chamam a si próprios durante sua execução. Diferente dos algoritmos não-recursivos, não é facilmente deduzı́vel um somatório que represente o número de vezes que a operação base é executada. Para se encontrar este valor, busca-se uma relação de recorrência. O algoritmo 2 na página seguinte é um exemplo que utiliza recursão. A operação base é a multiplicação, cujo número de execuções para n, que será representado por T (n), é o número de execuções para n − 1, T (n − 1), somado com a execução atual, 1. Assim, tem-se: T (n) = T (n − 1) + 1 (13) Para encontrar a relação de recorrência, deve-se buscar a condição de parada. Na linha 2, vê-se que, quando n é igual a 0, o algoritmo pára a recursão e retorna apenas o valor 1. Com a condição de parada e a equação 13, pode-se criar a relação de recorrência. 6 Algoritmo 2 Encontra o fatorial do número n 1: Fatorial(n) 2: SE n == 0 ENTÃO 3: retorne 1; 4: SENÃO 5: retorne F atorial(n − 1) ∗ n; 6: FIM SE T (n) = T (n − 1) + 1 1 para n > 0 para n = 0 (14) Resolvendo a recorrência, tem-se: T (n) = T (n − 1) + 1 = T (n − 2) + 1 + 1 = T (n − 3) + 1 + 1 + 1 = · · · = T (n − n) + n = T (0) + n (15) Substituindo-se a equação 14 na 15, obtem-se: T (n) = 1 + n (16) O resultado da simplificação da relação de recorrência é o valor de f (n). Neste caso, f (n) = n. Como não é possı́vel, neste algoritmo, diferenciar entre os casos pior e médio, Θ(g(n)) = O(g(n)), pode-se encontrar apenas um valor para g(n) para esses casos. O melhor caso é quando n = 1, em que o tempo é constante, Ω(1). Assumindo-se que sempre deve-se passar exatamente n vezes pela operação base, então g(n) = n. É trivial provar que f (n) = O(n) para este algoritmo. Porém, nem sempre é trivial encontrar a solução da relação de recorrência. Para tanto, existem três métodos5 : Substituição uma solução é arbitrada e verificada matematicamente, através de indução para encontrar as contantes. Árvore de Recursão a recorrência é convertida em uma árvore e a sua altura é convertida para uma solução utilizável na Substituição. n Mestre para recorrências da forma T (n) = aT ( )+h(n), em que a ≥ 1, b > 1 e h(n) > 0, compara-se b nlogb a com h(n). 5 Principais Funções de Complexidade A tabela 1 na página seguinte mostra as principais funções de complexidade, e o tempo necessário para serem resolvidas para o tamanho de algumas entradas ilustrativas. 6 Conclusão Neste documento foi apresentado uma metodologia para analisar a eficiência assintótica de algoritmos recursivos e não-recursivos. Porém, esta breve introdução cobre apenas uma parte pequena de todo o universo de análise de complexidade de algoritmos. Outros tópicos, além da eficiência assintótica, que são de relevância para a análise de complexidade são os problemas de decisão, as classes de complexidade e tempo polinomial determinı́stico (P) versus tempo polinomial não-determinı́stico (NP). Todo esse conjunto de ferramentas de análise fazem parte de uma área de conhecimento que visa não apenas entender as limitações dos algoritmos, mas, também, melhorar a qualidade e a eficiência do software desenvolvido. Todo desenvolvedor deveria saber utilizar corretamente estas ferramentas. 5 estes métodos serão citados, mas não serão desenvolvidos neste documento. 7 Tabela 1: Principais funções de complexidade Nome Contante Logarı́tmica Linear n log n Quadrática Cúbica Exponencial Fatorial Função 1 log n n n log n n2 n3 2n n! 102 1 2 102 2 ∗ 103 104 106 1.267650600228 ∗ 1030 9.332621544394 ∗ 10157 Referências [Koerich, 2005] Koerich, A. (2005). Notas de aula. 8 103 1 3 103 3 ∗ 106 106 109 1.0715086072 ∗ 10301 ∞ 106 1 6 106 6 ∗ 109 1012 1018 ∞ ∞