Você está na página 1de 43

Análise de Algoritmos

Aula 02

Prof. João Paulo R. R. Leite


joaopaulo@unifei.edu.br
Universidade Federal de Itajubá
Algumas questões surgem ao analisar um
determinado algoritmo:

1. Ele está sempre correto? Para quaisquer valores de


entrada a saída corresponde ao esperado?

2. Ele pode ser considerado a melhor alternativa para a


solução do problema?
• O que torna um algoritmo melhor que outro?
• Quais métricas podemos utilizar?
• Qual métrica é a mais popular? E a mais justa?
A Análise de algoritmos aparece para nos ajudar
com essas questões, fazendo com que seja possível:
1. Determinar o custo de um algoritmo em relação ao
gasto de recursos computacionais (tempo, por
exemplo), expressando esse custo em função do
tamanho dos dados de entrada (n).
2. E, a partir da métrica estabelecida, viabilizar a
comparação de diferentes algoritmos que resolvam
um mesmo problema. Com isso, é possível
determinar qual deles é “melhor” (na métrica
escolhida) e, em alguns casos, verificar se algum
deles é ótimo;
Que vantagens eu tenho ao estabelecer métrica para
quantificar o desempenho de algoritmos?

– Consigo escolher entre vários algoritmos o mais


eficiente, ou o que melhor se adequa às minhas
limitações de hardware (processador, memória, etc.);
– Verifico a viabilidade da solução de um dado problema,
tanto com relação ao tamanho esperado da entrada ou
ao limite da tecnologia atual;
– Abro a possibilidade da pesquisa para desenvolvimento
de algoritmos novos e mais eficientes para problemas
que já possuem uma solução conhecida, ou para a
melhoria de algoritmos já existentes;
Na aula passada, vimos o caso da sequência de Fibonacci.
Ao realizar um cálculo bastante simplificado, temos a noção
clara do que eu quero dizer com viabilidade.

– Imagine que eu queira calcular o 100º número da


sequência utilizando o algoritmo recursivo.
– Como o tempo gasto é proporcional a 2n, gastaríamos
algo semelhante a 2100 passos básicos de computação
para realizar a tarefa.
– Imagine que tenhamos um computador que realize 40
trilhões de passos por segundo, ou seja 245.
(Lembrando que 1Ghz ≅ 1 bilhão/segundo).
– Mesmo com este supercomputador, demoraríamos
cerca de 255 segundos para completar o cálculo, que é
mais ou menos...
1 trilhão de
anos!
Para que a prática da análise de algoritmos torne-se parte do dia
a dia do programador, é necessário que ela seja apresentada
como uma tarefa sistemática. Assim, o engenheiro poderá utilizá-
la desde a concepção do projeto, passando pela implementação e
fase de testes.

Não dá pra ficar só no feeling, no sentimento ou “achômetro”.


É necessário definir um processo, que pode exigir ferramentas
matemáticas como análise combinatória, estatística, alguma
habilidade em álgebra e a capacidade de identificar termos mais
significativos em fórmulas.

É preciso seguir uma metodologia.


• Uma característica relevante de qualquer algoritmo é o tempo
gasto em sua execução.

• Esse tempo pode ser determinado por métodos empíricos:


medição do tempo gasto na execução de uma implementação
do algoritmo considerando entradas de diversos tamanhos e
composições diferentes.
– Fortemente dependente da máquina utilizada (embarcado, PC,
dedicado), da linguagem em que foi implementado o algoritmo (Java,
C++, Python), habilidade do programador, compilador, etc.
– Leva tempo e pode ser incompleta ou imprecisa.

• No entanto, existem métodos analíticos através dos quais


pode-se obter expressões matemáticas que traduzem o
comportamento de um algoritmo no tempo de maneira mais
vantajosa, especialmente devido ao fato de que é
independente de arquitetura e linguagem de programação.
Essa técnica possui vantagens sobre a medida empírica do
tempo de execução:

Ela é baseada na própria estrutura do algoritmo e, portanto,


independe do computador utilizado, da linguagem e
compiladores empregados e das condições locais de
processamento (quantidade de processos na máquina,
estado do sistema operacional, temperatura, etc.).

Pode ser realizada desde a fase de projeto, possivelmente


antecipando a análise para antes da implementação, o que
pode economizar muito tempo de desenvolvimento.
Pra isso, precisamos introduzir uma nova definição pra o
tempo de execução de um algoritmo:
Tomaremos como tempo de execução o número total de
operações primitivas ou passos computacionais executados
durante o algoritmo. Não utilizaremos unidades de tempo, como
segundos, milissegundos, etc. Iremos estabelecer que uma
quantidade fixa de tempo será necessária para executar cada uma
dessas operações (essa quantidade varia de máquina para
máquina, claro, mas não nos interessa).

Na maioria das vezes, o tempo gasto por um algoritmo


cresce com o tamanho da entrada. Isso é até intuitivo.
– Como definir o tamanho da entrada? Depende do tipo de
problema: Número de itens do vetor, de vértices ou arestas do
grafo, pixels de uma imagem, bytes em um arquivo, etc.
– Alguns algoritmos sofrerão maior impacto que outros com o
aumento da entrada, como veremos.
Análise Pessimista
Normalmente, é importante determinar somente o tempo de
execução no pior caso; ou seja, o tempo de execução mais longo
para qualquer entrada de um mesmo tamanho n.
– Estabelece limite superior de tempo de execução para
qualquer entrada de um mesmo tamanho n, garantindo
que o algoritmo nunca irá demorar além do esperado e
evitando a necessidade de se fazer suposições sobre
comportamentos ainda piores;
– O pior caso ocorre com bastante frequência;
• A busca por informações que não estão presentes pode ocorrer
com grande frequência em um banco de dados, por exemplo.
– Frequentemente, o caso médio é de difícil solução e
apresenta desempenho quase tão ruim quanto o pior caso.
Análise Pessimista
Qual o melhor algoritmo?
Normalmente, dizemos que um algoritmo é melhor que outro se seu
tempo de execução f(n) no pior caso apresenta uma ordem de
crescimento mais baixa que o segundo g(n), proporcionalmente ao
aumento da entrada. Ou seja, quando f(n) = O(g(n)).
– Devido às constantes e termos de menor ordem, ou pela própria
característica da curva, um algoritmo de ordem de crescimento
mais alta pode executar mais rápido que outro de ordem de
crescimento mais baixa para pequenas entradas.
– No entanto, geralmente estamos interessados no seu
desempenho para entradas suficientemente grandes. Neste caso,
um algoritmo Θ(n2) certamente será executado mais rapidamente
no pior caso que um algoritmo Θ(n3) ou Θ(2n) .
Análise Pessimista
A complexidade pessimista (no pior caso), do algoritmo
será obtida, passo a passo, a partir das complexidades
de cada uma de suas componentes.
– Algumas componentes serão sempre executadas;
– Outras componentes mostram alternativas, que serão
executadas conforme o caso (if, else, switch). Qual
alternativa escolher?
Cada componente executada dará sua contribuição
para o desempenho e complexidade do algoritmo.
– Contribuição depende do número de passos realizados
dentro da componente e de sua estrutura algorítmica
básica, ou seja, quantas operações ela realiza.
As estruturas algorítmicas mais utilizadas e mais
importantes são:
• Atribuição: v  e
• Condicional: se b então S senão T
• Seqüência: S; T;
• Iteração definida: para i de j até m faça S
• Iteração indefinida: Enquanto b faça S

Durante a análise, iremos perceber que algumas das


componentes do algoritmo irão contribuir de maneira mais
significativa para sua complexidade. Elas serão dominantes,
ou seja, o tempo gasto em sua execução ofusca o tempo gasto
em estruturas mais simples. Para melhorar a eficiência, são
essas componentes que devem ser melhoradas (gargalos).
Algumas questões importantes :

1. Como são definidas as componentes


dominantes?
Princípios da Absorção e Máximo Assintótico.

2. Como calcular os desempenhos individuais


de cada estrutura algorítmica?
Equações de complexidade.
Componentes de Algoritmos
As componentes dominantes são aquelas que possuem papel mais
importante no algoritmo, e consequentemente possuem maior
impacto no seu desempenho.

Existem dois conceitos simples que podem ser aplicados na análise


do algoritmo para definir quais as componentes dominantes:
Absorção: utilizada quando as componentes de um algoritmo
são sempre executadas.
Máximo Assintótico: utilizada quando o algoritmo define
caminhos alternativos de execução – cada caminho poderá ter
um custo de tempo diferente. Qual escolher?
Componentes de Algoritmos
É interessante classificar as componentes conforme sejam
sempre executadas ou não. Imagine os seguintes cenários:

Sempre são executadas, e


j  i + 1; não dependem de teste condicional.
k  i – 1; Elas são Componentes Conjuntivas.

Se (i != j)
Apenas uma das duas é executada,
j  i + 1; de acordo com resultado do teste condicional.
Senão Elas são Componentes Disjuntivas.
k  i – 1;
Componentes Conjuntivas

Considere um algoritmo A, cujas duas componentes B e C são


conjuntivas. O desempenho de A para uma entrada n será:
desemp[A](n) = desemp[B](n) + desemp[C](n)

Para componentes conjuntivas, utilizamos o conceito de


absorção, aplicado quando uma das componentes apresenta
uma ordem de crescimento maior, dominando as demais e
tornando-as irrelevantes para o contexto geral do algoritmo.
Dizemos que f(x) é absorvida por g(x) se f(x) = O(g(x)).
Componentes Conjuntivas
Exemplo: Imagine um algoritmo A que receba um vetor
de inteiros v e realize duas operações em seqüência,
ordenação e somatório:
Componentes Conjuntivas
Análise do algoritmo:
Levando-se em consideração que a operação ordena(v) já tenha
sido analisada previamente e possua complexidade Θ(n2), e a
operação somatorio(v) possua complexidade Θ(n), podemos
dizer que a complexidade de ordena_e_soma(v) é igual a
Θ (n2 + n). Como n = O(n2), pelo princípio da absorção, podemos
realizar uma simplificação e dizer que a complexidade total é da
ordem de Θ(n2).

Outro exemplo: 1 + n + log n + 2n = Θ(2n).


– A componente exponencial absorve as demais, tornando-as
irrelevantes em um contexto mais geral.
N 2N 2N+N2 N2
1 2 3 1
2 4 8 4
4500 3 8 17 9
4 16 32 16
4000 5 32 57 25
6 64 100 36
3500 7 128 177 49
8 256 320 64
9 512 593 81
3000
10 1024 1124 100
11 2048 2169 121
2500
12 4096 4240 144 2^N
2^N+N^2
2000
N^2

1500

1000

500

0
1 2 3 4 5 6 7 8 9 10 11 12

Repare que, graficamente, é muito fácil perceber o princípio da absorção.


N N2 N3 N2+N3
1 1 1 2
2000 2 4 8 12
3 9 27 36
1800
4 16 64 80
5 25 125 150
1600
6 36 216 252
7 49 343 392
8 64 512 576
1400
9 81 729 810
10 100 1000 1100
1200
11 121 1331 1452
12 144 1728 1872 N^2
1000
N^3
N^2+N3
800

600

400

200

0
1 2 3 4 5 6 7 8 9 10 11 12

Mesmo entre ordens polinomiais.


Componentes Disjuntivas
Considere um algoritmo A, cujas duas componentes B e C são
disjuntivas, e nunca serão executadas em sequência em uma
mesma instância do algoritmo. O desempenho de A para uma
entrada de tamanho n será:
desemp[A](n) = desemp[B](n) OU desemp[C](n)

Para componentes disjuntivas, utilizamos o conceito de máximo


assintótico (MxAO), aplicado para determinar o limite superior de
complexidade do algoritmo, independente da escolha feita.

De maneira simplificada, podemos dizer que


MxAO(f(x), g(x)) = g(x) sempre que f(x) = O(g(x)).
Componentes Disjuntivas
Exemplo: Imagine um algoritmo a que receba um vetor de
inteiros V e funcione da seguinte maneira:

Como n = O(n2), dizemos que MxAO(n, n2) = n2, e que


complexidade do algoritmo a é O(n2).
Repare que aqui utilizamos O, que é para limite superior, diferente da
conjuntiva onde utilizamos Θ, pois estabelecemos um tempo máximo.
Equações de Complexidade
Como calcular os desempenhos individuais de cada
estrutura algorítmica?
Para cada uma das estruturas algorítmicas, será estabelecida
uma equação que traduza o seu comportamento e reflita sua
complexidade no contexto do algoritmo como um todo.

São elas: atribuição, condicional, sequência, iteração


definida (for) e iteração indefinida (while, do).
Para fins de notação, utilizaremos v e i como variáveis; e, j e
m como expressões; b como condição (teste lógico) e S, T
como trechos de código.
Equação: Atribuição
Uma atribuição comum do tipo i  0; ou i  j; onde i e j são
inteiros, possui complexidade constante Θ(1).

No entanto, imagine que u e v sejam vetores com n inteiros.


Uma atribuição do tipo u  v transfere cada elemento de v
para u, levando um tempo linear Θ(n).

Imagine agora uma atribuição que envolve um cálculo, do


tipo i  máximo(v), que devolve o maior valor da lista v
para i. A função máximo(v), de complexidade linear Θ(n),
contribuiria decisivamente para a complexidade da
atribuição, dominando-a.
Equação: Atribuição
Dada uma atribuição do tipo v  e, a complexidade total é
definida como a soma das complexidades pessimistas da
expressão e, que está sendo atribuída, compl[e], e da própria
transferência, compl[], na forma:

compl[v  e] = O(compl[e] + compl[])


Desta maneira, caso o algoritmo possua uma atribuição do tipo
u  insertion_sort(v); a complexidade será
compl[v  e] = O(compl[e] + compl[]) = O(n2 + n) = O(n2)
Equação: Sequência
O desempenho da seqüência S; T, onde S e T são
trechos de código, é a soma dos desempenhos
individuais de suas componentes, que são conjuntivas.

compl[S; T] = O(compl[S] + compl[T])

Imagine o seguinte trecho de algoritmo:


v  revert(u); // Complexidade O(n)
insertion_sort(v); // Complexidade O(n2)

Neste caso, a complexidade total seria O(n + n2) = O(n2)


Equação: Sequência
No entanto, existe uma particularidade: Há casos onde S pode
alterar o tamanho da entrada para T, fazendo que:

compl[S; T](n) = O(compl[S](n) + compl[T](S(n)) )

Considere a seqüência:
v  metade_ini(u); w  metade_fin(u); // O(n)
v  insertion_sort(v); w  insertion_sort(w); // O(n2)
u  concat(v, w); // O(n + m)

Neste caso, a complexidade total seria


n + n + (n/2)2 + (n/2)2 + (n/2 + n/2) = O(n2)
Equação: Condicional
No primeiro caso, “se b então S”, a complexidade envolve
esforços computacionais relacionados à avaliação da condição b
e execução do trecho S. Como estamos interessados no pior
caso, vamos assumir que a condição será satisfeita todas as
vezes. Assim:
compl[condicional] = O(compl[b] + compl[S])

Imagine trecho de algoritmo


Se máximo(v) != 0 então
insertion_sort(v);

Como máximo(v) é O(n) e insertion_sort(v) é O(n2), a complexidade total é


O(n + n2) = O(n2)
Equação: Condicional
No segundo, “se b então S senão T”, a complexidade envolve esforços
computacionais relacionados à avaliação da condição b e execução do
trecho S ou T (o mais custoso deles, MxAO):

compl[condicional] = O(compl[b] + MxAO(compl[S], compl[T]))

Imagine o seguinte trecho de algoritmo:


Se máximo(v) != 0 então
insertion_sort(v);
senão popula(v);

Se popula(v) for O(n), a complexidade total é


O(n + MxAO(n2, n)) = O(n + n2) = O(n2)
Equação: Iteração Definida
Também chamada de iteração incondicional, pois não há um
teste explícito em sua construção. Geralmente realzia um
número fixo de iterações.
Possui estrutura do tipo: Para i de j até m faça S.

Alguns exemplos:
– Para i de 1 até 100 faça imprime(i); // O(1)
Complexidade é constante, igual a 100*1, Θ(1)
– Para i de 1 até n faça imprime(v[i]); // O(1)
Complexidade é linear, igual a n*1, Θ(n)
– No caso de Iterações aninhadas:
Para i de 1 até n faça
Para j de 1 até n faça imprime(u[i, j]); // O(1)
A complexidade é quadrática, n*n*1, ou Θ(n2)
Equação: Iteração Definida
Neste tipo de estrutura, o esforço da determinação de j e m e
dos valores de i é dominado pelo das iterações de S. A
complexidade fica:

compl[iteração](n) = O( N(n)*compl[S](n) )

Onde N(n) é o número de execuções do trecho S.


Caso a iteração de S não preserve o tamanho de um passo para
outro, a complexidade fica:

 m( n) 
c p [iteração](n) = O  c p [ S ](S ( i − j ( n ))
(n)) 
 i= j (n) 
Equação: Iteração Definida
Mas como assim, o tamanho não é preservado? Veja o exemplo:
Se considerarmos tam como o
tamanho da entrada n, temos que o
laço mais externo roda n-1 vezes.

E o laço interno?
Na primeira vez, ele roda n-1 vezes.
Na segunda vez, ele roda n-2 vezes.
Na terceira vez, ele roda n-3 vezes...
Na última vez, ele roda apenas 1 vez.

Como sabemos quantas vezes foi


feita a comparação (que é a operação
que potencialmente será executada
mais vezes)?
Equação: Iteração Definida
Mas como assim, o tamanho não é preservado? Veja o exemplo:
A sequência [(n-1), (n-2), (n-3), ... 1]
tem uma característica interessante.
Qual é?
Isso mesmo. É uma Progressão
Aritmética (PA), com razão -1.

E como calculamos quantas


operações foram realizadas dentro
do laço interno?
Equação: Iteração Definida
Mas como assim, o tamanho não é preservado? Veja o exemplo:

Temos, portanto, o seguinte cálculo:

S = [(n-1 + 1) * (n-1)] / 2
S = (n2 – n)/2

Pelo princípio da absorção,


podemos dizer que o algoritmo
tem complexidade O(n2).
Equação: Iteração Indefinida

Esta iteração “Enquanto b faça S” causa a execução


sucessiva de S enquanto a condição b está satisfeita. Em
geral, o esforço computacional para as execuções sucessivas
de S dominam os esforços para avaliação do teste
condicional.

Nela, o número de iterações não fica determinado no início


da execução, e é essa a diferença básica para a iteração
definida. No momento em que a condição não é mais
satisfeita, o laço é interrompido.
Equação: Iteração Indefinida
A equação de complexidade pessimista é:

 h ( n )−1 
c p [iteração](n) = O  c p [ S ](S ( anterior )
(n)) 
 i =0 
Imagine um exemplo onde seja necessário encontrar a linha de uma matriz que
contém o valor 1000 entre as n linhas existentes, cada uma com 20 elementos:

Enquanto (i ≤ n) faça
Para k de 1 até 20 faça
Se vetores[i, k] == 1000 então retorna i;
i = i + 1;
Fim Enquanto
Equação: Iteração Indefinida
Como estamos interessados no pior caso, iremos considerar
sempre que o número máximo de iterações (h(n)) será realizado.

Para o exemplo, o pior caso acontece quando nenhum dos


vetores possui o valor 1000.

Neste caso, o algoritmo irá realizar n*20 comparações (operação


fundamental, de complexidade O(1)), de onde obtemos uma
complexidade total de O(n).
Resumindo...
• Considere sempre o pior caso;
• As operações básicas possuem complexidade constante
O(1) (atribuição, operações aritméticas, comparações,
chamadas a métodos, etc.);
• Quando houver uma seqüência de operações, some suas
complexidades;
• Quando houver alternativas, encontre o máximo
assintótico entre suas complexidades;
• Continue simplificando até chegar à ordem dominante,
utilizando os conceitos de absorção e máximo assintótico;
• Multiplique o conteúdo dos laços pela quantidade de
iterações para obter sua complexidade total e lembre-se...
em geral, quanto mais laços aninhados, pior.
Hands-on!
Soma dos elementos de duas matrizes de
mesmo tamanho:

A operação dominante é a atribuição de valores da soma, dentro do laço, que é


executada tam*tam vezes. Portanto, a complexidade é Θ (n2).
Hands-on!
Multiplicação de matrizes:

A operação dominante é a atribuição de valores da multiplicação, dentro do terceiro


laço, que é executada tam*tam*tam vezes. Portanto, a complexidade é Θ (n3).
Hands-on!
Busca pelo maior elemento de um vetor:

A componente dominante (comparação + atribuição) é executada n-1 vezes, portanto,


a complexidade é Θ (n)

Você também pode gostar