• Nenhum resultado encontrado

Algoritmo 5.9 Algoritmo de Prim para construir uma

5.3 Complexidade de Algoritmos

Nas Seções 3.5 e 4.5, contamos o número de operações em alguns algoritmos simples. Na Seção 4.6 praticamos a arte de estimar quantidades. Nesta seção iremos juntar essas duas habilidades para desenvolver uma maneira matemática de predizer com que rapidez um algoritmo dado irá funcionar.

Existem muitos fatores que influenciam a rapidez com que um algoritmo dado será executado por deter­ minado computador. Considerações técnicas tais como a arquitetura do computador, a velocidade de processa­ mento e a memória dependem do estado atual da indús­ tria de hardware, e este é um alvo em movimento. Dadas todas essas variáveis, o máximo que devemos esperar obter da análise matemática de algoritmos é um meio de fazer comparações gerais entre algoritmos. Contar o número exato de operações não é tão importante, mas ser capaz de fazer boas estimativas é crucial.

5.3.1 O Bom, o Mau e o Médio

Frequentemente queremos saber a resposta para a pergunta, “O Algoritmo A irá terminar a tempo de fazer A?”Aqui X poderia ser “terminar meu relatório até as 17h” ou “renderizar o próximo quadro de animação” ou alguma outra restrição do tempo de execução. Nessa situ­ ação, uma análise do pior caso do algoritmo é bastante útil.

D efinição 5.4 Seja D o conjunto de todos os possí­ veis dados de entrada de tamanho n para um algoritmo dado. Para d E Dy seja c(d) o número de operações realizadas pelo algoritmo a partir do conjunto de dados

d. (Note que c(d) depende também de n.) O pior caso

para o número de operações realizadas pelo algoritmo é o valor máximo de c(d) à medida que d percorre todos os elementos de D.

A complexidade temporal de um algoritmo é uma medida de como o tempo de execução de um algoritmo aumenta como uma função de n, o tamanho do dado de entrada. Calculamos a complexidade temporal em pior caso calculando o pior caso do número de vezes que uma certa operação representativa no algoritmo é realizada. Costumamos estimar esse número usando as notações O-grande ou ©-grande.

Exem plo 5.9 Calcule a complexidade temporal em pior caso da busca sequencial (Algoritmo 5.1).

Solução: Vamos contar o número de comparações A. O

laço-enquanto nesse algoritmo continua a ser executado até o item alvo t ser encontrado, portanto o pior caso é quando t não está na lista. Nesse caso, o algoritmo terá que fazer a comparação A com cada elemento da lista e, finalmente, com o valor sentinela. Isso dá um total de

n + 1 comparações para uma lista de tamanho n, portanto

a complexidade temporal em pior caso é ©(n). 0 Como sabemos qual operação contar? Aqui temos alguma liberdade de escolha. Gostaríamos de contar a operação que mais irá demandar do computador em uma implementação do algoritmo, mas nem sempre teremos certeza de qual é essa operação. Se existir um bloco de operações que se repete mais do que qualquer outra parte do algoritmo, contar uma das operações nesse bloco é geralmente uma boa ideia. A boa notícia é que a maioria das escolhas razoáveis irá produzir o resultado correto, especialmente após passar para as notações ©-grande ou O-grande.

Em geral, iremos nos esforçar para descrever a complexidade de um algoritmo em notação 0-grande, mas existirão vezes em que precisaremos usar O-grande no lugar de ©-grande. Segue imediatamente das Defi­ nições 4.6 e 4.8 que / E O(g) se / £ ©(<?). Também é fácil mostrar que, se /(n) < g(n) para todo n, então / £ O(g). (Basta ter K = 1 na Definição 4.6.) Portanto, se formos forçados a superestimar uma contagem de operações em uma análise do pior caso, devemos relatar nossa estimativa usando a notação O-grande. Apenas lembre-se de que a notação O-grande está relatando um limite superior em complexidade, e não necessariamente a complexidade em si.

Calcular a complexidade do algoritmo euclidiano ilustra essa maneira de usar a notação O-grande. No Exercício 14 da Seção 5.1, vimos uma função recursiva para encontrar o maior divisor comum de dois números naturais. Aqui está uma versão iterativa.

A lg o ritm o 5.14 O Algoritmo Euclideano. Condições prévias: m, n E {0, 1, 2, 3, ...}

Condições posteriores: d é o maior inteiro tal que d

\m e d\ n. d <— m e *— n enquanto e A 0 fazer r r <— d mod e d <— e i_ e <— r

Exem plo 5.10 Mostre que a complexidade em pior caso do algoritmo euclidiano é O(n).

Solução: Assim como a versão recursiva, esse algoritmo

faz repetidas divisões até o resto ser zero. Vamos tentar contar o número do pior caso de “ d mod e” operações em termos de n. O algoritmo começa com e igual a n, e a cada vez no laço e é subtituído pelo resto r quando

d é dividido por e. Para distinguir os valores antigos e

novos de e, suponha que ê represente o valor de e após essa substituição, em que e é o seu valor antes da iteração do laço. Nessa notação,

ê = d mod e.

Em particular, isso implica ê < e, uma vez que é o resto da divisão por e. Portanto, e fica menor (por pelo menos 1) cada vez que o laço é percorrido, e o laço irá terminar quando e = 0. Isso mostra que o número de operações “d mod e” operações é no pior caso no máximo n, portanto a complexidade em pior caso é O(n). 0

E importante notarmos que relatar complexidade em termos da notação (9-grande apenas afirma um limite superior para a complexidade. O exemplo a seguir mostra que o algoritmo euclidiano é, na verdade, mais eficiente que O(n).

Exem plo 5.11 Mostre que a complexidade em pior caso do algoritmo euclidiano é 0 ( log2 n).

Solução: Iremos melhorar a solução do exemplo anterior

mostrando que e deve ser reduzido por pelo menos um fator de 1/2 a cada duas vezes que percorrer o laço; ou seja, mostraremos que

ê < e/2.

Também podemos aplicar a notação ~ em outras variá­ veis: d = e expressa o fato de que o novo valor de d é o antigo valor de e. Agora, considere percorrer o laço por uma segunda vez. Temos, então

ê — d mod ê = e mod ê

— e mod (d mod e).

Se ê > e/2, então ê cabe apenas uma vez dentro de e, deixando um resto de no máximo e/2, e assim

ê = e mod ê < e/2.

Por outro lado, se ê < e/2, então ê < e/2 também, uma vez que ê é o resto de uma divisão por ê.

Portanto, o valor de e é (pelo menos) reduzido à metade depois de duas iterações do laço. Uma vez que

ele levará no máximo 1 + 2 log2 n iterações para o valor de e alcançar zero. Por isso, a complexidade em pior caso

é C>(log2 n). 0

Em ambas as soluções anteriores, fornos capazes apenas de estabelecer que o número de operações era

no máximo algum valor; limitamos superiormente o

número de operações por uma função de n. Uma vez que não fomos capazes de obter uma contagem exata, e nunca estabelecemos um limite inferior, fomos forçados a relatar a complexidade na notação O-grande.

A análise da complexidade em pior caso nos dirá se o nosso algoritmo é bom o suficiente, mas nem sempre irá dizer quão bom ele é. Também podemos calcular a complexidade em melhor caso, fazendo as modificações óbvias na Definição 5.4.

Definição 5.5 Seja D o conjunto de todos os possíveis dados de entrada de tamanho n para um algoritmo dado. Para d G D, seja c(d) o número de operações execu­ tadas pelo algoritmo a partir do conjunto de dados d. (Note que c{d) também depende de n.) O melhor caso do número de operações executadas pelo algoritmo é o valor mínimo de c{d) enquanto d percorre todos os elementos de D.

E xem plo 5.12 Para a busca sequencial (Algoritmo 5.1), o melhor caso do número de operações A acontece quanto o alvo té xu o primeiro item no vetor. Nesse caso, é feita apenas uma comparação A, uma vez que nunca se entra no laço-enquanto. Então, a complexidade em melhor caso é 0(1).

Mesmo juntas, as análises de complexidade em pior e melhor caso nem sempre nos dizem toda a história, porque numa situação do mundo real a maioria dos conjuntos de dados ficará entre esses dois extremos. Os dados reais tendem a flutuar de forma aleatória, e a velo­ cidade do algoritmo pode variar de acordo. A comple­ xidade em caso médio de um algoritmo leva em conta todos os possíveis conjuntos de dados de entrada. D efinição 5.6 Suponha que existem k diferentes conjuntos de dados possíveis de tamanho n para um dado algoritmo, e que esses conjuntos de dados ocorrem de forma aleatória. Para cada i E {1, 2, ..., k}, seja p{ a probabilidade de o conjunto de dados i ocorrer, e seja

c{ o número de operações executadas pelo algoritmo a

partir do conjunto de dados i. (Tipicamente, ci é uma função de n.) Então o caso médio do número de opera­ ções executadas pelo algoritmo é

P i C i + P 2 C 2 H---\ - P k C k -

Em particular, se todos os conjuntos de dados são igual­ mente prováveis, o caso médio do número de operações é

C l + C2 + • • • + Cfc

k '

Em geral, calcular a complexidade em caso médio é muito mais complicado do que calcular as complexi­ dades em melhor e pior casos. Para começar, precisamos saber mais a respeito do conjunto de todos os possíveis conjuntos de dados de entrada, e devemos incorporar essa informação na nossa análise.

Exem plo 5.13 Calcule a complexidade temporal em caso médio da busca sequencial (Algoritmo 5.1). Suponha que a probabilidade de que o valor-alvo esteja na lista é 0,90, e que todas as posições da lista são igualmente prováveis.

Solução: Note que, se o item está na posição i, o algo­

ritmo faz i comparações. Uma vez que 90% do espaço amostrai consiste nas posições de lista igualmente prová­ veis 1, 2, ..., n, cada uma dessas tem probabilidade 0,9fn. Os 10% restantes do espaço amostrai requerem n + 1 comparações, pela análise do pior caso. Usando a Defi­ nição 5.6, o caso médio do número de comparações é

( l + 2 + . . . + n ) + 0 , l ( n + l ) =

/ 0,9 V n

n(n + 1)

2 + 0,l(7i + 1)

o que se simplifica para uma função linear de n. Portanto, a complexidade em caso médio da busca sequencial é

0(n). 0

Nesse exemplo, de fato não importa o que supomos a respeito da probabilidade de o alvo estar na lista: esse número desaparece quando passamos para a notação ©-grande. O exemplo a seguir ilustra uma outra situ­ ação quando as probabilidades podem ser ignoradas: alguns algoritmos sempre fazem a mesma quantidade de trabalho, independentemente do conjunto de dados. E x em p lo 5.14 Reveja a ordenação por bolhas do Exemplo 4.55. Esse algoritmo sempre faz n(n — l)/2 comparações a partir de uma lista de n itens, não importa o que aconteça. Portanto, a complexidade em melhor caso, em pior caso e em caso médio da ordenação por bolhas é 0 (n 2).

5.3.2 Cálculos Aproximados de

Complexidade

Para muitos algoritmos importantes, contar de forma exata o número de operações pode ser cansativo, difícil ou impossível. Entretanto, o uso criterioso de aproxi­ mações pode nos ajudar a explorar as ideias básicas da complexidade do algoritmo sem nos prendermos aos detalhes. O lado negativo é que as técnicas de aproxi­ mação não são matematicamente rigorosas, portanto é importante tratarmos seus resultados com cuidado. Mas entender os cálculos aproximados de complexidade nos ajudará a desenvolver a capacidade de pensar de forma analítica a respeito dos algoritmos.

E xem p lo 5.15 Aproxime a complexidade da busca binária iterativa (Algoritmo 5.3).

Solução: Temos uma escolha de qual operação iremos

contar. Uma vez que o laço-enquanto é a única parte desse algoritmo que se repete, faz sentido contarmos uma operação que é repetida dentro desse laço. Vamos concordar em contar as comparações > entre os elementos de U. Essa comparação acontece uma vez a cada volta no laço.

O algoritmo funciona eliminando aproximadamente metade dos itens na lista em consideração a cada volta no laço. O problema é que (l + r )j2 pode não ser um número inteiro, portanto \_(l + r)/2j não está exata­ mente no meio da lista. Algumas vezes eliminamos um pouco mais da metade dos itens, algumas vezes eliminamos um pouco menos. Portanto, dizer que a lista é “reduzida à metade” em cada volta é uma aproximação.

A qualquer momento na execução desse algoritmo, o número de itens em consideração é r — l + 1, e o laço termina quando l = r. Em outras palavras, o laço termina quando há apenas um item restante em consi­ deração. Então podemos aproximar o número de vezes

que percorremos o laço calculando o número de vezes que n precisa ser reduzido pela metade para obtermos 1. Chamemos esse número de c. Então

desse modo c ~ log2 n. Concluímos que a complexidade da busca binária é aproximadamente ®(log2 n). Note que essa aproximação representa o melhor caso, o pior caso e o caso médio, uma vez que o número de vezes que o laço é percorrido depende somente do tamanho da lista e não do arranjo do conjunto de dados. 0

É fácil verificarmos a solução ern forma fechada

D(p) = 3p + 3. Isso significa que uma lista de tamanho

2P requer aproximadamente Sp + 3 comparações, no pior caso. Substituindo p = log2 n, temos que C(n) ~ 3 log2 n + 3, então podemos aproximar a complexidade em pior caso desse algoritmo como 0(log2 n). 0

Esses cálculos anteriores mostram que a busca binária é mais eficiente do que a busca sequencial, no que depender da complexidade temporal. E comum que os algoritmos dividir-e-conquistar tenham um desempenho melhor do que algoritmos sequenciais simples. Para um outro exemplo, reveja a ordenação por fusão.

A nossa aproximação funciona exatamente quando

n é uma potência de 2. E um pouco cansativo calcu­

larmos essa complexidade quando n não é uma potência de 2, mas não devemos nos surpreender que um cálculo exato mostre que a complexidade da busca binária é ®(log2 n).

Exem plo 5.16 Calcule (ou aproxime) as complexidades em melhor e em pior caso da função recursiva de busca binária (Algoritmo 5.4).

Solução: Novamente, vamos concordar em contar o

número de comparações dos elementos de U. O melhor caso ocorre quando o item-alvo é logo encontrado pela primeira instrução se. Portanto, a complexidade em melhor caso é ®(1). Para o pior caso, aproxime o número de comparações C(n) feitas em uma lista de tamanho

n por uma relação de recorrência. Se n = 1, o pior

que o nosso algoritmo pode fazer são três comparações:

? ? ?

t — Xi, t < x{, e t > x{} Para uma lista de tamanho n,

o algoritmo também fará no máximo três comparações, seguidas por uma chamada para BuscaBin em uma lista

com aproximadamente metade do tamanho. Por isso a relação de recorrência

C(n) 3 se n = 1

3 + C {nj2) se n > 1

nos dá uma medida aproximada do número de compa­ rações. À medida que estamos aproximando, podemos facilitar nossa vida assumindo que n = 2P para algum

p. Seja D(p) = C{2P). Essa recorrência relacionada tem

a seguinte fórmula:

D(p) 3 se p = 0

3 + D(p — 1) se p > 0.

JA terceira dessas comparações é redundante, mas iremos ignorar essa ineficiência.

Exem plo 5.17 Aproxime a complexidade em pior caso do algoritmo de ordenação por fusão.

S o l u ç ã o - . Primeiro, veja novamente a sub-rotina Fundir

do Algoritmo 5.11. Sua função é pegar dois vetores orde­ nados yk, y2, ..., t/í e zl} z%, ..., zm e juntá-los em um grande vetor xl, x2, ..., xn, em que n = l + m. Pense nos dados dispostos em duas linhas, ordenados por “altura”, como mostrado na Figura 5.14. A cada vez que percorremos o laço, o algoritmo compara os elementos dos dados na frente de cada linha e escolhe o menor deles para se tornar o próximo elemento na lista de X/S.

Se contamos atribuições de itens do vetor, temos sempre n, um para cada um dos xks. No entanto, é de

costume contarmos em vez disso as comparações entre itens de vetor, e isso é um pouco mais complicado. Se em algum momento tivermos i > l, não existem mais itens restantes na lista y{, então as comparações entre itens não precisam mais ser feitas; nesse caso o resto de xk deve ser tirado da lista Zy Similarmente, ficamos sem Zj restantes quando j > m, e nesse caso preen­ chemos o resto dos xk usando os restantes sem fazer mais comparações entre itens. Uma vez que uma das listas deve se esgotar antes da outra, sempre haverá menos de n comparações. As coisas ficam ainda mais complicadas quando levamos em conta que as duas listas podem não ser exatamente do mesmo tamanho (quando / A m) . Assim, efetuar uma contagem exata das operações no algoritmo Fundir é difícil. Vamos dizer apenas que essa sub-rotina requer n operações, e lembrar que estamos fazendo uma sempre estima­ tiva conservadora.

Agora considere a função recursiva OrdF (Algoritmo

5.12). Para tornar mais fácil o cálculo, suponha que o tamanho do nosso vetor é n = 2P. Uma ordenação por fusão em uma lista de 2P elementos executa uma função Fundir em uma lista de 2P elementos depois de executar duas ordenações por fusão em listas de tamanho 2P-1. Portanto, pelo princípio da adição para algoritmos, temos a seguinte relação de recorrência para C(p), o

(a) Encontre uma aproximação para a complexi­ dade em caso médio desse algoritmo. (Para o caso médio, suponha que obtemos uma árvore de busca binária equilibrada. Não se preocupe com a Definição 5.6.)

(b) Calcule a complexidade em pior caso para esse algoritmo. (Dica: para começar, o pior caso ocorre quando o vetor já está ordenado!) 14. Neste problema iremos aproximar a complexidade

do algoritmo de Prim para encontrar uma árvore espalhada mínima de uma rede Aé (Algoritmo 5.9). Defina como tamanho de entrada n o número de vértices em Aí.

(a) Suponha que nenhum vértice tem grau maior que 5. Use o Teorema 2.6 para explicar por que o número de arestas é no máximo uma função linear de n.

(b) Use o Exemplo 4.54 para encontrar uma apro­ ximação para o pior caso do número de compa­ rações efetuadas pela seguinte parte do algo­ ritmo.

e <— a aresta mais curta entre um vértice em T e um vértice que não está em X

(c) Dê uma estimativa O-grande aproximada para a complexidade em pior caso do algoritmo de Prim.

15. Use uma relação de recorrência para aproximar o número de comparações feitas pela função recursiva

BuscarMax (Algoritmo 5.10).

16. Considere o algoritmo a seguir para encontrar um elemento-alvo t em um vetor xu a^, ..., xn. Suponha como condição prévia que exatamente um dos xts é igual a t.

Escolher i aleatoriamente de {1, 2, ..., n}