Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a...

56
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 outro 1 ? 1 Para além dos aspectos a seguir mencionados, refira-se ainda que poderá acontecer que “a ideia” que está por detrás de um programa seja até melhor do que a que está por detrás do outro, mas que essa ideia tenha sido num caso implementada de forma optimizada para um certo sistema computacional, ao passo que no outro caso pode estar “mal” implementada (não no sentido de não estar correcta – i.e. de não resolver o problema, mas sim no sentido de estar implementada de forma claramente pouco eficiente). Por exemplo, as funções predefinidas do sistema Mathematica foram construídas de forma totalmente optimizada, de modo a tirar o máximo partido das características desse sistema.

Transcript of Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a...

Page 1: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 2: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 3: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 4: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o 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.

Page 5: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 6: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 7: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 8: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 9: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 10: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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 !

Page 11: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 12: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 13: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 14: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 15: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

Page 16: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

Page 17: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 18: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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:

Page 19: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 20: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 21: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 22: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 23: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 24: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 25: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

Page 26: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

Page 27: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o 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).

Page 28: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 29: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 30: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 31: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 32: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 33: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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”.

Page 34: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

)

Page 35: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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] ]

]

]];

Page 36: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 37: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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)

Page 38: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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]).

Page 39: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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:

Page 40: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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+ε ) .

Page 41: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 42: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 43: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 44: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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)

Page 45: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 46: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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!

Page 47: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 48: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

Page 49: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 50: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 51: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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.

Page 52: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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∑ ) =

Page 53: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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

Page 54: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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)

Page 55: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

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).

Page 56: Capítulo 12 - cee.uma.ptcee.uma.pt/edu/ed/textosp/Texto12.pdf · descrever o tempo que demora a executar o algoritmo quando o seu (único) parâmetro num assume no início o valor

474