Análise de Algoritmos e Estruturas de Dados
|
|
|
- Madalena Carreira
- 6 Há anos
- Visualizações:
Transcrição
1 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 ainda em elaboração e não foi revisado.
2 ii
3 Sumário I Introdução à análise de algoritmos 1 1 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 Algoritmos recursivos Fatorial Busca binária Algoritmos recursivos algoritmos iterativos Métodos para solução de equações de recorrência Logaritmos e somatórios Método da substituição Desconsiderando pisos e tetos Diversas formas de obter o mesmo resultado Ajustando os palpites
4 iv SUMÁRIO Mais exemplos Método iterativo Limitantes assintóticos inferiores e superiores 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 61 4 Estruturas lineares Vetor Lista encadeada Árvores Árvores binárias de busca Árvores balanceadas Pilha 77 7 Fila 81 8 Fila de prioridades Heap binário Construção de um heap binário Remoção em um heap binário Inserção em um heap binário Alteração em um heap binário Disjoint Set Union-Find Tabelas hash 103
5 SUMÁRIO v III Algoritmos de ordenação Ordenação por inserção Insertion sort Corretude Análise do tempo de execução Uma análise mais direta Shellsort Ordenação por intercalação Ordenação por seleção Selection sort Heapsort Ordenação por troca Bubble sort Quicksort Análise do tempo de execução Ordenação em tempo linear Counting sort IV Técnicas de construção de algoritmos Divisão e conquista Multiplicação de inteiros Algoritmos gulosos Escalonamento de tarefas compatíveis Mochila fracionária Compressão de dados Programação dinâmica Sequência de Fibonacci
6 vi SUMÁRIO 18.2 Corte de barras de ferro Multiplicação de uma sequência de matrizes V Algoritmos em grafos Conceitos essenciais Formas de representar um grafo Trilhas, passeios, caminhos e ciclos Buscas Busca em largura Distância entre vértices Componentes conexas Busca em profundidade Ordenação topológica Componentes fortemente conexas Outras aplicações dos algoritmos de busca Árvores geradoras mínimas Algoritmo de Kruskal Algoritmo de Prim Trilhas Eulerianas Caminhos mínimos De única fonte Algoritmo de Dijkstra Algoritmo de Bellman-Ford Entre todos os pares Algoritmo de Floyd-Warshall Algoritmo de Johnson
7 VI Teoria da computação Complexidade computacional Classes P, NP e co-np NP-completude vii
8 viii
9 Parte I Introdução à análise de algoritmos Suppose computers were infinitely fast and computer memory was free. Would you have any reason to study algorithms? The answer is yes, if for no other reason than that you would still like to demonstrate that your solution method terminates and does so with the correct answer. Cormen, Leiserson, Rivest, Stein Introduction to Algorithms, 2009.
10
11 Nesta parte Um algoritmo é um conjunto de regras bem definidas que tomam uma entrada e produzem uma saída. São importantes ferramentas utilizadas para resolver problemas computacionais mas, hoje em dia, algoritmos aparecem em praticamente todos os aspectos de nossas vidas. A análise dos algoritmos é necessária pois nos permite prever o comportamento ou desempenho de um algoritmo sem que seja necessário implementá-lo em um dispositivo específico. Em geral, não existe um único algoritmo que resolve um problema e, por isso, é importante termos uma forma de comparar diferentes algoritmos para escolher o que melhor se adeque às nossas necessidades. Neste capítulo veremos um vocabulário básico necessário para projeto e análise de algoritmos em geral.
12 4
13 Capítulo 1 Corretude e tempo de execução 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 fazemos alguma sequência de passos de nossa preferência para resolver o problema. Por exemplo, para colocar um conjunto de cartas de baralho em ordem não-decrescente há quem prefira olhar todas as cartas e encontrar a menor, depois verificar o restante das cartas e encontrar a próxima menor, e assim por diante. Outras pessoas preferem manter a pilha de cartas sobre a mesa e olhar uma por vez, colocando-a de forma ordenada com relação às cartas que já estão em sua mão. Existem diversas outras maneiras de fazer isso e cada uma delas é realizada por um procedimento que chamamos de algoritmo. Formalmente, 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. Algoritmos estão presentes na vida das pessoas há muitos anos e são utilizados o tempo todo para tratar os mais diversos problemas e não apenas para ordenar um conjunto de itens. Por exemplo, também usamos algoritmos para descobrir qual o menor caminho entre dois locais, alocar disciplinas a professores e a salas de aula, controlar a informação de um estoque de mercadorias, etc. Dizemos que um algoritmo resolve um problema, ou que ele está correto, se, para todas as entradas possíveis, ele produz uma saída que seja uma solução do problema em questão. Analisar um algoritmo é uma tarefa que tem como objetivo prever seu compor-
14 tamento ou desempenho sem que seja necessário implementá-lo em um computador específico. Estamos interessados 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. É claro que o comportamento e desempenho de um algoritmo envolve o uso de recursos computacionais como memória, largura de banda e, principalmente, tempo. Para descrever o uso desses recursos, levamos em consideração o tamanho da entrada e contamos a quantidade de passos básicos que são feitos pelo algoritmo. O tamanho da entrada depende muito do problema que está sendo estudado: em vários problemas, como o de ordenação descrito acima, o tamanho é dado pelo número de elementos na entrada; em outros, como o problema de somar dois números, o tamanho é dado pelo número total de bits necessários para representar esses números em notação binária. Com relação a passos básicos, consideraremos operações simples que podem ser feitas pelos processadores comuns atuais, como por exemplo somar, subtrair, multiplicar ou dividir dois números, atribuir um valor a uma variável, ou comparar dois números 1. Explicaremos esses aspectos com mais detalhes por meio de exemplos nas seções a seguir. No restante desse capítulo consideraremos o problema de encontrar um certo valor em um dado conjunto de valores e analisaremos algoritmos simples para resolvê-lo. Para facilitar a discussão, vamos supor que esse conjunto de valores está armazenado em um vetor, a mais simples das estruturas de dados. 1.1 Algoritmos de busca em vetores Vetores são estruturas de dados simples que armazenam um conjunto de objetos do mesmo tipo de forma contínua na memória. Essa forma de armazenamento permite que o acesso a um elemento do vetor possa ser feito de forma direta, através do índice do elemento. Um vetor A que armazena n elementos é representado por A[1..n] ou A = (a 1, a 2,..., a n ) e A[i] = a i é o elemento que está armazenado 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]. 1 Estamos falando aqui de números que possam ser representados por 32 ou 64 bits, que são facilmente manipulados por computadores. 6
15 Problema 1.1: Busca Dado um vetor A[1..n] contendo n números reais e um número real x qualquer, descobrir se x está armazenado em A ou não. Veja que o problema é definido sobre um vetor que contém apenas números reais, mas poderíamos facilmente supor que o vetor contém registros e assumir que a busca é feita sobre algum campo específico dos registros que os diferenciariam (por exemplo, se os registros armazenam informações de pessoas, poderia haver um campo CPF, que é único para cada pessoa). Assim, frequentemente chamamos o valor x de chave de busca. O algoritmo mais simples para o Problema 1.1 é conhecido como busca linear e é descrito no Algoritmo 1. Ele percorre o vetor, examinando todos os seus elementos, um a um, até encontrar x ou até verificar todos os elementos de A e descobrir que x não está em A. Algoritmo 1: BuscaLinear(A, 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 n a quantidade de elementos armazenados no vetor A (seu tamanho) 2. O funcionamento do algoritmo BuscaLinear é bem simples. A variável i indica qual posição do vetor A estamos analisando. Inicialmente fazemos i = 1. Incrementamos o valor de i em uma unidade sempre que as duas condições do laço enquanto forem satisfeitas, i.e., quando A[i] x e i n. Assim, o laço enquanto simplesmente verifica se A[i] é igual a x e se o vetor A já foi totalmente verificado. Caso x seja encontrado, o laço enquanto é encerrado e o algoritmo retorna o índice i tal que A[i] = x. Caso contrário, o algoritmo retorna 1. 2 Em outros pontos do livro, poderemos diferenciar o tamanho de um vetor quantidade de elementos armazenados de sua capacidade quantidade máxima de elementos que podem ser armazenados. 7
16 Intuitivamente, é fácil perceber que BuscaLinear funciona corretamente, isto é, que para qualquer vetor A de números reais e número real x, o algoritmo irá retornar a posição de x em A caso ela exista, ou irá retornar 1 caso x não esteja em A. Mas como podemos ter certeza que o comportamento de BuscaLinear é sempre como esperamos que seja? Na 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, mas agora com a informação extra de que A está ordenado. Considere um vetor ordenado (ordem não-decrescente 3 ) 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 outro procedimento, chamado de busca binária, que consegue realizar a busca por uma chave x em A. A estratégia da busca binária também é muito simples. A ideia é 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 de que, se x estiver em A, então x estará na primeira metade de A, i.e., x estará 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 estará no vetor A[n/ n]. Suponha que x < A[n/2]. Note que 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], A[n/4 2], e verificamos a primeira ou segunda metade desse subvetor dependendo do resultado da comparação. O Algoritmo 2 apresenta a busca binária, que recebe um vetor A[1..n] ordenado de modo não-decrescente e um valor x a ser buscado. Ele retorna a posição em que x está armazenado, se x estiver em A, ou retorna 1, caso contrário Corretude de algoritmos (utilizando invariante de laços) Ao utilizar um algoritmo para resolver um determinado problema, esperamos que ele sempre dê a resposta correta, qualquer que seja a entrada recebida 4. Como analisar se um algoritmo está correto? A seguir veremos uma maneira de responder a essa 3 Aqui utilizamos o termo não-decrescente em vez de crescente para indicar que podemos ter A[i] = A[i + 1], para algum i. 4 É claro, considerando que temos uma entrada válida para o problema. 8
17 Algoritmo 2: BuscaBinaria(A, n, x) 1 esquerda = 1 2 direita = n 3 enquanto esquerda direita faça 4 meio = esquerda + direita esquerda 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 2 pergunta. Basicamente, mostraremos que o algoritmo possui certas propriedades e que elas 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 durante todas as iterações do laço (não variam). Definição 1.2: 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 ela é verdadeira imediatamente antes da próxima iteração (ou seja, a iteração atual faz algo que a mantém verdadeira para a próxima). 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. 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 de busca em vetores. 9
18 Comecemos com o algoritmo BuscaLinear, considerando a seguinte invariante de laço: Invariante: BuscaLinear 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 1.2 de invariante é trivialmente válido antes da primeira iteração, quando i = 1, pois nesse caso a invariante trata do vetor A[1..0], que é vazio e, logo, não pode conter x. Para verificar o item (ii), suponha agora que vamos começar a iteração indexada por i e que o vetor A[1..i 1] não contém x. Suponha agora que o laço enquanto termina a execução dessa 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 iteração indexada por i + 1. 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. 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 o laço enquanto foi executado por completo, até que chegamos em 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. Perceba que não fizemos nenhuma suposição sobre os dados contidos em A ou sobre o valor de x, portanto, o algoritmo funciona corretamente para qualquer entrada. À primeira vista, todo o processo que fizemos para mostrar que o algoritmo BuscaLinear funciona corretamente pode parecer excessivamente complicado. Porém, essa impressão vem do fato desse algoritmo ser muito simples (assim, a análise de algo simples parece ser desnecessariamente longa). Futuramente veremos casos onde a corretude de um dado algoritmo não é tão clara, de modo que a necessidade de se utilizar invariantes de laço é evidente. Para clarear nossas ideias, analisaremos agora o Algoritmo 3, 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]. 10
19 Algoritmo 3: Produtorio(A, n) 1 produto = 1 2 para i = 1 até n faça 3 produto = produto A[i] 4 retorna produto Como podemos definir a invariante de laço para mostrar a corretude de Produtorio(A, n)? Veja que 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 Produtorio, conseguimos perceber que ao fim da i-ésima iteração temos o produtório dos elementos de A[1..i]. 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 Produtorio funciona. Invariante: Produtorio Antes de cada iteração indexada por i, a variável produto contém o produtório dos elementos em 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 11
20 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 é de fato o resultado desejado. Portanto, o algoritmo funciona corretamente. Perceba que mostrar que uma invariante se mantém durante a execução de um algoritmo nada mais é que uma prova por indução na quantidade de iterações de um dado laço. Na próxima seção discutiremos 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. Note que vários fatores afetam o tempo que um algoritmo leva para executar. Ele 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 for muito grande. O sistema operacional utilizado, a linguagem de programação utilizada, a velocidade do processador ou o modo com o algoritmo foi implementado influenciam diretamente no tempo de execução de um algoritmo. Assim, queremos um conceito de eficiência que seja independente de detalhes da entrada, da plataforma utilizada e que possa ser de alguma forma quantificado concretamente. Consideramos que o tempo de execução de um algoritmo é a quantidade de operações primitivas (operações aritméticas entre números pequenos, comparações, etc.) e passos básicos executados por ele sobre uma certa entrada. 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. O tamanho da entrada é um fator que independe de detalhes de implementação e, por isso, 12
21 o tempo de execução definido dessa forma nos possibilita obter uma boa estimativa do quão rápido um algoritmo é. Podemos, assim, comparar um algoritmo com o outros por meio da ordem de crescimento das funções que descrevem seus tempos de execução. Vamos então considerar que um algoritmo é eficiente se seu tempo de execução, qualquer que seja a entrada, puder ser descrito por uma função que cresce devagar com o tamanho da entrada. Por exemplo, a função f(x) = x cresce mais devagar do que a função g(x) = x 2 e mais rápido do que a função h(x) = log x. Para entender melhor essas definições, vamos começar com uma análise simples dos algoritmos BuscaLinear e BuscaBinaria 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 BuscaLinear no Algoritmo 4. Algoritmo 4: BuscaLinear(A, 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 Denote por t x a posição do elemento x no vetor A[1..n], onde fazemos 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 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 total T (n) de BuscaLinear(A, 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) Note que o tempo de execução, portanto, depende de onde x se encontra no vetor A. 13
22 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 BuscaBinaria no Algoritmo 5. Lembre-se que na busca binária assumimos que o vetor está ordenado de modo não decrescente. Algoritmo 5: BuscaBinaria(A, n, x) 1 esquerda = 1 2 direita = n 3 enquanto esquerda direita faça 4 meio = esquerda + direita esquerda 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 2 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 2r x vezes (pois em cada iteração do laço somente talvez os dois testes precisem ser executados) e as linhas 8 e 10 são executadas um total de no máximo r x vezes. Assim, o tempo de execução T (n) de BuscaBinaria(A, 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 de onde x se encontra no vetor A. 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 encontre x ou descubra que x não está em A. Como sempre metade do vetor é descartado, o algoritmo analisa, nessa ordem, vetores de tamanho n, n/2, n/2 2,..., 14
23 n/2 i, onde o último vetor analisado pode chegar a ter tamanho 1, caso em que n/2 i = 1, o 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 Perceba que, na análise de tempo que fizemos para os algoritmos de busca linear e binária, mesmo considerando entradas de um mesmo tamanho n, o tempo de execução dependia de qual entrada era dada. O tempo de execução de melhor caso de um algoritmo é o tempo de execução de uma entrada que executa de forma mais rápida, dentre todas as entradas possíveis de um dado tamanho n. No caso da BuscaLinear, 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 BuscaLinear é 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 BuscaBinaria, o melhor caso ocorre quando x está exatamente na metade do vetor A, i.e., A [ (n 1)/2 ] = x. Nesse caso, o laço enquanto é executado somente uma vez, de modo que o tempo de execução (veja (1.5)) é T (n) 4r x + 3 = 7. O tempo de execução de melhor caso de um algoritmo nos dá a garantia de que, qualquer que seja a entrada recebida, pelo menos tal tempo será necessário. Geralmente, no entanto, 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 BuscaLinear e da BuscaBinaria ocorre quando o elemento x a ser buscado não se encontra no vetor A, 15
24 pois a busca linear precisa percorrer todo o vetor, e a busca binária vai subdividir o vetor até que não seja mais 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, assuma por simplicidade que x está em A. Agora considere que quaisquer uma das n! permutações dos n elementos de A tem a mesma chance de ser passada 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, r x é a quantidade de vezes que o laço principal é executado. Calcular precisamente essa média 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 (um 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, sendo ambos uma expressão da forma an + b, para constantes a e b, uma função linear em n. Assim, ambos possuem 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. Existe um algoritmo de ordenação chamado Quicksort que 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 16
25 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 do 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 nos 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, então 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) Notações O, Ω e Θ Começamos definindo as notações assintóticas O e Ω abaixo, que nos ajudarão, respectivamente, a limitar superiormente e inferiormente o tempo de execução dos algoritmos. Definição 1.1: Notações O e Ω Seja n um inteiro positivo e sejam f(n) e g(n) funções positivas. 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. 17
26 Em outras palavras, f(n) = O(g(n)) quando, para todo n suficientemente grande (maior que um n 0 ), a função f(n) é limitada superiormente por Cg(n). Dizemos que f(n) é no máximo da ordem de g(n). Por outro lado, f(n) = Ω(g(n)) quando, para todo n suficientemente grande (maior que um n 0 ), f(n) é limitada inferiormente por cg(n). Dizemos que f(n) é no mínimo da ordem de g(n). Dadas funções f(n) e g(n), se f(n) = O(g(n)) e f(n) = Ω(g(n)), então dizemos que f(n) = Θ(g(n)). Definição 1.2: Notação Θ Seja n um inteiro positivo e sejam f(n) e g(n) funções positivas. 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. Note que as três notações acima são definidas em termos de funções. Assim, podemos utilizar todas elas para analisar tempos de execução de melhor caso, pior caso ou caso médio de algoritmos. No que segue assumimos que n é grande o suficiente. Se um algoritmo tem tempo de execução T (n) no pior caso e sabemos que T (n) = O(n log n), então para a instância de tamanho n em que o algoritmo é mais lento, ele leva tempo no máximo Cn log n, onde C é constante. Portanto, podemos concluir que para qualquer instância de tamanho n o algoritmo leva tempo no máximo da ordem de n log n. Por outro lado, se dizemos que T (n) = Ω(n log n) é o tempo de execução de pior caso de um algoritmo, então não temos muita informação útil. Sabemos somente que para a instância I n de tamanho n em que o algoritmo é mais lento, o algoritmo leva tempo pelo menos Cn log n, onde C é constante. Mas isso não implica nada sobre quaisquer outras instâncias do algoritmo, nem informa nada a respeito do tempo máximo de execução para a instância I n. Se um algoritmo tem tempo de execução T (n) no melhor caso, uma informação importante é mostrar que T (n) = Ω(g(n)), pois isso afirma que para a instância de tamanho n em que o algoritmo é mais rápido, ele leva tempo no mínimo cg(n), onde c é constante. Isso também afirma que, para qualquer instância de tamanho n, o algoritmo leva tempo no mínimo da ordem de g(n). Porém, se sabemos somente que T (n) = O(g(n)), então a única informação que temos é que para a instância de tamanho n em que o algoritmo é mais rápido, ele leva tempo pelo menos Cg(n), onde 18
27 C é constante. Isso não diz nada sobre o tempo de execução do algoritmo para outras instâncias. Vamos trabalhar com alguns exemplos para entender melhor as notações O, Ω e Θ. Fato 1.3 Se f(n) = 10n 2 + 5n + 3, então f(n) = Θ(n 2 ). Demonstração. Para mostrar que f(n) = Θ(n 2 ), vamos mostrar que f(n) = O(n 2 ) e f(n) = Ω(n 2 ). Verifiquemos primeiramente 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, precisamos obter uma constante C tal que 10n 2 + 5n + 3 Cn 2. Mas então basta que Como para n 1 temos C 10n2 + 5n + 3 n 2 = n + 3 n n = 18, n2 basta tomar n 0 = 1 e C = 18. Assim, temos C = 18 = n + 3 n 2 = 10n2 + 5n + 3 n 2, como queríamos. Logo, concluímos que f(n) 18n 2 para todo n 1 e, portanto, f(n) = O(n 2 ). 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, precisamos obter uma constante c tal que 10n 2 + 5n + 3 cn 2. Mas então basta que c n + 3 n 2. Como para n 1 temos n + 3 n 2 10, basta tomar n 0 = 1 e c = 10. Concluímos então que f(n) 10n 2 para todo n 1 e, 19
28 portanto, f(n) = Ω(n 2 ). Como mostramos que f(n) = O(n 2 ) e f(n) = Ω(n 2 ), então concluímos que f(n) = Θ(n 2 ). Perceba que na prova do Fato 1.3 traçamos uma simples estratégia para encontrar um valor apropriado para as constantes. Os valores para n 0 escolhido nos dois casos foi 1, mas algumas vezes é mais conveniente ou somente é possível escolher um valor maior para n 0. Considere o exemplo a seguir. Fato 1.4 Se f(n) = 5 log n + n, então f(n) = O( n). Demonstração. Comece percebendo que f(n) = O(n), pois sabemos que log n e n são menores que n para valores grandes de n (na verdade, para qualquer n 2). Porém, é possível melhorar esse limitante para f(n) = O( n). De fato, basta obter C e n 0 tais que para n n 0 temos 5 log n + n C n. Logo, queremos que C 5 log n n + 1. (1.6) Mas nesse caso precisamos ter cuidado ao escolher n 0, pois com n 0 = 1, temos 5(log 1)/ = 1, o que pode nos levar a pensar que C = 1 é uma boa escolha para C. Com essa escolha, precisamos que a desigualdade (1.6) seja válida para todo n n 0 = 1. Porém, se n = 2, então (1.6) não é válida, uma vez que 5(log 2)/ 2+1 > 1. Para facilitar, podemos observar que, para todo n 16, temos (log n)/ n 1, de modo que a desigualdade (1.6) é válida, i.e., (5 log n)/ n Portanto, tomando n 0 = 16 e C = 6, mostramos que f(n) = O( n). A estratégia utilizada nas demonstrações dos Fatos 1.3 e 1.4 de isolar a constante e analisar a expressão restante não única. Veja o próximo exemplo. Fato 1.5 Se f(n) = 5 log n + n, então f(n) = O( n). Demonstração. Podemos observar facilmente que log n n sempre que n
29 Assim, 5 log n + n 5 n + n = 6 n, (1.7) onde a desigualdade vale sempre que n 16. Como chegamos a uma expressão da forma f(n) C n, concluímos nossa demonstração. Portanto, tomando n 0 = 16 e C = 6, mostramos que f(n) = O( n). Uma terceira estratégia ainda pode ser vista no próximo exemplo. Fato 1.6 Se f(n) = 5 log n + n, então f(n) = O( n). Demonstração. Para mostrar esse resultado, basta obter C e n 0 tais que para n n 0 temos 5 log n + n C n. Logo, queremos que C 5 log n n + 1. (1.8) Note que ( ) 5 log n lim + 1 = lim n n n = lim n = lim n ( ) 5 log n + lim 1 (1.9) n n ( ) 5 1 n (1.10) 2 n ( 10 n ) + 1 = = 1, (1.11) onde usamos a regra de L Hôpital na segunda igualdade. Sabendo que quando n = 1 temos 5(log 1)/ = 1 e usando o resultado acima, que nos mostra que a expressão (5 log n)/ n + 1 tende a 1, provamos que é possível encontrar um C que seja maior do que essa expressão a partir de algum n = n 0. Perceba que podem existir diversas possibilidades de escolha para n 0 e C: pela definição, basta que encontremos alguma. Por exemplo, na prova do Fato 1.4, usar n 0 = 3454 e C = 2 também funciona para mostrar que 5 log n + n = O( n). Outra escolha possível seria n 0 = 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. 21
30 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.7 Se f(n) = 7n 2 então f(n) 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., que existem tais constantes C e n 0 tais que se n n 0, então 7n 2 Cn. Nosso objetivo agora é chegar a uma contradição. Note que, isolando o n na equação acima, para todo n n 0, temos n C/7, o que é um absurdo, pois claramente isso não é verdade para valores de n maiores que C/7, e sabemos que esse fato deveria valer para todo n n 0, inclusive valores de n maiores do que C/7. Relações entre as notações O, Ω e Θ No teorema enunciado a seguir descrevemos propriedades importantes acerca das relações entre as notações assintóticas O, Ω e Θ. 22
31 Teorema 1.8: Propriedades de notações assintóticas Sejam f(n), g(n) e h(n) funções positivas. Temos que 1. f(n) = Θ(f(n)); 2. f(n) = Θ(g(n)) se e somente se g(n) = Θ(f(n)); 3. f(n) = O(g(n)) se e somente se g(n) = Ω(f(n)); 4. Se f(n) = O(g(n)) e g(n) = Θ(h(n)), então f(n) = O(h(n)); O mesmo vale substituindo O por Ω; 5. Se f(n) = Θ(g(n)) e g(n) = O(h(n)), então f(n) = O(h(n)); O mesmo vale substituindo O por Ω; 6. f(n) = O ( g(n) + h(n) ) se e somente se f(n) = O(g(n)) + O(h(n)); O mesmo vale substituindo O por Ω ou por Θ; 7. Se f(n) = O(g(n)) e g(n) = O(h(n)), então f(n) = O(h(n)); O mesmo vale substituindo O por Ω ou por Θ. Demonstração. Vamos mostrar que os itens enunciados no teorema são válidos. Item 1. Esse item é simples, pois para qualquer n 1 temos que f(n) = 1 f(n), de modo que para n 0 = 1, c = 1 e C = 1 temos que para todo n n 0 vale que cf(n) f(n) Cf(n), de onde concluímos que f(n) = Θ(f(n)). Item 2. Note que basta provar uma das implicações (a prova da outra implicação é idêntica). Provaremos que se f(n) = Θ(g(n)) então g(n) = Θ(f(n)). Se f(n) = Θ(g(n)), então temos que existem constantes positivas c, C e n 0 tais que cg(n) f(n) Cg(n) (1.12) 23
32 para todo n n 0. Assim, analisando as desigualdades em (1.12), concluímos que ( ) 1 f(n) g(n) C ( ) 1 f(n) c para todo n n 0. Portanto, existem constantes n 0, c = 1/C e C = 1/c tais que c f(n) g(n) C f(n) para todo n n 0. Item 3. Vamos provar uma das implicações (a prova da outra implicação é análoga). Se f(n) = O(g(n)), então temos que existem constantes positivas C e n 0 tais que f(n) Cg(n) para todo n n 0. Portanto, temos que g(n) (1/C)f(n) para todo n n 0, de onde concluímos que g(n) = Ω(f(n)). Item 4. Se f(n) = O(g(n)), então temos que existem constantes positivas C e n 0 tais que f(n) Cg(n) para todo n n 0. Se g(n) = Θ(h(n)), então temos que existem constantes positivas d, D e n 0 tais que dh(n) g(n) Dh(n) para todo n n 0. Então f(n) Cg(n) CDh(n) para todo n max{n 0, n 0}, de onde concluímos que f(n) = O(h(n)). Item 5. Se f(n) = Θ(g(n)), então temos que existem constantes positivas c, C e n 0 tais que cg(n) f(n) Cg(n) para todo n n 0. Se g(n) = O(h(n)), então temos que existem constantes positivas D e n 0 tais que g(n) Dh(n) para todo n n 0. Então f(n) Cg(n) CDh(n) para todo n max{n 0, n 0}, de onde concluímos que f(n) = O(h(n)). Item 6. Vamos provar uma das implicações (a prova da outra implicação é análoga). Se f(n) = O(g(n) + h(n)), então temos que existem constantes positivas C e n 0 tais que f(n) C(g(n) + h(n)) para todo n n 0. Mas então f(n) Cg(n) + Ch(n) para todo n n 0, de forma que f(n) = O(g(n)) + O(h(n)). Item 7. Análoga às provas dos itens 4 e 5. Note que se uma função f(n) é uma soma de funções logarítmicas, exponenciais e polinômios em n, então sempre temos que f(n) vai ser Θ(g(n)), onde g(n) é o termo de f(n) com maior taxa de crescimento (desconsiderando constantes). Por exemplo, se f(n) = 4 log n (log n) n + n 3 /10 + 5n 5 + n 8 /27, então sabemos que f(n) = Θ(n 8 ). 24
33 1.3.2 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 que 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. Definição 1.9: Notações o e ω Seja n um inteiro positivo e sejam f(n) e g(n) funções positivas. Dizemos que f(n) = o(g(n)) se para toda constante c > 0 existe uma constante 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). 25
34 Note que com uma análise similar à 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). 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 têm 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)). 26
35 Capítulo 2 Recursividade Você quis dizer: recursividade Google 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 é 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
36 mesmo problema (estrutura recursiva). Esses problemas podem ser resolvidos com os seguintes passos: (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 usando 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 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 de todos os inteiros entre 1 e n, onde assumimos 0! = 1. Mas note que podemos definir n! da seguinte forma recursiva: 1 se n = 0 n! = n (n 1)! se n > 0 Essa definição inspira um simples algoritmo recursivo, descrito no Algoritmo 6. Algoritmo 6: Fatorial(n) 1 se n == 0 então 2 retorna 1 3 retorna n Fatorial(n 1) Por exemplo, ao chamar Fatorial(3), o algoritmo vai executar a linha 3, fazendo 3 Fatorial(2). Antes de poder retornar, é necessário calcular Fatorial(2). Nesse 28
37 ponto, o computador salva o estado atual na pilha de execução e faz uma chamada a Fatorial(2), que vai executar a linha 3 novamente, para retornar 2 Fatorial(1). Novamente, o estado atual é salvo na pilha de execução e uma chamada a Fatorial(1) é realizada. Essa chamada recursiva será a última, pois nesse ponto a linha 2 será executada e essa chamada retorna o valor 1. Assim, a pilha de execuçã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 não-decrescente) A com n elementos. Podemos facilmente desenvolver uma variação recursiva do algoritmo BuscaBinaria 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, equivalente à versão iterativa. Se A[ n/2 ] = x, então a busca está encerrada. Caso contrário, se x < A[ n/2 ], então basta verificar se o vetor A[1.. n/2 1] contém x, o que pode ser feito recursivamente. Se x > A[ n/2 ], então verifica-se recursivamente o vetor A[ n/ 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 7 basta fazer uma chamada BuscaBinariaRecursiva(A, 1, n, x). Algoritmo 7: BuscaBinariaRecursiva(A, inicio, f im, x) 1 se inicio > fim então 2 retorna 1 3 meio = inicio + fim inicio 4 se A[meio] == x então 5 retorna meio 2 6 senão se x < A[meio] então 7 BuscaBinariaRecursiva(A[1..n], inicio, meio 1, x) 8 senão 9 BuscaBinariaRecursiva(A[1..n], meio + 1, f im, x) 29
38 2.1.3 Algoritmos recursivos algoritmos iterativos 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, que é a sequência infinita de números: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89,... Por definição, o n-ésimo número da sequência, escrito como F n, é dado por 1 se n = 1 F n = 1 se n = 2 (2.1) F n 1 + F n 2 se n > 2. Não fica muito claro pela definição, mas F 30 é maior do que 1 milhão, F 100 é um número com 21 dígitos e, em geral, F n n. Ou seja, F n é um valor exponencial em n. Problema 2.1: Número de Fibonacci Dado um inteiro n 0, encontrar F n. 30
39 O Algoritmo 8 calcula recursivamente F n para um n dado como entrada e ilustra o quão ineficiente um algoritmo recursivo pode ser. Algoritmo 8: FibonacciRecursivo(n) 1 se n 2 então 2 retorna 1 3 retorna FibonacciRecursivo(n 1) + FibonacciRecursivo(n 2) Apesar de sua simplicidade, o procedimento acima é muito ineficiente. Seja T (m) o tempo necessário para computar F m. Para qualquer n 2, FibonacciRecursivo(n) leva tempo T (n) = T (n 1)+T (n 2)+1 (calculando F n 1, F n 2, fazendo a comparação, a soma e o retorno). Mas então T (n) F n, ou seja, o tempo é exponencial em n. Na prática, isso significa que se tivermos um computador que executa 4 bilhões de instruções por segundo (nada que os computadores existentes não possam fazer), levaria menos de 1 segundo para calcular F 10 e cerca de milênios para calcular F 200. Mesmo se o computador fosse capaz de realizar 40 trilhões de instruções por segundo, ainda precisaríamos de cerca de milênios para calcular F 200. Isso ocorre porque na versão recursiva muito trabalho repetido é feito pelo algoritmo (veja Figura 2.1). De fato, quando FibonacciRecursivo(n 1) + Fibonacci- Recursivo(n 2) é executado, além da chamada a FibonacciRecursivo(n 2) que é feita, a chamada a FibonacciRecursivo(n 1) fará mais uma chamada a FibonacciRecursivo(n 2), mesmo que ele já tenho sido calculado antes, e esse fenômeno cresce exponencialmente até chegar à base da recursão. É possível implementar um algoritmo iterativo simples que resolve o problema do número de Fibonacci e é executado em tempo polinomial. Na prática, isso significa que os mesmos dois computadores mencionados acima conseguem calcular F 200 e mesmo F em menos de 1 segundo. Para isso, basta utilizar um vetor, como mostra o Algoritmo 9. Atenção! A análise acima sobre o tempo de execução do Algoritmo 8, versão recursiva, não está 100% correta de acordo com o número total de operações básicas. Note que acima dizemos a soma e a comparação envolvem um número constante de operações, usando o número 1 na fórmula T (n) = T (n 1) + T (n 2) + 1. Essa parte é verdade, pois temos uma comparação, uma soma e um comando de retorno, porém a soma feita ali pode não levar um número constante de operações básicas para ser 31
40 F n F n 1 F n 2 F n 2 F n 3 F n 3 F n 4 F n 3 F n 4 F n 4 F n 5 F n 4 F n 5 F n 5 F n 6 F n 4 F n 5... Figura 2.1: Árvore de execução de FibonacciRecursivo(n) (Algoritmo 8). Cada nó representa uma chamada ao algoritmo. Algoritmo 9: Fibonacci(n) 1 se n 2 então 2 retorna 1 3 Seja F [1..n] um vetor de tamanho n 4 F [1] = 1 5 F [2] = 1 6 para i = 3 até n faça 7 F [i] = F [i 1] + F [i 2] 8 retorna F [n] 32
41 realizada. É razoável imaginar que um número de 32 bits ou de 64 bits possa ser somado com outro rapidamente (os processadores atuais fazem isso), mas o n-ésimo número da sequência de Fibonacci precisa de uns 0.694n bits para ser armazenado e isso é bem maior do que 64 conforme n cresce. Essa análise não cuidadosa foi proposital, pois mesmo com ela podemos ver a diferença entre os dois algoritmos para o problema do número de Fibonacci. Estritamente falando, o Algoritmo 8 faz cerca de F n somas mas usa um número de passos básicos proporcional a nf n. Esse exemplo clássico mostra como as estruturas de dados podem ter grande impacto na análise de algoritmos. Na Parte II veremos várias estruturas de dados que devem ser de conhecimento de todo bom desenvolvedor de algoritmos. Na Parte III apresentamos 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. 33
42 34
43 Capítulo 3 Métodos para solução de equações de recorrência Relações como T (n) = T (n 1)+T (n 2)+1, T (n) = 2T (n/2)+n ou T (n) = T (n/3)+ T (n/4) + 3 log n são chamadas de recorrências, que são equações ou inequações que descrevem uma função em termos de seus valores para entradas menores. Recorrências são muito comuns para descrever o tempo de execução de algoritmos recursivos. Portanto, elas são compostas de duas partes que indicam, respectivamente, o tempo gasto quando não há recursão (caso base) e o tempo gasto quando há recursão, que consiste no tempo das chamadas recursivas juntamente com o tempo gasto no restante da chamada atual. Assim, a forma correta de descrever o tempo de execução do Algoritmo 8, Fibonacci, é 1 se n 2 T (n) = T (n 1) + T (n 2) + 1 caso contrário. Em geral, o tempo gasto nos casos base dos algoritmos é constante (Θ(1)), de forma que é comum descrevemos apenas a segunda parte. Por exemplo, o tempo de execução T (n) do Algoritmo 7, BuscaBinariaRecursiva, é T (n/2) + 1. É claro que a informação o tempo de execução do algoritmo é T (n) = T (n/3) + T (n/4) + n não nos diz muita coisa. Gostaríamos portanto de resolver a recorrência, encontrando uma expressão que não depende da própria função, para que de fato
44 possamos observar sua taxa de crescimento. Neste capítulo apresentaremos quatro métodos para resolução de recorrências: (i) substituição, (ii) iterativo, (iii) árvore de recorrência e (iv) mestre. Antes disso, apresentamos na próxima seção algumas relações matemáticas e somas que surgem com frequência nesses métodos. O leitor familiarizado com os conceitos apresentados deve seguir para a seção seguinte, que explica o método iterativo. 3.1 Logaritmos e somatórios Como recorrências são funções definidas recursivamente em termos de si mesmas 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. Somatórios dos tempos de execução realizados fora das chamadas recursivas também irão aparecer. Abaixo listamos as propriedades mais comuns envolvendo manipulação de logaritmos. 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 que log b a = x se e somente se b x = a. No que 36
45 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 (a/b) = 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 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. 37
46 (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, 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 ). Teorema 3.2 Considere uma progressão aritmética (a 1,..., a n ) com razão r e uma progressão geométrica (b 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 38
47 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 Método da substituição Esse método consiste simplesmente em provar por indução matemática que uma recorrência T (n) é limitada (inferiormente e/ou superiormente) por alguma função f(n). Um ponto importante é que, como é uma prova por indução, é necessário que se saiba qual é a função f(n) de antemão. O método da árvore de recorrência, descrito mais adiante (veja Seção 3.4), pode fornecer uma estimativa para f(n). 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. 39
48 Mostraremos inicialmente que T (n) = O(n 2 ). Para isso, provaremos por indução que T (n) cn 2 para c 1 e n 1, i.e., mostraremos que existem constantes c e n 0 tais que, se n n 0, então T (n) cn 2, (3.2) o que implica em T (n) = O(n 2 ). Via de regra assumiremos T (1) = 1, a menos que indiquemos algo diferente. Durante a prova, ficará claro quais os valores de c e n 0 necessários para que 3.2 aconteça (nesse exemplo, qualquer c 1 e n 0 1 funcionam). Comecemos pelo caso base, que vale trivialmente: para n = 1 temos T (1) = 1 = 1 n 2. Suponha que, para 1 m < n, temos T (m) m 2. Precisamos mostrar que T (n) n 2. Para isso, combinamos T (n) = 2T (n/2) + n com o fato de que T (m) m 2 para m = n/2 (por hipótese de indução). Assim, T (n) = 2T (n/2) + n ( ) n n 2 2 = (n 2 /2) + n n 2, onde a última desigualdade vale sempre que n 2, que é o caso. Portanto, mostramos por indução em n que T (n) cn 2 para c 1 e n n 0 = 1, de onde concluímos que T (n) = O(n 2 ). Há ainda uma pergunta importante a ser feita: será que é possível provar um limitante superior assintótico melhor que n 2? 1 Mostraremos que se T (n) = 2T (n/2)+n, então temos T (n) = O(n log n). Novamente, utilizaremos o método da substituição, que consiste em provar a relação desejada por indução em n. Assim, provaremos que T (n) cn log n para c 2 e n 2, i.e., existem constantes c e n 0 tais que, se n n 0, então T (n) cn log n, o que implica em T (n) = O(n log n). Aqui, faremos c = 2, n 0 = 2. Lembre que assumimos T (1) = 1. Note que se n = 1 for o caso base da indução, então temos um problema, pois 1 > 0 = cn log n para n = 1. Porém, em análise 1 Aqui queremos obter um limitante f(n) tal que f(n) = o(n 2 ). 40
49 assintótica estamos preocupados somente com valores suficientemente grandes de n. Assim, como T (2) = 2T (1) + 2 = 4 c 2 log 2 para c 2, vamos assumir que n 2, de forma que a base da indução que vamos realizar é n = 2. Suponha agora que, para 2 m < n, temos T (m) cm log m. Precisamos mostrar que T (n) cn log n. Temos T (n) = 2T (n/2) + n 2 ( c(n/2) log(n/2) ) + n = cn log n cn + n cn log n, para c 1. Portanto, mostramos que T (n) cn log n para c 2 e n n 0 = 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 geralmente podemos assumir que n é uma potência de 2, de modo que em recorrências do tipo T (n) = T ( n/2 ) + T ( n/2 ) + n não há perda de generalidade ao desconsiderar pisos e tetos. Suponha que n 3 não é uma potência de 2 e considere a recorrência T (n) = T ( n/2 ) + T ( n/2 ) + n. Como n não é uma potência de 2, existe um inteiro k 2 tal que 2 k 1 < n < 2 k. Portanto, T (2 k 1 ) T (n) T (2 k ). Já provamos que T (n) = Θ(n log n) no caso em que n é potência de 2. Em particular, T (2 k ) d2 k log(2 k ) para alguma constante d e T (2 k 1 ) d 2 k 1 log(2 k 1 ) para alguma constante d. Assim, 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. 41
50 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 considerar 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álises de tempo de execução 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, de modo que 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 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. 42
51 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 queremos mostrar que T (n) cn log n e supomos que T (m) cm log m, mas mostramos no passo indutivo que T (n) cn log n + 1, nós não provamos o que nos propusemos. Esse resultado portanto não implica que T (n) = O(n log n), pois precisaríamos provar que T (n)c 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, de onde concluímos que T (n) = Ω(n log n). A base da indução nesse caso é n = 1, e temos que aqui o resultado vale pois T (1) = 1 n log n. Suponha que para todo m, com 2 m < n, temos T (m) m log m. Assim, T (n) = 2T (n/2) + n 2 ( (n/2) log(n/2) ) + n = n log n. Portanto, mostramos que T (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 de fato T (n) = O ( f(n) ) mas o palpite para a função f(n) precise de um leve ajuste. Considere T (n) = 3T (n/3) + 1. Podemos imaginar que esse é o tempo de execução de um algoritmo recursivo sobre um vetor que a cada chamada divide o vetor em 3 partes de tamanho n/3, fazendo três chamadas recursivas sobre estes, e o restante não envolvido nas chamadas recursivas é realizado em tempo constante. Assim, um bom palpite é que T (n) = O(n). Para mostrar que o palpite está correto, vamos tentar provar que T (n) cn para alguma constante positiva c, por indução em n. No passo 43
52 indutivo, temos T (n) = 3T (n/3) + 1 cn + 1, o 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). Acontece que é verdade que T (n) = O(n), mas o problema é que a expressão que escolhemos para provar nosso palpite não foi forte o suficiente. Como corriqueiro em provas por indução, precisamos fortalecer a hipótese indutiva. Vamos tentar agora provar que T (n) cn d, onde c e d são constantes e d 1/2. Note que provando isso estaremos provando que T (n) = O(n) de fato. No passo indutivo, temos T (n) = 3T (n/3) + 1 ( cn ) 3 3 d + 1 = cn 3d + 1 cn d. Assim, como no caso base (n = 1) temos T (1) = 1 c d sempre que c d + 1, vale que 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. Vamos provar que T (n) = Θ(n 3 ). Primeiramente, mostraremos que T (n) = O(n 3 ) e, 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 44
53 2 m < n. Assim, temos que T (n) = 4T (n/2) + n 3 4cn3 + n 3 8 cn 3, onde a última desigualdade vale sempre que c 2. Portanto, fazendo c = 2 (ou qualquer valor maior), 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 algum 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 + n 3 8 dn 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, onde a última desigualdade vale se c 5/4. Como 3/2 > 5/4, basta tomar c = 3/2 45
54 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 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, 46
55 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 valores apropriados de a e c. No passo da indução, temos 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). 3.3 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. Expandindo: 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. 47
56 Sabemos que T (1) = 1. Então, tomando i = log n, continuamos a estimativa para T (n): T (n) = T (n/2 i ) + i = T (n/2 log n ) + log n = T (1) + log n = Θ(log n). Para um segundo exemplo, considere T (n) = 2T (n/2) + n. Temos 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. 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 Mergesort 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, Mergesort é assintoticamente mais eficiente que Insertion sort. Analisaremos agora um último exemplo, que representa o tempo de execução de um algoritmo que sempre divide o problema em 2 subproblemas de tamanho n/3 e cada chamada recursiva é executada em tempo constante. Assim, seja T (n) = 2T (n/3)
57 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 3 1 = 2n 1/ log 3 1 = Θ(n 1/ log 3 ) 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 apenas que T (n) = Ω(n 1/ log 3 ), podemos utilizar limitantes inferiores para nos ajudar na 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 49
58 T (n). Note que 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 3 + log 3 n = Ω(n 1/ log 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.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 nó são os subproblemas que foram gerados na chamada recursiva associada ao nó. 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 T (n) = 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, sendo que 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 50
59 Figura 3.1: Árvore de recorrência para T (n) = 2T (n/2) + cn. 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. 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. 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, e f(n) positiva. Esse resultado formaliza uma análise cuidadosa feita utilizando árvores de recorrência. Na Figura 3.3 temos 51
60 Figura 3.2: Árvore de recorrência para T (n) = 2T (n/2) + 1. uma análise da árvore de recorrência de 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 a) b. A ideia envolvida no Teorema 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 que 52
61 Figura 3.3: Árvore de recorrência para T (n) = at (n/b) + f(n). (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 af(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 53
62 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/ou 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 Θ(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). Pode ser o caso que f(n) = Ω(n log b a+ε ) mas a condição af(n/b) cf(n) do item (3) não é satisfeita. 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). 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 que (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 af(n/b) cf(n) para alguma constante c < 1. Dessa forma, o resultado segue diretamente do Teorema 3.1. Primeiro note que como f(n) = k i=0 a in i = Ω(n log b a+ε ) temos k = log b a + ε. 54
63 Resta provar que af(n/b) cf(n) para algum c < 1. Logo, basta provar que cf(n) 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 ) nn 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ências onde aplicaremos o Teorema Mestre para resolvê-las. Exemplo 1. T (n) = 2T (n/2) + n. Claramente, temos a = 2, b = 2 e f(n) = n. Como 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. 55
64 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. 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 ) 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); ou (ii) f(n) = Ω(n log b a+ε ) para alguma constante ε > 0, mas não existe c < 1 tal que af(n/b) cf(n) para todo n suficientemente grande. Para afirmar que o Teorema Mestre não vale devido à (i), temos que verificar que valem as três seguintes afirmações: 1) f(n) Θ(n log b a ); 2) f(n) O(n log b a ε ) para qualquer ε > 0; 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. 56
65 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 ε > 0, 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 ε > 0, 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 ε > 0, 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 Ω( nn ε ), de onde concluímos que o caso (3) do Teorema Mestre 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 ε > 0, 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 57
66 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 acima, claramente temos que 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) = 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. Exemplo 5. T (n) = T (n/2) + n(2 cos n). Primeiro vamos verificar em que caso estaríamos no 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, e portanto não é possível obter a condição extra 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) = 58
67 T (n/3) + T (2n/3) + Θ(n), mas não entraremos em detalhes desse método aqui. 59
68 60
69 Parte II Estruturas de dados Computer programs usually operate on tables of information. In most cases these tables are not simply amorphous masses of numerical values; they involve important structural relationships between the data elements. Knuth The Art of Computer Programming, 1997.
70
71 Nesta parte Algoritmos geralmente precisam manipular conjuntos de dados que podem crescer, diminuir ou sofrer diversas modificações durante sua execução. Um tipo abstrato de dados é um conjunto de dados, as relações entre eles e as funções e operações que podem ser aplicadas aos dados. Uma estrutura de dados é uma implementação de um tipo abstrato de dados. O segredo de muitos algoritmos é o uso de uma boa estrutura de dados. Como vimos na Seção 2.1.3, o uso de uma boa estrutura pode ter grande impacto na velocidade de um programa. Estruturas diferentes suportam operações diferentes em tempos diferentes, de forma que nenhuma estrutura funciona bem em todas as circunstâncias. Assim, é importante conhecer as qualidades e limitações de várias delas. Nas seções a seguir discutiremos os tipos abstratos e as estruturas de dados mais recorrentes em análises de algoritmos.
72 64
73 Capítulo 4 Estruturas lineares Neste capítulo veremos as estruturas de dados mais simples e clássicas, que formam a base para muitos dos algoritmos vistos neste livro. 4.1 Vetor Um vetor é uma coleção de elementos de um mesmo tipo que são referenciados por um identificador único. Esses elementos ocupam posições contíguas na memória, o que permite acesso direto (em tempo constante Θ(1)) a qualquer elemento por meio de um índice inteiro. Denota um vetor A com capacidade para m elementos por A[1..m]. Se o vetor armazena n elementos (seu tamanho), então podemos denotá-lo também por A = (a 1, a 2,..., a n ) e A[i] = a i é o elemento que está armazenado na posição i, para todo 1 i n. 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]. Como já foi discutido na Seção 1.1, o tempo de busca em um vetor de tamanho n é O(n) pois, no pior caso, precisamos acessar todos os elementos armazenados no vetor. A inserção de um novo elemento x em um vetor A de tamanho n é feita em tempo constante Θ(1), pois basta inseri-lo na primeira posição disponível, em A[n + 1]. Já a remoção de algum elemento do vetor envolve inicialmente uma busca pela posição na qual o elemento se encontra e, por isso, leva tempo O(n). É claro que, se o vetor estiver ordenado, então os tempos mencionados acima
74 mudam. Como vimos, a busca binária nos garante que o tempo de busca em um vetor de tamanho n é O(log n). A inserção, no entanto, não pode mais ser feita em tempo constante em uma posição qualquer, pois precisamos garantir que o vetor continuará ordenado. Assim, potencialmente precisaremos mover vários elementos do vetor durante uma inserção, de forma que ela leva tempo O(n). De forma similar, a remoção precisa de tempo O(log n) para que se encontre o elemento no vetor, e ainda precisa de tempo O(n) para mover os elementos à direita do elemento removido e manter o vetor ordenado. O fato do vetor estar ordenado ainda nos permite realizar a operação de encontrar o k-ésimo menor elemento do vetor em tempo Θ(1). Se o vetor não estiver ordenado, existe um algoritmo que consegue realizar tal operação em tempo O(n). 4.2 Lista encadeada Uma lista encadeada é uma estrutura de dados linear onde cada elemento é armazenado em um nó, que armazena também endereços para outros nós da lista. Por isso, cada nó de uma lista pode estar em uma posição diferente da memória, sendo diferente de um vetor, onde os elementos são armazenados de forma contínua. Na forma mais simples, têm-se acesso apenas ao primeiro nó da lista. Em qualquer variação, listas não permitem acesso direto a um elemento: para acessar o k-ésimo elemento da lista, deve-se acessar o primeiro, que dá acesso ao segundo, que dá acesso ao terceiro, e assim sucessivamente, até que o (k 1)-ésimo elemento dá acesso ao k-ésimo. Em uma lista duplamente encadeada L, cada nó 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 baseados nos atributos chave, que sempre contêm inteiros não negativos. Dado um nó x de uma lista duplamente encadeada, x. anterior aponta para o nó que está imediatamente antes de x na lista e x. proximo aponta para o nó que está imediatamente após x na lista. Se x. anterior = null, então x não tem predecessor, de modo que é o primeiro nó da lista, a cabeça da lista. Se x. proximo = null, então x não tem sucessor e é chamado de cauda da lista, sendo o último nó da mesma. O atributo L. cabeca aponta para o primeiro nó da lista L, sendo que L. cabeca = null 66
75 Figura 4.1: Lista duplamente encadeada circular. quando a lista está vazia. Existem diversas variações de listas além de listas duplamente encadeadas. Em uma lista encadeada simples não existe o ponteiro anterior. 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 4.1 mostra um exemplo de uma 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 BuscaNaLista mostrado no Algoritmo 10 realiza uma busca pelo primeiro nó que possui 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 BuscaNaLista é O(n) para uma lista com n elementos. Algoritmo 10: BuscaNaLista(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 Algoritmo 11 inserimos um nó x na lista L. Portanto, caso L não seja vazia, o ponteiro x. proximo deve 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. Como somente uma quantidade constante de operações é executada, o procedimento 67
76 Algoritmo 11: InsereNaLista(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 InsereNaLista é executado em tempo Θ(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 Algoritmo 12 mostra o procedimento RemoveDaLista, que remove um nó x de uma lista L. Note que o parâmetro passado para o procedimento não é um valor chave k, mas sim um ponteiro para um nó x. Esse ponteiro pode ser encontrado, por exemplo, com uma chamada à BuscaNaLista. A remoção é simples, sendo necessário somente atualizar os ponteiros x. anterior. proximo e x. proximo. anterior, e tendo cuidado com os casos onde x é a cabeça ou a cauda de L. Algoritmo 12: RemoveDaLista(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 Como somente uma quantidade constante de operações é efetuada, a remoção leva tempo Θ(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 BuscaNaLista(L, k) e então remover o elemento retornado pela busca, gastando tempo Θ(n) no pior caso. Observe que o fato do procedimento RemoveDaLista ter sido feito em uma lista duplamente encadeada é essencial para que seu tempo de execução seja Θ(1). Se L for uma lista encadeada simples, não temos a informação de qual elemento em L está 68
77 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 Θ(n) no pior caso. 69
78 70
79 Capítulo 5 Árvores Árvores são, de certa forma, um conceito estendido de listas ligadas. São estruturas não lineares constituídas de nós, onde cada nó x contém um elemento armazenado em x. chave e pode ter um ou mais ponteiros para outros nós. Mais especificamente, árvores são estruturas hierárquicas nas quais um nó aponta para os nós abaixo dele na hierarquia, chamados seus nós filhos. Um nó especial é a raiz, que é o topo da hierarquia e está presente no nível 0 da árvore. Nós filhos da raiz estão no nível 1, os nós filhos destes estão no nível 2, e assim por diante. O nível de um nó é definido formalmente como a menor quantidade de nós que existem entre o nó e a raiz. Um nó sem filhos é chamado de folha da árvore. Veja na Figura 5.1 um exemplo de árvore e as devidas nomenclaturas. x nível 0 y z w nível 1 a b c nível 2 d nível 3 Figura 5.1: Exemplo de estrutura árvore com 4 níveis e altura 3, onde: (i) x é o nó raiz (nível 0), (ii) y, z e w são filhos de x, (iii) y é pai de a e b, (iv) a, d, z e c são folhas.
80 Figura 5.2: Árvore binária quase completa. Em uma árvore, só temos acesso direto ao nó raiz e qualquer manipulação, portanto, deve percorrer os ponteiros entre os nós. Note ainda que existe um único caminho entre a raiz e uma folha. A distância do caminho raiz-folha mais longo, considerando todas as folhas, define a altura da árvore. Equivalentemente, a altura de uma árvore é igual ao maior nível. A altura de um nó x da árvore é definida como a menor quantidade de nós existentes entre x e uma folha. De outra forma, a altura de x é a altura da subárvore com raiz em x. Considerando apenas essas informações, vemos que qualquer busca deve ser feita percorrendo a árvore toda. Inserções e remoções não estão bem definidas também. Assim, essencialmente, não ganhamos muita coisa com relação a uma lista ligada. O tipo mais comum de árvore, e que define melhor as operações mencionadas, é a árvore binária. Árvores binárias são aquelas cujo maior número de filhos de qualquer nó é dois e, portanto, podemos distinguir os filhos entre direito e esquerdo. Elas também podem ser definidas recursivamente: ela é vazia ou é um nó raiz que é pai de uma árvore binária à direita e de outra árvore binária à esquerda. Assim, também dizemos que o filho direito (resp. esquerdo) do nó raiz é raiz da subárvore direita (resp. esquerda). Formalmente, se x é um nó, então x contém os atributos x. chave, x. direita e x. esquerda. 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 nós. Uma árvore binária com altura h é dita quase completa se os níveis 0, 1,..., h 1 têm todos os nós possíveis. Na Figura 5.2 temos um exemplo de uma árvore binária quase completa. 72
81 5.1 Árvores binárias de busca Árvores binárias de busca são árvores binárias especiais nas quais, para cada nó x, todos os nós da subárvore esquerda possuem chaves menores do que x. chave e todos os nós da subárvore direita possuem chaves maiores do que x. chave. Essa propriedade é usada justamente para guiar a operação de busca. Assim, se quisermos procurar um elemento k na árvore, primeiro o comparamos com a raiz: (i) k é igual à chave da raiz e a busca termina, (ii) k é menor do que a chave da raiz e o problema se reduz a procurar k na subárvore esquerda, ou (iii) k é maior do que a chave da raiz e o problema se reduz a procurar k na subárvore direita. Note que o pior caso de uma busca será percorrer um caminho raiz-folha inteiro, de forma que a busca pode levar tempo O(h), onde h é a altura da árvore. Agora temos uma potencial melhora com relação a listas ligadas: pode ser que a árvore tenha altura menor do que o número n de elementos armazenados nela. Outras operações possíveis em árvores de busca que não alteram sua estrutura são: Encontrar o menor elemento: basta seguir os filhos esquerdos a partir da raiz até chegar em um nó que não tem filho esquerdo este contém o menor elemento da árvore. Tempo necessário: O(h). Encontrar o maior elemento: basta seguir os filhos direitos a partir da raiz até chegar em um nó que não tem filho direito este contém o maior elemento da árvore. Tempo necessário: O(h). O sucessor de um elemento k: é o menor elemento que é maior do que k. Seja x o nó tal que x. chave = k. Pela estrutura da árvore, se x tem um filho direito, então o sucessor de k é o menor elemento armazenado nessa subárvore direita. Caso x não tenha filho direito, então o primeiro nó que contém um elemento maior do que k deve estar em um ancestral de x: é o nó de menor chave cujo filho esquerdo também é ancestral de x. Tempo necessário: O(h) O predecessor de um elemento k: se x é o nó que contém k, o predecessor de k é o maior elemento da subárvore enraizada no filho esquerdo de x ou então é o maior ancestral cujo filho direito também é ancestral de x. Tempo necessário: O(h) 73
82 Figura 5.3: Exemplo de árvore binária de busca onde o sucessor de 30 é o 37 (menor nó da subárvore enraizada em 90) e o sucessor de 20 é o 30 (menor ancestral do 20 cujo filho esquerdo, o 17, também é ancestral do 20). Veja a Figura 5.3 para exemplos de elementos sucessores. O Algoritmo 13 mostra o procedimento InsereNaABB, que recebe a raiz R de uma árvore binária de busca (ABB) e um novo nó x e tenta inseri-lo na árvore, retornando o nó raiz da árvore nova. Se a árvore está inicialmente vazia, então o nó x será a nova raiz. Caso contrário, o primeiro passo do algoritmo é buscar por x. chave na árvore. Se x. chave não estiver na árvore, então a busca terminou em um nó y que deverá ser o pai de x: se x. chave < y. chave, então inserimos x à esquerda de y e caso contrário o inserimos à direita. Note que qualquer busca posterior por x. chave vai percorrer exatamente o mesmo caminho e chegar corretamente a x. Portanto, essa inserção mantém a propriedade da árvore binária de busca. Não é difícil perceber que o tempo de execução desse algoritmo também é O(h). Algoritmo 13: InsereNaABB(R, x) 1 se R == null então 2 retorna x 3 se x. chave < R. chave então 4 R. esquerda = InsereNaABB(R. esquerda, x) 5 se x. chave > R. chave então 6 R. direita = InsereNaABB(R. direita, x) 7 retorna R No caso de remoções, precisamos tomar alguns cuidados extras para garantir que a árvore continue sendo de busca. Se o nó a ser removido é folha, então não há problemas 74
83 Figura 5.4: Cinco exemplos de árvores formadas pela inserção dos elementos 37, 45, 60, 90 e 97 em diferentes ordens. e basta removê-lo. Se o nó a ser removido tem um único filho, então temos um caso simples também e basta substituí-lo por esse filho. Agora, se o nó x a ser removido tem dois filhos, precisamos substituí-lo por algum outro nó que tenha no máximo um filho e vá manter a propriedade de busca. Um bom candidato para substituir x é seu sucessor: todos os nós à esquerda de x têm elementos menores do que o sucessor de x e todos os nós à direita têm elementos maiores. Como o sucessor de x é o nó de menor chave da subárvore direita de x (pois x tem dois filhos) e o menor nó de uma árvore tem no máximo um filho (à direita), podemos de fato trocar o nó sucessor com x e prosseguir removendo x, que passa a ter um único filho. Note que o tempo de execução dessa operação depende basicamente da operação que encontra o sucessor de um nó (pois nos outros casos temos simples atualizações de ponteiros), de forma que ela também leva tempo O(h). Assim, buscar por um elemento, inserir um novo nó, remover algum nó, encontrar o k-ésimo menor elemento e encontrar o predecessor ou sucessor de um elemento são operações que podem ser feitas em tempo O(h) em uma árvore binária de busca, onde h é a altura da árvore. Note agora que a inserção é feita de qualquer forma, apenas respeitando a propriedade de busca. Assim, a árvore gerada após um certo número n de inserções pode ter qualquer formato. Um mesmo conjunto de elementos, dependendo da ordem na qual são inseridos, pode dar origem a várias árvores diferentes, veja a Figura 5.4 Todas as operações que mencionamos têm tempo O(h) e, como vimos na Figura 5.4, uma árvore binária de busca com n nós pode ter altura h = n e, portanto, ser tão ruim quanto uma lista ligada. Uma forma de melhorar os tempos de execução das operações, portanto, é garantir que a altura da árvore não seja tão grande. 75
84 5.2 Árvores balanceadas Uma árvore balanceada garante que sua altura vai ser sempre pequena o suficiente mesmo depois de várias inserções e remoções. No caso de árvores binárias, se ela tem altura h então existem no máximo h = 2 h+1 1 nós. Se n é o total de nós, então n 2 h+1 1, o que implica que h log n Ou seja, a menor altura de qualquer árvore binária com n nós é O(log n). Árvore AVL é uma árvore binária de busca balanceada que mantém a seguinte propriedade: a diferença entre as alturas da subárvore esquerda e direita de qualquer nó é no máximo 1. Isso garante que a altura h de qualquer árvore AVL é sempre O(log n). Árvore Red-Black é uma árvore binária de busca balanceada que também tem altura O(log n). Árvore-B é uma árvore de busca balanceada mas que não é binária: cada nó tem m elementos e m + 1 filhos. Ela garante altura O(log n) também. 76
85 Capítulo 6 Pilha Pilha é uma coleção dinâmica de dados cuja operação de remoção deve remover o elemento que está na coleção há menos tempo. Essa política de remoção é conhecida como LIFO, acrônimo para last in, first out. Ela é um tipo abstrato de dados que oferece as operações de adicionar e remover um elemento. Independente da implementação, é possível realizar ambas em tempo Θ(1). 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. Vale mencionar também que pilhas são úteis na implementação de algoritmos de busca em profundidade em grafos. Vamos mostrar como implementar uma pilha utilizando um vetor P [1..m] com capacidade para m elementos. 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, que inicialmente é 0. O atributo P. capacidade contém a capacidade total do vetor, que é m. Em qualquer momento, o vetor P [1..P. topo] armazena os elementos da pilha em questão, onde P [1] contém o primeiro elemento inserido na
86 pilha e P [P. topo] contém o último. Note que o tamanho da pilha é dado por P. topo. Quando inserimos um elemento x na pilha P, dizemos que estamos empilhando x em P. Similarmente, ao remover um elemento de P nós desempilhamos de P. As duas operações, Empilha e Desempilha, são dadas nos Algoritmos 14 e 15, respectivamente. Elas são bem simples e, como dito acima, levam tempo Θ(1) para serem executadas. Para acrescentar um elemento x à pilha P, utilizamos o procedimento Empilha, que verifica se a pilha está cheia e, caso ainda haja espaço, atualiza o topo e o tamanho da pilha e insere x em P [P. topo]. Algoritmo 14: Empilha(P, x) 1 se P. topo P. capacidade então 2 P. topo = P. topo +1 3 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 15: Desempilha(P ) 1 x = null 2 se P. topo 0 então 3 x = P [P. topo] 4 P. topo = P. topo 1 5 retorna x Um outro procedimento interessante de se ter disponível é o Consulta, que simplesmente retorna o topo da pilha, sem mexer em sua estrutura. A Figura 6.1 ilustra algumas operações em pilha. 78
87 Figura 6.1: Operações em uma pilha P inicialmente vazia: Empilha(P, 3), Empilha(P, 5), Empilha(P, 1), Desempilha(P ), Desempilha(P ), Empilha(P, 8). 79
88 80
89 Capítulo 7 Fila Fila é uma coleção dinâmica de dados cuja operação de remoção deve remover o elemento que está na coleção há mais tempo. Essa política de remoção é conhecida como FIFO, acrônimo para first in, first out. Ela é um tipo abstrato de dados que oferece as operações de adicionar e remover um elemento. Independente da implementação, é possível realizar ambas em tempo Θ(1). O conceito de fila é amplamente utilizado em aplicações práticas. Por exemplo, qualquer sistema que controla a ordem de atendimento em bancos pode ser implementado utilizando filas. Também são úteis para manter a ordem de documentos que são enviados a uma impressora. De forma mais geral, 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. Vamos mostrar como implementar uma fila utilizando um vetor F [1..m] com capacidade para m elementos. O atributo F. cabeca contém o índice para o elemento que está há mais tempo na fila. O atributo F. cauda contém o índice para o último elemento que foi inserido na fila. Inicialmente F. cabeca = F. cauda = 1. Em qualquer momento, se F. cabeca < F. cauda, então os elementos da fila encontram-se nas posições F. cabeca, F. cabeca +1,..., F. cauda 1, F. cauda. Se F. cabeca > F. cauda, então os elementos encontram-se nas posições F. cabeca, F. cabeca +1,..., F. capacidade, 1, 2,..., F. cauda. E se F. cabeca = F. cauda, então a fila está vazia. Note ainda que a
90 fila estará cheia quando F. cabeca = F. cauda +1 ou então quando F. cabeca = 1 e F. cauda = m. Por isso, as operações de soma e subtração nos valores de F. cabeca e F. cauda são feitas módulo F. capacidade = m, i.e., podemos enxergar o vetor F de forma circular. Quando inserimos um elemento x na fila F, dizemos que estamos enfileirando x em F. Similarmente, ao remover um elemento de F nós estamos desenfileirando de F. As duas operações de fila, Enfileira e Desenfileira, são mostradas respectivamente nos Algoritmos 16 e 17 e levam tempo Θ(1) para serem executadas. O procedimento Enfileira adiciona um elemento x à 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 esse valor e o tamanho da fila. Esse procedimento realiza uma quantidade constante de operações, de modo que é claramente executado em tempo Θ(1). Algoritmo 16: Enfileira(F, x) 1 se (F. cabeca 1 ou F. cauda F. capacidade) e F. cabeca F. cauda +1 então 2 se F. cauda == F. capacidade então 3 F. cauda = 1 4 senão 5 F. cauda = F. cauda +1 6 F [F. cauda] = x 7 F. tamanho = F. tamanho +1 Para remover um elemento da fila, utilizamos o procedimento Desenfileira, 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 e o tamanho da fila. Como no procedimento Enfileira, claramente o tempo gasto em Desenfileira é Θ(1). Um outro procedimento interessante de se ter disponível é o Consulta, que simplesmente retorna o início da fila, sem mexer em sua estrutura. A Figura 7.1 ilustra algumas operações em fila. 82
91 Algoritmo 17: Desenfileira(F ) 1 x = null 2 se F. cabeca F. cauda então 3 x = F [F. cabeca] 4 se F. cabeca == F. capacidade então 5 F. cabeca = 1 6 senão 7 F. cabeca = F. cabeca +1 8 F. tamanho = F. tamanho 1 9 retorna x Figura 7.1: Operações em uma fila F inicialmente vazia: Enfileira(F, 3), Enfileira(F, 5), Enfileira(F, 1), Desenfileira(F ), Desenfileira(F ), Enfileira(F, 8). 83
92 84
93 Capítulo 8 Fila de prioridades Uma fila de prioridades é uma coleção dinâmica de elementos que possuem prioridades associadas e a operação de remoção deve sempre remover o elemento que possui maior prioridade. Ela é um tipo abstrato de dados que, além da remoção do elemento de maior prioridade, também oferece as operações de construção (feita a partir de um conjunto pré-existente de elementos), busca pelo elemento de maior prioridade, inserção de um elemento novo e alteração da prioridade de um elemento já armazenado. É importante perceber que o termo prioridade é usado de maneira genérica: ter maior prioridade não significa necessariamente que o valor indicativo da prioridade é o maior. Por exemplo, se falamos de atendimento em um banco e a prioridade de atendimento é indicada pela idade da pessoa, então tem maior prioridade a pessoa que tiver maior idade. Por outro lado, se falamos de gerenciamento de estoque de remédios em uma farmácia e a prioridade de compra é indicada pela quantidade em estoque, então tem maior prioridade o remédio que estiver em menor quantidade. Filas de prioridades são muito úteis na implementação de diversos algoritmos clássicos como Dijkstra, Prim, Huffman e Heapsort. Podem ser implementadas de diversas formas, como por exemplo um vetor ordenado pela prioridade dos elementos. Com essa implementação, se a estrutura possui n elementos, então construir a fila leva tempo O(n log n), inserir e alterar a prioridade de um elemento levam tempo O(n) e encontrar o elemento de maior prioridade e remover o elemento de maior prioridade levam tempo Θ(1) cada. No entanto, a implementação mais comum é por meio da estrutura de dados Heap binário, que permite construção
94 em tempo O(n), inserção, remoção e alteração de um elemento em tempo O(log n) e busca pelo elemento de maior prioridade em tempo Θ(1). 8.1 Heap binário Antes de discutirmos a estrutura heap, relembre a discussão feita no final da introdução do Capítulo 5. Um heap é uma estrutura de dados que implementa o tipo abstrato de dados fila de prioridades. Conceitualmente, um heap pode ser visto como uma árvore binária quase completa, isto é, todos os níveis estão cheios, exceto talvez pelo último, que é preenchido de forma contígua da esquerda para a direita. Em geral, no entanto, um heap é implementado em um vetor, que é a estrutura que usaremos nessa seção. Mesmo assim, no que segue, muitas vezes usaremos o termo nó para nos referirmos a um elemento armazenado no heap. E devido ao conceito de heap, também vamos utilizar a nomenclatura de pai e filhos. Definição 8.1: Propriedade de heap Em um heap, um nó deve ter prioridade maior ou igual à prioridade de seus filhos, se eles existirem. No que segue vamos assumir que queremos manter um conjunto de elementos tais que cada elemento x possui um atributo x. prioridade, que guarda o valor referente à prioridade do elemento x, e um atributo x. indice, que guarda o índice do vetor em que x está armazenado. Seja H um vetor que armazena n = H. tamanho elementos e tem espaço para armazenar H. capacidade elementos. Usamos o vetor H para armazenar os elementos de forma conceitual a uma árvore da seguinte maneira. O elemento na posição i tem filho esquerdo na posição 2i (se 2i n), filho direito na posição 2i + 1 (se 2i + 1 n) e pai na posição i/2 (se i > 1). Assim, dizemos que H é um heap se ele satisfaz a propriedade de heap dada na Definição 8.1, isto é, se para todo i com 2 i n, temos H[ i/2 ]. prioridade H[i]. prioridade, i.e., a prioridade do do pai é sempre maior ou igual à prioridade de seus filhos. Note que, ao percorrer o vetor H da esquerda para a direita, estamos acessando 86
95 A = ( }{{} 100, 19, 36, 17, 8, 25, 1, 2, 7, 5) }{{}}{{}}{{} nível 0 nível 1 nível 2 nível Figura 8.1: Exemplo de heap binário na forma de árvore binária e vetor. Os valores indicados são as prioridades dos elementos armazenados. Nesse caso, ter maior valor equivale a ter maior prioridade. todos os nós do nível l consecutivamente antes de acessar os nós do nível l + 1. Além disso, um elemento na posição i de H tem altura log(n/i) e está no nível log i. Veja a Figura 8.1. Perceba que a propriedade de heap garante que H[1] sempre armazena o elemento de maior prioridade do heap. Assim, a operação de busca pelo elemento de maior prioridade se dá em tempo Θ(1). Nas seções seguintes, discutiremos cada uma das outras quatro operações fornecidas pela estrutura (remoção, inserção, construção e alteração). Antes disso, precisamos definir dois procedimentos muito importantes que serão utilizados por todas elas. As quatro operações fornecidas por uma fila de prioridades podem perturbar a estrutura, de forma que precisamos ser capazes de restaurar a propriedade de heap se for necessário. Os procedimentos CorrigeHeapDescendo e CorrigeHeapSubindo, formalizados nos Algoritmos 18 e 19, respectivamente, e discutidos a seguir, têm como objetivo restaurar a propriedade de heap quando apenas um dos elementos está causando a falha da propriedade. O algoritmo CorrigeHeapDescendo recebe um vetor H e um índice i tal que as subárvores enraizadas em H[2i] e H[2i + 1] já são heaps. O objetivo dele é transformar a árvore enraizada em H[i] em heap. Veja que se H[i] não tem prioridade maior ou igual à de seus filhos, então basta trocá-lo com o filho que tem maior prioridade para restaurar localmente a propriedade. Potencialmente, o filho alterado pode ter causado falha na prioridade também. Por isso, fazemos trocas sucessivas entre pais e filhos até 87
96 Figura 8.2: Exemplo de execução da chamada CorrigeHeapDescendo(H, 2). que atingimos um vértice folha ou até que não tenhamos mais falha na propriedade. Durante essas trocas, os índices onde os elementos estão armazenados mudam, de forma que precisamos mantê-los atualizados também. A Figura 8.2 mostra um exemplo de execução desse algoritmo. O Teorema 8.2 mostra que o CorrigeHeapDescendo de fato consegue transformar a árvore enraizada em H[i] em um heap. Algoritmo 18: CorrigeHeapDescendo(H, i) 1 maior = i 2 se 2i H. tamanho e H[2i]. prioridade > H[maior]. prioridade então 3 maior = 2i 4 se 2i + 1 H. tamanho e H[2i + 1]. prioridade > H[maior]. prioridade então 5 maior = 2i se maior i então 7 troca H[i]. indice com H[maior]. indice 8 troca H[i] com H[maior] 9 CorrigeHeapDescendo(H, maior) Teorema 8.2: Corretude de CorrigeHeapDescendo O algoritmo CorrigeHeapDescendo recebe um vetor H e um índice i tal que as subárvores enraizadas em H[2i] e H[2i + 1] são heaps, e modifica H de modo que a árvore enraizada em H[i] é um heap. Demonstração. Seja h x a altura de um nó que está na posição x na heap (isto é, h x = log(n/x) ). Vamos provar o resultado por indução na altura h i do nó i. 88
97 Quando h i = 0, o nó deve ser uma folha, que por definição são heaps (de tamanho 1). O algoritmo não faz nada nesse caso, já que folhas não possuem filhos e, portanto, está correto. Suponha que o CorrigeHeapDescendo(H, k) corretamente transforma H[k] em heap se H[2k] e H[2k + 1] já forem heaps, para todo nó k tal que h k < h i. Precisamos agora mostrar que CorrigeHeapDescendo(H, i) funciona corretamente, i.e., a árvore com raiz H[i] é um heap. Considere uma execução de CorrigeHeapDescendo(H, i). Note que se H[i] tem prioridade maior ou igual a seus filhos, então os testes nas linhas 2, 4 e 6 serão falsos e o algoritmo não faz nada, o que é o esperado nesse caso, uma vez que as árvores com raiz em H[2i] e H[2i + 1] já são heaps. Assuma agora que H[i] tem prioridade menor do que a de algum dos seus filhos. Caso H[2i] seja filho de maior prioridade, o teste na linha 2 será verdadeiro e teremos maior = 2i. Como maior i, o algoritmo troca H[i] com H[maior] e executa CorrigeHeapDescendo(H, maior). Como qualquer filho de i tem altura menor do que a de i, h maior < h i e sabemos, pela hipótese de indução, que o algoritmo funciona corretamente, de onde concluímos que a árvore com raiz em H[2i] é heap. Como H[i] tem agora prioridade maior do que as prioridades de H[2i] e H[2i + 1] e a árvore em H[2i + 1] já era heap, concluímos que a árvore com raiz H[i] agora é um heap. A prova á análoga quando A[2i + 1] é o filho de maior prioridade de H[i]. Vamos analisar agora o tempo de execução de CorrigeHeapDescendo(H, i) em um heap com n elementos representado pelo vetor H. O ponto chave é perceber que, a cada chamada recursiva, CorrigeHeapDescendo acessa um elemento que está um nível acima na árvore, acessando apenas nós que fazem parte de um caminho que vai de i até uma folha. Assim, o algoritmo tem tempo proporcional à altura do nó i na árvore, isto é, O(log(n/i)). Como a altura de qualquer nó é no máximo a altura h da árvore, e em cada passo somente tempo constante é gasto, concluímos que o tempo de execução total é O(h). Como um heap pode ser visto como uma árvore binária quase completa, que tem altura O(log n) (veja Seção 5.2), o tempo de execução de CorrigeHeapDescendo é, portanto, O(log n). Vamos fazer uma análise mais detalhada do tempo de execução T (n) de CorrigeHeapDescendo sobre um vetor com n elementos. Note que a cada chamada recursiva o problema diminui consideravelmente de tamanho. Se estamos na iteração 89
98 correspondente a um elemento H[i], a próxima chamada recursiva será na subárvore cuja raiz é um filho de H[i]. 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 H[1] (i.e., enraizada em H[2]) e o último nível da árvore estar 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 CorrigeHeapDescendo 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. Sabemos que o tempo T (n) de CorrigeHeapDescendo é no máximo T (2n/3) + 1. Podemos então 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). O outro algoritmo importante para recuperação da propriedade de heap que mencionamos anteriormente é o CorrigeHeapSubindo. Ele recebe um vetor H e um índice i tal que o subvetor H[1..i 1] já é heap. O objetivo é fazer com que o subvetor H[1..i] seja heap também. Veja que se H[i] não tem prioridade menor ou igual à do seu pai, basta trocá-lo com seu pai para restaurar localmente a propriedade de 90
99 Figura 8.3: Exemplo de execução da chamada CorrigeHeapSubindo(H, 10). heap. Potencialmente, o pai pode ter causado falha na propriedade também. Por isso, fazemos trocas sucessivas entre filhos e pais até que atingimos a raiz ou até que não tenhamos mais falha na propriedade de heap. A Figura 8.3 mostra um exemplo de execução desse algoritmo. O Teorema 8.3 mostra que o CorrigeHeapSubindo de fato consegue transformar o subvetor H[1..i] em um heap. Algoritmo 19: CorrigeHeapSubindo(H, i) 1 pai = i/2 2 se i 2 e H[i]. prioridade > H[pai]. prioridade então 3 troca H[i]. indice com H[pai]. indice 4 troca H[i] com H[pai] 5 CorrigeHeapSubindo(H, pai) Teorema 8.3: Corretude de CorrigeHeapSubindo O algoritmo CorrigeHeapSubindo recebe um vetor H e um índice i tal que o subvetor H[1..i 1] é heap, e modifica H de modo que o subvetor H[1..i] é um heap. Demonstração. Seja l x o nível de um nó que está na posição x da heap (isto é, l x = log x ). Vamos provar o resultado por indução no nível l i do nó i. Quando l i = 0, o nó deve ser a raiz, H[1], que é um heap (de tamanho 1). O algoritmo não faz nada nesse caso, pois a raiz não tem pai, e, portanto, está correto. Suponha que o CorrigeHeapSubindo(H, k) corretamente transforma H[1..k] em heap se H[1..k 1] já for heap, para todo k tal que l k < l i. Considere então uma execução de CorrigeHeapSubindo(H, i). Note que se H[i] 91
100 tem prioridade menor ou igual à de que seu pai, então o teste na linha 2 falha e o algoritmo não faz nada, o que é o esperado, uma vez que H[1..i 1] já é heap. Assuma então que H[i] tem prioridade maior do que a de seu pai e seja p = i/2. O algoritmo então troca H[i] com H[p] e executa CorrigeHeapSubindo(H, p). Como o pai de i está em um nível menor do que o nível de i, l p < l i e sabemos, pela hipótese de indução, que o algoritmo funciona corretamente sobre p. Assim, concluímos que H[1..p] é heap. Como H[i] tem agora prioridade menor ou igual à prioridade de H[p], H[1..i 1] já era heap antes e os elementos de H[p + 1..i 1] não foram mexidos, concluímos que H[1..i] agora é heap. Para a análise do tempo de execução de CorrigeHeapSubindo(H, i), perceba que, a cada chamada recursiva, o algoritmo acessa um elemento que está um nível abaixo da árvore, acessando apenas nós que fazem parte de um caminho que vai de i até a raiz. Assim, o algoritmo tem tempo proporcional ao nível do nó i na árvore, isto é, O(log i). Como o nível de qualquer nó é no máximo a altura h da árvore, e em cada passo somente tempo constante é gasto, concluímos que o tempo de execução total é O(h), ou seja, O(log n) Construção de um heap binário Suponha que temos um vetor H já preenchido com n = H. tamanho elementos que não necessariamente é um heap (ele precisa satisfazer a propriedade de heap para isso), o objetivo do procedimento ConstroiHeap é transformar H em heap. Note que os últimos n/2 + 1 elementos de H são folhas e, portanto, são heaps de tamanho 1. O elemento H[ n/2 ], que é o primeiro elemento que tem filhos, pode não ser uma heap. No entanto, como seus filhos são, podemos utilizar o algoritmo CorrigeHeapDescendo para corrigir a situação. O mesmo vale para o elemento H[ n/2 1] e todos os outros elementos que são pais de folhas. Com isso teremos várias heaps de altura 2, de forma que podemos aplicar o CorrigeHeapDescendo aos elementos pais dessas também. O Algoritmo 20 formaliza essa ideia. A Figura 8.4 tem um exemplo de execução da rotina ConstroiHeap. 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. 92
101 Algoritmo 20: ConstroiHeap(H) 1 para i = 1 até H. tamanho faça 2 H[i]. indice = i 3 para i = H. tamanho /2 até 1 faça 4 CorrigeHeapDescendo(H, i) Invariante: ConstroiHeap Antes de cada iteração do laço para indexado por i, para todo j tal que i + 1 j n = H. tamanho, a árvore enraizada em H[j] é um heap. Teorema 8.5 O algoritmo ConstroiHeap transforma o vetor H em um heap. Demonstração. Inicialmente temos i = n/2, então precisamos verificar se, para todo j tal que n/2 + 1 j n, a árvore com raiz H[j] é um heap. Perceba que tal árvore é composta somente pelo elemento H[j], pois como j > n/2, o elemento H[j] é folha e não tem filhos. Assim, de fato a árvore com raiz em H[j] é um heap. Suponha agora que a invariante é válida imediatamente antes de uma certa iteração de índice i do laço para, i.e., para todo j tal que i + 1 j n, a árvore com raiz H[j] é um heap. Precisamos mostrar que a invariante é válida imediatamente antes da próxima iteração (onde teremos i 1). Se H[i] tem filhos, então esses são raízes de heaps devido à invariante ser válida imediatamente antes da iteração atual. Assim, a chamada a CorrigeHeapDescendo(H, i) na linha 4 funciona corretamente, transformando a árvore com raiz H[i] em um heap. Assim, para todo j tal que i j n, a árvore com raiz H[j] é um heap, e essa é justamente a invariante quando considerada imediatamente antes da próxima iteração. Portanto, a invariante se mantém válida 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 H[1] é um heap. No que segue seja T (n) o tempo de execução de ConstroiHeap em um vetor 93
102 H com n elementos. Uma simples análise permite concluir que T (n) = O(n log n): o laço para é executado n/2 vezes e, em cada uma dessas execuções, a rotina CorrigeHeapDescendo, que leva tempo O(log n) é executada. Logo, concluímos que T (n) = O(n log n). Uma análise mais cuidadosa, no entanto, 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. Como visto anteriormente, o tempo de execução do CorrigeHeapDescendo(H, i) é, na verdade, proporcional à altura do elemento i. Assim, para cada elemento de altura h, a chamada a CorrigeHeapDescendo correspondente executa em tempo O(h), de forma que cada uma dessas chamadas é executada em tempo no máximo Ch C(h + 1) para alguma constante C > 0. Portanto, o tempo de execução T (n) de ConstroiHeap é dado por: T (n) log n h=0 log n = Cn n 2 h+1 C(h + 1) h=0 log n +1 h + 1 = Cn 2h+1 i=1 i 2 Cn i i=1 i 2 i. Note que para todo i 1, vale que ( (i + 1)/2 i+1) /(i/2 i ) < 1. Assim, temos que T (n) Cn i=1 i 2 Cn i 2 1 i = Cn. i=1 Portanto, T (n) = O(n). 94
103 Figura 8.4: Execução do ConstroiHeap sobre o vetor H = [3, 1, 5, 8, 2, 4, 7, 6, 9]. 95
104 8.1.2 Remoção em um heap binário Sabendo que o elemento de maior prioridade em um heap H está em H[1], se quisermos removê-lo, precisamos fazer isso de modo que ao fim da operação H ainda seja um heap. Dado que H já é heap, podemos tentar remover H[1] sem mexer em muitos outros elementos, de forma que os algoritmos de correção possam ser facilmente utilizados, se necessário. A ideia do algoritmo RemoveDaHeap é trocar H[1] com H[H. tamanho], o que potencialmente destrói a propriedade de heap na posição 1. Como essa é a única posição que está causando problemas, aplicamos CorrigeHeapDescendo(H, 1) para restaurar a propriedade. O Algoritmo 21 formaliza essa ideia. Algoritmo 21: RemoveDaHeap(H) 1 x = null 2 se H. tamanho 1 então 3 x = H[1] 4 H[H. tamanho]. indice = 1 5 H[1] = H[H. tamanho] 6 H. tamanho = H. tamanho 1 7 CorrigeHeapDescendo(H, 1) 8 retorna x Note que CorrigeHeapDescendo(H, 1) é executado em tempo O(log n) para n = H. tamanho. Logo, é fácil perceber que o tempo de execução de RemoveDaHeap(H) é O(log n) também Inserção em um heap binário Para inserir um novo elemento x em uma heap H, primeiro verificamos se há capacidade em H para isso. Se sim, então inserimos x na primeira posição disponível, H[H. tamanho +1], o que potencialmente destruirá a propriedade de heap. No entanto, como H[1..H. tamanho] já era heap, podemos simplesmente fazer uma chamada a CorrigeHeapSubindo para restaurar a propriedade em H[1..H. tamanho +1]. O Algoritmo 22 formaliza essa ideia, do procedimento InsereNaHeap. Ele recebe um elemento x novo (que, portanto, tem atributos x. prioridade e x. indice). Como CorrigeHeapSubindo(H, H. tamanho) é executado em tempo O(log n), 96
105 Algoritmo 22: InsereNaHeap(H, x) 1 se H. tamanho H. capacidade então 2 H. tamanho = H. tamanho +1 3 x. indice = H. tamanho 4 H[H. tamanho] = x 5 CorrigeHeapSubindo(H, H. tamanho) com n = H. tamanho, é fácil perceber que o tempo de execução de InsereNaHeap é O(log n) Alteração em um heap binário Ao alterarmos a prioridade de um elemento armazenado em uma heap H, podemos estar destruindo a propriedade de heap. No entanto, como H já é heap, potencialmente fizemos isso em uma posição específica. Veja que se o elemento ficou com prioridade maior do que a de seu pai, então basta usar o algoritmo CorrigeHeapSubindo, e se ele ficou com prioridade maior do que a de algum filho, então basta usar o algoritmo CorrigeHeapDescendo. O Algoritmo 23 formaliza essa ideia, do procedimento AlteraHeap. Ele recebe a posição i do elemento que deve ter sua prioridade alterada para um novo valor k. Algoritmo 23: AlteraHeap(H, i, k) 1 aux = H[i]. prioridade 2 H[i]. prioridade = k 3 se aux < k então 4 CorrigeHeapSubindo(H, i) 5 se aux > k então 6 CorrigeHeapDescendo(H, i) Note que se sabemos que x é o elemento do conjunto de elementos armazenados em H que queremos alterar, então sua posição em H é facilmente recuperada fazendo-se x. indice, uma vez que a estrutura heap não suporta busca de maneira eficiente. A operação mais custosa do algoritmo AlteraHeap é uma chamada a CorrigeHeapSubindo ou a CorrigeHeapDescendo, é fácil ver que o tempo de execução 97
106 dele é O(log n). 98
107 Capítulo 9 Disjoint Set Um disjoint set é um tipo abstrato de dados que serve para manter uma coleção de elementos particionados em grupos disjuntos. Formalmente, dizemos que A 1, A 2,..., A m é uma partição de um conjunto B se para cada A i temos que A i B, A i A j = para todo i j e A 1 A m = B. Um disjoint set fornece as operações de criação de um novo conjunto, união de dois conjuntos existentes e busca pelo conjunto que contém um determinado elemento. Uma forma possível de implementar um disjoint set é usando uma árvore para representar cada conjunto. Cada nó dessa árvore é um elemento do conjunto e pode-se usar a raiz da árvore como representante do conjunto. Assim, a criação de um novo conjunto pode ser feita gerando-se uma árvore com apenas um nó, a união pode ser feita fazendo a raiz de uma árvore apontar para a raiz da outra, e a busca pelo conjunto que contém um elemento pode ser feita percorrendo o caminho do elemento até a raiz. Perceba que as duas primeiras operações são eficientes, podendo ser realizadas em tempo constante, mas a operação de busca pode potencialmente levar tempo O(n) se a sequência de operações de união que construiu uma árvore criar uma estrutura linear com n nós. É possível, no entanto, implementar um disjoint set garantindo tempo médio O(α(n)) por operação, onde α(n) é a inversa da função Ackermann que, para todos os valores práticos de n, é no máximo 5.
108 9.1 Union-Find A estrutura de dados conhecida como union-find mantém uma partição de um conjunto de elementos e permite as seguintes operações: MakeSet(x): cria um conjunto novo contendo somente o elemento x; FindSet(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. A seguir vamos descrever uma possível implementação da estrutura. Ela considera que cada conjunto tem um representante, que é um membro do conjunto e que irá identificar o conjunto. Dado um conjunto A, consideramos que os elementos x A possuem atributos x. representante, que armazena o representante do grupo onde x está, e x. tamanho, que armazena o tamanho do grupo onde x está. Precisaremos ainda de um vetor L de listas encadeadas tal que L[x] é uma lista encadeada que armazena todos os elementos que estão no conjunto representado por x A. O atributo L[x]. cabeca aponta para o primeiro nó da lista e o atributo L[x]. cauda aponta para o último. Note que a operação MakeSet(x) pode ser facilmente implementada em tempo constante, como mostra o Algoritmo 24. Algoritmo 24: MakeSet(x) 1 x. representante = x 2 x. tamanho = 1 3 L[x]. cabeca = x 4 L[x]. cauda = x A operação FindSet(x) também pode ser implementada em tempo constante, conforme mostra o Algoritmo 25. Algoritmo 25: FindSet(x) 1 retorna x. representante 100
109 Quando a operação de união de dois conjuntos é requerida, fazemos com que o conjunto de menor tamanho passe a ter o mesmo representante que o conjunto de maior tamanho. Para isso, acessamos os elementos do conjunto de menor tamanho e atualizamos seus atributos. Veja o Algoritmo 26. Algoritmo 26: Union(x, y) 1 X = FindSet(x) 2 Y = FindSet(y) 3 se X. tamanho < Y. tamanho então 4 para todo v em L[X] faça 5 v. representante = Y 6 v. tamanho = X. tamanho +Y. tamanho 7 L[Y ]. cauda. proximo = L[X]. cabeca 8 L[X]. cabeca = null 9 senão 10 para todo v em L[Y ] faça 11 v. representante = X 12 v. tamanho = X. tamanho +Y. tamanho 13 L[X]. cauda. proximo = L[Y ]. cabeca 14 L[Y ]. cabeca = null Perceba que graças à manutenção das listas ligadas em L, acessamos apenas os elementos do menor dos conjunto para atualizar seus atributos no laço para. Todas as operações levam tempo constante para serem executadas. Assim, é fácil perceber que o tempo de execução de uma única chamada a Union(x, y) é Θ(t), onde t = min{x. representante. tamanho, y. representante. tamanho}). 101
110 102
111 Capítulo 10 Tabelas hash Suponha que queremos projetar um sistema que armazena dados de funcionários usando como chave seus CPFs. Basicamente, esse sistema vai precisar fazer inserções, remoções e buscas (todas dependentes do CPF dos funcionários). Note que podemos usar um vetor ou lista ligada para isso, porém neste caso a busca é feita em tempo linear, o que pode ser custoso na prática se o número n de funcionários armazenados for muito grande. Se usarmos um vetor ordenado, a busca pode ser melhorada para ter tempo O(log n), mas inserções e remoções passam a ser custosas. Uma outra opção é usar uma árvore binária de busca balanceada, que garante tempo O(log n) em qualquer uma das três operações. Uma terceira solução é criar um vetor grande o suficiente para que ele seja indexado pelos CPFs. Essa estratégia, chamada endereçamento direto, é ótima pois garante que as três operações serão executadas em tempo Θ(1). Acontece que um CPF tem 11 dígitos, sendo 9 válidos e 2 de verificação, de forma que podemos ter 9 10 possíveis números diferentes (algo na casa dos bilhões). Logo, endereçamento direto não é viável. Por outro lado, a empresa precisa armazenar a informação de n funcionários apenas, o que é um valor bem menor. Temos ainda uma quarta opção: tabelas hash. Uma tabela hash é uma estrutura de dados que basicamente mapeia chaves a elementos. Ela implementa eficientemente em tempo médio O(1) as operações de busca, inserção e remoção. Ela usa uma função hash, que recebe como entrada uma chave (um CPF, no exemplo acima) e devolve um número pequeno (entre 1 e m), que serve como índice da tabela que vai armazenar os elementos de fato (que tem tamanho
112 m). Assim, se h é uma função hash, um elemento de chave k vai ser armazenado (falando de forma bem geral) na posição h(k). Note, no entanto, que sendo o universo U de chaves grande (tamanho M) e o tamanho m da tabela bem menor do que M, não importa como seja a função h: várias chaves serão mapeadas para a mesma posição o que é chamado de colisão. Aliás, vale mencionar que mesmo se o contrário fosse verdade ainda teríamos colisões: por exemplo, se 2450 chaves forem mapeadas pela função hash para uma tabela de tamanho 1 milhão, mesmo com uma distribuição aleatória perfeitamente uniforme, de acordo com o Paradoxo do Aniversário, existe uma chance de aproximadamente 95% de que pelo menos duas chaves serão mapeadas para a mesma posição. Temos então que lidar com dois problemas quando se fala em tabelas hash: (i) escolher uma função hash que minimize o número de colisões, e (ii) lidar com as colisões, que são inevitáveis. Se bem implementada e considerando que os dados não são problemáticos, as operações de busca, inserção e remoção podem ser feitas em tempo O(1) no caso médio. 104
113 Parte III Algoritmos de ordenação enquanto emordem(vetor) == false: embaralha(vetor) Algoritmo Bogosort
114
115 Nesta parte O problema da ordenação é um dos mais básicos e mais estudados em computação. Ele consiste em, dada uma lista de elementos, ordená-los de acordo com alguma ordem pré-estabelecida. Algoritmos que resolvem o problema de ordenação são simples e fornecem uma base para várias ideias de projeto de algoritmos. Além disso, vários outros problemas se tornam mais simples de tratar quando os dados estão ordenados. Existem inúmeros algoritmos de ordenação. Veremos os mais clássicos nas seções a seguir, considerando a seguinte definição do problema. Problema 10.1: Ordenação Dado um vetor A = (a 1, a 2,..., a n ) com com n números, obter uma permutação (a 1, a 2,..., a n) desses números de modo que a 1 a 2... a n. Note que estamos considerando um vetor que contém números, mas poderíamos facilmente supor que o vetor contém registros e assumir que existe um campo de tipo comparável em cada registro (que forneça uma noção de ordem, por exemplo numérica ou lexicográfica). Dentre características importantes de algoritmos de ordenação, podemos destacar duas. Um algoritmo é dito in-place se utiliza somente espaço constante além dos dados de entrada e é 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 nas seções a seguir. Perceba que em um vetor ordenado, todos os elementos à esquerda de um certo elemento são menores ou iguais a ele e todos os elementos à direita são maiores ou iguais a ele. Esse
116 argumento simples será usado muito nas discussões de corretude dos algoritmos que veremos. 108
117 Capítulo 11 Ordenação por inserção Algoritmos de ordenação por inserção consideram um elemento por vez e os inserem na posição correta de ordenação relativa aos elementos que já foram considerados. Neste capítulo veremos dois desses algoritmos, o Insertion sort e o Shellsort Insertion sort Dado um vetor A[1..n] com n números, a ideia do 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 subvetor A[1..i] está ordenado. Sabendo que o subvetor A[1..i] está ordenado, é fácil encaixar o elemento A[i + 1] na posição correta para deixar o subvetor A[1..i + 1] ordenado: compare A[i + 1] com A[i], A[i 1], e assim por diante, até encontrar um índice j tal que A[j] < A[i + 1], caso em que a posição correta de A[i + 1] é j, ou até descobrir que A[1] > A[i + 1], caso em que a posição correta de A[i + 1] é 1. Veja no Algoritmo 27 um pseudocódigo desse algoritmo, o InsertionSort. Não é difícil perceber que o InsertionSort é um algoritmo in-place e estável. A Figura 11.1 mostra uma execução do algoritmo.
118 Algoritmo 27: InsertionSort(A, n) 1 para i = 2 até 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 Figura 11.1: Execução do InsertionSort no vetor A = (2, 5, 1, 4, 3). 110
119 Corretude Vamos mostrar que o InsertionSort funciona corretamente, isto é, que para qualquer vetor A com n elementos dado na entrada, ele ordena os elementos de A de forma não-decrescente utilizando uma invariante de laço (veja a Seção para relembrar esse conceito). Invariante: InsertionSort Antes de cada iteração do laço para indexado por i, o subvetor A[1..i 1] contém os elementos contidos originalmente em A[1..i 1] em ordem não-decrescente. Observe que o item (i) da definição de invariante de laço é válido antes da primeira iteração, quando i = 2, pois o vetor A[1..i 1] = A[1] contém somente um elemento e, portanto, sempre está ordenado. Para verificar o item (ii), suponha que a invariante vale antes de uma certa iteração (fixe um valor de i qualquer entre 2 e n), isto é, que o vetor A[1..i 1] contém os elementos originais em ordem não-decrescente. Note que o laço enquanto move o elemento A[i] para a esquerda para uma posição onde todos os elementos à sua direita (até a posição i) são maiores do que ele e os elementos à sua esquerda são menores. Com isso, subvetor A[1..i] fica ordenado e contém os elementos originalmente naquelas posições, ou seja, a invariante se mantém verdadeira antes da próxima iteração (quando temos i + 1). 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 com todos os elementos originais, de onde concluímos que o algoritmo está correto Análise do tempo de execução Para calcular o tempo de execução de InsertionSort, 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 para cada valor de i 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. Assim, o tempo de execução T (n) de InsertionSort é 111
120 dado por n n T (n) = n + 3(n 1) + r i + 2 (r i 1) + 1 = 4n = 2n + 3 i=2 n r i 2 n 1 i=2 i=2 n r i. i=2 i=2 Note que para de fato sabermos a eficiência do algoritmo InsertionSort, precisamos saber o valor de cada r i, mas para isso é preciso assumir algo sobre a composição do vetor de entrada. Primeiro perceba que as linhas 2, 3 e 7 sempre serão executadas n 1 vezes, pois o laço para sempre será totalmente executado e não há condição que impeça a execução dessas linhas. Assim, o melhor caso de execução do InsertionSort ocorre quando o teste do laço enquanto é feito e falha já na primeira vez, fazendo assim com que o tempo de execução do algoritmo seja o menor possível. Isso ocorre apenas quando a sequência de entrada já está ordenada de modo não-decrescente. Assim, temos que r i = 1 para 2 i n e T (n) = 2n + 3 = 5n 3 n i=2 r i = Θ(n). (11.1) Por outro lado, o pior caso do InsertionSort ocorre quando todas as linhas são executadas o máximo de vezes possível. Veja que isso acontece quando o vetor está ordenado de modo decrescente, pois o laço enquanto será executado i vezes para cada 112
121 valor i do laço para, de modo que, nesse caso, r i = i. Assim, temos T (n) = 2n + 3 = n 2 + 2n 6 = Θ(n 2 ). (11.2) Podemos concluir, portanto, que assintoticamente o tempo de execução do pior caso do InsertionSort é menos eficiente do que o tempo no melhor caso. Outra conclusão que podemos assumir das análises acima é que o tempo do InsertionSort é Ω(n) e O(n 2 ) 1. Como vimos na Seção 1.2.1, o tempo de execução no caso médio de um algoritmo é a média do tempo de execução dentre todas as entradas possíveis. No caso do InsertionSort, 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 do que A[i], de modo que durante a execução do laço para em i, 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 InsertionSort 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. Vemos então que o tempo de execução no caso médio é quase tão ruim quanto o do pior caso. n i=2 r i 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 InsertionSort de forma mais rápida, focando apenas nos pontos que realmente importam. 1 Perceba como não podemos dizer que o tempo do InsertionSort é Θ(n 2 ) mas podemos dizer que o tempo dele no pior caso é. 113
122 Quando vistas de forma separada, todas as instruções de todas as linhas do InsertionSort 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 é claramente executado n 1 vezes, independente da entrada, 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, 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 uma 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 ) Shellsort O Shellsort é uma variação do Insertion sort que faz comparação de elementos mais distantes e não apenas vizinhos. A seguinte definição é muito importante para definirmos o funcionamento desse 114
123 algoritmo. Dizemos que um vetor está h-ordenado se, a partir de qualquer posição, considerar todo elemento a cada h posições leva a uma sequência ordenada. exemplo, o vetor A = (1, 3, 5, 8, 4, 15, 20, 7, 9, 6) está 5-ordenado, pois as sequências de elementos (1, 15), (3, 20), (5, 7), (8, 9) e (4, 6) estão ordenadas. Já o vetor A = (1, 3, 5, 6, 4, 9, 8, 7, 15, 20) está 3-ordenado, pois (1, 6, 8, 20), (3, 4, 7), (5, 9, 15), (6, 8, 20), (4, 7), (9, 15) e (8, 20) são sequências ordenadas de elementos que estão à distância 3 entre si. Note que um vetor 1-ordenado está totalmente ordenado. A ideia do Shellsort é iterativamente h-ordenar o vetor de entrada com uma sequência de valores de h que termina em 1. Ele usa o fato de que é fácil h -ordenar um vetor que já está h-ordenado, para h < h. Esse algoritmo se comporta exatamente como o Insertion sort quando h = 1. O procedimento Shellsort é formalizado no Algoritmo 28. Ele recebe o vetor A com n números a serem ordenados e um vetor H com m inteiros. Ele assume que H mantém uma sequência decrescente de inteiros menores do que n tal que H[m] = 1. Algoritmo 28: Shellsort(A, n, H, m) 1 para t = 1 até m faça 2 para i = H[t] + 1 até n faça 3 aux = A[i] 4 j = i 1 5 enquanto j H[t] e A[j H[t] + 1] > aux faça 6 A[j + 1] = A[j H[t] + 1] 7 j = j H[t] 8 A[j + 1] = aux Por Note que o tempo de execução do Shellsort depende drasticamente dos valores em H. Uma questão em aberto ainda hoje é determinar sua complexidade de tempo. Knuth por exemplo propôs a sequência 1, 4, 13, 40, 121, 246,... e ela dá bons resultados na prática e faz O(n 3/2 ) comparações. Uma sequência do tipo 1, 2, 4, 8, 16,... dá resultados muito ruins, já que elementos em posições ímpares não são comparados com elementos em posições pares até a última iteração. 115
124 116
125 Capítulo 12 Ordenação por intercalação O algoritmo que veremos nesse capítulo usa a ideia de ordenação por intercalação e faz uso do paradigma de divisão e conquista. Dado um vetor A com n números, esse algoritmo divide A em duas partes de tamanho n/2 e n/2, ordena as duas partes recursivamente e depois intercala o conteúdo as duas partes ordenadas em uma única parte ordenada. Esse algoritmo foi inventado por Jon von Neumann em O procedimento, MergeSort, é dado no Algoritmo 29, onde Combina é um procedimento para combinar duas partes ordenadas em uma só parte ordenada e será visto com mais detalhes adiante. Como o procedimento recursivamente acessa partes do vetor, ele recebe A e duas posições inicio e fim, e seu objetivo é ordenar o subvetor A[inicio..f im]. Assim, para ordenar um vetor A inteiro de n posições, basta executar MergeSort(A, 1, n). Algoritmo 29: MergeSort(A, inicio, f im) 1 se inicio < fim então 2 meio = (inicio + fim)/2 3 MergeSort(A, inicio, meio) 4 MergeSort(A, meio + 1, fim) 5 Combina(A, inicio, meio, f im) Na Figura 12.1 ilustramos uma execução do algoritmo MergeSort. Note que a metade superior da figura corresponde às chamadas recursivas das linhas 3 e 4. A metade inferior da figura corresponde às chamadas recursivas ao procedimento
126 Figura 12.1: Execução de MergeSort(A, 1, 8) para A = (7, 3, 1, 10, 2, 8, 15, 6). Combina (linha 5). Veja que a execução do MergeSort é realmente simples. A operação chave aqui é realizada pelo Combina. Esse algoritmo recebe o vetor A e as posições inicio, meio, f im, e considera que A[inicio..meio] e A[meio + 1..f im] estão ordenados. Seu objetivo é deixar A[inicio..f im] ordenado com os mesmos elementos. Como o conteúdo a ser deixado em A[inicio..fim] já está armazenado nesse mesmo subvetor, esse procedimento faz uso de dois vetores auxiliares B e C, que irão manter uma cópia de A[inicio..meio] e A[meio + 1..f im], respectivamente. O fato dos dois vetores B e C já estarem ordenados nos dá algumas garantias. Veja que o menor de todos os elementos que estão em B e C, que será colocado em A[inicio], só pode ser B[1] ou C[1], o que for menor dentre os dois. Se B[1] < C[1], então o elemento a ser colocado em A[inicio + 1] só pode ser B[2] ou C[1], o que for menor dentre esses dois. Mas se C[1] < B[1], então o elemento que vai para A[inicio + 1] só pode ser B[1] ou C[2], o que for menor dentre esses. E, a garantia mais importante é que uma vez que um elemento B[i] ou C[j] é copiado para sua posição final em A, esse elemento não precisa mais ser considerado. É possível, portanto, realizar todo esse procedimento fazendo uma única passagem por cada elemento de B e C. Pela discussão acima, vemos que precisamos manter um índice i para acessar elementos a serem copiadas de B, um índice j para acessar elementos em C e um índice k para acessar o vetor A. A cada iteração, precisamos colocar um elemento em A[k], que será o menor dentre B[i] e C[j]. Se B[i] (resp. C[j]) for copiado, incrementamos i 118
127 (resp. j) para que esse elemento não seja considerado novamente. Veja o procedimento Combina formalizado no Algoritmo 30. Algoritmo 30: Combina(A, inicio, meio, f im) 1 n 1 = meio inicio n 2 = fim meio 3 Crie vetores auxiliares B[1..n 1 ] e C[1..n 2 ] 4 para i = 1 até n 1 faça 5 B[i] = A[inicio + i 1] 6 para j = 1 até n 2 faça 7 C[j] = A[meio + j] 8 i = 1 9 j = 1 10 j = inicio 11 enquanto i < n 1 e j < n 2 faça 12 se B[i] C[j] então 13 A[k] = B[i] 14 i = i senão 16 A[k] = C[j] 17 j = j k = k enquanto i < n 1 faça 20 A[k] = B[i] 21 i = i k = k enquanto j < n 2 faça 24 A[k] = C[j] 25 j = j k = k + 1 Note que como o procedimento Combina usar vetores auxiliares, o MergeSort não é um algoritmo in-place. Na Figura 12.2 temos uma simulação da execução do Combina. Considere uma execução de Combina ao receber um vetor A e parâmetros inicio, meio e fim como entrada. Note que além das linhas que são executadas em tempo constante, o laço para na linha 4 é executado meio inicio + 1 vezes, o laço para 119
128 Figura 12.2: Execução de Combina(A, p, q, r) sobre o vetor A = (1, 3, 7, 10, 2, 6, 8, 15, 28, 19, 2) com parâmetros p = 1, q = 4 e r = 8. na linha 6 é executado fim meio vezes, e os laços enquanto das linhas 11, 19 e 23 são executados ao todo fim inicio + 1 vezes (podemos notar isso pela quantidade de valores diferentes que k assume). Se R(n) é o tempo de execução de Combina(A, inicio, meio, fim) onde n = fim inicio + 1, então claramente temos R(n) = Θ(n). Vamos agora analisar o tempo de execução do algoritmo MergeSort quando ele é utilizado para ordenar um vetor com n elementos. Vimos que o tempo para combinar as soluções recursivas é Θ(n). Como os vetores em questão são sempre divididos ao meio no algoritmo MergeSort, seu tempo de execução T (n) é dado por T (n) = T ( n/2 ) + T ( n/2 ) + Θ(n). Como estamos preocupados em fazer uma análise assintótica, podemos substituir Θ(n) por n apenas, pois isso não fará diferença no resultado obtido. Também podemos desconsiderar pisos e tetos, como visto na Seção 3.2.1, de forma que o tempo do MergeSort pode ser descrito por T (n) = 2T (n/2) + n, para n > 1, e T (n) = 1 para n = 1. Assim, como visto no Capítulo 3, o tempo de execução de MergeSort é T (n) = Θ(n log n). 120
129 Capítulo 13 Ordenação por seleção Neste capítulo vamos introduzir dois algoritmos para o problema de ordenação que utilizam a ideia de ordenação por seleção. Em ambos, consideramos uma posição i do vetor por vez, selecionamos o i-ésimo menor elemento do vetor e o colocamos em i, posição final desse elemento no vetor ordenado Selection sort O Selection sort é um algoritmo que sempre mantém o vetor de entrada A[1..n] dividido em dois subvetores contíguos separados por uma posição i, um à direita e outro à esquerda, estando um deles ordenado. Aqui consideraremos uma implementação onde o subvetor da esquerda, A[1..i], contém os menores elementos da entrada ainda não ordenados e o subvetor da direita, A[i + 1..n], contém os maiores elementos da entrada já ordenados. A cada iteração, o maior elemento x do subvetor A[1..i] é encontrado e colocado na posição i, de forma que o subvetor da direita é aumentado em uma unidade 1. O Algoritmo 31 descreve o procedimento SelectionSort e possui uma estrutura muito simples, contendo dois laços para aninhados. O primeiro laço, indexado por i, é executado n 1 vezes e, em cada iteração, aumenta o subvetor da direita que já 1 Não é difícil adaptar toda a discussão que faremos considerando que o subvetor A[1..i 1] da esquerda contém os menores elementos ordenados e o da direita contém os elementos não ordenados. Com isso, a cada iteração, o menor elemento do subvetor A[i..n] deve ser encontrado e colocado na posição i.
130 estava ordenado em uma unidade. Ademais, esse subvetor da direita sempre contém os maiores elementos de A. Para manter essa propriedade, a cada passo, o maior elemento que não está nesse subvetor já ordenado é adicionado ao início dele. Algoritmo 31: SelectionSort(A, n) 1 para i = n até 2 faça 2 indicemax = i 3 para j = 1 até i 1 faça 4 se A[j] > A[indiceMax] então 5 indicemax = j 6 troca A[indiceM ax] com A[i] 7 retorna A 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 SelectionSort(A, n) é Θ(n 2 ). Na Figura 13.1 temos um exemplo de execução do algoritmo SelectionSort. No que segue vamos utilizar a seguinte invariante de laço para mostrar que o algoritmo SelectionSort funciona corretamente, isto é, para qualquer vetor A e n dados na entrada, ele corretamente deixa os n elementos de A em ordem não-decrescente. Invariante: SelectionSort 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 13.2 O algoritmo SelectionSort ordena qualquer vetor A com n elementos de modo não-decrescente. Demonstração. Como inicialmente i = n, a invariante é trivialmente satisfeita pois trata-se de um vetor sem elementos. 122
131 Figura 13.1: Execução de SelectionSort(A, 5) no vetor A = (2, 5, 1, 4, 3). 123
132 Fixe agora um valor de i entre 2 e n e que a invariante é válida imediatamente antes da iteração correspondente a i 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 próxima iteração, quando teremos i 1, o subvetor A[i..n] estará ordenado de modo não-decrescente e conterá os maiores elementos de A. Note que na iteração correspondente a i, o segundo laço para (da linha 3) encontra o índice indicem ax do maior elemento do vetor A[1..i] (isso pode ser formalmente provado por outra invariante de laço!). Na linha 6, o maior elemento de A[1..i] é trocado de lugar com o elemento A[i]. Como, pela invariante, todos os elementos de A[i + 1..n] são maiores do que A[i], temos que A[i..n] está ordenado e contém os maiores elementos de A, valendo assim a invariante antes da próxima iteração. Por fim, note que na última vez que a linha é executada, temos i = 1. Assim, pela invariante de laço, o vetor A[2..n] está ordenado com os maiores elementos de A. Logo, concluímos que o vetor A[1..n] está ordenado Heapsort O Heapsort, assim como o Selection sort, é um algoritmo que sempre mantém o vetor de entrada A[1..n] dividido em dois subvetores contíguos separados por uma posição i, onde o subvetor da esquerda, A[1..i], contém os menores elementos da entrada ainda não ordenados e o subvetor da direita, A[i + 1..n], contém os maiores elementos da entrada já ordenados. A diferença está no fato do Heapsort utilizar a estrutura de dados heap binário (ou, simplesmente, heap) para repetidamente encontrar o maior elemento de A[1..i] e colocá-lo na posição i (o Selection sort faz essa busca percorrendo todo o vetor A[1..i]). Com isso, seu tempo de execução de pior caso é Θ(n log n), como o Merge sort. Dessa forma, o Heapsort pode ser visto como uma versão mais eficiente do Selection sort. O Heapsort é um algoritmo in-place, apesar de não ser estável. Com relação à estrutura heap, o Heapsort faz uso especificamente apenas dos procedimentos CorrigeHeapDescendo e ConstroiHeap, definidos na Seção 8.1. Consideraremos aqui que os valores armazenados no vetor A de entrada diretamente indicam as suas prioridades. Por comodidade, reproduzimos esses dois procedimentos nos Algoritmos 32 e 33, adaptados com essa consideração das prioridades. Note que se um vetor A com n elementos é um heap, então A[1] contém o maior 124
133 Algoritmo 32: CorrigeHeapDescendo(H, i) 1 maior = i 2 se 2i H. tamanho e H[2i] > H[maior] então 3 maior = 2i 4 se 2i + 1 H. tamanho e H[2i + 1] > H[maior] então 5 maior = 2i se maior i então 7 troca H[i] com H[maior] 8 CorrigeHeapDescendo(H, maior) Algoritmo 33: ConstroiHeap(H) 1 para i = H. tamanho /2 até 1 faça 2 CorrigeHeapDescendo(H, i) elemento de A[1..n]. O primeiro passo do Heapsort é trocar A[1] com A[n], colocando assim o maior elemento em sua posição final após a ordenação. Como A era heap, potencialmente perdemos a propriedade em A[1..n 1] ao fazer essa troca, porém devido a uma única posição. Assim, basta restaurar a propriedade de heap em A[1..n 1] a partir da posição 1 para que A[1..n 1] volte a ser heap. Agora, de forma equivalente, A[1] contém o maior elemento de A[1..n 1] e, portanto, podemos repetir o mesmo procedimento acima. Descrevemos formalmente o procedimento Heapsort no Algoritmo 34. Lembre-se que A. tamanho é a quantidade de elementos armazenados em A, isto é, n. Algoritmo 34: Heapsort(A, n) 1 ConstroiHeap(A) 2 para i = n até 2 faça 3 troca A[1] com A[i] 4 A. tamanho = A. tamanho 1 5 CorrigeHeapDescendo(A, 1) Na Figura 13.2 temos um exemplo de execução do algoritmo Heapsort. Uma vez que já provamos a corretude de ConstroiHeap e CorrigeHeapDescendo, a prova de corretude do algoritmo Heapsort é bem simples. Utilizaremos a 125
134 Figura 13.2: Execução de Heapsort(A, 6), com A = (4, 7, 3, 8, 1, 9). Note que a primeira árvore da figura é o heap obtido por ConstroiHeap(A). 126
135 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. tamanho = i e o vetor A[1..A. tamanho] é um heap. Teorema 13.2 O algoritmo Heapsort ordena qualquer 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 de uma iteração indexada por i do laço, i.e., o subvetor A[i+1..n] está ordenado de modo não-decrescente e contém os maiores elementos de A, e A. tamanho = i onde A[1..A. tamanho] é um heap. Precisamos mostrar que a invariante é válida antes da próxima iteração, onde teremos i 1. Note que a iteração correspondente a i começa com o algoritmo trocando A[1] com A[i], colocando portanto o maior elemento de A[1..A. tamanho] em A[i]. Em seguida, diminui-se o valor de A. tamanho em uma unidade, fazendo com que A. tamanho = i 1. Por fim, chama-se CorrigeHeapDescendo(A, 1), transformando A[1..i 1] em heap, pois o único elemento de A[1..A. tamanho] que pode não satisfazer a propriedade de heap é A[1] e sabemos que CorrigeHeapDescendo(A, 1) funciona corretamente. Como o maior elemento de A[1..i] 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 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 agora. Assim, mostramos que a invariante é válida antes da próxima iteraçã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. 127
136 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, ConstroiHeap é feito em tempo O(n). Como são realizadas n 1 execuções do laço para, e CorrigeHeapDescendo é executado em tempo O(log n), temos que o tempo total gasto por Heapsort é O(n log n). 128
137 Capítulo 14 Ordenação por troca Os algoritmos que veremos nesse capítulo funcionam realizando sucessivas trocas de vários elementos até que algum seja colocado em sua posição correta final (relativa ao vetor completamente ordenado) Bubble sort Em breve Quicksort O Quicksort tem tempo de execução de pior caso Θ(n 2 ), o que é bem pior que o tempo O(n log n) gasto pelo Heapsort ou pelo Mergesort. No entanto, o Quicksort costuma oferece a melhor escolha na prática. De fato, seu tempo de execução esperado é Θ(n log n) e a constante escondida em Θ(n log n) é bem pequena. Esse algoritmo também faz uso do paradigma de divisão e conquista, assim como o Mergesort. Seja A[1..n] um vetor com n elementos. Dizemos que A está particionado com relação a um elemento, chamado pivô, se os elementos que são menores do que o pivô estão à esquerda dele, os outros elementos (maiores ou iguais) estão à direita dele. Perceba que o pivô está em sua posição correta final (com relação ao vetor ordenado). A ideia do Quicksort é particionar o vetor e recursivamente ordenar as duas partes, não sendo mais necessário considerar o elemento pivô.
138 Formalmente, o algoritmo escolhe um elemento pivô qualquer (discutiremos adiante formas de escolha do pivô). Feito isso, ele particiona o vetor A com relação ao pivô. Suponha que após a partição o pivô termine na posição x. Assim, todos os elementos em A[1..x 1] são menores ou iguais ao pivô e todos os elementos em A[x + 1..n] são maiores ou iguais ao pivô. O próximo passo é ordenar recursivamente os vetores A[1..x 1] e A[x + 1..n], que efetivamente são menores do que o vetor original, pois removemos ao menos um elemento, o A[x]. O procedimento, Quicksort, é formalizado no Algoritmo 35, onde Particiona é um procedimento que particiona o vetor com relação a um pivô e será visto com mais detalhes adiante e Particiona é um procedimento que faz a escolha de um elemento como pivô. Como Quicksort recursivamente acessa partes do vetor, ele recebe A e duas posições inicio e fim, e seu objetivo é ordenar o subvetor A[inicio..fim]. Assim, para ordenar um vetor A inteiro com n elementos, basta executar Quicksort(A, 1, n). Algoritmo 35: Quicksort(A, inicio, f im) 1 se inicio < fim então 2 p = Particiona(A, inicio, f im) 3 troque A[p] com A[fim] 4 x = Particiona(A, inicio, f im) 5 Quicksort(A, inicio, x 1) 6 Quicksort(A, x + 1, fim) Na Figura 14.1 temos um exemplo de execução do procedimento Quicksort. O procedimento Particiona recebe o vetor A e as posições inicio e fim, e considera que o pivô é A[fim]. Seu objetivo é particionar A[inicio..fim] com relação ao pivô. Ele retorna a posição final do pivô após a partição. A ideia do Particiona é fazer uma única varredura no vetor e, a cada elemento acessado, decidir para que parte do vetor ele deverá ser colocado, baseado no fato do elemento ser maior ou menor do que o pivô. Precisamos, portanto, manter um índice j que irá indicar uma separação do vetor em duas partes: A[inicio..j 1] contém elementos que já foram acessados e A[j..f im 1] contém elementos que serão acessados. Também iremos manter um índice i que divida os elementos já acessados em duas partes: A[inicio..i 1] contém elementos menores ou iguais ao pivô e A[i..j 1] contém 130
139 Figura 14.1: Execução de Quicksort(A, 1, 10), onde A = (3, 9, 1, 2, 7, 4, 8, 5, 0, 6). 131
140 elementos maiores do que o pivô. Para realmente realizar uma única varredura no vetor, precisamos garantir que a cada passo o valor de j aumente. Se A[j] é menor ou igual ao pivô, então ele deve ser colocado próximo aos elementos de A[inicio..i 1]. Se A[j] é maior do que o pivô, então ele já estão próximo aos elementos maiores, que estão em A[i..j 1]. O Particiona é formalizado no Algoritmo 36. Algoritmo 36: Particiona(A, inicio, f im) 1 pivo = A[fim] 2 i = inicio 3 para j = inicio até fim 1 faça 4 se A[j] pivo então 5 troca A[i] e A[j] 6 i = i troca A[i] e A[fim] 8 retorna i Na Figura 14.2 temos um exemplo de execução do procedimento Particiona. Vamos começar analisando o algoritmo Particiona, é um algoritmo iterativo simples. Com relação ao tempo, claramente o laço para é executado f im inicio vezes, de forma que o tempo de execução de Particiona é Θ(fim inicio), isto é, leva tempo Θ(n) se n elementos são dados na entrada. Com relação à corretude, podemos utilizar a seguinte invariante de laço. Invariante: Particiona Antes de cada iteração do laço para indexado por j, temos pivo = A[fim] e vale que (i) os elementos de A[inicio..i 1] são menores ou iguais a pivo; (ii) os elementos de A[i..j 1] são maiores do que pivo. Teorema
141 Figura 14.2: Execução de Particiona(A, 1, 7), onde A = (3, 8, 6, 1, 5, 2, 4). 133
142 O algoritmo Particiona retorna um índice x tal que o pivô está na posição x, todo elemento em A[1..x 1] é menor ou igual ao pivô, e todo elemento em A[x + 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 pivo = A[fim] na invariante por enquanto, 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 = inicio e j = inicio, 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., A[inicio..i 1] contém elementos menores ou iguais a pivo e A[i..j 1] contém elementos maiores do que pivo. Precisamos provar que ela continua válida imediatamente antes da próxima iteração, onde teremos j + 1. Na iteração j do laço, se A[j] > pivo, a única operação feita é alterar j para j + 1, de modo que agora A[i..j] contém elementos maiores do que pivo e A[inicio..i 1] continua contendo elementos menores ou iguais. Portanto, nesse caso a invariante continua válida para antes da próxima iteração. Se A[j] pivo, então trocamos A[i] com A[j], de modo que agora temos que todo elemento em A[inicio..i] é menor ou igual a pivo e todo elemento em A[i + 1..j] é maior do que pivo. Feito isso, i é incrementado para i + 1. Assim, a invariante continua válida para antes da iteração j + 1. 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] (que tem um elemento maior do que pivo) com A[fim]. Para provar que o algoritmo Quicksort funciona corretamente, usaremos indução no valor de n = fim inicio + 1 (o tamanho do vetor). Perceba que a escolha do pivô não interfere na explicação do funcionamento ou da corretude do algoritmo. Você pode assumir por enquanto, se preferir, que Particiona(A, inicio, f im) retorna o índice fim. Teorema 14.3: Corretude de Quicksort O algoritmo Quicksort ordena qualquer vetor A de modo não-decrescente. 134
143 Demonstração. Quando n = 1, o algoritmo não faz nada, funcionando corretamente, já que um vetor com um elemento está trivialmente ordenado. Seja A um vetor com n elementos e suponha que o algoritmo funciona corretamente para vetores com menos do que n elementos. Note que a linha 4 devolve um índice x que contém um elemento em sua posição final na ordenação desejada, todos os elementos de A[inicio..x 1] são menores ou iguais a A[x], e todos os elementos de A[x + 1..fim] são maiores do que A[x]. Após a execução da linha 5, por hipótese de indução, sabemos que A[inicio..x 1] estará ordenado (esse vetor certamente tem tamanho menor do que fim inicio + 1, pois ao menos o pivô foi desconsiderado). Da mesma forma, após a execução da linha 6, sabemos que A[x + 1..fim] estará ordenado. Portanto, todo o vetor A fica ordenado ao final da execução de Quicksort Análise do tempo de execução O tempo de execução de Quicksort(A, inicio, f im) depende fortemente de como a partição é feita, o que depende da escolha do pivô. Seja n = fim inicio + 1 a quantidade de elementos do vetor de entrada. Suponha que Particiona retorna o índice que contém o maior elemento armazenado em A[inicio..f im]. Nesse caso, o vetor é sempre particionado em um subvetor de tamanho n 1 e outro de tamanho 0. Como o tempo de execução do Particiona é Θ(m) quando m elementos lhe são passados, temos que, nesse caso, o tempo de execução de Quicksort é dado por T (n) = T (n 1) + Θ(n). Se 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 ). 135
144 Intuitivamente, conseguimos perceber que esse é o pior caso possível. Formalmente, o tempo de execução de pior caso é dado por T (n) = max 0 x n 1 (T (x)+t (n x 1))+n. Vamos utilizar induçã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 (cx2 + c(n x 1) 2 ) + n (n 1) 2 + n = n 2 (2n 1) + n n 2, onde o máximo na primeira 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. Por outro lado, pode ser que o Particiona sempre retorna o índice que contém a mediana dos elementos do vetor, de forma que a partição produza duas partes de mesmo tamanho, sendo o tempo de execução dado por T (n) = 2T (n/2) + Θ(n) = Θ(n log n). Suponha agora que Particiona divide o problema em um subproblema de tamanho (n 1)/1000 e outro de tamanho 999(n 1)/1000, então o tempo de execução é dado por T (n) = T ((n 1)/1000) + T (999(n 1)/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 Particiona 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) = T (n/k)+t ((k 1)n/k)+n tem solução O(n log n). Assuma 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 136
145 para todo n k e alguma constante d > 0. Começamos notando que T (k) T (k 1) + T (1) + k 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 o tamanho da árvore de recursão é log k/(k 1) n = Θ(log n) e, em cada passo, é executada uma quantidade de passos proporcional ao tamanho do vetor analisado, de forma que o tempo total de execução é O(n log n). Com isso, vemos que qualquer divisão que não deixe um subvetor vazio já seria boa o suficiente para termos um bom tempo de execução (assintoticamente falando). O problema da discussão que tivemos até agora é que é improvável que a partição seja sempre feita da mesma forma em todas as chamadas recursivas. Vamos agora analisar o que acontece no caso médio, quando cada uma das n! possíveis ordenações dos elementos de A tem a mesma chance de ser a ordenação do vetor de entrada A. Suponha que Particiona sempre retorna a posição f im. É fácil ver que o tempo de execução de Quicksort é dominado pela quantidade de operações feitas na linha 4 de Particiona. Seja então X uma variável aleatória que conta o número de vezes que essa linha é executada durante uma execução completa do Quicksort, isto é, ela representa o número de comparações feitas durante toda. 137
146 a execução. Pela segunda observação acima, é fácil ver que o tempo de execução do Quicksort é T (n) E[X]. Logo, basta encontrar um limitante superior para E[X]. Sejam o 1,..., o n os elementos de A em sua ordenação final (após estarem ordenados de modo não-decrescente), i.e., o 1 o 2 o n e não necessariamente o i = A[i]. 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 é escolhido como pivô ele é colocado em sua posição final e ignorado pelas chamadas posteriores. Então defina X ij como a variável aleatória indicadora para o evento o i é comparado com o j. Claramente, 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 E[X ij ] P (o i ser comparado com o j ). (14.1) Vamos então 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 ij = {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 Particiona e nunca serão comparados durante toda a execução. 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 ij ) 2 = j i
147 Assim, voltando à (14.1), temos E[X] = n 1 n i=1 j=i+1 n 1 < 2 i=1 n k=1 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). 139
148 140
149 Capítulo 15 Ordenação em tempo linear Vimos, nos capítulos anteriores, 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 três algoritmos são baseados em comparações entre os elementos de entrada. Suponha um algoritmo correto para o problema da ordenação que recebe como entrada n números. Veja que, por ser correto, ele deve corretamente ordenar qualquer uma das n! possíveis entradas. Suponha que esse algoritmo faz no máximo k comparações para ordenar qualquer uma dessas entradas. Como uma comparação tem dois resultados possíveis (sim ou não), podemos associar uma string binária de k bits com cada possível execução do algoritmo. Temos, portanto, no máximo 2 k possíveis execuções diferentes do algoritmo para todas as n! entradas. Pelo Princípio da Casa dos Pombos e porque supomos que o algoritmo está correto, devemos ter 2 k n! (uma execução diferente para cada entrada). Como n! (n/2) n/2, temos que k (n/2) log(n/2), isto é, k = Ω(n log n). Pela discussão acima, temos 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, no entanto, sabemos informações extras sobre os dados de entrada. Nesses casos, é 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.
150 15.1 Counting sort Assuma que o vetor A de entrada contém somente números inteiros entre 0 e k. Quando k = O(n), o algoritmo CountingSort é 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 CountingSort verifica quantos elementos de A são menores ou iguais a x. Assim, o algoritmo consegue colocar x na posição correta sem precisar fazer nenhuma comparação. O procedimento é formalizado no Algoritmo 37. Algoritmo 37: CountingSort(A, k) /* C é um vetor auxiliar contador e B guardará o vetor ordenado */ 1 Sejam B[1..A. tamanho] e C[0..k] novos vetores 2 para i = 0 até k faça 3 C[i] = 0 /* C[i] guarda 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] guarda a qtd. 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 15.1 apresenta um exemplo de execução do algoritmo CountingSort. Os quatro laços para existentes no CountingSort 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 CountingSort é 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. Ele algoritmo é comumente utilizado como subrotina de um outro 142
151 Figura 15.1: Execução do CountingSort(A, 6), onde A = (3, 0, 5, 4, 3, 0, 1, 2). 143
152 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. 144
153 Parte IV Técnicas de construção de algoritmos (...) the more comfortable one is with the full array of possible design techiniques, the more one starts to recognize the clean formulations that lie within messy problems out in the world. Jon Kleinberg, Éva Tardos Algorithm Design, 2005.
154
155 Nesta parte Infelizmente, não existe uma solução única para todos os problemas computacionais. Também não existe fórmula que nos ajude a descobrir qual a solução para um problema. Uma abordagem prática é discutir técnicas que já foram utilizadas antes e que possam ser aplicadas a vários problemas, na esperança de poder reutilizá-las ou adaptá-las aos novos problemas. Veremos os três principais paradigmas de projeto de algoritmos, que são estratégias gerais para solução de problemas. A maioria dos problemas que consideraremos nesta parte são problemas de otimização. Em geral, um problema desses possui um conjunto de restrições que define o que é uma solução viável e uma função objetivo que determina o valor de cada solução. O objetivo é encontrar uma solução ótima, que é uma solução viável com melhor valor de função objetivo (maximização ou minimização).
156 148
157 Capítulo 16 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. Os algoritmos de busca binária em vetores (Seção 1.1) e Mergesort (Capítulo 12) e Quicksort (Seção 14.2), para ordenação de vetores, fazem uso desse paradigma. Nesse capítulo veremos outros algoritmos que também são de divisão e conquista Multiplicação de inteiros Considere o seguinte problema.
158 Problema 16.1: Multiplicação de inteiros Dados dois inteiros x e y contendo n dígitos cada, obter o produto xy. Todos nós conhecemos o algoritmo clássico de multiplicação. Seja x = 5678 e y = 1234 (ou seja, n = 4): A seguir provamos que ele está de fato correto, isto é, para quaisquer dois inteiros x e y, ele retorna xy. Seja y = y 1 y 2... y n, onde y i é um dígito de 0 a 9. Note que o algoritmo faz (x y n ) + (x y n 1 10) + + (x y 2 10 n 2 ) + (x y 1 10 n 1 ), que equivale exatamente a xy. Com relação ao tempo, vamos contar quantas operações básicas o algoritmo faz. No caso, somar ou multiplicar dois dígitos simples é uma operação básica. Note que para obter o primeiro produto parcial (x y n ), precisamos de n multiplicações de um dígito e talvez mais n 1 somas (para os carries), isto é, no máximo 2n operações. Similarmente, para obter x y n 1 10, outras no máximo 2n operações básicas foram necessárias. E isso é verdade para todos os produtos parciais. Assim, são no máximo 2n operações para cada um dos n dígitos de y, isto é, 2n 2 operações no máximo. Perceba que cada número obtido nos n produtos parciais tem no máximo 2n + 1 dígitos. Assim, as adições dos produtos parciais leva outras no máximo 2n 2 + n operações. Logo, temos que o tempo de execução desse algoritmo é O(n 2 ), quadrático no tamanho da entrada. Felizmente, existem algoritmos melhores para resolver o problema da multiplicação. Um deles é o algoritmo de Karatsuba. No que segue, vamos considerar n é um múltiplo de 2, para não nos preocuparmos com pisos e tetos. 150
159 Sejam a, b, c e d números inteiros com n/2 dígitos cada tais que x = 10 n/2 a + b e y = 10 n/2 c + d. No exemplo anterior, com x = 5678 e y = 1234, temos a = 56, b = 78, c = 12 e d = 4. Podemos então escrever xy = (10 n/2 a + b)(10 n/2 c + d) = 10 n ac + 10 n/2 (ad + bc) + bd. (16.1) Perceba como reduzimos o problema de multiplicar números de n dígitos para o problema de multiplicar números de n/2 dígitos. Isto é, podemos usar recursão para resolvê-lo. Um algoritmo de divisão e conquista simples para o problema da multiplicação é descrito no Algoritmo 38. Algoritmo 38: MultiplicaInteiros(x, y, n) 1 se n == 1 então 2 retorna xy 3 Seja x = 10 n/2 a + b e y = 10 n/2 c + d, onde a, b, c e d são números com n/2 dígitos cada 4 p 1 = MultiplicaInteiros(a, c, n/2) 5 p 2 = MultiplicaInteiros(a, d, n/2) 6 p 3 = MultiplicaInteiros(b, c, n/2) 7 p 4 = MultiplicaInteiros(b, d, n/2) 8 retorna 10 n p n/2 (p 2 + p 3 ) + p 4 É fácil provar por indução em n que MultiplicaInteiros corretamente calcula xy. Também é fácil perceber que seu tempo de execução, T (n), pode ser descrito por T (n) = 4T (n/2) + n, pois as operações necessárias na linha 8 levam tempo O(n). Pelo Método Mestre (Seção 3.5), temos T (n) = O(n 2 ), isto é, não houve muita melhora com relação ao algoritmo simples. O algoritmo de Karatsuba também usa o paradigma de divisão e conquista e se aproveita do fato de que (a + b)(c + d) = ac + ad + bc + bd para fazer apenas 3 chamadas recursivas. Calculando apenas os produtos ac, bd e (a + b)(c + d), como (a + b)(c + d) ac bd = ad + bc, conseguimos calcular (16.1). Veja o pseudocódigo no Algoritmo 39. Novamente, é fácil provar por indução em n que Karatsuba corretamente calcula xy. Seu tempo de execução, T (n), pode ser descrito por T (n) = 3T (n/2) + n, o que é 151
160 Algoritmo 39: Karatsuba(x, y, n) 1 se n == 1 então 2 retorna xy 3 Seja x = 10 n/2 a + b e y = 10 n/2 c + d, onde a, b, c e d são números com n/2 dígitos cada 4 p 1 = MultiplicaInteiros(a, c, n/2) 5 p 2 = MultiplicaInteiros(a, d, n/2) 6 p 3 = MultiplicaInteiros(a + b, c + d, n/2 + 1) 7 retorna 10 n p n/2 (p 3 p 1 p 2 ) + p 2 O(n 1.59 ). Logo, no pior caso, o algoritmo de Karatsuba é melhor do que o algoritmo básico de multiplicação. 152
161 Capítulo 17 Algoritmos gulosos Um algoritmo guloso é aquele que constrói uma solução através de uma sequência de decisões que visam o melhor cenário de curto prazo, sem garantia de que isso levará ao melhor resultado global. Algoritmos gulosos são muito usados porque costumam ser rápidos e fáceis de implementar. Em geral, é fácil descrever um algoritmo guloso que forneça uma solução viável e tenha complexidade de tempo fácil de ser analisada. A dificuldade normalmente se encontra em provar se a solução obtida é de fato ótima. Na maioria das vezes, inclusive, elas não são ótimas, mas em alguns casos é possível mostrar que elas têm valor próximo ao de uma solução ótima. Neste capítulo veremos diversos algoritmos que utilizam esse paradigma. Também são gulosos alguns algoritmos clássicos em grafos como Prim (Seção 21.2), Kruskal (Seção 21.1) e Dijkstra (Seção ) Escalonamento de tarefas compatíveis Seja um conjunto T = {t 1,..., t n } com n tarefas onde cada t i T tem um tempo inicial s i e um tempo final f i indicando que, se selecionada, a tarefa t i acontece no intervalo [s i, f i ). Dizemos que duas tarefas t i e t j são compatíveis se os intervalos [s i, f i ) e [s j, f j ) não se sobrepõem, isto é, s i f j ou s j f i. Considere o seguinte problema. Veja a Figura 17.1 para um exemplo.
162 Figura 17.1: Conjunto T = {t 1, t 2,..., t 10 } de tarefas e seus respectivos intervalos. Note que {t 3, t 9, t 10 } é uma solução viável para essa instância. As soluções viáveis {t 1, t 4, t 8, t 10 } e {t 2, t 4, t 8, t 10 }, no entanto, são ótimas. Problema 17.1: Escalonamento de tarefas compatíveis Dado conjunto T = {t 1,..., t n } com n tarefas onde cada t i T tem um tempo inicial s i e um tempo final f i, encontrar o maior subconjunto de tarefas mutuamente compatíveis. Note como temos escolhas a fazer: tarefas que sejam compatíveis com as tarefas já escolhidas. Como a intenção é escolher o maior número de tarefas, talvez uma boa escolha seja por uma tarefa que acabe o quanto antes (escolha gulosa). Esse procedimento de sempre escolher a tarefa que termina primeiro (com menor valor f i ) é descrito no Algoritmo 40. Ele mantém um conjunto S de tarefas escolhidas. Algoritmo 40: EscalonaCompativel(T, n) 1 Ordene as tarefas em ordem não-decrescente de tempo final 2 Renomeie-as de modo que f 1 f 2 f n 3 S = {t 1 } 4 k = 1 /* k mantém o índice da última tarefa adicionada à S */ 5 para i = 2 até n faça 6 se s i f k então 7 S = S {t i } 8 k = i 9 retorna S Note que o primeiro passo do algoritmo é ordenar as tarefas de acordo com o tempo final e renomeá-las, de forma que em t 1 temos a tarefa que termina primeiro. Essa é a primeira escolha do algoritmo. Em seguida, dentre as tarefas restantes, são escolhidas 154
163 apenas aquelas que começam após a última tarefa escolhida. Dessa forma, garantimos que estamos escolhendo apenas tarefas compatíveis. Assim, o conjunto S devolvido é de fato uma solução viável para o problema. O Lema 17.2 mostra que na verdade S é uma solução ótima. Lema 17.2 Dado conjunto T = {t 1,..., t n } com n tarefas onde cada t i T tem um tempo inicial s i e um tempo final f i, o algoritmo EscalonaCompativel(T, n) retorna uma solução ótima para o problema de Escalonamento de tarefas compatíveis. Demonstração. Denote por T k = {t i T : s i f k }, isto é, o conjunto das tarefas que começam após o fim da tarefa t k. Seja t x T k uma tarefa que termina primeiro em T k (com menor f i em T k ). Note que EscalonaCompativel escolhe t x. Vamos supor que essa escolha não está presente em nenhuma solução ótima, isto é, se S k T k é uma solução ótima para T k, então t x / S k. Seja t y S k uma tarefa que termina primeiro em S k (com menor f i em S k ). Monte o conjunto S k = (S k \ {t y }) {t x }. Note que, como ambas t x e t y estão em T k, temos que f x f y. E como f y f z para qualquer t z S k, temos que S k é uma solução viável para T k (é um conjunto de tarefas mutuamente compatíveis). Mas note que S k = S k, de forma que S k deve, portanto, ser solução ótima para T k também, o que é uma contradição. Ou seja, a escolha gulosa está de fato presente em uma solução ótima. Com relação ao tempo de execução, note que as linhas 1 e 2 levam tempo Θ(n log n) para serem executadas (podemos usar, por exemplo, o algoritmo Mergesort para ordenar as tarefas). O laço para da linha 5 claramente leva tempo total Θ(n) para executar, pois analisamos todas as tarefas fazendo operações de tempo constante. Assim, o tempo desse algoritmo é dominado pela ordenação das tarefas, tendo tempo total portanto de Θ(n log n). 155
164 Figura 17.2: Instância do problema da mochila onde W = 50, v 1 = 60, w 1 = 10, v 2 = 100, w 2 = 20, v 3 = 120 e w 3 = Mochila fracionária O problema da mochila é um dos clássicos em computação. Nessa seção veremos a versão da mochila fracionária. A Seção?? apresenta a versão da mochila inteira. Problema 17.1: Mochila fracionária Dado um conjunto I = {1, 2,..., n} de n itens onde cada i I tem um peso w i e um valor v i associados e dada uma mochila com capacidade de peso W, selecionar frações f i [0, 1] dos itens tal que n i=1 f iw i W e n i=1 f iv i é máximo. Veja a Figura 17.2 para um exemplo. 156
165 Uma estratégia gulosa óbvia é a de sempre escolher o item de maior valor que ainda cabe na mochila. Isso de fato cria soluções viáveis, no entanto não nos dá a garantia de sempre encontrar a solução ótima. No exemplo da Figura 17.2, essa estratégia gera a solução viável onde f 1 = 0, f 2 = 1 e f 3 = 1, de custo 220, mas sabemos que existe solução melhor (logo, essa não é ótima). É importante observar que para mostrar que o algoritmo não encontra a solução ótima basta mostrar um exemplo no qual ele falha. Note que a estratégia anterior falha porque a escolha pelo valor ignora totalmente outro aspecto do problema, que é a restrição do peso da mochila. Intuitivamente, o que queremos é escolher itens de maior valor que ao mesmo tempo tenham pouco peso, isto é, que tenham melhor custo-benefício. Assim, uma outra estratégia gulosa é sempre escolher o item com a maior razão v (valor/peso). No exemplo da Figura 17.2, temos w v 1 w 1 = 6, v 2 w 2 = 5 e v 3 w 3 = 4, de forma que essa estratégia funcionaria da seguinte forma. O item com a maior razão valor/peso é o item 1 e ele cabe inteiro na mochila, portanto faça f 1 = 1. Temos agora capacidade restante de 40. O próximo item de maior razão valor/peso é o item 2 e ele também cabe inteiro na mochila atual, portanto faça f 2 = 1. Temos agora capacidade restante de peso 20. O próximo item de maior razão é o item 3, mas ele não cabe inteiro. Pegamos então a maior fração possível dele que caiba, que é 2, portanto faça f 3 3 = 2. Veja que essa é de fato a solução ótima do exemplo dado. 3 Isso não prova que a estratégia escolhida é ótima, no entanto. Devemos fazer uma demonstração formal se suspeitarmos que nossa estratégia é ótima. Essa, no caso, de fato é (veja o Lema 17.2). O algoritmo usa essa estratégia está descrito formalmente no Algoritmo 41. O algoritmo funciona inicialmente ordenando os itens e renomeando-os para ter v 1 w 1 v 2 w 2 vn w n. Assim, o item 1 tem a maior razão valor/peso. Mantemos uma variável capacidade para armazenar a capacidade restante da mochila. No laço enquanto da linha 5 o algoritmo seleciona itens inteiros (f i = 1) na ordem da razão valor/peso enquanto eles couberem inteiros na mochila (w i capacidade). O próximo item, se existir, é pego de fracionadamente (linha 10). Nenhum outro item é considerado, tendo f i = 0 (laço da linha 11). Note que a solução gerada é de fato viável, tem custo n i=1 f[i]v i e vale que n i=1 f[i]w i = W. Lema
166 Algoritmo 41: MochilaFracionaria(I, n, W ) 1 Ordene os itens pela razão valor/peso e os renomeie de forma que vn w n v 1 w 1 v 2 w 2 2 capacidade = W 3 Seja f[1..n] um vetor 4 i = 1 5 enquanto i n e capacidade w i faça 6 f[i] = 1 7 capacidade = capacidade w i 8 i = i se i n então 10 f[i] = capacidade/w i 11 para j = i + 1 até n faça 12 f[j] = 0 13 retorna f Dado um conjunto I = {1, 2,..., n} de n itens onde cada i I tem um peso w i e um valor v i associados e dada uma mochila com capacidade de peso W, o algoritmo MochilaFracionaria(I, n, W ) retorna uma solução ótima para o problema da Mochila fracionária. Demonstração. Suponha que a solução f devolvida por MochilaFracionaria(I, n, W ) não é ótima. Seja então f uma solução ótima para a instância dada. Como f não é ótima, ela deve diferir de f em alguns valores. Seja i o menor índice tal que f[i] > f [i] (não podemos ter sempre f[j] f [j] porque para montar f sempre fazemos a escolha pela maior fração possível e f[i] 0). Monte uma solução f a partir de f da seguinte forma: 1. f [j] = f [j] para todo j < i; 2. f [i] = f[i]; 3. f [j], para i < j n, recebe valores de f [j] corrigidos apropriadamente para que w i (f [i] f [i]) = n j=i+1 w j(f [j] f [j]) para manter f uma solução viável. 158
167 Por construção, n i 1 f [j]v j = ( f [j]v j ) + f [i]v i + j=1 = ( = = = = j=1 n f [j]v j f [i]v i j=1 n j=i+1 n j=i+1 n f [j]v j + v i (f [i] f [i]) j=1 n j=1 f [j]v j f [j]v j ) + f [i]v i + n j=i+1 f [j]v j + v i (f [i] f [i]) w i w i n j=i+1 v j (f [j] f [j]) n j=i+1 f [j]v j v j (f [j] f [j]) w j w j n f [j]v j + v n i (f [i] f v i [i])w i (f [j] f [j])w j (17.1) w i w j=i+1 i ( ) n f [j]v j + v n i f [i] f [i])w i (f [j] f [j])w j w i j=i+1 n f [j]v j, (17.2) j=1 j=1 j=1 v j w j onde (17.1) vale porque v i w i e (17.2) vale pelo item 3 da construção de f. Com isso, concluímos que f não é pior do que f. De fato, como f é ótima, concluímos que f também deve ser. Fazendo essa transformação repetidamente chegaremos a f, e, portanto, f também deve ser ótima. Com relação ao tempo de execução, note que a linha 1 leva tempo Θ(n log n) para ser executada (usando, por exemplo, o Mergesort para fazer a ordenação). Os dois laços do algoritmo levam tempo total Θ(n), pois apenas fazemos operações constantes para cada item da entrada. Assim, o tempo desse algoritmo é dominado pela ordenação, tendo tempo total portanto de Θ(n log n) Compressão de dados Considere o seguinte problema. 159
168 Figura 17.3: Árvores representando três códigos diferentes para o alfabeto A = {a, b, c, d}. Problema 17.1: Compressão de dados Dado um arquivo com caracteres pertencentes a um alfabeto A onde cada i A possui uma frequência f i de aparição, encontrar uma sequência de bits (código) para representar cada caractere de modo que o arquivo binário tenha tamanho mínimo. Por exemplo, suponha que o alfabeto é A = {a, b, c, d}. Poderíamos usar um código de largura fixa, fazendo a = 00, b = 01, c = 10 e d = 11. Assim, a sequência acaba pode ser representada em binário por Mas note que a letra a aparece bastante nessa sequência, de modo que talvez utilizar um código de largura variável seja melhor. Poderíamos, por exemplo, fazer a = 0, b = 01, c = 10 e d = 1, de forma que a sequência acaba ficaria representada por No entanto, poderia ser interpretado também como baaac, ou seja, esse código escolhido possui ambiguidade. Perceba que o problema está no fato de que o bit 0 pode tanto representar a letra a como o prefixo do código da letra b. Podemos nos livrar desse problema utilizando um código de largura variável que seja livre de prefixo. Assim, podemos fazer a = 0, b = 10, c = 110 e d = 111. Vamos representar os códigos de um alfabeto A por uma árvore binária onde existe o rótulo 0 nas arestas que levam a filhos da esquerda, rótulo 1 nas arestas que levam a filhos da direita e existem rótulos em alguns nós com os símbolos de A. Assim, o código formado no caminho entre a raiz e o nó rotulado por um símbolo i A é o código binário desse símbolo. Note que uma árvore como a descrita acima é livre de prefixo se e somente se os nós rotulados são folhas. Veja a Figura 17.3 para exemplos. 160
169 Figura 17.4: Construção de árvores representativas de códigos binários tendo início com n = A árvores triviais. Note que o comprimento do código de i A é exatamente o nível do nó rotulado com i na árvore T e isso independe da quantidade de 0s e 1s no código. Denotaremos tal valor por d T (i). Com essa nova representação e notações, podemos redefinir o problema de compressão de dados da seguinte forma. Problema 17.2: Compressão de dados Dado alfabeto A onde cada i A possui uma frequência f i, encontrar uma árvore binária T cujas folhas são rotuladas com elementos de A e o custo c(t ) = i A f id T (i) é mínimo. No que seque, seja n = A. Uma forma de construir uma árvore pode ser partir de n árvores que contêm um único nó cada, um para cada i A, e repetitivamente escolher duas árvores e uni-las por um novo nó pai sem rótulo até que se chegue em uma única árvore. Veja na Figura 17.4 três exemplos simples. Note que independente de como as árvores são escolhidas, são feitas exatamente n 1 uniões para gerar a árvore final. O ponto importante desse algoritmo é decidir quais duas árvores serão escolhidas para serem unidas em um certo momento. Veja que nossa função de custo envolve multiplicar a frequência do elemento pelo nível em que ele aparece na árvore. Assim, intuitivamente, parece bom manter os elementos de maior 161
170 Figura 17.5: Exemplo de execução de Huffman com A = {a, b, c, d}, f a = 60, f b = 25, f c = 10 e f d = 5. O custo final da árvore é c(t ) = f a + 2f b + 3f c + 4f d. frequência próximos à raiz. Vamos associar a cada árvore um certo peso. Inicialmente, esse peso é a frequência do elemento que rotula os nós. Quando escolhemos duas árvores e a unimos, associamos à nova árvore a soma dos pesos das duas que a formaram. Assim, uma escolha gulosa bastante intuitiva é selecionar as duas árvores de menor peso sempre. Veja que no início isso equivale aos dois elementos de menor frequência. Essa ideia encontra-se formalizada no Algoritmo 42, conhecido como algoritmo de Huffman. Um exemplo de execução é dado na Figura Algoritmo 42: Huffman(A, f) 1 Sejam a e b os elementos de menor frequência em A 2 se A == 2 então 3 retorna árvore com um nó pai não rotulado e a e b como nós filhos 4 Seja A = (A \ {a, b}) {ab} 5 Defina f ab = f a + f b 6 T = Huffman(A, f) 7 Construa T a partir de T separando a folha rotulada por ab em folhas a e b irmãs 8 retorna T Note que o algoritmo pode ser facilmente implementado em tempo Θ(n 2 ) no pior 162
171 caso: existem Θ(n) chamadas recursivas pois essa é a quantidade total de uniões que faremos, e uma chamada pode levar tempo Θ(n) para encontrar os dois elementos de menor frequência (procurando-os de maneira simples dentre todos os disponíveis). Uma forma de melhorar esse tempo é usando uma estrutura de dados apropriada. Note que a operação que mais leva tempo é a de encontrar os dois elementos de menor frequência. Assim, podemos usar a estrutura heap, que fornece remoção do elemento de maior prioridade (no caso, o de menor frequência) em tempo O(log n) sobre um conjunto de n elementos. Ela também fornece inserção em tempo O(log n), o que precisa ser feito quando o novo símbolo é criado e sua frequência definida como a soma das frequências dos elementos anteriores (linhas 4 e 5). Assim, o tempo total do algoritmo melhora para Θ(n log n) no pior caso. Até agora, o que podemos afirmar é que o algoritmo de Huffman de fato calcula uma árvore binária que representa códigos binários livres de prefixo de um dado alfabeto. Veja que, por construção, os nós rotulados são sempre folhas. O Lema 17.3 mostra que na verdade a estratégia escolhida por Huffman sempre gera uma árvore cujo custo é o menor possível dentre todas as árvores que poderiam ser geradas dado aquele alfabeto. Lema 17.3 Dado alfabeto A onde cada i A possui uma frequência f i, o algoritmo Huffman(A, f) retorna uma solução ótima para o problema da Compressão de dados. Demonstração. É fácil perceber que árvore binária T devolvida pelo algoritmo possui apenas folhas rotuladas com elementos de A. Vamos mostrar por indução em n = A que c(t ) é mínimo. Quando n = 2, a árvore construída pelo algoritmo é claramente ótima. Suponha que o algoritmo constrói uma árvore ótima para qualquer alfabeto de tamanho menor do que n, dadas as frequências dos elementos. Seja n > 2 e A um alfabeto com n elementos. Sejam a, b A os dois elementos de menor frequência em A. Construa A a partir de A substituindo ambos a e b por um novo elemento ab e defina a frequência desse novo elemento como sendo f ab = f a + f b. Note que existe uma bijeção entre árvores cujas folhas são rotuladas com símbolos de A e árvores cujas folhas são rotuladas com símbolos de A onde a e b são irmãos. 163
172 Vamos chamar o conjunto de árvores desse último tipo de T a,b. Seja ˆT uma árvore cujas folhas são rotuladas com símbolos de A e seja ˆT uma árvore de T a,b. Por definição, c( ˆT ) = f i d ˆT (i) + f a d ˆT (a) + f b d ˆT (b), i A\{a,b} e c( ˆT ) = i A \{ab} f i d ˆT (i) + f ab d ˆT (ab). Como A \ {a, b} = A \ {ab}, temos que c( ˆT ) c( ˆT ) = f a d ˆT (a) + f b d ˆT (b) f ab d ˆT (ab). Além disso, d ˆT (a) = d ˆT (b) = d ˆT (ab) + 1 e f ab = f a + f b, por construção. Então temos c( ˆT ) c( ˆT ) = f a + f b, o que independe do formato das árvores. Agora note que, por hipótese de indução, o algoritmo encontra uma árvore T que é ótima para A (isto é, minimiza c(t ) dentre todas as árvores para A ). Então diretamente pela observação acima, a árvore correspondente T construída para A é ótima dentre as árvores contidas em T a,b. Com isso, basta mostrar que existe uma árvore ótima para A (dentre todas as árvores para A) que está contida em T a,b para provar que T é de fato ótima para A. Seja T qualquer árvore ótima para A e sejam x e y nós irmãos no maior nível de T. Crie uma árvore T a partir de T trocando os rótulos de x com a e de y com b. Claramente, T T a,b. Seja B = A \ {x, y, a, b}. Temos, por definição, c(t ) = f i d T (i) + f x d T (x) + f y d T (y) + f a d T (a) + f b d T (b), i B e c( T ) = i B f i d T (i) + f x d T (a) + f y d T (b) + f a d T (x) + f b d T (y). Assim, c(t ) c( T ) = f x (d T (x) d T (a)) + f y (d T (y) d T (b)) + f a (d T (a) d T (x)) + f b (d T (b) d T (y)) = (f x f a )(d T (x) d T (a)) + (f y f b )(d T (y) d T (b)). 164
173 Pela nossa escolha, d T (x) d T (a), d T (y) d T (b), f a f x e f b f y. Então, c(t ) c( T ) 0, isto é, c(t ) c( T ), o que só pode significar que T também é ótima. 165
174 166
175 Capítulo 18 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. Isso porque a característica mais marcante da programação dinâmica é evitar resolver o mesmo subproblema diversas vezes. Para isso, os algoritmos fazem uso de memória extra para armazenar as soluções dos subproblemas. Nos referimos genericamente à estrutura utilizada como tabela mas, em geral, vetores e matrizes são utilizados.
176 Algoritmos de programação dinâmica podem ser implementados de duas formas, que são top-down (também chamada de memoização) 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 na tabela. Assim, sempre que o algoritmo precisar da solução de um subproblema, ele consulta a tabela antes de fazer a chamada recursiva para resolvê-lo. Em geral, algoritmos top-down são compostos por dois procedimentos, um que faz uma inicialização de variáveis e prepara a tabela, e outro procedimento que compõe o análogo a um algoritmo recursivo natural para o problema. Na abordagem bottom-up, o algoritmo é desenvolvido de forma iterativa, e resolvemos os subproblemas do tamanho menor para o maior, salvando os resultados na tabela. Assim, temos a garantia que ao resolver um problema de determinado tamanho, todos os subproblemas menores necessários já foram resolvidos. Essa abordagem dispensa verificar na tabela se um 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. Algoritmos 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 outro lado, é possível que a abordagem top-down seja assintoticamente mais eficiente no caso onde vários subproblemas não precisam ser resolvidos. Um algoritmo bottom-up resolveria todos os subproblemas, mesmo os desnecessários, diferentemente do algoritmo top-down, que resolve somente os subproblemas necessários. Neste capítulo veremos diversos algoritmos que utilizam a técnica de programação dinâmica e mostraremos as duas implementações para cada um. Também usam programação dinâmica alguns algoritmos clássicos em grafos como Bellman-Ford (Seção ) e Floyd-Warshall (Seção ). 168
177 18.1 Sequência de Fibonacci A sequência 1, 1, 2, 3, 5, 8, 13, 21, 34, é conhecida como sequência de Fibonacci. Por definição, o n-ésimo número da sequência, escrito como F n, é dado por 1 se n = 1 F n = 1 se n = 2 (18.1) F n 1 + F n 2 se n > 2. Introduzimos na Seção o problema do Número de Fibonacci e apresentamos algoritmos para o mesmo. Repetiremos alguns trechos daquela discussão aqui, por conveniência. Problema 18.1: Número de Fibonacci Dado um inteiro n 0, encontrar F n. Pela definição de F n, o Algoritmo 43, recursivo, para calcular o n-ésimo número da sequência de Fibonacci segue de forma natural. Algoritmo 43: FibonacciRecursivo(n) 1 se n 2 então 2 retorna 1 3 retorna FibonacciRecursivo(n 1) + FibonacciRecursivo(n 2) No entanto, o algoritmo FibonacciRecursivo é extremamente ineficiente. De fato, muito trabalho repetido é feito, pois subproblemas são resolvidos recursivamente diversas vezes. A Figura 2.1 mostra como alguns subproblemas são resolvidos várias vezes em uma chamada a FibonacciRecursivo(n). 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 FibonacciRecursivo(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, 169
178 temos que T (2) x 2, para todo x 3 1, 732. temos Suponha que T (m) x n para todo 2 m n 1. Assim, aplicando isso a T (n) 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 FibonacciRecursivo é 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 Algoritmo 44 é uma variação de FibonacciRecursivo onde, cada vez que um subproblema é resolvido, o valor é salvo no vetor F. Ele foi escrito usando a abordagem top-down. O algoritmo Fibonacci-TopDown inicializa o vetor F [1..n] com valores que indicam que ainda não houve cálculo de nenhum subproblema, no caso, com 1. Feito isso, o procedimento FibonacciRecursivo-TopDown é chamado para calcular F [n]. Note 170
179 Algoritmo 44: Fibonacci-TopDown(n) 1 Cria vetor F [1..n] global 2 para i = 1 até n faça 3 F [i] = 1 4 retorna FibonacciRecursivo-TopDown(n) Algoritmo 45: FibonacciRecursivo-TopDown(n) 1 se n 2 então 2 retorna 1 3 se F [n] 0 então 4 retorna F [n] 5 F [n] = FibonacciRecursivo-TopDown(n 1) + FibonacciRecursivo-TopDown(n 2) 6 retorna F [n] que FibonacciRecursivo-TopDowntem a mesma estrutura do algoritmo recursivo natural FibonacciRecursivo, com a diferença que em FibonacciRecursivo- TopDown é realizada uma verificação em F antes de tentar resolver F [n]. Como cada subproblema é resolvido somente uma vez em uma execução de FibonacciRecursivo-TopDown, todas as operações realizadas levam tempo constante, e existem n 1 subproblemas (calcular F 1, F 2,..., F n 1 ), o tempo de execução de Fibonacci-TopDown é claramente Θ(n). Note que na execução de FibonacciRecursivo-TopDown(n) é necessário resolver FibonacciRecursivo-TopDown(n 1) e FibonacciRecursivo-TopDown(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. Isso deve ser válido sempre, por isso inicializamos o vetor F nas posições referentes aos casos base do algoritmo recursivo, que nesse caso são as posições 1 e 2. O Algoritmo 46 formaliza essa ideia, da abordagem bottom-up. 171
180 Algoritmo 46: Fibonacci-BottomUp(n) 1 se i 2 então 2 retorna 1 3 Seja F [1..n] um vetor de tamanho n 4 F [1] = 1 5 F [2] = 1 6 para i = 3 até n faça 7 F [i] = F [i 1] + F [i 2] 8 retorna F [n] n p 1 p 2 p 3 p 4 p 5 p Tabela 18.1: Preços para o problema do corte de uma barra de tamanho Corte de barras de ferro Imagine que uma empresa corta e vende pedaços de barras de ferro. As barras são vendidas em pedaços de tamanho inteiro, onde uma barra de tamanho i tem preço de venda p i. Por alguma razão, barras de tamanho menor podem ter um preço maior que barras maiores. A empresa deseja cortar uma grande barra de tamanho inteiro e vender os pedaços de modo a maximizar o lucro obtido. Problema 18.1: Corte de barras de ferro Sejam p 1,..., p n inteiros positivos que correspondem, respectivamente, ao preço de venda de barras de tamanho 1,..., n. Dado um inteiro positivo n, encontrar o maior o lucro obtido com a venda de uma barra de tamanho n, que pode ser vendida em pedaços de tamanho inteiro. Para exemplificar o problema, considere uma barra de tamanho 6 com preços dos pedaços como na tabela abaixo. Note que se a barra 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, 172
181 o que é pior que vender a barra 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 barras. Podemos definir l n recursivamente definindo onde aplicar o primeiro corte na barra. Assim, se o melhor lugar para realizar o primeiro corte na barra é 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 barra, que tem tamanho n i. Portanto, temos l n = max 1 i n {p i + l n i }. (18.2) A igualdade (18.2) sugere o seguinte algoritmo para resolver o problema, onde p é um vetor contendo os preços dos pedaços de uma barra de tamanho n. Algoritmo 47: CorteBarras(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 + CorteBarras(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 CorteBarras(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) = 173
182 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 (18.2)). 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 barras. Esse algoritmo mantém a estrutura de CorteBarras(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 barra de tamanho i. Ademais, vamos manter um vetor s[0..n] tal que s[j] contém o primeiro lugar que deve-se efetuar o corte em uma barra de tamanho j. Algoritmo 48: CorteBarras-TopDown(n, p) 1 Cria vetores r[0..n] e s[0..n] globais 2 r[0] = 0 3 para i = 1 até n faça 4 r[i] = 1 5 retorna CorteBarrasRecursivo-TopDown(n, p) O algoritmo CorteBarras-TopDown(n, p) 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, CorteBarrasRecursivo-TopDown(n, p) é executado. Inicialmente, nas linhas 1 e 2, o algoritmo CorteBarrasRecursivo-TopDown(n, p) 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??. A diferença é que agora salvamos o melhor local para fazer o primeiro corte em uma barra de tamanho n em s[n]. 174
183 Algoritmo 49: CorteBarrasRecursivo-TopDown(n, p) 1 se r[n] 0 então 2 retorna r[n] 3 lucro = 1 4 para i = 1 até n faça 5 valor = CorteBarrasRecursivo-TopDown(n i, p) 6 se lucro < p i + valor então 7 lucro = p i + valor 8 s[n] = i 9 r[n] = lucro 10 retorna lucro Vamos analisar agora o tempo de execução de CorteBarras-TopDown(n, p), que obviamente tem, assintoticamente, o mesmo tempo de execução de CorteBarrasRecursivo- TopDown(n, p). Note que cada chamada recursiva de CorteBarrasRecursivo- TopDown 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 CorteBarrasRecursivo-TopDown é 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 50: ImprimeCortes(n, s) 1 enquanto n > 0 faça 2 Imprime s[n] 3 n = n s[n] Vamos ver agora como é um algoritmo com abordagem bottom-up para o problema 175
184 do corte de barras. A ideia é simplesmente resolver os problemas em ordem de tamanho de barras, pois assim quando formos resolver o problema para uma barra de tamanho j, temos a certeza que todos os subproblemas menores já foram resolvidos. Abaixo temos o algoritmo que torna esse raciocínio preciso. Algoritmo 51: CorteBarras-BottomUp(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) 18.3 Multiplicação de 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. Problema 18.1: Multiplicação de uma sequência de matrizes Dadas uma sequência de matrizes M 1,..., M n onde M i é uma matriz de ordem m i m i+1, para 1 i < n, encontrar a ordem em que precisamos multiplicar as matrizes para que o produto M 1 M 2 M n seja feito com o menor número de operações possível. Perceba que a ordem em que multiplicamos as matrizes é essencial para garantir a eficiência do cálculo do produto total. Por exemplo, considere n = 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 na ordem ((M 1 M 2 ) M 3 ), 176
185 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 na ordem (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. Do exemplo acima vemos que se temos três matrizes M 1, M 2 e M 3, então a solução ótima para multiplicá-las é a melhor dentre ((M 1 M 2 ) M 3 ) e (M 1 (M 2 M 3 )). Se temos quatro, então a solução ótima para multiplicá-las é a melhor dentre multiplicar otimamente M 1, M 2, M 3 primeiro e depois multiplicar esse resultado com M 4, ou então multiplicar M 1, M 2 e multiplicar isso com o resultado da multiplicação de M 3, M 4, ou então multiplicar M 1 com o resultado da multiplicação ótima de M 2, M 3, M 4. Essa ideia pode ser generalizada da seguinte forma. A multiplicação ótima da sequência M 1,..., M n é a melhor dentre multiplicar otimamente M 1,..., M i e depois multiplicar isso com o resultado da multiplicação ótima de M i+1,..., M n, com 1 i < n. De fato, na multiplicação ótima da sequência M 1,..., M n deve existir algum índice i, com 1 i < k, tal que essa multiplicação ótima é da forma (M 1 M i ) (M i+1 M n ) = P 1 P 2, onde P 1 é multiplicação ótima da subsequência M 1,..., M i e P 2 é multiplicação ótima da subsequência M i+1,..., M k. Se P 1 não fosse ótima para M 1,..., M i, então deve haver uma P1 que seja ótima para M 1,..., M i. Mas então P1 P 2 resultaria em uma solução melhor para multiplicar M 1,..., M n, o que é uma contradição com o fato que P 1 P 2 já era ótima para a sequência de n matrizes. Uma argumentação similar vale para P 2. Veja que a solução mencionada acima é recursiva: para resolver otimamente a multiplicação de M 1,..., M n, precisamos considerar todos os valores de 1 i < n e encontrar a solução ótima para multiplicar M 1,..., M i, e para encontrar a solução ótima para algum M 1,..., M i precisamos considerar todos os valores de 1 j < i e encontrar a solução ótima para multiplicar M 1,..., M j. No entanto, é fácil notar que vários desses subproblemas estão se repetindo. Por isso, vamos utilizar uma tabela 177
186 para armazenar seus valores. Note que os subproblemas que vamos tratar envolvem encontrara a melhor multiplicação para subsequências M i,..., M j, com 1 i < j n. Por isso, vamos utilizar uma matriz P de ordem n n tal que P [i][j] irá guardar a menor quantidade de operações necessárias para computar o produto da sequência M i,..., M j de matrizes. Se a solução ótima para essa sequência envolve dividi-la na posição k, gerando subsequências M i,..., M k e M k+1,..., M j, então armazenaremos esse valor k em uma outra matriz S (i.e., S[i][j] = k). O Algoritmo?? está escrito com a abordagem bottom-up. 178
187 Parte V Algoritmos em grafos Suponha que haja três casas em um plano (ou superfície de uma esfera) e cada uma precisa ser ligada às empresas de gás, água e eletricidade. O uso de uma terceira dimensão ou o envio de qualquer uma das conexões através de outra empresa ou casa não é permitido. Existe uma maneira de fazer todas os nove ligações sem que qualquer uma das linhas se cruzem? Não.
188
189 Nesta parte Diversas situações apresentam relacionamentos par-a-par entre objetos, como malha rodoviária (duas cidades podem ou não estar ligadas por uma rodovia), redes sociais (duas pessoas podem ou não ser amigas), relações de precedência (uma disciplina pode ou não ser feita antes de outra), web (um site pode ou não ter link para outro), etc. Todas elas podem ser representadas por grafos. 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.
190 182
191 Capítulo 19 Conceitos essenciais 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 também é formado por um par (V, E), onde V é um conjunto de vértices e A é um conjunto de arcos, que é um conjunto de pares ordenados de V, i.e., um grafo cujas arestas têm uma direção associada. Dado um grafo (ou digrafo) G = (V, E), denotamos o conjunto de vértices de G e o conjunto de arestas (ou arcos) de G, respectivamente, por V (G) e E(G). Um grafo com conjunto de vértices {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 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)). Por simplicidade, muitas vezes vamos denotar arestas {u, v} de um grafo ou (u, v) de um digrafo por uv apenas. No que segue, considere um grafo G = (V, E). Dizemos que u e v são vizinhos (ou adjacentes) se uv E(G). A vizinhança de um vértice u, denotada por N G (u) (ou simplesmente N(u), se G for claro do contexto), é o conjunto dos vizinhos de u. Dizemos ainda que u e v são extremos da aresta uv e que u é adjacente a v (e vice versa). Ademais, dizemos que a aresta uv incide em u e em v. Arestas que compartilham o
192 Figura 19.1: Representação gráfica de um grafo G e um digrafo D. mesmo extremo também 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 grau do vértice de menor grau de G, i.e., δ(g) = min{d G (v): v V }. O grau máximo de um grafo G, denotado por (G), é o grau do vértice de maior grau 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) 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 184
193 Figura 19.2: Representação gráfica de um grafo G e um digrafo D e suas listas de adjacências. comuns de se representar um grafo são listas de adjacências e matriz 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 (G) entradas, uma para cada vértice, onde L G [u] contém uma lista encadeada com todos os vizinhos de u em G. Isto é, em L G [u] temos a cabeça de uma lista que contém N(u). Note que o espaço necessário para armazenar as listas de adjacências de um grafo é Θ( V (G) + E(G) ). Na representação por matriz de adjacências, um grafo G = (V, E) é dado por uma matriz quadrada simétrica M = (m ij ) de tamanho V (G) V (G) onde m ij = 1 se ij E, e m ij = 0 caso contrário. No caso de um digrafo D = (V, A), a matriz M não necessariamente é simétrica mas, de forma equivalente, temos m ij = 1 se (i, j) A, e m ij = 0 caso contrário. Note que o espaço necessário para armazenar uma matriz de adjacências de um grafo é Θ( V (G) 2 ). Em geral, o uso de listas de adjacências é preferido para representar grafos esparsos, que são grafos com n vértices e O(n) arestas, pois o espaço Θ(n 2 ) necessário pela matriz de adjacências é dispendioso. Já a representação por matriz de adjacências é muito usada para representar grafos densos, que são grafos com Θ(n 2 ) arestas. Porém, esse não 185
194 Figura 19.3: Representação gráfica de um grafo G e um digrafo D e suas matrizes de adjacências. é 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 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 alcança os vértices v i, com 1 i k, e as arestas v i v i+1, com 1 i < k. Também dizemos que esses vértices e arestas são alcançáveis a partir de v 0. 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. Note que na definição de passeio podem existir vértices ou arestas repetidas. 186
195 Figura 19.4: Passeios, trilhas, ciclos e caminhos. Passeios em que não há repetição de arestas são chamados de trilhas. Caso um passeio não tenha vértices repetidos, dizemos que esse passeio é um caminho (note como impedir a repetição de vértices também impede a repetição de arestas). Denotamos um caminho de comprimento n por P n. Um uv-caminho é um caminho tal que u é seu começo e v é seu fim. Um passeio é dito fechado se seu começo e fim são o mesmo vértice. Um passeio fechado em que o início e os vértices internos são dois a dois distintos é chamado de ciclo. Denotamos um ciclo de comprimento n por C n. Um subgrafo H = (V, E) de um grafo G = (V, E) é um grafo com V (H) V (G) e E(H) é um conjunto de pares em V (H) tal que E(H) E(H). O subgrafo H é gerador se V (H) = V (G). Dado um conjunto de vértices S V (G), dizemos que um subgrafo H de G é induzido por S se V (H) = S e uv E(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 (ou subgrafo) G é maximal com respeito a uma propriedade P (por exemplo, uma propriedade de um grafo G pode ser G não contém um C 3 ou G tem pelo menos k arestas ) se G possui a propriedade P e não está contido em nenhum outro grafo que possui a propriedade P. Similarmente, um grafo (ou subgrafo) G é 187
196 minimal com respeito a uma propriedade P se G possui a propriedade P e não contém nenhum grafo que possui a propriedade P. Um grafo G = (V, E) é conexo se existir um caminho entre quaisquer dois vértices de V (G). Um grafo que não é conexo é dito desconexo. Os subgrafos conexos de um grafo desconexo G que são maximais com respeito à conexidade são chamados de componentes. Um digrafo G = (V, A) é fortemente conexo se existir um caminho entre quaisquer dois vértices de V (G). Um digrafo que não é fortemente conexo consiste em um conjunto de componentes fortemente conexas, que são subgrafos fortemente conexos maximais. Nas representações gráficas, podemos facilmente distinguir as componentes, o que nem sempre é o caso para componentes fortemente conexas. Uma árvore T com n vértices é um grafo conexo com n 1 arestas ou, alternativamente, é um grafo conexo sem ciclos. Figura 19.5: Exemplos de árvores. 188
197 Capítulo 20 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 sejam visitados. Em geral, a informação em um grafo não está tão organizada como é o caso de vetores ou árvores binárias de busca. Assim, usamos algoritmos de busca para obter mais informações sobre a estrutura do grafo como, por exemplo, para descobrir se a rede representada pelo grafo está totalmente conectada, qual a distância entre dois vértices do grafo, qual o caminho entre dois vértices, se existe um ciclo no grafo ou mesmo para formular um plano (podemos ver um caminho em um grafo como uma sequência de decisões que levam de um estado inicial a um estado final). 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. Dizemos que um vértice v é alcançável a partir de um vértice u se existir um uv-caminho no grafo. Em geral, buscas em grafos recebem como entrada um vértice inicial do qual a busca deve se originar (grafos não têm uma raiz ou um nó cabeça ). O objetivo da busca é encontrar tudo que é alcançável a partir do vértice inicial sem explorar nada duas vezes, para se manter eficiente. De forma simples, temos um subconjunto de vértices que já foram explorados e um subconjunto de vértices ainda não explorados. Para alcançar um vértice não explorado, basta seguir por alguma aresta que o conecte com um vértice já explorado. A forma como tal aresta é escolhida faz a variedade dos algoritmos de busca.
198 20.1 Busca em largura Dado um grafo G = (V, E) e um vértice s V (G), o algoritmo de busca em largura (BFS, de breadth-first search) visita todos os vértices v que são alcançáveis por algum caminho partindo de s. Em outras palavras, se G é um grafo (e não digrafo), então ao fim de sua execução, todos os vértices que estão no mesmo componente de s são visitados. Mesmo assim, esse algoritmo pode ser aplicado tanto sobre grafos quanto sobre digrafos e, apesar de estarmos considerando um grafo G = (V, E), o algoritmo para digrafos é essencialmente o mesmo. O nome desse algoritmo vem do fato de ele explorar os vértices por camadas, de forma que s está na primeira camada, seus vizinhos estão na segunda, os vizinhos deste estão na terceira e assim por diante. Como veremos mais adiante, existe uma correspondência direta entre as camadas e a distância de um vértice até s. Para possibilitar a exploração dos vértices de G dessa maneira, vamos utilizar uma fila como estrutura de dados auxiliar (veja o Capítulo 7 para mais informações sobre filas). Inicialmente, colocamos o vértice s na fila. Enquanto a fila não estiver vazia, removemos um elemento u da fila (inicialmente, s é removido), adicionamos os vizinhos de u à fila e repetimos o procedimento. Note que, após s, os próximos vértices removidos da fila são exatamente os vizinhos de s, depois os vizinhos dos vizinhos de s, e assim por diante. Manteremos, para cada vértice v, um atributo v. predecessor que armazenará o vértice vizinho de v que o colocou na fila e nos auxiliará a descrever um sv-caminho (v. predecessor será o vértice imediatamente antes de v no sv-caminho). Manteremos também um atributo v. visitado, que indicará se v já foi explorado pelo algoritmo. O Algoritmo 52 mostra o pseudocódigo para esse procedimento. Ele considera que quem o chamou já inicializou todos os vértices como não visitados e seus predecessores com null. Vamos agora explicar o algoritmo BuscaLargura em detalhes. O algoritmo primeiramente marca o vértice s com visitado (já que temos acesso direto a ele) e indica que s é predecessor de si próprio. Feito isso, criamos a fila F 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 ainda (i.e., com v. visitado = 0) vamos marcar esse vértice como visitado, atualizar v. predecessor com u (u é o vértice imediatamente antes de v em um caminho de s a v) e colocar v na fila. 190
199 Algoritmo 52: BuscaLargura(G = (V, E), s) 1 s. visitado = 1 2 s. predecessor = s 3 cria fila vazia F 4 Enfileira(F, s) 5 enquanto F. tamanho > 0 faça 6 u = Desenfileira(F ) 7 para todo vértice v N(u) faça 8 se v. visitado == 0 então 9 v. visitado = 1 10 v. predecessor = u 11 Enfileira(F, v) Na Figura 20.1 simulamos uma execução da busca em largura começando no vértice s. Sejam V s (G) e E s (G) os vértices e arestas, respectivamente, que estão no componente que contém um certo vértice s. Sejam n s = V s (G), m s = E s (G), n = V (G) e m = E(G). Vamos analisar o tempo de execução do algoritmo BuscaLargura quando aplicado em um grafo G = (V, E) com início em s. Na inicialização (linhas 1 a 4) é gasto tempo total Θ(1). Note que antes de um vértice v entrar na fila, atualizamos v. visitado de 0 para 1 (linha 9) e depois que o laço enquanto é iniciado, nenhum vértice possui o atributo visitado modificado de 1 para 0. Assim, uma vez que um vértice entra na fila, ele nunca mais passará no teste da linha 8. Portanto, todo vértice alcançável a partir de s entra somente uma vez na fila, e como a linha 6 sempre remove alguém da fila, o laço enquanto é executado n s 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 da linha 7 é executado. Esse é o ponto do algoritmo onde é essencial o uso de lista de adjacências para obtermos uma implementação 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 execução total de Θ(n s n) = O(n 2 ). Porém, se utilizarmos listas de adjacências, então o laço para é executado N(u) vezes apenas, de modo que, no total, ele é executado u V N(u) = 2m s(g) s vezes, e então o tempo total de execução do algoritmo é Θ(n s + m s ). 191
200 Figura 20.1: Execução de BuscaLargura(G = (V, E), s). Observe também que é fácil construir um caminho mínimo de s para qualquer vértice v. Basta seguir o caminho a partir de v, voltando para v. predecessor, depois para v. predecessor. predecessor, e assim por diante, até chegarmos em s. De fato, a árvore T com conjunto de vértices V (T ) = {v V (G): v. predecessor null} e conjunto de arestas E(T ) = {{v. predecessor, v}: v V (T ) \ {s}} contém um único caminho entre s e qualquer v V (T ) Distância entre vértices Dado um grafo G, a distância entre dois vértices u e v, denotada por dist G (u, v) é a menor quantidade de arestas de um caminho entre u e v. Quando não existe caminho entre u e v, definimos dist G (u, v) =. Dizemos que um uv-caminho que possui a menor quantidade de arestas é um uv-caminho mínimo. Ao percorrer o grafo, o algoritmo de busca em largura visita os vértices de acordo com sua distância ao vértice inicial s. Assim, durante esse processo, o algoritmo pode facilmente calcular a distância entre s e v, para todo vértice v V (G). O algoritmo salva essa distância em um atributo v. distancia. O Algoritmo 53 contém poucas diferenças com relação ao algoritmo BuscaLargura anterior: as linhas 3 e 11. Essas 192
201 linhas salvam as distâncias entre s e os outros vértices do grafo. Ele considera que quem o chamou já inicializou todos os vértices como não visitados, seus predecessores com null e as distâncias com. Algoritmo 53: BuscaLarguraDistancia(G = (V, E), s) 1 s. visitado = 1 2 s. predecessor = s 3 s. distancia = 0 4 cria fila vazia F 5 Enfileira(F, s) 6 enquanto F. tamanho > 0 faça 7 u = Desenfileira(F ) 8 para todo vértice v N(u) faça 9 se v. visitado == 0 então 10 v. visitado = 1 11 v. distancia = u. distancia v. predecessor = u 13 Enfileira(F, v) Seja T a árvore com conjunto de vértices V (T ) = {v V : v. predecessor null} e conjunto de arestas E(T ) = {{v. predecessor, v}: v V (T ) \ {s}}. Em T existe um único caminho entre s e qualquer v V (T ) e esse caminho é um caminho mínimo. A seguir mostramos que, ao fim do algoritmo BuscaLarguraDistancia(G = (V, E), s), o atributo v. distancia contém de fato a distância entre s e v, para todo vértice v do grafo G. Começamos apresentando o Lema 20.2, que garante que as estimativas obtidas pelo algoritmo para as distâncias nunca são menores que as distâncias. No lema usaremos o seguinte fato, que pode ser mostrado de forma simples. Fato 20.1 Seja G = (V, E) um grafo. Para qualquer s V (G) e toda aresta uv E(G) temos dist G (s, v) dist G (s, u)
202 Lema 20.2 Sejam G = (V, E) um grafo e s V (G). Ao fim da execução de BuscaLarguraDistancia(G, s) temos que, para todo v V (G), v. distancia dist G (s, v). Demonstração. Comece notando que cada vértice é adicionado à fila somente uma vez. A prova segue por indução na quantidade k de vértices adicionados à fila, i.e., na quantidade de vezes que a rotina Enfileira é executada. Se k = 1, o único vértice adicionado à fila é o vértice s, antes do laço enquanto começar. Nesse ponto, temos s. distancia = 0 dist G (s, s) = 0 e v. distancia = dist G (s, v) para todo v V (G) \ {s}, de modo que o resultado é válido. Suponha agora que o enunciado do lema vale para as primeiras k 1 inserções à fila. Considere o momento em que o algoritmo acaba de realizar a k-ésima inserção na fila, onde v é o vértice que foi adicionado. O vértice v foi considerado no laço para da linha 8 por estar na vizinhança de um vértice u que foi removido da fila. Por hipótese de indução, como u foi um dos k 1 primeiros vértices a ser inserido na fila, temos que u. distancia dist G (s, u). Mas note que, pela linha 11 e utilizando o Fato 20.1, temos v. distancia = u. distancia +1 dist G (s, u) + 1 dist G (s, v). Como cada vértice entra na fila somente uma vez, o valor em v. distancia não muda mais durante a execução do algoritmo. O próximo resultado, Lema 20.3, garante que se um vértice u entra na fila antes de um vértice v, então no momento em que v é adicionado à fila temos u. distancia v. distancia. Como uma vez que a estimativa v. distancia de um vértice v é calculada ela nunca muda, concluímos que a relação entre as estimativas para as distâncias de s a u e v não mudam até o final da execução do algoritmo. 194
203 Lema 20.3 Sejam G = (V, E) um grafo e s V (G). Considere a execução de BuscaLarguraDistancia(G, s). Para todos os pares de vértices u e v na fila tal que u 195
204 entrou na fila antes de v, vale que no momento em que v entra na fila temos u. distancia v. distancia u. distancia +1. Demonstração. Vamos mostrar o resultado por indução na quantidade de iterações do laço enquanto. Antes da primeira iteração não há o que provar, pois a fila contém somente o vértice s. Suponha agora que logo após a (k 1)-ésima iteração do laço enquanto temos u. distancia v. distancia u. distancia +1 para todos os pares de vértices u e v na fila, onde u entrou na fila antes de v. Considere agora a k-ésima iteração do laço enquanto. Seja F = (u, v 1,..., v l ) a fila no início dessa iteração. Durante a iteração, o algoritmo remove u de F e adiciona os vizinhos não visitados de u, digamos u 1,..., u h à fila F, deixando F = (v 1,..., v l, u 1,..., u h ). O algoritmo então faz u j. distancia = u. distancia +1 para todo vizinho u j não visitado de u (executando o laço para). Utilizando a hipótese de indução, sabemos que para todo 1 i l temos u. distancia v i. distancia u. distancia +1. Assim, ao adicionar à fila um vizinho u j de u (lembre que u foi removido da fila) temos, pela desigualdade acima, que, para todo 1 i l, v i. distancia u. distancia +1 = u j. distancia = u. distancia +1 v i. distancia +1. Por hipótese de indução (lembrando que o valor em u j. distancia não muda depois de modificado), sabemos que os pares em {u, v 1,..., v l } satisfazem a conclusão do lema. Ademais, pares dos vizinhos de u que entraram na fila têm a mesma estimativa de distância (u. distancia +1). Portanto, todos os pares de vértices em {v 1,..., v l, u 1,..., u h } satisfazem a conclusão do lema. Com os Lemas 20.2 e 20.3, temos todas as ferramentas necessárias para mostrar que BuscaLarguraDistancia calcula corretamente as distâncias de s a todos os vértices do grafo. 196
205 Teorema 20.4 Sejam G = (V, E) um grafo conexo e s V (G). Ao fim de BuscaLargura- Distancia(G, s) temos que, para todo v V (G), v. distancia = dist G (s, v). Demonstração. Suponha, por contradição, que ao fim da execução de BuscaLarguraDistancia(G, s) exista algum vértice v V (G) com v. distancia dist G (s, v). Seja v o vértice com menor v. distancia tal que v. distancia dist G (s, v). Pelo Lema 20.2, sabemos que v. distancia > dist G (s, v). (20.1) Seja u o vértice que precede v em um sv-caminho mínimo. Então, dist G (s, v) = dist G (s, u) + 1. Pela escolha de v, portanto, u. distancia = dist G (s, u). Assim, usando (20.1), temos que v. distancia > dist G (s, v) = dist G (s, u) + 1 = u. distancia +1. (20.2) Vamos analisar o momento em que BuscaLarguraDistancia(G, s) remove u da fila F. Se nesse momento o vértice v está na fila, então note que v entrou na fila por ser vizinho de um vértice w que já tinha sido removido de F (antes de u). Logo, temos v. distancia = w. distancia +1. Pelo Lema 20.3, w. distancia u. distancia. Portanto, temos v. distancia u. distancia +1, uma contradição com (20.2). Podemos então assumir que quando u foi removido da fila F, o vértice v não estava em F. Se v já havia sido visitado, então ele tinha entrado em F anteriormente e foi removido de F. Nesse caso, pelo Lema 20.3, temos que v. distancia u. distancia, uma contradição com (20.2). Assim, assuma que v não havia sido visitado, de forma que ele não tinha entrado em F quando u foi removido de F. Nesse caso, quando v entrar na fila (certamente entra, pois é vizinho de u), teremos v. distancia = u. distancia +1, uma contradição com (20.2). 197
206 Componentes conexas Os algoritmos BuscaLargura e BuscaLarguraDistancia como vistos anteriormente visitam todos os vértices que são alcançáveis a partir de s, isto é, todos os vértices que estão na mesma componente conexa que s está. Se o grafo é conexo, então as buscas irão visitar todos os vértices do grafo. No entanto, se o grafo não é conexo, existirão ainda vértices não visitados ao fim de uma execução desses dois algoritmos. O Algoritmo 8 mostra como utilizar a BuscaLargura para visitar todos os vértices do grafo, mesmo que ele seja desconexo. Cada vértice possui um atributo componente, que irá manter o vértice representante de sua componente (no caso, o vértice no qual a busca se originou). Para o bom funcionamento desse algoritmo, o algoritmo BuscaLargura deve ser alterado, com uma linha extra v. componente = s sendo adicionada logo antes do vértice v entrar na fila. Algoritmo 54: BuscaComponentes 1 para todo vértice v V (G) faça 2 v. visitado = 0 3 v. predecessor = null 4 para todo vértice v V (G) faça 5 se v. visitado == 0 então 6 v. componente = v 7 BuscaLargura(G, v) 8 (G = (V, E)) Perceba que BuscaLargura(G, v) apenas visita os vértices pertencentes ao componente ao qual v pertence e cada componente é visitado uma única vez por BuscaComponentes. Assim, o tempo de execução desse algoritmo é Θ( V (G) + E(G) ) quando listas de adjacências são utilizadas na representação do grafo Busca em profundidade Dado um grafo G = (V, E) e um vértice s V (G), o algoritmo de busca em profundidade (DFS, de depth-first search) visita todos os vértices v que são alcançáveis por algum caminho partindo de s. Em outras palavras, se G é um grafo (e não digrafo), então 198
207 ao fim de sua execução, todos os vértices que estão no mesmo componente de s são visitados. Mesmo assim, esse algoritmo pode ser aplicado tanto sobre grafos quanto sobre digrafos e, apesar de estarmos considerando um grafo G = (V, E), o algoritmo para digrafos é essencialmente o mesmo. O nome desse algoritmo vem do fato de ele explorar vértices de forma agressiva, sempre visitando o vértice vizinho ao vértice que foi mais recentemente visitado e que ainda tenha vizinhos não visitados. Para possibilitar a exploração dos vértices de G dessa maneira, vamos utilizar uma pilha como estrutura de dados auxiliar (veja o Capítulo 6 para mais informações sobre pilhas). 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 visitado e adiciona v na pilha. Caso todos os vizinhos de u já tenham sido explorados, u é removido da pilha. Cada vértice u possui os atributos u. predecessor, u. fim e u. visitado. O atributo u. predecessor indica qual vértice antecede u em um su-caminho (qual vértice levou u a ser inserido na pilha). O atributo 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). O algoritmo vai fazer uso de uma variável global encerramento, que auxiliará a preencher u. fim. Por fim, u. visitado tem valor 1 se o vértice u já foi visitado pelo algoritmo e 0 caso contrário. O Algoritmo 55 mostra o pseudocódigo para esse procedimento. Ele considera que quem o chamou já inicializou todos os vértices como não visitados, seus predecessores com null e inicializou a variável encerramento. O procedimento Consulta(P ) consulta o último valor inserido na pilha P. O grafo T = (V, E) com conjunto de vértices V (T ) = {v V (G): v. predecessor null} e conjunto de arestas E(T ) = {{v. predecessor, v}: v V (T ) \ {s}} é uma árvore geradora de G e é chamada de árvore de busca em profundidade. Nas linhas 1 a 4 inicializamos alguns atributos, criamos a pilha e empilhamos s. Então, nas linhas 7 a 10 o algoritmo alcança um único vizinho de u (topo da pilha) que ainda não foi visitado e o coloca na pilha, visitando-o. Se u não tem vizinhos não visitados, então a exploração de u é encerrada e o mesmo é retirado da pilha (linhas 11 a 14). 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. Sejam V s (G) 199
208 Algoritmo 55: BuscaProfundidadeIterativa(G = (V, E), s) 1 s. visitado = 1 2 s. predecessor = s 3 cria pilha vazia P 4 Empilha(P, s) 5 enquanto P faça 6 u = Consulta(P ) 7 se existe uv E(G) e v. visitado == 0 então 8 v. visitado = 1 9 v. predecessor = u 10 Empilha(P, v) 11 senão 12 encerramento = encerramento u. fim = encerramento 14 u = Desempilha(P ) e E s (G) os vértices e arestas, respectivamente, que estão no componente que contém o vértice s. Note que imediatamente antes de um vértice x ser empilhado (linha 8), modificamos x. visitado de 0 para 1 e tal atributo não é modificado novamente. Assim, um vértice x só será empilhado uma vez em toda a execução do algoritmo. Dessa forma, fica simples analisar o tempo de execução do algoritmo: a inicialização feita nas linhas 1 a 4 leva tempo Θ(1), o laço enquanto é executado uma vez para cada vértice visitado, levando tempo O( V s (G) ), e a condição na linha 7 é testada uma vez para cada vizinho de cada vértice visitado, de modo que é executada O( E s (G) ) vezes ao todo. Todas as outras instruções são executadas em tempo constante. Assim, o tempo total de execução da busca em profundidade é O( V s (G) + E s (G) ), como na busca em largura (considerando listas de adjacências). Na Figura 20.2 simulamos uma execução da busca em profundidade começando no vértice a. Uma observação interessante é que, dada a forma como os vértices são visitados (sempre explorando um vizinho assim que o mesmo é visitado), é simples escrever um algoritmo recursivo para a busca em profundidade. O Algoritmo 57 descreve o pseudocódigo para esse algoritmo, enquanto o Algoritmo 56 mostra como utilizar a busca em profundidade para visitar todos os vértices do grafo, mesmo que o grafo seja 200
209 Figura 20.2: Execução de BuscaProfundidadeIterativa(G = (V, E), a), indicando a pilha e o tempo de encerramento de cada vértice. desconexo. Cada vértice possui um atributo componente, que irá manter o vértice representante do seu componente (no caso, o vértice no qual a busca se originou). O algoritmo BuscaComponentes ainda mantém uma variável global representante, que irá auxiliar no preenchimento desse atributo. Note que o algoritmo BuscaComponentes faz exatamente a mesma coisa que o algoritmo BuscaComponentes apresentado na Seção Isto é, tanto a busca em largura quanto a busca em profundidade podem ser utilizadas para encontrar quais são os componentes conexos de um grafo. Nas seções a seguir veremos aplicações em que apenas a busca em profundidade pode ser utilizada. 201
210 Algoritmo 56: BuscaComponentes(G = (V, E)) 1 para todo vértice v V (G) faça 2 v. visitado = 0 3 v. predecessor = null 4 encerramento = 0 5 para todo u V (G) faça 6 se u. visitado == 0 então 7 u. componente = u 8 representante = u 9 BuscaProfundidade(G, u) Algoritmo 57: BuscaProfundidade(G = (V, E), s) 1 s. visitado = 1 2 para todo vértice v N(s) faça 3 se v. visitado == 0 então 4 v. predecessor = s 5 v. componente = representante 6 BuscaProfundidade(G, v) 7 encerramento = encerramento u. fim = encerramento 202
211 Ordenação topológica Uma ordenação topológica de um digrafo D é uma rotulação f dos vértices de D tal que: f(v) {1, 2,..., V (G) }, f(u) f(v) se u v, se (u, v) E(G) então f(u) < f(v). Uma ordenação topológica pode ser graficamente visualizada no plano se, ao desenharmos os vértices em ordem, para toda aresta (u, v), o vértice u aparecer à esquerda de v no desenho. Não é difícil perceber que um digrafo admite ordenação topológica se, e somente se, ele não tiver ciclos orientados. Isto é, não existe uma sequência de vértices (v 1, v 2,..., v k ) tal que k 3 e (v i, v i+1 ) é uma aresta para todo 1 i k 1, e (v k, v 1 ) é uma aresta. Um digrafo sem ciclos é chamado de digrafo acíclico. Diversos problemas 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. 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, 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 digrafo 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. Por exemplo, os vértices podem representar tarefas e uma aresta (u, v) indica que a tarefa u deve ser executada antes da tarefa v. O Algoritmo 58 encontra uma ordenação topológica de um digrafo acíclico D. Nas Figuras 20.3 e 20.4 temos um exemplo de execução do algoritmo Ordenacao- Topologica. 203
212 Algoritmo 58: OrdenacaoTopologica(D = (V, A)) 1 para todo vértice v V (D) faça 2 v. visitado = 0 3 v. predecessor = null 4 encerramento = 0 5 para todo vértice v V (D) faça 6 se v. visitado == 0 então 7 BuscaProfundidade(D, v) 8 para todo vértice v V (D) faça 9 f(v) = V (D) v. fim retorna f Figura 20.3: Um digrafo acíclico com vértices representando tópicos de estudo de uma disciplina, e uma aresta (u, v) indica que o tópico u deve ser compreendido antes do estudo referente ao tópico v. Para cada vértice u, indicamos o valor de u. fim. 204
213 Figura 20.4: Uma ordenação topológica obtida com uma execução de OrdenacaoTopologica no grafo da Figura
214 Lema 20.1 Dado digrafo acíclico D, a rotulação f retornada por OrdenacaoTopologica(G) é uma ordenação topológica. Demonstração. Claramente, f(u) {1,..., V (D) } para todo u V (D) e f(u) f(v) sempre que u v. Assim, basta mostrar que f(u) < f(v) para qualquer aresta uv A(D). Tome uma aresta uv qualquer e suponha primeiro que u é visitado antes de v pela busca em profundidade. Isso significa que BuscaProfundidade(D, v) termina antes que BuscaProfundidade(D, u), ou seja, v. fim < u. fim, de onde vemos que f(v) > f(u). Suponha agora que v é visitado antes de u. Como D é acíclico, não existe vucaminho. Então BuscaProfundidade(D, v) não visita u e termina antes mesmo de considerarmos u. Logo, v. fim < u. fim também, de onde temos f(v) > f(u) Componentes fortemente conexas Seja D um digrafo e D 1,..., D k o conjunto de todas as componentes fortemente conexas de D. Pela maximalidade das componentes, cada vértice pertence somente a uma componente e, mais ainda, entre quaisquer duas componentes D i e D j existem arestas apenas em uma direção, caso contrário a união de D i e D j formaria uma componente maior que as duas sozinhas, contradizendo a maximalidade da definição. Por isso, sempre deve existir ao menos uma componente D i que é um ralo: não existe aresta saindo de D i em direção a nenhuma outra componente. Considere a Figura 20.5 para a discussão a seguir. Perceba que se executarmos BuscaProfundidade(D, a) primeiro, vamos encontrar os vértices a, d e e, que de fato fazem parte exatamente de uma componente fortemente conexa de D. Então, quando executarmos BuscaProfundidade(D, b), encontraremos outra componente, que contém os vértices b, f e g. Por outro lado, se executarmos BuscaProfundidade(D, b) primeiro, vamos encontrar os vértices b, f, g, a, d e e. Ou seja, a busca em profundidade é útil para encontrar as componentes quando sabemos a ordem dos vértices iniciais a partir dos quais podemos tentar começá-la. Felizmente, existe um pré-processamento que podemos fazer usando também busca em profundidade. 206
215 a b c d e f g h i Figura 20.5: Digrafo com três componentes fortemente conexas. Dado um digrafo D, o digrafo D é o grafo obtido de D invertendo o sentido de todas suas arestas. O procedimento todo para encontrar as componentes fortemente conexas tem dois passos: 1. Execute BuscaComponentes (Algoritmo 56) sobre D: esse passo tem objetivo de calcular a ordem dos vértices mencionada acima; 2. Execute BuscaComponentes sobre D percorrendo os vértices em ordem decrescente do atributo fim no laço da linha 5: esse passo irá de fato marcar o atributo componente de cada vértice corretamente. A intuição por trás desse procedimento é, após a execução de BuscaComponentes( D), o maior valor do atributo fim vai estar em um vértice pertencente a uma componente fortemente conexa que é ralo em D. Por isso, a primeira chamada a BuscaProfundidade quando executarmos BuscaComponentes(D) irá encontrar tal componente e nada mais. A próxima chamada a BuscaProfundidade vai desconsiderar tal componente, agindo como se fosse sobre D com essa componente removida e, de forma equivalente, irá começar em uma componente ralo no digrafo restante. E assim, sucessivas chamadas vão removendo as componentes fortemente conexas uma a uma, de forma que o procedimento encontra todas. Esse procedimento está descrito formalmente no Algoritmo 59. Algoritmo 59: ComponentesFortementeConexas(D = (V, A)) 1 BuscaComponentes( D) 2 BuscaComponentes(D), considerando os vértices em ordem decrescente do atributo fim no laço da linha 5 Se o grafo estiver representado com lista de adjacências, então não é difícil perceber que o Algoritmo 59 acima funciona em tempo O( V (D) + A(D) ). 207
216 20.3 Outras aplicações dos algoritmos de busca Tanto a busca em largura como a busca em profundidade podem ser aplicadas em vários problemas além dos já vistos. Alguns exemplos são testar se um dado grafo é bipartido 1, detectar ciclos em grafos, encontrar caminhos entre vértices, listar todos os vértices de uma componente conexa e encontrar vértices ou arestas de corte (vértices ou arestas que quando removidos desconectam o grafo). 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 (G) + E(G) )) o problema de encontrar uma trilha Euleriana. 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 que possuem 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, eles 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. 1 Um grafo G é bipartido se V (G) pode ser dividido em dois conjuntos S e V (G) \ S tais que toda aresta uv E(G) é tal que u S e v V (G) \ S. 208
217 Capítulo 21 Árvores geradoras mínimas Uma árvore geradora de um grafo G é uma árvore que é um subgrafo gerador de G, i.e., um subgrafo conexo que não possui ciclos e contém todos os vértices de G. Dado um grafo G = (V, E) e uma função w : E(G) R de pesos nas arestas de G, dizemos que uma árvore geradora T = (V, E) de G tem peso w(t ) = e E(T ) w(e). Diversas aplicações necessitam encontrar uma árvore geradora T = (V, E) 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 ) = min{w(t ): T é uma árvore geradora de G}. Uma árvore T com essas propriedades é uma árvore geradora mínima de G. Problema 21.1: Árvore geradora mínima Dado um grafo G = (V, E) e uma função w : E(G) R, encontrar uma árvore geradora T de G cujo custo e E(T ) w(e) é mínimo. 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, E) 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) é
218 Figura 21.1: Exemplo de um grafo G e uma árvore geradora mínima (representada pelas arestas destacadas). mínima se tem peso mínimo dentre todas as arestas que cruzam esse mesmo corte (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 21.2 Sejam G = (V, E) um grafo e w : E(G) R uma função de pesos. Se e é uma aresta de um ciclo C e e cruza um corte (S, V (G) \ S), então existe outra aresta de C que cruza o mesmo 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 21.2 é 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. 210
219 Teorema 21.3 Sejam G = (V, E) um grafo conexo e w : E(G) R uma função de pesos. Seja (S, V (G) \ S) um corte. Se e é uma aresta mínima desse corte, então existe uma árvore geradora mínima de G que contém e. Demonstração. Sejam G = (V, E) um grafo conexo e w : E(G) R uma função de pesos. Considere uma árvore geradora mínima T = (V, E) de G e seja (S, V G \ S) um corte de G. Seja e = {u, v} 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 de G. Note que como T é uma árvore geradora, adicionar e a T gera exatamente um ciclo. Assim, pelo Lema 21.2, sabemos que existe outra aresta f de T que está no ciclo e 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 ), pois w(e) w(f), o 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 21.3 para obter árvores geradoras mínimas de grafos conexos Algoritmo de Kruskal Dado um grafo conexo G = (V, E) e uma função w de pesos sobre as arestas de G, o algoritmo de Kruskal começa com um conjunto vazio F de arestas e a cada passo adiciona uma aresta e a F garantindo que F {e} é um subconjunto de arestas de uma árvore geradora mínima de G. Esse é um algoritmo guloso (veja Capítulo 17) e sua característica gulosa é adicionar a F a aresta de menor custo possível dentre as restantes que não forma ciclos com as arestas que já estão em F. O algoritmo termina quando F tem n 1 arestas. Veremos no Lema 21.1 que essa estratégia de fato nos permite gerar uma árvore geradora mínima para G. O algoritmo de Kruskal está formalizado no Algoritmo 60. Lembre-se que, dado um grafo G = (V, E) e um 211
220 subconjunto F E(G), o grafo G[F ] é o subgrafo de G com conjunto de arestas F e com os vértices que são extremos das arestas de F. Algoritmo 60: Kruskal(G = (V, E), w) 1 Crie um vetor C[1.. E(G) ] e copie as arestas de G para C 2 Ordene C de modo não-decrescente de pesos das arestas 3 Seja F = 4 para i = 1 até E(G) faça 5 se G[F {C[i]}] não contém ciclos então 6 F = F {C[i]} 7 retorna F No começo do algoritmo, o conjunto de arestas do grafo é ordenado de acordo com seus pesos (linha 2). Assim, para considerar arestas de menor peso, basta percorrer o vetor C em ordem. Na linha 3 criamos o conjunto F 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, arestas de peso mínimo que não formam ciclos com as arestas que já estão em F. Lema 21.1 Kruskal retorna F tal que G[F ] é árvore geradora mínima para qualquer grafo G = (V, E) conexo e função de custo w sobre as arestas. Demonstração. Seja F i o conjunto de arestas na i-ésima iteração do algoritmo e seja F o conjunto devolvido ao fim. Claramente, por construção, G[F ] não tem ciclos. Basta mostrar então que G[F ] é conexo e que w(g[f ]) é mínimo. Considere um corte qualquer (S, V (G) \ S). Dentre as arestas que cruzam esse corte, seja e E(G) a primeira delas que é considerada pelo Kruskal e suponha que isso acontece na i-ésima iteração. Se ela é a primeira desse corte que é considerada, então ao observar o corte em G[F i {e}], essa aresta é sozinha no corte. Sendo sozinha, pelo resultado do Lema 21.2, não existem ciclos em G[F i {e}]. Logo, e é de fato escolhida para ser adicionada a F i. Acabamos de mostrar portanto que qualquer corte do grafo possui uma aresta escolhida que o cruza, de forma que G[F ] é conexo. Por fim, seja e = uv uma aresta que é adicionada na i-ésima iteração. Seja S V (G) 212
221 o conjunto de vértices da componente do grafo G[F i ] que contém u. Logo, S não contém v. Como e tem o menor custo em (S, V \ S) devido à ordem de escolha do algoritmo, então pelo Teorema 21.3 ela deve fazer parte de uma árvore geradora mínima de G. Ou seja, o algoritmo apenas fez escolhas de arestas que estão em uma árvore geradora mínima e, portanto, construiu uma árvore geradora mínima. Seja G = (V, E) um grafo conexo 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 Mergesort ou Heapsort, podemos executar a linha 2 em tempo O(m log m). A linha 3 leva tempo Θ(1) e o laço para (linha 4) é executado m vezes. O tempo gasto na linha 5 depende de como identificamos os ciclos. Utilizar algoritmos de busca para verificar a existência de ciclos em F {C[i]} leva tempo O(n + F ) (basta procurar por ciclos em G[F ] e não em G). Como F possui no máximo n 1 arestas, a linha 5 é executada em tempo O(n). Portanto, como o laço é executado m vezes, no total o tempo gasto nas linhas 4 a 6 é O(mn). Se T (n, m) é o tempo de execução de Kruskal(G = (V, E), w), 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) = O(mn). Para entender as igualdades acima, note que, como G é conexo, temos m n 1, de modo que vale 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). Veja que a operação mais importante e repetida no algoritmo é a checagem de ciclos. É possível melhorar o tempo de execução dessa operação através do uso de uma estrutura de dados apropriada. Union-find é uma estrutura que mantém uma partição de um conjunto de objetos. Ela oferece as funções FindSet(x), que retorna o conjunto que contém o elemento x, e Union(x, y), que funde os conjuntos que contêm os elementos x e y. Veja mais sobre essa estrutura na Seção 9.1. Note que o algoritmo de Kruskal no fundo está mantendo uma partição dos vértices do grafo, onde os conjuntos formados são as componentes conexas do grafo G[F ]. Inicialmente, cada vértice está em um conjunto sozinho e cada aresta escolhida une dois conjuntos. Perceba que uma aresta que conecta duas componentes conexas de G[F ] 213
222 certamente não cria ciclos. É suficiente, portanto, adicionar a aresta de menor peso que conecta vértices mantidos em conjuntos diferentes, não sendo necessário procurar explicitamente por ciclos. O Algoritmo 61 reapresenta o algoritmo de Kruskal utilizando explicitamente a estrutura union-find. O procedimento MakeSet(x) cria um conjunto novo contendo somente o elemento x. Algoritmo 61: KruskalUnionFind(G = (V, E), w) 1 Crie um vetor C[1.. E(G) ] e copie as arestas de G para C 2 Ordene C de modo não-decrescente de pesos das arestas 3 Seja F = 4 para todo vértice v V (G) faça 5 MakeSet(v) 6 para i = 1 até E(G) faça 7 Seja uv a aresta em C[i] 8 se FindSet(u) FindSet(v) então 9 F = F {C[i]} Union(u, v) 10 retorna F Novamente, nas primeiras linhas as arestas são ordenadas e o conjunto F é criado. No laço para da linha 4 criamos um conjunto para cada um dos vértices. Esses conjuntos são nossas componentes conexas iniciais. No laço para da linha 6 são adicionadas, passo a passo, aresta de peso mínimo que conectam duas componentes conexas de G[F ]. Note que o teste da linha 8 falha para uma aresta cujos extremos estão no mesmo conjunto e criariam um ciclo em F. Ao adicionar uma aresta uv ao conjunto F, precisamos unir as componentes que contêm u e v (linha 9). Seja G = (V, E) um grafo conexo 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 Θ(1) e levamos tempo Θ(n) no laço da linha 4. O laço para da linha 6 é executado m vezes. Como a linha 8 tem somente operações FindSet, ela é executada em tempo Θ(1) e a linha 9 também, sendo, ao todo, O(m) verificações de ciclos. Com relação à linha 9, precisamos fazer uma análise mais cuidadosa. Uma execução do algoritmo Union(x, y) leva tempo Θ(t), onde t é o tamanho do menor conjunto 214
223 dentre os conjuntos que contêm x e y, pois precisamos atualizar todos os representantes desse conjunto (veja Seção 9.1 para mais detalhes). De fato, dois conjuntos unidos podem ter O(n) vértices cada, mas poucos deles de fato terão Θ(n) vértices. Porém, 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 pelo menos dobra de tamanho. Assim, como cada vértice x começa em um conjunto de tamanho 1 e termina em um conjunto de tamanho n, 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 execuções da linha 9 é O(n log n). Se T (n, m) é o tempo de execução de KruskalUnionFind(G = (V, E), w), então vale o seguinte: T (n, m) = O(n + m) + O(m log m) + +O(m) + O(n log n) = O(m) + O(m log n) + O(m) + O(m log n) = O(m log n) Algoritmo de Prim Lembre-se que, dado um grafo G = (V, E) e um subconjunto F E(G), o grafo G[F ] é o subgrafo de G com conjunto de arestas F e com os vértices que são extremos das arestas de F. Dado um grafo conexo G = (V, E) e uma função w de pesos nas arestas de G, o algoritmo de Prim começa com um conjunto vazio F de arestas e a cada passo adiciona uma aresta e a F garantindo que F {e} é um subconjunto de arestas de uma árvore geradora mínima de G. Esse é um algoritmo guloso (veja Capítulo 17) e sua característica gulosa é adicionar a F a aresta uv de menor custo possível tal que u V (G[F ]) e v / V (G[F ]). O algoritmo termina quando escolheu n 1 arestas. Veremos no Lema 21.1 que essa estratégia de fato nos permite gerar uma árvore geradora mínima para G. O algoritmo de Prim está formalizado no Algoritmo 62. Ele mantém um conjunto S que deverá ser igual a V (G) = V (G[F ]) ao fim e, a cada iteração, escolhe a aresta de menor custo no corte (S, V (G) \ S). Note que G[F ] é conexo durante toda a execução do algoritmo. Veja a Figura 21.2 para um exemplo de seu funcionamento. 215
224 a b d e f g h i c (a) Grafo G de entrada. O vértice c foi escolhido como inicial arbitrariamente. a b d f g h i c e (b) Primeira iteração: escolhidos = {c}; aresta de menor custo que liga um não escolhido a um escolhido = ce. a b d f g i c e h (c) Segunda iteração: escolhidos = {c, e}; aresta de menor custo que liga um não escolhido a um escolhido = eh. a b d g i c e h f (d) Terceira iteração: escolhidos = {c, e, h}; aresta de menor custo que liga um não escolhido a um escolhido = hf. a b d i c e h f g (e) Quarta iteração: escolhidos = {c, e, h, f}; aresta de menor custo que liga um não escolhido a um escolhido = fg. a b d c e h f g i (f) Quinta iteração: escolhidos = {c, e, h, f, g}; aresta de menor custo que liga um não escolhido a um escolhido = gi. a b c e h f g i d (g) Sexta iteração: escolhidos = {c, e, h, f, g, i}; aresta de menor custo que liga um não escolhido a um escolhido = gd. a c e h f g i d b (h) Sétima iteração: escolhidos = {c, e, h, f, g, i, d}; aresta de menor custo que liga um não escolhido a um escolhido = eb. c e h f g i d b a (i) Oitava iteração: escolhidos = {c, e, h, f, g, i, d, b}; aresta de menor custo que liga um não escolhido a um escolhido = ba. Figura 21.2: Exemplo de execução de Prim. 216
225 Algoritmo 62: Prim(G = (V, E), w) 1 Seja S = {s}, onde s V (G) é um vértice qualquer 2 Seja F = 3 enquanto S V (G) faça 4 Seja e = uv uma aresta de menor custo com u S e v / S 5 F = F {uv} 6 S = S {v} 7 retorna F Lema 21.1 Prim retorna F tal que G[F ] é árvore geradora mínima para qualquer grafo G = (V, E) conexo e função de custo w sobre as arestas. Demonstração. Note que o algoritmo termina: se esse não fosse o caso, haveria alguma iteração onde o corte (S, V (G) \ S) seria vazio e não haveria escolha para e, o que significaria que G não é conexo, uma contradição. Então no fim temos de fato S = V (G). Seja F a árvore devolvida ao fim da execução. Por construção, F é geradora pois todo vértice que é extremo de alguma aresta de F está em S. Note agora que F não tem ciclos: considere uma iteração onde e = uv é escolhida para ser adicionada a F. Neste momento, todas as arestas de F têm extremos em S, então e é a primeira aresta a cruzar (S, V (G) \ S) em G[F ] e, portanto, não participa de ciclos em G[F ], pelo resultado do Lema Resta mostrar que w(g[f ]) é mínimo. Note que cada aresta e F é a menor do corte (S, V (G) \ S) no momento de sua adição. Então, pelo Teorema 21.3, G[F ] é uma árvore geradora mínima. Note que a operação mais importante do Prim está na linha 4, que consiste em escolher a aresta de menor custo no corte (S, V (G) \ S) (as outras envolvem simples atualizações de conjuntos). Para implementar essa escolha, podemos simplesmente percorrer todas as arestas do grafo verificando seus extremos e armazenando a de menor custo, o que leva tempo Θ(m), onde m = E(G). Pela condição do laço enquanto, temos então que essa implementação leva tempo Θ(nm), onde n = V (G). É possível melhorar esse tempo de execução através do uso de uma estrutura de 217
226 dados apropriada para esse tipo de operação. Heap é uma estrutura que oferece a operação RemoveDaHeap(H), que remove o elemento de maior prioridade em tempo O(log k), onde k é a quantidade de elementos armazenados na estrutura. Veja mais sobre essa estrutura na Seção 8.1. Note que o algoritmo de Prim no fundo faz uma escolha por um novo vértice ainda não visitado. Dentre todos os vértices não visitados que possuem uma aresta que os conecta a vértices já visitados, escolhemos o que tenha a aresta de menor custo. Vamos utilizar um heap para armazenar vértices e o valor da prioridade de um vértice x será o custo da aresta de menor custo que conecta x a um vértice que não está mais na heap. Mais especificamente, nossa heap irá manter os vértices de V (G) \ S e, para cada x V (G) \ S, sua prioridade será o custo da aresta de menor custo xv onde v S. Se tal aresta não existir, a prioridade será. Note que tem mais prioridade o vértice que tem menor valor. Assim, é suficiente escolher o vértice removido da heap para adicionar a S. O Algoritmo 63 reapresenta o algoritmo de Prim utilizando explicitamente a estrutura heap. Assuma que V (G) = {1,..., V (G) } e que cada vértice x possui os atributos prioridade, para armazenar sua prioridade, indice, para indicar em que posição da heap x está, e predecessor, para indicar o vértice v S tal que a aresta xv é a de menor custo que conecta x a um elemento de S. Note que quando um vértice v é removido da heap (para ser inserido na árvore), algumas prioridades de alguns vértices mudam, pois o conjunto V (G) \ S muda. No entanto, é suficiente recalcular apenas as prioridades dos vértices que são adjacentes a v, pois é em v que saem as únicas arestas que não estavam no corte antes e agora estão. Assuma que a representação do grafo é dada por listas de adjacências. No que segue, temos n = V (G) e m = E(G). Inicialmente, temos S = {s}, de forma que em tempo O(m) conseguimos calcular os valores das prioridades dos vértices que estão em V (G) \ S (laço da linha 4) e com O(n log n) operações inserimos todos os vértices de V (G) \ S na heap. Claramente, são feitas O(n) remoções da heap, que levam tempo total O(n log n). O total de alterações feitas é O(m), já que essa operação é feita toda vez que um dos extremos de uma aresta vai ser adicionado a S, de forma que o tempo total gasto nessas operações é de O(m log n). Asim, o tempo total gasto no algoritmo é de O(m log n). 218
227 Algoritmo 63: PrimHeap(G = (V, E), w) 1 Seja S = {s}, onde s V (G) é um vértice qualquer 2 Seja F = 3 Seja H[1.. V (G) 1] um vetor vazio 4 para todo vértice v N(s) faça 5 v. prioridade = w(sv) 6 v. predecessor = s 7 InsereNaHeap(H, v) 8 para todo vértice v / N(s) faça 9 v. prioridade = 10 v. predecessor = null 11 InsereNaHeap(H, v) 12 enquanto H. tamanho > 0 faça 13 v = RemoveDaHeap(H) 14 para cada vx E(G) faça 15 se x. prioridade < w(vx) então 16 x. predecessor = v 17 AlteraHeap(H, x. indice, w(vx)) 18 Seja u = v. predecessor 19 F = F {uv} 20 S = S {v} 21 retorna F 219
228 220
229 Capítulo 22 Trilhas Eulerianas Uma trilha em um grafo G é uma sequência de vértices v 1,..., v k tal que v i v i+1 E(G) para todo 1 i k 1 e todas essas arestas são distintas (pode haver repetição de vértices). Uma trilha é dita fechada se tem comprimento não nulo e tem início e término no mesmo vértice. Se a trilha inicia em um vértice e termina em outro vértice, então dizemos que a trilha é aberta. Um clássico problema em Teoria dos Grafos é o de, dado um grafo conexo G, encontrar uma trilha que passa por todas as arestas de G. Uma trilha com essa propriedade é chamada de trilha Euleriana, em homenagem a Euler, que observou que propriedades um grafo deve ter para que contenha uma trilha Euleriana. O seguinte clássico teorema fornece uma condição necessária e suficiente para que existe uma trilha Euleriana fechada em um grafo conexo. Teorema 22.1 Um grafo conexo G contém uma trilha Euleriana fechada se e somente se todos os vértices de G têm grau par. O seguinte resultado trata de trilhas Eulerianas abertas. Teorema 22.2 Um grafo conexo G contém uma trilha Euleriana aberta se e somente se G contém exatamente dois vértices de grau ímpar.
230 A seguir veremos um algoritmo guloso que encontra uma trilha Euleriana fechada em grafos conexos em que todos os vértices têm grau par. Uma ponte em um grafo é uma aresta cuja remoção aumenta a quantidade de componentes do grafo. O algoritmo de Fleury, descrito no Algoritmo 64, começa uma trilha em um vértice arbitrário do grafo e segue por uma aresta evitando pontes sempre que possível. A cada aresta visita, essa aresta é removida do grafo e a trilha continua por uma aresta que, se possível, não seja ponte do grafo atual. Algoritmo 64: Fleury(G = (V, E)) 1 para todo vértice v V (G) faça 2 se d(v) é ímpar então 3 retorna Não existe trilha Euleriana em G 4 v = vértice qualquer de V (G) 5 cria vetor T [1.. E(G) ] 6 T [1] = v 7 i = 1 8 Seja G 1 = G 9 enquanto d Gi (T [i]) 1 faça 10 se existe aresta {T [i], w} para algum w V (G) que não seja ponte em G i então 11 T [i + 1] = w 12 senão 13 T [i + 1] = z, onde {T [i], z} é ponte de G i 14 i = i G i+1 = G i T [i]t [i + 1]} /* Removendo a aresta utilizada */ 16 retorna T Para encontrar uma trilha Euleriana aberta em um grafo G, caso tal trilha exista, basta executar o algoritmo de Fleury começando em um vértice de grau ímpar. Um ponto chave no algoritmo é como descobrir se uma dada aresta é uma ponte. Uma maneira simples de descobrir se uma aresta {u, v} é uma ponte em um grafo H é remover {u, v} e executar uma busca em profundidade começando de u em H. A aresta {u, v} é uma ponte se e somente se v não é alcançado na execução da busca em profundidade. Uma maneira mais eficiente é utilizar um algoritmo desenvolvido por Tarjan. 222
231 Claramente, o primeiro laço para faz com que o algoritmo retorne Não existe trilha Euleriana em G caso isso seja verdade (veja Teorema 22.1). O seguinte resultado vai ser útil na prova de corretude do algoritmo de Fleury. Teorema 22.3 Seja G um grafo onde d G (v) é par para todo v V (G). Então G não contém pontes. A seguir mostramos que o algoritmo de Fleury encontra uma trilha Euleriana fechada no caso de grafos onde todos os vértices têm grau par. Teorema 22.4 Seja G = (V, E) um grafo onde todos seus vértices têm grau par. Então o algoritmo Fleury(G) retorna uma trilha euleriana T de G. Demonstração. Seja T i a sequência de vértices T [1], T [2],..., T [i] construída pelo algoritmo. Inicialmente, observamos que no início da execução da i-ésima iteração do laço enquanto, T i é uma trilha. De fato, essa afirmação é trivialmente válida para i = 1. Ademais, considere o início da da i-ésima iteração do laço enquanto (início da linha 8) e suponha que T i 1 é uma trilha. Como o algoritmo chegou até este ponto de sua execução, sabemos que a (i 1)-ésima iteração do laço foi realizada com sucesso. Assim, d Gi 1 (T [i 1]) 1. Mas note que na (i 1)-ésima iteração o algoritmo adiciona um vizinho x de T [i 1] à trilha atual (veja linhas 10 e 12), e a aresta {xt [i]} não está contida em T i 1, pois sempre que uma aresta é adicionada a trilha atual ela é removida de E G (veja linha 13). Portanto, concluímos que no início da execução da i-ésima iteração do laço enquanto, T i é uma trilha. A seguir vamos utilizar o seguinte fato que pode ser provado facilmente: uma trilha T de um grafo G cujo vértice final tem grau par em T é uma trilha fechada. O algoritmo termina sua execução quando analisa um vértice T [i] sem vizinhos no grafo G i. Como ao fim da execução do algoritmo temos d Gi (T [i]) = 0 e todos os vértices do grafo inicial G têm grau par, sabemos que o vértice T [i] tem grau par na trilha T i. 223
232 Logo, T i é fechada. Em resumo, até o momento, sabemos que o algoritmo termina sua execução retornando uma trilha fechada T. Resta mostrar que T é Euleriana. Suponha por contradição que T não é Euleriana. Assim, existem arestas no grafo final H = (V G, E G \ E(T )). Seja V 1 os vértices v de H com d H (v) 1. Seja V 0 := V (G) \ V 1. Assim, para todo vértice v V 0 temos d H (v) = 0 (não confunda d H (v) com d G (v)). Como o grafo inicial G é conexo, em G existe pelo menos uma aresta entre V 0 e V 1. Assim, seja xy a última aresta da trilha T tal que x V 1 e y V 0. Esse fato juntamente com o fato do vértice final de T estar em V 0 (isso segue da condição do laço enquanto), sabemos que a aresta xy de T foi atravessada por T de x para y, i.e., x vem antes de y em T. Como xy é a última aresta entre V 0 e V 1 e a trilha T termina em um vértice de V 0, no momento em que v é adicionado em T, xy é uma ponte. Mas note que todo vértice v de V 1 tem grau par em H, pois todo vértice tem grau par em G e foram removidas somente as arestas da trilha fechada T. Assim, temos d H (v) 2 para todo v em V 1. Logo, pelo Teorema 22.3, não existem pontes em H. Portanto, quando o algoritmo escolheu a aresta xy, essa aresta não era ponte do grafo, uma contradição com a escolha do algoritmo. 224
233 Capítulo 23 Caminhos mínimos Dado um grafo ou digrafo G = (V, E) 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 de s a qualquer outro vértice de G alcançável a partir de s. Se as arestas do grafo não possuem pesos associados, então a busca em largura calcula o menor caminho possível entre s e os outros vértices, com relação à quantidade de arestas. 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 esses pesos. Dados um grafo G = (V, E) e uma função w : E(G) R de pesos, definimos o peso de um passeio P = (v 0, v 1,..., v k ) como a soma dos pesos das arestas em P, i.e., k 1 w(p ) = w(v i v i+1 ). i=0 Definimos a distância entre dois vértices u, v V (G), denotada por por dist w G(u, v), como sendo o peso de um uv-caminho de menor peso, isto é, min{w(p ): P é caminho de u a v}, se existe caminho de u a v, dist w G(u, v) =, caso contrário. Dizemos que um uv-caminho cujo peso é a distância entre u e v é um caminho mínimo. Omitiremos w e/ou G da notação quando eles forem claros pelo contexto. Existem basicamente duas variações de problemas de caminhos mínimos, definidas
234 nos problemas a seguir. Problema 23.1: Caminhos mínimos de única fonte Dados um grafo G = (V, E), uma função w de peso nas arestas e um vértice s V (G), calcular dist w G(s, v) para todo v V (G). Problema 23.2: Caminhos mínimos entre todos os pares Dados um grafo G = (V, E) e uma função w de peso nas arestas, calcular dist w G(u, v) para todo par u, v V (G). Antes de analisarmos algoritmos para tratar esses dois problemas, precisamos entender algumas tecnicalidades envolvendo ciclos. É fácil ver que nenhum passeio que possui ciclo com peso positivo pode ser um caminho mínimo. Se o passeio tem um ciclo com peso negativo, então percorrê-lo repetidamente iria sempre diminuir o peso do passeio. Assim, nos problemas de caminhos mínimos vamos assumir que se o grafo em questão possui ciclos negativos, então o algoritmo não resolverá o problema. Nas seções a seguir, sempre consideraremos que estamos lidando com um digrafo G = (V, E) e uma função w : E(G) R de pesos nas arestas, pois todo grafo não orientado pode ser visto como um digrafo onde uma aresta sempre aparece nas duas direções. Dessa forma, as descrições feitas são mais gerais De única fonte Problemas de caminhos mínimos de única fonte basicamente podem ser resolvidos por três algoritmos. De forma geral, se o grafo/digrafo em questão não possui pesos nas arestas ou se w(e) = 1 para toda aresta e, então o algoritmo de busca em largura pode ser utilizado. Se o grafo/digrafo possui apenas arestas com peso positivo, então o algoritmo de Dijkstra pode ser utilizado. Para quaisquer valores de pesos nas arestas, o algoritmo de Bellman-Ford pode ser utilizado. É importante saber quais as vantagens e desvantagens de cada um para fazer uma boa escolha. 226
235 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 utilizada no algoritmo de busca em largura, de modo que a estrutura do algoritmo de Dijkstra é bem semelhante à estrutura do algoritmo de busca em largura e do algoritmo de Prim (para 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 atualiza as informações sobre caminhos mínimos de s aos outros vértices, de acordo com as arestas exploradas até o momento. A cada iteração, o algoritmo garante que o peso de um caminho mínimo de s a algum vértice v é calculado corretamente. Tal vértice v é removido da fila de prioridades F, indicando que o caminho mínimo até ele já foi calculado. 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., um vértice v cujo peso do caminho mínimo a partir de s ainda não foi garantido pelo algoritmo). Em cada iteração, o vértice v escolhido será sempre aquele que tem o menor peso estimado em v. distancia pelo algoritmo no momento. Veremos que essa escolha garante que, no momento em que v é escolhido para sair de F, temos v. distancia = dist(s, v) (veja Teorema 23.2). O algoritmo também manterá atributos v. predecessor, 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. A Figura 23.1 contém um exemplo de execução do algoritmo de Dijkstra. 227
236 Algoritmo 65: Dijkstra(G = (V, E), w, s) 1 para todo vértice v V (G) faça 2 v. distancia = 3 v. predecessor = null 4 s. distancia = 0 5 cria fila de prioridades F com conjunto V (G) baseada em v. distancia 6 para i = 1 até V (G) faça 7 u = RemoveDaHeap(F ) 8 para todo vértice v N(u) em F faça 9 se v. distancia > u. distancia +w(uv) então 10 v. predecessor = u 11 v. distancia = u. distancia +w(uv) 12 AlteraHeap(F, v. indice, u. distancia +w(uv)) Figura 23.1: Execução do algoritmo de Dijkstra. Vértices se tornam vermelhos quando são removidos da fila de prioridades. Cada uma das quatro últimas ilustrações indica uma completa iteração do primeiro laço para. 228
237 Assim como o algoritmo de Prim, o algoritmo de Dijkstra toma, a cada passo, a decisão mais apropriada no momento. Mais precisamente, o algoritmo escolhe o vértice v F incidente à 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 8. Seja n = V (G) e m = E(G). Dado que o primeiro laço para é executado n vezes, o segundo laço para é executado N(v) vezes para cada v V (G), cada operação RemoveDaHeap(F ) é executada em tempo O(log n), e cada operação AlteraHeap(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, E), w, s) é O ( (m + n) log n ). O seguinte lema será usado na prova da corretude do algoritmo de Dijkstra. Lema 23.1 Sejam G = (V, E) um grafo, w uma função de pesos não negativos em E(G), e s V (G). Em qualquer ponto da execução de Dijkstra(G = (V, E), w, s), temos que v. distancia dist(s, v) para todo v V (G). O seguinte resultado mostra que o algoritmo de Dijkstra calcula corretamente os caminhos mínimos. Teorema 23.2 Ao final da execução de Dijkstra(G = (V, E), w, s) temos v. distancia = dist(s, v) para todo v V (G). Demonstração. Nessa prova consideramos uma execução de Dijkstra(G = (V, E), w, s). Inicialmente perceba que como a cada iteração do primeiro laço para um vértice é removido de F e nenhum vértice é adicionado a F (após a criação de F ), o algoritmo é 229
238 encerrado após V (G) iterações desse laço e a fila F é vazia. Precisamos mostrar que quando isso acontece, temos v. distancia = dist(s, v) para todo v V (G). Uma vez que o algoritmo nunca modifica o atributo v. distancia depois que v sai de F, basta provarmos que quando um vértice v é removido de F, temos v. distancia = dist(s, v) nesse momento. Suponha por contradição que existe um vértice u com u. distancia > dist(s, u) (23.1) no momento em que u saiu de F. Seja u o primeiro vértice com u. distancia > dist(s, u) a ser removido de F. Assim, para todo vértice v removido de F antes de u, temos v. distancia = dist(s, v). Analisaremos a situação do algoritmo no início da iteração do primeiro laço para que removeu u de F. Seja P um caminho mínimo de s a u e seja w o primeiro vértice de P que pertence a F. Ademais, seja v o vértice imediatamente antes de w em P. Note que a parte inicial de P que vai de s a w é um caminho mínimo de s a w, pois caso contrário P não seria um caminho mínimo de s a u. Pela escolha de u, temos v. distancia = dist(s, v). Como v já foi removido de F, nesse momento todas as arestas incidentes a v foram analisadas pelo algoritmo, incluindo a arestas vw. Mas ao analisar vw, o algoritmo atualiza a estimativa em w. distancia para v. distancia +w(v, w) (caso ainda não tenha esse valor). Portanto, temos w. distancia = v. distancia +w(v, w) = dist(s, v) + w(v, w) = dist(s, w). Como não existem arestas de peso negativo, dist(s, w) dist(s, u). Logo, w. distancia = dist(s, w) dist(s, u), (23.2) mas, no momento em que u é escolhido para ser removido de F, os vértices u e w ainda estão em F. Assim, pela linha 7, temos u. distancia w. distancia. Combinando esse fato com (23.2), temos u. distancia dist(s, u), uma contradição com (23.1). 230
239 Algoritmo de Bellman-Ford O algoritmo de Bellman-Ford resolve o problema de caminhos mínimos mesmo quando há arestas de peso negativo no grafo ou digrafo em questão. Mais ainda, quando existe um ciclo de peso total negativo, o algoritmo identifica a existência de tal ciclo. Dessa forma, é um algoritmo que funciona para mais instâncias que o algoritmo de Dijkstra. Por outro lado, como veremos a seguir, é menos eficiente que o algoritmo de Dijkstra. O algoritmo de Bellman-Ford recebe um grafo G = (V, E), uma função w de pesos nas arestas de G e um vértice s inicial. Assim como no algoritmo de Dijkstra, dado um vértice v, o atributo v. distancia sempre contém a menor distância de s a v conhecida pelo algoritmo. Porém, a forma como essas distâncias são atualizadas ocorre de forma bem diferente. O algoritmo vai tentar, em V (G) 1 iterações, melhorar a distância conhecida de s a todos os vértices v analisando todas as E(G) arestas de G em cada iteração. O algoritmo mantém atributos v. predecessor que permitem se obter um caminho mínimo de s a v. No final de sua execução, o algoritmo retorna verdadeiro se G não contém ciclos de peso negativo, e retorna falso caso exista algum ciclo de peso negativo em G. O algoritmo de Bellman-Ford é descrito formalmente no Algoritmo 66. Algoritmo 66: Bellman-Ford(G = (V, E), w, s) 1 para todo vértice v V (G) faça 2 v. distancia = 3 v. predecessor = null 4 s. distancia = 0 5 s. predecessor = s 6 para i = 1 até V (G) 1 faça 7 para toda aresta uv E(G) faça 8 se v. distancia > u. distancia +w(uv) então 9 v. predecessor = u 10 v. distancia = u. distancia +w(uv) 11 para toda aresta uv E(G) faça 12 se v. distancia > u. distancia +w(uv) então 13 retorna f also 14 retorna verdadeiro 231
240 Figura 23.2: Execução do algoritmo Bellman-Ford. A Figura 23.2 mostra um exemplo de execução do algoritmo Bellman-Ford. Antes de entendermos qual a razão do algoritmo de Bellman-Ford funcionar corretamente, vamos analisar seu tempo de execução. Seja n = V (G) e m = E(G) e considere que o grafo G está implementado utilizando uma lista de adjacências. Por causa do laço para na linha 1, as linhas 1 4 são executadas em tempo Θ(n). Já os laços aninhados nas linhas 5 e 6 fazem com que a linha 7 seja executada nm vezes (note que as linhas 8 e 9 são executadas no máximo nm vezes). Assim, o tempo gasto nas execuções das linhas 5 9 é Θ(nm). Por fim, o laço da linha 10 garante que o teste na linha 11 seja executado no máximo m vezes. Logo, o tempo gasto nas linhas é O(m). Portanto, o tempo de execução de Bellman-Ford(G = (V, E), w, s) é Θ(n) + Θ(nm) + O(m), que é igual a Θ(nm). Voltemos nossa atenção agora para a corretude do algoritmo. O lema abaixo é a peça chave para entender a razão pela qual o algoritmo funciona corretamente. Por simplicidade, vamos nos referir a execução das linhas 7 9 para uma aresta uv como relaxação da aresta uv, i.e., dizemos que a aresta uv é relaxada quando verificamos se v. distancia > u. distancia +w(uv), atualizando, em caso positivo, o valor de v. distancia para u. distancia +w(uv). 232
241 Lema 23.3 Seja G = (V, E) um grafo com uma função de pesos w em suas arestas e seja s V (G). Considere s. distancia = 0 e v. distancia = para todo vértice v V (G) \ {s}. Se P = (s, v 1, v 2,..., v k ) é um caminho mínimo de s a v k, então o seguinte vale. Se as arestas sv 1, v 1 v 2,..., v k 1 v k forem relaxadas nessa ordem, então temos v k. distancia = dist(s, v k ) após essas relaxações. Demonstração. Provaremos o resultado por indução na quantidade de arestas de um caminho mínimo P = (s, v 1, v 2,..., v k ). Se o comprimento do caminho é 0, i.e., não há arestas, então o caminho é formado somente pelo vértice s. Logo, tem distância 0. Para esse caso, o teorema é válido, dado que temos s. distancia = 0 = dist(s, s). Seja k 1 e suponha que para todo caminho mínimo com k 1 arestas o teorema é válido. Considere o caminho mínimo P = (s, v 1, v 2,..., v k ) de s a v k com k arestas e suponha que as arestas sv 1, v 1 v 2,..., v k 1 v k foram relaxadas nessa ordem. Note que como P = (s, v 1, v 2,..., v k 1 ) é um caminho dentro de um caminho mínimo, então P também é um caminho mínimo. Assim, como as arestas de P, a saber sv 1, v 1 v 2,..., v k 2 v k 1, foram relaxadas na ordem do caminho e P tem k 1 arestas, concluímos por hipótese de indução que v k 1. distancia = dist(s, v k 1 ). Caso v k. distancia = dist(s, v k ), então a prova está concluída. Assim, podemos assumir que v k. distancia > dist(s, v k ) = dist(s, v k 1 ) + w(v k 1 v k ). Logo, ao relaxar a aresta v k 1 v k, o algoritmo vai verificar que v k. distancia > dist(s, v k ) = dist(s, v k 1 ) + w(v k 1 v k ), atualizando o valor de v k. distancia como abaixo. v k. distancia = v k 1. distancia +w(v k 1 v k ) = dist(s, v k 1 ) + w(v k 1 v k ) = dist(s, v k ). Com isso, a prova está concluída. Note que, no Lema 23.3, não importa que arestas tenham sido relaxadas entre quaisquer das relaxações sv 1, v 1 v 2,..., v k 1 v k. O Lema 23.3 garante que se as arestas 233
242 Figura 23.3: Ordem de relaxação das arestas de um caminho mínimo de s a v. de um caminho mínimo de s a v forem relaxadas na ordem correta, então o algoritmo de Bellman-Ford calcula corretamente o valor de um caminho mínimo de s a v. Mas como o algoritmo de Bellman-Ford garante isso para todo vértice v V (G)? A chave é notar que todo caminho tem no máximo n 1 arestas, de modo que relaxando todas as arestas n 1 vezes, é garantido que qualquer que seja o caminho mínimo P = (s, v 1, v 2,..., v k ) de s a um vértice v k, as arestas desse caminho vão ser relaxadas na ordem correta. A Figura 23.3 mostra um exemplo ilustrando que as arestas de um caminho mínimo P sempre são relaxadas na ordem do caminho P. O Lema 23.4 abaixo torna a discussão acima precisa, mostrando que o algoritmo Bellman-Ford calcula corretamente os caminhos mínimos, dado que não exista ciclo de peso negativo. Lema 23.4 Seja G = (V, E) um grafo com uma função de pesos w em suas arestas e seja s V (G). Se G não contém ciclos de peso negativo, então após terminar a execução das linhas 5 9 de Bellman-Ford(G = (V, E), w, s) temos v. distancia = dist(s, v) para todo vértice v V (G). Demonstração. Seja G um grafo sem ciclos de peso negativo, e considere o momento após o término da execução das linhas 5 9 de Bellman-Ford(G = (V, E), w, s). Se v k não é alcançável a partir de s, então temos v. distancia = e não é difícil verificar 234
243 que o algoritmo nunca vai modificar o valor de v. distancia. Como não existem ciclos de peso negativo, sabemos que existe algum caminho mínimo de s a qualquer vértice alcançável a partir de s. Assim, seja P = (s, v 1, v 2,..., v k ) um caminho mínimo de s a um vértice arbitrário v k que pode ser alcançável a partir de s. Note que como P é um caminho mínimo, então P tem no máximo V (G) 1 arestas. Seja v 0 = s. Como a cada uma das V (G) 1 iterações do laço para na linha 5 todas as arestas do grafo são relaxadas, temos que certamente, para 1 i k, a aresta v i 1 v i é relaxada na iteração i. Assim, as arestas v 0 v 1, v 1 v 2,..., v k 1 v k são relaxadas nessa ordem. Pelo Lema 23.3, temos v k. distancia = dist(s, v k ). Assim, a prova do lema está concluída. Usando o Lema 23.4, podemos facilmente notar que o algoritmo identifica um ciclo de peso negativo. Corolário 23.5 Seja G = (V, E) um grafo com uma função de pesos w em suas arestas e seja s V (G). Se Bellman-Ford(G = (V, E), w, s) retorna falso, então G contém um ciclo de peso negativo. Demonstração. Se Bellman-Ford(G = (V, E), w, s) retorna falso, então após a execução das linhas 5 9, existe uma aresta uv tal que v. distancia > u. distancia +w(uv). Mas é fácil mostrar que a qualquer momento do algoritmo, se o valor em v. distancia é finito, então ele representa o peso de algum caminho entre s e v. Logo, como v. distancia > u. distancia +w(uv), sabemos que o peso em v. distancia é maior do que o peso de um caminho de s a v passando por u. Portanto, v. distancia > dist(s, v). Assim, usando a contrapositiva do Lema 23.4, concluímos que G contém um ciclo de peso negativo. Agora que sabemos que o algoritmo de Bellman-Ford funciona corretamente, vamos compará-lo com o algoritmo de Dijkstra, que também resolve o problema de caminhos mínimos de um vértice s para os outros vértices do grafo. Dado um grafo G com n vértices e m arestas, o algoritmo de Dijkstra é executado em tempo O((n + m) log n), que é assintoticamente mais eficiente que o algoritmo de Bellman-Ford sempre que m = Ω(log n), dado que o algoritmo de Bellman-Ford leva tempo Θ(mn) para ser 235
244 executado. Porém, o algoritmo de Bellman-Ford funciona em grafos que contém arestas de peso negativo, diferentemente do algoritmo de Dijkstra. Por fim, observamos que o algoritmo de Bellman-Ford também tem a capacidade de identificar a existência de ciclos negativos no grafo Entre todos os pares Considere agora o problema de encontrar caminhos mínimos entre todos os pares de vértices de um grafo ou digrafo G = (V, E) com n vértices e m arestas e pesos nas arestas (Problema 23.2). Certamente uma opção simples para resolver esse problema seria utilizar soluções para o problema de caminhos mínimos de única fonte. Assim, podemos executar Dijkstra ou Bellman-Ford n vezes, passando cada um dos vértices v em V (G) como vértice inicial desses algoritmos. Dessa forma, a cada uma das n execuções de Dijkstra ou Bellman-Ford, encontramos caminhos mínimos do vértice v a todos os outros vértices de G. Note que, como o tempo de execução de Dijkstra(G = (V, E), w, s) é O ( (m + n) log n ), então ao executar Dijkstra n vezes, teríamos um tempo de execução total de O ( (mn + n 2 ) log n ). Ressaltamos que, caso a fila de prioridades utilizada no algoritmo de Dijkstra seja implementada com um heap de Fibonacci, o tempo de execução total é da ordem de O ( n 2 log n + nm ). (23.3) Para grafos densos (i.e., grafos com Θ(n 2 ) arestas), esse valor representa um tempo de execução da ordem de O ( n 3). Porém, se existirem arestas de peso negativo em G, então o algoritmo de Dijkstra não funciona. Se em vez de Dijkstra executarmos o algoritmo de Bellman-Ford n vezes, teríamos um tempo de execução total de Θ(n 2 m), o que no caso de grafos densos é da ordem de Θ(n 4 ). Nase seções a seguir veremos dois algoritmos específicos para o problema de caminhos mínimos entre todos os pares. Um deles é o algoritmo de Floyd-Warshall, mais conhecido, 236
245 Figura 23.4: Considere que o grafo da figura é apenas uma parte do grafo de entrada. Seja i = 17, j = 10 e k = 5. Aqui só existe um ij-caminho que só possui vértices em V 5, que é (17, 2, 3, 10). Existe, no entanto, outro ij-caminho, inclusive de custo menor: (17, 7, 10). que executa em tempo Θ(n 3 ) independente do grafo ser denso ou não, e aceita custos negativos nas arestas. O outro é o algoritmo de Johnson, que também aceita pesos negativos e combina execuções de Bellman-Ford e Dijkstra, executando em tempo Θ(nm log n) Algoritmo de Floyd-Warshall O algoritmo de Floyd-Warshall é um algoritmo de programação dinâmica (veja Capítulo 18) que encontra caminhos mínimos entre todos os pares de vértices de um grafo ou digrafo G em tempo Θ(n 3 ). Ele usa o fato de que um uv-caminho mínimo que passa por vértices x e y contém um subcaminho entre x e y que é um xy-caminho mínimo. No que segue, considere V (G) = {1, 2,..., n}. Seja P um ij-caminho mínimo cujos vértices internos estão contidos em {1, 2,..., k}, para algum k {1, 2,..., n}. Note que temos duas possibilidades: 1. se k não é um vértice interno de P, então na verdade P é um ij-caminho mínimo cujos vértices internos estão contidos em {1, 2,..., k 1}; 2. se k é um vértice interno de P, então P = (i,..., x, k, y,..., j) e ele pode ser dividido em dois subcaminhos P 1 = (i,..., x, k) e P 2 = (k, y,..., j) onde P 1 é um ik-caminho mínimo que só tem vértices internos em {1, 2,..., k 1} e P 2 é um kj-caminho mínimo que só tem vértices internos em {1, 2,..., k 1} também. Veja a Figura 23.4 para um exemplo simples dessa discussão. 237
246 Assim, conseguimos definir uma estrutura recursiva para resolver esse problema. Seja D i,j,k o custo de um ij-caminho mínimo que só tem vértices internos em {1, 2,..., k}. Claramente, se k > 0, temos D i,j,k = min{d i,j,k 1, D i,k,k 1 + D k,j,k 1 }. (23.4) Quando k = 0, significa que o caminho não tem vértices internos, logo 0 se i = j D i,j,0 = w(ij) se ij E e i j se i j e ij / E(G) Nossa intenção é, portanto, calcular D i,j,n para todo par i, j V (G).. (23.5) A ideia do algoritmo de Floyd-Warshall é manter uma matriz W de dimensões (n + 1) (n + 1) (n + 1) onde W [i][j][k] = D i,j,k. Como cada vértice pode participar de vários caminhos mínimos, armazenar um único vértice no atributo predecessor de cada vértice não nos ajudará a descrever os caminhos mínimos de fato ao fim da execução. Assim, consideraremos o atributo predecessor de cada vértice como sendo um vetor de tamanho n tal que j. predecessor[i] armazenará o vértice predecessor de j em um ij-caminho mínimo. O Algoritmo 67 formaliza essas ideias. Ele está escrito na abordagem bottom-up de programação dinâmica. Veja que devido à ordem em que os laços são executados, a terceira dimensão da matriz W é um tanto desperdiçada: para calcular algo na k-ésima posição, usamos apenas o que está na (k 1)-ésima posição. Assim, é possível utilizar apenas uma matriz bidimensional para obter o mesmo resultado. O Algoritmo 68 formaliza essa ideia. Por causa dos três laços aninhados, independente da economia de espaço ou não, claramente o tempo de execução de Floyd-Warshall(G, w) é Θ(n 3 ), o que é bem melhor que o tempo Θ(n 4 ) gasto em n execuções do algoritmo de Bellman-Ford. Porém, note que para grafos esparsos (i.e., com m = O(n) arestas), a opção mais eficiente assintoticamente é executar o algoritmo de Dijkstra repetidamente, gastando tempo total o(n 3 ) (veja (23.3)). Mas, novamente, temos o empecilho de que o algoritmo de Dijkstra é correto somente para grafos sem arestas de peso negativo. Perceba que em nenhum momento o algoritmo de Floyd-Warshall falha se o grafo de 238
247 Algoritmo 67: Floyd-Warshall(G = (V, E), w) 1 Seja W [0..n][0..n][0..n] uma matriz 2 para i = 1 até n faça 3 para j = 1 até n faça 4 se i == j então 5 W [i][j][0] = 0 6 j. predecessor[i] = i 7 senão se ij E(G) então 8 W [i][j][0] = w(ij) 9 j. predecessor[i] = i 10 senão 11 W [i][j][0] = 12 j. predecessor[i] = null 13 para k = 1 até n faça 14 para i = 1 até n faça 15 para j = 1 até n faça 16 se W [i][j][k 1] < W [i][k][k 1] + W [k][j][k 1] então 17 W [i][j][k] = W [i][j][k 1] 18 senão 19 W [i][j][k] = W [i][k][k 1] + W [k][j][k 1] 20 j. predecessor[i] = j. predecessor[k] 21 retorna W 239
248 Algoritmo 68: Floyd-Warshall(G = (V, E), w) 1 Seja W [0..n][0..n] uma matriz 2 para i = 1 até n faça 3 para j = 1 até n faça 4 se i == j então 5 W [i][j] = 0 6 j. predecessor[i] = i 7 senão se ij E(G) então 8 W [i][j] = w(ij) 9 j. predecessor[i] = i 10 senão 11 W [i][j] = 12 j. predecessor[i] = null 13 para k = 1 até n faça 14 para i = 1 até n faça 15 para j = 1 até n faça 16 se W [i][j] > W [i][k] + W [k][j] então 17 W [i][j] = W [i][k] + W [k][j] 18 j. predecessor[i] = j. predecessor[k] 19 retorna W 240
249 entrada possuir um ciclo negativo. De fato, ele executa, porém não corretamente. Como saber se o grafo possui um ciclo negativo para poder executar Floyd-Warshall e ter certeza de que o resultado está correto? A boa notícia é que podemos usar o próprio Floyd-Warshall. A matriz W devolvida por ele pode ser utilizada para verificar se o grafo possui ciclo negativo ou não, isto é, para verificar se o problema de caminhos mínimos entre todos os pares pode ser resolvido em G ou não. Veja o Algoritmo 69. Algoritmo 69: ResolveCaminhosEntreTodosPares(G = (V, E), w) 1 W = Floyd-Warshall(G, w) para i = 1 até V (G) faça 2 se W [i][i] < 0 então 3 retorna null 4 retorna W O Algoritmo 70 mostra como construir um caminho mínimo entre dois vértices quaisquer após a execução correta de ResolveCaminhosEntreTodosPares: se l é o predecessor de j em um ij-caminho, basta construir o il-caminho e depois acrescentar a aresta lj. Algoritmo 70: ConstroiCaminho(i, j) 1 se j. predecessor[i] i então 2 ConstroiCaminho(i, j. predecessor[i]) 3 Coloque j no caminho Algoritmo de Johnson O algoritmo de Johnson faz uso de um truque para converter um grafo G = (V, E) com função de pesos w : E(G) R em um novo grafo G = (V, E) que contém somente um vértice a mais que G e suas arestas têm pesos de acordo com uma função de pesos não negativos w : E(G ) R 0. O algoritmo de Johnson adiciona um vértice s a V (G) e todas as arestas sv, para todo v V (G). Todas as novas arestas tem peso 0, i.e., faça w(sv) = 0 para todo v V (G). Feito isso, executamos Bellman-Ford(G, w, s) para obter o peso de um caminho mínimo, dist w G(s, v) entre s e todo vértice v V (G). Agora vem um passo 241
250 importantíssimo, que é transformar os pesos da função w em pesos não negativos, formando a função w. O novo peso de cada aresta uv será dado por w (uv) = ( dist w G(s, u) + w(uv) ) dist w G(s, v). (23.6) Note que dada uma aresta uv, sempre temos dist w G(s, u)+w(uv) dist w G(s, v). Portanto, a função w é composta por pesos não negativos. Podemos aplicar Dijkstra(G, w, x) n vezes, uma para cada x V (G), calculando os caminhos mínimos de u a v no grafo G com função de pesos w para todo par de vértices u, v. Não é difícil mostrar que dado um caminho P = (v 1,..., v k ) de u a v em G é um caminho mínimo com função w se e somente se P é um caminho mínimo com a função w. Para calcular o valor dos caminhos mínimos em G com a função de pesos original w basta fazer, para cada par uv, dist w G(u, v) = dist w G (u, v) + dist w G(s, v) dist w G(s, u). O seguinte fato garante que a igualdade acima coloca o peso correto em dist w G(u, v): seja P = (u = v 1,..., v k = v) um caminho mínimo de u a v com função w. Assim, utilizando (23.6), obtemos dist w G (u, v) = w (v 1 v 2 ) + + w (v k 1 v k ) = w(v 1 v 2 ) + + w(v k 1 v k ) + dist w G(s, v 1 ) + dist w G(s, v 2 ) + + dist w G(s, v k 1 ) dist w G(s, v 2 ) dist w G(s, v k 1 ) dist w G(s, v k ) = w(v 1 v 2 ) + + w(v k 1 v k ) + dist w G(s, u) dist w G(s, v) = dist w G(u, v) + dist w G(s, u) dist w G(s, v). Portanto, de fato temos dist w G(u, v) = dist w G (u, v) + dist w G(s, v) dist w G(s, u). Abaixo temos o algoritmo de Johnson, que, caso não exista ciclo de peso negativo no grafo, retorna uma matriz D com n linhas e n colunas tal que D[i][j] contém o peso de um caminho mínimo de v i a v j. Note que o tempo de execução de Johnson(G = (V, E), w) é o mesmo de n execuções de Dijkstra. De fato, a linha 11, que é executada para cada vértice do 242
251 Algoritmo 71: Johnson(G = (V, E), w) 1 Crie grafo G = (V, E), onde V (G ) = V (G) {s} e E(G ) = E(G) {sv : v V G } 2 Estenda a função w fazendo w(s, v) = 0 para todo v V (G) 3 Crie uma matriz D[1..n][1..n] 4 se Bellman-Ford(G, w, s) == falso então 5 retorna O grafo G contém ciclo de peso negativo 6 Crie vetor A[1..n] 7 para todo vértice u V (G) faça 8 Execute Bellman-Ford(G, w, s) para fazer u. distancia s = dist w G(s, u) 9 para toda aresta uv E(G ) faça 10 w (uv) = u. distancia s +w(uv) v. distancia s 11 para todo vértice u V (G) faça 12 Execute Dijkstra(G, w, u) para fazer v. distancia = dist w G (u, v) v V (G) 13 para todo vértice v V (G) faça 14 D[u][v] = v. distancia +v. distancia s u. distancia s 15 retorna D 243
252 grafo, é o que determina o tempo de execução de Johnson. 244
253 Parte VI Teoria da computação Os problemas computacionais vêm em diferentes variedades: alguns são fáceis e outros, difíceis. Por exemplo, o problema da ordenação é fácil. (...) Digamos que você tenha que encontrar um escalonamento de aulas para a universidade inteira que satisfaça algumas restrições razoáveis (...). Se você tem somente mil aulas, encontrar o melhor escalonamento pode requerer séculos (...). O que faz alguns problemas computacionalmente difíceis e outros fáceis? Michael Sipser Introdução à Teoria da Computação, 2006.
254
255 Nesta parte
256 248
257 Capítulo 24 Complexidade computacional Um algoritmo é dito eficiente se seu tempo de execução é O(n k ), onde n é o tamanho da entrada do algoritmo e k é um inteiro positivo que não depende de n. Todos os problemas que vamos tratar nesta seção são problemas de decisão, que definimos abaixo. Definição 24.1 Um problema de decisão é um problema cuja solução é uma resposta sim ou não. Por exemplo, decidir se um número é par é um problema de decisão. Outro problema de decisão é decidir se existe um caminho entre dois vértices de um grafo. Um problema que não é problema de decisão é exibir um caminho mínimo entre dois vértices de um grafo. No que segue vamos classificar problemas de decisão e discutir as relações entre essas classes de problemas. As principais classes de problemas são P, NP e co-np. Mas antes precisamos de algumas definições relacionadas à verificação de soluções para problemas Classes P, NP e co-np Considere o problema Clique-k abaixo.
258 Problema 24.1: Clique-k Dados um grafo G e um inteiro positivo k, o problema Clique-k(G, k) consiste em determinar se G contém um subgrafo isomorfo a um grafo completo com pelo menos k vértices. Nesse problema, a resposta é sim caso exista o grafo completo e não caso contrário. Note que, se de alguma forma recebermos um subgrafo completo H de G com k vértices, é fácil escrever um algoritmo Alg eficiente para verificar se H é realmente um grafo completo: basta verificar se todos seus pares de vértices formam arestas. Nesse caso, dizemos que H é um certificado positivo para Clique-k(G,k), e o algoritmo Alg é um verificador que aceita o certificado positivo H. Um grafo é bipartido se é possível particionar seu conjunto de vértices em duas partes tal que todas as arestas do grafo estão entre essas partes. Considere agora o problema Bipartido(G) que consiste em determinar se um grafo G é bipartido. Nesse problema, a resposta é sim caso G seja bipartido e não caso contrário. Um clássico resultado da Teoria dos Grafos afirma que um grafo é bipartido se e somente se não contém um ciclo com uma quantidade ímpar de vértices. Note que uma partição dos vértices do grafo em duas partes tal que todas as arestas estão entre as partes é um verificador positivo para Bipartido(G) e é fácil escrever um verificador para esse certificado. Mas observe também que um ciclo ímpar C é o que chamamos de certificado negativo, que é um conjunto de dados tal que existe um algoritmo eficiente que verifica que a resposta de Bipartido(G) é não. Tal algoritmo é um verificador que aceita o certificado negativo C. Definição 24.2: Certificado positivo Um certificado positivo para um problema de decisão P e uma instância I é um conjunto de dados D tal que existe um algoritmo eficiente que recebe D e verifica se a resposta de P para a instância I é sim. Tal algoritmo é um verificador que aceita o certificado positivo D. 250
259 Definição 24.3: Certificado negativo Um certificado negativo para um problema de decisão P e uma instância I é um conjunto de dados D tal que existe um algoritmo eficiente que recebe D e verifica se a resposta de P para a instância I é não. Tal algoritmo é um verificador que aceita o certificado negativo D. Agora estamos prontos para definir as classes P, NP e co-np. Definição 24.4: Classe P P é a classe dos problemas de decisão que podem ser resolvidos por um algoritmo eficiente. Portanto, sabemos que o problema de determinar se existe um caminho entre dois vértices de um grafo está na classe P, pois, por exemplo, os algoritmos de busca em largura e profundidade são algoritmos eficientes que resolvem este problema. Outro exemplo de problema na classe P é o problema de decidir se um grafo possui uma árvore geradora de peso total menor que k. Pois se executarmos, por exemplo, o algoritmo de Prim e verificarmos se uma árvore geradora mínima tem peso menor que k então a resposta para o problema é sim, caso contrário a resposta é não. Portanto, todos os problemas para os quais conhecemos um algoritmo eficiente que o resolva estão na classe P. Para definir as classes NP e co-np precisamos usar os conceitos de verificadores e certificados positivos e negativos. Definição 24.5: Classe NP NP é a classe dos problemas de decisão em que existe um verificador que aceita um certificado positivo. A definição da classe co-np é similar à da classe NP. 251
260 Definição 24.6: Classe co-np co-np é a classe dos problemas de decisão em que existe um verificador que aceita um certificado negativo. Como discutido anteriormente, existe um verificador que aceita um certificado positivo para o problema Clique-k(G, k). Assim, Clique-k(G, k) está em NP. Também mencionamos que existem verificadores que aceitam certificados positivos e negativos para Bipartido(G), que garante que Bipartido(G) está em NP e em co-np. Na verdade, todo problema da classe P está em NP e em co-np. Isso se dá pelo fato de que um algoritmo eficiente que resolve o problema é um verificador que aceita certificados positivos e negativos, onde os certificados são a própria entrada do algoritmo, pois o algoritmo recebe a entrada e verifica se a resposta do problema é sim ou não em tempo polinomial. Portanto, temos o seguinte resultado. Teorema 24.7 Vale que P NP e P co-np. Uma questão natural (e muito importante!) é saber se é verdade que NP P. Porém, essa questão continua em aberto até os dias atuais. Dada sua importância, esse problema é um dos Problemas do Milênio e o Clay Institute oferece um prêmio monetário de $ , NP-completude Muitas vezes é possível resolver um problema de decisão P utilizando para isso um problema de decisão Q que sabemos resolver. Para isso, precisamos converter a entrada E 1 de P para uma entrada de E 2 Q de modo que a resposta de E 2 em Q é sim se e somente se a resposta para E 1 em P é sim. Dessa forma, se sabemos resolver Q, então automaticamente obtemos a resposta para P. A definição abaixo torna essa ideia precisa. 252
261 Definição 24.1: Redução polinomial Sejam P e Q problemas de decisão. O problema P é redutível a Q se existe um algoritmo eficiente que converte uma entrada E 1 para P em uma entrada E 2 para Q de modo que a resposta para P com entrada E 1 é sim se e somente se a resposta para Q com entrada E 2 é sim. Escrevemos P Q para denotar que P é redutível a Q. Dadas variáveis booleanas x 1,..., x n, i.e., que só recebem valores 0 ou 1, e uma fórmula composta por conjunções (operadores e) de conjuntos de disjunções (operadores ou) das variáveis dadas e suas negações. Exemplos dessas fórmulas são (x 1 x 2 x 3 x 4 ) (x 1 x 2 ) e (x 1 x 2 x 3 ) (x 1 x 2 x 4 x 5 ) (x 4 x 5 x 6 ). Cada conjunto de disjunções é chamado de cláusula e um literal é uma variável x ou sua negação x. Uma fórmula booleana composta por conjunções de cláusulas que contém exatamente 3 literais é chamada de 3-CNF. Por exemplo, as fórmulas abaixo são 3-CNF. (x 1 x 2 x 3 ) (x 1 x 2 x 4 ) e (x 1 x 2 x 3 ) (x 1 x 2 x 4 ) (x 4 x 5 x 6 ). Considere o seguinte problema conhecido como 3-satisfabilidade ou 3-sat. Problema 24.2: 3-SAT Dada uma fórmula 3-CNF φ contendo literais de variáveis booleanas x 1,..., x n, o problema 3-Sat(φ) consiste em decidir se existe uma atribuição de valores a x 1,..., x n tal que φ é satisfatível, i.e., φ tem valor 1. O resultado abaixo mostra que 3-Sat Clique-k, i.e., existe uma redução polinomial de 3-Sat para Clique-k, ou ainda, 3-Sat é redutível a Clique-k. Teorema Sat Clique-k. 253
262 Demonstração. Precisamos exibir um algoritmo eficiente que converte uma 3-CNF φ em um grafo G tal que φ é satisfatível se e somente se G contém um grafo completo com k vértices. O grafo G que construiremos possui 3k vértices, de modo que cada uma das k cláusulas tem 3 vértices representando cada um de seus literais. Um par de vértices v e w de G forma uma aresta se e somente se v e w estão em cláusulas diferentes, v corresponde a um literal x, e w não corresponde ao literal x. Veja Figura 24.1 para um exemplo de construção de G. Figura 24.1: Construção de um grafo G dada uma instância de 3-Sat. O próximo passo é verificar que φ é satisfatível se e somente se G contém um grafo completo com k vértices. Para mostrar um lado dessa implicação note que se φ é satisfatível, então em cada uma das k cláusulas existe um literal com valor 1. Como 254
263 um literal e sua negação não podem ter valor 1, sabemos que em todo par {x, y} desses k literais temos x y. Portanto, existe uma aresta entre quaisquer dois vértices representando esses literais em G, de modo que formam um grafo completo com k vértices dentro de G. Para verificar a volta da implicação, suponha que G contém um grafo completo H com k vértices. Assim, como existe uma aresta entre quaisquer dois vértices de H, sabemos que qualquer par de vértices de H representa dois literais que não são a negação um do outro e estão em diferentes cláusulas. Logo, φ é satisfatível. A definição abaixo descreve quando um problema está na classe dos problemas NP-completos. Definição 24.4: NP-completude Um problema de decisão R é NP-completo se R NP e todo problema Q NP é redutível a R, i.e., Q R. Portanto, uma solução eficiente de um problema NP-completo resolve todos os problemas da classe NP. De fato, isso segue direto da definição de redução polinomial e da definição de NP-completude. A forma mais utilizada para mostrar que um problema R é NP-completo é reduzindo um problema Q que é NP-completo a R. Porém, para que essa estratégia funcione, é necessário um ponto de partida, i.e., é necessário que exista uma prova de que algum problema é NP-completo que não necessite de outro problema NP-completo. Esse ponto de partida é o problema 3-Sat. Foi provado por Cook e Levin que 3-Sat é NP-completo. Assim, note que o Teorema 24.3 prova o seguinte resultado. Teorema 24.5 Clique-k é NP-completo. Note que para mostrar que NP P, é suficiente provar que existe um algoritmo eficiente que resolve um problema NP-completo Q, pois como todo problema da classe NP é redutível a Q, teríamos um algoritmo eficiente para resolver todos os problemas de NP. 255
Análise de Algoritmos e Estruturas de Dados
Análise de Algoritmos e Estruturas de Dados Guilherme Oliveira Mota CMCC - Universidade Federal do ABC [email protected] 21 de julho de 2018 Esta versão é um rascunho ainda em elaboração e não foi revisado
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
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
Projeto e Análise de Algoritmos
Projeto e Algoritmos Pontifícia Universidade Católica de Minas Gerais [email protected] 26 de Maio de 2017 Sumário A complexidade no desempenho de Quando utilizamos uma máquina boa, ela tende a ter
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
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
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
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
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
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
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
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
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.
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
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
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
É 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
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
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
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
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
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 [email protected], [email protected] Grafos e Algoritmos Preparado a partir do texto: Rangel, Socorro.
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 [email protected]
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
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
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
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
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
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
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
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
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
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
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
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
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
ALGORITMOS AVANÇADOS UNIDADE I Análise de Algoritmo - Notação O. Luiz Leão
Luiz Leão [email protected] 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
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
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
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
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);
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.
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
Referências e materiais complementares desse tópico
Notas de aula: Análise de Algoritmos Centro de Matemática, Computação e Cognição Universidade Federal do ABC Profa. Carla Negri Lintzmayer Conceitos matemáticos e técnicas de prova (Última atualização:
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
Pra início de conversa... O que é um algoritmo? Exemplos de algoritmos. Como podemos descrever algoritmos? Como podemos descrever algoritmos?
Pra início de conversa... O que é um algoritmo? Como podemos descrever algoritmos? avaliar algoritmos? Introdução à Análise de Algoritmos Prof. Cláudio E. C. Campelo http://claudiocampelo.com Derivado
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
Algoritmos e Estruturas de Dados I Prof. Tiago Eugenio de Melo
Algoritmos e Estruturas de Dados I Prof. Tiago Eugenio de Melo [email protected] www.tiagodemelo.info Observações O conteúdo dessa aula é parcialmente proveniente do Capítulo 11 do livro Fundamentals of
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/ [email protected]
Prova Didática Grafos: Árvores Geradoras e Caminhos Mínimos, Análise de Complexidade
Prova Didática Grafos: Árvores Geradoras e Caminhos Mínimos, Análise de Complexidade Gustavo E.A.P.A. Batista 25 de janeiro de 2005 1 Contextualização 2 Caminhos Mínimos Caminhos Mínimos de uma Origem
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
Algoritmos de Ordenação. Cota inferior. Professora: Fátima L. S. Nunes SISTEMAS DE INFORMAÇÃO
Algoritmos de Ordenação Cota inferior Professora: Fátima L. S. Nunes 1 1 1 Algoritmos de Ordenação Algoritmos de ordenação que já conhecemos: 2 2 2 Algoritmos de Ordenação Algoritmos de ordenação que já
Projeto e Análise de Algoritmos Análise de Complexidade. Prof. Luiz Chaimowicz
Projeto e Análise de Algoritmos Análise de Complexidade Prof. Luiz Chaimowicz AGENDA Modulo 1 Data Assunto Capítulos 05/03 Algoritmos / Invariantes / Intro Análise de Complexidade 07/03 Não Haverá Aula
Universidade Estadual de Mato Grosso do Sul Bacharelado em Ciência da Computação Algoritmos e Estruturas de Dados II Prof. Fabrício Sérgio de Paula
Universidade Estadual de Mato Grosso do Sul Bacharelado em Ciência da Computação Algoritmos e Estruturas de Dados II Prof. Fabrício Sérgio de Paula Tópicos Introdução Ordenação por bolha (bubble sort)
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)
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.
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
Complexidade Assintótica
Complexidade Assintótica 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 [email protected] 08/2008 Material baseado
Algoritmos de Ordenação
Algoritmos de Ordenação! Problema: encontrar um número de telefone em uma lista telefônica! simplificado pelo fato dos nomes estarem em ordem alfabética! e se estivesse sem uma ordem?! Problema: busca
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 16 de agosto de 2018 Marco Antonio
Lista de exercícios sobre contagem de operações Prof. João B. Oliveira
Lista de exercícios sobre contagem de operações Prof. João B. Oliveira 1. metodo m ( Vetor V ) int i, res = 0; para i de 1 a V.size res = res + V[i]; return res; Soma de elementos de um vetor, O( ). 2.
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
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
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
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
Classes, Herança e Interfaces
Escola de Artes, Ciências e Humanidades EACH-USP ACH2002 Introdução à Ciência da Computação II Professor: Delano Medeiros Beder revisada pelo professor: Luciano Digiampietri EACH Segundo Semestre de 2011
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
1 a Lista de Exercícios
Universidade Federal de Ouro Preto Instituto de Ciências Exatas e Biológicas Programa de Pós-Graduação em Ciência da Computação Projeto e Análise de Algoritmos - 1 o semestre de 2010 Professor: David Menotti
CES-11. Noções de complexidade de algoritmos. Complexidade de algoritmos. Avaliação do tempo de execução. Razão de crescimento desse tempo.
CES-11 Noções de complexidade de algoritmos Complexidade de algoritmos Avaliação do tempo de execução Razão de crescimento desse tempo Notação O Exercícios COMPLEXIDADE DE ALGORITMOS Importância de análise
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
PLANO DE DISCIPLINA DISCIPLINA: Análise de Algoritmos
UNIVERSIDADE FEDERAL DE UBERLÂNDIA FACULDADE DE COMPUTAÇÃO BACHARELADO EM CIÊNCIA DA COMPUTAÇÃO PLANO DE DISCIPLINA DISCIPLINA: Análise de Algoritmos ( X ) SEMESTRAL - ( ) ANUAL CÓDIGO: GBC052 PERÍODO:
Projeto e Análise de Algoritmos
Projeto e Análise de Algoritmos Apresentação da Disciplina Edirlei Soares de Lima Por que Estudar Algoritmos? Razões Práticas e Teóricas: Devemos conhecer um conjunto de algoritmos
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.
Teoria da Computação. Aula 3 Comportamento Assintótico 5COP096. Aula 3 Prof. Dr. Sylvio Barbon Junior. Sylvio Barbon Jr
5COP096 Teoria da Computação Aula 3 Prof. Dr. Sylvio Barbon Junior 1 Sumário 1) Exercícios Medida de Tempo de Execução. 2) Comportamento Assintótico de Funções. 3) Exercícios sobre Comportamento Assintótico
Algoritmos de Ordenação: QuickSort
Algoritmos de Ordenação: QuickSort 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 [email protected] 10/2008 Material
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
1 a Lista Professor: Claudio Fabiano Motta Toledo Estagiário PAE: Jesimar da Silva Arantes
SSC0503 - Introdução à Ciência de Computação II 1 a Lista Professor: Claudio Fabiano Motta Toledo ([email protected]) Estagiário PAE: Jesimar da Silva Arantes ([email protected]) 1. O que significa
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/ [email protected]
Análise de Algoritmos Parte 4
Análise de Algoritmos Parte 4 Túlio Toffolo [email protected] 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)
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
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: [email protected]
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
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
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:
