1 Complexidade de tempo de algoritmos

Propaganda
Universidade Tecnológica Federal do Paraná
Professor Murilo V. G. da Silva
Notas de aula – Projeto e Análise de Algoritmos (Aula 01)
Conteúdos da Aula: [DPV06: 0.6, 2.1, 2.2][CLR09: cap. 1, 2, 3 e sec 4.1]
1
Complexidade de tempo de algoritmos
Começamos o curso com uma pergunta ao aluno: O que é a complexidade de tempo de um algoritmo?
Em linhas gerais, é uma medida do tempo (que basicamente reflete o número de operações executadas) que o
algoritmo leva para resolver o problema em questão em função do tamanho da entrada. Por exemplo:
1. O algoritmo “Bubblesort” ordena um vetor de tamanho n em aproximadamente n2 passos.
2. O algoritmo “Merge Sort” ordena um vetor de tamanho n em aproximadamente n log n passos.
3. O algoritmo de “força bruta” para testar se uma fórmula booleana com n variáveis é satisfazı́vel executa
aproximadamente 2n passos.
Pergunta 1: Para se ordenar um vetor de 1 milhão de posições, aproximadamente quantas vezes o Mergesort
é mais rápido do que o Bubblesort?
Pergunta 2: Quantos passos aproximadamente o algoritmo de força bruta leva para testar se uma fórmula
booleana com 1 milhão de variáveis é satisfazı́vel?
Notação assintótica: O , Ω e Θ .
Veremos que calcular o número exato de passos que um algoritmo executa é extremamente tedioso. Por
exemplo: Dissemos acima que o Bubblesort ordena um vetor de tamanho n em “aproximadamente” n2 passos.
Na realidade, se formos extremamente rigorosos, veremos que o número de passos pode ser algo mais especı́fico
como por exemplo 21 n2 + 12 n no caso da versão do Bubblesort abaixo:
Bubblesort: (A[1...N ])
1: for i = 1; i < N ; i++ do
2:
for j = N ; j < i; j– do
3:
CompExch(V [j − 1], V [j])
4:
end for
5: end for
Quando estamos analisando algoritmos e nos deparamos com uma função do tipo f (n) = cn2 + g(n) onde c
é uma constante e g(n) é uma função dominada assintoticamente por n2 , comumente ignoramos c e g(n). Neste
caso normalmente escrevemos f (n) = O(n2 ) (leia-se “f (n) é ó de n2 ”). Obviamente usamos o mesmo princı́pio
para qualquer outra função e não apenas funções quadráticas.
Na realidade, a notação O, chamada de notação “ó grande”, ou “big oh” em inglês, é muitas vezes informalmente usada como sinônimo da notação Θ (que veremos abaixo). Veremos que a notação O é uma notação para
dar um limitante superior a uma dada função e, portanto, poderı́amos a rigor dizer que a função 21 n2 + 12 n + 2 é
O(n5 ) ou mesmo O(2n ). Mas claro que em geral não fazemos isso, pois o que acontece é que em geral queremos
mostrar o limitante superior mais “apertado” que pudermos com a notação O.
Segue abaixo a definição formal para a notação O juntamente com as definições para suas “irmãs” Ω e Θ:
f (n) = O(g(n)) ⇔ ∃c, n0 ∈ N tal que ∀n ≥ n0 temos f (n) ≤ cg(n)
cg(n).
f (n) = Ω(g(n)) ⇔ ∃c, n0 ∈ N tal que ∀n ≥ n0 temos f (n) ≥ cg(n)
cg(n).
f (n) = Θ(g(n)) se e somente se f (n) = O(g(n)) e f (n) = Ω(g(n))
Ω(g(n)).
1
Complexidade de tempo exponencial: Too bad...
A maioria dos algoritmos vistos em cursos de computação tem complexidade de tempo polinomial, mas esse
não é sempre o caso. Existem alguns algoritmos em que o tempo de execução cresce exponencialmente em
função do tamanho da entrada. Isso é ruim? SIM! Isso ridiculamente ruim. Entretanto, para muitos problemas
interessantes os melhores algoritmos conhecidos tem esse comportamento exponencial. Na realidade a situação é
pior ainda, pois acredita-se que de fato não existam algoritmos polinomiais para tais problemas1 . Tais problemas
são os famosos problemas N P-completos.
2
Divisão e Conquista
No inı́cio deste curso vamos revisar brevemente algumas técnicas mais ou menos gerais para atacar problemas
computacionais. Estas técnicas, que esperamos que o alunos já deva estar familiarizado, é a estratégia de divisão
e conquista, a estratégia gulosa e programação dinâmica. Depois disso veremos outras técnicas como programação
linear, uso de aleatorização, algoritmos de aproximação outras estratégias para atacar problemas N P-completos.
Vamos começar com divisão e conquista. Pergunta para o aluno: Você sabe analisar algoritmos recursivos como
o algoritmo MergeSort2 abaixo?
Mergesort: (v[1...N ])
1: if n > 1 then
2:
Mergesort((v[1... N2 ])
3:
Mergesort(v[ N2 + 1...N ])
4:
Merge(v[1... N2 ], v[ N2 + 1...N ])
5: end if
6: Return (v[1...N ])
Merge: (A[1... N2 ], B[1... N2 ])
1: i = 1
2: j = 1
3: for k = 1; k ≤ N ; k++ do
4:
if A[i] ≤ B[j] then
5:
C[k] = A[i]
6:
i++
7:
else
8:
C[k] = B[j]
9:
j++
10:
end if
11: end for
12: Return C
Vamos tratar agora desta técnica genérica que pode ser usada para resolver vários problemas: Dividir o
problema em pedaços menores, resolvê-los separadamente e depois de alguma maneira juntar estas soluções.
O ponto principal é que para resolver os pedaços menores podemos usar a mesma idéia de divisão e conquista
recursivamente. Para isso vamos aprender como analisar a complexidade dos algoritmos recursivos advindos da
aplicação desta técnica. Além do Algoritmo Mergesort que vimos acima, o livro texto apresenta vários outros
algoritmos que também utilizam a técnica da divisão e conquista. Apresentamos abaixo dois deles, que são
algoritmos recursivos para resolver o problema da multiplicação de inteiros (em qualquer base).
1 Ninguém
ainda conseguiu provar formalmente que não existem algoritmos polinomiais para tais problemas, mas a comunidade
cientı́fica acredita que isso é verdade e, de fato, a prova formal desta afirmação e um dos maiores problemas em aberto da matemática
da atualidade.
2 Para simplificar a explicação nesta versão do problema assumimos que o tamanho do vetor de entrada é uma potência de 2.
2
Problema do subvetor máximo
(ver no livro CLR09, seção 4.1)
Multiplicação recursiva de inteiros
Dados dois inteiros x e y, definidos pelos bits (vamos nos ater a números binários, mas o princı́pio é o mesmo
para qualquer base) x = xn ...x1 e y = yn ...y1 . Para simplificar supomos n par. Seja xL o número definido pelos
n
n
2 bits da esquerda de x e xR o número definido pelos 2 bits da direita de x. Os números yR e yL são definidos
de maneira semelhante. Como x = 2n/2 xL + xR e y = 2n/2 yL + yR , temos que
xy = (2n/2 xL + xR )(2n/2 yL + yR )
= 2n xL yL + 2n/2 (xL yR + xR yL ) + xR yR
(1)
Pergunta: Você consegue ver um algoritmo de divisão e conquista escondido na fórmula acima?
Algoritmo de Karatsuba para multiplicação de inteiros
Não é difı́cil ver que podemos tomar equação (1) e fazer
2n xL yL + 2n/2 (xL yR + xR yL ) + xR yR = 2n xL yL + 2n/2 [(xL + xR )(yL + yR ) − xL yL − xR yR ) + xR yR
Pergunta: Você consegue ver um algoritmo com apenas três chamadas recursivas a partir da fórmula acima?
Recorrências
Segue as funções de complexidade de tempo do Mergesort, e dos dois demais algoritmos que vimos acima:
• Mergesort: T (n) = 2T ( n2 ) + O(n)
• Mult. recursiva: T (n) = 4T ( n2 ) + O(n)
• Karatsuba: T (n) = 3T ( n2 ) + O(n)
Enunciamos abaixo um teorema famoso e extremamente útil para calcular a complexidade de tempo de
algoritmos recursivos:
Teorema Mestre: Seja f uma função crescente que satisfaça a seguinte relação de recorrência:
f (n) = af (d nb e) + O(nd ) para constantes a > 0, b > 1 e d ≥ 0.
Então:

d

se a < bd
O(n )
f (n) = O(nd log n) se a = bd


O(nlogb a )
se a > bd
Esboço da prova (detalhes em sala): Para simplificar vamos assumir que n é potência de dois (não é difı́cil
ver que isso não influi no resultado, exceto por constantes multiplicativas). A partir do problema original de
tamanho n são feitas inicialmente a chamadas recursivas com subproblemas de tamanho nb . Podemos ver a
ramificação das chamadas recursivas como uma árvore a-ária, tal que na raiz, que chamaremos de nı́vel 0, temos
o problema original com tamanho n. Em seguida, no nı́vel 1 temos a subproblemas de tamanho nb . No nı́vel j
3
temos aj subproblemas de tamanho
recursivas) é dado por
n
bj .
O total de operações executadas no nı́vel j (sem contar as chamadas
total no nı́vel j ≤ aj c
n d
bj
Observe que escrevemos explicitamente a constante c advinda de O(nd ) na equação anterior. Agora somando
as operações executadas em todos os nı́veis j = 0, 1, ..., logb n temos que o total é
f (n) ≤ cnd
logb n X
j=0
a j
bd
Agora tudo depende de r = a/bd . Fazendo k = logb n temos três casos:
Caso 1: r < 1
d
f (n) ≤ cn
k
X
j
d
r = cn
j=0
1 − rk+1
1−r
d
< cn
1
1−r
= O(nd )
Caso 2: r = 1
f (n) ≤ cnd
k
X
1 = cnd (logb (n + 1)) = O(nd log n)
j=0
Caso 3: r > 1
k
X
1
rk+1 − 1
d
k
f (n) ≤ cn
= cn r 1 +
= cnd rk c1
r = cn
r
−
1
r
−
1
j=0
a logb n
a logb n
d
d
= cn
c1 = O n
bd
bd
d
j
d
Trabalhando um pouco temos
nd
a logb n
= nd alogb n b−d logb n = nd alogb n (blogb n )−d = nd alogb n n−d = alogb n
bd
= (blogb a )logb n = (blogb n )logb a = nlogb a
Portanto f (n) = O(nlogb a ) e assim finalizamos a prova do teorema mestre. 2
Aplicando o Teorema Mestre temos a complexidade de tempo dos algoritmos recursivos vistos anteriormente:
• Mergesort: T (n) = n log n
• Mult. recursiva: T (n) = n2
• Karatsuba: T (n) = n1.59
No livro texto da disciplina são apresentados mais dois exemplos clássicos de problemas que podem ser
resolvidos usando a técnica da divisão e conquista: Mutiplicação de Matrizes e a Transformada Rápida
de Fourier. Não vamos cobrir esses tópicos aqui, mas sugerimos que o aluno leia a respeito no livro texto.
4
Download