Análise de Algoritmos e Estruturas de Dados

Tamanho: px
Começar a partir da página:

Download "Análise de Algoritmos e Estruturas de Dados"

Transcrição

1 Análise de Algoritmos e Estruturas de Dados Guilherme Oliveira Mota CMCC - Universidade Federal do ABC g.mota@ufabc.edu.br 21 de julho de 2018 Esta versão é um rascunho ainda em elaboração e não foi revisado

2 ii

3 Sumário I Introdução à Análise de Algoritmos 1 1 Algoritmos: corretude e tempo de execução Algoritmos de busca em vetores Corretude de algoritmos (utilizando invariante de laços) Tempo de execução Análise de melhor caso, pior caso e caso médio Notação assintótica Notações O, Ω e Θ Notações o e ω Relações entre as notações assintóticas Recursividade / Divisão e Conquista Algoritmos recursivos Fatorial Busca binária Algoritmos recursivos algoritmos iterativos Divisão e conquista Métodos para solução de equações de recorrência Logaritmos e somas Método iterativo Limitantes assintóticos inferiores e superiores Método da substituição

4 iv SUMÁRIO Desconsiderando pisos e tetos Diversas formas de obter o mesmo resultado Ajustando os palpites Mais exemplos Método da árvore de recorrência Método mestre Resolvendo recorrências com o método mestre Ajustes para aplicar o método mestre II Estruturas de dados 51 4 Lista encadeada, fila e pilha Lista encadeada Pilha Fila Heap binário Construção de um heap binário Fila de prioridades 73 7 Union-find 77 III Algoritmos de ordenação 79 8 Insertion sort Corretude e tempo de execução Análise de melhor caso, pior caso e caso médio Uma análise mais direta Merge sort Selection sort e Heapsort Selection sort

5 SUMÁRIO v 10.2 Heapsort Quicksort Tempo de execução Ordenação em tempo linear Counting sort IV Técnicas de construção de algoritmos Programação dinâmica Um problema simples Aplicação e características principais Utilizando programação dinâmica Corte de hastes Comparando algoritmos top-down e bottom-up V Algoritmos em grafos Grafos Formas de representar um grafo Conceitos essenciais Trilhas, passeios, caminhos e ciclos Buscas Busca em largura Busca em profundidade Ordenação topológica Componentes fortemente conexas Outras aplicações dos algoritmos de busca Árvores geradoras mínimas Algoritmo de Prim Algoritmo de Kruskal

6 17 Caminhos mínimos Algoritmo de Dijkstra Algoritmo de Bellman-Ford Caminhos mínimos entre todos os pares de vértices Algoritmo de Floyd-Warshall Algoritmo de Johnson VI Teoria da computação Complexidade computacional Classes P, NP e co-np NP-completude vi

7 Parte I Introdução à Análise de Algoritmos

8

9 Capítulo 1 Algoritmos: corretude e tempo de execução Um algoritmo é um procedimento que recebe um conjunto de dados como entrada e devolve um conjunto de dados como saída após uma quantidade finita de passos bem definidos. Dizemos que um algoritmo resolve um problema se, para todas as entradas possíveis, ele produz uma saída que contém a solução do problema computacional em questão. Algoritmos estão presentes na vida das pessoas há muitos anos e são utilizados o tempo todo. Muitas vezes quando precisamos colocar um conjunto de fichas numeradas em ordem não-decrescente, ordenar um conjunto de cartas de baralho ou selecionar a cédula de maior valor em nossa carteira, inconscientemente nós utilizamos um algoritmo de nossa preferência para resolver o problema. Por exemplo, para colocar um conjunto de fichas numeradas em ordem não-decrescente há quem prefira olhar todas as fichas e encontrar a menor, depois verificar o restante das fichas e encontrar a menor e assim por diante. Outras pessoas preferem dividir as fichas em vários conjuntos menores de fichas, ordenar cada um desses conjuntos e depois juntá-los de modo que o conjunto todo fique ordenado. Existem diversas outras maneiras de fazer isso e cada uma delas é realizada por um procedimento que chamamos de algoritmo. Ao analisar um algoritmo estamos interessados primeiramente em entender os detalhes de como ele funciona, bem como em mostrar que, como esperado, o algoritmo funciona corretamente. Verificar se um algoritmo é eficiente é outro aspecto importantíssimo da análise de algoritmos. Explicaremos esses aspectos analisando o problema

10 de encontrar um valor em um vetor e analisar algoritmos simples que resolvem esse problema. 1.1 Algoritmos de busca em vetores Vetores são estruturas de dados simples que armazenam um conjunto de objetos, geralmente do mesmo tipo, armazenados de forma contínua na memória. O acesso a um elemento do vetor é feito de forma direta, através do índice do elemento. Um vetor A com capacidade para n elementos é representado por A[1..n] e A[i] retorna o elemento contido na posição i, para todo 1 i n. Ademais, para quaisquer 1 i < j n, denotamos por A[i..j] o subvetor de A que contém os elementos A[i], A[i + 1],..., A[j]. Uma operação fundamental e de extrema importância em diversos procedimentos computacionais é a busca por uma informação específica em um conjunto de dados. Primeiramente, considere um vetor A[1..n] não ordenado contendo números reais. Gostaríamos de saber se um valor x está dentro de A. O algoritmo mais simples é conhecido como Busca linear. Esse algoritmo percorre o vetor, examinando todos os seus elementos, um a um, até encontrar x ou até verificar todos os elementos de A. Algoritmo 1: Busca linear(a[1..n], x) 1 i = 1 2 enquanto i n faça 3 se A[i] == x então 4 retorna i 5 i = i retorna 1 No que segue, seja tamanho(a) = n. O funcionamento do algoritmo Busca linear é bem simples. A variável i indica que posição do vetor A estamos analisando. Inicialmente fazemos i = 1. Incrementamos o valor de i de uma unidade sempre que as duas condições do laço enquanto forem satisfeitas, i.e., A[i] x e i n. Assim, o laço enquanto simplesmente verifica se A[i] contém x e se o vetor A já foi totalmente verificado. Caso x seja encontrado, o laço enquanto é encerrado e o algoritmo retorna 4

11 o índice i tal que A[i] = x. Caso contrário, o algoritmo retorna 1. Intuitivamente, é fácil perceber que Busca linear funciona corretamente. Mas como podemos ter certeza que o comportamento de Busca linear é sempre como esperamos que seja? Na próxima seção veremos uma forma de provar que algoritmos funcionam corretamente. Antes, vejamos outra forma de resolver o problema de encontrar um valor em um vetor A dado que A está ordenado. Considere um vetor ordenado (ordem não-decrescente 1 ) A com n elementos, i.e., A[i] A[i + 1] para todo 1 i n 1. Por simplicidade, assuma que n é múltiplo de 2 (assim não precisamos nos preocupar com pisos e tetos). Nesse caso, existe um procedimento simples, chamado de busca binária, que consegue realizar a busca por uma chave x em A. A estratégia da busca binária é muito simples. Basta verificar se A[n/2] = x e realizar o seguinte procedimento: se A[n/2] = x, então a busca está encerrada. Caso contrário, se x < A[n/2], então temos a certeza que se x estiver em A, então x está na primeira metade de A, i.e., x está em A[1... n/2 1] (isso segue do fato de A estar ordenado). Caso x > A[n/2], então sabemos que se x estiver em A, então x está no vetor A[n/ n]. Suponha que x < A[n/2]. Assim, podemos verificar se x está em A[1... n/2 1] utilizando a mesma estratégia, i.e., comparamos x com o valor que está na metade do vetor A[1... n/2 1], i.e., comparamos x com A[n/4 2] e verificamos a primeira ou segunda metade do vetor dependendo do resultado da comparação. Abaixo temos o algoritmo de busca binária, que recebe um vetor A[1..n] ordenado de modo não-decrescente e um valor x a ser buscado. 1 Aqui utilizamos o termo não-decrescente em vez de crescente para indicar que podemos ter A[i] = A[i + 1]. 5

12 Algoritmo 2: Busca binária(a[1..n], x) 1 esquerda = 1 2 direita = n 3 enquanto esquerda direita faça 4 meio = esquerda + direita esquerda 2 5 se A[meio] == x então 6 retorna meio 7 senão se x < A[meio] então 8 esquerda = meio senão 10 direita = meio 1 11 retorna Corretude de algoritmos (utilizando invariante de laços) Ao utilizar um algoritmo para resolver um determinado problema esperamos que ele dê sempre a resposta correta. Como analisar se um algoritmo é executado corretamente? A seguir veremos uma maneira de mostrar que algoritmos funcionam corretamente. Basicamente, mostraremos que o algoritmo possui certas propriedades e tais propriedades continuam verdadeiras após cada iteração de um determinado laço (para ou enquanto). Uma invariante de laço é um conjunto de propriedades do algoritmo que se mantém após iterações do laço. Mais formalmente, uma invariante de laço é definida como abaixo. 6

13 Definição 1.1: Invariante de laço É um conjunto de propriedades (a invariante) tal que valem os itens abaixo. (i) a invariante é verdadeira imediatamente antes da primeira iteração do laço, (ii) se a invariante é verdadeira antes de uma iteração, então é verdadeira imediatamente antes da próxima iteração. Para ser útil, uma invariante de laço precisa permitir que após a última iteração do laço possamos concluir que o algoritmo funciona corretamente utilizando essa invariante. Uma observação importante é que quando dizemos imediatamente antes de uma iteração estamos nos referindo ao momento imediatamente antes de iniciar a linha correspondente ao laço. Para entender como podemos utilizar as invariantes de laço para provar a corretude de algoritmos vamos inicialmente fazer a análise dos algoritmos Busca linear. Comecemos com o algoritmo Busca linear, considerando a seguinte invariante de laço: Invariante: Busca linear Antes de cada iteração indexada por i, o vetor A[1..i 1] não contém x. Observe que o item (i) na definição de invariante é trivialmente válido antes da primeira iteração, quando i = 1, pois nesse caso a invariante trata de A[0], que não existe. Logo, não pode conter x. Para verificar o item (ii), suponha agora que o vetor A[1... i 1] não contém x e o laço enquanto termina a execução de sua i-ésima iteração. Como a iteração foi terminada, isso significa que a linha 4 não foi executada. Portanto, A[i] x. Esse fato, juntamente com o fato de que x / A[1... i 1], implica que x / A[1,..., i]. Assim, a invariante continua válida antes da (i + 1)-ésima iteração. Precisamos agora utilizar a invariante para concluir que o algoritmo funciona corretamente, i.e., caso x esteja em A o algoritmo deve retornar um índice i tal que A[i] = x, e caso x não esteja em A o algoritmo deve retornar 1. Mas note que se o algoritmo retorna i na linha 4, então a comparação na linha 3 é verificada com sucesso, de modo que temos A[i] = x como desejado. Porém, se o algoritmo retorna 1, então 7

14 o laço enquanto foi executado até que i = n + 1. Assim, na última vez que a linha que contém o laço enquanto é verificada, temos i = n + 1. Pela invariante de laço, sabemos que x / A[1... i 1], i.e., x / A[1..n]. Na última linha o algoritmo retorna 1, que era o desejado no caso em que x não está em A. Portanto, o algoritmo funciona corretamente. À primeira vista todo o processo que fizemos para mostrar que o algoritmo Busca linear funciona corretamente pode parecer excessivamente complicado. Porém, essa impressão vem do fato desse algoritmo ser muito simples. Veremos casos onde a corretude de um dado algoritmo não é clara, de modo que a necessidade de se utilizar invariantes de laço é evidente. Para clarear nossas ideias, analisaremos agora o seguinte algoritmo que realiza uma tarefa muito simples: recebe um vetor A[1..n] e retorna o produtório de seus elementos, i.e., n i=1 A[i]. Algoritmo 3: Produtório(A[1..n]) 1 produto = 1 2 para i = 1 até tamanho(a) faça 3 produto = produto A[i] 4 retorna produto Como podemos definir a invariante de laço para mostrar a corretude de Produtório(A[1..n])? A cada iteração do laço para nós ganhamos mais informação. Precisamos entender como essa informação ajuda a obter a saída desejada do algoritmo. No caso de Produtório, conseguimos perceber que ao fim da i-ésima iteração temos o produtório dos elementos de A[1..k]. Isso é muito bom, pois podemos usar esse fato para ajudar no cálculo do produtório dos elementos de A[1..n]. De fato, a cada iteração caminhamos um passo no sentido de calcular o produtório desejado. Não é difícil perceber que a seguinte invariante é uma boa opção para mostrar que Produtório funciona. 8

15 Invariante: Produtório Antes de cada iteração indexada por i, a variável produto contém o produtório 9

16 dos elementos de A[1..i 1]. Trivialmente a invariante é válida antes da primeira iteração do laço para, de modo que o item (i) da definição de invariante de laço é válido. Para verificar o item (ii), suponha que a invariante seja válida antes da iteração i, i.e., produto = i 1 j=1 A[j] e considere o momento imediatamente antes da iteração i + 1. Dentro da i-ésima iteração do laço para vamos obter produto = produto A[i] (1.1) ( i 1 ) = A[j] A[i] (1.2) = j=1 i A[j], (1.3) j=1 confirmando a validade do item (ii), pois mostramos que a invariante se manteve válida após a i-ésima iteração. Note que na última vez que a linha 2 do algoritmo é executada temos i = n + 1. Assim, o algoritmo não executa a linha 3, e retorna produto. Como a invariante é válida, temos que produto = n i=1 A[i], que é o resultado desejado. Portanto, o algoritmo funciona corretamente. Na próxima seção discutimos o tempo que algoritmos levam para ser executados, entendendo como analisar algoritmos de uma maneira sistemática para determinar quão eficiente eles são. 1.2 Tempo de execução Uma propriedade desejável para um algoritmo é que ele seja eficiente. Apesar de intuitivamente associarmos a palavra eficiente nesse contexto com o significado de velocidade em que um algoritmo é executado, precisamos discutir alguns pontos para deixar claro o que seria um algoritmo eficiente. Um algoritmo será mais rápido quando implementado em um computador mais potente do que quando implementado em um computador menos potente. Se a entrada for pequena, o algoritmo provavelmente será executado mais rapidamente do que se a entrada fosse muito grande. Vários fatores 10

17 afetam o tempo de execução de um algoritmo. Por exemplo, o sistema operacional utilizado, linguagem de programação utilizada, velocidade do processador, modo com o algoritmo foi implementado, dentre outros. Assim, queremos um conceito de eficiência que seja independente da entrada, da plataforma utilizada e que possa ser de alguma forma quantificada concretamente de acordo com o tamanho da entrada. Para analisar a eficiência de um algoritmo vamos analisar o seu tempo de execução, que conta a quantidade de operações primitivas (operações aritméticas, comparações etc.) e passos executados. Dessa forma é possível ter uma boa estimativa do quão rápido um algoritmo é, além de permitir comparar seu tempo de execução com o de outros algoritmos, o que nos permite escolher o mais eficiente para uma determinada tarefa. Em geral, o tempo de execução de um algoritmo cresce junto com a quantidade de dados passados como entrada. Portanto, definimos o tempo de execução como uma função no tamanho da entrada. Para entender melhor vamos começa com uma análise simples dos algoritmos Busca linear e Busca binária vistos anteriormente. Veremos adiante que não é tão importante para a análise do tempo de execução de um algoritmo se uma dada operação primitiva leva um certo tempo t para ser executada ou não. Assim, vamos assumir que toda operação primitiva leva tempo 1 para ser executada. Por comodidade, repetimos o algoritmo Busca linear abaixo. Algoritmo 4: Busca linear(a[1..n], x) 1 i = 1 2 enquanto i tamanho(a) faça 3 se A[i] == x então 4 retorna i 5 i = i retorna 1 Denote por t x a posição do elemento x no vetor A[1..n], onde colocamos t x = n + 1 caso x não esteja em A. Note que a linha 1 é executada somente uma vez e somente uma dentre as linhas 4 e 6 é executada (obviamente, somente uma vez, dado que o algoritmo encerra quando retorna um valor). Já o laço enquanto da linha 2 é executado 11

18 t x vezes, a linha 3 é executada t x vezes, e a linha 5 é executada t x 1 vezes. Assim, o tempo de execução T (n) de Busca linear(a[1..n], x) é dado como abaixo (note que o tempo de execução depende do tamanho n do vetor de entrada A). T (n) = t x + t x + t x 1 = 3t x + 1. (1.4) O tempo de execução depende de onde x se encontra no vetor A. Se A contém n elementos e x está na última posição de A, então T (n) = 3n + 1. Porém, se x está na primeira posição de A, temos T (n) = 4. Para a busca binária, vamos fazer uma análise semelhante. Por comodidade, repetimos o algoritmo Busca binária abaixo. Lembre-se que na busca binária assumimos que o vetor está ordenado de modo não decrescente. Algoritmo 5: Busca binária(a[1..n], x) 1 esquerda = 1 2 direita = tamanho(a) 3 enquanto esquerda direita faça 4 meio = esquerda + direita esquerda 2 5 se A[meio] == x então 6 retorna meio 7 senão se x < A[meio] então 8 esquerda = meio senão 10 direita = meio 1 11 retorna 1 Denote por r x a quantidade de vezes que o laço enquanto na linha 3 é executado (note que isso depende de onde x está em A). As linhas 1 e 2 são executadas uma vez cada, e somente uma das linhas 6 e 11 é executada. A linha 4 é executada no máximo r x vezes, as linhas 5, 7 e 9 são executadas um total de no máximo r x vezes 12

19 (pois em cada iteração do laço somente uma delas é executada) e as linhas 8 e 10 são executadas (no total) no máximo r x vezes. Assim, o tempo de execução T (n) de Busca binária(a[1..n], x) é dado como abaixo. T (n) r x r x + r x + r x = 4r x + 3. (1.5) Assim como na busca linear, o tempo de execução depende do tamanho da entrada. Se x está na primeira ou última posição do vetor, note que o algoritmo de busca binária sempre descarta metade do vetor que está sendo considerado, diminuindo o tamanho do vetor analisado pela metade, até que se chegue em um vetor com uma única posição (ou duas, dependendo da paridade de n). Como sempre metade do vetor é descartado, o algoritmo analisa, nessa ordem, vetores de tamanho n, n/2, n/ n/2 i, onde o último vetor analisado tem tamanho 1, i.e., temos n/2 i = 1, que implica i = log n. Assim, o laço enquanto é executado no máximo log n vezes, de modo que temos r x log n. Assim, temos T (n) 4 log n Análise de melhor caso, pior caso e caso médio O tempo de execução de melhor caso de um algoritmo é o tempo de execução da instância de entrada que executa de forma mais rápida, dentre todas as instâncias possíveis. No caso da Busca linear, o melhor caso ocorre quando o elemento x a ser buscado encontra-se na primeira posição do vetor A. Como o tempo de execução de Busca linear é dado por T (n) = 3t x + 1 (veja (1.4)), onde t x é a posição de x em A, temos que no melhor caso, o tempo de execução é T (n) = 4. Já no caso da Busca binária, o melhor caso ocorre quando x está exatamente na metade do vetor A, i.e., A [ (direita esquerda)/2 ] = x. Nesse caso, o laço enquanto é executado somente uma vez, de modo que o tempo de execução é dado como abaixo 13

20 (veja (1.5)). T (n) 4r x + 3 = 7. Geralmente estamos interessados no tempo de execução de pior caso do algoritmo, isto é, o maior tempo de execução do algoritmo dentre todas as entradas possíveis de um dado tamanho n. A análise de pior caso é muito importante, pois limita superiormente o tempo de execução para qualquer entrada, garantindo que o algoritmo nunca vai demorar mais do que esse limite. Outra razão para a análise de pior caso ser considerada é que para alguns algoritmos, o pior caso (ou algum caso próximo do pior) ocorre com muita frequência. O pior caso da Busca linear a da Busca binária ocorre quando o elemento x a ser buscado não se encontra no vetor A, pois a busca linear precisa percorrer todo o vetor, e a busca binária vai subdividir o vetor até que não seja possível. No caso da busca linear, o tempo de execução do pior caso é dado por T (n) = 3(n + 1) + 1 = 3n + 4. Já a busca binária é executada em tempo T (n) 4 log n + 3. O tempo de execução do caso médio de um algoritmo é a média do tempo de execução dentre todas as entradas possíveis de um dado tamanho n. Por exemplo, para os algoritmos de busca, por simplicidade assuma que x está em A. Agora considere que quaisquer das n! permutações dos n elementos de A tem a mesma chance de ser passado como o vetor de entrada. Note que, nesse caso, cada número tem a mesma probabilidade de estar em quaisquer das n posições do vetor. Assim, em média, a posição t x de x em A é dada por ( n)/n = (n + 1)/2. Logo, o tempo médio de execução da busca linear é dado por T (n) = 3t x + 1 = 3n O tempo de execução de caso médio da busca binária envolve calcular a média de r x dentre todas as ordenações possíveis do vetor, onde lembre-se que r x é a quantidade de vezes que o laço é executado na busca binária. Calcular precisamente essa média 14

21 não é difícil, mas vamos evitar essa tecnicalidade nesse momento, apenas mencionando que no caso médio, o tempo de execução da busca binária é dado por c log n, para alguma constante c (número que não é uma função de n). Muitas vezes o tempo de execução no caso médio é quase tão ruim quanto no pior caso. No caso das buscas, vimos que a busca linear tem tempo de execução 3n + 4 no pior caso, e (3n + 5)/2 no caso médio, ambos da forma an + b, para constantes a e b. Assim, ambos possui tempo de execução linear no tamanho da entrada. Mas é necessário deixar claro que esse nem sempre é o caso. Por exemplo, seja n o tamanho de um vetor que desejamos ordenar. Um algoritmo de ordenação chamado Quicksort tem tempo de execução de pior caso quadrático em n (i.e., da forma an 2 + bn + c, para constantes a, b e c), mas em média o tempo gasto é da ordem de n log n, que é muito menor que uma função quadrática em n para valores grandes de n. Embora o tempo de execução de pior caso do Quicksort seja pior do que de outros algoritmos de ordenação (e.g., Mergesort, Heapsort), ele é comumente utilizado, dado que seu pior caso raramente ocorre. Por fim, vale mencionar que nem sempre é simples descrever o que seria uma entrada média para um algoritmo, e análises de caso médio são geralmente mais complicadas que análises de pior caso. 1.3 Notação assintótica Uma abstração que ajuda bastante na análise do tempo de execução de algoritmos é o estudo da taxa de crescimento de funções. Esse estudo nor permite comparar tempo de execução de algoritmos independentemente da plataforma utilizada, da linguagem etc. Se um algoritmo leva tempo f(n) = an 2 + bn + c para ser executado, onde a, b e c são constantes e n é o tamanho da entrada, o termo que realmente importa para grandes valores de n é an 2. Ademais, as constantes também podem ser desconsideradas, de modo que o tempo de execução nesse caso seria da ordem de n 2. Por exemplo, para n = 1000 e a = b = c = 2, temos an 2 + bn + c = = e n 2 = Estamos interessados no que acontece com f(n) quando n tende a infinito, o que chamamos de análise assintótica de f(n). 15

22 1.3.1 Notações O, Ω e Θ Dado um inteiro positivo n e uma função f(n), que aqui tem o papel do tempo de execução de algoritmos, começamos definindo as notações assintóticas O(f(n)) e Ω(f(n)) abaixo, que nos ajudaram, respectivamente, a limitar superiormente e inferiormente o tempo de execução dos algoritmos. Definição 1.1: Notações O e Ω Dadas funções positivas f(n) e g(n), dizemos que f(n) = O(g(n)) se existem constantes positivas C e n 0 tais que f(n) Cg(n) para todo n n 0 ; f(n) = Ω(g(n)) se existem constantes positivas c e n 0 tais que cg(n) f(n) para todo n n 0. Em outras palavras, f(n) = O(g(n)) quando para todo n suficientemente grande, a função f(n) é limitada superiormente por Cg(n). Por outro lado, f(n) = Ω(g(n)) quando para todo n suficientemente grande, f(n) é limitada inferiormente por cg(n). Como a notação O(g(n)) fornece um limitante superior, ela é comumente utilizada na análise de pior caso de algoritmos, fornecendo um limitante superior para todas as instâncias possíveis. Por outro lado, se um algoritmo possui tempo de execução Ω(g(n)) para uma instância de melhor caso, então temos a certeza que o algoritmo executa em tempo assintoticamente pelo menos g(n) para todas as instâncias possíveis. Dada uma função f(n), se f(n) = O(g(n)) e f(n) = Ω(g(n)), então dizemos que f(n) = Θ(g(n)). Formalmente, Definição 1.2: Notação Θ Dadas funções positivas f(n) e g(n), dizemos que f(n) = Θ(g(n)) se existem constantes positivas c, C e n 0 tais que cg(n) f(n) Cg(n) para todo n n 0. Vamos trabalhar com alguns exemplos para entender melhor as notações O, Ω e Θ. 16

23 f(n) = 10n 2 + 5n + 3. Nesse caso temos f(n) = O(n 2 ) e f(n) = Ω(n 2 ). Vamos mostrar primeiro que f(n) = O(n 2 ). Se tomarmos n 0 = 1, então note que como queremos f(n) Cn para todo n n 0 = 1, queremos obter um C tal que 10n 2 + 5n + 3 Cn 2. Mas então basta que Mas para n 1, temos C 10n2 + 5n + 3 n 2 = n + 3 n n = 18. n2 Logo, tomando n 0 = 1 e C = 18, concluímos que f(n) Cn 2 para todo n n 0. Agora vamos verificar que f(n) = Ω(n 2 ). Se tomarmos n 0 = 1, então note que como queremos f(n) cn para todo n n 0 = 1, queremos obter um C tal que 10n 2 + 5n + 3 cn 2. Mas então basta que c n + 3 n 2. Mas para n 1, temos n + 3 n Logo, tomando n 0 = 1 e c = 10, concluímos que f(n) cn 2 para todo n n 0. Como mostramos que f(n) = O(n 2 ) e f(n) = Ω(n 2 ), então concluímos que f(n) = Θ(n 2 ). Considere agora o seguinte exemplo. f(n) = 5 log n + n. Não é difícil ver que f(n) = O(n). Porém, é possível melhorar esse limitante para f(n) = O( n). De fato, basta obter C e n 0 tal que para n n 0 temos 5 log n + n C n. Logo, queremos que C 5 log n n + 1. Mas para todo n 16, temos (log n)/ n 1. Assim, (5 log n)/ n Portanto, tomando n 0 = 16 e C = 6, mostramos que f(n) = O( n). Lembre-se que podem existir diversas possibilidades de escolha para n 0 e C. Por exemplo, n 0 = 3454 e C = 2 17

24 também funcionam para mostrar que 5 log n + n = O( n). Outra escolha possível seria n = 1 e C = 11. Não é difícil mostrar que f(n) = Ω( n). Outros exemplos de limitantes seguem abaixo, onde a e b são inteiros positivos. log a n = Θ(log b n). log a n = O(n ε ) para qualquer ε > 0. (n + a) b = Θ(n b ). 2 n+a = Θ(2 n ). 2 an O(2 n ). 7n 2 O(n). Vamos utilizar a definição da notação assintótica para mostrar que 7n 2 O(n). Fato 1.3 7n 2 O(n) Demonstração. Lembre que f(n) = O(g(n)) se existem constantes positivas C e n 0 tais que se n n 0, então 0 f(n) Cg(n). Suponha por contradição que 7n 2 = O(n), i.e., existem constantes positivas C e n 0 tais que se n n 0, então 7n 2 Cn. Logo, temos n C/7 para todo n n 0, um absurdo, pois claramente isso não é verdade para valores de n maiores que C/ Notações o e ω Apesar das notações assintóticas descritas até aqui fornecerem informações importantes acerca do crescimento das funções, muitas vezes elas não são tão precisas quanto gostaríamos. Por exemplo, temos 2n 2 = O(n 2 ) e 4n = O(n 2 ). Apesar dessas duas funções terem ordem de complexidade O(n 2 ), somente a primeira é justa. para descrever melhor essa situação, temos as notações o-pequeno e ω-pequeno. Dizemos que 18

25 Definição 1.4: Notações o e ω Dadas funções f(n) e g(n), dizemos que f(n) = o(g(n)) se para toda constante c > 0 existe n 0 > 0 tal que 0 f(n) < cg(n) para todo n n 0 ; f(n) = ω(g(n)) se para toda constante C > 0 existe n 0 f(n) > Cg(n) 0 para todo n n 0. > 0 tal que Por exemplo, 2n = o(n 2 ) mas 2n 2 o(n 2 ). O que acontece é que se f(n) = o(g(n)), então f(n) é insignificante com relação a g(n) para n grande. Alternativamente, podemos dizer que f(n) = o(g(n)) quando lim n (f(n)/g(n)) = 0. Por exemplo, 2n 2 = ω(n) mas 2n 2 ω(n 2 ). Vamos ver um exemplo para ilustrar como podemos mostrar que f(n) = o(g(n)) para duas funções f e g. Fato n + 3 log n = o(n 2 ). Demonstração. Seja f(n) = 10n + 3 log n. Precisamos mostrar que para qualquer constante positiva c existe um n 0 tal que 10n + 3 log n < cn 2 para todo n n 0. Assim, seja c > 0 uma constante qualquer. Primeiramente note que 10n + 3 log n < 13n e que se n > 13/c, então 10n + 3 log n < 13n < cn. Portanto, acabamos de provar o que precisávamos (com n 0 = 13/c + 1). Note que com uma análise similar a feita na prova acima podemos provar que 10n + 3 log n = o(n 1+ε ) para todo ε > 0. Basta que, para todo c > 0, façamos n > (13/c) 1/ε. Outros exemplos de limitantes seguem abaixo, onde a e b são inteiros positivos. log a n o(log b n). log a n ω(log b n). 19

26 log a n = o(n ε ) para qualquer ε > 0. an = o(n 1+ε ) para qualquer ε > 0. an = ω(n 1 ε ) para qualquer ε > n 2 = o((log n)n 2 ) Relações entre as notações assintóticas Muitas dessas comparações assintóticas tem propriedades importantes. No que segue sejam f(n), g(n) e h(n) assintoticamente positivas. Todas as cinco notações descritas são transitivas, e.g., se f(n) = O(g(n)) e g(n) = O(h(n)), então temos f(n) = O(h(n)). Reflexividade vale para O, Ω e Θ, e.g., f(n) = O(f(n)). Temos também a simetria com a notação Θ, i.e., f(n) = Θ(g(n)) se e somente se g(n) = Θ(f(n)). Por fim, a simetria transposta vale para os pares {O, Ω} e {o, ω}, i.e., f(n) = O(g(n)) se e somente se g(n) = Ω(f(n)), e f(n) = o(g(n)) se e somente se g(n) = ω(f(n)). 20

27 Capítulo 2 Recursividade / Divisão e Conquista Ao desenvolver um algoritmo, muitas vezes precisamos executar uma tarefa repetidamente, utilizando para isso estruturas de repetição para ou enquanto. Algumas vezes precisamos tomar decisões condicionais, utilizando operações da forma se...senão...então para isso. Em geral, todas essas operações são rapidamente assimiladas pois fazem parte do cotidiano de qualquer pessoa, dado que muitas vezes precisamos tomar decisões condicionais ou executar tarefas repetidamente. Porém, para desenvolver alguns algoritmos será necessário fazer uso da recursão. Essa técnica de solução de problemas resolve problemas grandes através de sua redução em problemas menores do mesmo tipo, que por sua vez são reduzidos e assim por diante, até que os problemas sejam tão pequenos que podem ser resolvidos diretamente. Diversos problemas têm a seguinte característica: toda instância do problema contém uma instância menor do mesmo problema (estrutura recursiva). Esses problemas podem ser resolvidos com os passos a seguir. (i) Se a instância for suficientemente pequena, resolva o problema diretamente, (ii) caso contrário, divida a instância em instâncias menores, resolva-as utilizando os passos (i) e (ii) e retorne à instância original. Um algoritmo que aplica o método acima é chamado de algoritmo recursivo. No que segue, vamos analisar alguns exemplos de algoritmos recursivos para entender melhor

28 como funciona a recursividade. 2.1 Algoritmos recursivos Uma boa forma de entender melhor a recursividade é através da análise de alguns exemplos. Vamos mostrar como executar procedimentos recursivos para calcular o fatorial de um número e para encontrar um elemento em um vetor ordenado Fatorial Uma função bem conhecida na matemática é o fatorial de um inteiro não negativo n. A função fatorial, denotada por n!, é definida como o produto dos inteiros entre 1 e n, onde assumimos 0! = 1. Mas note que podemos definir n! da seguinte forma recursiva: 0! = 1, n! = n (n 1)! para n > 0. Essa definição inspira o seguinte simples algoritmo recursivo. Algoritmo 6: Fatorial(n) 1 se n = 0 então 2 retorna 1 3 senão 4 retorna n Fatorial(n 1) Por exemplo, ao chamar Fatorial(3) o algoritmo vai executar a linha 4, retornando 3 Fatorial(2). Nesse ponto o computador salva o estado atual na pilha de recursão e faz uma chamada a Fatorial(2), que vai executar a linha 4 novamente e retornar 2 Fatorial(1), Novamente o estado atual é salvo na pilha de recursão e uma chamada a Fatorial(1) é realizada. Essa chamada recursiva será a última, 22

29 pois nesse ponto a linha 2 será executada e essa chamada retorna 1. Assim, a pilha de recursão começa a ser desempilhada, e o resultado final será 3 (2 (1 1)). Pelo exemplo do parágrafo anterior, conseguimos perceber que a execução de um programa recursivo precisa salvar vários estados do programa ao mesmo tempo, de modo que isso aumenta o uso de memória. Por outro lado, muitas vezes uma solução recursiva é bem mais simples que uma iterativa correspondente Busca binária Considere um vetor ordenado (ordem crescente) A com n elementos. Nesse caso, podemos facilmente desenvolver uma variação recursiva do algoritmo Busca binária que consegue realizar (como na versão iterativa) a busca por uma chave x em A em tempo O(log n) no pior caso. A estratégia é muito simples. Basta verificar se A[ n/2 ] = x e realizar o seguinte: se A[ n/2 ] = x, então a busca está encerrada. Caso contrário, se x < A[ n/2 ], então basta verificar recursivamente o vetor A[1,..., n/2 1]. Se x > A[ n/2 ], então verifica-se recursivamente o vetor A[ n/2 + 1,..., n]. Como esse procedimento analisa, passo a passo, somente metade do tamanho do vetor do passo anterior, seu tempo de execução é O(log n). Para executar o algoritmo abaixo basta fazer Busca binária - Recursiva(A[1..n], 1, n, x). Algoritmo 7: Busca binária - Recursiva(A[1..n], inicio, f im, x) 1 se inicio > fim então 2 retorna 1 3 meio = inicio + fim inicio 2 4 se A[meio] == x então 5 retorna meio 6 senão se x < A[meio] então 7 Busca binária - Recursiva(A[1..n], inicio, meio 1, x) 8 senão 9 Busca binária - Recursiva(A[1..n], meio + 1, fim, x) 23

30 2.1.3 Algoritmos recursivos algoritmos iterativos Mas quando utilizar um algoritmo recursivo ou um algoritmo iterativo? Vamos discutir algumas vantagens e desvantagens de cada tipo de procedimento. A utilização de um algoritmo recursivo tem a vantagem de, em geral, ser simples e oferecer códigos claros e concisos. Assim, alguns problemas que podem parecer complexos de início, acabam tendo uma solução simples e elegante, enquanto que algoritmos iterativos longos requerem experiência por parte do programador para serem entendidos. Por outro lado, uma solução recursiva pode ocupar muita memória, dado que o computador precisa manter vários estados do algoritmo gravados na pilha de execução do programa. Muitas pessoas acreditam que algoritmos recursivos são, em geral, mais lentos do que algoritmos iterativos para o mesmo problema, mas a verdade é que isso depende muito do compilador utilizado e do problema em si. Alguns compiladores conseguem lidar de forma rápida com as chamadas a funções e com o gerenciamento da pilha de execução. Algoritmos recursivos eliminam a necessidade de se manter o controle sobre diversas variáveis que possam existir em um algoritmo iterativo para o mesmo problema. Porém, pequenos erros de implementação podem levar a infinitas chamadas recursivas, de modo que o programa não encerraria sua execução. Nem sempre a simplicidade de um algoritmo recursivo justifica o seu uso. Um exemplo claro é dado pelo problema de se calcular termos da sequência de Fibonacci (1, 1, 2, 3, 5, 8, 13, 21, 34,...). O seguinte algoritmo ilustra quão ineficiente um algoritmo recursivo pode ser. Algoritmo 8: Fibonacci(n) 1 se n 2 então 2 retorna 1 3 retorna Fibonacci(n 1) + Fibonacci(n 2) 24

31 Apesar de sua simplicidade, o procedimento acima é muito ineficiente. Seu tempo de execução é dado por T (n) = T (n 1) + T (n 2) + 1, que é exponencial em n. É possível implementar um algoritmo iterativo simples que é executado em tempo linear. Isso ocorre porque na versão recursiva muito trabalho repetido é feito pelo algoritmo. De fato, quando Fibonacci(n 1) + Fibonacci(n 2) é executado, além da chamada a Fibonacci(n 2) que é feita, a chamada a Fibonacci (n 1) fará mais uma chamada a Fibonacci (n 2), mesmo que ele já tenho sido calculado antes, e esse fenômeno cresce exponencialmente até chegar à base da recursão. Na Parte III veremos diversos algoritmos recursivos para resolver o problema de ordenação dos elementos de um vetor. Ao longo deste livro muitos outros algoritmos recursivos serão discutidos. 2.2 Divisão e conquista Divisão e conquista é um paradigma para o desenvolvimento de algoritmos que faz uso da recursividade. Para resolver um problema utilizando esse paradigma, seguimos os três seguintes passos. O problema é dividido em subproblemas menores; Os subproblemas menores são resolvidos recursivamente: cada um desses subproblemas menores é divido em subproblemas ainda menores, a menos que sejam tão pequenos a ponto de ser simples resolvê-los diretamente; Soluções dos subproblemas menores são combinadas para formar uma solução do problema inicial. Essa parte ainda será escrita... 25

32 26

33 Capítulo 3 Métodos para solução de equações de recorrência Relações como T (n) = 2T (n/2) + n ou T (n) = T (n/3) + T (n/4) + 3 log n são chamadas de recorrências, definidas como equações ou inequações que descrevem uma função em termos de seus valores para entradas menores. Apresentaremos quatro métodos para resolução de recorrências: (i) iterativo, (ii) árvore de recorrência, (iii) substituição e (iv) mestre. Antes de discutirmos os métodos de resolução de recorrências, apresentamos na próxima seção algumas relações matemáticas e somas que surgem com frequência na resolução de recorrências. O leitor familiarizado com os conceitos apresentados deve seguir para a seção seguinte, que explica o método iterativo. 3.1 Logaritmos e somas Como recorrências são funções definidas recursivamente em termos da própria função para valores menores, se expandirmos recorrências até que cheguemos ao caso base da recursão, muitas vezes teremos realizado uma quantidade logarítmica de passos recursivos. Assim, é natural que termos logarítmicos apareçam durante a resolução de recorrências. Assim, abaixo listamos as propriedades mais comuns envolvendo manipulação de logaritmos.

34 Fato 3.1 Dados números reais a, b, c 1, as seguintes igualdades são válidas. (i) a log a b = b. (ii) log c (ab) = log c a + log c b. (iii) log c (a/b) = log c a log c b. (iv) log c (a b ) = b log c a. (v) log b a = log c a log c b. (vi) log b a = 1 log a b. (vii) a log c b = b log c a. Demonstração. Por definição, temos log b a = x se e somente se b x = a. No que segue vamos provar cada uma das identidades descritas no enunciado. (i) a log a b = b. Segue diretamente da definição de logaritmo, uma vez que a x = b se e somente se x = log a b. (ii) log c (ab) = log c a + log c b. Como a, b e c são positivos, existem números k e l tais que a = c k e b = c l. Assim, temos log c (ab) = log c (c k c l ) = log c ( c k+l ) = k + l = log c a + log c b, onde as duas últimas desigualdades seguem da definição de logaritmos. (iii) log c (a/b) = log c a log c b. Como a, b e c são positivos, existem números k e l tais que a = c k e b = c l. Assim, temos log c (ab) = log c (c k /c l ) = log c ( c k l ) = k l = log c a log c b. (iv) log c (a b ) = b log c a. Como a, b e c são positivos, podemos escrever a = c k para 28

35 algum número real k. Assim, temos log c (a b ) = log c (c k b) = kb = b log c a. (v) log b a = log c a log c b. Vamos mostrar que log c a = (log b a)(log c b). Note que, pela identidade (i), temos log c a = log c ( b log b a ). Assim, usando a identidade (iii), temos que log c a = (log b a)(log c b). (vi) log b a = 1 log a b. Vamos somente usar (v) e o fato de que log a a = 1: log b a = log a a log a b = 1 log a b. (vii) a log c b = b log c a. Esse fato segue das identidades (i), (v) e (vi). De fato, a log c b = a (log a b)/(log a c) = ( a log b) 1/(log a a c) = b 1/(log a c) = b log c a. Vamos agora verificar como se obter fórmulas para algumas somas que aparecem com frequência em análise de algoritmos, que são as somas dos termos de progressões aritméticas e a soma dos termos de progressões geométricas. Uma progressão aritmética (PA) (a 1, a 2,..., a n ) com razão r é uma sequência de números que contém um termo inicial a 1 e todos os outros termos a i (com 2 i n) são definidos como a i = a 1 + (i 1)r. Assim, a soma dos termos dessa PA é dada por n i=1 a i = n i=1 (a 1 + (i 1)r). Uma progressão geométrica (PG) (b 1, b 2,..., b n ) com razão q é uma sequência de números que contém um termo inicial b 1 e todos os outros termos b i (com 2 i n) são definidos como b i = b 1 q i 1. Assim, a soma dos termos dessa PG é dada por n i=1 b i = n i=1 (b 1q i 1 ). 29

36 Teorema 3.2 Considere uma progressão aritmética n i=1 a n com razão r e uma progressão geométrica n i=1 b n com razão q. A soma dos termos da progressão aritmética é dada por (a 1+a n)n e a soma dos termos da progressão geométrica é dada por 2 a 1 (q n 1). q 1 Demonstração. Vamos começar com a progressão aritmética. A primeira observação importante é que para todo inteiro positivo k temos que k = k(k + 1)/2. (3.1) Esse fato pode facilmente ser provado por indução em n. Agora considere a soma n i=1 (a 1 + (i 1)r). Temos que n ( a1 + (i 1)r ) = a 1 n + r( (n 1)) i=1 rn(n 1) = a 1 n + 2 = n ( a 1 + (a 1 + r(n 1)) ) = n(a 1 + a n ), 2 onde na segunda igualdade utilizamos (3.1). Resta verificar a fórmula para a soma dos termos da progressão geométrica S = n i=1 (b 1q i 1 ). Note que temos qs = b 1 (q + q 2 + q q n 1 + q n ), e S = b 1 (1 + q + q q n 2 + q n 1 ). Portanto, subtraindo S de qs obtemos (q 1)S = b 1 (q n 1), de onde concluímos que S = b 1(q n 1). q 1 30

37 3.2 Método iterativo Esse método consiste simplesmente em expandir a recorrência até se chegar no caso base, que sabemos como calcular diretamente. Em geral, vamos utilizar como caso base T (1) = 1. Como um primeiro exemplo, considere T (n) = T (n/2) + 1, que é o tempo de execução do algoritmo de busca binária. T (n) = T (n/2) + 1 = (T ((n/2)/2) + 1) + 1 = T (n/2 2 ) + 2 = (T ((n/2 2 )/2) + 1) + 2 = T (n/2 3 ) + 3. = T (n/2 i ) + i. Sabemos que T (1) = 1. Então, se tomarmos i = log n, temos T (n) = T (n/2 log n ) + log n = T (1) + log n = Θ(log n). Para um segundo exemplo, considere T (n) = 2T (n/2) + n. Portanto, T (n) = 2T (n/2) + n = 2 ( 2T (n/4) + n/2 ) + n = 2 2 T (n/2 2 ) + 2n = 2 3 T (n/2 3 ) + 3n. = 2 i T (n/2 i ) + in. 31

38 Fazendo i = log n, temos T (n) = 2 log n T (n/2 log n ) + n log n = nt (1) + n log n = n + n log n = Θ(n log n). Como veremos na Parte III, Insertion sort e Merge sort são dois algoritmos que resolvem o problema de ordenação e têm, respectivamente, tempos de execução de pior caso T 1 (n) = Θ(n 2 ) e T 2 (n) = 2T (n/2) + n. Como acabamos de verificar, temos T 2 (n) = Θ(n log n), de modo que podemos concluir que, no pior caso, Merge sort é assintoticamente mais eficiente que Insertion sort. Analisaremos agora um último exemplo, que representa um algoritmo que sempre divide o problema em 2 subproblemas de tamanho n/3 e cada chamada recursiva é executada em tempo constante. Seja T (n) = 2T (n/3)+1. Seguindo a mesma estratégia dos exemplos anteriores, obtemos o seguinte. T (n) = 2T (n/3) + 1 = 2 ( 2T (n/3 2 ) + 1 ) + 1 = 2 2 T (n/3 2 ) + (1 + 2) = 2 3 T (n/3 3 ) + ( ). i 1 = 2 i T (n/3 i ) + j=0 2 j = 2 i T (n/3 i ) + 2 i 1 Fazendo i = log 3 n, temos T (n/3 log 3 n ) = 1, de onde concluímos que T (n) = 2 2 log 3 n 1 = 2 ( 2 log n) 1/ log = 2n 1/ log = Θ(n 1/ log 2 3 ). 32

39 3.2.1 Limitantes assintóticos inferiores e superiores Se quisermos apenas provar que T (n) = O(f(n)) em vez de Θ(f(n)), podemos utilizar limitantes superiores em vez de igualdades. Analogamente, para mostrar que T (n) = Ω(f(n)), podemos utilizar limitantes inferiores em vez de igualdades. Por exemplo, para T (n) = 2T (n/3) + 1, se quisermos mostrar somente que T (n) = Ω(n 1/ log 2 3 ), podemos simplificar a análise. O ponto principal é, ao expandir a recorrência T (n), entender qual é o termo que domina assintoticamente T (n), i.e., qual é o termo que determina a ordem de complexidade de T (n). T (n) = 2T (n/3) + 1 = 2 ( 2T (n/3 2 ) + 1 ) T (n/3 2 ) T (n/3 3 ) i T (n/3 i ) + i Fazendo i = log 3 n, temos T (n/3 log 3 n ) = 1, de onde concluímos que T (n) 2 log 3 n + log 3 n = n 1/ log log 3 n = Ω(n 1/ log 2 3 ). Nem sempre o método iterativo para resolução de recorrências funciona bem. Quando o tempo de execução de um algoritmo é descrito por uma recorrência não tão balanceada como a dos exemplos dados, pode ser difícil executar esse método. Outro ponto fraco é que rapidamente os cálculos podem ficar complicados. 3.3 Método da substituição Esse método consiste simplesmente em adivinhar a solução e provar por indução matemática que o palpite dado é, de fato, a solução para a recorrência. Mas como adivinhar uma solução? Podemos utilizar o método da árvore de recorrência visto a seguir para estimar um valor, mas algumas vezes será necessário experiência e 33

40 criatividade. Considere um algoritmo com tempo de execução T (n) = T ( n/2 ) + T ( n/2 ) + n. Por simplicidade, vamos assumir agora que n é uma potência de 2. Logo, podemos considerar T (n) = 2T (n/2)+n, pois temos que n/2 i é um inteiro para todo 1 i log n. Mostraremos que T (n) = O(n log n). Para isso, provaremos por indução que T (n) cn log n para c 2 e n 2, i.e., existem constantes c = 2, n 0 = 2 tais que se n n 0, então T (n) cn log n, que implica T (n) = O(n log n). Via de regra assumiremos T (1) = 1, a menos que indiquemos algo diferente. Note que se n = 1 for o caso base da indução, então temos um problema nesse exemplo, pois 1 > 0 = cn log n para n = 1. Porém, em análise assintótica estamos preocupados somente com valores grandes de n. Assim, como T (2) = 2T (1) + 2 = 4 c 2 log 2 para c 2, vamos assumir que n 2, onde a base da indução que vamos realizar é n = 2. Suponha que para 2 m < n temos T (m) cn log n. Vamos mostrar que T (n) cn log n. T (n) = 2T (n/2) + n 2 ( c(n/2) log(n/2) ) + n = cn log n cn + n cn log n. Portanto, mostramos que T (n) cn log n para c 2 e todo n 2, de onde concluímos que T (n) = O(n log n) Desconsiderando pisos e tetos Vimos que T (n) = T ( n/2 ) + T ( n/2 ) + n = Θ(n log n) sempre que n é uma potência de 2. Mostraremos a seguir que podemos sempre assumir que n é uma potência de 2. Vamos observar agora que de fato podemos desconsiderar o piso e o teto em T (n) = T ( n/2 ) + T ( n/2 ) + n. Suponha que n 3 não é uma potência de 2. Então 34

41 existe um inteiro k 2 tal que 2 k 1 < n < 2 k. Portanto, T (n) T (2 k ) d2 k log(2 k ) = (2d)2 k 1 log(2 2 k 1 ) < (2d)n(log 2 + log n) < (2d)n(log n + log n) = (4d)n log n. Similarmente, T (n) T (2 k 1 ) d 2 k 1 log(2 k 1 ) = d 2 2k (log(2 k ) 1) ( > d 2 n log n 9 log n ) 10 ( ) d = n log n. 20 Como existem constantes d /20 e 4d tais que para todo n 3 temos (d /20)n log n T (n) (4d)n log n, então T (n) = Θ(n log n). Logo, é suficiente ter considerado somente valores de n que são potências de 2. Análises semelhantes funcionam para a grande maioria das recorrências consideradas em análise de algoritmos. Em particular, é fácil mostrar que podemos desconsiderar pisos e tetos em recorrências do tipo T (n) = a(t ( n/b ) + T ( n/c )) + f(n) para constantes a > 0 e b, c > 1. Portanto, geralmente vamos assumir que n é potência de algum inteiro positivo, sempre que for conveniente para a análise. Assim, em geral desconsideraremos pisos e tetos Diversas formas de obter o mesmo resultado Podem existir diversas formas de encontrar um limitante assintótico utilizando indução. Lembre-se que anteriormente mostramos que T (n) dn log n para d 2 e a base de nossa indução era n = 2. Mostraremos agora que T (n) = O(n log n) provando que 35

42 T (n) n log n + n. A base da indução nesse caso é T (1) = 1 1 log Suponha que para todo 2 m < n temos T (m) m log m + m. Assim, T (n) = 2T (n/2) + n 2 ( (n/2) log(n/2) + n/2 ) + n = n log(n/2) + 2n = n log n n + 2n = n log n + n. Logo, mostramos que T (n) = O(n log n + n) = O(n log n). Uma observação importante é que no passo indutivo é necessário provar exatamente o que foi suposto, com a mesma constante. Por exemplo, se supormos T (m) cm log m e mostrarmos no passo indutivo que T (n) cn log n + 1, isso não implica que T (n) = O(n log n). Vimos que se T (n) = 2T (n/2) + n, então temos T (n) = O(n log n). Porém esse fato não indica que não podemos diminuir ainda mais esse limite. Para garantir que a ordem de grandeza de T (n) é n log n, precisamos mostrar que T (n) = Ω(n log n). Utilizando o método da substituição, mostraremos que T (n) n log n n, de onde concluímos que T (n) = Ω(n log n). A base da indução nesse caso é T (1) = 1 n log n n para n = 1. Suponha que para todo 2 m < n temos T (m) m log m m. Assim, T (n) = 2T (n/2) + n 2 ( (n/2) log(n/2) + n/2 ) n = n log(n/2) = n log n n. Portanto, mostramos que T (n) = Ω(n log n n) = Ω(n log n) Ajustando os palpites Algumas vezes quando queremos provar que T (n) = O ( f(n) ) para alguma função f(n), podemos ter problemas para obter êxito caso nosso palpite esteja errado. Porém, é possível que tenhamos de fato T (n) = O ( f(n) ) mas o palpite precise de um leve ajuste. 36

43 Considere T (n) = 3T (n/3) + 1. Podemos imaginar que esse é o tempo de execução de um algoritmo recursivo que a cada passo divide o vetor em 3 partes de tamanho n/3, e a cada chamada é executada em tempo constante. Assim, um bom palpite é que T (n) = O(n). Vamos tentar provar que o palpite T (n) cn é correto para alguma constante positiva c. Assim, temos T (n) = 3T (n/3) + 1 cn + 1, que não prova o que desejamos, pois para completar a prova por indução precisamos mostrar que T (n) cn (e não cn+1, como foi feito). Porém, é verdade que T (n) = O(n), mas o problema é que o palpite não foi forte o suficiente. Como corriqueiro em provas por indução, precisamos fortalecer a hipótese indutiva. Vamos tentar agora um novo palpite: T (n) cn d, onde d 1/2. T (n) = 3T (n/3) + 1 ( cn ) 3 3 d + 1 = cn 3d + 1 cn d. Assim, como a base T (1) = 1 c d para c d+1, temos que T (n) = O(cn d) = O(n) Mais exemplos Discutiremos agora alguns exemplos que nos ajudarão a entender todas as particularidades que podem surgir na aplicação do método da substituição. Exemplo 1: T (n) = 4T (n/2) + n 3. Considere n 2. Vamos provar que T (n) = Θ(n 3 ). Primeiramente, mostraremos que T (n) = O(n 3 ). Para isso, vamos provar que T (n) cn 3 para alguma constante apropriada c. Note que T (1) = 1 c 1 3 desde que c 1. Suponha que T (m) cm 3 para todo 37

44 2 m < n. Assim, temos que T (n) = 4T (n/2) + n 3 4cn3 8 cn 3, + n 3 onde a última desigualdade vale sempre que c 2. Portanto, fazendo c 2, acabamos de provar por indução que T (n) cn 3 = O(n 3 ). Para provar que T (n) = Ω(n 3 ), vamos provar que T (n) dn 3 para um d apropriado. Primeiro note que T (1) = 1 d 1 3 desde que d 1. Suponha que T (m) dm 3 para todo 2 m < n. Assim, temos que T (n) = 4T (n/2) + n 3 4dn3 8 dn 3, + n 3 onde a última desigualdade vale sempre que d 2. Portanto, fazendo d 1, acabamos de provar por indução que T (n) dn 3 = Ω(n 3 ). Exemplo 2: T (n) = 4T (n/16) + 5 n. Comecemos provando que T (n) c n log n para um c apropriado. Assumimos que n 16. Para o caso base temos T (16) = = 24 c 16 log 16, onde a última desigualdade vale sempre que c 3/2. Suponha que T (m) c m log m para todo 16 m < n. Assim, T (n) = 4T (n/16) + 5 n ( ) n 4 c (log n log 16) + 5 n 16 = c n log n 4c n + 5 n c n log n, 38

45 onde a última desigualdade vale se c 5/4. Como 3/2 > 5/4, basta fazer c = 3/2 para concluir que T (n) = O( n log n). A prova de que T (n) = Ω( n log n) é similar à prova feita para o limitante superior, de modo que a deixamos por conta do leitor. Exemplo 3: T (n) = T (n/2) + 1. Temos agora o caso onde T (n) é o tempo de execução do algoritmo de busca binária. Mostraremos que T (n) = O(log n). Para n = 2 temos T (2) = 2 c = c log 2 sempre que c 2. Suponha que T (m) c log m para todo 2 m < n. Logo, T (n) = T (n/2) + 1 c log n c + 1 c log n, onde a última desigualdade vale para c 1. Assim, T (n) = O(log n). Exemplo 4: T (n) = T ( n/2 + 2) + 1, onde assumimos T (4) = 1. Temos agora o caso onde T (n) é muito semelhante ao tempo de execução do algoritmo de busca binária. Logo, nosso palpite é que T (n) = O(log n), o que de fato é correto. Porém, para a análise funcionar corretamente precisamos de cautela. Vamos mostrar duas formas de analisar essa recorrência. Primeiro vamos mostrar que T (n) c log n para um valor de c apropriado. Seja n 4 e note que T (4) = 1 c log 4 para c 1/2. Suponha que T (m) c log m para 39

46 todo 4 m < n temos T (n) = T ( n/2 + 2) + 1 ( n ) c log ( ) n + 4 = c log = c log(n + 4) c + 1 c log(3n/2) c + 1 = c log n + c log 3 2c + 1 = c log n c(2 log 3) + 1 c log n, onde a penúltima desigualdade vale para n 8 e a última desigualdade vale sempre que c 1/(2 log 3). Portanto, temos T (n) = O(log n). Veremos agora uma outra abordagem, onde fortalecemos a hipótese de indução. Provaremos que T (n) c log(n a) para um valor apropriado de a e c. T (n) = T ( n/2 + 2) + 1 ( n ) c log a + 1 ( ) n a = c log = c log(n a) c + 1 c log(n a), onde a primeira desigualdade vale para a 4 e a última desigualdade vale para c 1. Assim, faça a = 4 e note que T (6) = T (5) + 1 = T (4) + 2 = 3 c log(6 4) para todo c 3. Portanto, fazendo a = 4 e c 3, mostramos que T (n) c log(n a) para todo n 6, de onde concluímos que T (n) = O(log n). 40

47 3.4 Método da árvore de recorrência Este é talvez o mais simples dos métodos, que consiste em analisar a árvore de recursão do algoritmo, uma árvore onde cada nó representa o custo do subproblema associado em cada nível da recursão, e os filhos de cada vértice são os subproblemas que foram gerados na chamada recursiva associada ao vértice. Nós somamos os custos dentro de cada nível, obtendo o custo total por nível, e então somamos os custos de todos os níveis, obtendo a solução da recorrência. A Figura 3.1 abaixo é uma árvore de recursão para a recorrência T (n) = 2T (n/2)+cn e fornece o palpite O(n log n). Na Figura 3.2 temos a árvore de recursão para a recorrência T (n) = 2T (n/2) + 1. Nas árvores abaixo, em cada nível temos dois valores, o primeiro desses valores determina o custo do subproblema em questão, e o segundo valor (circulado nas figuras), é o tamanho do subproblema. No lado direito temos o custo total em cada nível da recursão. Por fim, no canto inferior direito das Figuras 3.1 e 3.2 temos a estimativa para o valor das recorrências. Figura 3.1: Árvore de recorrência para T (n) = 2T (n/2) + cn. 41

48 Figura 3.2: Árvore de recorrência para T (n) = 2T (n/2) + 1. Note que o valor de c não faz diferença no resultado T (n) = O(n log n), de modo que quando for conveniente, podemos considerar tais constantes como tendo valor 1. Geralmente o método da árvore de recorrência é utilizado para fornecer um bom palpite para o método da substituição, de modo que é permitida uma certa frouxidão na análise. Porém, uma análise cuidadosa da árvore de recorrência e dos custos associados a cada nível pode servir como uma prova direta para a solução da recorrência em questão. 42

49 3.5 Método mestre O método mestre faz uso do Teorema 3.1 abaixo para resolver recorrências do tipo T (n) = at (n/b) + f(n) para a 1, b > 1, onde f(n) é positiva. Esse resultado formaliza uma análise cuidadosa feita utilizando árvores de recorrência. Na Figura 3.3 temos uma análise da árvore de recorrência de T (n) = at (n/b) + f(n). Figura 3.3: Árvore de recorrência para T (n) = at (n/b) + f(n). Note que temos a 0 + a a log b n = a1+log b n 1 a 1 = (bn)log b a 1 a 1 = Θ ( n log b a). Portanto, considerando somente o tempo para dividir o problema em subproblemas recursivamente, temos que é gasto tempo Θ ( n log b a). A ideia envolvida no Teorema 43

50 Mestre (que será apresentado a seguir) analisa situações dependendo da diferença entre f(n) e n log b a. Teorema 3.1: Teorema Mestre Sejam a 1 e b > 1 constantes e seja f(n) uma função. Para T (n) = at (n/b) + f(n), vale o seguinte: (1) Se f(n) = O(n log b a ε ) para alguma constante ε > 0, então T (n) = Θ(n log b a ). (2) Se f(n) = Θ(n log b a ), então T (n) = Θ(n log b a log n). (3) Se f(n) = Ω(n log b a+ε ) para alguma constante ε > 0, e para n suficientemente grande temos a f(n/b) cf(n) para alguma constante c < 1, então T (n) = Θ(f(n)). Mas qual a intuição por trás desse resultado? Imagine um algoritmo com tempo de execução T (n) = at (n/b) + f(n). Primeiramente, lembre que a árvore de recorrência descrita na Figura 3.3 sugere que o valor de T (n) depende de quão grande ou pequeno f(n) é com relação a n log b a. Se a função f(n) sempre assume valores pequenos (aqui, pequeno significa f(n) = O(n log b a ε )), então é de se esperar que o mais custoso para o algoritmo seja dividir cada instância do problema em a partes de uma fração 1/b dessa instância. Assim, nesse caso, o algoritmo vai ser executado recursivamente log b n vezes até que se chegue à base da recursão, gastando para isso tempo da ordem de a log b n = n log b a, como indicado pelo item (1). O item (3) corresponde ao caso em que f(n) é grande comparado com o tempo gasto para dividir o problema em a partes de uma fração 1/b da instância em questão. Portanto, faz sentido que f(n) determine o tempo de execução do algoritmo nesse caso, que é a conclusão obtida no item (3). O caso intermediário, no item (2), corresponde ao caso em que a função f(n) e dividir o algoritmo recursivamente são ambos essenciais no tempo de execução do algoritmo. Infelizmente, existem alguns casos não cobertos pelo Teorema Mestre, mas mesmo nesses casos conseguir utilizar o teorema para conseguir limitantes superiores e inferiores. Entre os casos (1) e (2) existe um intervalo em que o Teorema Mestre não fornece nenhuma informação, que é quando f(n) é assintoticamente menor que n log b a, mas assintoticamente maior que n log b a ε para todo ε > 0, e.g., f(n) = Θ(n log b a / log n) ou 44

51 Θ(n log b a / log(log n)). De modo similar, existe um intervalo sem informações entre (2) e (3). Existe ainda um outro caso em que não é possível aplicar o Teorema Mestre a uma recorrência do tipo T (n) = at (n/b) + f(n). Em algumas recorrências T (n) = at (n/b) + f(n) podemos ter f(n) = Ω(n log b a+ε ), porém não satisfazem a condição a f(n/b) cf(n) no item (3). Felizmente, essa condição é geralmente satisfeita em recorrências que representam tempo de execução de algoritmos. Desse modo, para algumas funções f(n), podemos considerar a seguinte versão simplificada do Teorema Mestre, que dispensa a condição extra no item (3). Vamos considerar funções f(n) que geralmente aparecem em análise de algoritmos. Seja f(n) um polinômio de grau k com coeficientes não negativos (para k constante), i.e., f(n) = k i=0 a in i, onde a 0, a 1,..., a k são constantes e a 0, a 1,..., a k 1 0 e a k > 0. Teorema 3.2: Teorema Mestre - Versão simplificada Sejam a 1, b > 1 e k 0 constantes e seja f(n) um polinômio de grau k com coeficientes não negativos. Para T (n) = at (n/b) + f(n), vale o seguinte: (1) Se f(n) = O(n log b a ε ) para alguma constante ε > 0, então T (n) = Θ(n log b a ). (2) Se f(n) = Θ(n log b a ), então T (n) = Θ(n log b a log n). (3) Se f(n) = Ω(n log b a+ε ) para alguma constante ε > 0, então T (n) = Θ(f(n)). Demonstração. Vamos provar que para f(n) como no enunciado, se f(n) = Ω(n log b a+ε ), então para todo n suficientemente grande temos a f(n/b) cf(n) para alguma constante c < 1. Dessa forma, não precisamos verificar essa condição extra de (3) em 3.1, pois será sempre satisfeita. Primeiro note que como f(n) = k i=0 a in i = Ω(n log b a+ε ) temos k = log b a + ε. Resta provar que af(n/b) cf(n) para algum c < 1. Logo, basta provar que cf(n) 45

52 af(n/b) 0 para algum c < 1. Assim, cf(n) af(n/b) = c k a i n i a i=0 k i=0 a i n i b i ( = a k c a ) k 1 ( n k + a b k i c a ) n i b i i=0 ( a k c a ) k 1 ( a ) n k a b k i n i b i i=0 a k ( c a b k ) n n k 1 = (c 1 n)n k 1 (c 2 )n k 1, ( ) k 1 a a i i=0 n k 1 onde c 1 e c 2 são constantes e na última desigualdade utilizamos o fato de b > 1 (assim, b i > 1 para todo i 0). Logo, para n c 2 /c 1, temos que cf(n) af(n/b) Resolvendo recorrências com o método mestre Vamos analisar alguns exemplos de recorrência onde aplicaremos o Teorema Mestre diretamente à recorrência desejada. Exemplo 1: T (n) = 2t(n/2) + n. Claramente, temos a = 2 e b = 2. Portanto, f(n) = n = n log 2 2. O caso do Teorema Mestre em que esses parâmetros se encaixam é o caso (2). Assim, pelo Teorema Mestre, T (n) = Θ(n log n). Exemplo 2: T (n) = 4T (n/10) + 5 n. Neste caso temos a = 4, b = 10 e f(n) = 5 n. Assim, log b a = log , 6. Como 5 n = 5n 0,5 = O(n 0,6 0,1 ), estamos no caso (1) do Teorema Mestre. Logo, T (n) = Θ(n log b a ) = Θ(n log 10 4 ). Exemplo 3: T (n) = 4T (n/16) + 5 n. 46

53 Note que a = 4, b = 16 e f(n) = 5 n. Assim, log b a = log 16 4 = 1/2. Como 5 n = 5n 0,5 = Θ(n log b a ), estamos no caso (2) do Teorema Mestre. Logo, T (n) = Θ(n log b a log n) = Θ(n log 16 4 log n) = Θ( n log n). Exemplo 4: T (n) = 4T (n/2) + 10n 3. Neste caso temos a = 4, b = 2 e f(n) = 10n 3. Assim, log b a = log 2 4 = 2. Como 10n 3 = Ω(n 2+1 ), estamos no caso (3) do Teorema Mestre. Vamos verificar a condição extra. Antes de concluir que T (n) = Θ(f(n)) precisamos mostrar que a f(n/b) cf(n) (i.e., 40(n/2) 3 < 10cn 3 ) para alguma constante c < 1 e para todo n suficientemente grande. Mas isso é verdade para todo n 1 para qualquer 1/2 < c < 1. Logo, concluímos que T (n) = Θ(n 3 ). Exemplo 5: T (n) = 5T (n/4) + n. Temos a = 5, b = 4 e f(n) = n. Assim, log b a = log 4 5. Como log 4 5 > 1, temos que f(n) = n = O(n log 4 5 ε ) para ε = 1 log 4 5 > 0. Logo, estamos no caso (1) do Teorema Mestre. Assim, concluímos que T (n) = Θ(n log 4 5 ). teste c : E F... c: E F Ajustes para aplicar o método mestre Dada uma recorrência T (n) = at (n/b) + f(n), existem duas possibilidades em que o Teorema Mestre (Teorema 3.1) não é aplicável (diretamente): (i) nenhuma das três condições assintóticas no teorema é válida para f(n); (ii) f(n) = Ω(n log b a+ε ) para alguma constante ε > 0, mas não existe c < 1 tal que a f(n/b) cf(n) para todo n suficientemente grande. 47

54 Para verificar (i), temos que verificar que valem as três seguintes afirmações: 1) f(n) Θ(n log b a ); para qualquer ε > 0 temos 2) f(n) O(n log b a ε ) e 3) f(n) Ω(n log b a+ε ). Lembre que, dado que temos a versão simplificada do Teorema Mestre (Teorema 3.2), não precisamos verificar o item (ii) pois essa condição é sempre satisfeita para polinômios f(n) com coeficientes não negativos. No que segue mostraremos que não é possível aplicar o Teorema Mestre diretamente a algumas recorrências, mas sempre é possível conseguir limitantes superiores e inferiores analisando recorrências levemente modificadas. Exemplo 1: T (n) = 2T (n/2) + n log n. Começamos notando que a = 2, b = 2 e f(n) = n log n. Para todo n suficientemente grande e qualquer constante C vale que n log n Cn. Assim, para qualquer ε, temos que n log n O(n 1 ε ), de onde concluímos que a recorrência T (n) não se encaixa no caso (1). Como n log n Θ(n), também não podemos utilizar o caso (2). Por fim, como log n Ω(n ε ) para qualquer ε > 0, temos que n log n = Ω(n 1+ε ), de onde concluímos que o caso (3) do Teorema Mestre também não se aplica. Exemplo 2: T (n) = 5T (n/8) + n log 8 5 log n. Começamos notando que a = 5, b = 8 e f(n) = n log 8 5 log n. Para todo n suficientemente grande e qualquer constante C vale que n log 8 5 log n Cn log 8 5. Assim, para qualquer ε, temos que n log 8 5 log n O(n log 8 5 ε ), de onde concluímos que a recorrência T (n) não se encaixa no caso (1). Como n log 8 5 log n Θ(n log 8 5 ), também não podemos utilizar o caso (2). Por fim, como log n Ω(n ε ) para qualquer ε > 0, temos que n log 8 5 log n Ω(n log 8 5+ε ), de onde concluímos que o caso (3) do Teorema Mestre também não se aplica. Exemplo 3: T (n) = 3T (n/9) + n log n. Começamos notando que a = 3, b = 9 e f(n) = n log n. Logo, n log b a = n. Para todo n suficientemente grande e qualquer constante C vale que n log n C n. Assim, para qualquer ε, temos que n log n O( n/n ε ), de onde concluímos que a recorrência T (n) não se encaixa no caso (1). Como n log n Θ( n), também não podemos utilizar o caso (2). Por fim, como log n Ω(n ε ) para qualquer ε > 0, temos que n log n Ω( n n ε ), de onde concluímos que o caso (3) do Teorema Mestre 48

55 também não se aplica. Exemplo 4: T (n) = 16T (n/4) + n 2 / log n. Começamos notando que a = 16, b = 4 e f(n) = n 2 / log n. Logo, n log b a = n 2. Para todo n suficientemente grande e qualquer constante C vale que n C log n. Assim, para qualquer ε, temos que n 2 / log n O(n 2 ε ), de onde concluímos que a recorrência T (n) não se encaixa no caso (1). Como n 2 / log n Θ(n 2 ), também não podemos utilizar o caso (2). Por fim, como n 2 / log n Ω(n 2+ε ) para qualquer ε > 0, concluímos que o caso (3) do Teorema Mestre também não se aplica. Como vimos, não é possível aplicar o Teorema Mestre diretamente às recorrências descritas nos exemplos acima. Porém, podemos ajustar as recorrências e conseguir bons limitantes assintóticos utilizando o Teorema Mestre. Por exemplo, para a recorrência T (n) = 16T (n/4) + n 2 / log n dada no exemplos acima, claramente temos T (n) 16T (n/4) + n 2, de modo que podemos aplicar o Teorema Mestre na recorrência T (n) = 16T (n/4) + n 2. Como n 2 = n log 4 16, pelo caso (2) do Teorema Mestre, temos que T (n) = Θ(n 2 log n). Portanto, como T (n) T (n), concluímos que T (n) = O(n 2 log n), obtendo um limitante assintótico superior para T (n). Por outro lado, temos que T (n) = 16T (n/4) + n 2 / log n T (n), onde T (n) = 16T (n/4) + n. Pelo caso (1) do Teorema Mestre, temos que T (n) = Θ(n 2 ). Portanto, como T (n) T (n), concluímos que T (n) = Ω(n 2 ). Dessa forma, apesar de não sabermos exatamente qual é a ordem de grandeza de T (n), temos uma boa estimativa, dado que mostramos que essa ordem de grandeza está entre n 2 e n 2 log n. A seguir temos um exemplo de recorrência que não satisfaz a condição extra do item (3) do Teorema 3.1. Ressaltamos que é improvável que tal recorrência descreva o tempo de execução de um algoritmo (a menos que esse algoritmo tenha sido projetado especialmente para ter esse tempo de execução). Exemplo 5: T (n) = T (n/2) + n(2 cos n). 49

56 Primeiro vamos verificar que estamos no caso (3) do Teorema Mestre. De fato, como a = 1 e b = 2, temos n log b a = 1. Assim, como f(n) = n(2 cos n) n, temos f(n) = Ω(n log b a+ε ) para qualquer 0 < ε < 1. Vamos agora verificar se é possível obter a condição extra do caso (3). Precisamos mostrar que f(n/2) c f(n) para algum c < 1 e todo n suficientemente grande. Vamos usar o fato que cos(2kπ) = 1 para qualquer inteiro k, e que cos(kπ) = 1 para todo inteiro ímpar k. Seja n = 2kπ para qualquer inteiro ímpar k 3. Assim, temos c f(n/2) f(n) = (n/2)( 2 cos(kπ) ) n(2 cos(2kπ)) = 2 cos(kπ) 2(2 cos(2kπ)) = 3 2. Logo, para infinitos valores de n, a constante c precisa ser pelo menos 3/2, não é possível obter a condição extre no caso (3). Assim, não há como aplicar o Teorema Mestre à recorrência T (n) = T (n/2) + n(2 cos n). Existem outros métodos para resolver equações de recorrência mais gerais que equações do tipo T (n) = at (n/b) + f(n). Um exemplo importante é o método de Akra-Bazzi, que consegue resolver equações não tão balanceadas como T (n) = T (n/3) + T (2n/3) + Θ(n), mas não entraremos em detalhes desse método aqui. 50

57 Parte II Estruturas de dados

58

59 Capítulo 4 Lista encadeada, fila e pilha Algoritmos geralmente precisam manipular conjuntos de dados que podem crescer, diminuir ou sofrer diversas modificações durante sua execução. Muitos algoritmos necessitam realizar algumas operações essenciais, como inserção e remoção de elementos em um conjunto de dados. A eficiência dessas e de outras operações depende fortemente do tipo de estrutura de dados utilizada. Abaixo vamos discutir as estruturas lista encadeada, pilha e fila. 4.1 Lista encadeada Lista encadeada é uma estrutura de dados onde a ordem dos elementos é determinada por um ponteiro em cada objeto, diferente do que acontece com vetores, onde os elementos estão dispostos em uma ordem linear determinada pelos índices do vetor. Em uma lista duplamente encadeada L, cada elemento contém um atributo chave e dois ponteiros, anterior e próximo. Obviamente, cada elemento da lista pode conter outros atributos contendo mais dados. Aqui vamos sempre inserir, remover ou modificar elementos de uma lista baseado nos atributos chave, que sempre contém inteiros não negativos. Dado um elemento x na lista, x.anterior aponta para o elemento que está imediatamente antes de x na lista e x.proximo aponta para o elemento que está imediatamente após x na lista. Se x.anterior = null, então x não tem predecessor, de modo que é o primeiro elemento da lista, a cabeça da lista. Se x.proximo = null, então x não

60 tem sucessor e é chamado de cauda da lista, sendo o último elemento de L. O atributo L.cabeca aponta para o primeiro elemento da lista L, sendo que L.cabeca = null quando a lista está vazia. Uma lista L pode ter vários formatos. Ela pode ser duplamente encadeada, como descrito no parágrafo anterior, ou pode ser uma lista encadeada simples, onde não existe o ponteiro anterior. Uma lista pode ser ordenada ou não ordenada, circular ou não circular. Em uma lista circular, o ponteiro proximo da cauda aponta para a cabeça da lista, enquanto o ponteiro anterior da cabeça aponta para a cauda. A figura abaixo mostra um exemplo de uma lista duplamente encadeada circular. Figura 4.1: Lista duplamente encadeada circular. A seguir vamos descrever os procedimentos de busca, inserção e remoção em uma lista duplamente encadeada, não ordenada e não-circular. O procedimento Busca lista abaixo realiza uma busca pelo primeiro elemento com chave k na lista L. Primeiramente, a cabeça da lista L é analisada e em seguida os elementos da lista são analisados, um a um, até que k seja encontrado ou até que a lista seja completamente verificada. No pior caso, toda a lista deve ser verificada, de modo que o tempo de execução de Busca lista é O(n) para uma lista com n elementos. Algoritmo 9: Busca lista(l, k) 1 x = L.cabeca 2 enquanto x null e x.chave k faça 3 x = x.proximo 4 retorna x A inserção é realizada sempre no começo da lista. No procedimento abaixo inserimos um elemento x na lista L. Portanto, caso L não seja vazia, o ponteiro x.proximo deve 54

61 apontar para a atual cabeça de L e L.cabeca.anterior deve apontar para x. Caso L seja vazia então x.proximo aponta para null. Como x será a cabeça de L, o ponteiro x.anterior deve apontar para null. Algoritmo 10: Inserção lista(l, x) 1 x.proximo = L.cabeca 2 se L.cabeca null então 3 L.cabeca.anterior = x 4 L.cabeca = x 5 x.anterior = null Como somente uma quantidade constante de operações é executada, o procedimento Inserção-Lista é executado em tempo O(1) para uma lista com n elementos. Note que o procedimento de inserção em uma lista encadeada ordenada levaria tempo O(n), pois precisaríamos inserir x na posição correta dentro da lista, tendo que percorrer toda a lista no pior caso. O procedimento Remoção lista abaixo, remove um elemento x de uma lista L. Note que o parâmetro passado para o procedimento é um ponteiro para x e não um valor chave k. Esse ponteiro pode ser retornado, por exemplo, por uma chamada à Busca-Lista. A remoção é simples, sendo necessário somente atualizar os ponteiros x.anterior.proximo e x.proximo.anterior, tendo cuidado com os casos onde x é a cabeça ou a cauda de L. Algoritmo 11: Remoção lista(l, x) 1 se x.anterior null então 2 x.anterior.proximo = x.proximo 3 senão 4 L.cabeca = x.proximo 5 se x.proximo null então 6 x.proximo.anterior = x.anterior 55

62 Como somente uma quantidade constante de operações é efetuada, a remoção leva tempo O(1) para ser executada. Porém, se quisermos remover um elemento que contém uma dada chave k, precisamos primeiramente efetuar uma chamada ao algoritmo Busca lista(l, k) e remover o elemento retornado pela busca, gastando tempo O(n) no pior caso. Observe que o fato do procedimento Remoção lista ter sido feito em uma lista duplamente encadeada é essencial para que seu tempo de execução seja O(1). Se L for uma lista encadeada simples, não temos a informação de qual elemento em L está na posição anterior a x, dado que não existe x.anterior. Portanto, seria necessário uma busca por esse elemento, para podermos efetuar a remoção de x. Desse modo, um procedimento de remoção em uma lista encadeada simples leva tempo O(n) no pior caso. 4.2 Pilha Pilha é uma estrutura de dados onde as operações de inserção e remoção são feitas na mesma extremidade, chamada de topo da pilha. Ademais, ao se realizar uma remoção na pilha, o elemento a ser removido é sempre o último elemento que foi inserido na pilha. Essa política de remoção é conhecida como LIFO, acrônimo para last in, first out. Existem inúmeras aplicações para pilhas. Por exemplo, verificar se uma palavra é um palíndromo é um procedimento muito simples de se realizar utilizando uma pilha. Basta inserir as letras em ordem e depois realizar a remoção uma a uma, verificando se a palavra formada é a mesma que a inicial. Uma outra aplicação (muito utilizada) é a operação desfazer, presente em vários editores de texto. Toda mudança de texto é colocada em uma pilha, de modo que cada remoção da pilha fornece a última modificação realizada. Mencionamos também que pilhas são úteis na implementação de algoritmos de busca em profundidade em grafos. Vamos mostrar como implementar uma pilha de no máximo n elementos utilizando um vetor P [1..n]. Ressaltamos que existem ainda outras formas de implementar pilhas. Por exemplo, poderíamos utilizar listas encadeadas para realizar essa tarefa. Dado um vetor P [1..n], o atributo P.topo contém o índice do elemento que foi inserido por último, contendo 0 quando a pilha estiver vazia. P.tamanho contém o 56

63 tamanho do vetor, i.e., n. Em qualquer momento, o vetor P [1... P.topo] representa a pilha em questão, onde P [1] contém o primeiro elemento inserido na pilha e P [P.topo] contém o último. Quando inserimos um elemento x na pilha P, dizemos que estamos empilhando x em P. Similarmente, ao remover x de P nós desempilhamos x de P. As duas operações de pilha a seguir, Empilha e Desempilha, são bem simples e todas elas levam tempo O(1) para serem executadas. Para acrescentar um elemento x à pilha P, utilizamos o procedimento Empilha abaixo, que verifica se a pilha está cheia e, caso ainda haja espaço, atualiza o topo da pilha P.topo para P.topo + 1 e insere x em P [P.topo]. Algoritmo 12: Empilha(P, x) 1 se P.topo = P.tamanho então 2 retorna Pilha cheia 3 senão 4 P.topo = P.topo P [P.topo] = x Para desempilhar basta verificar se a pilha está vazia e, caso contrário, decrementar de uma unidade o valor de P.topo, retornando o elemento que estava no topo da pilha. Algoritmo 13: Desempilha(P ) 1 se P.topo == 0 então 2 retorna Pilha vazia 3 senão 4 P.topo = P.topo 1 5 retorna P [P.topo + 1] A figura abaixo ilustra as seguinte operações, em ordem, onde a pilha P está inicialmente vazia: Empilha(P, 3), Empilha(P, 5), Empilha(P, 1), Desempilha(P ), Desempilha(P ), Empilha(P, 8). 57

64 Figura 4.2: Operações em uma pilha. 4.3 Fila A fila é uma estrutura de dados onde as operações de inserção e remoção são feitas em extremidades opostas, a cabeça e a cauda da fila. Ademais, ao se realizar uma remoção na fila, o elemento a ser removido é sempre o primeiro elemento que foi inserido na fila. Essa política de remoção é conhecida como FIFO, acrônimo para first in, first out. O conceito de fila é amplamente utilizado na vida real. Por exemplo, qualquer sistema que controla a ordem de atendimento em bancos pode ser implementado utilizando filas. Mais geralmente, filas podem ser utilizadas em algoritmos que precisam controlar acesso a recursos, de modo que a ordem de acesso é definida pelo tempo em que o recurso foi solicitado. Outra aplicação é a implementação de busca em largura em grafos. Como acontece com pilhas, filas podem ser implementadas de diversas formas. Aqui vamos focar na implementação utilizando vetores. Vamos mostrar como implementar uma fila de no máximo n 1 elementos utilizando um vetor F [1,..., n]. Para ter o controle de quando a pilha está vazia ou cheia, conseguimos guardar no máximo n 1 elementos em um vetor de tamanho n. Dado um vetor F [1,..., n], os atributos F.cabeca e F.cauda contêm, respectivamente, os índices para o início de F e para a posição onde o próximo elemento será inserido em F. Portanto, os elementos da fila encontram-se nas posições F.cabeca, F.cabeca + 1,..., F.cauda 2, F.cauda 1, onde as operações de soma e subtração são feitas módulo F.tamanho = n, i.e., podemos enxergar o vetor F de forma circular. Quando inserimos um elemento x na fila F, dizemos que estamos enfileirando x em 58

65 F. Similarmente, ao remover x de F nós estamos desenfileirando x de F. Antes de descrever as operações, vamos discutir alguns detalhes sobre filas. Inicialmente, temos F.cabeca = F.cauda = 1. Sempre que F.cabeca = F.cauda, a fila está vazia, e a fila está cheia quando F.cabeca = F.cauda + 1. As duas operações de fila a seguir, Enfileira e Desenfileira levam tempo O(1) para serem executadas. O procedimento Enfileira abaixo adiciona um elemento à fila. Primeiramente é verificado se a fila está cheia, caso onde nada é feito. Caso contrário, o elemento é adicionado na posição F.cauda e atualizamos o valor de F.cauda. Esse procedimento realiza uma quantidade constante de operações, de modo que é claramente executado em tempo O(1). Algoritmo 14: Enfileira(F, x) 1 se (F.cabeca == 1 e F.cauda == n) ou (F.cabeca == F.cauda + 1) então 2 retorna Fila cheia 3 senão 4 F [F.cauda] = x 5 se F.cauda == F.tamanho então 6 F.cauda = 1 7 senão 8 F.cauda = F.cauda + 1 Para remover um elemento da fila, utilizamos o procedimento Desenfileira abaixo, que verifica se a fila está vazia e, caso contrário, retorna o primeiro elemento que foi inserido na fila (elemento contido no índice F.cabeca) e atualiza o valor de F.cabeca. Como no procedimento Enfileira, claramente o tempo gasto em Desenfileira é O(1). 59

66 Algoritmo 15: Desenfileira(F ) 1 se F.cabeca == F.cauda então 2 retorna Fila vazia 3 senão 4 x = F [F.cabeca] 5 se F.cabeca == F.tamanho então 6 F.cabeca = 1 7 senão 8 F.cabeca = F.cabeca retorna x A figura abaixo ilustra as seguinte operações (as mesmas que fizemos para ilustrar as operações de pilha), em ordem, onde a fila F está inicialmente vazia: Enfileira(F, 3), Enfileira(F, 5), Enfileira(F, 1), Desenfileira(F ), Desenfileira(F ), Enfileira(F, 8). Figura 4.3: Operações em uma fila. H aponta para a cabeça e T para a cauda. Resumindo as informações deste capítulo, temos que pilhas e filas são estruturas de dados simples mas com diversas aplicações. Inserção e remoção em ambas as estruturas levam tempo O(1) para serem executadas e são pré-determinadas pela estrutura. Inserções e remoções em pilha são feitas na mesma extremidade, implementando a política LIFO. Na fila, a política FIFO é implementada, onde o primeiro elemento inserido é o primeiro a ser removido. Listas encadeadas são organizadas com a utilização de ponteiros nos elementos. Uma característica interessante de listas duplamente encadeadas é que inserção e remoção são feitas em tempo O(1). Uma vantagem em relação ao uso de vetores é que 60

67 não é necessário saber a quantidade de elementos que serão utilizados previamente. Em geral, o tempo de execução das operações em listas encadeadas depende do tipo de lista em questão, que sumarizamos na tabela abaixo. Não ordenada, Ordenada, Não ordenada, Ordenada simples simples dupla. enc. dupla. enc. Busca-Lista O(n) O(n) O(n) O(n) Inserção-Lista O(1) O(n) O(1) O(n) Remoção-Lista O(n) O(n) O(1) O(1) 61

68 62

69 Capítulo 5 Heap binário Antes de discutirmos heaps binários lembre que uma árvore binária é uma estrutura de dados organizada em formato de árvores onde existe um vertice raiz, cada vértice possui no máximo dois filhos, e cada vértice que não é raiz tem exatamente um pai. O único nó que não possui pai é chamado de raiz da árvore. Vértices que não possuem filhos são chamados de folhas. Lembre também que a altura de uma árvore é a quantidade de arestas do maior caminho entre a raiz e uma de suas folhas. Dizemos que os vértices que estão à uma distância i da raiz estão no nível i (a raiz está no nível 0). Uma árvore binária é dita completa se todos os seus níveis estão completamente preenchidos. Note que árvores binárias completas com altura h possuem 2 h+1 1 vértices. Dizemos que a altura de um vértice v é a altura da subárvore com raíz em v. Uma árvore binária com altura h é dita quase completa se os níveis 0, 1,..., h 1 têm todos os vértices possíveis. Na Figura 5.1 temos um exemplo de uma árvore quase completa ordenada. Um heap é uma estrutura que pode ser definida de duas formas diferentes, dependendo da aplicação: heap máximo e heap mínimo. Como todas as operações em heaps máximos são similares às operações em heaps mínimos, vamos aqui trabalhar somente com heaps máximos. Dado um vetor A, a quantidade de elementos suportada por A é denotada por A.tamanho. Definiremos agora a estrutura em que estamos interessados nesta seção, o heap máximo, que pode ser representado através do uso de um vetor. Um heap repre-

70 Figura 5.1: Árvore binária quase completa. sentado em A tem no máximo A.tam-heap elementos, onde A.tam-heap A.tamanho. Vamos utilizar nomenclatura de pai e filhos, como em árvores. O elemento em A[1] é o único elemento que não tem pai e, para todo 2 i A.tam-heap, temos que o índice do pai de A[i] é i/2. Os filhos esquerdo e direito de um elemento A[i] estão, respectivamente, nos índices 2i e 2i + 1, onde um elemento tem filho esquerdo somente se 2i A.tam-heap e tem filho direito somente se 2i + 1 A.tam-heap. Finalmente, o vetor A satisfaz a propriedade de heap: para todo 2 i A.tam-heap, temos A[ i/2 ] A[i], i.e., o valor do pai é sempre maior ou igual ao valor de seus filhos. Analisando a definição acima podemos enxergar um heap como uma árvore binária quase completa onde a propriedade de heap é satisfeita. Ademais, em um heap máximo visto como uma árvore binária, o último nível da árvore é preenchido de forma contígua da esquerda para a direita. A Figura 5.1 vista anteriormente representa um heap máximo. 5.1 Construção de um heap binário Primeiramente descreveremos um procedimento chamado de Max-conserta-heap que será útil na construção de um heap e também para o algoritmo Heapsort. Maxconserta-heap recebe um vetor A e um índice i e assumimos que as subárvores com raiz A[2i] ou A[2i + 1] são heaps máximos. Max-conserta-heap vai mover A[i] para baixo na árvore de modo que, ao final do procedimento, a subárvore com raiz em A[i] satisfaz a propriedade de heap. 64

71 Algoritmo 16: Max-conserta-heap(A, i) 1 maior = i 2 se 2i A.tam-heap então 3 se A[2i] > A[maior] então 4 maior = 2i 5 se 2i + 1 A.tam-heap então 6 se A[2i + 1] > A[maior] então 7 maior = 2i se maior i então 9 troca A[i] com A[maior] 10 Max-conserta-heap (A, maior) A Figura 5.2 mostra um exemplo de execução da rotina Max-conserta-heap. Figura 5.2: Execução de Max-conserta-heap(A, 2) em A = [20, 0, 10, 6, 8, 3, 5, 1, 4, 7, 2]. Teorema 5.1: Corretude de Max-conserta-heap O algoritmo Max-conserta-heap(A, i) recebe um vetor A e um índice i tal que as subárvores com raiz A[2i] ou A[2i + 1] são heaps máximos, e modifica A de modo que a árvore com raiz em A[j] para todo i j A.tam-heap é um heap máximo. 65

72 Demonstração. Vamos provar a corretude de Max-conserta-heap(A, i) por indução em i. Como os últimos A.tam-heap/2 elementos de A são folhas (heaps de tamanho 1), sabemos que as árvores com raiz em A[i] para A.tam-heap/2 + 1 i A.tam-heap são heaps máximos. Seja i 1 e suponha agora que o algoritmo funciona corretamente quando recebe A e um índice i + 1 j A.tam-heap. Precisamos mostrar que Max-consertaheap(A, i) funciona corretamente, i.e., a árvore com raiz A[j], para todo i j A.tam-heap, é um heap máximo. Considere uma execução de Max-conserta-heap(A, i). Note que se A[i] é maior ou igual a seus filhos, então os testes nas linhas 3, 6 e 8 não serão verificados e o algoritmo não faz nada, que é o esperado, uma vez que a árvore com raiz em A[i] já é um heap máximo. Assuma agora que A[i] é menor do que algum dos seus filhos. Caso A[2i] seja o maior dos filhos, o teste na linha 2 e na linha 3 será executado com sucesso e teremos maior = 2i. A linha 7 não será executada, e como maior i, o algoritmo troca A[i] com A[maior] e executa Max-conserta-heap (A, maior) na linha 10. Como maior = 2i i + 1, sabemos pela hipótese de indução que o algoritmo funciona corretamente, de onde concluímos que a árvore com raiz em A[2i] é um heap máximo. Como A[i] é agora maior que A[2i], concluímos que a árvore com raiz A[j], para todo i j A.tam-heap, é um heap máximo. A prova á análoga quando A[2i + 1] é o maior dos filhos de A[i]. Vamos analizar o tempo de execução de Max-conserta-heap(A, i) em um heap com n elementos representado pelo vetor A. O ponto chave é perceber que a cada chamada recursiva, Max-conserta-heap desce um nível na árvore. Assim, em uma árvore de altura h, em O(h) passos a base da árvore é alcançada. Como em cada passo somente tempo constante é gasto, concluímos que Max-conserta-heap tem tempo de execução O(h), onde h é a altura da árvore em questão. Sabendo que um heap pode ser visto como uma árvore binária quase completa, que tem altura O(log n), o tempo de execução de Max-conserta-heap é O(log n). Vamos fazer uma análise mais detalhada do tempo de execução T (n) de Maxconserta-heap (A, i). Note que a cada chamada recursiva o problema diminui consideravelmente de tamanho. Se estamos na iteração correspondente a um elemento A[i], a próxima chamada recursiva será na subárvore cuja raiz é um filho de A[i]. 66

73 Mas qual o pior caso possível? No pior caso, se o problema inicial tem tamanho n, o subproblema seguinte possui tamanho no máximo 2n/3. Isso segue do fato de possivelmente analisarmos a subárvore cuja raiz é o filho esquerdo de A[1] (i.e., está no índice 2) e o último nível da árvore está cheio até a metade. Assim, a subárvore com raiz no índice 2 possui aproximadamente 2/3 dos vértices, enquanto que a subárvore com raiz em 3 possui aproximadamente 1/3 dos vértices. Em todos os próximos passos os subproblemas são divididos na metade do tamanho da instância atual. Como queremos um limitante superior, podemos calcular o tempo de execução de Max-conserta-heap como: T (n) T (2n/3) + 1 T ( (2/3) 2 n ) + 2. T ( (2/3) i n ) + i = T ( n/(3/2) i) + i Fazendo i = log 3/2 n e assumindo T (1) = 1, temos T (n) 1 + log 3/2 n = O(log n). Podemos também aplicar o Teorema Mestre. Sabendo que o tempo de execução T (n)de Max-conserta-heap é no máximo T (2n/3) + 1. podemos aplicar o Teorema Mestre à recorrência T (n) = T (2n/3) + 1 para obter um limitante superior para T (n). Como a = 1, b = 3/2 e f(n) = 1, temos que f(n) = Θ(n log 3/2 1 ). Assim, utilizando o caso (2) do Teorema Mestre, concluímos que T (n) = Θ(log n). Portanto, T (n) = O(log n). Note que os últimos n/2 elementos de A são folhas (heaps de tamanho 1), de modo que um heap pode ser construído simplesmente chamando o procedimento Maxconserta-heap(A, i) para i = n/2,..., 1, nessa ordem. Seja a rotina Construaheap(A) abaixo tal procedimento. 67

74 Algoritmo 17: Construa-heap(A[1..n]) 1 A.tam-heap = n 2 para i = n/2 até 1 faça 3 Max-conserta-heap(A, i) A Figura 5.3 tem um exemplo de execução da rotina Construa-heap. Antes de estimarmos o tempo de execução do algoritmo, vamos mostrar que ele funciona corretamente. Para isso precisaremos da seguinte invariante de laço. Invariante: Construa-heap Antes de cada iteração do laço para (indexado por i), para todo i + 1 j n, a árvore com raiz A[j] é um heap máximo. Teorema 5.3 O algoritmo Construa-heap(A[1..n]) transforma o vetor A em um heap máximo. Demonstração. Inicialmente temos i = n/2, então precisamos verificar se, para todo n/2 + 1 j n, a árvore com raiz A[j] é um heap máximo. Mas essa árvore é composta somente pelo elemento A[j], pois como j > n/2, o elemento A[j] não tem filhos. Assim, a árvore com raiz em A[j] é um heap máximo. Suponha agora que a invariante é válida imediatamente antes da i-ésima iteração do laço para, i.e., para todo i + 1 j n, a árvore com raiz A[j] é um heap máximo. Para mostrar que a invariante é válida imediatamente antes da (i 1)-ésima iteração, note que na i-ésima iteração do laço temos que as árvores com raiz A[j] são heaps, para i + 1 j n. Portanto, caso A[i] tenha filhos, esses são raízes de heaps, de modo que a chamada a Max-conserta-heap(A, i) na linha 3 funciona corretamente, transformando a árvore com raiz A[i] em um heap máximo. Assim, para todo i j n, a árvore com raiz A[j] é um heap máximo. Portanto, a invariante se mantém válida 68

75 antes de todas as iterações do laço. Ao fim da execução do laço temos i = 0, de modo que, pela invariante de laço, a árvore com raiz em A[1] é um heap máximo. No que segue seja T (n) o tempo de execução de Construa-heap em um vetor A com n elementos. Uma simples análise permite concluir que T (n) = O(n log n): o laço para é executado no máximo n/2 vezes, e em cada uma dessas execuções a rotina Max-conserta-heap, que leva tempo O(log n) é executada. Logo, concluímos que T (n) = O(n log n). Uma análise mais cuidadosa fornece um limitante melhor que O(n log n). Primeiro vamos observar que em um heap de tamanho n existem no máximo n/2 h+1 elementos com altura h. Verificaremos isso por indução na altura h. As folhas são os elementos com altura h = 0. Como temos n/2 = n/2 0+1 folhas, então a base está verificada. Seja 1 h log n e suponha que existem no máximo n/2 h elementos com altura h 1. Note que na altura h existem no máximo metade da quantidade máxima possível de elementos de altura h 1. Assim, utilizando a hipótese indutiva, na altura h temos no máximo n/2 h /2 elementos, que implica que existem no máximo n/2 h+1 elementos com altura h. Assim, para cada elemento de altura h, a chamada recursiva de Max-consertaheap correspondente executa em tempo O(h). Assim, para n suficientemente grande, temos que cada uma dessas chamadas recursivas é executada em tempo no máximo Ch para alguma constante C > 0. Portanto, o tempo de execução de Construa-heap é dado como segue. T (n) log n h=0 n 2 h+1 Ch log n h = Cn 2. h h=0 69

76 Seja S = log n h=0 h. Notando que 2S = 1 + log n 2 h+1 h=1 S = 2S S = 1 + = ( = 2. log n h=1 log n n h=1 1 2 h h 2 h 1 ) + h, obtemos 2 h 1 log n + h log n 1 h=1 h=0 1 2 h 2 h+1 Portanto, T (n) = O(Sn) = O(n). 70

77 Figura 5.3: Execução do Construa-heap(A) no vetor A = [3, 1, 5, 8, 2, 4, 7, 6, 9]. 71

78 72

79 Capítulo 6 Fila de prioridades Neste capítulo introduzimos filas de prioridades. Essas estruturas são úteis em diversos procedimentos, incluindo uma implementação eficiente dos algoritmos de Prim e Dijkstra (veja Capítulos 16 e 17). Dado um conjunto V de elementos, onde cada elemento de v V possui um atributo v.chave e um atributo v.indice. Uma fila de prioridades baseada nos atributos chave dos elementos de V é uma estrutura de dados que contém as chaves de V e permite executar algumas operações de forma eficiente. Filas de prioridades podem ser de mínimo ou de máximo, mas como os algoritmos são todos análogos, mostraremos aqui somente as operações em uma fila de prioridades de mínimo. Uma fila de prioridades F sobre um conjunto V, baseada nos valores v.chave para cada v V, permite remover (ou consultar) um elemento com chave mínima, inserir um novo elemento em F, e alterar o valor da chave de um elemento em F para um valor menor. Vamos mostrar como implementar uma fila de prioridades F utilizando um heap mínimo. Após quaisquer operações em F, essa fila de prioridades sempre representará um heap mínimo. No Capítulo 5 introduzimos diversos algoritmos sobre a estrutura de dados heap. Fizemos isso utilizando um vetor F com um conjunto de chaves. A seguir discutimos uma pequena variação dos algoritmos Max-conserta-heap e Construa-heap apresentados na Seção 5 que, em vez de um conjunto de chaves, mantém um vetor F de elementos v de um conjunto V tal que, cada v V possui atributos v.chave e v.indice,

80 representando respectivamente a chave do elementos e o índice em que o elemento se encontra dentro do vetor F. Os algoritmos que apresentaremos mantém os índices dos elementos de F atualizados. Esses algoritmos serão úteis para uma implementação eficiente dos algoritmos de Prim e Dijkstra vistos nas próximas seções. Lembre que F possui tamanho elementos e o heap contém F.tam-heap F.tamanho. Abaixo temos a versão correspondente a heaps mínimos do algoritmo Max-conserta-heap, onde mantemos os índices dos elementos de F atualizados. Algoritmo 18: Min-conserta-heap-indice(F, i) 1 menor = i 2 se 2i F.tam-heap então 3 se F [2i].chave < F [menor].chave então 4 menor = 2i 5 se 2i + 1 A.tam-heap então 6 se F [2i + 1].chave < F [menor].chave então 7 menor = 2i se menor i então 9 troca F [i].indice com F [menor].indice 10 troca F [i] com F [menor] 11 Min-conserta-heap-indice (F, menor) Para construir um heap baseado no vetor F, vamos utilizar um procedimento similar ao descrito na Seção 5, fazendo uso do algoritmo Min-conserta-heap-indice. Algoritmo 19: Construa-heap-indice(F ) 1 F.tam-heap = F.tamanho 2 para i = 1 até F.tam-heap faça 3 F [i].indice = i 4 para i = F.tam-heap/2 até 1 faça 5 Min-conserta-heap-indice(F, i) 74

81 Vamos voltar nossa atenção às filas de prioridade. Se Mínimo(F ) é o procedimento para retornar o elemento de menor valor em F, basta que ele retorne F [1], de modo que é executado em tempo constante. Porém, se quisermos remover o elemento de menor valor, precisamos fazer isso de modo que ao fim da operação a fila de prioridades ainda seja um heap mínimo. Para garantir essa propriedade, salvamos o valor de F [1].chave em uma variável e colocamos F [F.tam-heap] em F [1], reduzindo em seguida o tamanho do heap F em uma unidade. Porém, como a propriedade de heap pode ter sido destruída, vamos consertá-la executando Min-conserta-heap-indice(F, 1). O algoritmo Remoção-min(F ) abaixo remove e retorna o elemento que contém a menor chave dentre todos os elementos de F. Algoritmo 20: Remoção-min(F ) 1 se F.tam-heap < 1 então 2 retorna Fila de prioridades está vazia 3 indice-menor = F [1] 4 F [F.tam-heap].indice = 1 5 F [1] = F [F.tam-heap] 6 F.tam-heap = F.tam-heap 1 7 Min-conserta-heap-indice(F, 1) 8 retorna indice-menor Como Min-conserta-heap-indice(F, 1) é executado em tempo O(log n) para um heap F com n elementos, é fácil notar que o tempo de execução de Remoção-min(F ) é O(log n) para uma fila de prioridades F com n elementos. Para alterar o valor de uma chave salva em F [i].chave para um valor menor, basta realizar a alteração diretamente e ir subindo esse elemento no heap até que a propriedade de heap seja restaurada. O seguinte procedimento realiza essa operação. 75

82 Algoritmo 21: Diminui-chave(F, i, x) 1 se x > F [i].chave então 2 retorna x é maior que F [i].chave 3 F [i].chave = x 4 enquanto i > 1 e F [i].chave < F [ i/2 ].chave faça 5 troca F [i].indice e F [ i/2 ].indice 6 troca F [i] e F [ i/2 ] 7 i = i/2 Como o algoritmo simplesmente sobe no heap, i.e., a cada passo o índice i é dividido por 2, então em uma fila de prioridades com n elementos, Diminui-chave(F, i, x) é executado em tempo O(log n). Para inserir um novo elemento com chave x em uma fila de prioridades F, primeiro verificamos se é possível aumentar o tamanho do heap, caso seja possível, aumentamos seu tamanho tam-heap em uma unidade, inserimos um elemento com valor maior que todas as chaves em F (aqui representado por ) e executamos Diminuichave(F, tam-heap, x) para colocar esse elemento em sua posição correta. Algoritmo 22: Insere-fila-prioridades(F, x) 1 se F.tam-heap = F.tamanho então 2 retorna heap está cheio 3 F.tam-heap = F.tam-heap F [tam-heap].indice = F.tam-heap 5 F [tam-heap].chave = 6 Diminui-chave(F, tam-heap, x) Como o algoritmo realiza somente uma operação Diminui-chave e todas as outras operações são executadas em tempo constante, concluímos que Insere-filaprioridades(F, x) é executado em tempo O(log n). 76

83 Capítulo 7 Union-find A estrutura de dados conhecida como union-find mantém uma partição de um conjunto de elementos A e permite as seguintes operações: Cria conjunto(x): cria um conjunto novo contendo somente o elemento x; Find(x): retorna qual é o conjunto de A que contém o elemento x; Union(x, y): gera um conjunto obtido da união dos conjuntos que contém os elementos x e y de A. Podemos facilmente obter algoritmos que realizam as operações Cria conjunto(x) e Find(x) em tempo constante, i.e., O(1). Para a operação Union(x, y) vamos descrever as ideias de um algoritmo que a realiza em tempo O( X ), onde X e Y são respectivamente o tamanho dos conjuntos que contém x e y, e X Y. Dado um conjunto A, cada subconjunto X de A mantido pela estrutura Union-find é identificado através de um atributo x.representante presente em cada elemento de A. Assim, se temos X = {a, b, c}, os três elementos de X tem o mesmo representante, como por exemplo a.representante = a, b.representante = a e c.representante = a. A operação Cria conjunto(x) faz x.representante = x, de modo que para realizar a operação Union(x, y) onde x X, y Y e X Y, vamos atualizar o representante de todo elemento de X (o menor dentre X e Y ) para ter o mesmo representante dos elementos de Y, isto é, basta fazer v.representante = y.representante para todo v X. Assim, é possível executar essa operação em tempo O( X ).

84 78

85 Parte III Algoritmos de ordenação

86

87 Capítulo 8 Insertion sort O problema de ordenação consiste em ordenar um conjunto de chaves contidas em um vetor. Mais precisamente, seja A = (a 1, a 2,..., a n ) uma sequência com n números dada como entrada. Queremos obter uma permutação (a 1, a 2,..., a n) desses números de modo que a 1 a 2... a n, i.e., desejamos obter como saída os elementos da sequência de entrada ordenados de modo não-decrescente. Dentre características importantes de algoritmos de ordenação, podemos destacar duas: um algoritmo é dito in-place se utiliza somente espaço constante a mais do que os dados de entrada, e um algoritmo é dito estável se a ordem em que chaves de mesmo valor aparecem na saída são a mesma da entrada. Discutiremos essas propriedades, e a aplicabilidade e tempo de execução dos algoritmos que serão apresentados. Vamos analisar um algoritmo simples, chamado de Insertion sort, que recebe um vetor A = (a 1, a 2,..., a n ) com n números e retorna esse mesmo vetor A em ordem não-decrescente. A ideia do algoritmo Insertion sort é executar n rodadas de instruções, onde a cada rodada temos um subvetor de A ordenado que contém um elemento a mais do que o subvetor da rodada anterior. Mais precisamente, ao fim na i-ésima rodada, o algoritmo garante que o vetor A[1..i] está ordenado. Sabendo que o vetor A[1..i] está ordenado, é fácil encaixar o elemento A[i + 1] na posição correta no vetor A[1..i + 1]. Para encaixar o elemento A[i + 1] na posição correta em A[1..i + 1], o algoritmo vai comparar o número contido em A[i + 1] com A[i], A[i 1], e assim por diante, até que encontre um índice j tal que A[j] < A[i+1]. Assim, a posição correta de A[i+1] é a posição j+1. Segue o pseudocódigo do Insertion sort.

88 Algoritmo 23: Insertion sort(a) 1 para i = 2,..., n faça 2 atual = A[i] 3 j = i 1 4 enquanto j > 0 e A[j] > atual faça 5 A[j + 1] = A[j] 6 j = j 1 7 A[j + 1] = atual 8 retorna A Note que o Insertion sort é um algoritmo in-place e estável. A Figura 8.1 mostra uma execução do algoritmo. Figura 8.1: Execução do Insertion sort no vetor A = [2, 5, 1, 4, 3]. Na seção seguinte mostraremos que o algoritmo funciona corretamente. 8.1 Corretude e tempo de execução 82

89 Para entender como podemos utilizar as invariantes de laço para provar a corretude de algoritmos vamos fazer a análise do algoritmo Insertion sort. Considere a seguinte invariante de laço. Invariante: Insertion sort Antes de cada iteração do laço para (indexado por i), o subvetor A[1..i 1] está ordenado de modo não-decrescente. Observe que o item (i) é válido antes da primeira iteração, quando i = 2, pois o vetor A[1,..., i 1] contém somente um elemento e, portanto, sempre está ordenado. Para verificar (ii), suponha agora que o vetor A[1,..., i 1] está ordenado e o laço para executa sua i-ésima iteração. O laço enquanto move passo a passo o elemento A[i] para a esquerda até encontrar sua posição correta, deixando o vetor A[1,..., i] ordenado. Por fim, precisamos mostrar que ao final da execução o algoritmo ordena todo o vetor A. Note que o laço termina quando i = n + 1, de modo que a invariante de laço considerada garante que A[1,..., i 1] = A[1,..., n] está ordenado, de onde concluímos que o algoritmo está correto. Para calcular o tempo de execução de Insertion sort, basta notar que a linha 1 é executada n vezes, as linhas 2, 3 e 7 são executadas n 1 vezes cada, e se r i é a quantidade de vezes que o laço enquanto é executado na i-ésima iteração do laço para, então a linha 4 é executada n i=2 (r i) vezes, e as linhas 5 e 6 são executadas n i=2 (r i 1) vezes cada uma. Por fim, a linha 8 é executada somente uma vez. Assim, o tempo de execução T (n) de Insertion sort é dado por T (n) = n + 3(n 1) + = 4n = 2n + 3 n r i + 2 i=2 n r i 2 i=2 n r i. i=2 n 1 i=2 n (r i 1) + 1 i=2 Note que para de fato sabermos a eficiência do algoritmo Insertion sort precisamos saber o valor de cada r i, mas para isso é preciso assumir algo sobre a ordenação 83

90 do vetor de entrada Análise de melhor caso, pior caso e caso médio No Insertion sort, o melhor caso ocorre quando a sequência de entrada está ordenada de modo crescente. Nesse caso, o laço da linha 4 é executado somente uma vez para cada 2 i n, de modo que temos r i = 1. De fato, a condição A[j] > atual será falsa já na primeira iteração do laço enquanto, pois aqui temos j = i 1 e como o vetor de entrada está ordenado, temos A[i 1] < A[i]. Portanto, nesse caso, temos que T (n) = 2n + 3 = 5n 3 = Θ(n). n i=2 r i Geralmente estamos interessados no tempo de execução de pior caso do algoritmo, isto é, o maior tempo de execução do algoritmo entre todas as entradas possíveis de um dado tamanho. A análise de pior caso é muito importante, pois limita superiormente o tempo de execução para qualquer entrada, garantindo que o algoritmo nunca vai demorar mais do que esse limite. Outra razão para a análise de pior caso ser considerada é que para alguns algoritmos, o pior caso (ou algum caso próximo do pior) ocorre com muita frequência. O pior caso do Insertion sort acontece quando o vetor está ordenado de modo decrescente, pois o laço da linha 4 será executado i vezes em cada iteração i do laço na linha 1, de modo que temos r i = i. Assim, temos T (n) = 2n + 3 n i=2 r i = n 2 + 2n 6 (8.1) = Θ(n 2 ), (8.2) Podemos concluir que assintoticamente o tempo de execução do pior caso de Insertion sort é menos eficiente que o tempo no melhor caso. Como vimos anteriormente, o tempo de execução do caso médio de um algoritmo é a média do tempo de execução dentre todas as entradas possíveis. Por exemplo, no 84

91 caso do Insertion sort, pode-se assumir que quaisquer das n! permutações dos n elementos tem a mesma chance de ser o vetor de entrada. Note que, nesse caso, cada número tem a mesma probabilidade de estar em quaisquer das n posições do vetor. Assim, em média, metade dos elementos em A[1,..., i 1] são menores que A[i], de modo que na i-ésima execução do laço para, o laço enquanto é executado cerca de i/2 vezes em média. Portanto, temos em média por volta de n(n 1)/4 execuções do laço enquanto. Com uma análise simples do tempo de execução do Insertion sort que descrevemos anteriormente, obtemos que no caso médio, T (n) é uma função quadrática em n, i.e., uma função da forma T (n) = a 2 n + bn + c, onde a, b e c são constantes que não dependem de n. Muitas vezes o tempo de execução no caso médio é quase tão ruim quanto no pior caso, como na análise do Insertion sort que fizemos anteriormente, onde para ambos os casos obtivemos uma função quadrática no tamanho do vetor de entrada. Mas é necessário deixar claro que esse nem sempre é o caso. Por exemplo, seja n o tamanho de um vetor que desejamos ordenar. Um algoritmo de ordenação chamado Quicksort tem tempo de execução de pior caso quadrático em n, mas em média o tempo gasto é da ordem de n log n, que é muito menor que uma função quadrática em n para valores grandes de n. Embora o tempo de execução de pior caso do Quicksort seja pior do que de outros algoritmos de ordenação (e.g., Merge sort, Heapsort), ele é comumente utilizado, dado que seu pior caso raramente ocorre. Por fim, vale mencionar que nem sempre é simples descrever o que seria uma entrada média para um algoritmo, e análises de caso médio são geralmente mais complicadas que análises de pior caso Uma análise mais direta Não precisamos fazer uma análise tão cuidadosa como a que fizemos na seção anterior. Essa é uma das vantagens de se utilizar notação assintótica para estimar tempo de execução de algoritmos. No que segue vamos fazer a análise do tempo de execução do Insertion sort de forma mais rápida, focando apenas nos pontos que realmente importam. Todas as instruções de todas as linhas de Insertion sort são executadas em tempo constante, de modo que o que vai determinar a eficiência do algoritmo é a quantidade de vezes que os laços para e enquanto são executados. O laço para é 85

92 executado n 1 vezes, mas a quantidade de execuções do laço enquanto depende da distribuição dos elementos dentro do vetor A. Se A estiver em ordem decrescente, então as instruções dentro do laço enquanto são executadas i vezes para cada execução do laço para (indexado por i), totalizando n 1 = n(n 1)/2 = Θ(n 2 ) execuções. Porém, se A já estiver corretamente ordenado no início, então o laço enquanto é executado somente 1 vez para cada execução do laço para, totalizando n 1 = Θ(n) execuções, bem menos que no caso anterior. Para deixar claro como a análise assintótica pode ser útil para simplificar a análise, imagine que um algoritmo tem tempo de execução dado por T (n) = an 2 + bn + c. Em análise assintótica queremos focar somente no termo que é relevante para valores grandes de n. Portanto, na maioria dos casos podemos esquecer as constantes envolvidas em T (n) (nesse caso, a, b e c). Podemos também esquecer dos termos que dependem de n mas que não são os termos de maior ordem (nesse caso, podemos esquecer do termo an). Assim, fica fácil perceber que temos T (n) = Θ(n 2 ). Para verificar que essa informação é de fato verdadeira, basta tomar n 0 = 1 e notar que para todo n n 0 temos an 2 an 2 + bn + c (a + b + c)n 2, i.e., fazemos c = a e C = a + b + c na definição da notação Θ. Com uma análise similar, podemos mostrar que para qualquer polinômio f(n) = k a i n i, i=1 onde a i é constante para 1 i k, e a k > 0, temos f(n) = Θ(n k ). 86

93 Capítulo 9 Merge sort O algoritmo Merge sort é um algoritmo simples que faz uso do paradigma de divisão e conquista. Dado um vetor de entrada A com n números, o Merge sort divide A em duas partes de tamanho n/2, ordena as duas partes recursivamente e depois combina as duas partes ordenadas em uma única parte ordenada. O procedimento Merge sort é como segue, onde Combina é um procedimento para combinar duas partes ordenadas em uma só parte ordenada. Para ordenar um vetor A de n posições, basta executar Merge sort (A, 1, n). Algoritmo 24: Merge sort(a, inicio, f im) 1 se inicio < fim então 2 meio = (inicio + fim)/2 3 Merge sort(a, inicio, meio) 4 Merge sort(a, meio + 1, fim) 5 Combina(A, inicio, meio, f im) Na Figura 14.3 ilustramos uma execução do algoritmo Merge sort no vetor A = [7, 3, 1, 10, 2, 8, 15, 6]. Note que na metade superior da figura corresponde às chamadas recursivas nas linhas (3) e (4). A metade inferior da figura corresponde às chamadas recursivas ao procedimento Combina (linha (5)). Logo a seguir temos o algoritmo Combina.

94 Figura 9.1: Execução de Merge sort(a, 1, n) para A = [7, 3, 1, 10, 2, 8, 15, 6]. Algoritmo 25: Combina(A, inicio, meio, f im) 1 n 1 = meio inicio n 2 = fim meio 3 cria vetores auxiliares E[1..(n 1 + 1)] e D[1..(n 2 + 1)] 4 E[n 1 + 1] = 5 D[n 2 + 1] = 6 para i = 1 até n 1 faça 7 E[i] = A[inicio + i 1] 8 para j = 1 até n 2 faça 9 D[j] = A[meio + j] 10 i = 1 11 j = 1 12 para k = inicio até fim faça 13 se E[i] D[j] então 14 A[k] = E[i] 15 i = i senão 17 A[k] = D[j] 18 j = j

95 O procedimento Combina(A, inicio, meio, f im) cria um vetor E com meio inicio+ 1 posições e um vetor D com fim meio posições, que recebem, respectivamente, o vetor ordenado A[inicio..meio] e A[meio + 1..f im]. Comparando os elementos desses dois vetores, é fácil colocar em ordem todos esses elementos em A[inicio..f im]. Note que por usar os vetores auxiliares E e D, o Merge sort não é um algoritmo in-place. Na Figura 9.2 temos uma simulação da execução de Combina(A, 1, 4, 8), onde A = [1, 3, 7, 10, 2, 6, 8, 15]. Figura 9.2: Execução de Combina(A, p, q, r) no vetor A = [1, 3, 7, 10, 2, 6, 8, 15] com parâmetros p = 1, q = 4 e r = 8. Considere uma execução de Combina ao receber um vetor A e parâmetros inicio, meio e fim como entrada. Note que a linha 3 é executada em tempo Θ(fim inicio) e todas as outras linhas são executadas em tempo constante. O laço para na linha (6) é executado meio inicio + 1 vezes, o laço para na linha (8) é executado fim 1 vezes, e o laço para na linha (12)) é executado fim inicio + 1 vezes. Se C(n) é 89

96 o tempo de execução de Combina(A, inicio, meio, fim) onde n = fim inicio + 1, então temos C(n) = Θ(n). Vamos agora analisar o tempo de execução do algoritmo Merge sort quando ele é utilizado para ordenar um vetor com n elementos. Vimos que o tempo para combinar as soluções recursivas é Θ(n). Portanto, como os vetores em questão são sempre divididos ao meio no algoritmo Merge sort, seu tempo de execução T (n) é dado por T (n) = T ( n/2 ) + T ( n/2 ) + cn. Como estamos preocupados em fazer uma análise assintótica, podemos assumir que c = 1, pois isso não fará diferença no resultado obtido. Por ora, vamos desconsiderar pisos e tetos, considerar T (n) = 2T (n/2) + n, para n > 1, e T (n) = 1 para n = 1. Como visto no Capítulo 3, o tempo de execução de Merge sort é dado por T (n) = 2T (n/2) + n = Θ(n log n). 90

97 Capítulo 10 Selection sort e Heapsort Neste capítulo vamos introduzir dois algoritmos para o problema de ordenação, o Selection sort e o Heapsort. O Selection sort é um algoritmo que sempre mantém o vetor de entrada A dividido em dois subvetores contíguos, uma parte inicial A e de A contendo elementos não ordenados, e a segunda parte A d de A contendo os maiores elementos de A (já ordenados). A cada iteração do algoritmo, o maior elemento x do subvetor A e é encontrado, e o subvetor A d é aumentado de uma unidade com a inserção do elemento x em sua posição correta. O Heapsort utiliza uma estrutura de dados chamada de heap binário (ou, simplesmente, heap) para encontrar o maior elemento de um subvetor de forma eficiente. Dessa forma, o Heapsort pode ser visto como uma versão mais eficiente do Selection sort Selection sort O algoritmo Selection sort possui uma estrutura muito simples, contendo dois laços para aninhados. O primeiro laço é executado n 1 vezes, de modo que em cada iteração desse laço, obtemos um vetor ordenado A d que é uma unidade maior que o vetor ordenado que tínhamos antes da iteração. Ademais, o vetor A d sempre contém os maiores elementos de A. Para manter essa propriedade, a cada passo, o maior elemento fora do subvetor ordenado A d é adicionado ao início de A d. Abaixo temos o pseudocódigo de Selection sort.

98 Algoritmo 26: Selection sort(a[1..n]) 1 para i = n até 2 faça 2 indicemax = i 3 para j = i 1 até 1 faça 4 se A[j] > A[indiceMax] então 5 indicemax = j 6 troca A[indiceM ax] com A[i] Note que todas as linhas são executadas em tempo constante e cada um dos laços para é executado Θ(n) vezes cada. Como um dos laços está dentro do outro, temos que o tempo de execução de Selection sort(a[1..n]) é Θ(n 2 ). Na Figura 10.1 temos um exemplo de execução do algoritmo Selection sort(a). No que segue vamos utilizar a seguinte invariante de laço para mostrar que o algoritmo Selection sort(a[1..n]) funciona corretamente. Invariante: Selection sort Antes de cada iteração do primeiro laço para (indexado por i), o subvetor A[i + 1..n] está ordenado de modo não-decrescente e contém os maiores elementos de A. Teorema 10.2 O algoritmo Selection sort(a) ordena um vetor A de modo não-decrescente. Demonstração. Como inicialmente i = n, a invariante é trivialmente satisfeita. Suponha agora que a invariante é válida imediatamente antes da i-ésima iteração do primeiro laço para, i.e., o subvetor A[i + 1..n] está ordenado de modo não-decrescente e contém os maiores elementos de A. Precisamos mostrar que antes da (i 1)-ésima iteração o subvetor A[i..n] está ordenado de modo não-decrescente e contém os maiores elementos de A. Mas note que na i-ésima iteração do primeiro laço para, o segundo 92

99 Figura 10.1: Execução de Selection sort(a) no vetor A = [2, 5, 1, 4, 3]. laço para (na linha 3) verifica qual o índice indicemax do maior elemento do vetor A[1..i 1] (isso pode ser formalmente provado por uma invariante de laço!). Na linha 6, o maior elemento de A[1..i 1] é trocado de lugar com o elemento A[i], garantindo que A[i..n] está ordenado e contém os maiores elementos de A. Por fim, note que na última vez que a linha 1 é executada temos i = 1. Assim, pela invariante de laço, o vetor A[2..n] está ordenado. Como sabemos que os maiores elementos de A estão em A[2..n], concluímos que o vetor A[1..n] está ordenado Heapsort O Heapsort é um algoritmo de ordenação com tempo de execução de pior caso Θ(n log n), como o Merge sort. O Heapsort é um algoritmo in-place, apesar de não ser estável. 93

100 O algoritmo troca o elemento na raiz do heap (maior elemento) com o elemento na última posição do vetor e restaura a propriedade de heap para A[1,..., n 1], em seguida fazemos o mesmo para A[1,..., n 2] e assim por diante. O algoritmo é como segue. Algoritmo 27: Heapsort (A) 1 Construa-heap(A) 2 para i = n até 2 faça 3 troca A[1] com A[i] 4 A.tam-heap = A.tam-heap 1 5 Max-conserta-heap(A, 1) Na Figura 10.2 temos um exemplo de execução do algoritmo Heapsort. Uma vez que já provamos a corretude de Construa-heap e Max-consertaheap (veja Capítulo 5), a prova de corretude do algoritmo Heapsort é bem simples. Utilizaremos a seguinte invariante de laço. Invariante: Heapsort Antes de cada iteração do laço para (indexado por i) temos que: O vetor A[i + 1..n] está ordenado de modo não-decrescente e contém os maiores elementos de A. A.tam-heap = i e o vetor A[1..A.tam-heap] é um heap máximo. Teorema 10.2 O algoritmo Heapsort(A) ordena o vetor A de modo não-decrescente. Demonstração. A linha 1 constrói um heap a partir do vetor A. Assim, como inicialmente i = n, a invariante é trivialmente satisfeita. Suponha agora que a invariante é válida imediatamente antes da i-ésima iteração do laço, i.e., o subvetor A[i+1..n] está or- 94

101 Figura 10.2: Algoritmo Heapsort executado no vetor A = [4, 7, 3, 8, 1, 9]. Note que a primeira árvore da figura é o heap obtido por Construa-heap(A). denado de modo não-decrescente e contém os maiores elementos de A, e A.tam-heap = i onde A[1..A.tam-heap] é um heap máximo. Precisamos mostrar que a invariante é válida antes da (i 1)-ésima iteração. Na i-ésima iteração do primeiro laço, o algoritmo troca A[1] com A[i], colocando o maior elemento de A[1..A.tam-heap] em A[i], diminui A.tam-heap em uma unidade, fazendo com que A.tam-heap = i 1, e executa Max-conserta-heap(A, 1). Mas note que o único elemento de A[1..A.tam-heap] que pode não satisfazer a propriedade de heap é A[1]. Como sabemos que Max-consertaheap(A, 1) funciona corretamente, temos que após esse comando A[1..A.tam-heap] é um heap máximo. Como o maior elemento de A[1..A.tam-heap] está em A[i] e dado que sabemos que A[i+1..n] está ordenado de modo não-decrescente e contém os maiores 95

102 elementos de A, concluímos que o vetor A[i..n] está ordenado de modo não-decrescente e contém os maiores elementos de A. Assim, mostramos que a invariante é válida antes da (i 1)-ésima iteração do laço. Ao final da execução do laço, temos i = 1. Portanto, pela invariante, sabemos que A[2..n] está ordenado de modo não-decrescente e contém os maiores elementos de A. Como A[2..n] contém os maiores elementos de A, o menor elemento certamente está em A[1], de onde concluímos que A está ordenado. Claramente, esse algoritmo tem tempo de execução O(n log n). De fato, Construaheap é feito em tempo O(n) e como são realizadas n 1 execuções do laço para, e Max-conserta-heap é executado em tempo O(log n), temos que o tempo total gasto por Heapsort é O(n log n). Ademais, não é difícil perceber que se o vetor de entrada estiver ordenado, Heapsort leva tempo Ω(n log n). Portanto, o tempo de execução do Heapsort é Θ(n log n). 96

103 Capítulo 11 Quicksort O algoritmo Quicksort é um algoritmo que resolve o problema de ordenação e tem tempo de execução de pior caso Θ(n 2 ), bem pior que o tempo O(n log n) gasto por Heapsort e Mergesort. Porém, muitas vezes o Quicksort oferece a melhor escolha na prática. Isso se dá pelo fato de seu tempo de execução ser em média Θ(n log n) e a constante escondida em Θ(n log n) ser bem pequena. Vamos descrever o algoritmo Quicksort e fazer uma análise do tempo médio de execução do Quicksort. Seja A[1..n] um vetor. O algoritmo Quicksort faz uso do método de divisão e conquista (assim como o Mergesort). O algoritmo funciona como segue: um elemento de A chamado de pivô, é escolhido dentre todos os elementos de A. Feito isso, o Quicksort reorganiza o vetor A de modo que o pivô fique em sua posição final (no vetor ordenado), digamos A[x], todas as chaves em A[1,..., x 1] são menores que o pivô e todas as chaves em A[x + 1,..., n] são maiores que o pivô. O próximo passo é ordenar recursivamente os vetores A[1,..., x 1] e A[x + 1,..., n]. O algoritmo Partição abaixo reorganiza o vetor A[início,..., fim] in-place, retornando a posição correta do pivô escolhido.

104 Algoritmo 28: Partição(A, início, f im) 1 pivô = A[fim] 2 i = início 3 para j = início até fim 1 faça 4 se A[j] pivô então 5 troca A[i] e A[j] 6 i = i troca A[i] e A[fim] 8 retorna i Na Figura 11.1 temos um exemplo de execução do procedimento Partição. A seguinte invariante de laço pode ser utilizada para provar a corretude do algoritmo Partição(A, início, fim). Invariante: Partição Antes de cada iteração do laço para indexada por j, temos A[fim]=pivô e vale que (i) para início k i 1, temos A[k] pivô; (ii) para i k j 1, temos A[k] > pivô. Teorema 11.2 O algoritmo Partição(A[1..n]) retorna um índice i tal que o pivô está na posição A[i], todo elemento em A[1..i 1] é menor ou igual ao pivô, e todo elemento em A[i + 1..n] é maior que o pivô. Demonstração. Como o pivô está inicialmente em A[f im], não precisamos nos preocupar com a condição A[fim]=pivô na invariante, dado que A[fim] só é alterado após a execução do laço. Antes da primeira iteração do laço para temos i = início 98

105 e j = início, logo as condições (i) e (ii) são trivialmente satisfeitas. Suponha que a invariante é válida antes da iteração j do laço para, i.e., para início k i 1, temos A[k] pivô, e para i k j 1, temos A[k] > pivô. Provaremos que ela continua válida imediatamente antes da (j + 1)-ésima iteração. Na j-ésima iteração do laço, caso A[j] > pivô, a única operação feita é alterar j para j + 1, de modo que a condição (ii) continua válida (nesse caso a condição (i) é claramente satisfeita). Caso A[j] pivô, trocamos A[i] e A[j] de posição, de modo que agora temos que todo elemento em A[1..i] é menor ou igual ao pivô (pois sabíamos que, para início k i 1, tínhamos A[k] pivô). Feito isso, i é incrementado para i + 1. Assim, como para início k i, temos A[k] pivô, a invariante continua válida. Ao fim da execução do laço, temos j = fim, de modo que o teorema segue diretamente da validade da invariante de laço e do fato da linha 7 trocar A[i] e A[fim] de posição. Como o laço para é executado fim início vezes, o tempo de execução de Partição é Θ(f im início). Agora podemos descrever o algoritmo Quicksort. Para ordenar A basta executar Quicksort(A, 1, n). Algoritmo 29: Quicksort(A, início, f im) 1 se início < fim então 2 i = Partição(A, início, fim) 3 Quicksort (A, início, i 1) 4 Quicksort (A, i + 1, fim) Na Figura 11.2 temos um exemplo de execução do procedimento Quicksort. Para provar que o algoritmo Quicksort funciona corretamente, usaremos indução no índice i. Teorema 11.3: Corretude de Quicksort O algoritmo Quicksort(A[início..f im]) ordena o vetor A de modo nãodescrescente. Demonstração. Claramente o algoritmo ordena um vetor que contém somente um 99

106 Figura 11.1: Partição executado em A = [3, 8, 6, 1, 5, 2, 4] com início = 1 e fim = 7. elemento (pois esse vetor já está trivialmente ordenado). Seja A um vetor com n elementos e suponha que o algoritmo funciona corretamente para vetores com menos que n elementos. Note que a linha 2 devolve um índice i que contém um elemento em sua posição final na ordenação desejada, e todos os elementos de A[início, i 1] são menores que A[i], e todos os elementos de A[i + 1, fim] são maiores que A[i]. Assim, ao executar a linha 3, por hipótese de indução sabemos que A[início, i 1] estará ordenado. Da mesma forma, ao executar a linha 4, sabemos que A[i + 1, fim] estará ordenado. Portanto, todo o vetor A fica ordenado ao final da execução de Quicksort. 100

107 Figura 11.2: Algoritmo Quicksort executado no vetor A = [3, 9, 1, 2, 7, 4, 8, 5, 0, 6] com início = 1 e fim = Tempo de execução O tempo de execução de Quicksort depende fortemente de como as chaves estão distribuídas dentro do vetor de entrada A. Se na linha 1 de Quicksort, o elemento escolhido como pivô é sempre o maior do vetor analisado, então o problema de ordenar é sempre quebrado em dois subproblemas, um de tamanho n 1 e um de tamanho 0. Lembrando que o tempo de execução de Partição(A, 1, n) é Θ(n), temos que, nesse caso, o tempo de execução de Quicksort é dado por T (n) = T (n 1) + Θ(n). Se 101

108 esse fenômeno ocorre em todas as chamadas recursivas, então temos T (n) = T (n 1) + n = T (n 2) + n + (n 1). n 1 = T (1) + i i=2 (n + 1)(n 2) = = Θ(n 2 ) Então, no caso analisado, T (n) = Θ(n 2 ). Intuitivamente, esse é o pior caso possível. Mas pode ser que o vetor seja sempre dividido em duas partes de mesmo tamanho, tendo tempo de execução dado por T (n) = 2T (n/2) + Θ(n) = Θ(n log n). Felizmente, para grande parte das possíveis ordenações iniciais do vetor A, o tempo de execução do caso médio para o Quicksort é assintoticamente bem próximo de Θ(n log n). Por exemplo, se Partição divide o problema em um subproblema de tamanho (n 1)/1000 e outro de tamanho 999(n 1)/1000, o tempo de execução é dado por T (n) = T ((n 1)/1000) + T (999(n 1)n/1000) + Θ(n) = T (n/1000) + T (999n/1000) + Θ(n). É possível mostrar que temos T (n) = O(n log n). De fato, para qualquer constante k > 1 (e.g., k = ), se Partição divide A em partes de tamanho aproximadamente n/k e (k 1)n/k, o tempo de execução ainda é O(n log n). Vamos utilizar o método da substituição para mostrar que T (n) = O(n log n). Assumindo que T (n) c para alguma constante c 1 e todo n k 1. Vamos provar que T (n) = T (n/k) + T ((k 1)n/k) + n é no máximo dn log n + n para todo n k e algum d > 0. Começamos notando que T (k) T (k 1)+T (1)+k 102

109 2c + k dk log k + k. Suponha que T (m) dm log m + m para todo k < m < n e vamos analisar T (n). T (n) = T (n/k) + T ((k 1)n/k) + n ( n ( n )) d k log + n ( ( )) (k 1)n (k 1)n k k + d (k 1)n log + + n k k k ( n ( n )) ( (k 1)n ( ( n )) ) = d k log + d log(k 1) + log + 2n k k k ( ) d(k 1)n = dn log n + n dn log k + log(k 1) + n k dn log n + n. onde a última desigualdade vale se d k/ log k. Pois para tal valor de d temos ( d(k 1)n dn log k k ) log(k 1) + n. Portanto, acabamos de mostrar que T (n) = O(n log n) quando o Quicksort divide o vetor A sempre em partes de tamanho aproximadamente n/k e (k 1)n/k. A ideia por trás desse fato que, a princípio, pode parecer contraintuitivo, é que pelo fato do tamanho da árvore de recursão nesse caso ser log k/(k 1) n = Θ(log n), e em cada passo é executada uma quantidade de passos proporcional ao tamanho do vetor analisado, então o tempo total de execução é O(n log n). Vamos agora analisar formalmente o tempo de execução de pior caso. O pior caso é dado por T (n) = max 0 x n 1 (T (x) + T (n x 1)) + n. Vamos utilizar o método da substituição para mostrar que T (n) n 2. Supondo que T (m) m 2 para todo m < n, obtemos T (n) max 0 x n 1 (x2 + c(n x 1) 2 ) + n = max 0 x n 1 (x2 + (n x 1) 2 ) + n (n 1) 2 + n = n 2 (2n 1) + n n 2, 103

110 onde o máximo na segundo linha é atingido quando x = 0 ou x = n 1. Para ver isso, seja f(x) = (x 2 + (n x 1) 2 ) e note que f (x) = 2x 2(n x 1), de modo que f ((n 1)/2) = 0. Assim, (n 1)/2 é um ponto máximo ou mínimo. Como f ((n 1)/2) > 0, temos que (n 1)/2 é ponto de mínimo de f. Portanto, os pontos máximos são x = 0 e x = n 1. Vamos agora analisar o que acontece no caso médio, quando todas as ordenações possíveis dos elementos de A tem a mesma chance de serem o vetor de entrada A. Suponha agora que o pivô é escolhido uniformemente ao acaso dentre as chaves contidas em A, i.e., cada uma das possíveis n! ordenações de A tem a mesma chance de ser a ordenação do vetor de entrada A. É fácil ver que o tempo de execução de Quicksort é dominado pela quantidade de operações feitas na linha 4 de Partição, dentro do laço para. Mostraremos agora que a variável aleatória X que conta a quantidade de vezes que essa linha é executada durante uma execução completa de Quicksort tem valor esperado O(n log n). Sejam o 1,..., o n os elementos de A em sua ordenação final (após estarem ordenados de modo crescente), i.e., o 1 < o 2 <... < o n. A primeira observação importante é que dois elementos o i e o j são comparados no máximo uma vez, pois elementos são comparados somente com o pivô e uma vez que algum elemento é o pivô ele nunca mais será comparado com nenhum outro elemento. Defina X ij como a variável aleatória indicadora para o evento o i é comparado com o j. Vamos calcular P (o i ser comparado com o j ). Comecemos notando que para o i ser comparado com o j, um dos dois precisa ser o primeiro elemento de {o i, o i+1,..., o j } a ser escolhido como pivô. De fato, caso o k com i < k < j seja escolhido como pivô antes de o i e o j, então o i e o j irão para partes diferentes do vetor ao fim da chamada atual ao algoritmo Partição e nunca serão comparados. Portanto, P (o i ser comparado com o j ) = P (o i ou o j ser o primeiro a ser escolhido como pivô em {o i, o i+1,..., o j }) 2 = j i

111 Voltando nossa atenção para a variável aleatória X, temos X = n 1 n i=1 j=i+1 X ij. Utilizando a linearidade da esperança, concluímos que E[X] = = = n 1 n i=1 j=i+1 n 1 n i=1 j=i+1 n 1 n i=1 j=i+1 n 1 < 2 i=1 n k=1 E[X ij ] P (o i ser comparado com o j ) 2 j i k n 1 = O(log n) i=1 = O(n log n). Portanto, concluímos que o tempo médio de execução de Quicksort é O(n log n). Se, em vez de escolhermos um elemento fixo para ser o pivô, escolhermos um dos elementos do vetor uniformemente ao acaso, então uma análise análoga a que fizemos aqui mostra que o tempo esperado de execução dessa versão aleatória de Quicksort é O(n log n). Assim, sem supor nada sobre a entrada do algoritmo, garantimos um tempo de execução esperado de O(n log n). 105

112 106

113 Capítulo 12 Ordenação em tempo linear Vimos alguns algoritmos com tempo de execução (de pior caso ou caso médio) Θ(n log n). Mergesort e Heapsort têm esse limitante no pior caso e Quicksort possui tempo de execução esperado da ordem de n log n. Note que esses 3 algoritmos são baseados em comparações entre os elementos de entrada. É possível mostrar, analisando uma árvore de decisão geral, que qualquer algoritmo baseado em comparações requer Ω(n log n) comparações no pior caso. Portanto, Mergesort e Heapsort são assintoticamente ótimos. Algumas vezes, quando sabemos informações extras sobre os dados de entrada, é possível obter um algoritmo de ordenação em tempo linear. Obviamente, tais algoritmos não são baseados em comparações. Para exemplificar, vamos discutir o algoritmo Counting sort a seguir Counting sort Assuma que o vetor de entrada A contém somente números inteiros entre 0 e k. Quando k = O(n), o algoritmo Counting sort é executado em tempo Θ(n). Será necessário utilizar um vetor extra B com n posições e um vetor C com k posições, de modo que o algoritmo não é in-place. A ordem relativa de elementos iguais será mantida, de modo que o algoritmo é estável. Para cada elemento x em A, o Counting sort verifica quantos elementos de A são menores ou iguais a x. Assim, o algoritmo consegue colocar x na posição correta

114 sem precisar fazer nenhuma comparação. O algoritmo pode ser visto abaixo. Algoritmo 30: Counting sort(a, k) /* C é um vetor auxiliar e B guardará o vetor ordenado */ 1 Sejam B[1..A.tamanho] e C[0..k] novos vetores /* Inicializando o vetor C */ 2 para i = 0 até k faça 3 C[i] = 0 /* C[i] conterá a quantidade de ocorr^encias de i em A */ 4 para j = 1 até n faça 5 C[A[j]] = C[A[j]] + 1 /* C[i] conterá a quantidade de ocorr^encias de elementos de {0,... i} em A */ 6 para i = 1 até k faça 7 C[i] = C[i] + C[i 1] /* Colocando o resultado da ordenaç~ao de A em B */ 8 para j = n até 1 faça 9 B[C[A[j]]] = A[j] 10 C[A[j]] = C[A[j]] 1 11 retorna B A Figura 12.1 contém um exemplo de execução do algoritmo Counting sort. Os quatro laços para existentes no algoritmo Counting-sort são executados, respectivamente, k, n, k e n vezes. Portanto, claramente a complexidade do procedimento é Θ(n + k). Concluímos então que quando k = O(n), o algoritmo Counting sort é executado em tempo Θ(n), de modo que é assintoticamente mais eficiente que todos os algoritmos de ordenação vistos aqui. Uma característica importante do algoritmo é que ele é estável. Esse algoritmo é comumente utilizado como subrotina de um outro algoritmo de ordenação em tempo linear, chamado Radix sort, e é essencial para o funcionamento do Radix sort que o Counting sort seja estável. 108

115 Figura 12.1: Execução do Counting sort no vetor A = [3, 0, 5, 4, 3, 0, 1, 2]. 109

116 110

117 Parte IV Técnicas de construção de algoritmos

118

119 Capítulo 13 Programação dinâmica Dynamic programming is a fancy name for divide-and-conquer with a table. Ian Parberry Problems on Algorithms, Programação dinâmica é uma importante técnica de construção de algoritmos, utilizada em problemas cujas soluções podem ser modeladas de forma recursiva. Assim, como na divisão e conquista, um problema gera subproblemas que serão resolvidos recursivamente. Porém, quando a solução de um subproblema precisa ser utilizada várias vezes em um algoritmo de divisão e conquista, a programação dinâmica pode ser uma eficiente alternativa no desenvolvimento de um algoritmo para o problema. Uma das características mais marcantes da programação dinâmica é evitar resolver o mesmo subproblema diversas vezes. Isso pode ser feito de duas formas (abordagens top-down e bottom-up), que veremos ao longo deste capítulo.

120 13.1 Um problema simples Antes de discutirmos a técnica de programação dinâmica, vamos analisar o problema de encontrar o n-ésimo número da sequência de Fibonacci para obter um pouco de intuição sobre o que será discutido adiante. A sequência 1, 1, 2, 3, 5, 8, 13, 21, 34,... é conhecida como sequência de Fibonacci. O n-ésimo termo dessa sequência, denotado por F (n), é dado por F (1) = 1, F (2) = 1 e para n 3 temos F (n) = F (n 1) + F (n 2). Assim, o seguinte algoritmo recursivo para calcular o n-ésimo número da sequência de Fibonacci é muito natural. Algoritmo 31: Fibonacci(n) 1 se n 2 então 2 retorna 1 3 retorna Fibonacci(n 1) + Fibonacci(n 2) O algoritmo acima é extremamente ineficiente. De fato, muito trabalho repetido é feito, pois subproblemas são resolvidos recursivamente diversas vezes. A Figura?? mostra como alguns subproblemas são resolvidos várias vezes em uma chamada a Fibonacci(5). Podemos estimar o método da substituição para mostrar que o tempo de execução T (n) = T (n 1) + T (n 2) + 1 de Fibonacci(n) é Ω (( (1 + 5)/2 ) n). Para ficar claro de onde tiramos o valor ( (1 + 5)/2 ) n, vamos provar que T (n) x n para algum x 1 de modo que vamos verificar qual o maior valor de x que conseguimos obter. Seja T (1) = 1 e T (2) = 3. Vamos provar o resultado para todo n 2. Assim, temos que T (2) x 2, para todo x 3 1, 732. Suponha que T (m) x n para todo 2 m n 1. Assim, aplicando isso a T (n) 114

121 temos T (n) = T (n 1) + T (n 2) + 1 x n 1 + x n 2 x n 2 (1 + x). Note que 1 + x x 2 sempre que (1 5)/2 x (1 + 5)/2. Portanto, fazendo x = (1 + 5)/2 e substituindo em T (n), obtemos T (n) = ( ( ( 1 + ) n 2 ( ( ) n 2 ( (1, 618) n. ) n 1 + ) )) 5 2 Portanto, acabamos de provar que o algoritmo Fibonacci é de fato muito ineficiente, tendo tempo de execução T (n) = Ω ( (1, 618) n). Mas como podemos evitar que o algoritmo repita trabalho já realizado? Uma forma possível é salvar o valor da solução de um subproblema em uma tabela na primeira vez que ele for calculado. Assim, sempre que precisarmos desse valor, a tabela é consultada antes de resolver o subproblema novamente. O seguinte algoritmo é uma variação de Fibonacci onde cada vez que um subproblema é resolvido, o valor é salvo no vetor F. Algoritmo 32: Fibonacci-TD(n) 1 Cria vetor F [1..n] 2 F[1] = 1 3 F[2] = 1 4 para i = 3 até n faça 5 F [i] = 1 6 retorna Fib-recursivo-TD(n) 115

122 Algoritmo 33: Fib-recursivo-TD(n) 1 se F [n] 0 então 2 retorna F [n] 3 F [n] = Fib-recursivo-TD(n 1) + Fib-recursivo-TD(n 2) 4 retorna F [n] O algoritmo Fibonacci-TD inicializa o vetor F [0..n] com os valores para F [0] e F [1], e todos os outros valores são inicializados com 1. Feito isso, o procedimento Fib-recursivo-TD é chamado para calcular F [n]. Note que Fib-recursivo-TD tem a mesma estrutura do algoritmo recursivo natural Fibonacci, com a diferença que em Fib-recursivo-TD, é realizada uma verificação em F antes de tentar resolver F [n]. Como cada subproblema é resolvido somente uma vez em uma execução de Fibrecursivo-TD e todas as operações realizadas levam tempo constante, então, notando que existem n subproblemas (F [0], F [1],..., F [n 1]), o tempo de execução de Fibonacci-TD é Θ(n). Note que no cálculo de Fib-recursivo-TD(n) é necessário resolver Fib-recursivo- TD(n 1) e Fib-recursivo-TD(n 2). Como o cálculo do n-ésimo número da sequência de Fibonacci precisa somente dos dois números anteriores, podemos desenvolver um algoritmo não recursivo que calcula os números da sequência em ordem crescente. Dessa forma, não é preciso verificar se os valores necessários já foram calculados, pois temos a certeza que isso já aconteceu. Algoritmo 34: Fibonacci-BU(n) 1 Cria vetor F [1..n] 2 F [1] = 1 3 F [2] = 1 4 para i = 3 até n faça 5 F [i] = F [i 1] + F [i 2] 6 retorna F [n] 116

123 13.2 Aplicação e características principais Problemas em que a programação dinâmica pode ser aplicada em geral são problemas de otimização, i.e., problemas onde estamos interessados em maximizar ou minimizar certa quantidade dadas algumas restrições. Algumas vezes a programação dinâmica pode ser usada em problemas onde estamos interessados em determinar uma quantidade recursivamente. Abaixo definimos subestrutura ótima e sobreposição de problemas, duas características que um problema deve ter para que programação dinâmica seja aplicada com sucesso. Definição 13.1: Subestrutura ótima Um problema tem subestrutura ótima se uma solução ótima para o problema pode ser obtida através de soluções ótimas de subproblemas. Definição 13.2: Sobreposição de subproblemas Um problema tem sobreposição de problemas quando pode ser dividido em subproblemas que são utilizados repetidamente em um algoritmo recursivo que resolve o problema. Se um problemas possui subestrutura ótima e sobreposição de subproblemas, dizemos que é um problema de programação dinâmica. Para clarear o entendimento sobre as Definições 13.1 e 13.2, vamos analisar um clássico problema de decidir em que ordem multiplicamos uma sequência de matrizes. No que segue, assuma que a multiplicação AB de uma matriz A de ordem k l por uma matriz B de ordem l m realiza cerca de klm operações. O problema a seguir servirá para exemplificar os tópicos discutidos nesta seção. 117

124 Problema 13.3: Multiplicação de sequências de matrizes 118

125 Dadas matrizes M 1,..., M k tais que M i é uma matriz m i m i+1, para 1 i k, encontrar a ordem em que precisamos multiplicar as matrizes para que o produto M 1 M 2... M k seja feito da forma mais eficiente possível. Perceba que a ordem em que multiplicamos as matrizes é essencial para garantir a eficiência do produto total. Por exemplo, considere k = 3, i.e., matrizes M 1, M 2 e M 3, onde m 1 = 1000, m 2 = 2, m 3 = 1000 e m 4 = 2. Se fizermos primeiro o produto M 1 M 2, i.e., estamos realizando a multiplicação ((M 1 M 2 )M 3 ), então a quantidade de operações realizadas é de cerca de m 1 m 2 m 3 + m 1 m 3 m 4 = m 1 m 3 (m 2 + m 4 ) = Porém, se calcularmos primeiro M 2 M 3, i.e., multiplicamos (M 1 (M 2 M 3 )), então a quantidade de operações realizadas é de cerca de m 2 m 3 m 4 + m 1 m 2 m 4 = m 2 m 4 (m 1 + m 3 ) = Claramente, pode haver uma grande diferença na eficiência dependendo da ordem em que as multiplicações são realizadas. Uma forma de ver que o problema de multiplicar sequência de matrizes possui subestrutura ótima é notar o seguinte: Uma forma ótima de multiplicar matrizes M 1... M k é encontrar o índice 1 i k tal que a forma ótima de multiplicar M 1... M k é multiplicar (M 1... M i ) e (M i+1... M k ) de forma ótima e depois efetuar o produto (M 1... M i )(M i+1... M k ). Portanto, para multiplicar (M 1 M 2... M i ) de forma ótima, precisamos resolver os subproblemas de multiplicar de forma ótima (M 1... M i ) e (M i+1... M k ). Para encontrar o melhor índice i para dividir o problema, precisamos considerar todas as possibilidades, i.e., i = 1, i = 2,..., i = k 1. Assim, já para escolhermos o primeiro índice i para dividir o problema inicial em dois subproblemas, já precisamos considerar o problema de multiplicar de forma ótima a sequencia M 1... M i, para 1 i k 1. Mas, por exemplo, para resolver o subproblema (M 1... M i ) precisamos considerar todos os subproblemas de multiplicar (M 1... M j ) para 1 j i 1, que são subproblemas que já foram analisados antes. Portanto, é fácil notar que o problema possui a propriedade de sobreposição de subproblemas. A programação dinâmica salva 119

126 cada subproblema analisado em uma tabela (ou uma matriz) evitando a resolução de um mesmo subproblema repetidas vezes. As propriedades de subestrutura ótima e sobreposição de subproblemas definem se um problema de otimização pode ser atacado de forma eficiente por um algoritmo de programação dinâmica. Em geral, o tempo de execução de algoritmos de programação dinâmica é determinado por dois fatores: (i) a quantidade de subproblemas que uma solução ótima utiliza; (ii) quantidade de possibilidades analisadas para determinar que subproblemas são utilizados em uma solução ótima. No exemplo do problema de multiplicação de uma sequência de matrizes, temos que (i) o problema sempre é dividido em dois subproblemas, e (ii) se o subproblema possui k matrizes, analisamos k 1 subproblemas para decidir quais duas subsequências compõem a solução ótima. Dado um problema, podemos dividir os passos para a elaboração de um algoritmo de programação dinâmica para o problema como na definição abaixo. Definição 13.4: Construindo algoritmos de programação dinâmica Os seguintes três passos compõem as etapas de construção de um algoritmo de programação dinâmica. (1) Caracterização da estrutura ótima e do valor de uma solução ótima recursivamente; (2) Cálculo do valor de uma solução ótima; (3) Construção de uma solução ótima. Antes de resolvermos alguns problemas utilizando a técnica de programação dinâmica seguindo os passos acima, vamos discutir brevemente duas formas de implementar essa técnica, que são as abordagens top-down e bottom-up. Na abordagem top-down, o algoritmo é desenvolvido de forma recursiva natural, com a diferença que, sempre que um subproblema for resolvido, o resultado é salvo em uma tabela. Assim, sempre que o algoritmo precisar da solução de um subproblema, ele consulta a tabela antes de resolver o subproblema. Em geral, algoritmos top-down são compostos por dois procedimentos, um que faz uma inicialização de variáveis e 120

127 prepara a tabela, e outro procedimento que compõe o análogo a um algoritmo recursivo natural para o problema. Veja os Algoritmos 32 e 33. Na abordagem bottom-up, é necessário entender quais os tamanhos dos subproblemas que precisam ser resolvidos antes de resolvermos o problema. Assim, resolvendo os subproblemas em ordem crescente de tamanho, i.e., começando pelos menores, conseguimos garantir que ao resolver um subproblema de tamanho n, todos os subproblemas menores necessários já foram resolvidos. Essa abordagem dispensa verificar se um dado subproblema já foi resolvido, dado que temos a certeza que isso já aconteceu. Em geral as duas abordagens fornecem algoritmos com mesmo tempo de execução assintótico. No final deste capítulo apresentamos uma comparação entre aspectos de algoritmos top-down e bottom-up Utilizando programação dinâmica Nesta seção vamos desenvolver e analisar algoritmos de programação dinâmica para diversos problemas de programação dinâmica, discutindo algoritmos top-down e bottomup para alguns desses problemas Corte de hastes Imagine que uma empresa corta e vende pedaços de hastes de aço. As hastes são vendidas em pedaços de tamanho inteiro, onde uma haste de tamanho i tem preço de venda p i. Por alguma razão, hastes de tamanho menor podem ter um preço maior que hastes maiores. A empresa deseja cortar uma haste de tamanho inteiro e vender os pedaços de modo a maximizar o lucro obtido. Problema 13.1: Corte de hastes Sejam p 1,..., p n inteiros positivos que correspondem, respectivamente, ao preço de venda de hastes de tamanho 1,..., n. Dado um inteiro positivo n, o problema consiste em maximizar o lucro l n obtido com a venda de uma haste de tamanho n, que pode ser vendida em pedaços de tamanho inteiro. Para exemplificar o problema, considere uma haste de tamanho 6 com preços dos 121

128 pedaços como na tabela abaixo. n p 1 p 2 p 3 p 4 p 5 p Tabela 13.1: Preços para o problema do corte de uma haste de tamanho 6. Note que se a haste for vendida sem nenhum corte, então temos lucro l 6 = 20. Caso cortemos um pedaço de tamanho 5, então a única possibilidade é vender uma parte de tamanho 5 e outra de tamanho 1, que fornece um lucro de l 6 = p 5 + p 1 = 13, o que é pior que vender a haste inteira. Caso efetuemos um corte de tamanho 4, o que aparentemente é uma boa opção (dado que p 4 é um valor alto), então o melhor a se fazer é vender uma parte de tamanho 4 e outra de tamanho 2, obtendo lucro l 6 = p 4 + p 2 = 23. Porém, se vendermos dois pedaços de tamanho 3, obtemos um lucro total de l 6 = 2p 3 = 28, que é o maior lucro possível. De fato, vender somente pedaços de tamanho 2 ou 1 garantirá um lucro menor. Primeiro vamos construir um algoritmo de divisão e conquista natural para o problema do corte de hastes. Podemos definir l n recursivamente definindo onde aplicar o primeiro corte na haste. Assim, se o melhor lugar para realizar o primeiro corte na haste é no ponto i (onde 1 i n), então o lucro total é dado por l n = p i + l n i, que é o preço do pedaço de tamanho i somado ao maior lucro possível obtido com a venda do restante da haste, que tem tamanho n i. Portanto, temos l n = max 1 i n {p i + l n i }. (13.1) A igualdade (13.1) sugere o seguinte algoritmo para resolver o problema, onde p é um vetor contendo os preços dos pedaços de uma haste de tamanho n. 122

129 Algoritmo 35: Corte hastes-dv(n,p) 1 se n = 0 então 2 retorna 0 3 lucro = 0 4 para i = 1 até n faça 5 valor = p i + Corte hastes-dv(n i,p) 6 se lucro < valor então 7 lucro = valor 8 retorna lucro Apesar de ser um algoritmo intuitivo e calcular corretamente o lucro máximo possível, ele é extremamente ineficiente, pois muito trabalho é repetido pelo algoritmo. De fato, seja T (n) o tempo de execução de Corte hastes-dv(n,p). Vamos utilizar o método da substituição para provar que T (n) 2 n. Claramente temos T (0) = 1 = 2 0. Suponha que T (m) 2 m para todo 0 m n 1. Portanto, notando que T (n) = 1 + T (0) + T (1) T (n 1), obtemos T (n) = 1 + T (0) + T (1) T (n 1) 1 + ( n 1 ) = 2 n. Assim, o problema possui a propriedade de sobreposição de subproblemas. Claramente, o problema também possui a propriedade de subestrutura ótima, dado que inclusive já modelamos o valor de uma solução ótima baseado em soluções ótimas de subproblemas (veja (13.1)). Portanto, o problema tem os ingredientes necessários para que um algoritmo de programação dinâmica o resolva de forma eficiente. Abaixo apresentamos um algoritmo com abordagem top-down para o problema do corte de hastes. Esse algoritmo mantém a estrutura de Corte hastes-dv(n,p), salvando os valores de soluções ótimas de subproblemas em um vetor r[0..n], de modo que r[i] contém o valor de uma solução ótima para o problema de corte de uma haste de tamanho i. Ademais, vamos manter um vetor s[0..n] tal que s[j] contém o primeiro 123

130 lugar que deve-se efetuar o corte em uma haste de tamanho j. Algoritmo 36: Corte hastes-td(n, p) 1 Cria vetores r[0..n] e s[0..n] 2 r[0] = 0 3 para i = 1 até n faça 4 r[i] = 1 5 retorna Corte hastes-aux(n, p, r, s) Algoritmo 37: Corte hastes-aux(n,p,r,s) 1 se r[n] 0 então 2 retorna r[n] 3 lucro = 1 4 para i = 1 até n faça 5 (valor, s) = Corte hastes-aux(n i,p,r,s) 6 se lucro < p i + valor então 7 lucro = p i + valor 8 s[n] = i 9 r[n] = lucro 10 retorna (lucro, s) O algoritmo Corte hastes-td(n) inicialmente cria os vetores r e s, faz r[0] = 0 e inicializa todas as outras entradas de r com 1, representando que ainda não calculamos esses valores. Feito isso, Corte hastes-aux(n,p,r,s) é executado. Inicialmente, nas linhas 1 e 2, o algoritmo Corte hastes-aux(n,p,r,s) verifica se o subproblema em questão já foi resolvido. Caso o subproblema não tenha sido resolvido, então o algoritmo vai fazer isso de modo muito semelhante ao algoritmo 35. A diferença é que agora salvamos o melhor local para fazer o primeiro corte em uma haste de tamanho n em s[n]. Vamos analisar agora o tempo de execução de Corte hastes-td(n,p,r,s), que obviamente tem, assintoticamente, o mesmo tempo de execução de Corte hastes- 124

131 aux(n,p,r,s). Note que cada chamada recursiva de Corte hastes-aux a um subproblema que já foi resolvido retorna imediatamente, e todas as linhas são executadas em tempo constante. Como salvamos o resultado sempre que resolvemos um subproblema, cada subproblema é resolvido somente uma vez. Na chamada recursiva em que resolvemos um subproblema de tamanho m (para 1 m n), o laço para na linha 4 é executado m vezes. Assim, como existem subproblemas de tamanho 0, 1,..., n, o tempo de execução T (n) de Corte hastes-aux é assintoticamente dado por T (n) = n = Θ(n 2 ). Caso precise imprimir os pontos em que os cortes foram efetuados, basta executar o seguinte procedimento. Algoritmo 38: Imprime cortes(n,p) 1 (lucrot otal, s) = Corte hastes-td(n, p) 2 enquanto n > 0 faça 3 Imprime s[n] 4 n = n s[n] Vamos ver agora como é um algoritmo com abordagem bottom-up para o problema do corte de hastes. A ideia é simplesmente resolver os problemas em ordem de tamanho de hastes, pois assim quando formos resolver o problema para uma haste de tamanho j, temos a certeza que todos os subproblemas menores já foram resolvidos. Abaixo temos o algoritmo que torna esse raciocínio preciso. 125

132 Algoritmo 39: Corte hastes-bu(n,p) 1 Cria vetores r[0..n] e s[0..n] 2 r[0] = 0 3 para i = 1 até n faça 4 lucro = 1 5 para j = 1 até i 1 faça 6 se lucro < p j + r[i j 1] então 7 lucro = p j + r[i j 1] 8 s[i] = j 9 r[i] = lucro 10 retorna (r[n], s) 13.4 Comparando algoritmos top-down e bottomup Nesta curta seção comentamos sobre alguns aspectos positivos e negativos das abordagens top-down e bottom-up. Algoritmos top-down possuem a estrutura muito semelhante a de um algoritmo recursivo cuja construção se baseia na estrutura recursiva da solução ótima. Já na abordagem bottom-up, essa estrutura não existe, de modo que o código pode ficar complicado no caso onde muitas condições precisam ser analisadas. Por outro lado, algoritmo bottom-up são geralmente mais rápidos, por conta de sua implementação direta, sem que diversas chamadas recursivas sejam realizadas, como no caso de algoritmos top-down. Por fim, mencionamos que embora na maioria dos casos, as duas abordagens levam a tempos de execução assintoticamente iguais, é possível que a abordagem top-down seja assintoticamente mais eficiente no caso onde vários subproblemas não precisam ser resolvidos. Nesse caso, um algoritmo bottom-up resolveria todos os subproblemas, mesmo os desnecessários, diferentemente do algoritmo top-down, que resolve somente os subproblemas necessários. 126

133 Parte V Algoritmos em grafos

134

135 Capítulo 14 Grafos Um grafo G é uma estrutura formada por um par (V, E), onde V é um conjunto finito e E é um conjunto de pares de elementos de V. O conjunto V é chamado de conjunto de vértices e E é o conjunto de arestas de G. Um digrafo D = (V, A) é definido como um conjunto de vértices V e um conjunto de arcos A, que é um conjunto de pares ordenados de V, i.e., um grafo cujas arestas tem uma direção associada. Um grafo com conjunto de vértices V = {v 1,..., v n } é dito simples quando não existem arestas do tipo {v i, v i } e para cada par de índices 1 i < j n existe no máximo uma aresta {v i, v j }. De modo similar, um digrafo com conjunto de vértices V = {v 1,..., v n } é dito simples quando não existem arestas do tipo (v i, v i ) e para cada par de índices 1 i < j n existe no máximo uma aresta (v i, v j ) e no máximo uma aresta (v j, v i ). Todos os grafos e digrafos considerados aqui, a menos que dito explicitamente o contrário, são simples. Note que o máximo de arestas que um grafo (resp. digrafo) com n vértices pode ter é n(n 1)/2 (resp. n(n 1)). Dado um grafo G, denotamos o conjunto de vértices de G e o conjunto de arestas de G, respectivamente, por V (G) e E(G). Por simplicidade, vamos muitas vezes denotar arestas {u, v} de um grafo por uv. No caso de digrafos, vamos utilizar uv para aresta orientada (u, v). A Teoria de Grafos, que estuda essas estruturas, tem aplicações em diversas áreas do conhecimento, como Bioinformática, Sociologia, Física, Computação e muitas outras, e teve início em 1736 com Leonhard Euler, que estudou um problema conhecido como o problema das sete pontes de Königsberg.

136 Figura 14.1: Representação gráfica de um grafo G e um digrafo D Formas de representar um grafo Certamente podemos representar grafos simplesmente utilizando conjuntos para vértices e arestas. Porém, é desejável utilizar alguma estrutura de dados que nos permita ganhar em eficiência dependendo da tarefa que necessitamos. As duas formas mais comuns de se representar um grafo utilizam listas de adjacências ou matrizes de adjacências. Por simplicidade vamos assumir que um grafo com n vértices tem conjunto de vértices {1, 2,..., n}. Na representação por listas de adjacências, um grafo G = (V, E) consiste em um vetor L G com V listas de adjacências, uma para cada vértice, onde L G (u) contém uma lista encadeada com todos os vizinhos de u em G. Em L G (u) temos a cabeça da lista que contém N(u). Na representação por matriz de adjacências, um grafo G = (V, E) é uma matriz simétrica A = (a ij ) de tamanho V V onde a ij = 1 se ij E, e a ij = 0 caso contrário. No caso de grafos direcionados, temos a ij = 1 se (i, j) E, e a ij = 0 caso contrário. Em geral, o uso das listas de adjacências são preferidas para representar grafos esparsos, que são grafos com n vértices e o(n 2 ) arestas, pois não é necessário alocar n 2 espaços de memória somente para representar o grafo. Já a representação por matriz 130

137 Figura 14.2: Representação gráfica de um grafo G e um digrafo D e suas listas de adjacências. Figura 14.3: Representação gráfica de um grafo G e um digrafo D e suas matrizes de adjacências. de adjacências é muito usada para representar grafos densos, que são grafos com Θ(n 2 ) 131

138 arestas. Porém, ressaltamos que esse não é o único fator importante na escolha da estrutura de dados utilizada para representar um grafo, pois determinados algoritmos precisam de propriedades da representação por listas e outros da representação por matriz para serem eficientes Conceitos essenciais No que segue, considere um grafo G = (V, E). Dizemos que u e v são vizinhos (ou adjacentes se {u, v} E. A vizinhança de um vértice u, denotada por N G (u) (ou simplesmente N(u)) é o conjunto dos vizinhos de u. Dizemos ainda que u e v são extremos da aresta {u, v}, que u é adjacente a v (e vice versa). Ademais, dizemos que a aresta {u, v} incide em u e em v. Arestas que dividem o mesmo extremo são chamadas de adjacentes. O grau de um vértice v, denotado por d G (v) (ou simplesmente d(v)) é a quantidade de vértices na vizinhança de v, i.e., N(v). O grau mínimo de um grafo G, denotado por δ(g), é o menor grau de um vértice de G dentre todos os vértices de G, i.e., δ(g) = min{d G (v): v V }. O grau máximo de um grafo G, denotado por (G), é o maior grau de um vértice de G dentre todos os vértices de G, i.e., (G) = max{d G (v): v V }. O grau médio de G, denotado por d(g), é a média dos graus de todos os vértices de G, i.e., d(g) = v V (G) d(v). V (G) 14.3 Trilhas, passeios, caminhos e ciclos Dado um grafo G = (V, E), um passeio em G é uma sequência não vazia de vértices P = (v 0, v 1,..., v k ) tal que v i v i+1 E para todo 0 i < k. Dizemos que P é um passeio de v 0 a v k e que P passa pelos vértices v i (1 i k) e pelas arestas v i v i+1 (1 i < k). 132

139 Os vértices v 0 e v k são, respectivamente, o começo e o fim de P, e os vértices v 1,..., v k 1 são os vértices internos do passeio P. Denotamos por V (P ) o conjunto de vértices que fazem parte de P, i.e., V (P ) = {v 0, v 1,..., v k }, e denotamos por E(P ) o conjunto de arestas que fazem parte de P, i.e., E(P ) = { v 0 v 1, v 1 v 2,..., v k 1 v k }. O comprimento de P é a quantidade de arestas de P. Denotamos um caminho de comprimento n por P n. Note que na definição de passeio podem existir arestas repetidas. Passeios em que não há repetição de arestas são chamados de trilhas. Caso um passeio não tenha nem vértices repetidos, dizemos que esse passeio é um caminho. Um passeio é dito fechado se seu começo e fim são o mesmo vértice. Uma trilha fechada em que o início e os vértices internos são dois a dois distintos é chamada de ciclo. Denotamos um ciclo de comprimento n por C n. Figura 14.4: Passeios, trilhas, ciclos e caminhos. Um subgrafo H = (V H, E H ) de um grafo G = (V G, E G ) é um grafo com V H V G e E H é um conjunto de pares em V H tal que E H E G. O subgrafo H é gerador se V H = V G, e dado um conjunto de vértices W V G, dizemos que um subgrafo H de G é induzido por W se V H = W e uv V H se e somente se uv E G. Dado F E G, um subgrafo H de G é induzido por F se E H = F e v é um vértice de H se e somente se existe alguma aresta de F que incide em v. Um grafo G = (V G, E G ) é conexo se existe um caminho entre quaisquer dois vértices 133

140 de V G. Uma árvore T com n vértices é um grafo conexo com n 1 arestas ou, alternativamente, é um grafo conexo sem ciclos. Figura 14.5: Exemplos de árvores. 134

141 Capítulo 15 Buscas Algoritmos de busca são importantíssimos em grafos. Eles permitem inspecionar as arestas do grafo de forma sistemática de modo que todos os vértices do grafo são visitados. Ademais, algoritmos de busca servem de inspiração para vários algoritmos importantes. Dentre eles, mencionamos o algoritmo de Prim para encontrar árvores geradoras mínimas em grafos e o algoritmo de Dijkstra para encontrar caminhos mais curtos Busca em largura Dado um grafo G = (V, E) e um vértice s V, o algoritmo de busca em largura visita todos os vértices v que são alcançáveis por algum caminho partindo de s. Por simplicidade, ao longo desta seção assumimos que o grafo G em que aplicamos a busca em largura é conexo. Nesse processo, o algoritmo calcula a distância entre s e v, i.e., a quantidade de arestas do menor caminho entre s e v. Essa distância é salva em um atributo v.distancia. Denotamos a distância entre vértices u e v como d G (u, v). Apesar de estarmos considerando um grafo G = (V, E), o algoritmo para digrafos é essencialmente o mesmo. O nome do algoritmo vem do fato de, nesse processo, primeiramente serem explorados os vértices à distância 1 de s, seguido pelos vértices à distância 2 de s e assim por diante. Para possibilitar a exploração dos vértices de G dessa maneira, vamos utilizar uma fila como estrutura de dados auxiliar. Inicialmente, colocamos o vértice s na fila. Enquanto a fila não ficar vazia remove-

142 mos um elemento u da fila (inicialmente, s é removido), adicionamos os vizinhos de u à fila, calculamos a distância de s a u, e repetimos o procedimento. Note que após s, os próximos vértices removidos da fila são os vizinhos de s, depois os vizinhos dos vizinhos de s e assim por diante. Manteremos, para cada vértice v, um atributo v.pai que indicará o caminho percorrido de s até v, e um atributo v.visitado indicando se v já foi explorado pelo algoritmo. Para a busca em largura, veremos que será conveniente utilizar a representação de grafos em listas de adjacências. Abaixo temos o pseudocódigo para esse procedimento. Algoritmo 40: Busca Largura(G = (V, E), s) 1 para todo vértice v V \ {s} faça 2 v.visitado = 0 3 v.pai = null 4 s.visitado = 1 5 s.distancia = 0 6 cria fila vazia F 7 Enfileira(F, s) 8 enquanto Fila F não é vazia faça 9 u = Desenfileira(F ) 10 para todo vértice v N(u) faça 11 se v.visitado = 0 então 12 v.visitado = 1 13 v.distancia = u.distancia v.pai = u 15 Enfileira(F, v) Vamos agora explicar o algoritmo de Busca Largura em detalhes: o algoritmo primeiramente inicializa todas as distâncias como infinito e todos os pais como null. Feito isso, criamos a fila F, atualizamos a distância de s para 0, indicamos que s foi visitado e enfileiramos s. A partir daí vamos repetir o seguinte procedimento: desenfileiramos um vértice, chamado de u; para todo vizinho v de u que não foi visitado 136

143 ainda (i.e., com v.visitado = 0) vamos marcar esse vértice como visitado, atualizar a distância de s a v, atualizar v.pai para o vértice imediatamente antes de v em um caminho mínimo de s a v e colocar v na fila. Na Figura 15.1 simulamos uma execução da busca em largura começando no vértice s. Figura 15.1: Execução de Busca Largura(G = (V, E), s). Seja n = V G e m = E G. Vamos analisar o tempo de execução do algoritmo Busca Largura aplicado em um grafo G = (V, E). Na inicialização (linhas 1 7) é gasto tempo Θ(n) e todas as outras operações levam tempo constante. Note que antes de um vértice v entrar na fila, atualizamos v.visitado de 0 para 1 ((linha 12) e depois que o laço enquanto é iniciado, nenhum vértice possui o atributo visitado mudado de 1 para 0. Assim, uma vez que um vértice entra na fila, ele ele nunca mais passará no teste da linha 11. Portanto, todo vértice entra somente uma vez na fila, e como a linha 9 sempre remove alguém da fila, o laço enquanto é executado n vezes, sendo uma execução para cada vértice. O ponto essencial da análise é a quantidade total de vezes que o laço para é executado. Esse é o ponto do algoritmo onde é essencial o uso de lista de adjacências para um algoritmo eficiente. Se utilizarmos matriz de adjacências, então o laço para é executado n vezes em cada iteração do laço enquanto, o que leva a um tempo de 137

144 execução total de Θ(n 2 ). Porém, se utilizarmos lista de adjacências, então em cada execução do laço para, ele é executado N(u) vezes, de modo que no total, é executado u V N(u) = 2m vezes, de modo que o tempo total de execução é Θ(n + m). Observe também que é fácil construir um caminho mínimo de s a qualquer vértice v. Basta seguir o caminho a partir de v, voltando para v.pai, depois (v.pai).pai e assim por diante até chegarmos em s. De fato, a árvore T com conjunto de vértices V T = {v V : v.pai null} {s} e conjunto de arestas E T = {{v.pai, v}: v V T \{s}} contém um único caminho entre s e qualquer v V T e esse caminho é um caminho mínimo Busca em profundidade Na busca em profundidade os vértices são explorados de forma diferente de como é feito na busca em largura, que explora primeiramente os vizinhos de s para somente depois explorar os vértices à distância 2 de s e assim por diante. Na busca em profundidade, exploramos os vértices seguindo um caminho a partir de s, enquanto for possível fazer isso sem repetir vértices. Ao fim desse caminho, volta-se um passo e seguimos outro caminho, e assim por diante. Cada vértice que é descoberto (visitado pela primeira vez) pelo algoritmo é inserido na pilha. A cada iteração, o algoritmo consulta o topo u da pilha, segue por um vizinho v de u ainda não explorado e adiciona v na pilha. Caso todos os vizinhos de u já tenham sido explorados, u é removido da pilha. O algoritmo vai manter uma variável encerrado com a ordem em que cada vértice teve sua toda vizinhança visitada. Cada vértice u possui três atributos: u.pai, u.fim e u.visitado. O atributo u.pai indica que vértice antecede u no caminho explorado, u.fim indica o momento em que o algoritmo termina a verificação da lista de adjacências de u (e remove u da pilha). Por fim, u.visitado é um atributo que tem valor 1 se o vértice u já foi visitado pelo algoritmo e 0 caso contrário. Abaixo temos o pseudocódigo para esse procedimento, lembrando que, dada uma pilha P, os procedimentos Empilha(P, u), Desempilha(P ) e Consulta(P) fazem, respectivamente, inserção de um elemento u, remoção do elemento no topo da pilha, e consulta ao último valor inserido em P. 138

145 Algoritmo 41: Busca Profundidade(G = (V, E), s) 1 para todo vértice v V \ {s} faça 2 v.visitado = 0 3 v.pai = null 4 s.visitado = 1 5 encerramento = 0 6 cria pilha vazia P 7 Empilha(P, s) 8 enquanto P faça 9 u = Consulta(P) 10 se existe uv E e v.visitado = 0 então 11 v.visitado = 1 12 v.pai = u 13 Empilha(P, v) 14 senão 15 encerramento = encerramento u.fim = encerramento 17 u = Desempilha(P ) O grafo A = (V A, E A ) com conjunto de vértices V A = {v V (G): v.pai null} {s} e conjunto de arestas E A = {(v.pai, v): v V A e v.pai null} é uma árvore geradora de G e é chamado de Árvore de Busca em Profundidade. Nas linhas 1-7 inicializamos alguns atributos, criamos a pilha e colocamos s na pilha. Então, nas linhas o algoritmo visita um vizinho de u ainda não visitado, o colocando na pilha. Se u não tem vizinho não visitado, então u é encerrado e retirado da pilha (linhas 15-17). Prosseguiremos agora com a análise do tempo de execução do algoritmo, onde assumimos que o grafo G está representado por uma lista de adjacências. Note que imediatamente antes de um vértice x ser empilhado (linhas 7 e 13), modificamos x.visitado de 0 para 1. Assim, tal vértice x só será empilhado uma vez em toda a 139

146 execução do algoritmo. Dessa forma, fica simples analisar o tempo de execução do algoritmo: a inicialização feita nas linhas 1 7 leva tempo O( V ), a condição na linha 10 verifica os vizinhos de cada vértice, de modo que é executada O( E ) vezes ao todo, e todas as outras instruções são executadas em tempo constante. Assim, o tempo total de execução da Busca em Profundidade é O( V + E ), como na Busca em Largura. Na Figura 15.2 simulamos uma execução da busca em profundidade começando no vértice s. Figura 15.2: Execução de Busca Profundidade(G = (V, E), s), indicando a pilha e o tempo de encerramento de cada vértice. Uma observação interessante é que, dada a estrutura em que os vértices são visitados (sempre visitando um vizinho de u, e assim por diante), é simples escrever um algoritmo recursivo para a busca em profundidade. Abaixo descrevemos o pseudocódigo para 140

147 esse algoritmo, com uma pequena variação com relação ao algoritmo anterior, que é forçar o algoritmo a ser executado até que todos os vértices sejam visitados, mesmo vértices de diferentes componentes conexas (veja linhas 7-8 de Busca Profundidade - Recursivo(G = (V, E))). Algoritmo 42: Busca Profundidade - Recursivo(G = (V, E)) 1 para todo vértice v V \ {s} faça 2 v.visitado = 0 3 v.pai = null 4 s.visitado = 1 5 encerramento = 0 6 Busca - visita(g = (V, E), s) 7 para todo u com u.visitado = 0 faça 8 Busca - visita(g = (V, E), u) Algoritmo 43: Busca - visita(g = (V, E), u) 1 u.visitado = 1 2 para todo vizinho v de u faça 3 se v.visitado == 0 então 4 v.pai = u 5 Busca - visita(g, v) 6 encerramento = encerramento u.fim = encerramento Note que o algoritmo de busca em profundidade funciona da mesma forma em um grafo orientado. O grafo F = (V F, E F ) com conjunto de vértices V F = V (G) e conjunto de arestas E F = {(v.pai, v): v V F e v.pai null} é uma floresta geradora de G e é chamado de Floresta de Busca em Profundidade. 141

148 Ordenação topológica Consideraremos agora um grafo orientado, i.e., um grafo em que suas arestas são pares ordenados. Assim, um grafo orientado G = (V, E) é um grafo com conjunto de vértices V e suas arestas são pares ordenados (u, v) de E. O grafo que vamos considerar não tem ciclos que respeitam a orientação, i.e., não existe uma sequência de pelo menos três vértices (v 1, v 2,..., v k ) tal que (v i, v i+1 ) é uma aresta para todo 1 i k 1, e (v k v 1 ) é uma aresta. Um grafo orientado sem ciclos é chamado de grafo orientado acíclico. Uma ordenação topológica de um grafo orientado G é uma ordenação dos vértices de G tal que, para toda aresta (u, v), o vértice u aparece antes de v na ordenação. Assim, podemos pensar em cada uma das arestas orientadas (u, v) como representando uma relação de dependência, indicando que v depende de u. Por exemplo, os vértices podem representar tarefas e uma arestas (u, v) indica que a tarefa u deve ser executada antes da tarefa v. Diversos problemas no mundo real necessitam do uso da ordenação topológica para serem resolvidos de forma eficiente. Isso se dá pelo fato de muitos problemas precisarem lidar com uma certa hierarquia de pré-requisitos ou dependências. Por exemplo, para montar qualquer placa eletrônica composta de diversas partes, é necessário saber exatamente em que ordem devemos colocar cada componente da placa. Isso pode ser feito de forma simples modelando o problema em um grafo orientado que representa tal dependência e fazendo uso da ordenação topológica. Outra aplicação que exemplifica bem a importância da ordenação topológica é o problema de escalonar tarefas respeitando todas as dependências entre as tarefas. O seguinte algoritmo encontra uma ordenação topológica de um grafo ordenado G. Algoritmo 44: Ordenação topológica(g = (V, E)) 1 cria uma lista de elementos L inicialmente vazia 2 executa Busca Profundidade(G) e toda vez que um vértice v é encerrado ele é inserido no começo da lista L 3 retorna L Nas Figuras Ordenação topológica e 15.4 abaixo temos um exemplo de execução do algoritmo 142

149 Figura 15.3: Um grafo orientado acı clico com ve rtices representando to picos de estudo de uma disciplina, e uma aresta (u, v) indica que o to pico u deve ser compreendido antes do estudo referente ao to pico v. Para cada ve rtice u, indicamos o valor de u.fim. Figura 15.4: Uma ordenac a o topolo gica obtida com uma execuc a o de Ordenac a o topolo gica no grafo da Figura

150 Componentes fortemente conexas Dado um grafo orientado G = (V, E), uma componente fortemente conexa de G é um subgrafo G = (V, E ) maximal de G com respeito à seguinte propriedade: para todo par u, v V existe um caminho de u para v e outro de v para u em G. Sejam G 1,..., G k o conjunto de todas as componentes fortemente conexas de G. Pela maximalidade das componentes, cada vértice pertence somente a uma componente e, mais ainda, entre quaisquer duas componentes G i e G j existem arestas apenas em uma direção, caso contrário, a união de G i e G j formaria uma componente maior que as duas sozinhas, contradizendo a maximalidade da definição. Um simples algoritmo para encontrar componentes fortemente conexas faz uso da busca em profundidade. Dado um grafo direcionado G, vamos executar duas buscas em profundidade, sendo uma em G e uma no grafo Ḡ, que é o grafo obtido de G invertendo o sentido de todas suas arestas. No algoritmo que segue, Ḡ é o grafo descrito acima. Algoritmo 45: Componentes fortemente conexas(g = (V, E)) 1 executa Busca Profundidade - Recursivo(G) 2 Seja v.encerramento como calculado na linha 1 para todo v V (G) 3 Visitando os vértices em ordem decrescente de v.encerramento como na linha 2, executa Busca Profundidade - Recursivo(Ḡ) Se o grafo estiver representado com lista de adjacências, então o algoritmo acima funciona em tempo O( V + E ) Outras aplicações dos algoritmos de busca Tanto a busca em largura como a busca em profundidade podem ser aplicadas em vários problemas. Alguns exemplos são testar se um dado grafo é bipartido, detectar circuitos em grafos, encontrar caminhos entre vértices, e listar todos os vértices de uma componente conexa. Ademais, podem ser usados como ferramenta na implementação do método de Ford-Fulkerson, que calcula o fluxo máximo em uma rede de fluxos. Uma outra aplicação interessante do algoritmo de Busca em Profundidade é resolver de forma eficiente (tempo O( V + E )) o problema de encontrar um caminho ou circuito 144

151 Euleriano. Algoritmos importantes em grafos têm estrutura semelhante ao algoritmo de busca em largura, como é o caso do algoritmo de Prim para encontrar uma árvore geradora mínima, e o algoritmo de Dijkstra, que encontra caminhos mínimos em grafos com pesos não-negativos nas arestas. Além de todas essas aplicações dos algoritmos de busca em problemas clássicos da Teoria de Grafos, esses algoritmos continuam sendo de extrema importância no desenvolvimentos de novos algoritmos. O algoritmo de Busca em Profundidade, por exemplo, vem sendo muito utilizado em algoritmos que resolvem problemas em Teoria de Ramsey, uma vertente da Teoria de Grafos e Combinatória. 145

152 146

153 Capítulo 16 Árvores geradoras mínimas Uma árvore geradora de um grafo G é uma árvore que é um subgrafo gerador de G, i.e., uma árvore que contém todos os vértices de G. Dado um grafo G = (V G, E G ) e uma função w : E G R de pesos nas arestas de G, diversas aplicações necessitam encontrar uma árvore geradora T = (V T, E T ) de G que tenha peso total w(t ) mínimo dentre todas as árvores geradoras de G, i.e., uma árvore T tal que w(t ) = w(e) = min{w(t ): T é uma árvore geradora de G}. e E T Uma árvore T com essas propriedades é uma árvore geradora mínima de G. Figura 16.1: Exemplo de um grafo G e uma árvore geradora mínima (representada pelas arestas ressaltadas).

154 Apresentaremos alguns conceitos e propriedades relacionadas a árvores geradoras mínimas e depois discutiremos algoritmos gulosos que encontram uma árvore geradora mínima de G. Dado um grafo G = (V G, E G ) e um conjunto de vértices S V G, um corte (S, V G \S) de G é uma partição de V G. Uma aresta uv cruza o corte (S, V G \S) se u S e v V G \S. Por fim, uma aresta que cruza um corte (S, V G \ S) é mínima se tem peso mínimo dentre todas as arestas que cruzam (S, V G \ S). Antes de discutirmos algoritmos para encontrar árvores geradoras mínimas vamos entender algumas características de arestas que cruzam cortes para obter uma estratégia gulosa para o problema. Lema 16.1 Sejam G = (V G, E G ) um grafo e w : E R uma função de pesos. Se e é uma aresta de um ciclo C e cruza um corte (S, V G \ S), então existe outra aresta de C que cruza o corte (S, V G \ S). Demonstração. Seja e = {u, v} uma aresta de G como no enunciado, onde u S e v (V G \ S). Como e está em um ciclo C, existem dois caminhos distintos em C entre os vértices u e v. Um desses caminho é a própria aresta e, e o outro caminho necessariamente contém uma aresta f que cruza o corte (S, V G \ S), uma vez que u e v estão em lados distintos do corte. Uma implicação clara do Lema 16.1 é que se e é a única aresta que cruza um dado corte, então e não pertence a nenhum ciclo. Dado um corte (S, V G \ S) de um grafo G, o seguinte teorema indica uma estratégia para se obter uma árvore geradora mínima. Teorema 16.2 Sejam G = (V G, E G ) um grafo conexo e w : E R uma função de pesos. Dado um conjunto A de arestas de uma árvore geradora mínima e um corte (S, V G \ S). Se e E G \ A é uma aresta que cruza o corte e tem peso mínimo dentre todas as arestas que cruzam o corte, então existe uma árvore geradora mínima que contém A {e}. 148

155 Demonstração. Sejam G = (V G, E G ) um grafo conexo e w : E R uma função de pesos. Considere um conjunto A de arestas de uma árvore geradora mínima T e seja (S, V G \ S) um corte de G. Seja e = {u, v} E G \ A uma aresta que cruza o corte e tem peso mínimo dentre todas as arestas que cruzam o corte. Suponha por contradição que e não está em nenhuma árvore geradora mínima. Note que como T é uma árvore geradora, adicionando e a T geramos exatamente um ciclo. Assim, pelo Lema 16.1, sabemos que existe outra aresta f de T que cruza o corte (S, V G \ S). Portanto, o grafo obtido da remoção da aresta f de T e da adição da aresta e a T é uma árvore (geradora). Seja T essa árvore. Claramente, temos w(t ) = w(t ) w(f) + w(e) w(t ), onde usamos o fato de w(e) w(f), que vale pela escolha de e. Como T é uma árvore geradora de peso mínimo e temos w(t ) w(t ), então concluímos que T é uma árvore geradora mínima, uma contradição. Nas seções a seguir veremos os algoritmos de Prim e Kruskal que utilizam a ideia do Teorema 16.2 para obter árvores geradoras mínimas de grafos conexos Algoritmo de Prim Dado um grafo conexo G = (V G, E G ) e uma função de pesos nas arestas de G, o algoritmo de Prim começa obtendo uma árvore que consiste de somente uma aresta e, a cada iteração, acrescenta uma aresta à árvore obtida, aumentando assim a quantidade de arestas da árvore. O algoritmo termina quando temos uma árvore geradora de G. Para garantir que uma árvore geradora mínima é encontrada, o algoritmo começa com uma árvore vazia T = (V T, E T ), e a cada passo adiciona uma aresta mínima que cruza o corte (V T, V G \ V T ). Pelo Teorema 16.2, ao se obter uma árvore geradora, tal árvore é mínima. O algoritmo de Prim mantém uma fila de prioridades de mínimo F que contém os vértices que não estão na árvore T = (V T, E T ) que estamos construindo (inicialmente, F = V G ). A fila de prioridades F é baseada na estimativa, para cada vértice v, do peso da aresta de menor peso que conecta v à árvore T. Essa informação fica salva no atributo v.estimativa. Mantendo esses atributos atualizados, é simples encontrar uma aresta mínima que cruza (V T, V G \ V T ), aumentando o tamanho da árvore geradora. O 149

156 atributo v.pai indica o vizinho de v na árvore T. Assim, utilizando os atributos v.pai, ao fim do algoritmo de Prim, a árvore geradora mínima T terá conjunto de arestas E T = { {v, v.pai}: v V G \ {s} }, onde s é o primeiro vértice analisado pelo algoritmo, passado como entrada. O algoritmo de Prim vai manter também um atributo v.arvore para cada vértice, indicando se o vértice pertence ou não à árvore T, de modo que v.arvore = 1 se v está em T e v.arvore = 0 caso contrário. Algoritmo 46: Prim(G = (V G, E G ), w, s) 1 para todo vértice v V faça 2 v.estimativa = 3 v.pai = null 4 v.arvore = 0 5 s.estimativa = 0 6 cria fila de prioridades (min) F com conjunto V G baseada em v.estimativa 7 enquanto F faça 8 u = Remoção-min(F ) 9 u.arvore = 1 10 para todo vértice v N(u) faça 11 se v.arvore = 0 (v está em F ) e w(u, v) < v.estimativa então 12 v.pai = u 13 v.estimativa = w(u, v) 14 Diminui-chave(F, v.indice, w(u, v)) A Figura 16.2 mostra um exemplo de execução do algoritmo de Prim. O algoritmo de Prim toma, a cada passo, a decisão mais apropriada no momento (a escolha da aresta a ser incluída na árvore) e nunca muda essa decisão. Algoritmos dessa forma são conhecidos como algoritmos gulosos. Perceba a semelhança na estrutura do algoritmo de Prim e no algoritmo de busca em largura. O tempo de execução depende de como o grafo G e a fila de prioridades F 150

157 Figura 16.2: Execução do algoritmo de Prim. Um vértice fica preenchido no momento em que é removido da fila de prioridades. são implementados. Vamos assumir que G é representado por uma lista de adjacências, que é a forma mais eficiente para o algoritmo de Prim, e que F é uma fila de prioridades implementada através do uso de um heap binário como no Capítulo 6. No que segue, temos n = V G e m = E G. Na inicialização, o algoritmo leva tempo Θ(n) para executar as linhas 1 5, tempo Θ(n) para construir a fila de prioridades F na linha 6, pois um heap com n elementos pode ser construído em tempo Θ(n) (basta criar o vetor F com os elementos de V e executarconstrua-heap(f ). O laço enquanto na linha 7 é executado n vezes, uma execução para cada elemento de F. Como a operação Remoção-min(F ) executa em tempo O(log n), o tempo total gasto com as operações na linha 8 é O(n log n). (16.1) A linha 9 é claramente executada em tempo constante. O laço para na linha 10 é executado, para cada v, N(v) vezes, de modo que no total é executado Θ(m) vezes. Para finalizar a análise precisamos saber o tempo gasto com a execução das linhas 11, 12 e 13. As linhas 11, 12 e 13 são claramente executadas em tempo constante, de modo que levam tempo Θ(m) ao todo. A linha 14 executa o procedimento Diminuichave(F, v.indice, w(u, v)) que leva tempo O(log n). Assim, o tempo total gasto com 151

158 execuções da linha 14 é O(m log n). (16.2) Portanto, por (16.1) e (16.2), temos que o tempo total de execução do algoritmo de Prim é O(n log n) + O(m log n) = O ( (m + n) log n ). Como o grafo G é conexo, sabemos que G possui m n 1 arestas. Logo, concluímos que o tempo de execução do algoritmo de Prim é O ( (m + n) log n ) = O(m log n) Algoritmo de Kruskal Dado um grafo conexo G = (V G, E G ) e uma função de pesos nas arestas de G, o algoritmo de Kruskal, assim como o algoritmo de Prim, começa com um conjunto vazio A de arestas e a cada passo adiciona uma aresta e a A garantindo que A {e} é um subconjunto de uma árvore geradora mínima. Porém, diferente do que ocorre no algoritmo de Prim, o conjunto A não é uma árvore em todo momento da execução do algoritmo. O algoritmo de Kruskal vai adicionando a A sempre a aresta de menor peso que não forma ciclos com as arestas que já estão em A. Dessa forma, cada aresta adicionada pertence a uma árvore geradora mínima junto com as arestas de A. O algoritmo termina quando A tem n 1 arestas, de modo que é o conjunto de arestas de uma árvore geradora mínima de G. Para o algoritmo a seguir lembre que dado um, grafo G = (V, E) e um subconjunto A E, o grafo G[A] é o subgrafo de G com conjunto das arestas A e os vértices de V são todos os extremos de arestas de A. 152

159 Algoritmo 47: Kruskal(G = (V G, E G ), w, s) 1 Crie um vetor C[1.. E G ] e copie as arestas para C 2 Ordene C de modo não-decrescente de pesos das arestas 3 Crie conjunto A = 4 para i = 1 até E G faça 5 se G[A {C[i]}] não contém ciclos então 6 A = A {C[i]} 7 retorna (A) Nas linhas 1 e 2 o conjunto das arestas é copiado para um vetor C[1.. E G ] e ordenado. Assim, para considerar arestas de menor peso, basta percorrer o vetor C em ordem. Na linha 3 criamos o conjunto A que receberá iterativamente as arestas que compõem uma árvore geradora mínima. Nas linhas 4, 5 e 6 são adicionadas, passo a passo, aresta de peso mínimo que não formam ciclos com as arestas que já estão em A. Seja G = (V, E) um grafo com n vértices e m arestas. Se o grafo está representado por listas de adjacências, então é simples executar a linha 1 em tempo Θ(n + m). Utilizando algoritmos de ordenação como Merge sort ou Heapsort, podemos executar a linha 2 em tempo O(m log m). A linha 3 leva tempo O(1) e o laço para (linha 4) é executado m vezes. O tempo gasto na linha 5 depende de como identificamos os ciclos. Utilizando algoritmos de busca para verificar a existência de ciclos em A {C[i]} levamos tempo O(n + A ). Mas note que A possui no máximo n 1 arestas, de modo que a linha 5 é executada em tempo O(n). Portanto, como o laço é executado m vezes, no total o tempo gasto nas linhas 4 6 é O(mn). Se T (n, m) é o tempo de execução de Kruskal(G = (V G, E G ), w, s), então vale o seguinte. T (n, m) = O(n + m) + O(m log m) + O(mn) = O(m) + O(m log n) + O(mn) (16.3) = O(mn). Para entender as igualdades acima, note que como G é conexo, temos m n 1, 153

160 de modo que vale que n = O(m). Também note que como m = O(n 2 ) (em qualquer grafo simples) temos que m log m m log(n 2 ) = 2m log n = O(m log n). Mas é possível melhorar o tempo de execução em (16.3) através do uso de uma estrutura de dados apropriada. Vamos agora enxergar o algoritmo de Kruskal sob outra perspectiva: ao adicionar uma aresta que não forma ciclos com as arestas que estavam em A, o que o algoritmo faz é adicionar uma aresta entre duas componentes conexas do grafo que contém somente as arestas de A. Assim, se fizermos o algoritmo de Kruskal manter uma partição de A em componentes conexas, e a cada passo adicionar a A sempre a aresta de menor peso que conecta duas dessas componentes, não precisamos verificar a existência de ciclos, que é o fator determinante para o tempo obtido em (16.3). Para manter essas componentes conexas de modo eficiente, vamos utilizar a estrutura de dados union-find (veja Capítulo 7). Abaixo temos uma versão do algoritmo de Kruskal utilizando a estrutura union-find. Algoritmo 48: Kruskal-UF(G = (V G, E G ), w, s) 1 Crie um vetor C[1.. E G ] e copie as arestas para C 2 Ordene C de modo não-decrescente de pesos das arestas 3 Crie conjunto A = 4 para todo v V G faça 5 Cria conjunto(v) 6 para i = 1 até E G faça 7 se Find(u) Find(v), onde C[i] = {u, v} então 8 A = A {u, v} 9 Union(u, v) 10 retorna (A) A ideia é muito semelhante à do algoritmo Kruskal. Nas três primeiras linhas as arestas são ordenadas e o conjunto A é criado. Nas linhas 4 e 5 criamos um conjunto para cada um dos vértices. Esses conjuntos são nossas componentes conexas iniciais. Nas linhas 6 9 são adicionadas, passo a passo, aresta de peso mínimo que conecta 154

161 duas componentes conexas (considerando apenas as arestas de A). Note que o teste da linha 7 falha para uma aresta cujos extremos estão no mesmo conjunto. Ao adicionar uma aresta {u, v} ao conjunto A (linha 8), vamos juntar as componentes que contém u e v (linha 9). Seja G = (V, E) um grafo com n vértices e m arestas. Como na análise do algoritmo Kruskal, executamos a linha 1 em tempo Θ(n + m) e a linha 2 em tempo O(m log m). A linha 3 leva tempo O(1) e levamos tempo O(n) nas linhas 4 e 5. O laço para (linha 6) é executado m vezes. Como a linha 7 tem somente operações find, e executada em tempo O(1) e a linha 8 também é executada em tempo O(1). Precisamos analisar com cuidado o tempo de execução gasto na linha 9. Para isso, vamos estimar quantas vezes essa linha pode ser executada no total, ao fim de todas as execuções do laço para. Lembrando de como a operação Union é realizada (veja Capítulo 7), sabemos que ao utilizar Union(x, y) com x X, y Y e X Y, gastamos tempo O( X ) atualizando os representantes de todos os elementos de X. A pergunta importante a ser respondida agora é: quantas vezes um vértice pode ter seu representante atualizado? Como na operação Union somente os elementos do conjunto de menor tamanho são atualizados, então toda vez que isso acontece com um elemento x, o seu conjunto dobra de tamanho. Assim, como o grafo tem n vértices, cada vértice x tem seu representante atualizado no máximo log n vezes. Logo, de novo pelo fato do grafo ter n vértices, o tempo total gasto nas linhas 6 9 é de O(n log n). Se T (n, m) é o tempo de execução de Kruskal-UF(G = (V G, E G ), w, s), então vale o seguinte. T (n, m) = O(n + m) + O(m log m) + O(n log n) = O(m) + O(m log n) + O(m log n) = O(m log n). 155

162 156

163 Capítulo 17 Caminhos mínimos Dado um grafo ou digrafo G = (V G, E G ) e um vértice s V G, o algoritmo de busca em largura explora os vértices de G calculando a quantidade de arestas em um caminho mínimo de s a qualquer outro vértice de G alcançável a partir de s. Porém, diversas aplicações são modeladas através de grafos que possuem pesos nas arestas. Assim, é interessante encontrar caminhos mínimos em grafos levando em conta os pesos nas arestas. O peso de um caminho P = (v 0, v 1,..., v k ) é a soma dos pesos das arestas de P, i.e., k 1 w(p ) = w(v i v i+1 ). i=0 Assim, dados u, v V G, o peso de um caminho mínimo de u a v em G, denotado por δ G (u, v), é definido como min{w(p ): C é caminho de u a v}, se existe caminho de u a v, δ G (u, v) =, caso contrário. Pesos de ciclos são definidos da mesma forma, i.e., é igual a soma dos pesos das arestas do ciclo. No restante desta seção vamos considerar um digrafo G = (V G, E G ) e uma função w : E G R de pesos nas arestas de G. Antes de analisarmos algoritmos para encontrar caminhos mínimos, precisamos tratar de algumas tecnicalidades envolvendo ciclos: se existe um ciclo de peso negativo em uma trilha de u a v, então ao percorrer uma trilha que passa repetidamente por tal

164 ciclo, conseguimos obter uma trilha de u a v de peso tão pequeno quanto quisermos. Assim, no problema de caminhos mínimos vamos assumir que não existem ciclos de peso negativo no grafo em questão Algoritmo de Dijkstra Um clássico algoritmo para resolver o problema de caminhos mínimos é o algoritmo de Dijkstra. Esse algoritmo é muito eficiente, mas tem um ponto fraco, que é o fato de não funcionar quando o grafo contém arestas de peso negativo. Assim, nesta seção vamos assumir que o digrafo G em que queremos encontrar caminhos mínimos não contém arestas de peso negativo. Esse é mais um algoritmo inspirado pela estratégia de busca em largura, de modo que a estrutura do algoritmo é semelhante a estrutura do algoritmo de busca em largura e do algoritmo de Prim (para o problema de encontrar árvores geradoras mínimas). Dado um vértice s V G, que será o vértice inicial, o Algoritmo de Dijkstra calcula a distância de s a todos os vértices de G, salvando também um caminho mínimo de s aos vértices de G. Cada vértice v do grafo vai ter um atributo v.distancia que contém a melhor estimativa de distância entre s e v conhecida pelo algoritmo até o momento. Vamos fazer uso de uma fila de prioridades F baseada nas chaves v.distancia de cada vértice v V G. O algoritmo funciona como segue: a cada iteração o algoritmo calcula (de forma definitiva) o peso de um caminho mínimo de s até um certo vértice v, de modo que em V G iterações o algoritmo se encerra. Sempre que o algoritmo calcula o peso de um caminho mínimo de s a um vértice v, esse vértice v é removido da fila de prioridades F. Isso é feito de forma iterativa, de modo que a cada iteração o algoritmo encontra o peso de um caminho mínimo de s a um vértice v que ainda está em F (i.e., que o algoritmo ainda não encontrou um caminho mínimo). Tal vértice v é o vértice adjacente a algum vértice w que não está em F tal que vw é uma aresta de peso mínimo entre vértices de F e fora de F. O algoritmo também manterá atributos v.pai que permitem se obter um caminho mínimo de s a v, e os atributos v.indice contendo o índice de v dentro da fila de prioridades F. Ao fim do algoritmo a fila F fica vazia, garantindo que a distância de s a todos os vértices do grafo foi calculada. 158

165 Algoritmo 49: Dijkstra(G = (V G, E G ), w, s) 1 para todo vértice v V G faça 2 v.distancia = 3 v.pai = null 4 s.distancia = 0 5 cria fila de prioridades F com conjunto V G baseada em v.distancia 6 enquanto Fila de prioridades F não é vazia faça 7 u = Remoção-min(F ) 8 para todo vértice v N(u) faça 9 se v.distancia > u.distancia + w(u, v) então 10 v.pai = u 11 v.distancia = u.distancia + w(u, v) 12 Diminui-chave(F, v.indice, u.distancia + w(u, v)) A Figura 17.1 contém um exemplo de execução do algoritmo de Dijkstra. Assim como o algoritmo de Prim, o algoritmo de Dijkstra toma, a cada passo, a decisão mais apropriada no momento. Em outras palavras, o algoritmo escolhe o vértice v F incidente a aresta de menor peso entre vértices de F e vértices fora de F e essa decisão não é modificada no restante da execução do algoritmo. Assim, também é considerado um algoritmo guloso. O tempo de execução depende de como o grafo G e a fila de prioridades F são implementados. Assim, como na busca em largura e no algoritmo de Prim, a forma mais eficiente é representar o grafo G através de uma lista de adjacências. Vamos assumir que F é uma fila de prioridades implementada através do uso de um heap binário como no Capítulo 6. Seja n = V G e m = E G. Dado que o laço enquanto é executado n vezes, o laço para é executado N(v) vezes para cada v V G, cada operação Remoção-min(F ) é executada em tempo O(log n), e cada operação Diminui-chave(F, v, u) que leva tempo O(log n), uma análise muito similar a feita no algoritmo de Prim mostra que o tempo de execução de Dijkstra(G = (V G, E G ), w, s) é O ( (m + n) log n ). 159

166 Figura 17.1: Execução do algoritmo de Dijkstra. Vértices vermelhos indicam o momento em que o vértice entrou na fila de prioridades, e vértices azuis indicam o momento em que o vértice saiu da fila de prioridades. O seguinte resultado mostra que o algoritmo de Dijkstra calcula corretamente os caminhos mínimos. Teorema 17.1 Ao final da execução de Dijkstra(G = (V G, E G ), w, s) temos v.distancia = δ(s, v) para todo v V G. Demonstração. Nessa prova consideramos uma execução de Dijkstra(G = (V G, E G ), w, s). O Algoritmo constrói uma fila de prioridades F contendo todos os vértices do grafo e a cada iteração do laço enquanto remove um vértice de F. Ademais, uma vez que o algoritmo nunca modifica o atributo v.distancia depois que v sai de F, basta provarmos que a seguinte invariante de laço é sempre válida: 160

Análise de Algoritmos e Estruturas de Dados

Análise de Algoritmos e Estruturas de Dados Análise de Algoritmos e Estruturas de Dados Carla Negri Lintzmayer Guilherme Oliveira Mota CMCC Universidade Federal do ABC {carla.negri g.mota}@ufabc.edu.br 12 de junho de 2019 Esta versão é um rascunho

Leia mais

Projeto e Análise de Algoritmos

Projeto e Análise de Algoritmos Projeto e Análise de Algoritmos A. G. Silva Baseado nos materiais de Souza, Silva, Lee, Rezende, Miyazawa Unicamp Ribeiro FCUP 18 de agosto de 2017 Conteúdo programático Introdução (4 horas/aula) Notação

Leia mais

Projeto e Análise de Algoritmos

Projeto e Análise de Algoritmos Projeto e Algoritmos Pontifícia Universidade Católica de Minas Gerais harison@pucpcaldas.br 26 de Maio de 2017 Sumário A complexidade no desempenho de Quando utilizamos uma máquina boa, ela tende a ter

Leia mais

Estruturas de Dados Algoritmos

Estruturas de Dados Algoritmos Estruturas de Dados Algoritmos Prof. Eduardo Alchieri Algoritmos (definição) Sequência finita de instruções para executar uma tarefa Bem definidas e não ambíguas Executáveis com uma quantidade de esforço

Leia mais

Lista de Exercícios 6: Soluções Funções

Lista de Exercícios 6: Soluções Funções UFMG/ICEx/DCC DCC Matemática Discreta Lista de Exercícios 6: Soluções Funções Ciências Exatas & Engenharias o Semestre de 06 Conceitos. Determine e justifique se a seguinte afirmação é verdadeira ou não

Leia mais

Complexidade de Algoritmos

Complexidade de Algoritmos Complexidade de Algoritmos O que é um algoritmo? Sequência bem definida e finita de cálculos que, para um dado valor de entrada, retorna uma saída desejada/esperada. Na computação: Uma descrição de como

Leia mais

Elementos de Análise Assintótica

Elementos de Análise Assintótica Elementos de Análise Assintótica Marcelo Keese Albertini Faculdade de Computação Universidade Federal de Uberlândia 23 de Março de 2018 Aula de hoje Nesta aula veremos: Elementos de Análise Assintótica

Leia mais

CT-234. Estruturas de Dados, Análise de Algoritmos e Complexidade Estrutural. Carlos Alberto Alonso Sanches

CT-234. Estruturas de Dados, Análise de Algoritmos e Complexidade Estrutural. Carlos Alberto Alonso Sanches CT-234 Estruturas de Dados, Análise de Algoritmos e Complexidade Estrutural Carlos Alberto Alonso Sanches Bibliografia T.H. Cormen, C.E. Leiserson and R.L. Rivest Introduction to algorithms R. Sedgewick

Leia mais

MC102 Aula 26. Instituto de Computação Unicamp. 17 de Novembro de 2016

MC102 Aula 26. Instituto de Computação Unicamp. 17 de Novembro de 2016 MC102 Aula 26 Recursão Instituto de Computação Unicamp 17 de Novembro de 2016 Roteiro 1 Recursão Indução 2 Recursão 3 Fatorial 4 O que acontece na memória 5 Recursão Iteração 6 Soma em um Vetor 7 Números

Leia mais

Aula 1. Teoria da Computação III

Aula 1. Teoria da Computação III Aula 1 Teoria da Computação III Complexidade de Algoritmos Um problema pode ser resolvido através de diversos algoritmos; O fato de um algoritmo resolver um dado problema não significa que seja aceitável

Leia mais

ANÁLISE DE ALGORITMOS: PARTE 3

ANÁLISE DE ALGORITMOS: PARTE 3 ANÁLISE DE ALGORITMOS: PARTE 3 Prof. André Backes 2 A notação grande-o é a forma mais conhecida e utilizada de análise Complexidade do nosso algoritmo no pior caso Seja de tempo ou de espaço É o caso mais

Leia mais

É interessante comparar algoritmos para valores grandes de n. Para valores pequenos de n, mesmo um algoritmo ineficiente não custa muito para ser

É interessante comparar algoritmos para valores grandes de n. Para valores pequenos de n, mesmo um algoritmo ineficiente não custa muito para ser É interessante comparar algoritmos para valores grandes de n. Para valores pequenos de n, mesmo um algoritmo ineficiente não custa muito para ser executado 1 Fazendo estimativas e simplificações... O número

Leia mais

Algoritmos e Estrutura de Dados. Algoritmos Prof. Tiago A. E. Ferreira

Algoritmos e Estrutura de Dados. Algoritmos Prof. Tiago A. E. Ferreira Algoritmos e Estrutura de Dados Aula 3 Conceitos Básicos de Algoritmos Prof. Tiago A. E. Ferreira Definição de Algoritmo Informalmente... Um Algoritmo é qualquer procedimento computacional bem definido

Leia mais

Análise de Algoritmos Estrutura de Dados II

Análise de Algoritmos Estrutura de Dados II Centro de Ciências Exatas, Naturais e de Saúde Departamento de Computação Análise de Algoritmos Estrutura de Dados II COM10078 - Estrutura de Dados II Prof. Marcelo Otone Aguiar marcelo.aguiar@ufes.br

Leia mais

Estruturas de Dados 2

Estruturas de Dados 2 Estruturas de Dados 2 Técnicas de Projeto de Algoritmos Dividir e Conquistar IF64C Estruturas de Dados 2 Engenharia da Computação Prof. João Alberto Fabro - Slide 1/83 Projeto de Algoritmos por Divisão

Leia mais

Análise de algoritmos

Análise de algoritmos Análise de algoritmos Recorrências Conteúdo Introdução O método mestre Referências Introdução O tempo de execução de um algoritmo recursivo pode frequentemente ser descrito por uma equação de recorrência.

Leia mais

Busca Binária. Aula 05. Busca em um vetor ordenado. Análise do Busca Binária. Equações com Recorrência

Busca Binária. Aula 05. Busca em um vetor ordenado. Análise do Busca Binária. Equações com Recorrência Busca Binária Aula 05 Equações com Recorrência Prof. Marco Aurélio Stefanes marco em dct.ufms.br www.dct.ufms.br/ marco Idéia: Divisão e Conquista Busca_Binária(A[l...r],k) 1:if r < lthen 2: index = 1

Leia mais

Análise de algoritmos

Análise de algoritmos Análise de algoritmos Introdução à Ciência da Computação 2 Baseado nos slides do Prof. Thiago A. S. Pardo Algoritmo Noção geral: conjunto de instruções que devem ser seguidas para solucionar um determinado

Leia mais

Luís Fernando Schultz Xavier da Silveira. 12 de maio de 2010

Luís Fernando Schultz Xavier da Silveira. 12 de maio de 2010 Monóides e o Algoritmo de Exponenciação Luís Fernando Schultz Xavier da Silveira Departamento de Informática e Estatística - INE - CTC - UFSC 12 de maio de 2010 Conteúdo 1 Monóides Definição Propriedades

Leia mais

Análise de Algoritmos

Análise de Algoritmos Análise de Algoritmos Estes slides são adaptações de slides do Prof. Paulo Feofiloff e do Prof. José Coelho de Pina. Algoritmos p. 1 Introdução CLRS 2.2 e 3.1 AU 3.3, 3.4 e 3.6 Essas transparências foram

Leia mais

Programação Estruturada

Programação Estruturada Programação Estruturada Recursão Professores Emílio Francesquini e Carla Negri Lintzmayer 2018.Q3 Centro de Matemática, Computação e Cognição Universidade Federal do ABC Recursão Recursão 1 Recursão 2

Leia mais

Preliminares. Profa. Sheila Morais de Almeida. agosto

Preliminares. Profa. Sheila Morais de Almeida. agosto Preliminares Profa. Sheila Morais de Almeida DAINF-UTFPR-PG agosto - 2016 Algoritmos Definição - Skiena Algoritmo é a ideia por trás dos programas de computador. É aquilo que permanece igual se o programa

Leia mais

Algoritmo. Exemplo. Definição. Programação de Computadores Comparando Algoritmos. Alan de Freitas

Algoritmo. Exemplo. Definição. Programação de Computadores Comparando Algoritmos. Alan de Freitas Algoritmos Programação de Computadores Comparando Algoritmos Um algoritmo é um procedimento de passos para cálculos. Este procedimento é composto de instruções que definem uma função Até o momento, vimos

Leia mais

Análise e Projeto de Algoritmos

Análise e Projeto de Algoritmos Análise e Projeto de Algoritmos Mestrado em Ciência da Computação Prof. Dr. Aparecido Nilceu Marana Faculdade de Ciências I think the design of efficient algorithms is somehow the core of computer science.

Leia mais

Técnicas de análise de algoritmos

Técnicas de análise de algoritmos CENTRO FEDERAL DE EDUCAÇÃO TECNOLÓGICA DE MINAS GERAIS Técnicas de análise de algoritmos Algoritmos e Estruturas de Dados I Natália Batista https://sites.google.com/site/nataliacefetmg/ nataliabatista@decom.cefetmg.br

Leia mais

Análise de algoritmos

Análise de algoritmos Análise de algoritmos Introdução à Ciência de Computação II Baseados nos Slides do Prof. Dr. Thiago A. S. Pardo Análise de algoritmos Existem basicamente 2 formas de estimar o tempo de execução de programas

Leia mais

Projeto e Análise de Algoritmos

Projeto e Análise de Algoritmos Projeto e Análise de Algoritmos Aula 01 Complexidade de Algoritmos Edirlei Soares de Lima O que é um algoritmo? Um conjunto de instruções executáveis para resolver um problema (são

Leia mais

Complexidade de Algoritmos

Complexidade de Algoritmos Complexidade de Algoritmos! Uma característica importante de qualquer algoritmo é seu tempo de execução! é possível determiná-lo através de métodos empíricos, considerando-se entradas diversas! é também

Leia mais

Análise de algoritmos. Parte I

Análise de algoritmos. Parte I Análise de algoritmos Parte I 1 Recursos usados por um algoritmo Uma vez que um procedimento está pronto/disponível, é importante determinar os recursos necessários para sua execução Tempo Memória Qual

Leia mais

Medida do Tempo de Execução de um Programa. David Menotti Algoritmos e Estruturas de Dados II DInf UFPR

Medida do Tempo de Execução de um Programa. David Menotti Algoritmos e Estruturas de Dados II DInf UFPR Medida do Tempo de Execução de um Programa David Menotti Algoritmos e Estruturas de Dados II DInf UFPR Classes de Comportamento Assintótico Se f é uma função de complexidade para um algoritmo F, então

Leia mais

Algoritmos de ordenação Quicksort

Algoritmos de ordenação Quicksort Algoritmos de ordenação Quicksort Sumário Introdução Descrição do quicksort Desempenho do quicksort Pior caso Melhor caso Particionamento balanceado Versão aleatória do quicksort Análise do quicksort Pior

Leia mais

Teoria dos Grafos. Valeriano A. de Oliveira Socorro Rangel Departamento de Matemática Aplicada.

Teoria dos Grafos. Valeriano A. de Oliveira Socorro Rangel Departamento de Matemática Aplicada. Teoria dos Grafos Valeriano A. de Oliveira Socorro Rangel Departamento de Matemática Aplicada antunes@ibilce.unesp.br, socorro@ibilce.unesp.br Grafos e Algoritmos Preparado a partir do texto: Rangel, Socorro.

Leia mais

Introdução à Ciência da Computação II

Introdução à Ciência da Computação II Introdução à Ciência da Computação II 2semestre/200 Prof Alneu de Andrade Lopes Apresentação com material gentilmente cedido pelas profas Renata Pontin Mattos Fortes http://wwwicmcuspbr/~renata e Graça

Leia mais

Teoria dos Grafos Aula 7

Teoria dos Grafos Aula 7 Teoria dos Grafos Aula 7 Aula passada Implementação BFS DFS, implementação Complexidade Aplicações Aula de hoje Classe de funções e notação Propriedades da notação Funções usuais Tempo de execução Comparando

Leia mais

Análise de Problemas Recursivos. Algoritmos e Estruturas de Dados Flavio Figueiredo (

Análise de Problemas Recursivos. Algoritmos e Estruturas de Dados Flavio Figueiredo ( Análise de Problemas Recursivos Algoritmos e Estruturas de Dados 2 2017-1 Flavio Figueiredo (http://flaviovdf.github.io) 1 Lembrando de Recursividade Procedimento que chama a si mesmo Recursividade permite

Leia mais

Análise de algoritmos. Parte I

Análise de algoritmos. Parte I Análise de algoritmos Parte I 1 Procedimento X Algoritmo Procedimento: sequência finita de instruções, que são operações claramente descritas, e que podem ser executadas mecanicamente, em tempo finito.

Leia mais

Teoria da Computação Aula 9 Noções de Complexidade

Teoria da Computação Aula 9 Noções de Complexidade Teoria da Computação Aula 9 Noções de Complexidade Prof. Esp. Pedro Luís Antonelli Anhanguera Educacional Análise de um Algoritmo em particular Qual é o custo de usar um dado algoritmo para resolver um

Leia mais

Análise e Complexidade de Algoritmos

Análise e Complexidade de Algoritmos Análise e Complexidade de Algoritmos Professor Ariel da Silva Dias Algoritmos Divisão e Conquista Construção incremental Resolver o problema para um sub-conjunto dos elementos de entrada; Então, adicionar

Leia mais

03 Análise de Algoritmos (parte 3) SCC201/501 - Introdução à Ciência de Computação II

03 Análise de Algoritmos (parte 3) SCC201/501 - Introdução à Ciência de Computação II 03 Análise de Algoritmos (parte 3) SCC201/501 - Introdução à Ciência de Computação II Prof. Moacir Ponti Jr. www.icmc.usp.br/~moacir Instituto de Ciências Matemáticas e de Computação USP 2010/2 Moacir

Leia mais

Estruturas de Dados 2

Estruturas de Dados 2 Estruturas de Dados 2 Algoritmos de Ordenação em Tempo Linear IF64C Estruturas de Dados 2 Engenharia da Computação Prof. João Alberto Fabro - Slide 1/38 Algoritmos de Ordenação em Tempo Linear Limite Assintótico

Leia mais

Análise de algoritmos

Análise de algoritmos Análise de algoritmos SCE-181 Introdução à Ciência da Computação II Alneu Lopes Thiago A. S. Pardo 1 Algoritmo Noção geral: conjunto de instruções que devem ser seguidas para solucionar um determinado

Leia mais

ANÁLISE DE ALGORITMOS

ANÁLISE DE ALGORITMOS ANÁLISE DE ALGORITMOS Paulo Feofiloff Instituto de Matemática e Estatística Universidade de São Paulo agosto 2009 Introdução P. Feofiloff (IME-USP) Análise de Algoritmos agosto 2009 2 / 102 Introdução

Leia mais

Comportamento assintótico

Comportamento assintótico ANÁLISE DE ALGORITMOS: PARTE 2 Prof. André Backes 2 Na última aula, vimos que o custo para o algoritmo abaixo é dado pela função f(n) = 4n + 3 1 3 Essa é a função de complexidade de tempo Nos dá uma ideia

Leia mais

Aula 02 Notação Assintótica p. 4. Usodanotação O. Notação O. Notação O, Ω, ΘeExemplos. Intuitivamente... O(f(n)) funções que não crescem mais

Aula 02 Notação Assintótica p. 4. Usodanotação O. Notação O. Notação O, Ω, ΘeExemplos. Intuitivamente... O(f(n)) funções que não crescem mais Notação O Aula 02 Notação Assintótica Notação O, Ω, Θe Prof. Marco Aurélio Stefanes marco em dct.ufms.br www.dct.ufms.br/ marco Intuitivamente... O() funções que não crescem mais rápido que funções menores

Leia mais

Lista 1. 8 de abril de Algorithms: Capítulo 0, exercícios 1 e 2. Tardos: Todos exercícios do cap 2 do livro texto, exceto 7 e 8 letra b.

Lista 1. 8 de abril de Algorithms: Capítulo 0, exercícios 1 e 2. Tardos: Todos exercícios do cap 2 do livro texto, exceto 7 e 8 letra b. Lista 1 8 de abril de 2013 1 Exercícios Básicos 1.1 Na bibliografia Algorithms: Capítulo 0, exercícios 1 e 2. Tardos: Todos exercícios do cap 2 do livro texto, exceto 7 e 8 letra b. 1.2 Outros 1. Projete

Leia mais

ESTRUTURA DE DADOS CIÊNCIA E TECNOLOGIA DO RIO. Curso de Tecnologia em Sistemas para Internet

ESTRUTURA DE DADOS CIÊNCIA E TECNOLOGIA DO RIO. Curso de Tecnologia em Sistemas para Internet INSTITUTO FEDERAL DE EDUCAÇÃO, CIÊNCIA E TECNOLOGIA DO RIO GRANDE DO NORTE ESTRUTURA DE DADOS Docente: Éberton da Silva Marinho e-mail: ebertonsm@gmail.com eberton.marinho@ifrn.edu.br Curso de Tecnologia

Leia mais

Rascunho. CI165 - Análise de algoritmos (rascunho alterado constantemente) André Guedes Departamento de Informática UFPR. 7 de dezembro de 2016

Rascunho. CI165 - Análise de algoritmos (rascunho alterado constantemente) André Guedes Departamento de Informática UFPR. 7 de dezembro de 2016 CI165 - Análise de algoritmos (rascunho alterado constantemente) André Guedes Departamento de Informática UFPR 7 de dezembro de 016 Sumário 1 Apresentação do Curso Problemas computacionais e algoritmos

Leia mais

7. Introdução à Complexidade de Algoritmos

7. Introdução à Complexidade de Algoritmos 7. Introdução à Complexidade de Algoritmos Fernando Silva DCC-FCUP Estruturas de Dados Fernando Silva (DCC-FCUP) 7. Introdução à Complexidade de Algoritmos Estruturas de Dados 1 / 1 Análise de Algoritmos

Leia mais

Projeto e Análise de Algoritmos Aula 4: Dividir para Conquistar ou Divisão e Conquista ( )

Projeto e Análise de Algoritmos Aula 4: Dividir para Conquistar ou Divisão e Conquista ( ) Projeto e Análise de Algoritmos Aula 4: Dividir para Conquistar ou Divisão e Conquista (2.1-2.2) DECOM/UFOP 2013/1 5º. Período Anderson Almeida Ferreira Adaptado do material desenvolvido por Andréa Iabrudi

Leia mais

Rascunho. CI165 - Análise de algoritmos (rascunho alterado constantemente) André Guedes Departamento de Informática UFPR. 11 de junho de 2017

Rascunho. CI165 - Análise de algoritmos (rascunho alterado constantemente) André Guedes Departamento de Informática UFPR. 11 de junho de 2017 CI165 - Análise de algoritmos (rascunho alterado constantemente) André Guedes Departamento de Informática UFPR 11 de junho de 017 Sumário 1 Apresentação do Curso Problemas computacionais e algoritmos 4

Leia mais

Estruturas de Dados 2

Estruturas de Dados 2 Estruturas de Dados 2 Recorrências IF64C Estruturas de Dados 2 Engenharia da Computação Prof. João Alberto Fabro - Slide 1/31 Recorrências Análise da Eficiência de Algoritmos: Velocidade de Execução; Análise

Leia mais

Estruturas de Dados, Análise de Algoritmos e Complexidade Estrutural. Carlos Alberto Alonso Sanches

Estruturas de Dados, Análise de Algoritmos e Complexidade Estrutural. Carlos Alberto Alonso Sanches CT-234 Estruturas de Dados, Análise de Algoritmos e Complexidade Estrutural Carlos Alberto Alonso Sanches CT-234 2) Algoritmos recursivos Indução matemática, recursão, recorrências Indução matemática Uma

Leia mais

Construção de Algoritmos II Aula 06

Construção de Algoritmos II Aula 06 exatasfepi.com.br Construção de Algoritmos II Aula 06 André Luís Duarte Porque mil anos são aos teus olhos como o dia de ontem que passou, e como a vigília da noite. Salmos 90:4 Recursividade e complexidade

Leia mais

Algoritmos e Estrutura de Dados. Aula 04 Recorrência Prof. Tiago A. E. Ferreira

Algoritmos e Estrutura de Dados. Aula 04 Recorrência Prof. Tiago A. E. Ferreira Algoritmos e Estrutura de Dados Aula 04 Recorrência Prof. Tiago A. E. Ferreira Esta Aula... Nesta aula veremos três métodos para resolver recorrência: Método da substituição É suposto um limite hipotético

Leia mais

Complexidade Assintótica de Programas Letícia Rodrigues Bueno

Complexidade Assintótica de Programas Letícia Rodrigues Bueno Complexidade Assintótica de Programas Letícia Rodrigues Bueno Análise de Algoritmos 1. Introdução; Análise de Algoritmos 1. Introdução; 2. Conceitos básicos; Análise de Algoritmos 1. Introdução; 2. Conceitos

Leia mais

Quantidade de memória necessária

Quantidade de memória necessária Tempo de processamento Um algoritmo que realiza uma tarefa em 10 horas é melhor que outro que realiza em 10 dias Quantidade de memória necessária Um algoritmo que usa 1MB de memória RAM é melhor que outro

Leia mais

COMPLEXIDADE DE ALGORITMOS

COMPLEXIDADE DE ALGORITMOS COMPLEXIDADE DE ALGORITMOS Algoritmos Seqüência de instruções necessárias para a resolução de um problema bem formulado Permite implementação computacional COMPLEXIDADE DE ALGORITMOS Um algoritmo resolve

Leia mais

Introdução à Análise Algoritmos

Introdução à Análise Algoritmos Introdução à Análise Algoritmos Notas de aula da disciplina IME 4-182 Estruturas de Dados I Paulo Eustáquio Duarte Pinto (pauloedp arroba ime.uerj.br) abril/218 Ordenação por SELEÇÃO: Idéia: Dado um vetor

Leia mais

Complexidade de algoritmos Notação Big-O

Complexidade de algoritmos Notação Big-O Complexidade de algoritmos Notação Big-O Prof. Byron Leite Prof. Tiago Massoni Engenharia da Computação Poli - UPE Motivação O projeto de algoritmos é influenciado pelo estudo de seus comportamentos Problema

Leia mais

Análise de Algoritmos

Análise de Algoritmos Análise de Algoritmos CLRS 2.2 e 3.1 AU 3.3, 3.4 e 3.6 Essas transparências foram adaptadas das transparências do Prof. Paulo Feofiloff e do Prof. José Coelho de Pina. Algoritmos p. 1 Intuitivamente...

Leia mais

Solução de Recorrências

Solução de Recorrências CENTRO FEDERAL DE EDUCAÇÃO TECNOLÓGICA DE MINAS GERAIS Solução de Recorrências Algoritmos e Estruturas de Dados I Natália Batista https://sites.google.com/site/nataliacefetmg/ nataliabatista@decom.cefetmg.br

Leia mais

Complexidade de Tempo e Espaço

Complexidade de Tempo e Espaço Complexidade de Tempo e Espaço Profa. Sheila Morais de Almeida DAINF-UTFPR-PG junho - 2018 Sheila Almeida (DAINF-UTFPR-PG) Complexidade de Tempo e Espaço junho - 2018 1 / 43 Este material é preparado usando

Leia mais

Projeto e Análise de Algoritmos

Projeto e Análise de Algoritmos Projeto e Análise de Algoritmos A. G. Silva Baseado nos materiais de Souza, Silva, Lee, Rezende, Miyazawa Unicamp Ribeiro FCUP Manber, Introduction to Algorithms (1989) Livro 06 de abril de 2018 Conteúdo

Leia mais

2. Complexidade de Algoritmos

2. Complexidade de Algoritmos Introdução à Computação II 5952011 2. Complexidade de Algoritmos Prof. Renato Tinós Depto. de Computação e Matemática (FFCLRP/USP) 1 Principais Tópicos 2.1. Introdução 2.1.1. Revisão de Pseudo-Código 2.1.2.

Leia mais

Capítulo 6 Análise de Algoritmos Capítulo 6

Capítulo 6 Análise de Algoritmos Capítulo 6 666 Apêndice C Respostas e Sugestões para os Exercícios de Revisão 42. Consulte a Seção 5.4. 43. (a) Escoamento de memória.(b) Porque não há garantia que o cliente irá usá-la devidamente. 44. (a) Contagem

Leia mais

André Vignatti DINF- UFPR

André Vignatti DINF- UFPR Notação Assintótica: O André Vignatti DINF- UFPR Notação Assintótica Vamos expressar complexidade através de funções em variáveis que descrevam o tamanho de instâncias do problema. Exemplos: Problemas

Leia mais

Análise e Projeto de Algoritmos

Análise e Projeto de Algoritmos Análise e Projeto de Algoritmos Profa. Sheila Morais de Almeida DAINF-UTFPR-PG junho - 2018 Sheila Almeida (DAINF-UTFPR-PG) Análise e Projeto de Algoritmos junho - 2018 1 / 40 Este material é preparado

Leia mais

Lista de Exercícios 6 Funções

Lista de Exercícios 6 Funções UFMG/ICEx/DCC DCC Matemática Discreta Lista de Exercícios 6 Funções Ciências Exatas & Engenharias o Semestre de 06 Conceitos. Determine e justifique se a seguinte afirmação é verdadeira ou não para todas

Leia mais

7. Introdução à Complexidade de Algoritmos

7. Introdução à Complexidade de Algoritmos 7. Introdução à Complexidade de Algoritmos Fernando Silva DCC-FCUP Estruturas de Dados Fernando Silva (DCC-FCUP) 7. Introdução à Complexidade de Algoritmos Estruturas de Dados 1 / 1 Análise de Algoritmos

Leia mais

Técnicas de Projeto de Algoritmos

Técnicas de Projeto de Algoritmos UNIVERSIDADE NOVE DE JULHO - UNINOVE Pesquisa e Ordenação Técnicas de Projeto de Algoritmos Material disponível para download em: www.profvaniacristina.com Profa. Vânia Cristina de Souza Pereira 03 _ Material

Leia mais

André Vignatti DINF- UFPR

André Vignatti DINF- UFPR Notação Assintótica: Ω, Θ André Vignatti DINF- UFPR Limitantes Inferiores Considere o seguinte trecho de código: void main () { /* trecho que le N da entrada padrao */ for (i = 0 ; i< N; i++) puzzle(i);

Leia mais

Medida do Tempo de Execução de um Programa

Medida do Tempo de Execução de um Programa Medida do Tempo de Execução de um Programa Livro Projeto de Algoritmos Nívio Ziviani Capítulo 1 Seção 1.3.1 http://www2.dcc.ufmg.br/livros/algoritmos/ Comportamento Assintótico de Funções O parâmetro n

Leia mais

ALGORITMOS AVANÇADOS UNIDADE I Análise de Algoritmo - Notação O. Luiz Leão

ALGORITMOS AVANÇADOS UNIDADE I Análise de Algoritmo - Notação O. Luiz Leão Luiz Leão luizleao@gmail.com http://www.luizleao.com Conteúdo Programático 1.1 - Algoritmo 1.2 - Estrutura de Dados 1.2.1 - Revisão de Programas em C++ envolvendo Vetores, Matrizes, Ponteiros, Registros

Leia mais

INF 1010 Estruturas de Dados Avançadas

INF 1010 Estruturas de Dados Avançadas INF 1010 Estruturas de Dados Avançadas Complexidade de Algoritmos 2012 DI, PUC-Rio Estruturas de Dados Avançadas 2012.2 1 Introdução Complexidade computacional Termo criado por Hartmanis e Stearns (1965)

Leia mais

Comportamento Assintótico. Algoritmos e Estruturas de Dados Flavio Figueiredo (http://flaviovdf.github.io)

Comportamento Assintótico. Algoritmos e Estruturas de Dados Flavio Figueiredo (http://flaviovdf.github.io) Comportamento Assintótico Algoritmos e Estruturas de Dados 2 2017-1 Flavio Figueiredo (http://flaviovdf.github.io) 1 Até Agora Falamos de complexidade de algoritmos com base no número de passos Vamos generalizar

Leia mais

Mergesort. Aula 04. Algoritmo Mergesort. Divisão e Conquista. Divisão e Conquista- MergeSort

Mergesort. Aula 04. Algoritmo Mergesort. Divisão e Conquista. Divisão e Conquista- MergeSort Mergesort Aula 0 Divisão e Conquista- MergeSort Prof. Marco Aurélio Stefanes marco em dct.ufms.br www.dct.ufms.br/ marco Mergesort é um algoritmo de ordenação recursivo Ele recursivamente ordena as duas

Leia mais

5. Invólucros Convexos no Plano (cont )

5. Invólucros Convexos no Plano (cont ) 5. Invólucros Convexos no Plano (cont ) Antonio Leslie Bajuelos Departamento de Matemática Universidade de Aveiro Mestrado em Matemática e Aplicações Complexidade Algorítmica Notação O Sejam T(n) e f(n)

Leia mais

Técnicas de projeto de algoritmos: Indução

Técnicas de projeto de algoritmos: Indução Técnicas de projeto de algoritmos: Indução ACH2002 - Introdução à Ciência da Computação II Delano M. Beder Escola de Artes, Ciências e Humanidades (EACH) Universidade de São Paulo dbeder@usp.br 08/2008

Leia mais

Projeto e Análise de Algoritmos

Projeto e Análise de Algoritmos Projeto e Análise de Algoritmos A. G. Silva Baseado nos materiais de Souza, Silva, Lee, Rezende, Miyazawa Unicamp Ribeiro FCUP Manber, Introduction to Algorithms (989) Livro de abril de 08 Conteúdo programático

Leia mais

Aula 2. Divisão e conquista. Exemplo 1: Número de inversões de uma permutação (problema 2-4 do CLRS; veja também sec 5.4 do KT)

Aula 2. Divisão e conquista. Exemplo 1: Número de inversões de uma permutação (problema 2-4 do CLRS; veja também sec 5.4 do KT) Aula 2 Divisão e conquista Exemplo 1: Número de inversões de uma permutação (problema 2-4 do CLRS; veja também sec 5.4 do KT) Exemplo 2: Par de pontos mais próximos (sec 33.4 do CLRS) Essas transparências

Leia mais

AED2 - Aula 11 Problema da separação e quicksort

AED2 - Aula 11 Problema da separação e quicksort AED2 - Aula 11 Problema da separação e quicksort Projeto de algoritmos por divisão e conquista Dividir: o problema é dividido em subproblemas menores do mesmo tipo. Conquistar: os subproblemas são resolvidos

Leia mais

Análise e Complexidade de Algoritmos

Análise e Complexidade de Algoritmos Análise e Complexidade de Algoritmos Principais paradigmas do projeto de algoritmos - Recursividade - Tentativa e erro - Divisão e Conquista - Programação dinâmica - Algoritmos Gulosos e de Aproximação

Leia mais

ESTRUTURAS DE DADOS prof. Alexandre César Muniz de Oliveira

ESTRUTURAS DE DADOS prof. Alexandre César Muniz de Oliveira ESTRUTURAS DE DADOS prof. Alexandre César Muniz de Oliveira 1. Introdução 2. Pilhas 3. Filas 4. Listas 5. Árvores 6. Grafos 7. Complexidade 8. Ordenação 9. Busca Sugestão bibliográfica: ESTRUTURAS DE DADOS

Leia mais

Modelagem com relações de recorrência. Exemplo: Determinada população dobra a cada ano; população inicial = 5 a n = população depois de n anos

Modelagem com relações de recorrência. Exemplo: Determinada população dobra a cada ano; população inicial = 5 a n = população depois de n anos Relações de recorrência 8. RELAÇÕES DE RECORRÊNCIA Introdução a relações de recorrência Modelagem com relações de recorrência Solução de relações de recorrência Exemplos e aplicações Relações de recorrência

Leia mais

Lista 1 - PMR2300. Fabio G. Cozman 3 de abril de 2013

Lista 1 - PMR2300. Fabio G. Cozman 3 de abril de 2013 Lista 1 - PMR2300 Fabio G. Cozman 3 de abril de 2013 1. Qual String é impressa pelo programa: p u b l i c c l a s s What { p u b l i c s t a t i c void f ( i n t x ) { x = 2 ; p u b l i c s t a t i c void

Leia mais

Divisão e Conquista. Norton T. Roman. Apostila baseada nos trabalhos de Cid de Souza, Cândida da Silva e Delano M. Beder

Divisão e Conquista. Norton T. Roman. Apostila baseada nos trabalhos de Cid de Souza, Cândida da Silva e Delano M. Beder Divisão e Conquista Norton T. Roman Apostila baseada nos trabalhos de Cid de Souza, Cândida da Silva e Delano M. Beder Divisão e Conquista Construção incremental Ex: Consiste em, inicialmente, resolver

Leia mais

UNIVERSIDADE FEDERAL DO RIO DE JANEIRO DEPARTAMENTO DE CIÊNCIAS DA COMPUTAÇÃO. 4 a Lista de Exercícios Gabarito de algumas questões.

UNIVERSIDADE FEDERAL DO RIO DE JANEIRO DEPARTAMENTO DE CIÊNCIAS DA COMPUTAÇÃO. 4 a Lista de Exercícios Gabarito de algumas questões. UNIVERSIDADE FEDERAL DO RIO DE JANEIRO DEPARTAMENTO DE CIÊNCIAS DA COMPUTAÇÃO MATEMÁTICA COMBINATÓRIA 4 a Lista de Exercícios Gabarito de algumas questões. Este gabarito foi feito direto no computador

Leia mais

ALGORITMOS E ESTRUTURAS DE DADOS 2011/2012 ANÁLISE DE ALGORITMOS. Armanda Rodrigues 3 de Outubro 2011

ALGORITMOS E ESTRUTURAS DE DADOS 2011/2012 ANÁLISE DE ALGORITMOS. Armanda Rodrigues 3 de Outubro 2011 ALGORITMOS E ESTRUTURAS DE DADOS 2011/2012 ANÁLISE DE ALGORITMOS Armanda Rodrigues 3 de Outubro 2011 2 Análise de Algoritmos Temos até agora analisado soluções de problemas de forma intuitiva A análise

Leia mais

Marcelo Keese Albertini Faculdade de Computação Universidade Federal de Uberlândia

Marcelo Keese Albertini Faculdade de Computação Universidade Federal de Uberlândia Introdução à Análise de Algoritmos Marcelo Keese Albertini Faculdade de Computação Universidade Federal de Uberlândia Aula de hoje Nesta aula veremos: Sobre a disciplina Exemplo: ordenação Sobre a disciplina

Leia mais

PCC104 - Projeto e Análise de Algoritmos

PCC104 - Projeto e Análise de Algoritmos PCC104 - Projeto e Análise de Algoritmos Marco Antonio M. Carvalho Departamento de Computação Instituto de Ciências Exatas e Biológicas Universidade Federal de Ouro Preto 7 de outubro de 2016 Marco Antonio

Leia mais

Melhores momentos AULA 1. Algoritmos p.38/86

Melhores momentos AULA 1. Algoritmos p.38/86 Melhores momentos AULA 1 Algoritmos p.38/86 Definições x := inteiro i tal que i x < i + 1 x := inteiro j tal que j 1 < x j Exercício A1.B Mostre que n 1 2 n 2 n 2 e n 2 n 2 n + 1 2 para qualquer inteiro

Leia mais

Análise de Algoritmos Parte 4

Análise de Algoritmos Parte 4 Análise de Algoritmos Parte 4 Túlio Toffolo tulio@toffolo.com.br www.toffolo.com.br BCC202 Aula 07 Algoritmos e Estruturas de Dados I Como escolher o algoritmo mais adequado para uma situação? (continuação)

Leia mais

Análise empírica de algoritmos de ordenação

Análise empírica de algoritmos de ordenação Análise empírica de algoritmos de ordenação Mario E. Matiusso Jr. (11028407) Bacharelado em Ciências da Computação Universidade Federal do ABC (UFABC) Santo André, SP Brasil mario3001[a]ig.com.br Resumo:

Leia mais

Projeto e Análise de Algoritmos

Projeto e Análise de Algoritmos Projeto e Análise de Algoritmos A. G. Silva Baseado nos materiais de Souza, Silva, Lee, Rezende, Miyazawa Unicamp Ribeiro FCUP Mariani UFSC Manber, Introduction to Algorithms (1989) Livro 16 de março de

Leia mais

BCC202 - Estrutura de Dados I

BCC202 - Estrutura de Dados I BCC202 - Estrutura de Dados I Aula 13: Ordenação: MergeSort Reinaldo Fortes Universidade Federal de Ouro Preto, UFOP Departamento de Computação, DECOM Website: www.decom.ufop.br/reifortes Email: reifortes@iceb.ufop.br

Leia mais

Análise de complexidade

Análise de complexidade Introdução Algoritmo: sequência de instruções necessárias para a resolução de um problema bem formulado (passíveis de implementação em computador) Estratégia: especificar (definir propriedades) arquitectura

Leia mais

BCC402 Algoritmos e Programação Avançada Prof. Marco Antonio M. Carvalho Prof. Túlio Ângelo M. Toffolo 2011/1

BCC402 Algoritmos e Programação Avançada Prof. Marco Antonio M. Carvalho Prof. Túlio Ângelo M. Toffolo 2011/1 BCC402 Algoritmos e Programação Avançada Prof. Marco Antonio M. Carvalho Prof. Túlio Ângelo M. Toffolo 2011/1 Na aula anterior Prova 2 Na aula de hoje Técnicas básicas de contagem; Tentativa e Erro; Recursividade.

Leia mais

Otimização em Grafos

Otimização em Grafos Otimização em Grafos Luidi G. Simonetti PESC/COPPE 2017 Luidi Simonetti (PESC) EEL857 2017 1 / 33 Definição do Problema Dado: um grafo ponderado G = (V, E), orientado ou não, onde d : E R + define as distâncias

Leia mais

RESOLUÇÃO DCC-UFRJ MATEMÁTICA COMBINATÓRIA 2006/2 PROVA Considere a soma. S n = n 2 n 1

RESOLUÇÃO DCC-UFRJ MATEMÁTICA COMBINATÓRIA 2006/2 PROVA Considere a soma. S n = n 2 n 1 DCC-UFRJ MATEMÁTICA COMBINATÓRIA 2006/2 PROVA 1 1. Considere a soma S n = 1 2 0 + 2 2 1 + 3 2 2 + + n 2 n 1. Mostre, por indução finita, que S n = (n 1)2 n + 1. Indique claramente a base da indução, a

Leia mais