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