Análise de Algoritmos

Analisar um algoritmo é prever o que o algoritmo irá precisar. Às vezes o hardware é importante, mas acho que o que acontece com mais freqüência, ao menos em olimpíadas, maratonas e problemas em casa, é precisarmos medir o tempo que ele irá demorar.

Eu expliquei em algum dos artigos anteriores que o tempo de um algoritmo depende geralmente do tamanho de sua entrada. Com este artigo, pretendo explicar como analisamos um algoritmo baseado nesse tamanho de sua entrada para compará-lo com outros algoritmos e ter uma noção de quanto tempo ele vai demorar.

Para o entendimento ficar mais fácil, vamos partir do seguinte algoritmo (que vamos chamar de Algoritmo 1):

para i \leftarrow{} 1 até n, faça
para j \leftarrow{} 1 até i, faça
  imprima i ×\times{} j ×\times{} n
fim-para
fim-para

O que este algoritmo faz é, depois de receber a entrada nn do usuário, imprimir o produto de nn com todos dois números ii e jj, tal que jinj \leq{} i \leq{} n.

Para medir o custo do algoritmo, nossa análise consistirá em ver quantas vezes cada passo é executado. Mediremos o custo de cada linha (cada passo a ser executado), sempre em função de n, que para este algoritmo é a variável mais importante (aliás, a única variável). Por isso o pseudocódigo do Algoritmo 1 está com suas linhas numeradas. Vamos analisar…

  • Linha 1: Será executada n+1n + 1 vezes.
  • Linha 2: Será executada n×_i=1n+nn \times{} \sum\_{i=1}^{n} + n vezes.
  • Linha 3: Será executada n×_i=1nn \times{} \sum\_{i=1}^{n} vezes.
  • Linhas 4 e 5: Não tem custo. :)

Por que estes números de execução?

Se você já entendeu por que cada passo é executado este número de vezes, pode pular essa parte (continuar a ler a partir da linha horizontal).

Linha 1

O loop para voltará para si mesmo nn vezes, isso é, testará novamente sua condicional e incrementará um. Por sempre testar um condicional, no final ele terá que testar novamente para dizer que já passou de nn. Por isso, ele será executado n+1n+1 vezes, ao invés de simplesmente nn.

Linha 2

Este loop para será executado um número de vezes variável (ii), que irá de 11 a nn. Portanto, ele será executado duas vezes (1 mais “o último condicional”) no primeiro loop de ii, três (2 mais “o último condicional”) no segundo, e por aí vai. Com isso, ele será executado o número de vezes que equivale a soma de 11 a nn, mais nn que são “os últimos condicionais”.

Linha 3

Exatamente o mesmo número que a Linha 2, mas sem “os últimos condicionais” (n-n).


Imprimir algo na tela pode demorar mais do que fazer uma operação, mas a análise de algoritmos é uma coisa bem rústica. Desprezamos todas as constantes, com isso só levando a sério a informação importante: neste caso, apenas nn. Então agora, vamos escrever o tempo de execução do algoritmo, que é a soma dos tempos de execução para cada instrução executada.

T(n)=(n+1)+(_i=1n+n)+(_i=1n)T(n) = (n + 1) + (\sum\_{i=1}^{n} + n) + (\sum\_{i=1}^{n})

Os parênteses não são necessários, mas coloquei para ajudar na visualização separando o custo de cada instrução.

Simplificando esta operação, teremos:

T(n)=n2+3nT(n) = n^{2} + 3n, uma função quadrática.

Ordem de Crescimento

Como eu já disse antes, descobrir o custo de um algoritmo é uma coisa feita sem precisão, porque para entradas realmente grandes (que são casos onde precisamos do computador!) as constantes não importam. Agora vamos determinar a ordem de crescimento de um algoritmo resgatando do nosso algoritmo apenas o valor mais importante, o maior expoente de nn nele, neste caso, n2n^{2}. Se tivéssemos 2n22 n^{2}, por exemplo, também usaríamos apenas n2n^{2} porque o 22 que multiplica também é desprezível!

Vamos agora aprender como representar o custo desse algoritmo usando notações assintóticas com a ordem de crescimento do algoritmo.

Se você não entendeu alguma coisa aí em cima, sugiro reler antes de continuar…

Notações Assintóticas

Sugestão

Principalmente para pessoas pouco habituadas com matemática, essa parte é difícil e cansativa. Quando eu comecei a aprender isto, talvez por causa da matemática tão básica que é ensinada na escola, eu não entendia nada… Mas só quero dar uma dica: se você não entender direito ou achar muito complicado, pule para a próxima linha horizontal ao invés de desistir e dizer que “algoritmos são muito difíceis”. Tentei fazer o artigo para você poder pular essa parte e mesmo assim não parar no estudo dos algoritmos… Depois, com o tempo, você vai aprendendo isso.

As notações que usamos para descrever o tempo de execução de um algoritmo são cinco:

  • Θ\Theta{}
  • OO
  • Ω\Omega{}
  • oo
  • ω\omega{}

Embora essas notações sejam conjuntos, usamos o sinal de igualdade (=) para expressar que f(n)f(n) pertence a algum deles, ao invés de usar o sinal de pertinência (\in{}).

Vou explicá-las, omitindo alguns fatos para tentar facilitar o entendimento, porque eu acho que analisar algoritmos é meio complicado e nessa parte é extremamente difícil ser didático. Mas se você realmente se interessar, você pode me enviar um comentário pedindo mais um artigo sobre isso (e eu terei o prazer de até pesquisar para informar-lhes mais) ou então leia o Capítulo 3 do livro Algoritmos: Teoria e Prática, que acredito que seja bem completo. Gostaria de enfatizar aqui que meu objetivo com essa série é tornar uma introdução a algoritmos simples e não ser uma referência, como é o objetivo, por exemplo, do livro do Cormen [et al].

A notação Θ\Theta{}

Lê-se “theta de gê de ene”.

Θ(g(n))=f(n)\Theta{}(g(n)) = f(n), se existem constantes positivas c_1c\_{1}, c_2c\_{2} e n_0n\_{0} tais que 0c_1g(n)f(n)c_2g(n)0 \leq{} c\_{1} g(n) \leq{} f(n) \leq{} c\_{2} g(n) para todo nn_0n \geq{} n\_{0}.

A notação OO

Lê-se “ó maiúsculo de gê de ene”. Para quando há apenas um limite assintótico superior.

O(g(n))=f(n)O(g(n)) = f(n), se existem constantes positivas cc e n_0n\_{0} tais que 0f(n)cg(n)0 \leq{} f(n) \leq{} cg(n) para todo nn_0n \geq{} n\_{0}.

A notação Ω\Omega{}

Lê-se “omega maiúsculo de gê de ene”. Para quando há apenas um limite assintótico inferior.

Ω(g(n))=f(n)\Omega{}(g(n)) = f(n), se existem constantes positivas cc e n_0n\_{0} tais que 0cg(n)f(n)0 \leq{} cg(n) \leq{} f(n) para todo nn_0n \geq{} n\_{0}.

A notação oo

Lê-se “ó minúsculo de gê de ene”. Para quando há apenas um limite assintótico superior, sem permitir que f(n)=cg(n)f(n) = cg(n). Utiliza-se a notação oo para denotar um limite superior que não é assintoticamente restrito.

o(g(n))=f(n)o(g(n)) = f(n), se para qualquer constante c>0c > 0, existe uma constante n_0>0n\_{0} > 0 tal que 0f(n)cg(n)0 \leq{} f(n) \leq{} cg(n) para todo nn_0n \geq{} n\_{0}.

A notação ω\omega{}

Lê-se “omega minúsculo de gê de ene”. Para quando há apenas um limite assintótico inferior, sem permitir que cg(n)=f(n)cg(n) = f(n). Utiliza-se a notação ω\omega{} para denotar um limite inferior que não é assintoticamente restrito.

ω(g(n))=f(n)\omega{}(g(n)) = f(n), se para qualquer constante c>0c > 0, existe uma constante n_0>0n\_{0} > 0 tal que 0cg(n)f(n)0 \leq{} cg(n) \leq{} f(n) para todo nn_0n \geq{} n\_{0}.

Para analisar problemas mais complexos como, por exemplo, recorrências, existem métodos bastante interessantes, como o Teorema Mestre que o Cormen apresenta no Capítulo 4. É uma boa leitura pra quem se interessou.


Podemos criar várias comparações entre estas funções, mas isto não vem ao caso. O importante é saber em que notação a nossa função se encontra. Com o tempo vamos compreendendo melhor essas fórmulas.

Vamos relembrar o custo de nosso algoritmo… T(n)=n2+3nT(n) = n^{2} + 3n.

Vamos ver em que notação ele pode se encaixar, sabendo que g(n)g(n) seria a ordem de crescimento (parte importante) do nosso custo; no caso, n2n^{2}.

Testamos primeiro se ele encaixa na função Θ(n2)\Theta{}(n^{2}). Vamos substituir f(n)f(n) e g(n)g(n) (naquela função ali em cima, onde diz A notação Θ\Theta{}) pelos valores que conhecemos.

c_1n2n2+3nc_2n2c\_{1}n^{2} \leq{} n^{2} + 3 n \leq{} c\_{2} n^{2}

Se dividirmos tudo por n2n^{2}, obteremos:

c_11+3nc_2c\_{1} \leq{} 1 + \frac{3}{n} \leq{} c\_{2}

Agora separaremos as inequações.

Inequação 1: c_11+3nc\_{1} \leq{} 1 + \frac{3}{n}

Inequação 2: 1+3nc_21 + \frac{3}{n} \leq{} c\_{2}

Para satisfazer a Inequação 1, podemos quase automaticamente perceber que para qualquer n1n \geq{} 1, é válido c_1=1c\_{1} = 1 (ora, por mais que 3n\frac{3}{n} chegue perto de 0, sempre ainda vamos ter a constante 1 adicionada a ele). Para satisfazer a Inequação 2, podemos perceber facilmente que para qualquer n1n \geq{} 1, é válido c_2=4c\_{2} = 4 (a função só tende a diminuir a partir que nn vai aumentando e com n=1n=1, c_2=4c\_{2}=4). Com isso, agora chegamos as três constantes que precisávamos.

n_0n\_{0} (o menor valor de nn) =1= 1; c_1=1c\_{1} = 1; c_2=4c\_{2} = 4.

Logo, concluímos que f(n)=n2+3n=Θ(n2)f(n) = n^{2} + 3n = \Theta{}(n^{2}). Uma função que pertence a Θ\Theta{}, tem um limite assintótico superior e inferior e, portanto, pertenceria também a O(n2)O(n^{2}) e Ω(n2)\Omega{}(n^{2}), mas nem é necessário testar os outros valores porque já identificamos nossa função como “theta de ene ao quadrado”, que é a função mais “retinha” que podemos esperar.

Bom… Nos próximos artigos, veremos que um mesmo problema pode ter várias soluções diferentes com custos ainda mais diferentes! Por isso, é crucial sabermos analisar, mesmo que por cima, qual o algoritmo que é mais eficiente. Vou ficando por aqui…

O blog está temporariamente sem comentários. Em breve eles estarão de volta.
© 2005–2020 Tiago Madeira