capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/texto12.pdf · descrever o tempo que demora a...
Post on 18-Jan-2019
218 Views
Preview:
TRANSCRIPT
419
Capítulo 12 - Análise da eficiência de algoritmos
Neste capítulo efectuaremos uma introdução à análise de eficiência de algoritmos, tópico importante da
ciência da computação, ilustrando-a através de alguns exemplos. Iremos considerar que os algoritmos a
analisar se encontram implementados na linguagem de programação Mathematica.
Como veremos, a análise do custo de algoritmos recursivos conduz-nos em geral a relações de
recorrência, ao passo que somatórios aparecem muitas vezes ligados à caracterização do custo de “ciclos”.
Algoritmos recursivos, que se baseiam em dividir um problema num certo número (fixo) de
subproblemas, conduzem-nos normalmente a certo tipo relações de recorrência não lineares, não analisadas
explicitamente no capítulo 9, pelo que também aproveitaremos este capítulo para introduzir um método
que em geral permite determinar a ordem de grandeza da solução de tais relações de recorrência.
Por outro lado, embora algoritmos recursivos sejam normalmente a solução computacional mais
simples e elegante para certo tipo de problemas, eles são em regra um pouco menos eficientes que os
“correspondentes” algoritmos de caráter iterativo, e em certos casos (que caracterizaremos informalmente)
não têm mesmo qualquer interesse prático, devido à sua “ineficiência”.
Finalmente, como já referimos, embora o tempo de execução não seja o único aspecto relevante na
análise de um algoritmo, uma vez que este consome outros recursos computacionais (como memória) que
também têm de estar disponíveis, iremos concentrar a nossa análise apenas no tempo de execução (ou,
mais precisamente, numa medida que traduza de algum modo tal tempo de execução dos algoritmos).
Secção 1: Análise empírica.
Considere-se que temos um certo problema que queremos resolver computacionalmente, e que dispomos de
dois (ou mais) programas que resolvem o problema em causa e queremos escolher o “melhor” (no sentido
de o mais eficiente – com menor tempo/custo de execução) de entre eles.
Uma primeira coisa que podemos fazer é proceder a uma (chamada) análise empírica: pôr os dois
programas a correr e ver qual demora menos tempo.
Se um demorar 1 segundo e o outro 10 segundos, tudo indica que o primeiro é melhor. E, na prática,
muitas vezes limitamo-nos a uma análise empírica, deste tipo.
No entanto, se em casos como o anterior, esta análise empírica é um indicador que o programa que
demora menos tempo é capaz de ser o melhor, ela pode ser enganadora, e tem de ser feita com cuidado:
• Se um demorar 1h e o outro 1h15m, será que ainda poderemos estar “tão certos” de que um é melhor
que o outro1 ?
1 Para além dos aspectos a seguir mencionados, refira-se ainda que poderá acontecer que “a ideia” que está por detrás de umprograma seja até melhor do que a que está por detrás do outro, mas que essa ideia tenha sido num caso implementada de formaoptimizada para um certo sistema computacional, ao passo que no outro caso pode estar “mal” implementada (não no sentido denão estar correcta – i.e. de não resolver o problema, mas sim no sentido de estar implementada de forma claramente poucoeficiente). Por exemplo, as funções predefinidas do sistema Mathematica foram construídas de forma totalmente optimizada, demodo a tirar o máximo partido das características desse sistema.
420
• Se os programas em causa, em vez de demorarem segundos, demorarem horas ou dias a correr, será que
esta análise ainda é uma boa solução ?
• Pelo menos do ponto de vista teórico, um programa pode demorar, para uma instância de dimensão n
(mesmo para um valor de n razoável) 10 vezes menos tempo do que outro, mas para instâncias maiores
(ou muito maiores) pode ser muito, muito pior.
Como se ilustrou no capítulo anterior, se um programa executar p.ex. 1000n2 operações e outro
executar n3 operações, e se cada operação demorar (em média) um nano-segundo a ser executada, então
para n=100 o primeiro demora 10 vezes mais tempo que o segundo (0,01 segundos vs 0,001
segundos), para n=1000 demoram o mesmo tempo (1 segundo), mas para n=1000000, o primeiro
demora 277,8 horas ao passo que o segundo demorará 31,71 anos !
• Até que ponto não poderemos estar a executar os programas para uma instância do problema que
corresponda ao caso mais favorável para um dos programas e ao mais desfavorável para o outro ?
• Pode ser que um dos programas se comporte melhor para input’s de pequena dimensão e o outro para
input’s de maior dimensão (e podemos querer apllicar o programa a um conjunto de casos de pequena
ou de grande dimensão, devendo então a escolha ser feita em função disso).
Devemos portanto, em geral, mesmo quando fazemos uma análise empírica, proceder não a um teste,
mas sim a uma “bateria de testes” que permita testar os programas para diversos tipos de input’s pequenos
e/ou grandes (em função do caso em questão) e que cubram as diversas situações possíveis (melhor
situação para um e para outro programa, pior situação, situação “típica”) .
Mas isso obriga a que se analise os algoritmos em causa, estudando como o comportamento dos
programas é afectado por certas características dos input’s relevantes.
Assim, embora tal análise empírica seja relevante, e não deva ser desprezada, o que em geral se procura
fazer é dispor de maneiras de medir matematicamente, de alguma forma, o tempo/custo de execução do
programa, e compará-lo com o de outros programas que resolvam o mesmo problema.
Isto é, como referimos no capítulo anterior, o que procuramos é traduzir tal tempo de execução como
uma função matemática do seu parâmetro n e estudar como ela se comporta.
Como calcular esse tempo de execução ?
Como observámos no capítulo anterior, uma primeira hipótese consiste em associar a cada tipo de
operação executada pelo programa um certo parâmetro que denota o tempo de execução dessa operação2,
contar o número de vezes que são efectuadas cada uma dessas operações, e a partir daí calcular o tempo
total de execução do programa como uma função do input n, parametrizada a esses tempos (tempos que
depois podemos, naturalmente, instanciar).
Na próxima secção ilustraremos este tipo de análise do tempo de execução de um programa, para um
programa imperativo e um programa recursivo que resolvem o mesmo problema.
2 Tempo esse cujo valor concreto depende do sistema computacional em que tal programa é implementado.
421
Secção 2: Cálculo do tempo total de execução de um programa: um exemplo3.
Comecemos por considerar, como primeiro exemplo, o caso de um algoritmo muito simples para o
cálculo do factorial de um natural. Este exemplo servirá para ilustrar todos os detalhes que podem ser
considerados na cálculo do tempo total de execução de programas imperativos e recursivos.
Suponha-se que se pretende calcular computacionalmente4 o factorial de um número natural.
Um algoritmo para o cálculo imperativo do factorial :
É fácil construir um algoritmo para o cálculo, imperativo, do factorial. Por exemplo, usando
• num, para guardar o núm ero cujo factorial se pretende calcular, e cujo valor será passado ao algoritmo
(i.e. esse número será o valor input do algoritmo e num será o único parâmetro do algoritmo);
• res, para designar a variável que vai guardando o res ultado calculado até ao momento; e
• prox, para designar a variável que vai controlando o pro g esso no cálculo;
facilmente se verifica que o algoritmo a seguir (onde a variável do progresso prox indica o próx imo5
valor a multiplicar), codificado na linguagem Mathematica, permite calcular, de forma imperativa, tal
factorial (assumindo, sem testar, que o input é um natural):
res = 1 ; (* comentário: res guarda no início o valor de 0! *)
prox = 1 ;
While[ prox <= num,
res = res * prox ;
prox = prox + 1
]
Análise do tempo de execução do algoritmo :
Suponha-se, então, que se pretende calcular o tempo de execução deste algoritmo.
Uma execução deste algoritmo, envolve a execução de 5 comandos/acções atómicas (atribuições ou
testes). As atribuições res=1 e prox=1 são executadas uma só vez. Designando por n o valor do input
do algoritmo, que é guardado no início em num (o v alor i nicial de num, que denotaremos por6 vi(num)),
facilmente se verifica que o teste prox<=num (a chamada “guarda” do ciclo While) é executado/avaliado
3 Nesta secção segue-se, com ligeiras modificações (nomeadamente notacionais), o texto [13]. (De facto, o exemplo aquiescolhido é diferente do exemplo introdutório considerado em [13], mas o tipo de análise é análoga.)4 Várias linguagens de programação, como p.ex. o Mathematica, já disponibilizam funções predefinidas que permitem efectuardirectamente o cálculo do factorial, naturalmente mais eficientes do que aquelas que iremos referir em seguida. Mas o objectivoaqui é apenas o usarmos este exemplo para ilustrar como se calcula os tempos associados a programas imperativos e recursivos.5 Pelo que por vezes se diz que a variável do progresso está adiantada. Refira-se ainda que o nome prox, usado neste exemplopara a variável do progresso, foi escolhido de forma a ser mnemónico de próximo (salientando assim que o “progresso estavaadiantado”). Analogamente, o nome res foi escolhido para sugerir (variável do) resultado. No entanto, nem sempre temos estaspreocupações metodológicas, escolhendo normalmente nomes mais curtos para as variáveis do programa (p.ex. i e j são nomesusualmente escolhidos para a variável do progresso).6 No caso do algoritmo em questão o valor guardado em num não é alterado durante a execução do algoritmo (num não é alvode qualquer atribuição). No entanto, à partida, nada impede que num programa se altere uma variável, que guarda no início oinput do programa, pelo que convém dispomos de notações que nos permitam falar desse valor.
422
n+1 vezes (uma vez que o teste prox<=num é avaliado com prox=1,...,num+1, e o valor guardado
em num não é alterado). Finalmente, cada uma das atribuições res=res*prox e prox=prox+1 é
executada uma vez por cada execução do passo do ciclo While ; como este é executado com
prog=1,...,num (e o valor guardado em num não é alterado), temos que cada uma dessas atribuições é
executada n vezes.
Se assumirmos que o tempo de execução de uma atribuição ou teste não depende dos valores guardados
nas variáveis envolvidas (pelo que cada execução dessa atribuição ou teste demorará o mesmo tempo),
então é fácil de calcular o tempo total de execução do algoritmo sabendo o número de vezes que é executada
cada atribuição e teste. O quadro seguinte resume essa informação, designando por t1, ..., t5 os tempos de
execução das 5 operações em causa e considerando n = vi(num):
operação tempo de execução nº de vezes
res=1 t1 1
prox=1 t2 1
prox<=num t3 n+1
res=res*prox t4 n
prox=prox+1 t5 n
Designando por Talgoritmo(n) o tempo de execução do algoritmo (com input n), tem-se então que
Talgoritmo(n) = t1 + t2 + (n+1) t3 + n t4 + + n t5 = (t3+t4+t5) n + t1 + t2 + t3
isto é, Talgoritmo(n) é da forma
Talgoritmo(n) = k1 n + k2
com k1 e k2 constantes, e k1≠0, pelo que Talgoritmo(n) = Θ(n).
Em vez de Talgoritmo(n), podemos também usar Talgoritmo(num→n) como forma de salientar que estamos a
descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no
início o valor n. A expressão
Talgoritmo(num → n) = k1 n + k2
salienta que o tempo do algoritmo só depende do valor inicialmente atribuído ao parâmetro num, sendo
uma função linear desse valor.
É possível proceder a uma análise mais “fina” dos tempos acima, em que se detalha os tempos
envolvidos no cálculo das diferentes operações7. Tal é feito a seguir.
Observação 1 (cálculo dos tempos envolvidos na avaliação de expressões e atribuições) :
Seguem-se algumas observações sobre os tempos envolvidos na avaliação de expressões e atribuições, que
são relevantes quando se pretende fazer uma análise mais detalhada desses tempos.
7 Este tipo de análise mais detalhada não se justificaria no âmbito deste exemplo, tão simples, sendo aqui efectuadaessencialmente apenas para ilustrar como é que ela pode feita.
423
Começando pela avaliação de expressões, considere-se, por exemplo, a expressão res*prog. A sua
avaliação exige o acesso a duas variáveis e à multiplicação dos seus valores. Se assumirmos que o tempo
associado à localização de uma v ariável não depende de qual é essa variável (sendo, portanto, uma constante
que designaremos a seguir de tv) e se assumirmos (para simplificar) que o tempo que demora a multiplicar
dois valores não depende da ordem de grandeza dessas valores, então podemos dizer que o tempo que demora
a avaliar res*prog é dado por tv+t*+tv (com t* o tempo da multiplicação).
Mais geralmente, podemos dizer que o tempo de avaliação de uma expressão não atómica
o(exp1,...,expn) é igual à soma dos tempos necessários à determinação dos valores e1,...,en das
expressões argumento exp1, .., expn, mais o tempo necessário ao cálculo do valor o(e1,...,en),
assumindo-se em geral (para as expressões aritméticas e booleanas) que o tempo associado ao cálculo do
valor o(e1,...,en) é independente dos valores e1,...,en em causa, pelo que o podemos designar por uma
constante (dependente apenas da operação em causa) to.
No que respeita às atribuições, o tempo associado à execução de uma atribuição var=exp é igual ao
tempo T(exp) de determinação do resultado da expressão exp mais o tempo correspondente à associação
desse valor à variável var. Se assumirmos que o tempo correspondente à associação do valor da expressão
exp à variável var, em questão, não depende nem de qual é essa variável, nem de qual é o valor da
expressão exp, podemos designar esse tempo por uma constante t=, tendo-se T(var=exp) = t= + T(exp).
∇
À luz das considerações anteriores, é fácil verificar que, no caso em questão, se tem8:
• t1 = T(res=1) = t=
• t2 = T(prox=1) = t= (= t1)
• t3 = T(prox<=num) =9 tv + t<= + tv
• t4 = T(res=res*prox) = t= + tv + t* + tv
• t5 = T(prox=prox+1) = t= + tv + t+
e Talgoritmo(n) = Talgoritmo(num→n) = (t3+t4+t5) n + t1 + t2 + t3 = (5tv + 2t=+ 2t* + t<=) n + 2tv + 2t=+ t<=
Funções/programas Mathematica e sua invocação:
Se quisermos codificar o algoritmo atrás como um programa/função da linguagem Mathematica, que
recebe o input através do parâmetro num, e retorna o resultado calculado10, somos conduzidos ao seguinte
programa11, a que chamaremos de factImp (abreviatura de “ fac torial imp erativo”):
8 Assume-se a seguir que, durante a execução do programa, na avaliação das expressões, a identificação das constantes quenelas ocorrem não envolve qualquer tempo. Mesmo que isso possa não ser completamente verdade, tal tempo será certamentena prática desprezável. Mais ainda, como se pode assumir que ele é independente do valor da constante em causa (sendoportanto um tempo constante que poderíamos denotar por tc), a sua eventual consideração não afectaria o essencial do que sesegue (acrescentando apenas mais umas constantes às expressões a que chegaremos).9 Está-se aqui a considerar que num é uma variável. Embora essa seja a situação usual nas linguagens de programação, no casoda linguagem Mathematica, num funcionará como um parâmetro da função (ver a seguir), que é substituído pelo valor doargumento, aquando de uma invocação, e não como uma varável (local) no qual é guardada o valor do argumento.10 No Mathematica é retornado o valor da última expressão avaliada, pelo que uma forma de retornar o resultado pretendidoconsiste em no final (como última instrução) mandar avaliar a variável res que guarda esse resultado.11 Onde as variáveis prox e res são declaradas como variáveis locais ao corpo da função, através da construção Module.
424
factImp = Function[num, Module[{res,prox},
(* comentário: segue-se o corpo da função *)
res = 1 ;
prox = 1 ;
While[ prox <= num,
res = res * prox ;
prox = prox + 1
] ;
res
]];
Podemos agora ser mais precisos e calcular mesmo o tempo envolvido na obtenção do valor do
factorial em causa, através da invocação desta função/programa12 Mathematica.
Sendo13 f = Function[{par1,...,park}, corpo] uma função Mathematica de k
parâmetros14, o tempo T(f[exp1,...,expk]), associado a uma invocação f[exp1,...,expk] de f
(onde exp1,...,expk designam as k expressões argumento dessa invocação), pode ser obtido como se
segue:
T(f[exp1,...,expk]) =
T(exp1) + ... + T(expk) + cinv + Tcorpo(f)(par1→vi(exp1),...,park→vi(expk))
onde:
• T(expj) designa o tempo associado à determinação do valor da expressão expj (para j=1,...,k);
• vi(expj) designa esse valor, isto é, mais precisamente, o valor que a expressão expj denota aquando
(no início) da invocação (podemos ler vi(expj) como o v alor i nicial, ou o v alor aquando da i nvocação,
da expressão expj) 15;
• cinv (mnemónico de c usto da inv ocação) é uma constante16 que denota a soma dos seguintes tempos:
localização da função f, atribuição aos parâmetros dos valores das correspondentes expressões
12 O que se segue será particularmente relevante para a análise do tempo do cáculo recursivo do factorial, a discutir a seguir.13 As chavetas em torno da sequência de parâmetros não são necessárias se só existir um parâmetro (ver apêndice 2).14 Ou, caso existam variáveis locais: f = Function[{par1,...,park},Module[{sequência das variáveislocais}, corpo]].15 Como um programa pode recorrer e alterar variáveis globais (herdadas), se alguma das eventuais variáveis ocorrendo naexpressão argumento expj for alterada durante a execução do corpo da função, então o valor denotado pela expressão expjtambém irá variar ao longo de tal execução, pelo que é importante especificar que vi(expj) denota o valor da expressão expjaquando da invocação.16 Constante no sentido de que não depende das expressões argumento, nem da ordem de grandeza do seu valor. Naturalmenteo tempo em causa depende de alguma forma da função f em questão, no sentido de que depende do número de argumentos e docorpo da função (uma vez que há que substituir neste os parâmetros pelos valores dos correspondentes argumentos), bem comodepende do número de variáveis locais que há que criar. De qualquer forma trata-se de um tempo “desprezável” (quandoefectuado apenas uma vez), que assumiremos constante e designaremos por cinv, como se ele fosse independente da própriafunção f em questão.
425
argumento17 (cujo tempo se assume que não depende da ordem de grandeza desses valores), e criação
das eventuais variáveis locais;
• e Tcorpo(f)(par1→vi(exp1),...,park→vi(expk)) denota o tempo de execução do corpo da função f
quando o parâmetro parj (com j=1,...,k) assume o valor vi(expj) indicado (isto é, o valor que a
correspondente expressão argumento expj denota aquando da invocação) 18.
Assim (de acordo com o que acabámos de ver para o caso geral), no caso em questão o tempo
envolvido numa invocação factImp[exp] é calculado como se segue:
• Em primeiro lugar há que determinar o valor da expressão argumento exp, o que demora um tempo
que designámos de T(exp) (de modo a salientar que ele pode não ser constante, dependendo da
expressão argumento exp).
• Depois há que localizar a função factImp, “atribuir” o valor da expressão argumento (designado de
vi(exp)) ao parâmetro num, e criar as variáveis locais res e prox. Designámos por cinv a constante
correspondente à soma dos tempos associados a estas actividades (e que se assume independente do
valor da expressão argumento).
• Finalmente há que executar o corpo da função factImp (quando o parâmetro num assume o valor
vi(exp)): no caso em questão o corpo da função é constituído pelo algoritmo anterior (cuja execução
demora o tempo Talgoritmo(num→vi(exp)), seguido da avaliação da variável do resultado res (o que
demora um tempo constante, que, seguindo as notações atrás, designaremos de tv).
Deste modo, o tempo T(factImp[exp]) de uma invocação factImp[exp] é dado por
T(factImp[exp]) = T(exp) + cinv + Tcorpo(factImp)(num→vi(exp))
= T(exp) + cinv + Talgoritmo(num→vi(exp)) + tv
= T(exp) + cinv + (5tv + 2t=+ 2t* + t<=) vi(exp) + (2tv + 2t=+ t<=) + tv
= k1 vi(exp) + T(exp) + k2
com k1 = 5tv + 2t=+ 2t* + t<= ≠ 0 e k2 = cinv + 3tv + 2t=+ t<=
No caso da expressão argumento exp ser uma variável n, então T(exp) é o tempo associado à
localização do valor de n, dado por tv (tempo constante que assumimos que não depende de qual é essa
variável, nem de qual é o seu valor). Assim, continuando a designar por n o valor vi(n), tem-se:
T(factImp[n]) = k1 vi(n) + T(n) + k2 = k1 n + k2 + tv
17 Em vez de “atribuição aos parâmetros dos valores das correspondentes expressões argumento” deveríamos dizer“substituição no (em todo o) corpo da função de cada parâmetro pelo valor da correspondente expressão argumento”, pois é issoo que é feito no Mathematica, onde os parâmetros não funcionam como variáveis locais (não podendo ser alvo de atribuições).No entanto, como na maioria das linguagens de programação (imperativas) os parâmetros são variáveis locais às quais sãoatribuídos os valores dos argumentos no início da invocação, iremos aqui, a este respeito, proceder como se o Mathematicafuncionasse como as linguagens de programação mais usuais (dando assim maior generalidade ao modo como são efectuadas aseguir as contas).18 Poder-se-ia ainda considerar o tempo associado ao retorno do resultado da função (i.e., no caso do sistema Mathematica, otempo do retorno do valor da última expressão avaliada). Iremos assumir, para simplificar, que esse tempo está incluído no quechamámos de custo da invocação (e designámos por cinv).
426
isto é, existem constantes k1 e k3, com k1≠0 e k3= k2 + tv, tais que
T(factImp[n]) = k1 n + k3
Ou seja, T(factImp[n]) cresce linearmente com n (= vi(n)).
No caso da expressão argumento exp ser por exemplo n^2 (com n= vi(n)) então
T(factImp[n^2]) = k1 vi(n^2) + T(n^2) + k2
com T(n^2) o tempo associado à localização do valor de n e ao cálculo do seu quadrado, tempo constante
dado por tv+t^2. Assim, tem-se:
T(factImp[n^2]) = k1 n2 + tv + t^2 + k2
tempo que (naturalmente) cresce quadraticamente com n (= vi(n)).
E o tempo de uma invocação factImp[factImp[n]] será dado por
T(factImp[factImp[n]])
= k1 vi(factImp[n]) + T(factImp[n]) + k2
= k1 n! + k1 n + k3 + k2
Cálculo recursivo do factorial :
Considere-se agora o seguinte programa recursivo para o cálculo do factorial de um inteiro positivo:
factRec = Function[num,
If[ num == 0,(* então o resultado é: *)
1 ,(* senão o resultado é: *)
num * factRec[num-1]
]
];
Análise do tempo de execução do cálculo recursivo :
À luz do que observámos anteriormente, é agora fácil de calcular o tempo T(factRec[exp]) de
execução de uma invocação factRec[exp]:
T(factRec[exp]) = T(exp) + cinv + T(corpofactRec(num→vi(exp)))
Por sua vez, o T(corpofactRec(num→vi(exp))) pode ser obtido como se segue:
• Se vi(exp)=0, então a execução de corpofactRec(num→vi(exp)) corresponde19 a avaliar o teste
num==0, que demora um tempo (constante) tv + t==.
19 No caso da linguagem Mathematica o que devia estar aqui era “corresponde a avaliar o teste vi(exp)==0, que demora umtempo (constante) t==” (ver nota de rodapé 17).
427
• Se vi(exp)>0, então a execução de corpofactRec(num→vi(exp)) corresponde20 a avaliar o teste
num==0 (tempo tv + t==) e a avaliar a expressão
num * factRec[num-1]
o que demora o tempo
tv + t* + T(factRec[num-1])
isto é
tv + t* + T(num-1) + cinv + T(corpofactRec(num→vi(exp)-1))
ou seja
tv + t* + tv + t_ + cinv + T(corpofactRec(num→vi(exp)-1))
Em resumo, designando por n o valor vi(exp) e designando T(corpofactRec(num→n)) simplesmente
por TC(n), então
T(factRec[exp]) = T(exp) + cinv + TC(n)
com TC(n) dado por uma relação de recorrência (como é típico no caso de programas recursivos), mais
concretamente pela relação de recorrência
• TC(n) = tv + t==, se n=0
• TC(n) = 2tv + t* + t_ + cinv + TC(n-1), se n>0
Usando p.ex. o método iterativo, facilmente se chega a:
TC(n) = tv + t== + (2tv + t* + t_ + cinv) n
pelo que
T(factRec[exp]) = T(exp) + cinv + tv + t== + (2tv + t* + t_ + cinv) n
com n= vi(exp).
No caso da expressão argumento exp ser uma variável n, então T(exp) é dado por tv, e portanto
T(factRec[n]) = tv + cinv + tv + t== + (2tv + t* + t_ + cinv) n
isto é, existem constantes d1 e d2, com d1≠0, tais que
T(factRec[n]) = d1 n + d2
pelo que também T(factRec[n]) cresce linearmente com n (= vi(n)).
Observação 2 (profundidade de recursão) :
A profundidade de recursão corresponde ao número de invocações de uma função recursiva que estão
pendentes. Por exemplo, a invocação de factRec[2] necessita de factRec[1] que, por sua vez,
necessita de factRec[0] (que já não envolve qualquer invocação recursiva). Assim, quando no cálculo
de factRec[2] se chega à invocação factRec[0] estão pendentes 3 invocações (incluindo
20 No caso da linguagem Mathematica o que devia estar aqui era “corresponde a avaliar o teste valor(exp)==0 (tempo t==) e aavaliar a expressão vi(exp)*factRec[vi(exp)-1]” (ver nota de rodapé 17). E outras alterações, do mesmo tipo, teriam deser feitas no que se segue, se quiséssemos fazer as contas exactamente como as coisas se passam no Mathematica.
428
factRec[0]), o que significa uma profundidade de recursão de 3. A invocação factRec[n] implica
uma profundidade de recursão de21 n+1.
Por omissão, o sistema Mathematica define 256 como a profundidade máxima de recursão aceite
(parando no cálculo quando se chega a essa profundidade máxima, e imprimindo uma mensagem
apropriada, juntamente com a expressão nesse momento em avaliação).
É possível, no entanto, alterar este valor, modificando o valor de $RecursionLimit (por exemplo,
pondo-o a infinito). Sugere-se que tal alteração da variável $RecursionLimit seja encapsulada no
âmbito de um comando Block, de modo a que ela só surta efeito durante a execução do corpo do Block
(as variáveis a que são atribuídos novos valores no Block, no final da execução deste voltam a ter os
valores que lhes estavam atribuídos antes dessa execução).
Assim, avaliando, por exemplo,
Block[{$RecursionLimit=∞}, factRec[322]]
já conseguimos calcular factRec[322], apesar da profundidade de recursão requerida ser superior a 256.
∇
Conclusão – comparação das versões imperativa e recursiva :
No exemplo considerado, o comportamento assimptótico das versões imperativa e recursiva é análogo. Tal
não significa, contudo, que uma das versões não seja mais rápida que a outra. No entanto, uma comparação
entre os tempos exactos de execução das duas versões, do cálculo do factorial, exigiria o conhecimento dos
valores das várias constantes envolvidas nas expressões matemáticas (atrás referidas) que descrevem esses
tempos, e não será objecto de análise aqui.
De qualquer forma, uma experimentação, calculando o valor do tempo de execução de factImp[n] e
de factRec[n] para vários valores de n, revela (ou, pelo menos, sugere) claramente que a versão
recursiva é um pouco mais lenta que a versão imperativa.
Por exemplo, num computador pessoal, que hoje já estaria completamente obsoleto, obteve-se no
Mathematica os seguintes tempos
Timing[factRec[250];]
{1.06667 Second, Null}
Timing[factImp[250];]
{0.916667 Second, Null}
e num computador pessoal muito recente obteve-se22
Timing[factRec[250];]
{0.005744 Second, Null}
Timing[factImp[250];]
{0.003778 Second, Null}
21 De acordo com o texto [13], que temos seguido nesta secção, a profundidade considerada no Mathematica é mesmo n+2, porrazões que são aí referidas, e que não abordaremos aqui.22 Compare-se a evolução no tempo de cálculo !
429
Qual a razão desta maior lentidão da versão recursiva ? Essencialmente tem a ver com o custo
envolvido numa invocação, face ao custo das operações que são executadas em cada passo do ciclo do
versão imperativa, uma vez que é fácil de ver que no exemplo em causa, grosso modo, cada passo do ciclo
da versão imperativa é substituído por uma invocação da própria função, na versão recursiva.
Secção 3: Cálculo (contagem) apenas das principais operações realizadas.
Como se referiu no último capítulo, uma análise da eficiência do tipo da anterior, em que se procura obter
o tempo total de execução de uma invocação de um programa, entrando em linha de conta com todos os
tempos envolvidos nessa invocação (contando nomeadamente todas as operações executadas por um
programa, e procurando associar um tempo a cada uma dessas operações), pode mostrar-se às vezes
bastante complexa (embora não se possa dizer que tal seja o caso no exemplo, muito simples, anterior).
E muitas vezes não necessitamos de ter uma ideia tão exacta do tempo de execução do programa. O que
é essencial é obter um valor que traduza de algum modo o custo da execução do programa, para cada input
n. E, para esse efeito, muitas vezes basta-nos contar o número de vezes que são efectuadas as principais
operações23 que são realizadas pelo programa em causa24, nomeadamente se só quisermos ter uma ideia da
ordem de grandeza do custo/tempo de execução do programa.
Comecemos por ilustrar este tipo de abordagem à análise de eficiência, a propósito do exemplo muito
simples, do cálculo do factorial, que abordámos na secção anterior.
Antes porém, efectuaremos algumas observações e estabeleceremos certas convenções notacionais a
utilizar neste tipo de análise de eficiência, no resto deste capítulo.
Observação 1 (variáveis da linguagem de programação e seu valor, e convenções notacionais) :
Informalmente, podemos dizer que uma variável de uma linguagem de programação é um nome25 que
denota um valor. De facto, uma variável de uma linguagem de programação denota uma célula de memória
onde está guardada um valor (o valor que a variável indirectamente denota), valor esse que pode ser alterado
durante a execução de um programa (através de comandos de atribuição a essa variável).
Por essa razão, em particular, torna-se por vezes conveniente distinguir estas duas perspectivas
(sintáctica e semântica), distinguindo a variável (o nome), bem como as expressões construídas à custa de
variáveis, do valor que denotam.
Suponha-se por exemplo que tínhamos codificado o programa factImp como se segue (de uma forma
que neste caso não se aconselha, onde ele altera variáveis não locais):
23 As operações que, em princípio, desempenham um papel mais importante no tempo que o programa demora a ser executado.24 E depois, se tal for desejado, podemos associar um tempo a cada uma dessas operações, obtendo uma ideia aproximada dotempo de execução do programa.25 Mais precisamente, nas linguagens de programação considera-se em geral que uma variável é uma sequência (não vazia) deletras e números, começando por uma letra. No caso das linguagens de programação “tipadas” (o que não é o caso dalinguagem Mathematica), tal sequência de letras e números deverá ainda ser declarada como sendo uma variável, onde nessadeclaração se indica também qual o tipo dessa variável, especificando-se assim qual o género de valores que poderão estarguardados na(s) célula(s) de memória a associar à variável em questão.
430
factImp = Function[num,
n=1; prox=1;
While[prox<=num, n=n*prox; prox=prox+1];
n
];
e que escrevíamos (informalmente)
T(factImp[n]) = (5tv + 2t=+ 2t* + t<=) n + (cinv + 4tv + 2t=+ t<=)
Imediatamente poderia surgir a dúvida se o valor de n no lado direito representava o valor que tinha n
aquando (no início) da invocação factImp[n] ou o valor que n tinha no fim dessa invocação
factImp[n] (e que é igual ao factorial do valor inicial de n) 26.
Por essa razão (para evitar dúvidas como a anterior), introduzimos a notação vi(exp) para denotar o
valor que uma expressão argumento exp denotava aquando (no início) da invocação de um programa27
(cujo tempo pretendíamos calcular), e escrevemos p.ex.
T(factImp[n]) = (5tv + 2t=+ 2t* + t<=) vi(n) + (cinv + 4tv + 2t=+ t<=)
bem como
T(factImp[n^2]) = (5tv + 2t=+ 2t* + t<=) vi(n^2) + (cinv + 4tv + 2t=+ t<=+ t^2)
= (5tv + 2t=+ 2t* + t<=) n2 + (cinv + 4tv + 2t=+ t<=+ t^2), com n = vi(n)
e, mais geralmente,
T(factImp[exp]) = T(exp) + cinv + Tcorpo(factImp)(num→vi(exp))
Considere-se agora que não pretendemos obter o tempo total de execução de uma invocação de um
programa (a seguir designado genericamente de) prog, entrando em linha de conta com todos os tempos
envolvidos nessa invocação, mas pretendemos apenas obter um valor que traduza de algum modo o custo
da execução do programa, para cada input do programa, contando p.ex. o número de vezes que são
efectuadas certo tipo de operações que são realizadas pelo programa em causa. Mais concretamente,
designando por C tal função de custo, pretende-se calcular o número dessas operações que são realizadas
numa invocação prog[exp], número que podemos designar de C(prog[exp]).
Nesse caso, não nos interessa o custo da invocação (que designámos de cinv) e podemos escrever
genericamente (onde C(exp) e Ccorpo(prog)(num→vi(exp)) têm o significado intuitivo esperado: p.ex.
C(exp) designa o número de operações desse tipo que são realizadas na avaliação da expressão exp, o que
normalmente só é relevante se exp envolver uma outra invocação do programa em causa):
C(prog[exp]) = C(exp) + Ccorpo(prog)(parâmetro→vi(exp))
Mais ainda, para este efeito não é relevante se as expressões atómicas que compõem exp são
constantes ou variáveis: o que nos interessa é o seu valor. (O mesmo não é verdade, se quisermos ser
26 Associado à execução de um programa imperativo temos uma noção de estado (da computação), caracterizado pelos valoresguardados nas variáveis do programa (em cada “momento” dessa execução), e uma invocação de um programa imperativopode ser vista como uma transição de um estado inicial para um estado final, obtida através de sucessivas alterações do estadoda computação provenientes da execução do programa invocado.27 Como só necessitámos de nos referir ao valor das expressões argumento aquando (no início) da invocação de um programa(no estado inicial dessa invocação), não procurámos introduzir notações (mais complexas) para poder denotar os valores que asvariáveis (e as expressões construídas à custa delas) podem assumir durante a (em cada “momento” da) execução do programa.
431
muito precisos, quando se calcula T(prog[exp]): supondo p.ex. que prog recebe inteiros, então os
tempos T(prog[3]) e T(prog[n]), com vi(n)=3, são diferentes, pois no último caso, para o cálculo de
T(n), há que contar com o tempo do acesso à célula de memória associada à variável n para se obter o
valor 3 lá guardado).
Assim, e para simplificar as notações usadas28, iremos utilizar letras (escritas em times e itálico) para
designar valores (que à partida podem ser quaisquer), subentendendo-se que quando tais letras ocorrem na
expressão argumento de uma invocação prog[exp], tal significa que tais valores podem estar29 aí a ser
expressos através de uma constante, ou através de uma variável da linguagem de programação, assumindo-
se então, neste último caso, que essa variável não ocorre no programa prog.
Esta convenção justifica-se para evitar ter de estar a considerar a notação vi(exp)30, e tem em conta
que nesta análise da eficiência estamos genericamente interessados em caracterizar apenas (o valor do custo
da execução do programa para um certo input n, independentemente de como este input é expresso numa
invocação do programa, i.e. estamos interessados em caracterizar apenas) o valor de
C(prog[n]) (muitas vezes abreviado por C(n), deixando prog implícito)
Naturalmente, se prog for um programa recursivo, para o cálculo de C(prog[n]) podemos ter de
calcular o valor do custo de outras invocações de prog, com outros argumentos (como C(prog[n-1])),
mas nesse cálculo continua a ser verdade que o que nos interessa é o valor do argumento dessa invocação, e
não onde tal valor se encontra guardado (pois nos abstraímos do tempo necessário para o obter).
∇
Exemplo 1: cálculo do factorial (de um natural).
No caso do factorial podemos supor que o custo do cálculo do factorial é essencialmente determinado pelo
número de multiplicações realizadas. Vejamos, então, como se obteria tal medida de custo para os dois
programas indicados para o cálculo do factorial.
Designe-se por NMvi(n) o n úmero de m últiplicações realizadas pela v ersão i mperativa, tendo como
input o valor n, isto é, NMvi(n) designa o número de múltiplicações que ocorrem numa invocação
factImp[n] (i.e. NMvi(n) pode ser visto como uma abreviatura de NM(factImp[n])), onde recorde-se
factImp = Function[num, Module[{res,prox},
res = 1 ; prox = 1 ;
While[prox<=num, res = res*prox ; prox = prox+1] ;
res
]];
28 E tornar “mais leves” as expressões matemáticas a construir neste tipo de análise da eficiência (simplificando a sua leitura).29 Isto é, é irrelevante, para efeitos da análise em causa, se tais valores são expressos em exp, através de uma constante, ouatravés de uma variável da linguagem de programação. De outra forma, supondo p.ex. que prog tem inteiros como input,quando escrevemos prog[n] tanto podemos encarar n como estando a designar uma particular constante inteira (qualquer),como podemos encarar n como uma variável da linguagem de programação n (guardando inteiros) que não ocorra em prog.30 Pois é imediato que, debaixo da assumpção mencionada, p.ex. vi(n)=n, vi(n+2)=n+2 e vi(n+k)=n+k, e, mais geralmente, ovalor da expressão exp, argumento da invocação, não varia com a execução do programa prog.
432
Ora, ocorre uma multiplicação (apenas) em cada execução do passo do ciclo While e, numa invocação
factImp[n], este é executado com prog=1,..., n.
Assim, é imediato que 31
NMvi(n) = n
Designe-se, agora, por NMvr(n) o n úmero de m últiplicações realizadas pela v ersão r ecursiva, tendo
como input o valor n, isto é, NMvr(n) = NM(factRec[n]), onde (recorde-se)
factRec = Function[num,
If[num==0, 1, num * factRec[num-1]]
];
É imediato que NMvr(n) é dado pela relação de recorrência
• NMvr(n) = 0, se n=0
• NMvr(n) = 1 + NMvr(n-1), se n>0
e, usando p.ex. o método iterativo, facilmente se chega a
NMvr(n) = n
Como era imediato, olhando para os dois programas, ambos realizam n multiplicações para calcular o
factorial de n. Assim, enquanto que na análise do tempo total (exacto) de execução, obtinhamos expressões
distintas para os tempos dos dois programas, nesta análise mais simples não os conseguimos diferenciar.
Mas, em termos da ordem de grandeza do seu crescimento, ambas as análises nos dizem que os dois
programas são da mesma ordem de grandeza (linear em n).
Ainda que numa análise informal, se observarmos os dois programas constatamos que eles de facto têm
essencialmente os mesmos custos: como no programa imperativo a inicialização das variáveis res e
prox só é efectuada uma vez, para além das multiplicações, o custo do programa imperativo envolve a
avaliação da guarda do ciclo (prox<=num) e o progresso no cálculo (prox=prox+1) efectuado em cada
passo do ciclo; grosso modo, a isto corresponde, na versão recursiva, a avaliação, em cada invocação, do
teste num==0 e do argumento num-1 da invocação seguinte. O que torna a versão recursiva um pouco
mais lenta que a imperativa, são fundamentalmente os outros custos associados a cada invocação (e
enquanto ocorre uma só invocação na versão imperativa, na versão recursiva ocorrem n+1 invocações).
Exemplo 2: Supremo de uma lista (de inteiros).
Suponha-se agora que se pretende calcular o supremo de uma lista de inteiros distintos32.
31 Para mostrar como esse número de comparações é obtido (em que é realizada uma multiplicação por cada execução do
passo do ciclo) podemos recorrer aos somatórios e escrever NMvi(n) =
€
1i=1
n
∑ = n . Tal ainda ficará mais sugestivo, se utilizarmos
a variável prox como variável do somatório, e escrevermos NMvi(n) =
€
1prox=1
n
∑ = n .
32 Os algoritmos a seguir funcionam mesmo que se permita que haja repetições na lista, mas o impor-se que não haja repetiçõesfacilita a análise em média da (primeira) versão recursiva que apresentaremos para o cálculo do supremo (para a análise dosoutros algoritmos tal é irrelevante).
433
Comecemos por uma versão imperativa .
Considerando que o supremo de uma lista vazia de inteiros é -∞, facilmente se verifica que a função
Mathematica a seguir permite calcular o supremo, assumindo sem o testar que o argumento é uma lista de
inteiros (para o caso poderiam ser reais).
Uma versão imperativa para o cálculo do supremo:
supImp = Function[lista,Module[{res,prox,comp},
comp = Length[lista];
res = -Infinity;
prox = 1;
While[ prox <= comp,
If[res < lista[[prox]], res = lista[[prox]]];
prox = prox + 1
] ;
res
]];
Tal como no caso anterior, vamos supor que não pretendemos calcular o tempo exacto de execução, através
da contagem de todas as operações executadas por uma invocação do programa e da associação a cada
operação de um parâmetro denotando o tempo de execução dessa operação, mas apenas pretendemos ter
uma ideia do custo de execução do programa, através da contagem das principais operações realizadas.
Ora, podemos considerar que o cálculo do supremo se baseia em comparações com os elementos da
lista argumento, pelo que uma medida do custo da sua execução é dada pelo número dessas comparações
que foi necessário efectuar. Designemos por NCvi ( n úmero de c omparações da v ersão i mperativa) tal função
de custo.
No entanto, ao contrário do que se passava no cálculo do factorial, em que o parâmetro da função de
custo NMvi era o valor do argumento (da invocação) do programa, agora o parâmetro da função de custo
NCvi não é a lista argumento do programa, mas sim uma medida da dimensão (complexidade) dessa lista,
mais concretamente, o seu comprimento.
Observação 2 (funções de custo de programas que operam sobre listas) :
O procedimento anterior não é específico deste exemplo. Quando se analiza a eficiência de um programa
que opera sobre listas o seu custo/tempo não é medido para cada lista argumento específica, mas sim como
uma função do comprimento da lista argumento. (As razões deste procedimento são simples de entender, e
já foram explicitadas na secção 1 do capítulo anterior, a propósito da ordenação de listas.)
Naturalmente, só sabendo a dimensão n da lista argumento, podemos não conseguir determinar o
custo/tempo exacto da execução desse programa (pois tal poderá depender da composição dessa lista).
Quando tal acontece, o que fazemos em geral é (como se referiu na secção 1 do capítulo anterior) efectuar o
cálculo do custo da execução, considerando: a pior situação (i.e. a situação em que a lista input, de
dimensão n, tem as piores caraterísticas para o programa em causa), a melhor situação e em média (não
434
assumindo nada sobre o input em causa, i.e. considerando que ele pode ser uma qualquer lista de dimensão
n, e supondo que todas as listas de dimensão n são igualmente prováveis).
∇
Embora, como se acabou de referir, possa acontecer que, só sabendo a a dimensão n da lista argumento,
não seja possível determinar o valor exacto da função de custo do programa (obrigando a uma eventual
análise na pior situação, em média e na melhor situação), não é isso o que se verifica no caso vertente, em
que se pretende que o custo da execução do programa seja traduzido simplesmente pelo número de
comparações com elementos da lista realizadas, uma vez que é imediato verificar que este número não
depende da composição da lista argumento, mas apenas da sua dimensão.
Comece-se, então, por precisar o que se pretende: dado um qualquer natural n, NCvi(n) é o número de
comparação com elementos da lista w que ocorre numa invocação supImp[w] (número que podemos
designar por NC(supImp[w])), quando se assume que a lista argumento w é uma qualquer lista de n
inteiros distintos.
Ora, o cálculo NCvi(n) é muito simples, sendo perfeitamente análogo ao cálculo de NMvi(n) efectuado
no exemplo anterior, uma vez que é realizada uma comparação com um elemento da lista argumento da
invocação, por cada execução do passo do ciclo:
NCvi(n) =
€
1prox=1
Length[w ]
∑ = 1prox=1
n
∑ = n
Observação 3 :
Suponha-se, agora, que em vez de se querer contar apenas o número de comparações com elementos da
lista argumento, se pretendia contar o número total de operações realizadas (mais precisamente, apenas o
número total de atribuições e testes realizados) durante uma invocação supImp[w], com a lista
argumento w uma qualquer lista de n inteiros distintos. Designe-se tal número por NATvi(n).
Só sabendo a dimensão n da lista argumento w, não conseguimos determinar o valor exacto de
NATvi(n), pois tal número depende da composição dessa lista: p.ex., se a lista for não vazia (i.e. se n>0) e
estiver ordenada de forma decrescente, apenas uma atribuição a res é efectuada durante a execução do ciclo
While (a melhor situação para o programa), ao passo que se a lista estiver ordenada de forma crescente,
em cada execução do passo do ciclo é efectuada uma atribuição a res (a pior situação para o programa), e
se a lista não estiver ordenada, o número exacto de atribuições a res só pode ser determinado
inspeccionando a lista argumento concreta w.
Assim, uma hipótese seria (ver observação 1 anterior) efectuar tal contagem (do número total de
atribuições e testes realizados), considerando a pior situação, a melhor situação e em média.
No entanto, se quisermos apenas saber a ordem de grandeza do crescimento de tal função NATvi(n),
então, neste caso, conseguimos obter tal ordem de grandeza procedendo a minorações e majorações
adequadas de tal número, o que é mais simples do que calcular o valor médio desse número.
Notando que numa invocação supImp[w], para além da inicialização (3 atribuições), é executada,
para prox assumindo os valores de 1 até n, uma avaliação da guarda do ciclo e no mínimo um teste (do
If) e uma atribuição (prox=prox+1), e no máximo um teste (do If ) e duas atribuições
435
(res=lista[[prox]]] e prox=prox+1), seguindo-se uma avaliação final da guarda do ciclo (com
prox assumindo o valor n+1), é imediato que (para n>0):
€
4 + 2prox=1
n
∑ ≤ NATvi (n) ≤ 4 + 3prox=1
n
∑
€
⇔ 4 + 2n ≤ NATvi (n) ≤ 4 + 3n
€
⇒ 2n ≤ NATvi (n) ≤ 4n, para n ≥ 4
Isto é,
€
∃c1 ,c2 ∈R +∃n0 ∈N0∀n≥n0c1n ≤ NATvi (n) ≤ c2n} , pelo que
€
NATvi (n) =Θ(n)
ou seja, NATvi(n) tem uma ordem grandeza de crescimento linear.
∇
Passemos agora ao cálculo recursivo do supremo .
Continuando a considerar que o supremo de uma lista vazia de inteiros é -∞, é fácil verificar que a função
Mathematica a seguir permite calcular o supremo de uma lista de inteiros (ou reais).
Uma (primeira) versão recursiva para o cálculo do supremo:
supRec = Function[lista,
If[ lista=={} (* ou Length[lista]==0 *),
-Infinity,
If[First[lista]>=supRec[Rest[lista]],
First[lista],
supRec[Rest[lista]]
]
]
];
Seja (para n natural) NCvr(n) o número de comparação com elementos de w que ocorre numa invocação
supRec[w] (que podemos designar por NC(supRec[w])), quando se assume que a lista argumento w é
uma qualquer lista de n inteiros distintos.
Ora, ao contrário do que se passava na versão imperativa, aqui o número de comparações que ocorre
numa invocação supRec[w] não depende apenas do número de elementos n da lista w , mas depende da
própria composição dessa lista.
Assim, como já referimos, o que se faz então, em geral, nestes casos é analisar três possíveis
situações: aquela em que a lista argumento w (tem n elementos distintos e) tem um tipo de composição
que é a melhor possível para o algoritmo em questão, o que nos dá o número de comparações mínimo do
algoritmo - NCminvr(n); aquela em que a composição da lista argumento é a pior possível, o que nos dá o
número de comparações máximo do algoritmo – NCmaxvr(n); e aquela em que se assume que a lista
argumento pode ser, com igual probabilidade, uma qualquer lista de n inteiros distintos, e se procura
determinar o número de comparações esperado, ou médio, do algoritmo – NCmedvr(n).
436
Os valores mais importantes são os valores máximo e médio.
Mas comecemos por calcular primeiro os valores mínimo e máximo, pois se eles forem da mesma
ordem de grandeza, então o número de comparações médio também será dessa ordem de grandeza, não se
tornando essencial o seu cálculo exacto (normalmente mais complicado). Caso o valor mínimo e o valor
máximo tenham ordens de grandeza distintas, já será necessário calcular o valor médio.
Melhor caso
O valor NCminvr(n) designa, então, o número de comparações dado por NC(supRec[w]) quando se
assume que a lista argumento w é uma qualquer lista de n inteiros distintos, ordenada de forma decrescente
(a melhor situação para o algoritmo apresentado).
Ora, neste caso a execução de supRec[w] (mais precisamente, a invocação de supRec[w]
corresponde a executar o corpo de supRec, quando o parâmetro lista assume/guarda a lista w, e tal
execução) comporta-se como se segue:
• É avaliado o teste w=={} e
• se este for positivo (i.e. se n=0), não é feita qualquer comparação, sendo retornado -Infinity e
parando a execução;
• caso contrário, é avaliada a “guarda do If” (que no caso em questão se traduz pela comparação
First[w]>supRec[Rest[w]]) obtendo-se True, sendo retornado o valor de First[w], e
parando a execução.
É importante notar que na avaliação supRec[Rest[w]], se tem que Rest[w] tem n-1 inteiros
distintos e continua a estar na melhor situação possível para o algoritmo (pois está ordenada de forma
decrescente).
Assim, é imediato que NCminvr(n) é dado pela relação de recorrência:
• Se n=0, então NCminvr(n) = 0.
• Se n>0, então NCminvr(n) = 1 + NCmin
vr(n-1)
e, resolvendo a rrelação de recorrência, obtém-se:
NCminvr(n) = n
(tal como para a versão imperativa).
Pior caso
O valor NCmaxvr(n) designa, então, o número de comparações dado por NC(supRec[w]) quando se
assume que a lista argumento w é uma qualquer lista de n inteiros distintos, ordenada de forma crescente (a
pior situação para o algoritmo apresentado).
Ora, nessa situação, a execução de supRec[w] comporta-se como se segue:
• Se w=={} (i.e. se n=0), não é feita qualquer comparação e acaba a execução.
• Se w!={} é avaliada a “guarda do If” (First[w]>supRec[Rest[w]]) que retorna False, e é
(de novo) avaliado supRec[Rest[w]].
Notando que na avaliação supRec[Rest[w]], se tem que Rest[w] tem n-1 elementos distintos e
continua a estar na pior situação possível para o algoritmo (pois está ordenada de forma decrescente), é
imediato que NCmaxvr(n) é dado pela relação de recorrência:
437
• Se n=0, então NCmaxvr(n) = 0.
• Se n>0, então NCmaxvr(n) = 1 + 2 NCmax
vr(n-1)
e, resolvendo a relação de recorrência (idêntica à do problema das Torres de Hanoi), obtém-se:
NCmaxvr(n) = 2n - 1
Agora, o número de comparações realizado já não cresce linearmente, com o número de elementos da lista,
mas sim exponencialmente !!!! Trata-se de um resultado péssimo, em termos de eficiência33, que torna
este programa sem qualquer interesse prático (salvo eventualmente para pequenos valores de n).
Dada a enorme diferença entre o comportamento do pior e do melhor caso, torna-se importante vermos
qual o valor esperado (médio) do número de comparações. De facto, dado o mau comportamento no pior
caso, dificilmente este algoritmo recursivo teria algum interesse prático. De qualquer forma, vejamos
como calcular o número médio de comparações, o que servirá para ilustrar como se processa esta análise.
Análise em média
O valor NCmedvr(n) designa, então, o número de comparações médio, ou esperado, de uma invocação
supRec[w] (a seguir designado de NC(supRec[w])), quando se assume que a lista w pode ser, com
igual probabilidade, uma qualquer lista de n inteiros distintos.
Ora, tem-se:
• Se w=={} (i.e. se n=0), então NC(supRec[w]) = 0.
• Caso contrário, é avaliada a “guarda do I f ” (First[w]>supRec[Rest[w]]), com
1+NC(supRec[w]) comparações, e
ou essa avaliação retorna True, e não é realizada mais nenhuma comparação
ou essa avaliação retorna False , e é avaliado (outra vez) supRec[Rest[w] ]
(NC(supRec[Rest[w]]) comparações).
Assim, se n>0, o número esperado de comparações é dado por:
(1+NC(supRec[Rest[w]])) * Prob (First[w]≥supRec[Rest[w]] retornar True)
+ (1+2NC(supRec[Rest[w]])) * Prob (First[w]<supRec[Rest[w]] retornar False)
Resta determinar qual a probabilidade de “First[w]≥supRec[Rest[w]] retornar True”.
Ora, como estamos a supor que que a lista w é uma qualquer lista (tirada ao acaso) de n inteiros
distintos, podemos assumir que qualquer elemento dessa lista tem igual probabilidade de ser o maior deles
todos (isto é, probabilidade 1/n ). E, o mesmo raciocínio se aplica no cálculo de
NC(supRec[Rest[w]]).
Somos assim conduzidos à seguinte relação de recorrência:
• Se n=0, então NCmedvr(n) = 0.
• Se n>0, então
NCmedvr(n) = (1 + NCmed
vr(n-1)) * 1/n +(1 + 2 NCmedvr(n-1)) * (n-1)/n
33 O cálculo do tempo total de execução do algoritmo é um pouco maic complexo do que o simples número de comparações(nomeadamente porque se tem de entrar com o tempo de avaliação de Rest[w] e do teste w!={}) e pode ser visto em [13].De qualquer modo, a ordem de grandeza do tempo total de execução é também do mesmo tipo que a do número decomparações.
438
=
€
2n −1n
NCmedvr(n-1) + 1
Trata-se de uma relação de recorrência linear, mas não só não homogénea, como de coeficientes não
constantes. O que vimos atrás não nos dá um método directo para a sua resolução. Podemos, contudo,
tentar o método iterativo, que nos conduz a
NCmedvr(n) =
€
1+(2n −1)(2n − 3)...(2n − (2i +1))
n(n −1)...(n − i)i=0
n−1∑
Parece um somatório complicado de resolver. Mas como apenas queremos conhecer a ordem de
grandeza de NCmedvr(n) pode ser que tal se consiga obter, sem grandes dificuldades, através de majorações e
minorações adequadas daquele somatório.
Mais ainda, se verificarmos que a ordem de grandeza de NCmedvr(n) é limitada inferiormente por uma
exponencial34, então não vale a pena perder mais tempo com esta análise, pois o algoritmo não terá
qualquer interesse prático.
Ora, como 2n-(2j+1) > 2(n-j-1), para j=0,..,i, tem-se, para n>0 (minorando todos os factores do
numerador com excepção do último)
€
1+(2n −1)(2n − 3)...(2n − (2i +1))
n(n −1)...(n − i)i=0
n−1∑ ≥
€
1+2(n −1)2(n − 2)...2(n − i)(2n − 2i −1)
n(n −1)...(n − i)i=0
n−1∑
=
€
1+2i (2n − 2i −1)
ni=0
n−1∑ ≥
€
1+2i (2n − 2(n −1) −1)
ni=0
n−1∑ =
€
1+1n
2i
i=0
n−1∑ =
€
1− 1n
+2n
n
Logo (como a desigualdade anterior se verifica para todo o n>0), por definição de Ω:
NCmedvr(n) =
€
Ω(1− 1n
+2n
n)
Mas, como
€
limn→∞
1− 1n
+2n
n(1.9)n
= +∞ , sabemos que =
€
1− 1n
+2n
n=ϖ (1.9n ) .
Logo pelo teorema 11.2.1 (alíneas vi) e iii)), NCmedvr(n) =
€
Ω(1.9n ) .
Isto é, o algoritmo em média também tem uma ordem de grandeza exponencial, assimptoticamente
pelo menos da ordem grandeza de
€
1.9n . Não vale a pena perder mais tempo a analisar em maior pormenor
o custo médio deste algoritmo: ele só servirá para pequenos valores de n.
O resultado anterior não implica necessariamente que uma versão recursiva para o cálculo do supremo
tenha uma ordem de grandeza, no pior caso e em média, exponencial. Pode acontecer que não tenhamos
escolhido uma versão recursiva eficiente para o cálculo do supremo. E de facto é esse o caso.
Uma segunda versão recursiv a para o cálculo do supremo :
Considere-se, por exemplo, uma outra versão recursiva para o cálculo do supremo, como a que se segue:
34 Já sabemos que NCmed
vr(n) =
€
Ο(2n ) , uma vez que NCmaxvr(n) = 2n – 1. Mas o facto de NCmed
vr(n) =
€
Ο(2n ) não significaque NCmed
vr(n) não possa ter uma ordem de crescimento muito inferior.
439
supRec = Function[lista, Module[{m},
If[ lista=={},
-Infinity,
m = supRec[Rest[lista]];
If[First[lista]>=m, First[lista], m]
]
]];
ou, como se pode considerar que a utilização de uma variável local para guardar valores já foge da
programação recursiva (pura), a seguinte outra versão35:
supRec = Function[lista, Module[{maximo},
maximo = Function[{x,y},If[x>=y,x,y]];
If[ lista=={},
-Infinity,
maximo[First[lista],supRec[Rest[lista]]]
]
]];
Facilmente se verifica que para esta versão recursiva o valor NCvr(n), correspondente ao número de
comparações NC(supRec[w]) quando se assume que a lista argumento w é uma qualquer lista de n
inteiros distintos, já não depende da composição da lista w, sendo dado pela relação de recorrência:
• Se n=0, então NCvr(n) = 0.
• Se n>0, então NCvr(n) = 1 + NCvr(n-1)
e, resolvendo a relação de recorrência, obtém-se:
NCvr(n) = n
Isto é, o mesmo valor que para a versão imperativa36.
Secção 4: Quando evitar a recursão ?
Embora os algoritmos recursivos sejam normalmente a solução (computacional) mais simples e elegante
para certo tipo de problemas, eles são em regra menos eficientes que os “correspondentes” algoritmos de
carácter iterativo, devido ao custo envolvido nas várias invocações da função que a recursão envolve.
Mas, como os exemplos anteriores ilustraram, em muitas circunstâncias, embora a solução recursiva
seja um pouco menos eficiente que a correspondente versão imperativa, elas são da mesma ordem de
grandeza.
35 Em vez da função local maximo podíamos ter recorrido à função predefinida Max. De facto, tal função permite-nos mesmocalcular directamente o supremo pretendido.36 Refira-se que em [13] se ilustra como, em geral, para cada programa imperativo sobre listas se consegue obter umacorrespondente versão recursiva, em que a recursão é feita sobre os índices da lista, que tem a mesma ordem de grandeza daversão imperativa.
440
Contudo, há casos em que uma solução recursiva não tem mesmo qualquer interesse prático, devido à
sua “ineficiência”.Veja-se a primeira versão recursiva do cálculo do supremo, acabada de apresentar !
O que torna tal versão recursiva tão ineficiente ? Basicamente o repetir o mesmo trabalho várias vezes:
em cada passo da recursão são calculados duas vezes o supremo[Rest[w]] ! A cada passo do ciclo da
versão imperativa, corresponde, nessa versão recursiva, duas invocações da função, que repetem o mesmo
trabalho !
O próximo exemplo, relativo ao cálculo do n-ésimo número de Fibonacci, é um outro exemplo
elucidativo deste problema
Exemplo: cálculo do n-ésimo número de Fibonacci.
Suponha-se que se pretende construir um programa para o cálculo do n-ésimo número de Fibonacci (n≥0),
sabendo que tais números satisfazem a relação de recorrência:
f0 = 0
f1 = 1
fn = fn-1 + fn-2, para n≥2
É muito fácil de construir um programa recursivo (compacto e elegante) que satisfaz esse objectivo.
Aliás, tal foi feito na disciplina de “Paradigmas da Programação”, usando a linguagem de programação
Mathematica. Recorde-se o programa então construído:
fibonacci = Function[n,
If[n==0, 0,
If[n==1, 1,
fibonacci[n-1] + fibonacci[n-2]
]]];
Acontece que este cálculo (recursivo) de fibonacci[n] é extremamente ineficiente, pois repete
imenso trabalho37, como se ilustra a seguir:
fibonacci[7]
fibonacci[6] fibonacci[5]
fibonacci[5] fibonacci[4] fibonacci[4] fibonacci[3]
fibonacci[4] fibonacci[3] ..... ..... .....
Como se vê, para o cálculo do valor de fibonacci[7], o valor de fibonacci[4] é calculado
três vezes, etc.
37 Podem ser usadas técnicas de programação dinâmica para evitar que um programa recursivo re-calcule valores jácalculados, mas a explicação de como tal funciona está completamente fora do âmbito deste texto.
441
Podemos, aliás, usar as técnicas de resolução das relações de recorrência (abordadas na secção 4 do
capítulo 9) para calcular o número de chamadas recursivas da função fibonacci que são efectuadas
aquando do cálculo de fibonacci[n]. Designemos tal número por RecCall(n) (“RecCall” é
mnemónico de “Recursive Call”).
a) Comecemos por caracterizar o valor de RecCall(n):
• RecCall(0) = 0
• RecCall(1) = 0
• RecCall(n) = ReCall(n-1) + ReCall(n-2) + 2 , para n≥2
(para n≥2, o cálculo de fibonacci[n] envolve a chamada recursiva de fibonacci[n-1] e
de fibonacci[n-2], mais as chamadas recursivas que estas duas chamadas envolverem)
b) Passemos à resolução desta recorrência linear de ordem 2, de coeficientes constantes, não homogénea:
(1) Comecemos por encontrar a solução geral da equação homogénea associada
RecCall(n) = ReCall(n-1) + ReCall(n-2), para n≥2
A equação polinomial característica é x2-x-1 = 0, e (como já vimos a propósito do cálculo dos números
de Fibonacci) as suas raízes são a razão de ouro e o seu conjugado.
Logo, a solução geral é
f(n) =
€
a(1+ 52
)n + b(1− 52
)n , com n≥0
(2) Procure-se uma solução particular da equação não homogénea
RecCall(n) = ReCall(n-1)+ReCall(n-2)+2
De acordo com as sugestões dadas na secção 4 do capítulo 9, devemos começar por tentar uma solução
da forma g(n) = d, para d uma constante apropriada.
Para tal sucessão, g(n), ser solução da equação RecCall(n)= ReCall(n-1)+ReCall(n-2)+2, tem de ter-se:
g(n) = g(n-1) + g(n-2) + 2 ⇔ d = d + d + 2
Logo
g(n) = -2
é uma solução particular da equação não homogénea.
(3) Combinamos agora a solução geral com a solução específica
RecCall(n) =
€
a(1+ 52
)n + b(1− 52
)n − 2
e procuramos determinar o valor das constante a e b que satisfazem as condições iniciais:
RecCall(0) = 0
RecCall(1) = 0
Obtém-se que
RecCall(n) =
€
(1+15)(1+ 5
2)n + (1− 1
5)(1− 5
2)n − 2 , com n≥0
é a solução procurada para a nossa recorrência linear não homogénea.
442
c) Para termos uma ideia de como se comporta RecCall(n), para valores grandes de n, podemos procurar
uma expressão mais simples que seja um minorante da expressão anterior. Ora38:
€
(1+15)(1+ 5
2)n + (1− 1
5)(1− 5
2)n − 2 =
€
(1+ 52
)n +15(1+ 5
2)n − 1
5(1− 5
2)n + (1− 5
2)n − 2 =
€
(1+ 52
)n + fn + (1− 52
)n − 2 ≥ (pois
€
1− 52
< 1)
€
(1+ 52
)n + fn − 3
e, como fn > 3, para n > 4, podemos concluir que
RecCall(n) >
€
(1+ 52
)n , para n > 4 (onde
€
φ =1+ 52 > 1,6)
isto é, o número de chamadas recursivas cresce exponencialmente (RecCall(n) =
€
Ω((1+ 52
)n ) ).
O número de chamadas recursivas envolvidas no cálculo de fibonacci[n] dá-nos uma ideia da quantidade
de trabalho envolvida nesse cálculo. E o facto desse número crescer exponencialmente dá-nos já bem uma
noção de quão ineficiente é o cálculo recursivo do n-ésimo número de Fibonacci.
Podemos ainda tentar traduzir, matematicamente, a quantidade de trabalho envolvida no cálculo de
fibonacci[n], através do estudo de outras grandezas.
Podemos mesmo tentar estudar o tempo de execução de tal cálculo. Designando por T(n) o número de
unidades de tempo envolvidas no cálculo de fibonacci[n], por t o número de unidades de tempo
envolvidas na execução dos testes (n==0 e n==1), por s o número de unidades de tempo envolvidas na
execução da soma (fibonacci[n-1]+fibonacci[n-2]), e por i o número de unidades de tempo
envolvidas na execução de uma invocação de fibonacci e no retorno do seu resultado, podemos caracterizar
T(n) como se segue:
• T(0) = i + t
• T(1) = i + 2 t
• T(n) = T(n-1) + T(n-2) + i + 2 t + s , para n≥2
Mas, para simplificar, podemos supor (tal como fizemos na última secção para outros algoritmos)
que, em vez de estudar o tempo total de execução do programa, nos basta contar o número de vezes que
são realizadas as principais operações do programa em causa, que no caso do cálculo do n-ésimo número
de Fibonacci são obviamente as adições.
38 Observe-se que podemos reformular a expressão atrás obtida para RecCall(n) como se segue:
RecCall(n) =
€
2( 15(1+ 52
)n+1 −15(1− 52
)n+1 −1)
pelo que RecCall(n) =
€
2( fn+1 −1) , onde
€
fn+1 é o termo da sucessão de Fibonacci de índice n+1.
443
Seja, então, A(n) o número de adições envolvidas no cálculo (recursivo) de fibonacci[n].
a) Caracterização de A(n):
• A(0) = 0
• A(1) = 0
• A(n) = A(n-1) + A(n-2) + 1 , para n≥2
b) Trata-se de recorrência linear de ordem 2, de coeficientes constantes, não homogénea, pelo que
podemos resolvê-la usando as mesmas técnicas que usámos para calcular RecCall(n).
Mas, a título ilustrativo, iremos em seguida mostrar como podemos resolver esta recorrência,
através da sua redução a uma outra recorrência que já estudámos, no caso a recorrência que define a
própria sucessão de número de Fibonacci.
Tem-se
A(n) = A(n-1) + A(n-2) + 1 ⇔ A(n) + 1 = A(n-1) + 1 + A(n-2) + 1
pelo que, substituindo A(n)+1 por h(n) se obtém a recorrência
• h(0) = 1
• h(1) = 1
• h(n) = h(n-1) + h(n-2) , para n≥2
Mas é imediato que esta não é mais do que a própria recorrência de Fibonacci, mas iniciando-a no
seu segundo termo. Isto é, mais precisamente
h(n) = fn+1, para n≥0
Logo
A(n) = h(n) - 1 = fn+1 – 1 =
€
15(1+ 5
2)n+1 −
15(1− 5
2)n+1 −1, para n≥0
E se recordarmos que se observou atrás (na última nota de rodapé) que RecCall(n)=2(fn+1–1),
concluímos que A(n) = RecCall(n)/2, pelo que A(n) também cresce exponencialmente.
Para termos uma ideia mais concreta do que isto significa, e ainda que numa análise informal, se
atendermos que
€
15(1− 5
2)n <
15
e que portanto as duas últimas parcelas contribuem com um número inferior a 3/2 (em valor absoluto),
podemos grosso modo dizer que
A(n) ≈
€
15(1+ 5
2)n+1
pelo que
A(n+1) é ≈
€
1+ 52
A(n)
isto é, A(n+1) é cerca de 1,6 A(n).
Logo, supondo que o tempo gasto no cálculo de fibonacci[n] é apenas o tempo gasto nas adições
(e é maior), temos que se o cálculo de fibonacci[n] (para um certo n) demorar 1 segundo, então
444
(como 1,69>60 concluímos que) o cálculo de fibonacci[n+9] demora mais de 1 minuto e que o
cálculo de fibonacci[n+18] demora mais de 1 hora !
Ora, pode-se obter um programa melhor (muito mais eficiente) para o cálculo do n-ésimo número de
Fibonacci, de carácter imperativo.
De facto, o valor de fibonacci[n] pode ser calculado imperativamente com n-1 iterações (do
passo de um ciclo), guardando em duas variáveis os dois últimos valores calculados, por exemplo como se
segue:
fibonacci= Function[n, Module[{a, b, aux, i},
If[n==0, 0,
If[n==1, 1, (* a é o penúltimo valor calculado, b o último e i o índice do próximo *)
a = 0; b = 1; i = 2;
While[i<=n,
aux = b; b = b + a; a = aux;
i = i + 1
];
b (*retorno do resultado *)
]]]];
Podemos aliás comparar os tempos de execução obtidos, num mesmo computador, no cálculo,
recursivo e imperativo, do valor de fibonacci[n]. Usando fibRec para denotar a versão recursiva
fibonacci, e fibImp para denotar a versão imperativa, obteve-se (num computador pessoal, não muito
recente):
Timing[fibRec[16]]
{0.15 Second, 987} (* o 1º elemento da lista é o tempo gasto e o 2º o valor de fibRec[16] *)
Timing[fibImp[16]]
{0. Second, 987}
Timing[fibRec[30]]
{115.133 Second, 832040}
Timing[fibImp[30]]
{0. Second, 832040}
Timing[fibImp[120]]
{0.0166667 Second, 5358359254990966640871840}
É claro que neste caso podemos mesmo dizer que se pode obter uma solução ainda mais eficiente,
recorrendo directamente à expressão explícita que obtivemos (no capítulo 9) para o termo geral da sucessão
de Fibonacci. Mas atenção, que salvo em linguagens de computação simbólica, como o Mathematica, que
suportam o cálculo de valores exactos com irracionais, o que obteremos por essa via é um valor
445
aproximado do resultado (e não o seu valor inteiro, exacto), em virtude da presença do irracional raiz de 5
nessa expressão.
E, mesmo no Mathematica, para obter o valor de fibonacci[n], para um certo valor de n, a partir
da expressão explícita do termo geral, terá de recorrer-se à utilização da função Simplify, como se
ilustra a seguir39 (e que traduz o que se passou numa sessão com o Mathematica):
fibonacci= Function[n,
€
15(1+ 5
2)n − 1
5(1− 5
2)n];
fibonacci[15]
€
−(1− 15 )15
32768 5+(1+ 15 )15
32768 5
Simplify[fibonacci[15]]
€
610
Refira-se, a propósito, que o Mathematica já disponibiliza uma função predefinida que calcula os
números de fibonacci, função que tem precisamente esse nome, mas começando por uma maiúscula, como
é padrão dos nomes predefinidos do Mathematica. Se avaliarmos Fibonacci[15] obtemos
€
610
∇
Secção 5: Pesquisa.
A pesquisa de informação é uma das operações que mais frequentemente realizamos, e muitas vezes
envolvendo grandes (ou mesmo enormes) quantidades de informação a pesquisar. Torna-se assim crucial
existirem algoritmos que suportem uma tal pesquisa, em computador, de uma forma muito rápida.
Suponha-se, então, que pretendemos estudar algoritmos que nos permitam pesquisar um elemento x
numa lista w de elementos do mesmo tipo. Embora o que se segue seja facilmente adaptável a outro tipo
de elementos40, iremos assumir que estes são números (inteiros, reais, etc.) e que não há repetições de
elementos (o que tem em vista, apenas, facilitar as contas em algumas análises de custo em média). Por
outro lado, tal como para o supremo, a operação essencial (ou básica) da pesquisa é a comparação com
elementos da lista, pelo que limitaremos a análise do custo dos nossos algoritmos de pesquisa à contagem
dessas comparações, tendo-se que NC(pesquisa[w,x]) denotará o número de comparações de x com
elementos da lista w, realizados durante uma invocação pesquisa[w,x] do função/programa de
pesquisa em análise. Designaremos ainda por NCpesquisa(n) o valor de NC(pesquisa[w,x]),
quando w é uma qualquer lista de n números distintos, nos casos em que tal número de comparações não
depende da composição da lista argumento w, mas apenas da sua dimensão n. Para outros casos,
introduziremos, oportunamente outras notações apropriadas.
39 Se pedirmos um valor aproximado de fibonacci[15], recorrendo à função N, isto é, avaliando N[fibonacci[15]],obtém-se o inteiro 610 convertido em real, com um ponto no fim.40 Os quais poderiam ser eles próprios (p.ex.) listas: o que é fundamental para o que se segue é que se disponha de um teste deigualdade sobre esses elementos, que nos permita determinar quando é que dois elementos são iguais (ou representam a mesmainformação).
446
Finalmente, e embora isto seja um aspecto secundário, como já houve o cuidado de nas análises
anteriores distinguir claramente os parâmetros dos valores dos argumentos que a eles são atribuídos
aquando de uma invocação (ilustrando como tal se processa), a partir daqui tipicamente confundi-los-
emos41, dando-lhes “os mesmos” nomes (embora em fontes distintas): assim p.ex. o parâmetro que
receberá a lista w será designado de w, etc.
Pesquisa linear
Comecemos por um primeiro programa, de natureza imperativa (onde o resultado ser True significa que x
ocorre em w):
Primeiro programa de pesquisa
pesq1 = Function[{w,x},Module[{b,i,n},
n = Length[w];
b = False; i = 1;
While[i<=n && b==False (* ou Not[b] *),
If[w[[i]]==x, b=True, i=i+1]
];
b
]];
Observação 1 :
Segue-se um outro programa para a pesquisa linear, alternativo ao anterior, em que não se introduz
qualquer variável para o resultado (sendo este expresso através da avaliação do teste i<=n final):
pesq2 = Function[{w,x},Module[{i,n},
n = Length[w]; i = 1;
While[i<=n && w[[i]]!=x, i=i+1];
i<=n
]];
É fácil verificar que os dois programas fazem o mesmo número de comparações de x com elementos da
lista w.
Por outro lado, é de referir que o programa pesq2 funciona bem devido à forma de avaliação
sequencial de uma conjunção que é efectuada pela linguagem Mathematica (e pela maioria das linguagens
de programação): na avaliação da guarda do ciclo, i<=n && w[[i]]==x, se a primeira condição der
False, já não é avaliada a segunda (se o fosse obtinha-se uma situação de erro: Porquê?)
∇
41 Note-se que os (valores “guardados” nos) parâmetros das funções a seguir não são alterados pela execução do corpo de taisfunções (o que, aliás, não seria permitido na linguagem Mathematica, embora o fosse, à partida, na maioria das linguagens deprogramação usuais).
447
Ora, é fácil verificar que o valor de NC(pesq1[w,x]) depende da composição da lista argumento w , e
não apenas da sua dimensão n. Assim, o que se procura fazer nestas situações é (como já sabemos) uma
análise do custo na pior situação e em média42. Concretizemo-la para o caso em análise.
Pesquisa mal sucedida
É imediato que a pior situação para o algoritmo apresentado é quando o valor x a pesquisar não ocorre na
lista w, o que podemos chamar de uma situação de pesquisa mal sucedida.
Podemos designar o número de comparações em análise por NCmaxpesq1(n) (número máximo de
comparações para pesquisar x numa lista w de n números distintos, numa invocação pesq1[w,x]), ou
por NCpesq1/ins(n) (número de comparações para pesquisar x numa lista w de n números distintos, numa
invocação pesq1[w,x], em situação de ins ucesso, i.e, se se verifica que x não ocorre em w).
É imediato que nesse caso o passo do ciclo é executado com i a assumir os valores de 1 a n, e em
cada passo do ciclo é efectuada uma comparação. Logo:
NCmaxpesq1(n) = NCpesq1/ins(n) = n
Número médio de comparações numa pesquisa bem sucedida
Assuma-se agora que queremos apenas analisar o que se passa em situação de sucesso. Isto é, assumimos
que estamos n uma situação de sucesso e queremos saber qual o número esperado (ou médio) de
comparações, para encontrar x em w. Seja NCmedpesq1/suc(n) o número esperado (ou médio) de comparações
de x que ocorrem numa invocação pesq1[w,x], em situação de suc esso, i.e, mais precisamente, quanso
se assume que w é uma qualquer lista de n números distintos onde x ocorre (e que todas essas listas têm
igual probabilidade de estar a ser o argumento da pesquisa em questão).
Ora:
NCmedpesq1/suc(n) =
€
nc(x,i) Prob(x ser w[[i]]i=1
n∑ )
onde nc(x,i) designa o número necessário de comparações com elementos de w se x está na i-ésima posição
de w (i.e. se x é igual a w[[i]]) e Prob(x ser w[[i]]) designa a probabilidade de tal acontecer.
É imediato que nc(x,i) = i.
E, como estamos assumir que x ocorre em w , podemos considerar que todos os elementos de w têm
igual probabilidade de ser x (e como eles são distintos e em número de n) , tem-se Prob(x ser w[[i]]) =
€
1n
.
Logo:
NCmedpesq1/suc(n) =
€
i 1n
i=1
n∑ =
€
1nn(n +1)2
=
€
n2
+12
isto é, NCmedpesq1/suc(n) cresce linearmente com o número n de elementos da lista a pesquisar
(NCmedpesq1/suc(n) =
€
Θ(n)).
42 Poder-se-ia também fazer uma análise na melhor situação: é imediato que esta se verifica quando x é o primeiro elemento dalista w, em cujo caso se faz apenas uma comparação.
448
Número médio de comparações
Normalmente, a análise do algoritmo de pesquisa fica-se por aqui.
Mas podemos mesmo procurar calcular o número médio de comparações NCmedpesq1(n ) numa
invocação pesq1[w,x], quando se assume apenas que w é uma qualquer lista de n números distintos (e
que todas essas listas são igualmente prováveis). Este valor calcula-se a partir dos anteriores, recorrendo às
probabilidades condicionadas:
NCmedpesq1(n) = NCmed
pesq1/suc(n) * Prob(x ocorrer em w) + NCpesq1/ins(n) * Prob(x não ocorrer em w)
Resta saber a que é igual a probabilidade de x ocorrer em w.
Seja k o número total de elementos do tipo que estamos a pesquisar (no caso seria o número de números
que podemos representar no computador). Ora o número de listas de n elementos distintos que podemos
formar com k elementos são os arranjos (simples) de k elementos, tomados n a n:
€
Akn =k!
(k − n)!. E o
número de listas de n elementos distintos onde x ocorre é o número de listas de n-1 elementos que
podemos formar com os restantes k-1 elementos (os arranjos de k-1 elementos, tomados n-1 a n-1), vezes
n (pois em cada uma dessas listas o x poderá ocorrer em qualquer uma das n posições). Logo43:
Prob(x ocorrer em w) =
€
n (k −1)!(k − n)!k!
(k − n)!
=
€
nk
Assim:
NCmedpesq1(n) =
€
(n2
+12) nk
+ n(1− nk) =
€
n 2k − n +12k
e (atender a que n≤k) 44:
€
n 2k − n +12k
≤
€
n 2k2k
= n, para n ≥ 1
€
n 2k − n +12k
≥
€
n 2k − k2k
=
€
n2
Logo NCmedpesq1(n) = Θ(n).
Mais precisamente, NCmedpesq1(n) cresce linearmente com n, situando-se (para n≥1) entre
€
n2
e
€
n .
Observação 2 :
Obviamente, podem também construir-se versões recursivas para a pesquisa. Por exemplo:
pesq3 = Function[{w,x},
If[w=={},False,
If[First[w]==x, True, pesq3[Rest[w],x]]
43 Podíamos também pensar que a probabilidade de x ocorrer em w é a probabilidade de x ser o primeiro elemento de w, mais aprobabilidade de x ser o segundo elemento de w, ..., mais a probabilidade de x ser o último elemento de w.
44 Podemos obter facilmente também a minoração
€
n 2k − n +12k
≥
€
n 2k − k +12k
=
€
n(12
+12k
) , mas tal é pouco maior que
€
n2
,
pois k é em geral muito grande.
449
(* ou Or[First[w]==x,pesq3[Rest[w],x] *)
]
];
É fácil constatar que o número de comparações que se obtém é da mesma ordem de grandeza que o da
versão imperativa anterior. Calcule p.ex. o número de comparações numa pesquisa mal sucedida.
∇
Pesquisa linear em lista ordenada
Como forma de permitir uma pesquisa mais rápida, normalmente a informação a pesquisar está ordenada
(pense-se p.ex. num dicionário).
Assuma-se então que a lista w na qual se quer pesquisar x, é uma lista de números45 (de dimensão n),
ordenada de forma estritamente crescente.
Ora, nesse caso podemos optimizar p.ex. o programa referido na observação 1, permitindo que o ciclo
de pesquisa pare também assim que encontre um elemento da lista maior que x:
pesq4 = Function[{w,x},Module[{i,n},
n = Length[w];
i = 1;
While[i<=n && w[[i]]<x, i=i+1];
i<=n && w[[i]]==x
]];
Agora o pior caso46 para o algoritmo não é quando x não ocorre simplesmente em w, mas sim quando x é
mesmo maior que todos os elementos em w , obtendo-se (o mesmo número de comparações que para
pesq1 e pesq2, mais uma, correspondente à avaliação necessária para passar o resultado):
NCmaxpesq1(n) = n+1
Tentemos agora calcular o número médio de comparações. Numa análise informal, mas que chega para
os fins em vista, podemos dizer que (sendo x um número tirado ao acaso e w uma lista de n números
distintos, escolhida ao caso) em média será de esperar que haja:
cerca de
€
n2
elementos menores que x em w e
€
n2
elementos maiores que x
pelo que o número de comparações será de:
cerca de
€
n2
+1 na execução do ciclo, mais uma para passar o resultado.
Assim o número médio de comparações será da ordem de
€
n2
.
45 Tal como anteriormente, para o que se segue não é essencial que se trate de uma lista de números: poderão ser elementos deoutro tipo (listas, etc.). O que é fundamental é que se disponha de uma relação de ordem total
€
p no conjunto desses elementos.46 O melhor caso é quando x é menor ou igual ao primeiro elemento de w, em cujo caso se faz uma comparação no ciclo e umapara passar o resultado.
450
Grosso modo, numa pesquisa mal sucedida, reduzimos o número de comparações para metade ! Parece
um ganho razoável, mas assimptoticamente não é nada ! Continuamos com um número de comparações da
mesma ordem de grandeza (linear em n).
Pesquisa binária em lista ordenada
Ora, quando a lista está ordenada, consegue-se fazer uma pesquisa muito mais rápida (dita pesquisa binária).
A ideia é simples: uma vez garantido que x está entre o primeiro e o último elemento da lista w,
introduzem-se duas variáveis (p.ex. i e j) para limitar o intervalo de pesquisa, mantendo a seguinte
condição como invariante do ciclo de pesquisa47:
w[[i]] ≤ x < w[[j]]
Em cada passo do ciclo compara-se x com o elemento que está em w “no meio” entre a posição i e a
posição j, alterando-se adequadamente o valor de i ou de j (conforme os casos: ver programa a seguir).
Assim, com uma só comparação “deita-se fora” cerca de metade dos elementos que faltava pesquisar.
Vejamos uma possível concretização desta ideia48:
pesq5 = Function[{w,x},Module[{i,j,n,meio},
n = Length[w];
If[w=={}||x<w[[1]]||x>w[[n]], False,
If[x==w[[1]]||x==w[[n]], True,
(* pesquisa binária *)
i=1; j=n;
While[j!=i+1,
meio=Quotient[i+j,2];
If[x<w[[meio]], j=meio, i=meio]
];
w[[i]]==x
]
]
]];
É fácil verificar que o número de comparações NC(pesq5[w,x]) não depende da composição da lista w,
se w[[1]] < x < w[[n]] (a pior situação para o algoritmos apresentado).
Caculemos então NCmaxpesq5(n), i.e. o número máximo comparações numa invocação pesq5[w,x]
quando w é uma lista de n números distintos, ordenada de forma crescente.
47 Isto é, tal condição deverá ser verdadeira no início e no fim da execução de cada passo do ciclo de pesquisa.48 Repare-se que não se procura parar o ciclo de pesquisa se w[[meio]] for igual a x: isso traduzir-se-ia por mais umacomparação. O algoritmo é tão rápido, que essa eventual paragem mais cedo (se o elemento procurado x ocorresse na lista), não“pagaria” o custo dessa comparação adicional.
451
Ora, se w[[1]] < x < w[[n]], temos 4 comparações no início, mais as comparações que ocorrem no
ciclo de pesquisa, mais uma comparação no final. E no ciclo de pesquisa ocorre uma comparação por cada
execução do passo do ciclo.
Calculemos então o número de vezes que é executado o passo do ciclo.
O cálculo do número exacto de vezes que o passo do ciclo é executado é aparentemente complicado, em
virtude de
€
i + j2
poder não ser um inteiro, e o valor que o Quotient[i+j,2] retorna é49
€
i + j2
.
Façamos primeiro as contas para o caso mais simples, em que o valor de
€
i + j2
é sempre inteiro.
Comecemos por notar que, como i é inteiro, se tem que (veja a secção 2 do capítulo 4):
€
meio =i + j2
= i +
j − i2
= i +
j − i2
Observemos ainda como se relaciona a diferença entre limite superior e o limite inferior no próximo passo
do ciclo e a diferença entre o limite superior (j) e o limite inferior (i) no actual passo do ciclo:
• ou meio vai ser o próximo limite superior e tem-se:
€
meio− i =j − i2
• ou meio vai ser o próximo limite inferior e tem-se (j-i é inteiro):
€
j −meio = j − i − j − i2
= j − i + −
j − i2
= j − i − j − i
2
=
j − i2
Assim, se no início do passo do ciclo, j-i for uma potência de 2 (
€
j − i = 2k , para algum k>0), então
não só
€
meio = i +j − i2
= i + 2k−1 é inteiro
como no fim do passo do ciclo j-i ainda é uma potência de 2 (ter-se-á
€
j − i = 2k−1, pois
€
2k
2
=2k
2
= 2k−1).
Suponha-se então que
€
n −1= 2k , para algum k>0. Tem-se:
• antes do primeiro passo: (i=1 e j=n e)
€
j − i = 2k ;
• após o primeiro passo:
€
j − i = 2k−1;
• após o segundo passo:
€
j − i = 2k−2;
• ...
• após x passos do ciclo:
€
j − i = 2k−x
parando o ciclo quando
€
j − i = 1= 20 , i.e. após se executarem x=k passos do ciclo.
Logo o número de vezes x que é executado o passo do ciclo é igual a k, isto é
€
log2 (n −1) .
Ou seja, se n-1 é uma potência de 2, então NCmaxpesq5(n) = 5 +
€
log2 (n −1) .
49 Tirado do Help do Mathematica: “Quotient[m,n] is equivalent to Floor[m/n] for integers m and n”.
452
Suponha-se agora n-1 não é uma potência de 2. Então existe k>0 tal que50
€
2k−1 < n −1< 2k
Seja x o número de vezes que é executado o passo do ciclo quando se tem inicialmente n elementos. É
intuitivamente imediato que51 esse número será menor ou igual que o número de vezes que é executado o
passo do ciclo quando se tem inicialmente 2k+1 elementos, isto é (pelo que acabámos de ver) k passos do
ciclo, e que esse número será maior ou igual que o número de vezes que é executado o passo do ciclo
quando se tem inicialmente 2k-1+1 elementos, isto é (pelo que acabámos de ver) k-1 passos do ciclo.
Assim:
€
x ≥ k −1= log2 (n −1)
€
x ≤ k = log2 (n −1)
Em conclusão, não só se tem que NCmaxpesq5(n ) =
€
Θ(log2 (n)), como se tem mesmo, mais
precisamente, que, para n>1:
€
5+ log2 (n −1) ≤ NCmaxpesq5(n) ≤
€
5+ log2 (n −1)
Vale a pena comparar, para grandes valores de n, este número com o número médio de comparações
obtido pelo programa atrás, da pesquisa linear em lista ordenada pesq4, dado por cerca de
€
n2
+ 2 :
n = 10 000 =
€
104 ⇒
€
2+n2
= 5 002 e
€
5+ log2 (n −1) = 19
e se passarmos para 100 vezes mais:
n = 1 000 000 =
€
106 ⇒
€
2+n2
= 500 002 e
€
5+ log2 (n −1) = 25
e se passarmos para 100 vezes mais ainda:
n = 100 000 000 =
€
108 ⇒
€
2+n2
= 50 000 002 e
€
5+ log2 (n −1) = 32
50 Donde não só sai que
€
k −1< log2 (n −1) < k , como sai mesmo que (recorde observação 11.2.4):
€
k −1= log2 (n −1) e
€
k = log2 (n −1) .
51 Isso pode confirmar-se observando que a diferença entre o limite superior e o limite inferior do intervalo de pesquisa
inicialmente satisfaz
€
2k−1 < j − i < 2k , e que se
€
2k−1 ≤ j − i ≤ 2k , então a diferença entre o limite superior e o limite inferior
do próximo intervalo de pesquisa satisfaz
€
2k−2 ≤ j − i ≤ 2k−1. Para o ver, basta notar que:
•
€
meio− i =j − i2
e
€
j −meio =j − i2
• (como
€
2k−2 é inteiro)
€
2k−1 ≤ j − i⇒ 2k−2 ≤j − i2
⇒ 2k−2 ≤j − i2
(≤ j − i
2
)
• (como
€
2k−1 é inteiro)
€
2k ≥ j − i⇒ 2k−1 ≥j − i2
⇒ 2k−1 ≥j − i2
(≥ j − i
2
)
453
Pesquisa binária recursiva (em lista ordenada)
Saliente-se que a pesquisa binária, em lista ordenada, também pode ser feita de forma recursiva, obtendo-se
um número de comparações da mesma ordem de grandeza (embora com o custo adicional de um maior
número de invocações).
A título ilustrativo procuremos uma versão recursiva apropriada da pesquisa binária, aproveitando para
ilustrar como se podem fazer as contas para tal versão.
Uma primeira versão recursiva, óbvia, da versão atrás da pesquisa binária é a seguinte (onde se pode
ainda guardar os valores de Length[w] e Quotient[1+Length[w],2] em variáveis locais, para
evitar “recalcular” o seu valor):
pesq6 = Function[{w,x},
If[w=={}||x<w[[1]]||x>w[[Length[w]]], False,
If[x==w[[1]]||x==w[[Length[w]]], True,
If[Length[w]<=2, False,
If[x<w[[Quotient[1+Length[w],2]]],
pesq6[Take[w,Quotient[1+Length[w],2]],x],
pesq6[Drop[w,Quotient[1+Length[w],2]-1],x],
]
]]]
];
No entanto, facilmente se verifica que deste modo em cada invocação recursiva se efectuam inicialmente
testes a mais (que não seriam necessários, excepto para a invocação inicial).
Uma solução simples para este problema consiste em considerar que a recursão é feita através de uma
função local, que só é chamada depois dos testes iniciais, e que se “move” apenas sobre os índices da lista
a pesquisar, a qual permanece inalterável em cada chamada recursiva dessa função local.
Vejamos uma concretização dessa ideia:
pesq7 = Function[{w,x},Module[{n,pesqbin},
pesqbin=Function[{i,j},Module[{meio},
If[j==i+1, w[[i]]==x,
meio=Quotient[i+j,2];
If[x<w[[meio]], pesqbin[i,meio], pesqbin[meio,j] ]
]
]];
n=Length[w];
If[w=={}||x<w[[1]]||x>w[[n]], False,
If[x==w[[1]]||x==w[[n]], True, pesqbin[1,n] ]
]
]];
454
Tal como para a versão imperativa, é fácil verificar que o número de comparações NC(pesq7[w,x]) não
depende da composição da lista w, se w[[1]] < x < w[[n]] (a pior situação para o algoritmos apresentado),
mas apenas da sua dimensão n da lista w.
Caculemos então NCmaxpesq7(n), i.e. o número máximo comparações numa invocação pesq7[w,x]
quando w é uma lista de n números distintos, ordenada de forma crescente.
Ora, se w[[1]] < x < w[[n]], temos 4 comparações no início, mais as comparações que ocorrem no
invocação pesqbin[1,n]. Designemos por N(m) o número máximo de comparações com elementos da
lista w que ocorrem numa invocação pesqbin[i,j], com 1≤i≤j≤n e j-i=m.
Ora, se m for uma potência de 2, então N(m) satisfaz a seguinte relação de recorrência52:
N(1) = 1;
N(m) = 1 + N(
€
m2
) = 1 + N(
€
m2
) = 1 + N(
€
m2
), se m>1
O que se passa se se m não for uma potência de 2 ? Então uma invocação pesqbin[i,j], com j-i=m,
dará origem a uma invocação pesqbin[i,j], com
€
j − i =m2
, ou a uma invocação pesqbin[i,j], com
€
j − i =m2
. Como esta é a pior situação (pois corresponde a um intervalo de pesquisa maior ou igual que o
anterior, podemos dizer que N(m) satisfaz a seguinte relação de recorrência:
N(1) = 1;
(*) N(m) = 1 + N(
€
m2
), se m>1
A solução explícita de uma recorrência deste tipo não é fácil de obter. Mas o fundamental para nós é ter
uma noção da ordem de grandeza do seu crescimento. Vejamos como tal pode ser obtido53.
• Comecemos por procurar (tal como para o caso iterativo) uma sua solução explícita quando m é uma
potência de 2.
Ora, se
€
m = 2k , então (*) transforma-se em
N(
€
2k) = 1 + N(
€
2k−1), para k = 1, 2, 3, ...
Definindo J(k) = N(
€
2k), obtém-se a seguinte equação de recorrência
J(k) = 1 + J(k-1), se k>1
sujeita à condição inicial
J(0) = 1
E, usando p.ex. o método iterativo, obtém-se
J(k) = 1 + k, se k ≥0
Logo se
€
m = 2k , para k≥0, então N(m) = N(
€
2k) = J(k) = 1 + k = 1 + log2m :
(**) se
€
m = 2k , para k≥0, então N(m) = 1+log2m (= 1+log2m = 1+log2m)
52 Note-se que para se garantir que esta função está bem definida precisamos de recorrer aos resultados sobre a definição defunções por recursão sobre conjuntos onde está definida uma relação bem fundada (ver capítulo 8).53 No que se segue o método ilustrado em [34] (páginas 290 e 291).
455
• Considere-se agora o caso em que m não é uma potência de 2. Então existe k>0:
(***)
€
2k−1 < m < 2k
• De (***), sai que
(***a)
€
k −1< log2 m < k
bem como
(***b)
€
k −1= log2 m e
€
k = log2 m
• Ora a sucessão N(m) (com m um qualquer inteiro maior ou igual a 1) dada pela relação de recorrência:
N(1) = 1;
N(m) = 1 + N(
€
m2
), se m>1
é crescente, como se demonstra, por indução, a seguir:
Seja P(m) a propriedade “
€
∀1≤i≤ j≤mN (i) ≤ N ( j)”
Queremos provar que
€
∀m≥1P(m)
Base: Tem-se P(1) (imediato pois N(1)≤N(1)).
Seja m≥1 qualquer.
HI: Tem-se P(m), i.e
€
∀1≤i≤ j≤mN (i) ≤ N ( j)
Tese: Tem-se P(m+1), i.e.
€
∀1≤i≤ j≤m+1N (i) ≤ N ( j)
Dem.: Atendendo a HI, é fácil verificar que nos basta provar que N(m+1)≥N(m).
Ora
€
m +12
≤ m , para m≥1 (demonstre por indução).
Logo, por HI, tem-se
€
N (i) ≤ N ( m +12
) , para qualquer
€
i ≤ m +12
e, particular,
€
N ( m2
) ≤ N ( m +1
2
) .
E, portanto:
N(m+1) = 1 + N(
€
m +12
) ≥ 1 + N(
€
m2
) = N(m), se m>1
e, se m=1, então N(2)=1+N(1)≥N(1) (c.q.d.)
• Assim, de (***), conclui-se que
€
N (2k−1) ≤ N (m) ≤ N (2k )
• E, usando N(
€
2k) =1 + k (isto é (**)), as desigualdades anteriores, (***a) e (***b), conclui-se que
€
N (m) ≥ N (2k−1) = k ≥ log2 m , bem como
€
N (m) ≥ N (2k−1) = k = log2 m
e
€
N (m) ≤ N (2k ) = 1+ k < 2+ log2 m , bem como
€
N (m) ≤ N (2k ) = 1+ k = 1+ log2 m
(pelo que, em particular, como é fácil de verificar,
€
N (m) =Θ(log2 (m))
Em conclusão, não só se tem que NCmaxpesq7(n ) =
€
Θ(log2 (n)), como se tem mesmo, mais
precisamente, que
€
4 + log2 (n −1) ≤ NCmaxpesq7(n) ≤
€
5+ log2 (n −1)
456
Secção 6: Caracterização assimptótica de recorrências da forma
€
C (n) = aC (nb) + f (n) .
O algoritmo recursivo anterior, para a pesquisa binária em lista ordenada, baseia-se na ideia de transformar
o problema da pesquisa numa lista num subproblema (do mesmo tipo), com metade da dimensão: a
pesquisa na metade esquerda ou a pesquisa na metade direita da lista inicial. E a análise do custo de tal
agoritmo (no caso do número de comparações com elementos da lista) conduziu-nos a uma equação de
recorrência da forma:
N(m) = 1 + N(
€
m2
), se m>1
Ora tal tipo de recorrência não é específica do algoritmo anterior, ocorrendo tipicamente em algoritmos
recursivos com certas características. Como se refere em [9] 54, equações de recorrência do forma genérica
€
C (n) = aC (nb) + f (n)
ou, mais precisamente, da forma
€
C (n) = aC ( nb
) + f (n) ou
€
C (n) = aC ( nb
) + f (n)
ocorrem frequente quando caracterizamos o custo
€
C (n) de um algoritmo que divide um problema de
dimensão n em a subproblemas, cada um de dimensão
€
nb
, com a e b constantes positivas, sendo cada um
dos a subproblemas resolvido recursivamente com custo
€
C (nb) , e onde a função
€
f (n) traduz o custo da
divisão do problema e da combinação dos resultados dos subproblemas.
No exemplo anterior ilustrámos um método genérico que pode ser usado para tentar caracterizar a ordem
de grandeza da solução de tais relações de recorrência.
Iremos, em seguida, enunciar (sem demonstrar) um resultado geral que nos dá directamente uma
caracterização da ordem de grandeza do crescimento assimptótico da solução dessas relações de recorrência,
para certo tipo de funções f(n).
Teorema (chamado de “Teorema principal” / “Master theorem” em [9]):
Sejam a e b constantes reais tais que a≥1 e b>1, f(n) uma função e C(n) uma função positiva (pelo menos
a partir de uma certa ordem), definida nos naturais (ou nos naturais maiores ou igual que um certo natural
p), que satisfaz a equação de recorrência
€
C (n) = aC (nb) + f (n) , para todo o natural n
(ou para todo o natural n maior ou igual que um certo natural p)
onde
€
nb
deve ser interpretado como significando
€
nb
ou
€
nb
(qualquer dos dois casos é coberto por este teorema)
a) Se existe uma constante
€
ε > 0 tal que
€
f (n) =Ο(n logb a−ε ) , então
€
C (n) =Θ(n logb a )
54 Texto que seguiremos nesta secção (ver nomeadamente páginas 61 a 63 de [9]).
457
b) Se
€
f (n) =Θ(n logb a ) , então
€
C (n) =Θ(n logb a log2 n)
c) Se existe uma constante
€
ε > 0 tal que
€
f (n) =Ω(n logb a+ε ) e se
€
∃0≤c<1∃n0∈N 0∀n≥n0af (nb) ≤ cf (n)
então
€
C (n) =Θ( f (n))
Demonstração :
Ver secção 4.4 de [9].
∇
Antes de vermos um exemplo que ilustre a aplicação de cada um dos casos referidos neste teorema,
alguns comentários sobre o seu significado (extraídos de [9], página 62):
Em qualquer dos três casos (i.e. das três alíneas) compara-se a função f(n) com a função
€
n logb a . E,
intuitivamente, a solução da recorrência é determinada por qual das duas é (assimptoticamente) maior. Se,
como no caso a), a função
€
n logb a é maior, então a solução é
€
C (n) =Θ(n logb a ) . Se, como no caso c), é a
função f(n) que é maior, então
€
C (n) =Θ( f (n)) . Se, como no caso b), as duas funções têm a mesma
ordem de grandeza, multiplicamos por um factor logarítmico e a solução é
€
C (n) =Θ(n logb a log2 n) =
€
Θ( f (n) log2 n) .
Continuando a seguir o que é dito a propósito deste teorema em [9], é de referir ainda que convém
precisar tecnicamente a intuição anterior. Assim, e sem entrarmos em grandes detalhes, no caso a), não
basta que f(n) seja menor que
€
n logb a : tem de ser “polinomialmente menor”, significando isto que f(n) tem
de ser menor que
€
n logb a por um factor
€
nε , para alguma constante
€
ε > 0 . No caso c), f(n) tem de ser
“polinomialmente maior” que
€
n logb a , para além de ter de satisfazer a “condição de regularidade”
€
af (nb) ≤ cf (n) (condição que é satisfeita pela maioria das funções limitadas polinomialmente que ocorrem
na análise dos algoritmos).
Deste modo, existe uma “lacuna” entre a situação a) e b), correspondente aos casos em que f(n) é menor
que
€
n logb a , mas não “polinomialmente menor”(e analogamente existe uma “lacuna” entre a situação b) e
c)): tais casos não são cobertos pelo teorema apresentado.
Exemplo 1 :
Seja
€
C (n) = 9C (n3) + n .
Tem-se: a=9, b=3, f(n)=n e (portanto)
€
n logb a = n log3 9 = n2 (logo
€
f (n) não é um Θ(n logb a ) ).
Ora
€
f (n) =Ο(n logb a−ε ) , com
€
ε = 1 (pelo que se aplica o caso a)).
Logo
€
C (n) =Θ(n logb a ) , i.e.
€
C (n) =Θ(n2)
∇
Exemplo 2 :
Considere-se o caso da pesquisa binária atrás analisado, dado pela equação de recorrência:
458
N(m) = 1 + N(
€
m2
),
Tem-se: a=1, b=2, f(n)=1.
Então
€
n logb a = n log2 1 = n0 = 1 e
€
f (n) =Θ(n logb a ) .
Logo (pelo caso b)),
€
N (n) =Θ(n logb a log2 n) , i.e.
€
C (n) =Θ(log2 n) (como obtivemos atrás).
∇
Repare-se que o resultado a que se chegou no exemplo 2 aplica-se a qualquer recorrência da forma
€
C (n) = C (nb) + k , com k uma constante positiva (i.e. com a=1 e f(n)=k).
Exemplo 3 :
Seja
€
C (n) = 3C (n4) + n log2 n .
Tem-se: a=3, b=4,
€
f (n) = n log2 n e
€
n logb a = n log4 3 = n0.79248... (=Ο(n0.793)) .
Ora
€
limn→+∞
n log2 nn
= +∞ , pelo que
€
f (n) =ϖ (n) . Ora
€
n = n logb a+ε , com
€
ε = 1− log4 3 ≈ 0.2 > 0. Logo
€
f (n) =Ω(n logb a+ε ) .
Por outro lado,
€
af (nb
) = 3 n4
log2 (n4
) ≤ 3 n4
log2 (n) =34n log2 (n) = cf (n), com c =
34
(para qualquer n≥1).
Logo (pelo caso c)),
€
C (n) =Θ( f (n)) , i.e.
€
C (n) =Θ(n log2 n)
∇
Vejamos, para terminar esta nossa breve referência a este assunto, um exemplo de uma relação de
recorrência à qual o teorema anterior não pode ser aplicado.
Exemplo 4 :
Seja
€
C (n) = 2C (n2) + n log2 n .
Tem-se: a=2, b=2,
€
f (n) = n log2 n e
€
n log2 2 = n .
Ora :
•
€
limn→+∞
n log2 nn × n−ε
= limn→+∞
nε log2 n = +∞ (
€
ε > 0 ) pelo que
€
f (n) não é um Ο(n logb a−ε ) e o caso a) não é
aplicável
•
€
limn→+∞
n log2 nn
= +∞ , pelo que
€
f (n) não é um Θ(n logb a ) e o caso b) não é aplicável
• Embora
€
f (n) =ϖ (n) (pois
€
limn→+∞
n log2 nn
= +∞ ), o caso c) também não é aplicável, pois, qualquer que
seja
€
ε > 0 que se considere, tem-se
€
limn→+∞
n log2 nn × nε
= limn→+∞
log2 nnε
= limn→+∞
log2 eε × nε
= 0 (
€
ε > 0 ) pelo que
€
f (n) não é um Ω(n logb a+ε ) .
∇
459
Secção 7: Ordenação.
Para terminar esta breve introdução à análise de eficiência de algoritmos, vejamos alguns exemplos de
algoritmos de ordenação, tarefa que é importante por várias razões, e em particular por a pesquisa binária
só poder ser realizada sobre uma lista ordenada.
Assume-se que a lista w a ordenar, de forma crescente, é uma lista de números, sem repetições55.
No que se segue iremos medir o custo/complexidade dos algoritmos de ordenação em função do número
de comparações com elementos da lista a ordenar que executam, considerando que as comparações de
elementos são as operações básicas, ou essenciais, que estão por detrás do funcionamento destes
algoritmos. De facto, para além destas, também se deveria estudar o número de movimentos de elementos
da lista que é necessário efectuar56. No entanto, consideramos que para os fins (introdutórios) aqui em
vista, é suficiente debruçar-nos sobre a análise do número de comparações.
Os algoritmos de ordenação costumam-se dividir em duas grandes classes:
• os chamados algoritmos de ordenação elementares, ou directos, com executam em média
€
Θ(n2)
comparações para ordenar uma lista de n elementos;
• e os chamados algoritmos de ordenação avançados, ou “bons”, que executam em média
€
Θ(n log2 n)
comparações57 para ordenar uma lista de n elementos.
Naturalmente, como regra58, os algoritmos de ordenação elementares só deverão ser utilizados para
ordenar listas com poucos elementos.
Comecemos por analisar um algoritmo de ordenação elementar, conhecido por algoritmo da inserção
directa.
Exemplo 1: algoritmo da inserção directa.
A ideia do algoritmo da inserção directa é simples. O algoritmo tem dois ciclos, um dentro do outro. O
ciclo de fora percorre os elementos da lista argumento sequencialmente, tendo como invariante (descrito
55 Tal como para a pesquisa em lista ordenada, para o que se segue não é essencial que se trate de uma lista de números,podendo considerar-se elementos de outros tipos. O que é fundamental é que se disponha de uma relação de ordem total
€
p noconjunto desses elementos. Por outro lado, os programas a apresentar funcionam mesmo que na lista argumento ocorramrepetições, mas facilita algumas contas supor que estas não ocorrem.56 Note-se que um movimento de um elemento de uma lista pode ser mais custoso (em termos de tempo de execução) que umasimples comparação, nomeadamente se os elementos da lista a ordenar forem estruturas complexas (listas/registos grandes) enão simples números. Existem, contudo, técnicas que permitem diminuir a importância desses movimentos em tais casos, em queos elementos são estruturas complexas (recorrendo p.ex. a memória adicional), mas sai fora do âmbito deste texto (que não é umtexto de algoritmia) a abordagem desses aspectos.57 Refira-se que no sistema computacional Mathematica já existe uma função predefinida de ordenação, Sort, que será comcerteza melhor (por estar completamente optimizada, tirando partido do sistema computacional em causa) do que as queilustraremos, pelo que deverá ser essa função que devemos utilizar quando pretendermos efectuar ordenações nesse sistemacomputacional. Tal não retira, obviamente, interesse ao estudo em geral de algoritmos de ordenação, e muito menos à suautilização para ilustração da análise de eficiência de algoritmos.58 A seguir veremos um caso que foge a essa regra, quando nos encontramos perto da situação óptima para o algoritmo emcausa.
460
informalmente): na lista do resultado os elementos já analisados estão por ordem. Em cada passo do ciclo
de fora o próximo elemento (a analisar) da lista argumento é colocado na lista do resultado por ordem. O
ciclo de dentro é utilizado para determinar a posição onde esse elemento deve ser colocado.
Esta ideia pode ser implementada de várias maneiras. Uma maneira directa é a seguinte:
Function[w,Module[{r,i,j,x,n},
n=Length[w];
r={}; i=1; (* i é o indíce do próximo elemento a analisar em w *)
While[i<=n,
x=w[[i]];
j=i-1;
While[j≥1&&r[[j]]>x,j=j-1];
r=Insert[r,x,j+1];
i=i+1
];
r
]];
Note-se que o ciclo interior funciona bem pois quando é avaliada a sua condição, j≥1&&r[[j]]>x, a
condição r[[j]]>x só é analisada se a condição j≥1 for verdadeira.
Uma variante alternativa de implementação do algoritmo59 consiste em guardar inicialmente toda a
lista a ordenar na variável local r, percorrendo então a lista em r e alterando-a, mantendo como invariante
que os elementos já analisados r[[1]], ..., r[[i-1]] estão ordenados (e constituem uma permutação dos
elementos que estavam inicialmente em r nessas posições)60. É fácil verificar que neste caso i pode ser
inicializado a 2, em vez de ser a61 1. Obtém-se, deste modo, o seguinte programa/função Mathematica:
Function[w,Module[{r,i,j,x,n},
r=w; n=Length[r]; i=2;
While[i<=n,
x=r[[i]] (* ou x=w[[i]] *); r=Delete[r,i];
j=i-1;
While[j≥1&&r[[j]]>x,j=j-1];
r=Insert[r,x,j+1];
i=i+1
];
r
]];
59 Que é mais facilmente comparável com a ideia do algoritmo da selecção directa, apresentado como exemplo 2 a seguir.60 O papel que é a seguir desempenhado pela variável r não pode ser desempenhado pelo parâmetro w, em virtude de noMathematica não podermos alterar os parâmetros das funções.61 No programa anterior a inicialização r={w[[1]]};i=2 não serve, se admitirmos que a lista argumento pode estar vazia.
461
Finalmente podemos ainda considerar uma variante de implementação deste algoritmo, em que se evita
a avaliação de j≥1 na condição do ciclo de dentro (a que também chamamos a “guarda” do ciclo de dentro),
recorrendo à chamada técnica da sentinela: em cada passo do ciclo de fora, antes da execução do ciclo de
dentro é colocado x no início da lista r (que funciona como sentinela), permitindo que a guarda do ciclo de
dentro possa ser simplesmente r[[j]]>x. Segue-se a descrição desta implementação, que será a que
consideraremos a seguir para o cálculo do número de comparações62.
insdir = Function[w,Module[{r,i,j,x,n},
r=w; n=Length[r]; i=2;
While[i<=n,
x=r[[i]]; r=Delete[r,i]; r=Prepend[r,x]; (* pôr sentinela *)
j=i; (* e não j=i-1 *)
While[r[[j]]>x,j=j-1];
r=Insert[r,x,j+1];
r=Rest[r]; (* tirar a sentinela *)
i=i+1
];
r
]];
É imediato verificar que numa invocação insdir[w] o número de comparações com elementos da
lista w depende da composição de w e não apenas da sua dimensão63. Justica-se assim que se analise o que
se passa no pior caso, em média e, eventualmente, no melhor caso.
Embora a análise do melhor caso não seja em geral muito relevante, tal não é o que se passa aqui. De
facto, existem muitas aplicações em que os dados são em princípio guardados numa lista (ou numa tabela)
por ordem, mas em que por alguma razão um ou outro dado pode ter sido inserido fora de ordem, e em que
se torna necessário fazer uma ordenação da tabela para garantir que esta fica mesmo ordenada e suporta
portanto uma pesquisa binária de um elemento. Ora, a melhor situação para este algoritmo é quando a lista
a ordenar já está ordenada, pelo que nessas aplicações estamos em geral perto da situação óptima para este
algoritmo. Assim, se a ordem de grandeza do algoritmo for muito boa nessa situação óptima (como se
verifica, como veremos já a seguir), ele pode ser preferível a um algoritmo avançado de ordenação para
essas aplicações (apesar de no pior caso e em média se “comportar mal”).
Número mínimo de comparações
É imediato que a melhor situação para o algoritmo apresentado é quando a lista argumento w já está
ordenada.
62 Apesar de o número de comparações ser da mesma ordem de grandeza nas várias variantes, os cálculos da versão comsentinela são mais simples.63 Afirmação que também é válida para as outras implementações apresentadas do algoritmo da inserção directa.
462
Designe-se por NCmininsdir(n) o número mínimo de comparações que o algoritmo executa para ordenar
uma lista de n números diferentes (i.e. NCmininsdir(n) designa o número de comparações que ocorrem
numa invocação insdir[w], quando w é uma qualquer lista de n números distintos que está ordenada de
forma crescente).
Ora, só ocorrem comparações com elementos da lista em r na avaliação da condição (da “guarda”) do
ciclo de dentro. O ciclo de dentro é executado com i a variar de 2 até n. Nesta melhor situação, a execução
do ciclo de dentro corresponde a avaliar a guarda (uma vez) e a terminar logo a execução. Assim:
NCmaxinsdir(n)=
€
1i=2
n∑ = n −1
pelo que NCmaxinsdir(n) =
€
Θ(n) (complexidade linear: muito bom !)
Número máximo de comparações
É imediato que a pior situação para o algoritmo apresentado é quando a lista argumento w está ordenada
(inicialmente) da forma inversa da pretendida (i.e. está ordenada de forma decrescente).
Designe-se por NCmaxinsdir(n) o número máximo de comparações que o algoritmo executa para ordenar
uma lista de n números diferentes (i.e. NCmaxinsdir(n) designa o número de comparações que ocorrem
numa invocação insdir[w], quando w é uma qualquer lista de n números distintos que está ordenada de
forma decrescente).
Ora, só ocorrem comparações com elementos da lista em r na avaliação da guarda do ciclo de dentro. O
ciclo de dentro é executado com i a variar de 2 até n. Nesta pior situação, em cada execução do ciclo de
dentro, a guarda r[[j]]>x é avaliada com j a variar de i a 1. Assim:
NCmaxinsdir(n)=
€
1=j=1
i∑
i=2
n∑ i =
i=2
n∑ (n + 2)(n −1)
2=n2 + n − 2
2
pelo que NCmaxinsdir(n) =
€
Θ(n2) (complexidade quadrática: mau !)
Número médio de comparações
Calculemos agora qual é o número esperado (ou médio) de comparações, para ordenar w, quando se assume
que w é uma qualquer lista de n números distintos, número que designaremos por NCmedinsdir(n).
Isto é, queremos saber qual o número esperado de comparações com elementos de w que ocorrem numa
invocação insdir[w], quando a lista w é uma lista escolhida ao acaso de entre as listas de n números
distintos.
Ora, como já vimos só ocorrem comparações com elementos da lista quando o ciclo de dentro é
executado. E na execução do ciclo de dentro, While[r[[j]]>x,j=j-1], o número de comparações
com elementos da lista pode ser descrito como sendo o número esperado de comparações NEC(i) que é
necessário para colocar x=r[[i]] na sua posição correcta, que será ou 2, ou 3, ..., ou i+1, atendendo a
que na posição 1 está a sentinela (i.e., equivalentemente, a sua posição correcta será ou 1, ou 2, ..., ou i,
depois de se ter retirado a sentinela). Ora:
€
NEC (i) = NC (i→ k) Pr ob(k=2
i+1∑ i→ k)
463
onde NC(i→k) designa o número de comparações que é necessário efectuar para colocar x na sua posição
correcta, se ele deve ficar colocado na posição k, e Prob(i→k) designa a probabilidade de r[[i]] dever
ficar colocado na posição k.
Como nas passagens anteriores do ciclo de fora o elemento x ainda não foi comparado com os
elementos que estão em r[[2]],...,r[[i]] (i.e. que estão em r[[1]],...,r[[i-1]] antes de se
colocar a sentinela), é de admitir que ele tenha a mesma probabilidade de dever estar em qualquer uma das i
posições disponíveis64.
Ou seja,
€
Pr ob(i→ k) =1i
.
Por sua vez, se x deve ficar colocado na posição k=2,...,i+1, isso é conhecido (tendo já sido colocada a
sentinela) após compará-lo sucessivamente com os elementos r[[i]], ..., r[[k-1]] (sendo r[[k-1]]
o primeiro desses elementos que é menor ou igual a x). Assim:
€
NEC (i) = NC (i→ k) 1ik=2
i+1∑ =
1i
(i − k + 2)k=2
i+1∑ =
1i
rr=1
i∑ =
1i(i +1)i2
=i +12
E:
NCmedinsdir(n) =
€
i +12i=2
n∑ =
(n + 4)(n −1)4
=n2 + 3n − 4
4
pelo que65 NCmedinsdir(n) =
€
Θ(n2) (complexidade quadrática: mau !)
Exemplo 2: algoritmo da selecção directa.
Antes de passarmos a um algoritmo de ordenação avançado, ilustremos (só) mais um algoritmo elementar
de ordenação.
O algoritmo conhecido como da selecção directa é igualmente simples. A lista a ordenar é inicialmente
guardada numa variável local66 r, após o que a lista em r é ordenada recorrendo a dois ciclos, um dentro
do outro. Usando i para referir a próxima posição, podemos dizer que o ciclo de fora mantém agora como
invariante a seguinte condição (descrita informamente): em r os elementos r[[1]], ..., r[[i-1]]
estão já nas posições finais (condição mais forte do que a simples imposição de eles estarem ordenados). E
em cada passo do ciclo de fora é colocada na posição i o menor de entre os elementos nas posições
r[[i]], ..., r[[n]]. O ciclo de dentro é utilizado para descobrir qual é a posição imin onde está o
menor desses elementos, após o que se troca r[[imin]] com r[[i]].
seldir = Function[w,Module[{r,n,i,j,x,imin},
r=w;
64 À partida qualquer permutação de i elementos tirados ao acaso tem igual probabiilidade de ser a permutação em que estesestão por ordem.65 Como a ordem de grandeza destes algoritmos directos é má (pelo menos em média e no pior caso), eles só devem em geralaplicados a listas cuja dimensão n é pequena. Por essa razão, é útil conhecermos a expressão exacta do seu número decomparações, pois para pequenos valores de n ela é relevante.66 Mais uma vez se chama a atenção de que isto só é necessário em virtude de no Mathematica não podermos alterar osparâmetros das funções.
464
n=Length[r];
i=1;
While[i<=n-1,(* descobrir posição do mínimo *)
imin=i;
j=i+1;
While[j<=n,
If[r[[j]]<r[[imin]],imin=j];
j=j+1
];(* troca *)
x=r[[i]]; r[[i]]=r[[imin]]; r[[imin]]=x;(* progredir no cálculo *)
i=i+1
];
r
]];
Embora as instruções que são executadas numa invocação seldir[w] dependam da composição de w e
não apenas da sua dimensão (nomeadamente as atribuições imin=j), se nos limitarmos, como até aqui, a
contar o número de comparações com elementos da lista w, tal já não é o caso.
Designe-se, então, por NCseldir(n) o número de comparações com elementos da lista w que ocorrem
numa invocação seldir[w], quando w é uma qualquer lista de n números. Tem-se:
NCseldir(n) =
€
1j=i+1
n∑
i=1
n−1∑ = (n − i) =
i=1
n−1∑ k =
k=1
n−1∑ n(n −1)
2=n2 − n2
pelo que67 NCseldir(n) =
€
Θ(n2) (complexidade quadrática: mau !)
Exemplo 3: Quicksort.
Para terminar esta nossa muito breve introdução à análise de algoritmos de ordenação, consideremos agora
um algoritmo avançado de ordenação68, conhecido pelo nome (sugestivo) de Quicksort.
A ideia básica do Quicksort é simples:
• partição:
Escolher um elemento (chamado elemento de partição) x entre o menor e o maior elemento da lista a
ordenar (por exemplo, o 1º elemento dessa lista, embora possam existir outras escolhas melhores69);
67 Observe-se, contudo, que no que respeita ao número de movimentos de elementos da lista que executa, se trata de um bomalgoritmo. De facto, o algoritmo efectua uma troca de elementos da lista (o que corresponde a 3 movimentos de elementos dalista) em cada execução do passo do ciclo de fora, ou seja, efectua só 3(n-1) movimentos para ordenar uma lista de n elementos.68 Saliente-se que existem outros algoritmos avançados de ordenação.69 Como a mediana da lista, mas há que calculá-la!
465
rearranjar a lista a ordenar “decompondo-a” em duas listas: uma lista esquerda formada pelos elementos
menores que x e uma lista direita formada pelos elementos maiores que x 70;
• recursão:
Repetir o processo anterior para as listas esquerda e direita até chegar a listas com no máximo um
elemento (que estão obviamente ordenadas).
Comecemos por ver uma implementação directa, muito simples, deste algoritmo na linguagem
Mathematica, em que se tira partido das funções disponibilizadas pelo Mathematica para manipular listas,
e em que se escolhe para elemento de partição sempre o primeiro elemento da lista a ordenar.
Uma primeira versão do Quicksort :
quicksort = Function[w,Module[{x},
If[w=={}, {},
x=First[w];
Join[quicksort[Select[w,Function[y,y<x]]],
Select[w,Function[y,y==x]],
quicksort[Select[w,Function[y,y>x]]] ]
]
]];
Continuando a contar apenas o número de comparações com elementos da lista a ordenar, iremos
calcular em seguida o número máximo, mínimo e médio de comparações efectuadas para ordenar uma lista
w de n números distintos pelo programa anterior.
Número máximo de comparações
Designe-se por NCmaxquicksort(n) o número máximo de comparações que o algoritmo executa para ordenar
uma lista de n números diferentes.
Curiosamente a pior situação para o algoritmo apresentado é quando a lista argumento w já está
ordenada (inicialmente) de forma crescente ou decrescente.
Contemos, então, o número de comparações com elementos da lista w a ordenar que ocorrem numa
invocação insdir[w], quando w é uma qualquer lista de n números distintos que já está ordenada de
forma crescente (o caso em que está ordenada de forma decrescente é análogo).
Ora, nesse caso:
70 Se o elemento x fizer parte da lista, considera-se que ele (e, no caso de se admitir repetições, todos os elementos da listaiguais a ele) não faz(em) parte nem da lista esquerda, nem da direita, sendo colocado(s) entre essas duas listas. Pode-se tambémsupor que o(s) elemento(s iguais a) x vão para a lista esquerda, passando esta a ser formada pelos elementos menores ou iguais ax e ficando na lista direita os maiores que x (ou, alternativamente, pode supor-se que os iguais a x vão para a lista direita).Contudo nesse caso é preciso ter cuidado de modo a garantir que nenhuma dessas sublistas tem a mesma dimensão da listainicial, para evitar que o processo (recursivo) possa não terminar (uma solução consiste em supor que o elemento de partição xestá sempre estritamente entre o menor e o maior elemento da lista a ordenar, não podendo ser igual a nenhum destes casosextremos; mas há outras soluções).
466
• Select[w,Function[y,y<x]] envolve n comparações e retorna a lista vazia (lista a que é
aplicada recursivamente a função quicksort, mas tal não envolve qualquer nova comparação, pois a
lista está vazia);
• Select[w,Function[y,y==x]] envolve n comparações e retorna uma lista só com o x (note-se
que a esta lista nunca é aplicado recursivamente a função quicksort);
• Select[w,Function[y,y>x]] envolve n comparações e retorna a lista Rest[w], que tem n-1
elementos e que continua ordenada crescentemente, i.e. que está na pior situação para o programa (lista
a que é aplicado recursivamente a função quicksort);
Assim, NCmaxquicksort(n) satisfaz a seguinte relação de recorrência:
• NCmaxquicksort(0) = 0;
• NCmaxquicksort(n) = 3n + NCmax
quicksort(n-1)
e, usando p.ex. o método iterativo chega-se a:
NCmaxquicksort(n) =
€
3i =i=1
n∑ 3 (n +1)n
2=3n2 + 3n
2
pelo que NCmaxquicksort(n) =
€
Θ(n2) (complexidade quadrática: mau !)
Número mínimo de comparações
Designe-se por NCminquicksort(n) o número mínimo de comparações que o algoritmo executa para ordenar
uma lista de n números diferentes.
A melhor situação para o programa apresentado é quando se tem a sorte de escolher para elemento de
partição um elemento em que o número de elementos da lista menores que ele difere do número de
elementos da lista maiores que ele, no máximo, de uma unidade (a mediana da lista).
Nessa situação, se n≥1, quando partimos a lista w ou71 a lista esquerda tem (n-1)/2 e a lista direita
tem (n-1)/2 (=72 n/2), ou vice-versa.
Assim, NCminquicksort(n) satisfaz a seguinte relação de recorrência:
• NCminquicksort(n) = 0
• NCminquicksort(n) = 3n + NCmin
quicksort((n-1)/2) + NCminquicksort(n/2), se n>0
Ora, intuitivamente NCminquicksort((n-1)/2) ≈ NCmin
quicksort(n/2), pelo que se pretendemos ter
apenas uma estimativa da ordem de grandeza de NCminquicksort(n) poderíamos substituir na equação de
recorrência anterior NCminquicksort((n-1)/2) por NCmin
quicksort(n/2), aplicar o teorema (“teorema
principal”) da secção 6, e obter a ordem de grandeza de NCminquicksort(n).
71 Recorde do capítulo 4 que k/2 + k/2 = k, para qualquer inteiro k.
72 Se n é par, então
€
n − 22
<n −1
2<n2
e n − 22
é inteiro e n2
é o inteiro seguinte. Logo
€
n −12
=n2
= n2
.
Se n é ímpar, então
€
n −12
<n2
<n +1
2 e n −1
2 é inteiro e n +1
2 é o inteiro seguinte. Logo
€
n −12
=n −1
2= n
2
467
Se temos algumas dúvidas sobre o procedimento anterior (duvidando, nomeadamente, da garantia de
“irrelevância” da substituição referida), podemos então p.ex. procurar estabelecer apenas um
Ο(NCminquicksort(n)): se a sua ordem de grandeza for “boa”, tal chega-nos.
É imediato que NCminquicksort((n-1)/2) ≤ NCmin
quicksort(n/2). Assim, NCminquicksort(n) ≤ N(n),
com N(n) satisfazendo a relação de recorrência:
• N(n) = 0;
• N(n) = 3n + N(n/2) + N(n/2) = 3n + 2N(n/2), se n>0
A equação de recorrência anterior é da forma
€
N (n) = aN ( nb
) + f (n)
com a=2, b=2, f(n)=3n.
Tem-se
€
n logb a = n log2 2 = n1 = n , pelo que
€
f (n) =Θ(n logb a ) . Logo pelo teorema (“teorema
principal”, alínea b)) da secção 6:
€
N (n) =Θ(n logb a log2 n) , i.e.
€
N (n) =Θ(n log2 n)
Assim NCminquicksort(n) =
€
Ο(n log2 n) (muito bom73)
Número médio de comparações
Pelo que vimos o Quicksort comporta-se muito bem no melhor caso, mas muito mal no pior caso. Torna-
se assim fundamental saber como se comporta em média. Como veremos a seguir, o número de
comparações em média do Quicksort é da mesma ordem de grandeza que no melhor caso, e o mérito do
algoritmo (que se traduz no seu nome) reside precisamente aí.
Designemos por NCmedquicksort(n) o número esperado (ou médio) de comparações necessários para
ordenar w, quando se assume que w é uma qualquer lista de n números distintos. Queremos, portanto,
saber qual o número esperado de comparações com elementos de w que ocorrem numa invocação
quicksort[w], quando a lista w é uma lista escolhida ao acaso de entre as listas de n números
distintos.
Seja x=First[w] e designemos por i a posição final em que x irá ficar na lista final ordenada. Então
i-1 é igual ao número de elementos da lista argumento w que são menores que o seu primeiro elemento, e
n-i o número de elementos da lista argumento w que são maiores que o seu primeiro elemento. Tem-se
1≤i≤n e qualquer uma dessas situações é equiprovável. Assim, designando por NC(x→i) o número de
comparações que se espera que (em média) ocorram numa invocação quicksort[w], se x=First[w]
deve ir parar à posição i (na lista final ordenada), e designando NCmedquicksort(n) simplesmente por N(n)
(para abreviar), tem-se:
73 Pode provar-se (ver p.ex. [3], secção 2.4) que um algoritmo que ordene uma lista baseado em comparações de elementos,
faz em média um número de comparações maior ou igual que
€
log2 (n!) ≈ n log2 n −1.5n . Assim, se o Quicksort fizer em
média um número de comparações da mesma ordem de grandeza que a indicada acima para o melhor caso, nernhum algoritmode ordenação consegue fazer (em média) substancialmente melhor que o Quicksort (embora haja outros da mesma ordem degrandeza, como p.ex. o Mergesort, que não abordaremos nesta introdução ao tópico).
468
N(0) = 0
e, para n>0:
€
N (n) =
€
NC (i→ k) Pr ob(i=1
n∑ i→ k) =
€
NC (i→ k) 1ni=1
n∑
=
€
1n
(3n + N (i −1)i=1
n∑ + N (n − i)) =
€
3n +1n
N (i −1)i=1
n∑ +
1n
N (n − i)i=1
n∑
=
€
3n +1n
N ( j)j=0
n−1∑ +
1n
N ( j)j=0
n−1∑
isto é,
€
N (n) =
€
3n +2n
N ( j)j=0
n−1∑
Deparamo-nos mais uma vez com uma relação de recorrência não linear74 e que não cai no esquema
geral considerado na secção 1 do capítulo 9, uma vez que o termo N(n) não é definido à custa de um
número fixo i de termos anteriores (embora caia no esquema geral de definição de uma função por recursão,
discutido no capítulo75 8).
Iremos, em seguida, mostrar como podemos tentar resolver esta relação de recorrência. Antes, porém,
vejamos como se pode mostrar por indução que ela nos conduz76 a uma função que é
€
Ο(n log2 n) .
Demonstração de que N(n) =
€
Ο(n log2 n) :
Para certos cálculos a seguir facilita trabalhar com o logaritmo natural, pelo que iremos demonstrar antes
que N(n) =
€
Ο(n lnn) , donde sai o resultado pretendido, uma vez que
€
lnn = ln 2× log2 n e
€
ln 2 ≈ 0.7 > 0
Queremos então mostar que existe alguma constante positiva c tal que a partir de certa ordem
€
N (n) ≤ cn lnn
Mostremos, por indução, que tal se verifica para todo o n≥2
(Mostraremos até que, seja qual for o c≥8 que se considere,
€
N (n) ≤ cn lnn , para qualquer n≥2)
Base: N(2) = 9, pelo que se c for p.ex. maior ou igual a 7 se tem que
€
N (n) ≤ cn log2 n
Seja n>2 qualquer.
74 Embora, como mostraremos a seguir, se possa, a partir dela, obter uma relação de recorrência desse tipo.75 Basta considerar a seguinte relação bem fundada nos naturais “k
€
p n sse k < n” e usar o teorema 8.3.1 (considerando aí
que g(0) = 0, que T(n,R) está definido sse n≠0 e dom(R) = {k: k
€
p n} e que T(n,R) =
€
3n +2n
R(k)kpn∑ , designando por R(k) o
único valor que está em relação com k (
€
p n) por R (i.e. tal que (k, R(k)) ∈ R).76 A razão que nos pode levar a ter uma tal suposição não é relevante para o caso, pois o que queremos ilustrar é como elapode ser demonstrada por indução. Podemos ter tal suposição por considerarmos que o comportamento em média (destealgoritmo) não deverá diferir muito do seu comportamento no melhor caso, ou p.ex. por sabermos que é essa a ordem degrandeza do melhor que se consegue com algoritmos baseados em comparações de elementos da lista, e por, olhando para onome do algoritmo, suspeitarmos que ele deve ser dos melhores.
469
HI: Suponha-se que
€
N (i) ≤ ci ln i , para qualquer 2≤i≤n-1
Tese:
€
N (n) ≤ cn lnn
Dem.:
Usando a equação de recursão e a HI, tem-se:
€
N (n) =
€
3n +2n
N ( j)j=0
n−1∑ =
€
3n +2n(0+ 3+ N ( j)
j=2
n−1∑ ) =
€
3n +6n
+2n
N ( j)j=2
n−1∑ ≤
€
3n +6n
+2n
cj ln jj=2
n−1∑
=
€
3n +6n
+2cn
j ln jj=2
n−1∑
Ora, (pelo menos) para x>=1, a função xlnx é crescente. Assim (recordar observação 11.2.5)
€
j ln jj=2
n−1∑ ≤ x ln x dx
2
n∫ =77
€
x2 ln x2
−x2
4
2
n
=n2 lnn2
−n2
4− 2 ln 2+1
Logo
€
N (n) ≤
€
3n +6n
+2cn(n2 lnn2
−n2
4− 2 ln 2+1)
=
€
cn lnn + n(3− c2) +2n(3− 2c ln 2+ c)
Para mostrarmos que
€
N (n) ≤ cn lnn basta-nos mostrar que as duas últimas parcelas são negativas ou
nulas. Ora é fácil de verificar que elas são negativas p.ex. se c≥8 (de facto, a constante c até poderá ser um
pouco inferior a 8).
∇
Como
€
ln 2 ≈ 0.693147 ≤ 0.7 , da demonstração anterior podemos concluir que (para n≥2)
€
N (n) ≤ 5.6n log2 n
Vejamos agora como podemos tentar resolver a recorrência em causa.
Resolução da recorrência :
• N(0) = 0
•
€
N (n) =
€
3n +2n
N ( j)j=0
n−1∑ , se n>0
Se calcularmos alguns termos iniciais
N(0) = 0, N(1) = 3, N(2) = 9, N(3) = 17, N(4) =
€
532
, ...
não se vislumbra qual poderá ser a forma do termo geral (para depois tentarmos provar por indução).
Igualmente, não parece que o método iterativo nos leve a lado algum neste caso.
Procuremos “manipular” a equação de recorrência
77 Recorde os conhecimentos de cálculo integral ou utilize p.ex. o Mathematica.
470
€
N (n) =
€
3n +2n
N ( j)j=0
n−1∑
tentanto reduzir a sua complexidade, procurando livrar-nos da divisão e do símbolo do somatório.
Multiplicando ambos os membros da equação por n, obtém-se:
€
nN (n) = 3n2 + 2 N ( j)j=0
n−1∑
e substituindo n por n-1:
€
(n −1)N (n −1) = 3(n −1)2 + 2 N ( j)j=0
n−2∑ (para n-1>0)
Subtraindo então a 2ª equação à 1ª equação, conseguimos livrar-nos do símbolo do somatório:
€
nN (n) − (n −1)N (n −1) = 6n − 3+ 2N (n −1) , para n>1
relação que também funciona para n=1, pois N(0) = 0 e N(1) = 3.
Chegamos assim à seguinte relação de recorrência, muito mais simples:
• condição inicial: N(0) = 0• equação de recorrência:
€
N (n) =n +1n
N (n −1) +6n − 3n
, para n≥1
Podemos agora tentar usar o método iterativo para obter uma expressão de N(n). Podemos também
considerar primeiro uma outra recorrência, que se define à custa desta e que é mais fácil de iterar, e depois
retornar à recorrência pretendida.
Seja
€
B(n) =N (n)n +1
, para n≥0. Tem-se:
• condição inicial: B(0) = 0• equação de recorrência:
€
B(n) = B(n −1) +3(n −1)(n +1)n
, para n≥1
E, aplicando o método iterativo, obtém-se:
€
B(n) =3(n −1)(n +1)n
+ B(n −1) =
€
3(n −1)(n +1)n
+3(n − 2)n(n −1)
+ B(n − 2) = ... =
€
3(n −1)(n +1)n
+3(n − 2)n(n −1)
+ ...+ 3(n − k)(n − k + 2)(n − k +1)
+ B(n − k) = (fazer k = n)
.
€
3(n −1)(n +1)n
+3(n − 2)n(n −1)
+ ...+ 3×13× 2
+3× 02×1
+ B(0) =
€
3(n − k)(n − k + 2)(n − k +1)k=1
n−1∑ = (mudança de variável i = n-k+1)
€
3(i −1)(i +1)ii=2
n∑ =
€
3( i(i +1)ii=2
n∑ −
1(i +1)ii=2
n∑ ) =
471
€
3( 1ii=3
n+1∑ −
1(i +1)ii=2
n∑ )
Ora (exemplo 10.3.2)
€
1(i +1)ii=2
n∑ =
n −12(n +1)
e (exemplo 11.2.15)
€
1ii=3
n+1∑ = Hn +
1n +1
−1− 12
, onde Hn é
o chamado n-ésimo número harmónico. Assim (ver exemplo 11.2.15 sobre o valor aproximado de Hn):
€
B(n) =
€
3(Hn − 2 nn +1
) ≈ 3 lnn
E, portanto
€
N (n) =
€
(n +1)B(n) =
€
≈ 3(n +1) lnn ≈ 2.1(n +1) log2 n
tendo-se que N(n) =
€
Θ(n log2 n) .
Uma segunda versão do Quicksort :
Na versão do Quicksort atrás apresentada recorremos à função predefinida no Mathematica Select. Para
terminar, vejamos uma das (várias) possíveis implementações deste importante algoritmo de ordenação,
em linguagens de programação que não disponibilizem um operador desse tipo sobre listas.
A versão a seguir envolve até menos comparações, embora seja da mesma ordem de grandeza que a
versão anterior, e é facilmente adaptada ao caso em que a lista a ordenar está guardada em estruturas
semelhantes às listas do Mathematica, como os “array’s”.
No programa a seguir, na partição da lista a ordenar, i designa a próxima posição a analisar nessa lista,
pp guarda o índice do p onto de p artição, prim guarda o índice da prim eira posição da sublista em
ordenação e ult guarda o índice da últ ima posição da sublista em ordenação, mantendo-se como invariante
do ciclo de partição (com x igual ao valor inicialmente em r[[prim]]):
€
r[[ prim]]= x∧∀ prim< j≤ ppr[[ j]] < x∧∀ pp+1≤ j<ir[[ j]]≥ x∧ prim ≤ pp ≤ i −1∧ prim +1≤ i ≤ ult +1
No final do ciclo de partição, r[[prim]] é trocado com r[[pp]] de modo a que x fique na posição
correcta, aplicando-se o mesmo algoritmo (recursivamentye) às listas esquerda (de prim a pp-1) e direita
(de pp+1 a ult), parando quando estas tiverem no máximo um elemento (em cujo caso estão ordenadas).
quicksort = Function[w,Module[{r,sort},
r=w;
sort = Function[{prim,ult}, Module[{i,x,pp,aux},
If[prim<ult,
(* partição *)
x=r[[prim]]; pp=prim; i=prim+1;
While[i<=ult,
If[r[[i]]<x,
(* troca r[[i]] com r[[pp+1]] e pp avança *)
pp=pp+1;
aux=r[[i]]; r[[i]]=r[[pp]]; r[[pp]]=aux
472
];
i=i+1
];
(* trocar r[[prim]] com r[[pp]] *)
aux=r[[pp]]; r[[pp]]=r[[prim]]; r[[prim]]=aux;
(* recursão *)
sort[prim,pp-1];
sort[pp+1,ult]
]
]];
sort[1,Length[r]];
r
]];
Comparando com o que se passava (em termos de número de comparações) com a primeira versão do
Quicksort (atrás apresentada), enquanto que nessa versão todos os elementos da (sub)lista em ordenação
eram comparados três vezes com o seu primeiro elemento (por intermédio dos Select), a que se
adicionava o número de comparações das chamadas recursivas, agora todos os elementos da (sub)lista em
ordenação, com excepção do primeiro, são comparados uma vez com o primeiro elemento (na partição), a
que se deve adicionar o número de comparações das chamadas recursivas,
Obtém-se assim, para o pior caso e em média (sem pormenorizar as contas que são análogas às da
versão anterior):
Número máximo de comparações
NCmaxquicksort(n) satisfaz a seguinte relação de recorrência:
• NCmaxquicksort(0) = 0;
• NCmaxquicksort(1) = 0;
• NCmaxquicksort(n) = n-1 + NCmax
quicksort(n-1), para n>1 (ou mesmo para n≥1)
e, usando p.ex. o método iterativo chega-se a (no caso do somatório, com n>1):
NCmaxquicksort(n) =
€
i =i=1
n−1∑ n(n −1)
2
Número médio de comparações
NCmedquicksort(n), abreviado por N(n), satisfaz:
• N(0) = 0
• N(1) = 0
• para n>1:
€
N (n) =
€
NC (i→ k) Pr ob(i=1
n∑ i→ k)
473
=
€
NC (i→ k) 1ni=1
n∑
=
€
1n
(n −1+ N (i −1)i=1
n∑ + N (n − i))
=
€
n −1+1n
N (i −1)i=1
n∑ +
1n
N (n − i)i=1
n∑
isto é,
€
N (n) =
€
n −1+2n
N ( j)j=0
n−1∑ (igualdade que também se verifica para n=1)
e “manipulando esta equação” chega-se a
•
€
N (n) =n +1n
N (n −1) +2n − 2n
, para n≥1
Definindo (tal como no caso anterior)
€
B(n) =N (n)n +1
, para n≥0
obtém-se:
• condição inicial: B(0) = 0• equação de recorrência:
€
B(n) = B(n −1) +2(n −1)(n +1)n
, para n≥1
chegando-se (aplicando o método iterativo) a
€
B(n) = =
€
2(Hn − 2 nn +1
) ≈ 2 lnn
E, portanto
€
N (n) =
€
(n +1)B(n) =
€
≈ 2(n +1) lnn ≈ 1.4(n +1) log2 n
(enquanto que no caso anterior se tinha
€
N (n)
€
≈ 2.1(n +1) log2 n ).
E com esta análise do Quicksort terminamos a introdução pretendida à análise da eficiência de
algoritmos, com o que concluímos este texto (seguindo-se apenas dois apêndices).
474
top related