Você está na página 1de 277

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

26 de junho de 2019– Esta versão é um rascunho ainda em elaboração e não foi revisado.
ii
Sumário

I Introdução à análise de algoritmos 1

1 Corretude e tempo de execução 5


1.1 Algoritmos de busca em vetores . . . . . . . . . . . . . . . . . . . . . . 6
1.1.1 Corretude de algoritmos (utilizando invariante de laços) . . . . . 8
1.2 Tempo de execução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
1.2.1 Análise de melhor caso, pior caso e caso médio . . . . . . . . . . 15
1.3 Notação assintótica . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
1.3.1 Notações O, Ω e Θ . . . . . . . . . . . . . . . . . . . . . . . . . 17
1.3.2 Notações o e ω . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
1.3.3 Relações entre as notações assintóticas . . . . . . . . . . . . . . 26

2 Recursividade 27
2.1 Algoritmos recursivos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.1.1 Fatorial . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.1.2 Busca binária . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
2.1.3 Algoritmos recursivos × algoritmos iterativos . . . . . . . . . . 30

3 Métodos para solução de equações de recorrência 35


3.1 Logaritmos e somatórios . . . . . . . . . . . . . . . . . . . . . . . . . . 36
3.2 Método da substituição . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
3.2.1 Desconsiderando pisos e tetos . . . . . . . . . . . . . . . . . . . 41
3.2.2 Diversas formas de obter o mesmo resultado . . . . . . . . . . . 42
3.2.3 Ajustando os palpites . . . . . . . . . . . . . . . . . . . . . . . . 43
iv SUMÁRIO

3.2.4 Mais exemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44


3.3 Método iterativo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
3.3.1 Limitantes assintóticos inferiores e superiores . . . . . . . . . . . 49
3.4 Método da árvore de recorrência . . . . . . . . . . . . . . . . . . . . . . 50
3.5 Método mestre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
3.5.1 Versão simplificada do método mestre . . . . . . . . . . . . . . . 55
3.5.2 Resolvendo recorrências com o método mestre . . . . . . . . . . 58
3.5.3 Ajustes para aplicar o método mestre . . . . . . . . . . . . . . . 59

II Estruturas de dados 63

4 Estruturas lineares 67
4.1 Vetor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
4.2 Lista encadeada . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68

5 Árvores 73
5.1 Árvores binárias de busca . . . . . . . . . . . . . . . . . . . . . . . . . 75
5.2 Árvores balanceadas . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78

6 Pilha 79

7 Fila 83

8 Fila de prioridades 87
8.1 Heap binário . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
8.1.1 Construção de um heap binário . . . . . . . . . . . . . . . . . . 94
8.1.2 Remoção em um heap binário . . . . . . . . . . . . . . . . . . . 98
8.1.3 Inserção em um heap binário . . . . . . . . . . . . . . . . . . . . 98
8.1.4 Alteração em um heap binário . . . . . . . . . . . . . . . . . . . 99

9 Disjoint Set 101


9.1 Union-Find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102

10 Tabelas hash 105


SUMÁRIO v

III Algoritmos de ordenação 107

11 Ordenação por inserção 111


11.1 Insertion sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
11.1.1 Corretude . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
11.1.2 Análise do tempo de execução . . . . . . . . . . . . . . . . . . . 113
11.1.3 Uma análise mais direta . . . . . . . . . . . . . . . . . . . . . . 115
11.2 Shellsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116

12 Ordenação por intercalação 119

13 Ordenação por seleção 123


13.1 Selection sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
13.2 Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 126

14 Ordenação por troca 131


14.1 Bubble sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
14.2 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
14.2.1 Análise do tempo de execução . . . . . . . . . . . . . . . . . . . 137

15 Ordenação em tempo linear 143


15.1 Counting sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144

IV Técnicas de construção de algoritmos 147

16 Divisão e conquista 151


16.1 Multiplicação de inteiros . . . . . . . . . . . . . . . . . . . . . . . . . . 151

17 Algoritmos gulosos 155


17.1 Escalonamento de tarefas compatı́veis . . . . . . . . . . . . . . . . . . . 155
17.2 Mochila fracionária . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
17.3 Compressão de dados . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161

18 Programação dinâmica 169


18.1 Sequência de Fibonacci . . . . . . . . . . . . . . . . . . . . . . . . . . . 171
vi SUMÁRIO

18.2 Corte de barras de ferro . . . . . . . . . . . . . . . . . . . . . . . . . . 174


18.3 Mochila inteira . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 179
18.4 Alinhamento de sequências . . . . . . . . . . . . . . . . . . . . . . . . . 183

V Algoritmos em grafos 187

19 Conceitos essenciais 191


19.1 Formas de representar um grafo . . . . . . . . . . . . . . . . . . . . . . 192
19.2 Trilhas, passeios, caminhos e ciclos . . . . . . . . . . . . . . . . . . . . 194

20 Buscas 197
20.1 Busca em largura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 198
20.1.1 Distância entre vértices . . . . . . . . . . . . . . . . . . . . . . . 200
20.1.2 Componentes conexas . . . . . . . . . . . . . . . . . . . . . . . 206
20.2 Busca em profundidade . . . . . . . . . . . . . . . . . . . . . . . . . . . 206
20.2.1 Ordenação topológica . . . . . . . . . . . . . . . . . . . . . . . . 211
20.2.2 Componentes fortemente conexas . . . . . . . . . . . . . . . . . 214
20.3 Outras aplicações dos algoritmos de busca . . . . . . . . . . . . . . . . 216

21 Árvores geradoras mı́nimas 217


21.1 Algoritmo de Kruskal . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
21.2 Algoritmo de Prim . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 223

22 Trilhas Eulerianas 229

23 Caminhos mı́nimos 233


23.1 De única fonte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
23.1.1 Algoritmo de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . 235
23.1.2 Algoritmo de Bellman-Ford . . . . . . . . . . . . . . . . . . . . 239
23.2 Entre todos os pares . . . . . . . . . . . . . . . . . . . . . . . . . . . . 244
23.2.1 Algoritmo de Floyd-Warshall . . . . . . . . . . . . . . . . . . . 245
23.2.2 Algoritmo de Johnson . . . . . . . . . . . . . . . . . . . . . . . 249
VI Teoria da computação 253

24 Complexidade computacional 257


24.1 Classes P e NP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 258
24.2 Classe NP-completo . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263
24.3 Exemplos de problemas NP-completos . . . . . . . . . . . . . . . . . . 265
24.4 Classe NP-difı́cil . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 267

25 Abordagens para lidar com problemas NP-difı́ceis 269

vii
viii
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.
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.
4
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-
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úmeros1 .
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 = (a1 , a2 , . . . , an ) e A[i] = ai é 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
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+1
6 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] 6= 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
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 1.1.1 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-decrescente3 ) 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/2 + 1..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.

1.1.1 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 recebida4 . 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
Algoritmo 2: BuscaBinaria(A, n, x)
1 esquerda = 1
2 direita = n
3 enquanto esquerda ≤ direita faça
meio = esquerda + direita−esquerda
 
4
2
5 se A[meio] == x então
6 retorna meio
7 senão se x > A[meio] então
8 esquerda = meio + 1
9 senão
10 direita = meio − 1
11 retorna −1

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
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] 6= 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.,
Qn
i=1 A[i].

10
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 Produ-


torio(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
Q
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
!
Y
= A[j] × A[i] (1.2)
j=1
i
Y
= A[j] , (1.3)
j=1

confirmando a validade do item (ii), pois mostramos que a invariante se manteve válida

11
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 = ni=1 A[i], que é de fato o resultado desejado. Portanto, o
Q

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
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) = x2 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+1
6 retorna −1

Denote por tx a posição do elemento x no vetor A[1..n], onde fazemos tx = 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 tx vezes,
a linha 3 é executada tx vezes, e a linha 5 é executada tx − 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) = 1 + 1 + tx + tx + tx − 1
= 3tx + 1 . (1.4)

Note que o tempo de execução, portanto, depende de onde x se encontra no vetor A.

13
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
meio = esquerda + direita−esquerda
 
4
2
5 se A[meio] == x então
6 retorna meio
7 senão se x > A[meio] então
8 esquerda = meio + 1
9 senão
10 direita = meio − 1
11 retorna −1

Denote por rx 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 rx vezes, as linhas 5, 7 e 9 são executadas um total de no máximo 2rx 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 rx vezes. Assim, o tempo de
execução T 0 (n) de BuscaBinaria(A, n, x) é dado como abaixo:

T 0 (n) ≤ rx + 3 + rx + rx + rx
= 4rx + 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/22 , . . .,

14
n/2i , onde o último vetor analisado pode chegar a ter tamanho 1, caso em que n/2i = 1,
o que implica i = log n. Assim, o laço enquanto é executado no máximo log n vezes,
de modo que temos rx ≤ log n. Assim, temos T 0 (n) ≤ 4 log n + 3.

1.2.1 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) = 3tx + 1 (veja (1.4)), onde tx é 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 b(n − 1)/2c = x. Nesse caso, o laço enquanto é executado
somente uma vez, de modo que o tempo de execução (veja (1.5)) é

T 0 (n) ≤ 4rx + 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
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 0 (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 tx de x em A é dada por (1 + 2 + · · · + n)/n = (n + 1)/2. Logo, o tempo
médio de execução da busca linear é dado por

3n 5
T (n) = 3tx + 1 = + .
2 2

O tempo de execução de caso médio da busca binária envolve calcular a média de


rx dentre todas as ordenações possı́veis do vetor, onde, lembre-se, rx é 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 an2 + bn + c, para constantes a, b e c), mas

16
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) = an2 + 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 é an2 . Ademais, as constantes também podem ser desconsideradas,
de modo que o tempo de execução nesse caso seria “da ordem de n2 ”. Por exemplo,
para n = 1000 e a = b = c = 2, temos an2 + bn + c = 2000000 + 2000 + 2 = 2002002
e n2 = 1000000. Estamos interessados no que acontece com f (n) quando n tende a
infinito, o que chamamos de análise assintótica de f (n).

1.3.1 Notações O, Ω e Θ
Começamos definindo as notações assintóticas O e Ω abaixo, que nos ajudarão, respec-
tivamente, 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 n0 tais que f (n) ≤ Cg(n)


para todo n ≥ n0 ;

• f (n) = Ω(g(n)) se existem constantes positivas c e n0 tais que cg(n) ≤ f (n)


para todo n ≥ n0 .

17
Em outras palavras, f (n) = O(g(n)) quando, para todo n suficientemente grande
(maior que um n0 ), 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 n0 ), 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 n0 tais que cg(n) ≤ f (n) ≤
Cg(n) para todo n ≥ n0 .

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 In 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 In .
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
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) = 10n2 + 5n + 3, então f (n) = Θ(n2 ).

Demonstração. Para mostrar que f (n) = Θ(n2 ), vamos mostrar que f (n) = O(n2 ) e
f (n) = Ω(n2 ). Verifiquemos primeiramente que f (n) = O(n2 ). Se tomarmos n0 = 1,
então note que, como queremos f (n) ≤ Cn para todo n ≥ n0 = 1, precisamos obter
uma constante C tal que 10n2 + 5n + 3 ≤ Cn2 . Mas então basta que

10n2 + 5n + 3 5 3
C≥ 2
= 10 + + 2 .
n n n

Como para n ≥ 1 temos

5 3
10 + + 2 ≤ 10 + 5 + 3 = 18 ,
n n

basta tomar n0 = 1 e C = 18. Assim, temos

5 3 10n2 + 5n + 3
C = 18 = 10 + 5 + 3 ≥ 10 + + 2 = ,
n n n2

como querı́amos. Logo, concluı́mos que f (n) ≤ 18n2 para todo n ≥ 1 e, portanto,
f (n) = O(n2 ).
Agora vamos verificar que f (n) = Ω(n2 ). Se tomarmos n0 = 1, então note que,
como queremos f (n) ≥ cn para todo n ≥ n0 = 1, precisamos obter uma constante c
tal que 10n2 + 5n + 3 ≥ cn2 . Mas então basta que

5 3
c ≤ 10 + + 2 .
n n

Como para n ≥ 1 temos


5 3
10 + + 2 ≥ 10 ,
n n
basta tomar n0 = 1 e c = 10. Concluı́mos então que f (n) ≥ 10n2 para todo n ≥ 1 e,

19
portanto, f (n) = Ω(n2 ).
Como mostramos que f (n) = O(n2 ) e f (n) = Ω(n2 ), então concluı́mos que f (n) =
Θ(n2 ).

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 n0 escolhido nos dois casos
foi 1, mas algumas vezes é mais conveniente ou somente é possı́vel escolher um valor
maior para n0 . 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 n0
√ √
tais que para n ≥ n0 temos 5 log n + n ≤ C n. Logo, queremos que

5 log n
C≥ √ +1 . (1.6)
n

Mas nesse caso precisamos ter cuidado ao escolher n0 , pois com n0 = 1, temos

5(log 1)/ 1 + 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 ≥ n0 = 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 + 1 ≤ 6. Portanto, tomando

n0 = 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 ≥ 16.

20
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 n0 = 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 n0 tais que para n ≥ n0
√ √
temos 5 log n + n ≤ C n. Logo, queremos que

5 log n
C≥ √ +1 . (1.8)
n

Note que
   
5 log n 5 log n
lim √ +1 = lim √ + lim 1 (1.9)
n→∞ n n→∞ n n→∞
!
5 n1
= lim 1 +1 (1.10)
n→∞ √
2 n
 
10
= lim √ +1=0+1=1 , (1.11)
n→∞ n

onde usamos a regra de L’Hôpital na segunda igualdade. Sabendo que quando n = 1



temos 5(log 1)/ 1 + 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 = n0 .

Perceba que podem existir diversas possibilidades de escolha para n0 e C: pela


definição, basta que encontremos alguma. Por exemplo, na prova do Fato 1.4, usar
√ √
n0 = 3454 e C = 2 também funciona para mostrar que 5 log n + n = O( n). Outra

escolha possı́vel seria n0 = 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
• loga n = Θ(logb n).

• loga n = O(nε ) para qualquer ε > 0.

• (n + a)b = Θ(nb ).

• 2n+a = Θ(2n ).

• 2an 6= O(2n ).

• 7n2 6= O(n).

Vamos utilizar a definição da notação assintótica para mostrar que 7n2 6= O(n).

Fato 1.7

Se f (n) = 7n2 então f (n) 6= O(n).

Demonstração. Lembre que f (n) = O(g(n)) se existem constantes positivas C e n0 tais


que se n ≥ n0 , então 0 ≤ f (n) ≤ Cg(n). Suponha, por contradição, que 7n2 = O(n),
i.e., que existem tais constantes C e n0 tais que se n ≥ n0 , então

7n2 ≤ Cn .

Nosso objetivo agora é chegar a uma contradição. Note que, isolando o n na equação
acima, para todo n ≥ n0 , 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 ≥ n0 , 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
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 n0 = 1, c = 1 e C = 1 temos que para todo n ≥ n0 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 n0 tais que

cg(n) ≤ f (n) ≤ Cg(n) (1.12)

23
para todo n ≥ n0 . Assim, analisando as desigualdades em (1.12), concluı́mos que
   
1 1
f (n) ≤ g(n) ≤ f (n)
C c

para todo n ≥ n0 . Portanto, existem constantes n0 , c0 = 1/C e C 0 = 1/c tais que


c0 f (n) ≤ g(n) ≤ C 0 f (n) para todo n ≥ n0 .
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 n0 tais que
f (n) ≤ Cg(n) para todo n ≥ n0 . Portanto, temos que g(n) ≥ (1/C)f (n) para todo
n ≥ n0 , 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 n0 tais
que f (n) ≤ Cg(n) para todo n ≥ n0 . Se g(n) = Θ(h(n)), então temos que existem
constantes positivas d, D e n00 tais que dh(n) ≤ g(n) ≤ Dh(n) para todo n ≥ n00 .
Então f (n) ≤ Cg(n) ≤ CDh(n) para todo n ≥ max{n0 , n00 }, 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 n0
tais que cg(n) ≤ f (n) ≤ Cg(n) para todo n ≥ n0 . Se g(n) = O(h(n)), então temos
que existem constantes positivas D e n00 tais que g(n) ≤ Dh(n) para todo n ≥ n00 .
Então f (n) ≤ Cg(n) ≤ CDh(n) para todo n ≥ max{n0 , n00 }, 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 n0 tais
que f (n) ≤ C(g(n) + h(n)) para todo n ≥ n0 . Mas então f (n) ≤ Cg(n) + Ch(n) para
todo n ≥ n0 , 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 + 1000(log n)100 + n + n3 /10 + 5n5 + n8 /27 ,

então sabemos que f (n) = Θ(n8 ).

24
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 2n2 = O(n2 ) e 4n = O(n2 ). Apesar dessas
duas funções terem ordem de complexidade O(n2 ), 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 n0 > 0
tal que 0 ≤ f (n) < cg(n) para todo n ≥ n0 ;

• f (n) = ω(g(n)) se para toda constante C > 0 existe n0 > 0 tal que
f (n) > Cg(n) ≥ 0 para todo n ≥ n0 .

Por exemplo, 2n = o(n2 ) mas 2n2 6= o(n2 ). 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 limn→∞ (f (n)/g(n)) = 0. Por exemplo,
2n2 = ω(n) mas 2n2 6= ω(n2 ).
Vamos ver um exemplo para ilustrar como podemos mostrar que f (n) = o(g(n))
para duas funções f e g.

Fato 1.10

10n + 3 log n = o(n2 ).

Demonstração. Seja f (n) = 10n + 3 log n. Precisamos mostrar que, para qualquer
constante positiva c, existe um n0 tal que 10n + 3 log n < cn2 para todo n ≥ n0 . 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 n0 = (13/c) + 1).

25
Note que com uma análise similar à feita na prova acima podemos provar que
10n + 3 log n = o(n1+ε ) 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.

• loga n 6= o(logb n).

• loga n 6= ω(logb n).

• loga n = o(nε ) para qualquer ε > 0.

• an = o(n1+ε ) para qualquer ε > 0.

• an = ω(n1−ε ) para qualquer ε > 0.

• 1000n2 = o((log n)n2 ).

1.3.3 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
Recursividade

Você quis dizer: recursividade

Google

Ao desenvolver um algoritmo, muitas vezes precisamos executar uma tarefa repetida-


mente, 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
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.

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

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

2.1.2 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 con-
segue 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[bn/2c] = x, então a busca está encerrada. Caso contrário, se x < A[bn/2c], então
basta verificar se o vetor A[1..bn/2c − 1] contém x, o que pode ser feito recursivamente.
Se x > A[bn/2c], então verifica-se recursivamente o vetor A[bn/2c + 1..n]. Como esse
procedimento analisa, passo a passo, somente metade do tamanho do vetor do passo
anterior, seu tempo de execução é O(log n). Para executar o Algoritmo 7 basta fazer
uma chamada BuscaBinariaRecursiva(A, 1, n, x).

Algoritmo 7: BuscaBinariaRecursiva(A, inicio, f im, x)


1 se inicio > f im então
2 retorna −1
 f im−inicio 
3 meio = inicio +
2
4 se A[meio] == x então
5 retorna meio
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
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 Fn , é dado por



 1 se n = 1

Fn = 1 se n = 2 (2.1)



F
n−1 + Fn−2 se n > 2 .

Não fica muito claro pela definição, mas F30 é maior do que 1 milhão, F100 é um número
com 21 dı́gitos e, em geral, Fn ≈ 20.684n . Ou seja, Fn é um valor exponencial em n.

Problema 2.1: Número de Fibonacci

Dado um inteiro n ≥ 0, encontrar Fn .

30
O Algoritmo 8 calcula recursivamente Fn 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 Fm . Para qualquer n ≥ 2, FibonacciRecursivo(n)
leva tempo T (n) = T (n−1)+T (n−2)+1 (calculando Fn−1 , Fn−2 , fazendo a comparação,
a soma e o retorno). Mas então T (n) ≥ Fn , 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 F10 e cerca de 1021 milênios para calcular F200 .
Mesmo se o computador fosse capaz de realizar 40 trilhões de instruções por segundo,
ainda precisarı́amos de cerca de 5 × 1017 milênios para calcular F200 .
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 F200 e mesmo
F1000000 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
Fn

Fn−1 Fn−2

Fn−2 Fn−3 Fn−3 Fn−4

Fn−3 Fn−4 Fn−4 Fn−5 Fn−4 Fn−5 Fn−5 Fn−6

Fn−4 Fn−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
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 Fn somas
mas usa um número de passos básicos proporcional a nFn .
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
34
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
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 logarit-
mos.

Fato 3.1

Dados números reais a, b, c ≥ 1, as seguintes igualdades são válidas.

(i) aloga b = b.

(ii) logc (ab) = logc a + logc b.

(iii) logc (a/b) = logc a − logc b.

(iv) logc (ab ) = b logc a.


logc a
(v) logb a = logc b
.
1
(vi) logb a = loga b
.

(vii) alogc b = blogc a .

Demonstração. Por definição, temos que logb a = x se e somente se bx = a. No que


segue vamos provar cada uma das identidades descritas no enunciado.

36
(i) aloga b = b. Segue diretamente da definição de logaritmo, uma vez que ax = b se e
somente se x = loga b.

(ii) logc (ab) = logc a + logc b. Como a, b e c são positivos, existem números k e ` tais
que a = ck e b = c` . Assim, temos

logc (ab) = logc (ck c` ) = logc ck+` = k + ` = logc a + logc b ,




onde as duas últimas desigualdades seguem da definição de logaritmos.

(iii) logc (a/b) = logc a − logc b. Como a, b e c são positivos, existem números k e `
tais que a = ck e b = c` . Assim, temos

logc (a/b) = logc (ck /c` ) = logc ck−` = k − ` = logc a − logc b .




(iv) logc (ab ) = b logc a. Como a, b e c são positivos, podemos escrever a = ck para
algum número real k. Assim, temos

logc (ab ) = logc (ck b) = kb = b logc a .

(v) logb a = log ca


logc b
. Vamos mostrar que logc a = (logb a)(logc b). Note que, pela

identidade (i), temos logc a = logc blogb a . Assim, usando a identidade (iii),
temos que logc a = (logb a)(logc b).

1
(vi) logb a = loga b
. Vamos somente usar (v) e o fato de que loga a = 1:

loga a 1
logb a = = .
loga b loga b

(vii) alogc b = blogc a . Esse fato segue das identidades (i), (v) e (vi). De fato,

alogc b = a(loga b)/(loga c)


1/(loga c)
= aloga b
= b1/(loga c)
= blogc a .

37
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) (a1 , a2 , . . . , an ) com razão r é uma sequência de


números que contém um termo inicial a1 e todos os outros termos ai , com 2 ≤ i ≤ n,
são definidos como ai = a1 + (i − 1)r. Assim, a soma dos termos dessa PA é dada por
Pn Pn
i=1 ai = i=1 (a1 + (i − 1)r).

Uma progressão geométrica (PG) (b1 , b2 , . . . , bn ) com razão q é uma sequência de


números que contém um termo inicial b1 e todos os outros termos bi , com 2 ≤ i ≤ n,
são definidos como bi = b1 q i−1 . Assim, a soma dos termos dessa PG é dada por
Pn Pn i−1
i=1 bi = i=1 (b1 q ).

Teorema 3.2

Considere uma progressão aritmética (a1 , . . . , an ) com razão r e uma progressão


geométrica (b1 , . . . , bn ) com razão q. A soma dos termos da progressão aritmética
é dada por (a1 +a2
n )n
e a soma dos termos da progressão geométrica é dada por
n
a1 (q −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

1 + 2 + · · · + k = k(k + 1)/2 . (3.1)

Esse fato pode facilmente ser provado por indução em n. Agora considere a soma

38
Pn
i=1 (a1 + (i − 1)r). Temos que

n
X 
a1 + (i − 1)r = a1 n + r(1 + 2 + · · · + (n − 1))
i=1
rn(n − 1)
= a1 n +
2

= n a1 + (a1 + r(n − 1))
n(a1 + an )
= ,
2

onde na segunda igualdade utilizamos (3.1).


Resta verificar a fórmula para a soma dos termos da progressão geométrica S =
Pn i−1
i=1 (b1 q ). Note que temos

qS = b1 (q + q 2 + q 3 + · · · + q n−1 + q n ) , e
S = b1 (1 + q + q 2 + · · · + q n−2 + q n−1 ) .

Portanto, subtraindo S de qS obtemos (q − 1)S = b1 (q n − 1), de onde concluı́mos que

b1 (q n − 1)
S= .
q−1

3.2 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 (bn/2c) + T (dn/2e) + 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/2i é um inteiro, para todo 1 ≤ i ≤
log n.

39
Mostraremos inicialmente que T (n) = O(n2 ). Para isso, provaremos por indução
que T (n) ≤ cn2 para c ≥ 1 e n ≥ 1, i.e., mostraremos que

existem constantes c e n0 tais que, se n ≥ n0 , então T (n) ≤ cn2 , (3.2)

o que implica em T (n) = O(n2 ). Via de regra assumiremos T (1) = 1, a menos que
indiquemos algo diferente. Durante a prova, ficará claro quais os valores de c e n0
necessários para que 3.2 aconteça (nesse exemplo, qualquer c ≥ 1 e n0 ≥ 1 funcionam).
Comecemos pelo caso base, que vale trivialmente: para n = 1 temos T (1) = 1 = 1 · n2 .
Suponha que, para 1 ≤ m < n, temos T (m) ≤ m2 . Precisamos mostrar que T (n) ≤ n2 .
Para isso, combinamos T (n) = 2T (n/2) + n com o fato de que T (m) ≤ m2 para
m = n/2 (por hipótese de indução). Assim,

T (n) = 2T (n/2) + n
 2
n
≤2 +n
22
= (n2 /2) + n
≤ n2 ,

onde a última desigualdade vale sempre que n ≥ 2, que é o caso. Portanto, mostramos
por indução em n que T (n) ≤ cn2 para c ≥ 1 e n ≥ n0 = 1, de onde concluı́mos que
T (n) = O(n2 ).
Há ainda uma pergunta importante a ser feita: será que é possı́vel provar um
limitante superior assintótico melhor que n2 ?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 n0 tais que, se n ≥ n0 , então T (n) ≤ cn log n,

o que implica em T (n) = O(n log n). Aqui, faremos c = 2, n0 = 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(n2 ).

40
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 ≥ n0 = 2, de onde concluı́mos


que T (n) = O(n log n).

3.2.1 Desconsiderando pisos e tetos

Vimos que T (n) = T (bn/2c) + T (dn/2e) + 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 (bn/2c) + T (dn/2e) + 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 (bn/2c) + T (dn/2e) + n. Como n não é uma potência de 2, existe um inteiro
k ≥ 2 tal que 2k−1 < n < 2k . Portanto, T (2k−1 ) ≤ T (n) ≤ T (2k ). Já provamos que
T (n) = Θ(n log n) no caso em que n é potência de 2. Em particular, T (2k ) ≤ d2k log(2k )
para alguma constante d e T (2k−1 ) ≥ d0 2k−1 log(2k−1 ) para alguma constante d0 . Assim,

T (n) ≤ T (2k ) ≤ d2k log(2k )


= (2d)2k−1 log(2 × 2k−1 )
< (2d)n(log 2 + log n)
< (2d)n(log n + log n)
= (4d)n log n .

41
Similarmente,

T (n) ≥ T (2k−1 ) ≥ d0 2k−1 log(2k−1 )


d0 k
= 2 (log(2k ) − 1)
2 
d0

9 log n
> n log n −
2 10
 0
d
= n log n .
20

Como existem constantes d0 /20 e 4d tais que para todo n ≥ 3 temos (d0 /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 (bn/bc) +
T (dn/ce)) + 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.

3.2.2 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 1 + 1. Suponha
que para todo 2 ≤ m < n temos T (m) ≤ m log m + m. Assim,

T (n) = 2T (n/2) + n

≤ 2 (n/2) log(n/2) + n/2 + n
= n log(n/2) + 2n
= n log n − n + 2n
= n log n + n .

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

3.2.3 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
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 −d +1
3
= 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).

3.2.4 Mais exemplos

Discutiremos agora alguns exemplos que nos ajudarão a entender todas as particulari-
dades que podem surgir na aplicação do método da substituição.

Exemplo 1. T (n) = 4T (n/2) + n3 .


Vamos provar que T (n) = Θ(n3 ). Primeiramente, mostraremos que T (n) = O(n3 )
e, para isso, vamos provar que T (n) ≤ cn3 para alguma constante apropriada c.
Note que T (1) = 1 ≤ c × 13 desde que c ≥ 1. Suponha que T (m) ≤ cm3 para todo

44
2 ≤ m < n. Assim, temos que

T (n) = 4T (n/2) + n3
4cn3
≤ + n3
8
≤ cn3 ,

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) ≤ cn3 = O(n3 ).
Para provar que T (n) = Ω(n3 ), vamos provar que T (n) ≥ dn3 para algum d
apropriado. Primeiro note que T (1) = 1 ≥ d × 13 desde que d ≤ 1. Suponha que
T (m) ≥ dm3 para todo 2 ≤ m < n. Assim, temos que

T (n) = 4T (n/2) + n3
4dn3
≥ + n3
8
≥ dn3 ,

onde a última desigualdade vale sempre que d ≤ 2. Portanto, fazendo d = 1, acabamos


de provar por indução que T (n) ≥ dn3 = Ω(n3 ).


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) = 4 + 5 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
√ √
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 (bn/2c + 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 (bn/2c + 2) + 1
n 
≤ c log +2 +1
2 
n+4
= c log +1
2
= 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
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 (bn/2c + 2) + 1
n 
≤ c log +2−a +1
2 
n−a
= c log +1
2
= 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/22 ) + 2
= (T ((n/22 )/2) + 1) + 2 = T (n/23 ) + 3
..
.
= T (n/2i ) + i .

47
Sabemos que T (1) = 1. Então, tomando i = log n, continuamos a estimativa para
T (n):

T (n) = T (n/2i ) + i
= T (n/2log 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 = 22 T (n/22 ) + 2n


= 23 T (n/23 ) + 3n
..
.
= 2i T (n/2i ) + in .

Fazendo i = log n, temos

T (n) = 2log n T (n/2log 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 T1 (n) = Θ(n2 ) e T2 (n) = 2T (n/2) + n. Como acabamos de verificar, temos
T2 (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) + 1.

48
Seguindo a mesma estratégia dos exemplos anteriores, obtemos o seguinte:

T (n) = 2T (n/3) + 1
= 2 2T (n/32 ) + 1 + 1 = 22 T (n/32 ) + (1 + 2)


= 23 T (n/33 ) + (1 + 2 + 22 )
..
.
i−1
X
i i
= 2 T (n/3 ) + 2j
j=0

= 2 T (n/3 ) + 2i − 1 .
i i

Fazendo i = log3 n, temos T (n/3log3 n ) = 1, de onde concluı́mos que

T (n) = 2 × 2log3 n − 1
1/ log 3
= 2 2log n −1
= 2n1/ log 3 − 1
= Θ(n1/ log 3 ) .

3.3.1 Limitantes assintóticos inferiores e superiores

Se quisermos apenas provar que T (n) = O(f (n)) em vez de Θ(f (n)), podemos utilizar
limitantes superiores em vez de igualdades. Analogamente, para mostrar que T (n) =
Ω(f (n)), podemos utilizar limitantes inferiores em vez de igualdades.

Por exemplo, para T (n) = 2T (n/3) + 1, se quisermos mostrar apenas que T (n) =
Ω(n1/ 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
T (n). Note que

T (n) = 2T (n/3) + 1
= 2 2T (n/32 ) + 1 + 1 ≥ 22 T (n/32 ) + 2


≥ 23 T (n/33 ) + 3
..
.
≥ 2i T (n/3i ) + i .

Fazendo i = log3 n, temos T (n/3log3 n ) = 1, de onde concluı́mos que

T (n) ≥ 2log3 n + log3 n


= n1/ log 3 + log3 n
= Ω(n1/ 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
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
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

a1+logb n − 1
a0 + a1 + . . . + alogb n =
a−1
(bn)logb a − 1
=
a−1
= Θ nlogb a .


Portanto, considerando somente o tempo para dividir o problema em subproblemas



recursivamente, temos que é gasto tempo Θ nlogb a . A ideia envolvida no Teorema
Mestre, que será apresentado a seguir, analisa situações dependendo da diferença entre
f (n) e nlogb 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
Figura 3.3: Árvore de recorrência para T (n) = aT (n/b) + f (n).

(1) se f (n) = O(nlogb a−ε ) para alguma constante ε > 0, então T (n) = Θ(nlogb a );

(2) se f (n) = Θ(nlogb a ), então T (n) = Θ(nlogb a log n);

(3) se f (n) = Ω(nlogb 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 nlogb a . Se a função f (n) sempre assume valores “pequenos” (aqui,
pequeno significa f (n) = O(nlogb 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 logb n
vezes até que se chegue à base da recursão, gastando para isso tempo da ordem de
alogb n = nlogb 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
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 nlogb a ,
mas assintoticamente maior que nlogb a−ε para todo ε > 0, e.g., f (n) = Θ(nlogb a / log n)
ou Θ(nlogb 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) = Ω(nlogb 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 uma versão simplificada
do Teorema Mestre, que dispensa a condição extra no item (3). Veremos essa versão
na Seção 3.5.1.
Antes disso, 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 1. 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 nlogb a = 1. Assim, como f (n) = n(2 − cos n) ≥ n, temos
f (n) = Ω(nlogb 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

f (n/2) (n/2) 2 − cos(kπ) 2 − cos(kπ) 3
c≥ = = = .
f (n) n(2 − cos(2kπ)) 2(2 − cos(2kπ)) 2

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

3.5.1 Versão simplificada do método mestre

Seja f (n) um polinômio de grau k cujo coeficiente do monômio de maior grau é positivo
(para k constante), i.e., f (n) = ki=0 ai ni , onde a0 , a1 , . . . , ak são constantes e ak > 0.
P

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 cujo


coeficiente do monômio de maior grau é positivo. Para T (n) = aT (n/b) + f (n),
vale que

(1) se f (n) = O(nlogb a−ε ) para alguma constante ε > 0, então T (n) = Θ(nlogb a );

(2) se f (n) = Θ(nlogb a ), então T (n) = Θ(nlogb a log n);

(3) se f (n) = Ω(nlogb 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) = Ω(nlogb 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) = ki=0 ai ni = Ω(nlogb a+ε ) temos k = logb a + ε.
P

Resta provar que af (n/b) ≤ cf (n) para algum c < 1. Logo, basta provar que cf (n) −

55
af (n/b) ≥ 0 para algum c < 1. Assim,

k k
X
i
X ni
cf (n) − af (n/b) = c ai n − a ai
i=0 i=0
bi
k−1
 a k X  a
= ak c− k n + ai c − i ni
b i=0
b
k−1
 a X a
≥ ak c − k nk − ai i ni
b i=0
b
k−1
!
 a  k−1 X
≥ ak c − k nn − a ai nk−1
b i=0

= (c1 n)nk−1 − (c2 )nk−1 ,

onde c1 e c2 são constantes e na última desigualdade utilizamos o fato de b > 1 (assim,


bi > 1 para todo i ≥ 0). Logo, para n ≥ c2 /c1 , temos que cf (n) − af (n/b) ≥ 0.

Abaixo mostramos uma segunda prova para o Teorema 3.2. Reformulamos seu
enunciado com base nas seguintes observações. Primeiro, sendo f (n) = ki=0 ai ni , onde
P

a0 , a1 , . . . , ak são constantes e ak > 0, não é difı́cil mostrar que f (n) = Θ(nk ). Segundo,
se Θ(nk ) = O(nlogb a−ε ) para algum ε > 0, então essencialmente estamos assumindo
nk ≤ nlogb a−ε . Mas nlogb a−ε < nlogb a pois ε > 0, ou seja, estamos assumindo nk < nlogb a ,
que equivale a assumir bk < a. Com argumentos semelhantes, assumir Θ(nk ) = Θ(nlogb a )
significa essencialmente assumir bk = a, e assumir Θ(nk ) = Ω(nlogb a+ε ) significa
essencialmente assumir bk > a.

Teorema 3.3: Teorema Mestre - Versão simplificada

Sejam a ≥ 1, b > 1 e k ≥ 0 constantes. Para T (n) = aT (n/b) + Θ(nk ), vale que

(1) se a > bk , então T (n) = Θ(nlogb a );

(2) se a = bk , então T (n) = Θ(nk log n);

(3) se a < bk , então T (n) = Θ(nk ).

Demonstração. Como T (n) = aT (n/b) + Θ(nk ), isso significa que existem constantes

56
c1 e c2 para as quais vale que:

1. T (n) ≤ aT (n/b) + c1 nk ; e

2. T (n) ≥ aT (n/b) + c2 nk .

Vamos assumir que T (1) = 1 em qualquer caso.


Considere inicialmente que o item 1 vale, isto é, T (n) ≤ aT (n/b) + c1 nk . Ao analisar
a árvore de recorrência para T (n), percebemos que a cada nı́vel o tamanho do problema
diminui por um fator b, de forma que o último nı́vel é logb n. Também notamos que
um certo nı́vel j possui aj subproblemas de tamanho n/bj cada.
Dessa forma, o total de tempo gasto em um nı́vel j é ≤ aj c1 (n/bj )k = c1 nk (a/bk )j .
Somando o tempo gasto em todos os nı́veis, temos o tempo total do algoritmo, que é

logb n  a j logb n 
X
k k
X a j
T (n) ≤ c1 n = c1 n , (3.3)
j=0
bk j=0
bk

de onde vemos que o tempo depende da relação entre a e bk . Assim,

a
(1) se a > bk , temos bk
> 1, e a equação (3.3) pode ser desenvolvida da seguinte
forma:
a logb n+1
 !
−1 c1 n k
 
k b k a logb n+1
T (n) ≤ c1 n a = a −1
bk
−1 bk
−1 bk
c1 nk  a logb n+1 ac1 nk  a logb n
≤ a = a

bk
− 1 bk b k − 1 bk bk
ac1 nk logb a/bk 0 k n
logb a
= a
 n = c n
bk
− 1 bk nlogb bk
= c0 nlogb a ,

onde c0 = (a/bac k
k −1)bk é constante. Ou seja, acabamos de mostrar que se a > b ,
1

então T (n) = O(nlogb a ).

a
(2) se a = bk , temos bk
= 1, e a equação (3.3) pode ser desenvolvida da seguinte

57
forma:

T (n) ≤ c1 nk (logb n + 1) = c1 nk logb n + c1 nk


≤ c1 nk logb n + c1 nk logb n = 2c1 nk logb n .

Ou seja, acabamos de mostrar que se a = bk , então T (n) = O(nk log n).


a
(3) se a < bk , temos bk
< 1, e a equação (3.3) pode ser desenvolvida da seguinte
forma:
a logb n+1
 !
1− c1 n k c1 n k
 
k a logb n+1
T (n) ≤ c1 n k b
= 1− k ≤ = c0 n k ,
1 − bak 1 − bak b 1 − bak

c1
onde c0 = 1−a/b k
k é constante. Ou seja, acabamos de mostrar que se a < b , então

T (n) = O(nk ).

Considere agora que o item 2 vale, isto é, T (n) ≥ aT (n/b) + c2 nk . De forma
semelhante, ao analisar a árvore de recorrência para T (n), somando o tempo gasto em
todos os nı́veis, temos que

logb n  a j logb n 
X
k k
X a j
T (n) ≥ c2 n = c2 n , (3.4)
j=0
bk j=0
bk

de onde vemos que o tempo também depende da relação entre a e bk . Não é difı́cil
mostrar que

(1) se a > bk , então T (n) = Ω(nlogb a ),

(2) se a = bk , então T (n) = Ω(nk log n), e

(3) se a < bk , então T (n) = Ω(nk ),

o que conclui o resultado.

3.5.2 Resolvendo recorrências com o método mestre


Vamos analisar alguns exemplos de recorrências onde aplicaremos o Teorema Mestre
para resolvê-las.

58
Exemplo 1. T (n) = 2T (n/2) + n.
Claramente, temos a = 2, b = 2 e f (n) = n. Como f (n) = n = nlog2 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, logb a = log10 4 ≈ 0, 6.

Como 5 n = 5n0,5 = O(n0,6−0,1 ), estamos no caso (1) do Teorema Mestre. Logo,
T (n) = Θ(nlogb a ) = Θ(nlog10 4 ).


Exemplo 3. T (n) = 4T (n/16) + 5 n.

Note que a = 4, b = 16 e f (n) = 5 n. Assim, logb a = log16 4 = 1/2. Como

5 n = 5n0,5 = Θ(nlogb a ), estamos no caso (2) do Teorema Mestre. Logo, T (n) =

Θ(nlogb a log n) = Θ(nlog16 4 log n) = Θ( n log n).

Exemplo 4. T (n) = 4T (n/2) + 10n3 .


Neste caso temos a = 4, b = 2 e f (n) = 10n3 . Assim, logb a = log2 4 = 2. Como
10n3 = Ω(n2+1 ), estamos no caso (3) do Teorema Mestre. Logo, concluı́mos que
T (n) = Θ(n3 ).

Exemplo 5. T (n) = 5T (n/4) + n.


Temos a = 5, b = 4 e f (n) = n. Assim, logb a = log4 5. Como log4 5 > 1, temos
que f (n) = n = O(nlog4 5−ε ) para ε = 1 − log4 5 > 0. Logo, estamos no caso (1) do
Teorema Mestre. Assim, concluı́mos que T (n) = Θ(nlog4 5 ).

3.5.3 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) = Ω(nlogb a+ε ) para alguma constante ε > 0, mas não existe c < 1 tal que
af (n/b) ≤ cf (n) para todo n suficientemente grande.

59
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) 6= Θ(nlogb a ); 2) f (n) 6= O(nlogb a−ε )
para qualquer ε > 0; e 3) f (n) 6= Ω(nlogb a+ε ). Lembre que, dado que temos a versão
simplificada do Teorema Mestre (Teorema 3.2), não precisamos verificar o item (ii), pois
essa condição é sempre satisfeita para polinômios f (n) com coeficientes não negativos.
No que segue mostraremos que não é possı́vel aplicar o Teorema Mestre diretamente
a algumas recorrências, mas sempre é possı́vel conseguir limitantes superiores e inferiores
analisando recorrências levemente modificadas.

Exemplo 1. T (n) = 2T (n/2) + n log n.


Começamos notando que a = 2, b = 2 e f (n) = n log n. Para todo n suficientemente
grande e qualquer constante C vale que n log n ≥ Cn. Assim, para qualquer ε > 0,
temos que n log n 6= O(n1−ε ), de onde concluı́mos que a recorrência T (n) não se encaixa
no caso (1). Como n log n = 6 Θ(n), também não podemos utilizar o caso (2). Por
fim, como log n 6= Ω(n ) para qualquer ε > 0, temos que n log n 6= Ω(n1+ε ), de onde
ε

concluı́mos que o caso (3) do Teorema Mestre também não se aplica.

Exemplo 2. T (n) = 5T (n/8) + nlog8 5 log n.


Começamos notando que a = 5, b = 8 e f (n) = nlog8 5 log n. Para todo n sufi-
cientemente grande e qualquer constante C vale que nlog8 5 log n ≥ Cnlog8 5 . Assim,
para qualquer ε > 0, temos que nlog8 5 log n 6= O(nlog8 5−ε ), de onde concluı́mos que a
recorrência T (n) não se encaixa no caso (1). Como nlog8 5 log n =
6 Θ(nlog8 5 ), também
não podemos utilizar o caso (2). Por fim, como log n 6= Ω(nε ) para qualquer ε > 0,
temos que nlog8 5 log n 6= Ω(nlog8 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, nlogb 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 = 6 O( n/nε ), de onde concluı́mos
√ √
que a recorrência T (n) não se encaixa no caso (1). Como n log n =6 Θ( n), também
não podemos utilizar o caso (2). Por fim, como log n 6= Ω(nε ) para qualquer ε > 0,
√ √
temos que n log n 6= Ω( nnε ), de onde concluı́mos que o caso (3) do Teorema Mestre

60
também não se aplica.

Exemplo 4. T (n) = 16T (n/4) + n2 / log n.


Começamos notando que a = 16, b = 4 e f (n) = n2 / log n. Logo, nlogb a = n2 .
Para todo n suficientemente grande e qualquer constante C vale que n ≥ C log n.
Assim, para qualquer ε > 0, temos que n2 / log n 6= O(n2−ε ), de onde concluı́mos que a
recorrência T (n) não se encaixa no caso (1). Como n2 / log n = 6 Θ(n2 ), também não
podemos utilizar o caso (2). Por fim, como n2 / log n 6= Ω(n2+ε ) para qualquer ε > 0,
concluı́mos que o caso (3) do Teorema Mestre também não se aplica.
Como vimos, não é possı́vel aplicar o Teorema Mestre diretamente às recorrências
descritas nos exemplos acima. Porém, podemos ajustar as recorrências e conseguir bons
limitantes assintóticos utilizando o Teorema Mestre. Por exemplo, para a recorrência
T (n) = 16T (n/4) + n2 / log n dada acima, claramente temos que T (n) ≤ 16T (n/4) + n2 ,
de modo que podemos aplicar o Teorema Mestre na recorrência T 0 (n) = 16T 0 (n/4) + n2 .
Como n2 = nlog4 16 , pelo caso (2) do Teorema Mestre, temos que T 0 (n) = Θ(n2 log n).
Portanto, como T (n) ≤ T 0 (n), concluı́mos que T (n) = O(n2 log n), obtendo um
limitante assintótico superior para T (n). Por outro lado, temos que T (n) = 16T (n/4) +
n2 / log n ≥ T 00 (n) = 16T 00 (n/4) + n. Pelo caso (1) do Teorema Mestre, temos que
T 00 (n) = Θ(n2 ). Portanto, como T (n) ≥ T 00 (n), concluı́mos que T (n) = Ω(n2 ). 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 n2 e
n2 log n.
Existem outros métodos para resolver equações de recorrência mais gerais que
equações do tipo T (n) = aT (n/b) + f (n). Um exemplo importante é o método
de Akra-Bazzi, que consegue resolver equações não tão balanceadas, como T (n) =
T (n/3) + T (2n/3) + Θ(n), mas não entraremos em detalhes desse método aqui.

61
62
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.


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.
66
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 =
(a1 , a2 , . . . , an ) e A[i] = ai é 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
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
quando a lista está vazia.

68
Figura 4.1: Lista duplamente encadeada circular.

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 6= null e x. chave 6= 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
InsereNaLista é executado em tempo Θ(1) para uma lista com n elementos. Note

69
Algoritmo 11: InsereNaLista(L, x)
1 x. proximo = L. cabeca
2 se L. cabeca 6= null então
3 L.cabeca . anterior = x
4 L. cabeca = x
5 x. anterior = null

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 6= null então
2 x. anterior . proximo = x. proximo
3 senão
4 L. cabeca = x. proximo
5 se x. proximo 6= 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á
na posição anterior a x, dado que não existe x. anterior. Portanto, seria necessário

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

71
72
Á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.
Em uma árvore, só temos acesso direto ao nó raiz e qualquer manipulação, portanto,

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.
Figura 5.2: Árvore binária quase completa.

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 2h+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.

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

75
30

17 90

4 20 60 97

18 45

37

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

76
90 45 37
60 60 45
60 97 37 97 60
45 90 45 90
45 60 90
37 97 37 97
37 90 97
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.

77
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 20 + 21 + 22 + · · · + 2h = 2h+1 − 1 nós. Se n é o
total de nós, então n ≤ 2h+1 − 1, o que implica que h ≥ blog n + 2c − 1. 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.

78
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 capaci-
dade 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
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 6= 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 6= 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.

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

81
82
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 imple-
mentado 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
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 6= 1 ou F. cauda 6= F. capacidade) e F. cabeca 6= 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.

84
Algoritmo 17: Desenfileira(F )
1 x = null
2 se F. cabeca 6= 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), Enfi-
leira(F , 5), Enfileira(F , 1), Desenfileira(F ), Desenfileira(F ), Enfileira(F ,
8).

85
86
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
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 bi/2c (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[bi/2c]. 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
todos os nós do nı́vel ` consecutivamente antes de acessar os nós do nı́vel ` + 1. Além

88
100

19 36
A = ( |{z}
100 , 19, 36 , 17, 8, 25, 1, 2, 7, 5)
| {z } | {z } | {z }
17 8 25 1 nı́vel 0 nı́vel 1 nı́vel 2 nı́vel 3

7 12 5

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.

disso, um elemento na posição i de H tem altura blog(n/i)c e está no nı́vel blog ic.
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é
que atingimos um vértice folha ou até que não tenhamos mais falha na propriedade.

89
100 100 100

2 36 17 36 17 36

17 8 25 1 2 8 25 1 12 8 25 1

7 12 5 7 12 5 7 2 5

Figura 8.2: Exemplo de execução da chamada CorrigeHeapDescendo(H, 2).

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 + 1
6 se maior 6= 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 hx a altura de um nó que está na posição x na heap (isto é,
hx = blog(n/x)c). Vamos provar o resultado por indução na altura hi do nó i.
Quando hi = 0, o nó deve ser uma folha, que por definição são heaps (de tamanho

90
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 hk < hi .
Precisamos agora mostrar que CorrigeHeapDescendo(H, i) funciona correta-
mente, i.e., a árvore com raiz H[i] é um heap. Considere uma execução de Corri-
geHeapDescendo(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 6= 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, hmaior < hi 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 Cor-
rigeHeapDescendo sobre um vetor com n elementos. Note que a cada chamada
recursiva o problema diminui consideravelmente de tamanho. Se estamos na iteração
correspondente a um elemento H[i], a próxima chamada recursiva será na subárvore

91
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 = log3/2 n e assumindo T (1) = 1, temos

T (n) ≤ 1 + log3/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 0 (n) = T 0 (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) = Θ(nlog3/2 1 ). Assim,
utilizando o caso ((2)) do Teorema Mestre, concluı́mos que T 0 (n) = Θ(log n). Portanto,
T (n) = O(log n).
O outro algoritmo importante para recuperação da propriedade de heap que men-
cionamos 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
heap. Potencialmente, o pai pode ter causado falha na propriedade também. Por isso,

92
100 100 125

17 36 17 125 17 100

12 8 125 1 12 8 36 1 12 8 36 1

7 2 5 7 2 5 7 2 5

Figura 8.3: Exemplo de execução da chamada CorrigeHeapSubindo(H, 10).

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 = bi/2c
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 `x o nı́vel de um nó que está na posição x da heap (isto é,
`x = blog xc). Vamos provar o resultado por indução no nı́vel `i do nó i.
Quando `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 `k < `i .
Considere então uma execução de CorrigeHeapSubindo(H, i). Note que se H[i]
tem prioridade menor ou igual à de que seu pai, então o teste na linha 2 falha e o

93
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 = bi/2c. 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, `p < `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).

8.1.1 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 bn/2c + 1 elementos de H são folhas e, portanto, são heaps
de tamanho 1. O elemento H[bn/2c], 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[bn/2c − 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.

94
Algoritmo 20: ConstroiHeap(H)
1 para i = 1 até H. tamanho faça
2 H[i]. indice = i
3 para i = bH. tamanho /2c 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 = bn/2c, então precisamos verificar se, para todo
j tal que bn/2c + 1 ≤ j ≤ n, a árvore com raiz H[j] é um heap. Perceba que tal árvore
é composta somente pelo elemento H[j], pois como j > bn/2c, 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

95
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 Cor-
rigeHeapDescendo, 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 dn/2h+1 e
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 = dn/20+1 e folhas, então a base está
verificada. Seja 1 ≤ h ≤ blog nc e suponha que existem no máximo dn/2h e 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 dn/2h e/2 elementos, que implica que existem no máximo
dn/2h+1 e 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:

blog nc l
X n m
T (n) ≤ C(h + 1)
h=0
2h+1
blog nc blog nc+1 ∞
X h+1 X i X i
= Cn h+1
= Cn i
≤ Cn .
h=0
2 i=1
2 i=1
2i

Note que para todo i ≥ 1, vale que (i + 1)/2i+1 /(i/2i ) < 1. Assim, temos que

∞ ∞
X i Cn X i
T (n) ≤ Cn ≤ 1 = Cn.
i=1
2i 2 i=1

Portanto,
T (n) = O(n) .

96
Figura 8.4: Execução do ConstroiHeap sobre o vetor H = [3, 1, 5, 8, 2, 4, 7, 6, 9].

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

8.1.3 Inserção em um heap binário


Para inserir um novo elemento x em uma heap H, primeiro verificamos se há capa-
cidade 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),

98
Algoritmo 22: InsereNaHeap(H, x)
1 se H. tamanho 6= 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).

8.1.4 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 Corri-
geHeapSubindo ou a CorrigeHeapDescendo, é fácil ver que o tempo de execução

99
dele é O(log n).

100
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 A1 , A2 , . . . , Am
é uma partição de um conjunto B se para cada Ai temos que Ai ⊆ B, Ai ∩ Aj = ∅
para todo i 6= j e A1 ∪ · · · Am = 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.
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

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

103
104
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 910 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
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.

106
Algoritmos de ordenação

“ enquanto emOrdem(vetor) == false:


embaralha(vetor)”

Algoritmo Bogosort
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 = (a1 , a2 , . . . , an ) com com n números, obter uma permutação


(a01 , a02 , . . . , a0n ) desses números de modo que a01 ≤ a02 ≤ . . . ≤ a0n .

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
argumento simples será usado muito nas discussões de corretude dos algoritmos que
veremos.

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

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

2 5 1 4 3 2 5 1 4 3 1 2 5 4 3 1 2 4 5 3

2 5 5 4 3 1 2 5 5 3 1 2 4 5 5

2 2 5 4 3 1 2 4 5 3 1 2 4 4 5

1 2 5 4 3 1 2 3 4 5

Figura 11.1: Execução do InsertionSort no vetor A = (2, 5, 1, 4, 3).

112
11.1.1 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 1.1.1 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.

11.1.2 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 ri é 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 ni=2 (ri ) vezes, e as linhas 5 e 6 são executadas
P
Pn
i=2 (ri − 1) vezes cada uma. Assim, o tempo de execução T (n) de InsertionSort é

113
dado por
n
X n
X
T (n) = n + 3(n − 1) + ri + 2 (ri − 1) + 1
i=2 i=2
n
X n
X
= 4n − 2 + 3 ri − 2 1
i=2 i=2
n
X
= 2n + 3 ri .
i=2

Note que para de fato sabermos a eficiência do algoritmo InsertionSort, precisa-


mos saber o valor de cada ri , 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
ri = 1 para 2 ≤ i ≤ n e
n
X
T (n) = 2n + 3 ri
i=2

= 5n − 3
= Θ(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

114
valor i do laço para, de modo que, nesse caso, ri = i. Assim, temos
n
X
T (n) = 2n + 3 ri
i=2
2
= n + 2n − 6
= Θ(n2 ) . (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(n2 )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) = a2 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.

11.1.3 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 é Θ(n2 ) mas podemos dizer
que o tempo dele no pior caso é.

115
Quando vistas de forma separada, todas as instruções de todas as linhas do Inser-
tionSort 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 1 + 2 + . . . + n − 1 = n(n − 1)/2 = Θ(n2 ) 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) = an2 + 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) = Θ(n2 ). Para verificar que essa
informação é de fato verdadeira, basta tomar n0 = 1 e notar que para todo n ≥ n0
temos an2 ≤ an2 + bn + c ≤ (a + b + c)n2 , 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

k
X
f (n) = ai n i ,
i=1

onde ai é constante para 1 ≤ i ≤ k, e ak > 0, temos f (n) = Θ(nk ).

11.2 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

116
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. Por
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 h0 -ordenar
um vetor que já está h-ordenado, para h0 < 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

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(n3/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.

117
118
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 bn/2c e dn/2e, 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 1945.
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 f im, 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 < f im então
2 meio = b(inicio + f im)/2c
3 MergeSort(A, inicio, meio)
4 MergeSort(A, meio + 1, f im)
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
Combina (linha 5).
Figura 12.1: Execução de MergeSort(A, 1, 8) para A = (7, 3, 1, 10, 2, 8, 15, 6).

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..f im] 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
(resp. j) para que esse elemento não seja considerado novamente. Veja o procedimento

120
Combina formalizado no Algoritmo 30.

Algoritmo 30: Combina(A, inicio, meio, f im)


1 n1 = meio − inicio + 1
2 n2 = f im − meio
3 Crie vetores auxiliares B[1..n1 ] e C[1..n2 ]
4 para i = 1 até n1 faça
5 B[i] = A[inicio + i − 1]
6 para j = 1 até n2 faça
7 C[j] = A[meio + j]
8 i=1
9 j=1
10 k = inicio
11 enquanto i < n1 e j < n2 faça
12 se B[i] ≤ C[j] então
13 A[k] = B[i]
14 i=i+1
15 senão
16 A[k] = C[j]
17 j =j+1
18 k =k+1
19 enquanto i < n1 faça
20 A[k] = B[i]
21 i=i+1
22 k =k+1
23 enquanto j < n2 faça
24 A[k] = C[j]
25 j =j+1
26 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 f im 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
na linha 6 é executado f im − meio vezes, e os laços enquanto das linhas 11, 19 e 23

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

são executados ao todo f im − 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, f im) onde n = f im − 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 (bn/2c) + T (dn/2e) + Θ(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).

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

13.1 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
unidade1 .
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á
estava ordenado em uma unidade. Ademais, esse subvetor da direita sempre contém os
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.
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 indiceM ax = i
3 para j = 1 até i − 1 faça
4 se A[j] > A[indiceM ax] então
5 indiceM ax = 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) é Θ(n2 ).
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.
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á

124
Figura 13.1: Execução de SelectionSort(A, 5) no vetor A = (2, 5, 1, 4, 3).

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

13.2 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
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,

126
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 + 1
6 se maior 6= i então
7 troca H[i] com H[maior]
8 CorrigeHeapDescendo(H, maior)

Algoritmo 33: ConstroiHeap(H)


1 para i = bH. tamanho /2c até 1 faça
2 CorrigeHeapDescendo(H, i)

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 CorrigeHeapDes-
cendo, a prova de corretude do algoritmo Heapsort é bem simples. Utilizaremos a
seguinte invariante de laço.

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

128
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 inicial-


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

129
Claramente, esse algoritmo tem tempo de execução O(n log n). De fato, Cons-
troiHeap é 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).

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

14.1 Bubble sort


Em breve.

14.2 Quicksort
O Quicksort tem tempo de execução de pior caso Θ(n2 ), 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ô.
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 f im, e seu objetivo é ordenar o subvetor A[inicio..f im]. 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 < f im então
2 p = Particiona(A, inicio, f im)
3 troque A[p] com A[f im]
4 x = Particiona(A, inicio, f im)
5 Quicksort(A, inicio, x − 1)
6 Quicksort(A, x + 1, f im)

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 f im, e considera
que o pivô é A[f im]. Seu objetivo é particionar A[inicio..f im] 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

132
Figura 14.1: Execução de Quicksort(A, 1, 10), onde A = (3, 9, 1, 2, 7, 4, 8, 5, 0, 6).

133
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[f im]
2 i = inicio
3 para j = inicio até f im − 1 faça
4 se A[j] ≤ pivo então
5 troca A[i] e A[j]
6 i=i+1
7 troca A[i] e A[f im]
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 é Θ(f im − 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[f im] 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 14.2

134
Figura 14.2: Execução de Particiona(A,
135 1, 7), onde A = (3, 8, 6, 1, 5, 2, 4).
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 preocu-
par com a condição pivo = A[f im] na invariante por enquanto, dado que A[f im] 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 = f im, 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[f im].

Para provar que o algoritmo Quicksort funciona corretamente, usaremos indução


no valor de n = f im − 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
f im.

Teorema 14.3: Corretude de Quicksort

O algoritmo Quicksort ordena qualquer vetor A de modo não-decrescente.

136
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..f im] 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 f im − 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..f im] estará ordenado. Portanto, todo o vetor A fica ordenado
ao final da execução de Quicksort.

14.2.1 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 = f im − 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
X
= T (1) + i
i=2
(n + 1)(n − 2)
=1+
2
2
= Θ(n ) .

137
Intuitivamente, conseguimos perceber que esse é o pior caso possı́vel. Formalmente,
o tempo de execução de pior caso é dado por T (n) = max0≤x≤n−1 (T (x)+T (n−x−1))+n.
Vamos utilizar indução para mostrar que T (n) ≤ n2 . Supondo que T (m) ≤ m2 para
todo m < n, obtemos

T (n) ≤ max (cx2 + c(n − x − 1)2 ) + n


0≤x≤n−1

≤ (n − 1)2 + n
= n2 − (2n − 1) + n
≤ n2 ,

onde o máximo na primeira linha é atingido quando x = 0 ou x = n − 1. Para ver


isso, seja f (x) = x2 + (n − x − 1)2 e note que f 0 (x) = 2x − 2(n − x − 1), de modo
que f 0 ((n − 1)/2) = 0. Assim, (n − 1)/2 é um ponto máximo ou mı́nimo. Como
f 00 ((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 = 10100 ), 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

138
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  n (k − 1)n (k − 1)n (k − 1)n
≤d log + +d log + +n
k k k k k k
  n 
n  n  (k − 1)n 
=d log +d log(k − 1) + log + 2n
k 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 ≥ log(k − 1) + n .
k

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 é logk/(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

139
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 o1 , . . . , on os elementos de A em sua ordenação final (após estarem ordenados


de modo não-decrescente), i.e., o1 ≤ o2 ≤ · · · ≤ on e não necessariamente oi = A[i].
A primeira observação importante é que dois elementos oi e oj 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 Xij como a variável aleatória indicadora para
o evento “oi é comparado com oj ”. Claramente,

n−1 X
X n
X= Xij .
i=1 j=i+1

Utilizando a linearidade da esperança, concluı́mos que

n−1 X
X n
E[X] = E[Xij ]
i=1 j=i+1
n−1 X
X n
= P (oi ser comparado com oj ) . (14.1)
i=1 j=i+1

Vamos então calcular P (oi ser comparado com oj ). Comecemos notando que para
oi ser comparado com oj , um dos dois precisa ser o primeiro elemento de Oij =
{oi , oi+1 , . . . , oj } a ser escolhido como pivô. De fato, caso ok , com i < k < j, seja
escolhido como pivô antes de oi e oj , então oi e oj 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 (oi ser comparado com oj ) = P (oi ou oj ser o primeiro a ser escolhido como pivô em Oij )
2
= .
j−i+1

140
Assim, voltando à (14.1), temos

n−1 X
n
X 2
E[X] =
i=1 j=i+1
j−i+1
n−1 X
n
X 1
<2
i=1 k=1
k
n−1
X
= 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).

141
142
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 qual-
quer 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 2k
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
2k ≥ 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.
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. encias de elementos de {0, . . . , i} em A */
de ocorr^
6 para i = 1 até k faça
7 C[i] = C[i] + C[i − 1]
ao de A em B
/* Colocando o resultado da ordenaç~ */
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, respectiva-
mente, 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

144
Figura 15.1: Execução do CountingSort(A, 6), onde A = (3, 0, 5, 4, 3, 0, 1, 2).

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

146
Técnicas de construção de algoritmos

“(...) the more comfortable one is with the full array of


possible design techniques, 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.


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 oti-
mizaçã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).
150
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 subpro-


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

16.1 Multiplicação de inteiros


Considere o seguinte problema.

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

5678
×1234
2 2712
17 0340
+11 3 5600
56 7 8000
70 0 6652

A seguir provamos que ele está de fato correto, isto é, para quaisquer dois inteiros
x e y, ele retorna xy. Seja y = y1 y2 . . . yn , onde yi é um dı́gito de 0 a 9. Note que o
algoritmo faz

(x × yn ) + (x × yn−1 × 10) + · · · + (x × y2 × 10n−2 ) + (x × y1 × 10n−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 × yn ), 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 × yn−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 é, 2n2 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 2n2 + n operações. Logo, temos
que o tempo de execução desse algoritmo é O(n2 ), 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.
Sejam a, b, c e d números inteiros com n/2 dı́gitos cada tais que x = 10n/2 a + b e
y = 10n/2 c + d. No exemplo anterior, com x = 5678 e y = 1234, temos a = 56, b = 78,

152
c = 12 e d = 4. Podemos então escrever

xy = (10n/2 a + b)(10n/2 c + d) = 10n ac + 10n/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 = 10n/2 a + b e y = 10n/2 c + d, onde a, b, c e d são números com n/2
dı́gitos cada
4 p1 = MultiplicaInteiros(a, c, n/2)
5 p2 = MultiplicaInteiros(a, d, n/2)
6 p3 = MultiplicaInteiros(b, c, n/2)
7 p4 = MultiplicaInteiros(b, d, n/2)
8 retorna 10n p1 + 10n/2 (p2 + p3 ) + p4

É 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(n2 ), 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 é
O(n1.59 ). Logo, no pior caso, o algoritmo de Karatsuba é melhor do que o algoritmo
básico de multiplicação.

153
Algoritmo 39: Karatsuba(x, y, n)
1 se n == 1 então
2 retorna xy
3 Seja x = 10n/2 a + b e y = 10n/2 c + d, onde a, b, c e d são números com n/2
dı́gitos cada
4 p1 = MultiplicaInteiros(a, c, n/2)
5 p2 = MultiplicaInteiros(a, d, n/2)
6 p3 = MultiplicaInteiros(a + b, c + d, n/2 + 1)
7 retorna 10n p1 + 10n/2 (p3 − p1 − p2 ) + p2

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

17.1 Escalonamento de tarefas compatı́veis


Seja um conjunto T = {t1 , . . . , tn } com n tarefas onde cada ti ∈ T tem um tempo
inicial si e um tempo final fi indicando que, se selecionada, a tarefa ti acontece no
intervalo [si , fi ). Dizemos que duas tarefas ti e tj são compatı́veis se os intervalos [si , fi )
e [sj , fj ) não se sobrepõem, isto é, si ≥ fj ou sj ≥ fi . Considere o seguinte problema.
Veja a Figura 17.1 para um exemplo.

Problema 17.1: Escalonamento de tarefas compatı́veis


Figura 17.1: Conjunto T = {t1 , t2 , . . . , t10 } de tarefas e seus respectivos intervalos.
Note que {t3 , t9 , t10 } é uma solução viável para essa instância. As soluções viáveis
{t1 , t4 , t8 , t10 } e {t2 , t4 , t8 , t10 }, no entanto, são ótimas.

Dado conjunto T = {t1 , . . . , tn } com n tarefas onde cada ti ∈ T tem um


tempo inicial si e um tempo final fi , 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 fi ) é
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 f1 ≤ f2 ≤ · · · ≤ fn
3 S = {t1 }
4 k = 1 /* k mantém o ı́ndice da última tarefa adicionada à S */
5 para i = 2 até n faça
6 se si ≥ fk então
7 S = S ∪ {ti }
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 t1 temos a tarefa que termina primeiro. Essa é a
primeira escolha do algoritmo. Em seguida, dentre as tarefas restantes, são escolhidas
apenas aquelas que começam após a última tarefa escolhida. Dessa forma, garantimos

156
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 = {t1 , . . . , tn } com n tarefas onde cada ti ∈ T tem um tempo


inicial si e um tempo final fi , o algoritmo EscalonaCompativel(T , n) retorna
uma solução ótima para o problema de Escalonamento de tarefas compatı́veis.

Demonstração. Denote por Tk = {ti ∈ T : si ≥ fk }, isto é, o conjunto das tarefas que
começam após o fim da tarefa tk . Seja tx ∈ Tk uma tarefa que termina primeiro em Tk
(com menor fi em Tk ). Note que EscalonaCompativel escolhe tx . Vamos supor que
essa escolha não está presente em nenhuma solução ótima, isto é, se Sk ⊆ Tk é uma
solução ótima para Tk , então tx ∈
/ Sk .
Seja ty ∈ Sk uma tarefa que termina primeiro em Sk (com menor fi em Sk ). Monte
o conjunto Sk0 = (Sk \ {ty }) ∪ {tx }. Note que, como ambas tx e ty estão em Tk , temos
que fx ≤ fy . E como fy ≤ fz para qualquer tz ∈ Sk , temos que Sk0 é uma solução
viável para Tk (é um conjunto de tarefas mutuamente compatı́veis). Mas note que
|Sk | = |Sk0 |, de forma que Sk0 deve, portanto, ser solução ótima para Tk 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).

17.2 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 18.3 apresenta a versão da mochila inteira.

157
Figura 17.2: Instância do problema da mochila onde W = 50, v1 = 60, w1 = 10,
v2 = 100, w2 = 20, v3 = 120 e w3 = 30.

Problema 17.1: Mochila fracionária

Dado um conjunto I = {1, 2, . . . , n} de n itens onde cada i ∈ I tem um peso wi e


um valor vi associados e dada uma mochila com capacidade de peso W , selecionar
frações fi ∈ [0, 1] dos itens tal que ni=1 fi wi ≤ W e ni=1 fi vi é máximo.
P P

Veja a Figura 17.2 para um exemplo.


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 f1 = 0, f2 = 1 e f3 = 1, de custo 220, mas sabemos que existe
solução melhor (logo, essa não é ótima). É importante observar que para mostrar que

158
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 wv (valor/peso). No exemplo da Figura 17.2, temos
v1
w1
= 6, wv22 = 5 e wv33 = 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 f1 = 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 f2 = 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
é 23 , portanto faça f3 = 23 . Veja que essa é de fato a solução ótima do exemplo dado.
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.

Algoritmo 41: MochilaFracionaria(I, n, W )


1 Ordene os itens pela razão valor/peso e os renomeie de forma que
v1
w1
≥ wv22 ≥ · · · ≥ wvnn
2 capacidade = W
3 Seja f [1..n] um vetor
4 i = 1
5 enquanto i ≤ n e capacidade ≥ wi faça
6 f [i] = 1
7 capacidade = capacidade − wi
8 i=i+1
9 se i ≤ n então
10 f [i] = capacidade/wi
11 para j = i + 1 até n faça
12 f [j] = 0
13 retorna f

O algoritmo funciona inicialmente ordenando os itens e renomeando-os para ter

159
v1
w1
≥ wv22 ≥ · · · ≥ wvnn . 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 (fi = 1) na ordem da razão
valor/peso enquanto eles couberem inteiros na mochila (wi ≤ capacidade). O próximo
item, se existir, é pego de fracionadamente (linha 10). Nenhum outro item é considerado,
tendo fi = 0 (laço da linha 11). Note que a solução gerada é de fato viável, tem custo
Pn Pn
i=1 f [i]vi e vale que i=1 f [i]wi = W .

Lema 17.2

Dado um conjunto I = {1, 2, . . . , n} de n itens onde cada i ∈ I tem um peso


wi e um valor vi 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] 6= 0).

Monte uma solução f 0 a partir de f ∗ da seguinte forma:

1. f 0 [j] = f ∗ [j] para todo j < i;

2. f 0 [i] = f [i];

3. f 0 [j], para i < j ≤ n, recebe valores de f ∗ [j] corrigidos apropriadamente para que
wi (f 0 [i] − f ∗ [i]) = nj=i+1 wj (f ∗ [j] − f 0 [j]) para manter f 0 uma solução viável.
P

160
Por construção,

n
X Xi−1 n
X
0 ∗ 0
f [j]vj = ( f [j]vj ) + f [i]vi + f 0 [j]vj
j=1 j=1 j=i+1
Xn X n n
X
∗ ∗ ∗ 0
=( f [j]vj − f [i]vi − f [j]vj ) + f [i]vi + f 0 [j]vj
j=1 j=i+1 j=i+1
n
X n
X
= f ∗ [j]vj + vi (f 0 [i] − f ∗ [i]) − vj (f ∗ [j] − f 0 [j])
j=1 j=i+1
n n
X wi X wj
= f ∗ [j]vj + vi (f 0 [i] − f ∗ [i]) − vj (f ∗ [j] − f 0 [j])
j=1
wi j=i+1 wj
n n
X
∗ vi 0 ∗
X vi ∗
≥ f [j]vj + (f [i] − f [i])wi − (f [j] − f 0 [j])wj (17.1)
j=1
w i j=i+1
w i

n n
!
X v i
X
= f ∗ [j]vj + f 0 [i] − f ∗ [i])wi − (f ∗ [j] − f 0 [j])wj
j=1
w i j=i+1
n
X
= f ∗ [j]vj , (17.2)
j=1

v
onde (17.1) vale porque wvii ≥ wjj e (17.2) vale pelo item 3 da construção de f 0 . Com
isso, concluı́mos que f 0 não é pior do que f ∗ . De fato, como f ∗ é ótima, concluı́mos
que f 0 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).

17.3 Compressão de dados


Considere o seguinte problema.

161
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 fi 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 “0010000100”. 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 “0100010”. No entanto, “0100010”
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.

162
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 dT (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 fi , encontrar uma


árvore binária T cujas folhas são rotuladas com elementos de A e o custo c(T ) =
P
i∈A fi dT (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

163
Figura 17.5: Exemplo de execução de Huffman com A = {a, b, c, d}, fa = 60, fb = 25,
fc = 10 e fd = 5. O custo final da árvore é c(T ) = fa + 2fb + 3fc + 4fd .

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

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 A0 = (A \ {a, b}) ∪ {ab}
5 Defina fab = fa + fb
6 T 0 = Huffman(A0 , f )
7 Construa T a partir de T 0 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 Θ(n2 ) no pior

164
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 fi , 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 A0 a partir de A substituindo ambos a e b por um
novo elemento ab e defina a frequência desse novo elemento como sendo fab = fa + fb .
Note que existe uma bijeção entre “árvores cujas folhas são rotuladas com sı́mbolos
de A0 ” e “árvores cujas folhas são rotuladas com sı́mbolos de A onde a e b são irmãos”.

165
Vamos chamar o conjunto de árvores desse último tipo de Ta,b . Seja T̂ 0 uma árvore cujas
folhas são rotuladas com sı́mbolos de A0 e seja T̂ uma árvore de Ta,b . Por definição,
X
c(T̂ ) = fi dT̂ (i) + fa dT̂ (a) + fb dT̂ (b) ,
i∈A\{a,b}

e
X
c(T̂ 0 ) = fi dT̂ 0 (i) + fab dT̂ 0 (ab) .
i∈A0 \{ab}

Como A \ {a, b} = A0 \ {ab}, temos que

c(T̂ ) − c(T̂ 0 ) = fa dT̂ (a) + fb dT̂ (b) − fab dT̂ 0 (ab) .

Além disso, dT̂ (a) = dT̂ (b) = dT̂ 0 (ab) + 1 e fab = fa + fb , por construção. Então temos
c(T̂ ) − c(T̂ 0 ) = fa + fb , o que independe do formato das árvores.

Agora note que, por hipótese de indução, o algoritmo encontra uma árvore T 0
que é ótima para A0 (isto é, minimiza c(T 0 ) dentre todas as árvores para A0 ). Então
diretamente pela observação acima, a árvore correspondente T construı́da para A é
ótima dentre as árvores contidas em Ta,b . Com isso, basta mostrar que existe uma
árvore ótima para A (dentre todas as árvores para A) que está contida em Ta,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̄ ∈ Ta,b . Seja B = A \ {x, y, a, b}. Temos, por definição,
X
c(T ∗ ) = fi dT ∗ (i) + fx dT ∗ (x) + fy dT ∗ (y) + fa dT ∗ (a) + fb dT ∗ (b) ,
i∈B

e
X
c(T̄ ) = fi dT ∗ (i) + fx dT ∗ (a) + fy dT ∗ (b) + fa dT ∗ (x) + fb dT ∗ (y) .
i∈B

Assim,

c(T ∗ ) − c(T̄ ) = fx (dT ∗ (x) − dT ∗ (a)) + fy (dT ∗ (y) − dT ∗ (b)) + fa (dT ∗ (a) − dT ∗ (x)) + fb (dT ∗ (b) − dT ∗ (y))
= (fx − fa )(dT ∗ (x) − dT ∗ (a)) + (fy − fb )(dT ∗ (y) − dT ∗ (b)) .

166
Pela nossa escolha, dT ∗ (x) ≥ dT ∗ (a), dT ∗ (y) ≥ dT ∗ (b), fa ≤ fx e fb ≤ fy . Então,
c(T ∗ ) − c(T̄ ) ≥ 0, isto é, c(T ∗ ) ≥ c(T̄ ), o que só pode significar que T̄ também é
ótima.

167
168
Programação dinâmica

“Dynamic programming is a fancy name for


divide-and-conquer with a table.”

Ian Parberry — Problems on Algorithms, 1995.

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.
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 23.1.2) e Floyd-Warshall (Seção 23.2.1).

170
18.1 Sequência de Fibonacci
A sequência 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 . . . é conhecida como sequência de Fibonacci.
Por definição, o n-ésimo número da sequência, escrito como Fn , é dado por



 1 se n = 1

Fn = 1 se n = 2 (18.1)



F
n−1 + Fn−2 se n > 2 .

Introduzimos na Seção 2.1.3 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 Fn .

Pela definição de Fn , 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
√ n 
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 , vamos provar que T (n) ≥ xn
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,

171
temos que
T (2) ≥ x2 ,

para todo x ≥ 3 ≈ 1, 732.
Suponha que T (m) ≥ xn para todo 2 ≤ m ≤ n − 1. Assim, aplicando isso a T (n)
temos

T (n) = T (n − 1) + T (n − 2) + 1
≥ xn−1 + xn−2
≥ xn−2 (1 + x) .
√ √
Note que 1 + x ≥ x2 sempre que (1 − 5)/2 ≤ x ≤ (1 + 5)/2. Portanto, fazendo

x = (1 + 5)/2 e substituindo em T (n), obtemos
√ !n−2 √ !!
1+ 5 1+ 5
T (n) ≥ 1+
2 2
√ !n−2 √ !2
1+ 5 1+ 5

2 2
√ !n
1+ 5
=
2
≈ (1, 618)n .

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 indi-
cam 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

172
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 cons-
tante, e existem n − 1 subproblemas (calcular F1 , F2 , . . ., Fn−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.

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

18.2 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 pi . 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 p1 , . . . , pn 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.

Considere uma barra de tamanho 6 com preços dos pedaços dados por:

p1 p2 p3 p4 p5 p6
3 8 14 15 10 20
Temos várias possibilidades de cortá-la e vender os pedaços. Por exemplo, se a barra
for vendida sem nenhum corte, então temos lucro 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, o que fornece um lucro de p5 + p1 = 13. Caso efetuemos um corte de
tamanho 4, o que aparentemente é uma boa opção (dado que p4 é um valor alto), então
o melhor a se fazer é vender uma parte de tamanho 4 e outra de tamanho 2, obtendo

174
lucro p4 + p2 = 23. Outra opção ainda seria vendermos dois pedaços de tamanho 3,
obtendo lucro total de 2p3 = 28. De todas as possibilidades, queremos a que permita o
maior lucro possı́vel que, nesse caso, é de fato 28.
Veja que é relativamente fácil resolver esse problema: basta enumerar todas as
formas possı́veis de cortar a barra, calcular o custo de cada forma e guardar o melhor
valor possı́vel. No entanto, existem 2n−1 formas diferentes de cortar uma barra de
tamanho n pois, para cada ponto que está à distância i do extremo da barra, com
1 ≤ i ≤ n − 1, temos a opção de cortar ali ou não. Além disso, para cada forma
diferente de cortar a barra, levamos tempo O(n) para calcular seu custo. Ou seja, esse
algoritmo leva tempo O(n2n ) para encontrar uma solução ótima para o problema.
Um algoritmo que enumera todas as possibilidades de solução, testa sua viabilidade
e calcula seu custo é chamado de algoritmo de algoritmo de força bruta. Eles utilizam
muito esforço computacional para encontrar uma solução e ignoram quaisquer estruturas
combinatórias do problema. Vamos então fazer algumas observações sobre a forma de
uma solução para tentar construir outro algoritmo para o problema do corte de barras.
Seja Lm o maior lucro obtido ao cortar uma barra de tamanho m dados os preços
pi de venda das barras de tamanho i. Claramente, L0 = 0. Note que se cortarmos
um pedaço de tamanho i, com 1 ≤ i ≤ n, então temos uma barra de tamanho n − i
restante. Ou seja, reduzimos o tamanho do problema: de uma barra de tamanho n
para uma de tamanho n − i. Note ainda que se o pedaço de tamanho i está em uma
solução ótima, então o lucro total é dado por Ln = pi + Ln−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. Como não sabemos exatamente qual é o valor de i, podemos
simplesmente tentar todos os valores de i possı́veis. Portanto, temos

Ln = max (pi + Ln−i ) . (18.2)


1≤i≤n

A igualdade (18.2) sugere um algoritmo bem simples recursivo para resolver o problema,
mostrado no Algoritmo 47.
Com uma indução simples em n e usando a Equação 18.2, podemos mostrar que o
algoritmo CorteBarras está de fato correto. Apesar disso e de ser um algoritmo
intuitivo, ele é extremamente ineficiente, pois muito trabalho é repetido. De fato,
seja T (n) o tempo de execução de CorteBarras(n, p). Claramente, T (0) = 1 e

175
Algoritmo 47: CorteBarras(n, p)
1 se n == 0 então
2 retorna 0
3 lucro = −1
4 para i = 1 até n faça
5 valor = pi + CorteBarras(n − i, p)
6 se valor > lucro então
7 lucro = valor
8 retorna lucro

T (n) = 1 + ni=1 T (n − i). Vamos utilizar o método da substituição para provar que
P

T (n) ≥ 2n . Claramente temos T (0) = 1 = 20 . Suponha que T (m) ≥ 2m para todo


0 ≤ m ≤ n − 1. Por definição de T (n),

T (n) = 1 + T (0) + T (1) + · · · + T (n − 1) ≥ 1 + (20 + 21 + · · · + 2n−1 ) = 2n .

Assim, esse algoritmo não é tão melhor se comparado ao algoritmo de força bruta.
De fato, esse algoritmo repete muito trabalho porque vários subproblemas são
resolvidos recursivamente diversas vezes. No entanto, existem apenas n−1 subproblemas
diferentes: o de calcular Ln−1 , o de calcular Ln−2 , e assim por diante, até o de calcular
L1 . Podemos então, com programação dinâmica, utilizar um vetor simples para
armazenar os valores de cada um desses subproblemas e acessar o valor diretamente
quando necessário. O Algoritmo 48 é uma variação de CorteBarras que, cada vez
que um subproblema é resolvido, o valor é salvo em um vetor B. Ele foi escrito com a
abordagem top-down. O algoritmo também mantém um vetor S tal que S[j] contém o
primeiro lugar onde deve-se efetuar um corte em uma barra de tamanho j.

Algoritmo 48: CorteBarras-TopDown(n, p)


1 Cria vetores B[0..n] e S[0..n] globais
2 B[0] = 0
3 para i = 1 até n faça
4 B[i] = −1
5 retorna CorteBarrasRecursivo-TopDown(n, p)

176
Algoritmo 49: CorteBarrasRecursivo-TopDown(m, p)
1 se B[m] == −1 então
2 lucro = −1
3 para i = 1 até m faça
4 valor = pi + CorteBarrasRecursivo-TopDown(m − i, p)
5 se valor > lucro então
6 lucro = valor
7 S[m] = i
8 B[m] = lucro
9 retorna B[m]

O algoritmo CorteBarras-TopDown(n, p) cria os vetores B e S, inicializa B[0]


com 0 e as entradas restantes de B com −1, representando que ainda não calculamos
esses valores. Feito isso, CorteBarrasRecursivo-TopDown(n, p) é executado.
O primeiro passo do algoritmo CorteBarrasRecursivo-TopDown(m, p) é
verificar se o subproblema em questão já foi resolvido (linha 1). Caso o subproblema
não tenha sido resolvido, então o algoritmo vai fazer isso de modo muito semelhante ao
Algoritmo 47. A diferença é que agora salvamos o melhor local para fazer o primeiro
corte em uma barra de tamanho m em S[m] e o maior lucro obtido em B[m]. A linha 9
é executada sempre, seja retornando o valor que já havia em B[m] (quando o teste da
linha 1 falha), ou retornando o valor recém calculado (linha 8).
Vamos analisar agora o tempo de execução de CorteBarras-TopDown(n, p)
que 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 da linha 3 é executado m vezes. Assim, como existem subproblemas de
tamanho 0, 1, . . . , m − 1, o tempo de execução T (m) de CorteBarrasRecursivo-
TopDown(m, p) é assintoticamente dado por

T (m) = 1 + 2 + · · · + m = Θ(m2 ) .

177
Note que o algoritmo apenas retorna o lucro obtido pelos cortes da barra. Caso
precisemos de fato construir uma solução (descobrir o tamanho dos pedaços em que a
barra foi cortada), podemos utilizar o vetor S. Para cortar uma barra de tamanho n e
obter seu lucro máximo B[n], cortamos um pedaço S[n] da mesma, o que significa que
sobrou um pedaço de tamanho n − S[n]. Para cortar essa barra de tamanho n − S[n]
e obter seu lucro máximo B[n − S[n]], cortamos um pedaço S[n − S[n]] da mesma.
Essa ideia é sucessivamente repetida até que tenhamos uma barra de tamanho 0. O
procedimento é formalizado no Algoritmo 50.

Algoritmo 50: ImprimeCortes(n, S)


1 enquanto n > 0 faça
2 Imprime S[n]
3 n = n − S[n]

Note que para calcular B[m], o algoritmo CorteBarrasRecursivo-TopDown


acessa todas as posições B[m − i], para todo 1 ≤ i ≤ m. Podemos então escrevê-lo de
forma não recursiva, preenchendo B da esquerda para a direita, de forma que não é
necessário verificar se de fato os valores necessários para B[m] já foram calculados. O
Algoritmo 51 formaliza essa ideia, da abordagem bottom-up.

Algoritmo 51: CorteBarras-BottomUp(n, p)


1 Cria vetores B[0..n] e S[0..n]
2 B[0] = 0
3 para i = 1 até n faça
4 lucro = −1
5 para j = 1 até i faça
6 se pj + B[i − j] > lucro então
7 lucro = pj + B[i − j]
8 S[i] = j
9 B[i] = lucro
10 retorna B[n]

178
18.3 Mochila inteira
O problema da mochila é um dos clássicos em computação. Nessa seção veremos a
versão da mochila inteira. A Seção 17.2 apresenta a versão da mochila fracionária.

Problema 18.1: Mochila inteira

Dado um conjunto I = {1, 2, . . . , n} de n itens onde cada i ∈ I tem um peso wi e


um valor vi associados e dada uma mochila com capacidade de peso W , selecionar
um subconjunto S ⊆ I dos itens tal que ni∈S wi ≤ W e i∈S vi é máximo.
P P

Por exemplo, considere n = 3, v1 = 60, w1 = 10, v2 = 100, w2 = 20, v3 = 120,


w3 = 30 e W = 50. Temos várias possibilidades de escolher itens que caibam nessa
mochila. Por exemplo, podemos escolher apenas o item 1, o que dá um peso total de
10 ≤ W e valor total de 60. Outra possibilidade melhor seria escolher apenas o item
3, o que dá um peso total de 30 ≤ W e valor total melhor, de 120. Uma opção ainda
melhor é escolher ambos itens 1 e 2, dando peso total 30 ≤ W e valor total 220. A
melhor opção de todas no entanto, que é a solução ótima, é escolher os itens 2 e 3, cujo
peso total é 50 ≤ W e valor total 220.
Veja que é relativamente fácil resolver o problema da mochila por força bruta: basta
enumerar todos os subconjuntos possı́veis de itens, verificar se eles cabem na mochila,
calcular o valor total e guardar o melhor possı́vel de todos. No entanto, existem 2n
subconjuntos diferentes de itens pois, para cada item, temos a opção de colocá-lo ou
não no subconjunto. Para cada subconjunto, levamos tempo O(n) para checar se os
itens cabem na mochila e calcular seu valor total. Ou seja, esse algoritmo leva tempo
O(n2n ) e, portanto, não é eficiente.
Para facilitar a notação, vamos dizer que uma instância da mochila inteira é a
4-upla (In , v, w, W ), onde In = {1, 2, . . . , n}, v e w indicam, respectivamente, o valor
e o peso dos itens de In , e W é a capacidade da mochila. Vamos também denotar
por Vn,W o valor da melhor solução possı́vel para a instância (In , v, w, W ). Vamos
analisar a estrutura de uma solução ótima S ∗ para a instância (In , v, w, W ), isto é,
P
i∈S ∗ vi = Vn,W . É claro que não sabemos quais itens a compõem (caso contrário não
precisarı́amos tentar encontrá-la), mas sabemos que S ∗ ⊆ In . Em particular, o item
n ∈ In pode estar ou não em S ∗ (não há outra opção para ele).

179
Se n ∈ / S ∗ , perceba que S ∗ ⊆ {1, 2, . . . , n−1} é uma solução possı́vel para a instância
(In−1 , v, w, W ). Em particular, ela deve ser ótima para tal instância. Isto porque se
houvesse outra solução S 0 ⊆ In−1 cujo valor i∈S 0 vi fosse maior do que o valor i∈S ∗ vi ,
P P

então poderı́amos usar S 0 como solução melhor para a instância (In , v, w, W ), o que é
uma contradição com o fato de que S ∗ era ótima para tal instância. Assim, temos que,
nesse caso, Vn,W = Vn−1,W .
Agora, se n ∈ S ∗ , então perceba que (S ∗ \ {n}) ⊆ {1, 2, . . . , n − 1} é uma solução
possı́vel para a instância (In−1 , v, w, W − wn ). Em particular, ela também deve ser
ótima para tal instância. Isto porque se houvesse outra solução S 0 ⊆ In−1 cujo valor
0
P P
i∈S 0 vi fosse maior do que o valor i∈S ∗ \{n} vi , então poderı́amos usar S ∪ {n} como
solução melhor para a instância (In , v, w, W ), o que é uma contradição com o fato de
S ∗ ser ótima para tal instância. Assim, temos que, nesse caso, Vn,W = vn + Vn−1,W −wn .
Mas como saber se n está ou não na solução ótima? Dado que temos apenas duas
opções para essa resposta, podemos testar ambas e retornar a melhor delas. Assim,
pela discussão acima,
(
max{Vn−1,W , Vn−1,W −wn + vn } se wn ≤ W
Vn,W = (18.3)
Vn−1,W se wn > W

É claro que se não houver itens, o melhor valor possı́vel de ser obtido é 0, independente
da capacidade da mochila, isto é, V0,x = 0 para todo 0 ≤ x ≤ W . A equação (18.3)
nos dá diretamente um algoritmo recursivo bem simples para resolver o problema,
formalizado no Algoritmo 52.

Algoritmo 52: MochilaInteira(n, v, w, W )


1 se n == 0 então
2 retorna 0
3 se wn > W então
4 retorna MochilaInteira(n − 1, v, w, W )
5 senão
6 usa = vn MochilaInteira(n − 1, v, w, W − wn )
7 naousa = MochilaInteira(n − 1, v, w, W )
8 retorna max{usa, naousa}

Não é difı́cil perceber que o tempo de execução T (n) de MochilaInteira é, no

180
pior caso, descrito pela recorrência T (n) = 2T (n − 1), cuja solução é O(2n ). Também
não é difı́cil perceber que o problema desse algoritmo está no fato de ele realizar as
mesmas chamadas recursivas diversas vezes, pois, ao todo, temos no máximo nW
subproblemas diferentes apenas (um para cada par j ∈ {1, . . . , n} e x ∈ {1, . . . , W }).
Assim, podemos usar uma estrutura de dados para manter seus valores e acessá-los
diretamente sempre que necessário ao invés de recalculá-los. Poderı́amos utilizar um
vetor com nW entradas, uma para cada subproblema, porém utilizar uma matriz de
dimensões n × W nos permite um acesso mais intuitivo. Assim, a ideia é armazenar em
M [j][x] o valor Vj,x , de forma que nosso objetivo é calcular M [n][W ]. O Algoritmo 53
formaliza a ideia dessa estratégia de programação dinâmica com a abordagem top-down
enquanto que o Algoritmo 55 o faz com a abordagem bottom-up.

Algoritmo 53: MochilaInteira-TopDown(n, v, w, W )


1 Seja M [0..n][0..W ] uma matriz global
2 para x = 0 até W faça
3 M [0][x] = 0 para j = 1 até n faça
4 M [j][x] = −1
5 retorna MochilaInteiraRecursivo-TopDown(n, v, w, W )

Algoritmo 54: MochilaInteiraRecursivo-TopDown(n, v, w, W )


1 se M [n][W ] == −1 então
2 se wn > W então
3 M [n][W ] = MochilaInteira(n − 1, v, w, W )
4 senão
5 usa = vn MochilaInteira(n − 1, v, w, W − wn )
6 naousa = MochilaInteira(n − 1, v, w, W )
7 M [n][W ] = max{usa, naousa}
8 retorna M [n][W ]

A tabela a seguir mostra o resultado final da matriz M após execução dos algoritmos
sobre a instância onde n = 4, W = 7, w1 = 1, v1 = 10, w2 = 3, v2 = 40, w3 = 4,
v3 = 50, w4 = 5 e v4 = 70:

181
Algoritmo 55: MochilaInteira-BottomUp(n, v, w, W )
1 Seja M [0..n][0..W ] uma matriz
2 para x = 0 até W faça
3 M [0][x] = 0
4 para j = 1 até n faça
5 para x = 0 até W faça
6 se wn > W então
7 M [j][x] = M [j − 1][x]
8 senão
9 usa = vj + M [j − 1][x − wj ]
10 naousa = M [j − 1][x]
11 M [j][x] = max{usa, naousa}

12 retorna M [n][W ]

item ↓ \ capacidade → 0 1 2 3 4 5 6 7
0 0 0 0 0 0 0 0 0
1 0 10 10 10 10 10 10 10
2 0 10 10 40 50 50 50 50
3 0 10 10 40 50 60 60 90
4 0 10 10 40 50 70 80 90

Não é difı́cil perceber que o tempo de execução desses algoritmos de programação


dinâmica para o problema da mochila inteira é Θ(nW ). Agora veja que esse algoritmo
não possui tempo polinomial no tamanho da entrada. O parâmetro W é um número,
e seu tamanho é log W , que é a quantidade de bits necessária para armazená-lo. A
função nW pode ser escrita como n2log W e essa sim está em função do tamanho da
entrada. Infelizmente, ela é exponencial no tamanho da entrada. Esse algoritmo é
o que chamamos de pseudo-polinomial. Seu tempo de execução será bom se W for
pequeno.
Com relação à solução ótima, sabemos que seu valor é M [n][W ], mas não sabemos
quais itens a compõem. No entanto, a maneira como cada célula da matriz foi preenchida
nos permite descobri-los. Veja o Algoritmo 56, que claramente executa em tempo Θ(n).

182
Algoritmo 56: ConstroiMochila(n, v, w, W , M )
1 S = ∅
2 x = W
3 j = n
4 enquanto i ≥ 1 faça
5 se M [j][x] == M [j − 1][x − wj ] + vj então
6 S = S ∪ {i}
7 x = x − wj
8 j =j−1
9 retorna S

18.4 Alinhamento de sequências


Um alinhamento de duas sequências de caracteres X e Y é obtido inserindo-se espaços
(gaps) nas sequências para que elas fiquem com o mesmo tamanho e cada caractere
ou espaço de uma fique emparelhado a um único caractere ou espaço da outra. Não
podem haver espaços emparelhados com espaços.
Por exemplo, sejam X = AGGGCT e Y = AGGCA. Dois alinhamentos possı́veis
para elas são:

A G G G C T A G G G C − T
e
A G G − C A A G G − C A −

Dadas dias sequências, várias são as possibilidades de alinhá-las. Por exemplo, o


primeiro caractere de X pode ser alinhado com um gap, ou com o primeiro caractere
de Y , ou com o segundo, ou com o sétimo, ou com o último, etc. Assim, é necessário
uma forma de comparar os vários alinhamentos e descobrir qual é o melhor deles. Para
isso, também nos é dada uma função de pontuação α, onde α(a, b) indica a penalidade
por alinhas os caracteres a e b e α(gap) indica a penalidade por alinhar um caractere
com um gap 1 .
Suponha que α(a, b) = −4 se a 6= b, α(a, a) = 2 e α(gap) = −1. O alinhamento
da esquerda dado acima tem pontuação 3 enquanto que o alinhamento da direita tem
pontuação 5.
1
Existem variações onde caracteres diferentes têm penalidades diferentes ao serem alinhados com
gaps.

183
Podemos então definir formalmente este problema.

Problema 18.1: Alinhamento de sequências

Dadas duas sequências X e Y sobre um mesmo alfabeto A, onde X = x1 x2 . . . xm ,


Y = y1 y2 . . . yn , xi , yj ∈ A e uma função α de pontuação, encontrar um alinhamento
entre X e Y de pontuação máxima.

Uma vez alinhadas, cada caractere ou espaço de uma sequência fica emparelhado
com um caractere ou espaço de outra – apenas não há espaço emparelhado com espaço
–, assim, podemos nos referir a uma “posição do alinhamento”. Nos dois exemplos
acima, na posição 4 de ambos, o caractere G está alinhado com um gap. Seja O∗ um
alinhamento ótimo de X e Y para a função α. Note que na posição final de O∗ só
podemos ter um dos três casos:

1. xm está alinhado com yn ,

2. xm está alinhado com um gap,

3. yn está alinhado com um gap.

Seja X 0 = x1 x2 . . . xm−1 (a sequência X sem o caractere xm ) e seja Y 0 a sequência


Y sem o caractere yn . Seja ainda O0 o alinhamento O∗ sem a última posição. Com
argumentos simples de contradição, é possı́vel provar que:

• se 1 acontece em O∗ , então O0 é ótimo para X 0 e Y 0 ,

• se 2 acontece em O∗ , então O0 é ótimo para X 0 e Y ,

• se 3 acontece em O∗ , então O0 é ótimo para X e Y 0 .

Seja Pi,j a pontuação máxima para alinhar x1 x2 . . . xi com y1 y2 . . . yj , com 1 ≤ i ≤ m


e 1 ≤ j ≤ n. Pela discussão acima:

 α(xi , yj ) + Pi−1,j−1

Pi,j = max α(gap) + Pi−1,j (18.4)

α(gap) + Pi,j−1

184
Claramente, P0,j = jα(gap) e Pi,0 = iα(gap) são as pontuações obtidas ao alinhar
todos os caracteres com gap.
O Algoritmo 57 mostra um algoritmo de programação dinâmica na abordagem
bottom-up para o problema do alinhamento de sequências.

Algoritmo 57: Alinhamento-BottomUp(X, m, Y , n, α)


1 Seja M [0..m][0..n] uma matriz
2 para i = 0 até m faça
3 M [i][0] = i × α(gap)
4 para j = 0 até n faça
5 M [0][j] = j × α(gap)
6 para i = 1 até m faça
7 para j = 1 até n faça
8 M [i][j] =
max{M [i−1][j −1]+α(xi , yj ), M [i−1][j]+α(gap), M [i][j −1]+α(gap)}
9 retorna M [m][n]

185
186
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.
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
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 {v1 , . . . , vn } é dito simples quando não existem
arestas do tipo {vi , vi } e, para cada par de ı́ndices 1 ≤ i < j ≤ n, existe no máximo
uma aresta {vi , vj }. De modo similar, um digrafo com conjunto de vértices {v1 , . . . , vn }
é dito simples quando não existem arestas do tipo (vi , vi ) e, para cada par de ı́ndices,
1 ≤ i < j ≤ n existe no máximo uma aresta (vi , vj ) e no máximo uma aresta (vj , vi ).
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 NG (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
mesmo extremo também são chamadas de adjacentes.
Figura 19.1: Representação gráfica de um grafo G e um digrafo D.

O grau de um vértice v, denotado por dG (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{dG (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{dG (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., P
¯ v∈V (G) d(v)
d(G) = .
|V (G)|

19.1 Formas de representar um grafo


Certamente podemos representar grafos simplesmente utilizando conjuntos para vértices
e arestas. Porém, é desejável utilizar alguma estrutura de dados que nos permita
ganhar em eficiência dependendo da tarefa que necessitamos. As duas formas mais
comuns de se representar um grafo são listas de adjacências e matriz de adjacências.

192
Figura 19.2: Representação gráfica de um grafo G e um digrafo D e suas listas 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 LG com |V (G)| entradas, uma para cada vértice, onde LG [u]
contém uma lista encadeada com todos os vizinhos de u em G. Isto é, em LG [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 = (mij ) de tamanho |V (G)| × |V (G)| onde mij = 1 se
ij ∈ E, e mij = 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 mij = 1 se (i, j) ∈ A, e
mij = 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 Θ(n2 ) 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 Θ(n2 ) arestas. Porém, esse não
é o único fator importante na escolha da estrutura de dados utilizada para representar

193
Figura 19.3: Representação gráfica de um grafo G e um digrafo D e suas matrizes de
adjacências.

um grafo, pois determinados algoritmos precisam de propriedades da representação


por listas e outros da representação por matriz para serem eficientes.

19.2 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 = (v0 , v1 , . . . , vk ) tal que vi vi+1 ∈ E para todo 0 ≤ i < k. Dizemos que P é um
passeio de v0 a vk e que P alcança os vértices vi , com 1 ≤ i ≤ k, e as arestas vi vi+1 ,
com 1 ≤ i < k. Também dizemos que esses vértices e arestas são alcançáveis a partir
de v0 . Os vértices v0 e vk são, respectivamente, o começo e o fim de P , e os vértices
v1 , . . . , vk−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 ) = {v0 , v1 , . . . , vk }, e denotamos por E(P )

o conjunto de arestas que fazem parte de P , i.e., E(P ) = v0 v1 , v1 v2 , . . . , vk−1 vk . O
comprimento de P é a quantidade de arestas de P . Note que na definição de passeio
podem existir vértices ou arestas repetidas.
Passeios em que não há repetição de arestas são chamados de trilhas. Caso um

194
Figura 19.4: Passeios, trilhas, ciclos e caminhos.

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 Pn . 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 Cn .
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 C3 ” 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 é
minimal com respeito a uma propriedade P se G possui a propriedade P e não contém

195
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, alternativa-
mente, é um grafo conexo sem ciclos.

Figura 19.5: Exemplos de árvores.

196
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.
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 58 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.

198
Algoritmo 58: 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 Vs (G) e Es (G) os vértices e arestas, respectivamente, que estão no componente
que contém um certo vértice s. Sejam ns = |Vs (G)|, ms = |Es (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 ns 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 Θ(ns n) = O(n2 ). Porém, se utilizarmos
listas de adjacências, então o laço para é executado |N (u)| vezes apenas, de modo
P
que, no total, ele é executado u∈Vs (G) |N (u)| = 2ms vezes, e então o tempo total de
execução do algoritmo é Θ(ns + ms ).

199
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 6= 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 ).

20.1.1 Distância entre vértices


Dado um grafo G, a distância entre dois vértices u e v, denotada por distG (u, v) é a
menor quantidade de arestas de um caminho entre u e v. Quando não existe caminho
entre u e v, definimos distG (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 59 contém poucas
diferenças com relação ao algoritmo BuscaLargura anterior: as linhas 3 e 11. Essas

200
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 59: 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 +1
12 v. predecessor = u
13 Enfileira(F , v)

Seja T a árvore com conjunto de vértices V (T ) = {v ∈ V : v. predecessor =


6 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
distG (s, v) ≤ distG (s, u) + 1 .

201
Lema 20.2

Sejam G = (V, E) um grafo e s ∈ V (G). Ao fim da execução de BuscaLargu-


raDistancia(G, s) temos que, para todo v ∈ V (G),

v. distancia ≥ distG (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 ≥ distG (s, s) = 0 e v. distancia = ∞ ≥ distG (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 ≥ distG (s, u). Mas note que, pela linha 11 e utilizando o Fato 20.1, temos

v. distancia = u. distancia +1 ≥ distG (s, u) + 1 ≥ distG (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.

202
Lema 20.3

Sejam G = (V, E) um grafo e s ∈ V (G). Considere a execução de BuscaLar-


guraDistancia(G, s). Para todos os pares de vértices u e v na fila tal que u

203
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, v1 , . . . , v` )
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 u1 , . . ., uh à fila F , deixando F =
(v1 , . . . , v` , u1 , . . . , uh ). O algoritmo então faz uj . distancia = u. distancia +1 para todo
vizinho uj não visitado de u (executando o laço para). Utilizando a hipótese de
indução, sabemos que para todo 1 ≤ i ≤ ` temos

u. distancia ≤ vi . distancia ≤ u. distancia +1 .

Assim, ao adicionar à fila um vizinho uj de u (lembre que u foi removido da fila) temos,
pela desigualdade acima, que, para todo 1 ≤ i ≤ `,

vi . distancia ≤ u. distancia +1 = uj . distancia = u. distancia +1 ≤ vi . distancia +1 .

Por hipótese de indução (lembrando que o valor em uj . distancia não muda depois de mo-
dificado), sabemos que os pares em {u, v1 , . . . , v` } satisfazem a conclusão do lema. Ade-
mais, 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 {v1 , . . . , v` , u1 , . . . , uh } satis-
fazem 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.

204
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 = distG (s, v) .

Demonstração. Suponha, por contradição, que ao fim da execução de BuscaLargu-


raDistancia(G, s) exista algum vértice v ∈ V (G) com v. distancia 6= distG (s, v). Seja
v o vértice com menor v. distancia tal que v. distancia 6= distG (s, v). Pelo Lema 20.2,
sabemos que
v. distancia > distG (s, v) . (20.1)

Seja u o vértice que precede v em um sv-caminho mı́nimo. Então, distG (s, v) =


distG (s, u) + 1. Pela escolha de v, portanto, u. distancia = distG (s, u). Assim,
usando (20.1), temos que

v. distancia > distG (s, v) = distG (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).

205
20.1.2 Componentes conexas
Os algoritmos BuscaLargura e BuscaLarguraDistancia como vistos anterior-
mente 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 60: 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.

20.2 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

206
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 61 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 6=
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 Vs (G)

207
Algoritmo 61: 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 6= ∅ 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 + 1
13 u. fim = encerramento
14 u = Desempilha(P )

e Es (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(|Vs (G)|), e a condição na linha 7 é testada uma vez para
cada vizinho de cada vértice visitado, de modo que é executada O(|Es (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(|Vs (G)| + |Es (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 63 descreve o
pseudocódigo para esse algoritmo, enquanto o Algoritmo 62 mostra como utilizar a
busca em profundidade para visitar todos os vértices do grafo, mesmo que o grafo seja

208
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 20.1.2. 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.

209
Algoritmo 62: 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 63: 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 + 1
8 u. fim = encerramento

210
20.2.1 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) 6= f (v) se u 6= 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
(v1 , v2 , . . . , vk ) tal que k ≥ 3 e (vi , vi+1 ) é uma aresta para todo 1 ≤ i ≤ k − 1, e (vk , v1 )
é 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 64 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.

211
Algoritmo 64: 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 +1
10 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.

212
Figura 20.4: Uma ordenação topológica obtida com uma execução de OrdenacaoTo-
pologica no grafo da Figura 20.3.

213
Lema 20.1

Dado digrafo acı́clico D, a rotulação f retornada por OrdenacaoTopolo-


gica(G) é uma ordenação topológica.

Demonstração. Claramente, f (u) ∈ {1, . . . , |V (D)|} para todo u ∈ V (D) e f (u) 6= f (v)
sempre que u 6= 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 vu-
caminho. 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).

20.2.2 Componentes fortemente conexas


Seja D um digrafo e D1 , . . . , Dk 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 Di e Dj existem arestas
apenas em uma direção, caso contrário a união de Di e Dj formaria uma componente
maior que as duas sozinhas, contradizendo a maximalidade da definição. Por isso,
sempre deve existir ao menos uma componente Di que é um ralo: não existe aresta
saindo de Di 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.

214
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 62) 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 de-


crescente 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 BuscaComponen-


tes(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 descon-
siderar 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 65.

Algoritmo 65: 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 65 acima funciona em tempo O(|V (D)| + |A(D)|).

215
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 é
bipartido1 , 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.

216
Á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
P
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 0 ) : T 0 é 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


P
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) é
mı́nima se tem peso mı́nimo dentre todas as arestas que cruzam esse mesmo corte
Figura 21.1: Exemplo de um grafo G e uma árvore geradora mı́nima (representada
pelas arestas destacadas).

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

218
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, VG \ 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 0 essa árvore. Claramente, temos w(T 0 ) = 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 0 ) ≤ w(T ), então concluı́mos que T 0 é 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.

21.1 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 66. Lembre-se que, dado um grafo G = (V, E) e um

219
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 66: 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 Fi 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[Fi ∪ {e}], essa aresta é sozinha no corte. Sendo sozinha,
pelo resultado do Lema 21.2, não existem ciclos em G[Fi ∪ {e}]. Logo, e é de fato
escolhida para ser adicionada a Fi . 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)

220
o conjunto de vértices da componente do grafo G[Fi ] 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(n2 ) em qualquer grafo
simples, temos que m log m ≤ m log(n2 ) = 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 ]

221
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 67 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 67: 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) 6= 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

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

21.2 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 68.
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.

223
b 4 e 3 h

-1 3 3 1

a 8 c 5 f

9 4 2

d 4 g 0 i

(a) Grafo G de entrada. O vértice c foi escolhido como


inicial arbitrariamente.
b 4 e 3 h b 4 e 3 h

-1 3 3 1 -1 3 3 1

a 8 c 5 f a 8 c 5 f

9 4 2 9 4 2

d 4 g 0 i d 4 g 0 i

(b) Primeira iteração: escolhidos = {c}; (c) Segunda iteração: escolhidos = {c, e};
aresta de menor custo que liga um não aresta de menor custo que liga um não
escolhido a um escolhido = ce. escolhido a um escolhido = eh.
b 4 e 3 h b 4 e 3 h

-1 3 3 1 -1 3 3 1

a 8 c 5 f a 8 c 5 f

9 4 2 9 4 2

d 4 g 0 i d 4 g 0 i

(d) Terceira iteração: escolhidos = (e) Quarta iteração: escolhidos =


{c, e, h}; aresta de menor custo que liga {c, e, h, f }; aresta de menor custo que liga
um não escolhido a um escolhido = hf . um não escolhido a um escolhido = f g.
b 4 e 3 h b 4 e 3 h

-1 3 3 1 -1 3 3 1

a 8 c 5 f a 8 c 5 f

9 4 2 9 4 2

d 4 g 0 i d 4 g 0 i

(f) Quinta iteração: escolhidos = (g) Sexta iteração: escolhidos =


{c, e, h, f, g}; aresta de menor custo que {c, e, h, f, g, i}; aresta de menor custo que
liga um não escolhido a um escolhido = liga um não escolhido a um escolhido =
gi. gd.
b 4 e 3 h b 4 e 3 h

-1 3 3 1 -1 3 3 1

a 8 c 5 f a 8 c 5 f

9 4 2 9 4 2

d 4 g 0 i d 4 g 0 i

(h) Sétima iteração: escolhidos = (i) Oitava iteração: escolhidos =


{c, e, h, f, g, i, d}; aresta de menor custo224{c, e, h, f, g, i, d, b}; aresta de menor custo
que liga um não escolhido a um escolhido que liga um não escolhido a um escolhido
= eb. = ba.

Figura 21.2: Exemplo de execução de Prim.


Algoritmo 68: Prim(G = (V, E), w)
1 Seja S = {s}, onde s ∈ V (G) é um vértice qualquer
2 Seja F = ∅
3 enquanto S 6= 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 21.2.
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

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

226
Algoritmo 69: 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

227
228
Trilhas Eulerianas

Uma trilha em um grafo G é uma sequência de vértices v1 , . . . , vk tal que vi vi+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.
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 70, 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 70: 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 G1 = G
9 enquanto dGi (T [i]) ≥ 1 faça
10 se existe aresta {T [i], w} para algum w ∈ V (G) que não seja ponte em Gi
então
11 T [i + 1] = w
12 senão
13 T [i + 1] = z, onde {T [i], z} é ponte de Gi
14 i=i+1
15 Gi+1 = Gi − 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.

230
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 dG (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 Ti 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, Ti é 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 Ti−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,
dGi−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 Ti−1 , pois sempre que uma aresta é adicionada a trilha atual ela é removida
de EG (veja linha 13). Portanto, concluı́mos que

no inı́cio da execução da i-ésima iteração do laço enquanto, Ti é 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 Gi . Como ao fim da execução do algoritmo temos dGi (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 Ti .

231
Logo, Ti é 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 =
(VG , EG \ E(T )). Seja V≥1 os vértices v de H com dH (v) ≥ 1. Seja V0 := V (G) \ V≥1 .
Assim, para todo vértice v ∈ V0 temos dH (v) = 0 (não confunda dH (v) com dG (v)).
Como o grafo inicial G é conexo, em G existe pelo menos uma aresta entre V0 e
V≥1 . Assim, seja xy a última aresta da trilha T tal que x ∈ V≥1 e y ∈ V0 . Esse fato
juntamente com o fato do vértice final de T estar em V0 (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 V0 e V≥1 e a trilha T
termina em um vértice de V0 , 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 dH (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.

232
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 = (v0 , v1 , . . . , vk ) como a soma dos pesos das arestas em P , i.e.,

k−1
X
w(P ) = w(vi vi+1 ) .
i=0

Definimos a distância entre dois vértices u, v ∈ V (G), denotada por por distw
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,
w
distG (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
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 distw
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


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

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

234
23.1.1 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.

235
Algoritmo 71: 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.

236
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 é

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

238
23.1.2 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 “f also” caso exista algum ciclo
de peso negativo em G. O algoritmo de Bellman-Ford é descrito formalmente no
Algoritmo 72.

Algoritmo 72: 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

239
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 cor-


retamente, 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
10–12 é 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).

240
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, v1 , v2 , . . . , vk ) é um caminho mı́nimo de s a vk , então
o seguinte vale. Se as arestas sv1 , v1 v2 , . . ., vk−1 vk forem relaxadas nessa ordem,
então temos vk . distancia = dist(s, vk ) após essas relaxações.

Demonstração. Provaremos o resultado por indução na quantidade de arestas de um


caminho mı́nimo P = (s, v1 , v2 , . . . , vk ). 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, v1 , v2 , . . . , vk ) de s a vk com k arestas e
suponha que as arestas sv1 , v1 v2 , . . ., vk−1 vk foram relaxadas nessa ordem. Note que
como P 0 = (s, v1 , v2 , . . . , vk−1 ) é um caminho dentro de um caminho mı́nimo, então P 0
também é um caminho mı́nimo. Assim, como as arestas de P 0 , a saber sv1 , v1 v2 , . . .,
vk−2 vk−1 , foram relaxadas na ordem do caminho e P 0 tem k − 1 arestas, concluı́mos por
hipótese de indução que vk−1 . distancia = dist(s, vk−1 ). Caso vk . distancia = dist(s, vk ),
então a prova está concluı́da. Assim, podemos assumir que

vk . distancia > dist(s, vk ) = dist(s, vk−1 ) + w(vk−1 vk ) .

Logo, ao relaxar a aresta vk−1 vk , o algoritmo vai verificar que vk . distancia > dist(s, vk ) =
dist(s, vk−1 ) + w(vk−1 vk ), atualizando o valor de vk . distancia como abaixo.

vk . distancia = vk−1 . distancia +w(vk−1 vk )


= dist(s, vk−1 ) + w(vk−1 vk )
= dist(s, vk ) .

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 sv1 , v1 v2 , . . ., vk−1 vk . O Lema 23.3 garante que se as arestas

241
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, v1 , v2 , . . . , vk ) de s a um vértice vk , 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
vk não é alcançável a partir de s, então temos v. distancia = ∞ e não é difı́cil verificar

242
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, v1 , v2 , . . . , vk ) um caminho mı́nimo de s a
um vértice arbitrário vk 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 v0 = 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
vi−1 vi é relaxada na iteração i. Assim, as arestas v0 v1 , v1 v2 , . . ., vk−1 vk são relaxadas
nessa ordem. Pelo Lema 23.3, temos vk . distancia = dist(s, vk ). 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 “f also”, então G
contém um ciclo de peso negativo.

Demonstração. Se Bellman-Ford(G = (V, E), w, s) retorna “f also”, 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

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

23.2 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 + n2 ) 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 n2 log n + nm .

(23.3)

Para grafos densos (i.e., grafos com Θ(n2 ) arestas), esse valor representa um tempo de
execução da ordem de
O n3 .


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 Θ(n2 m), o que no caso de grafos densos é da
ordem de
Θ(n4 ) .

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,

244
−4
2 2 3 5
17 −10 −10 10

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 Θ(n3 ) 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).

23.2.1 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 Θ(n3 ). 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 P1 = (i, . . . , x, k) e P2 = (k, y, . . . , j) onde P1 é um
ik-caminho mı́nimo que só tem vértices internos em {1, 2, . . . , k − 1} e P2 é 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.

245
Assim, conseguimos definir uma estrutura recursiva para resolver esse problema. Seja
Di,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

Di,j,k = min{Di,j,k−1 , Di,k,k−1 + Dk,j,k−1 } . (23.4)

Quando k = 0, significa que o caminho não tem vértices internos, logo



 0
 se i = j
Di,j,0 = w(ij) se ij ∈ E e i 6= j . (23.5)

∞ se i 6= j e ij ∈
/ E(G)

Nossa intenção é, portanto, calcular Di,j,n para todo par i, j ∈ V (G).
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] = Di,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 73 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 74 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) é Θ(n3 ), o que é bem
melhor que o tempo Θ(n4 ) 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(n3 ) (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

246
Algoritmo 73: 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

247
Algoritmo 74: 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

248
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 75.

Algoritmo 75: 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 76 mostra como construir um caminho mı́nimo entre dois vértices


quaisquer após a execução correta de ResolveCaminhosEntreTodosPares: se ` é
o predecessor de j em um ij-caminho, basta construir o i`-caminho e depois acrescentar
a aresta `j.

Algoritmo 76: ConstroiCaminho(i, j)


1 se j. predecessor[i] 6= i então
2 ConstroiCaminho(i, j. predecessor[i])
3 Coloque j no caminho

23.2.2 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 G0 = (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 w0 : E(G0 ) → 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, distw G (s, v) entre s e todo vértice v ∈ V (G). Agora vem um passo

249
importantı́ssimo, que é transformar os pesos da função w em pesos não negativos,
formando a função w0 . O novo peso de cada aresta uv será dado por

w0 (uv) = distw w

G (s, u) + w(uv) − distG (s, v) . (23.6)

Note que dada uma aresta uv, sempre temos distw w


G (s, u)+w(uv) ≥ distG (s, v). Portanto,
a função w0 é composta por pesos não negativos. Podemos aplicar Dijkstra(G0 , w0 ,
x) n vezes, uma para cada x ∈ V (G), calculando os caminhos mı́nimos de u a v no
grafo G0 com função de pesos w0 para todo par de vértices u, v.
Não é difı́cil mostrar que dado um caminho P = (v1 , . . . , vk ) 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
w0 . Para calcular o valor dos caminhos mı́nimos em G com a função de pesos original
w basta fazer, para cada par uv,

0
distw w w w
G (u, v) = distG (u, v) + distG (s, v) − distG (s, u) .

O seguinte fato garante que a igualdade acima coloca o peso correto em distw G (u, v):
0
seja P = (u = v1 , . . . , vk = v) um caminho mı́nimo de u a v com função w . Assim,
utilizando (23.6), obtemos

0
0 0
distw
G (u, v) = w (v1 v2 ) + · · · + w (vk−1 vk )

= w(v1 v2 ) + · · · + w(vk−1 vk )
+ distw w w
G (s, v1 ) + distG (s, v2 ) + · · · + distG (s, vk−1 )

− distw w w
G (s, v2 ) − · · · − distG (s, vk−1 ) − distG (s, vk )

= w(v1 v2 ) + · · · + w(vk−1 vk ) + distw w


G (s, u) − distG (s, v)

= distw w w
G (u, v) + distG (s, u) − distG (s, v) .

0
Portanto, de fato temos distw w w w
G (u, v) = distG (u, v) + distG (s, v) − distG (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 vi a vj .
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

250
Algoritmo 77: Johnson(G = (V, E), w)
0 0
1 Crie grafo G = (V, E), onde V (G ) = V (G) ∪ {s} e
0
E(G ) = E(G) ∪ {sv : v ∈ VG }
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) == f also 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. distancias = distw
G (s, u)

9 para toda aresta uv ∈ E(G0 ) faça


10 w0 (uv) = u. distancias +w(uv) − v. distancias
11 para todo vértice u ∈ V (G) faça
0
12 Execute Dijkstra(G0 , w0 , u) para fazer v. distancia = distw
G (u, v)
∀v ∈ V (G)
13 para todo vértice v ∈ V (G) faça
14 D[u][v] = v. distancia +v. distancias −u. distancias
15 retorna D

251
grafo, é o que determina o tempo de execução de Johnson.

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


Nesta parte

A maioria dos problemas que vimos até aqui neste livro são ditos tratáveis. São
problemas para os quais existem algoritmos eficientes para resolvê-los.

Definição 23.1

Um algoritmo é dito eficiente se seu tempo de execução no pior caso é O(nk ),


onde n é o tamanho da entrada do algoritmo e k é um inteiro positivo que não
depende de n.

Busca (1.1), Ordenação (10.1), Mochila fracionária (17.1), Corte de barras (18.1),
Árvore geradora mı́nima (21.1), Caminhos mı́nimos em grafos (23.1 e 23.2) são alguns
exemplos de problemas tratáveis. No entanto, muitos problemas, até onde se sabe, não
possuem algoritmos eficientes que os resolvam, como é o caso do problema da Mochila
inteira (18.1), por exemplo. Estes são ditos intratáveis.
Na verdade, muitos problemas interessantes e com fortes motivações e aplicações
práticas são intratáveis, como por exemplo escalonar um conjunto de tarefas a proces-
sadores, interligar de forma barata computadores especı́ficos em uma rede com diversos
outros computadores que podem ser usados como intermediários, cortar placas de
vidros em pedaços de tamanhos especı́ficos desperdiçando pouco material, ou decompor
um número em fatores primos. Para esses problemas, não se tem muita esperança
de encontrar algoritmos eficientes que os resolvam, porém felizmente existem vários
algoritmos eficientes que encontram boas soluções.
Nos capı́tulos a seguir veremos mais sobre a teoria envolvendo esses tipos de
problemas e formas de lidar com os mesmos.
256
Complexidade computacional

Definição 24.1

Um problema de decisão é um problema cuja solução é uma resposta sim ou


n~
ao.

Por exemplo, o problema “dado um número, ele é par?” é um problema de decisão.


Outro problema de decisão é “dados um grafo G e dois vértices u, v ∈ V (G), existe
uv-caminho?”.

Problema 24.2: Caminho

Dados um grafo G com pesos nas arestas, dois vértices u, v ∈ V (G) e um valor
k, existe uv-caminho de peso no máximo k?

Note que os problemas anteriores têm objetivos diferentes do problema a seguir.

Problema 24.3: Caminho mı́nimo

Dados um grafo G com pesos nas arestas e dois vértices u, v ∈ V (G), qual o
uv-caminho de peso mı́nimo?

O problema do caminho mı́nimo descrito acima é um problema de otimização.


Sim Sim Sim Sim Não Não Não
··· ··· ···
1 z |V |C

Figura 24.1: Exemplificação da discussão sobre a relação entre problemas de decisão e


problemas de otimização.

Definição 24.4

Um problema de otimização é um problema cuja solução deve ser a de melhor


valor dentre todas as soluções possı́veis.

Note, no entanto, que existe uma relação entre o Problema 24.2 e o Problema 24.3:
dada a mesma instância de entrada, se resolvermos um deles, então resolvemos o
outro, conforme a discussão a seguir. Seja G um grafo com pesos c nas arestas e sejam
u, v ∈ V (G) dois vértices quaisquer. Suponha primeiro que sabemos resolver o problema
do caminho mı́nimo e que z é o custo do menor uv-caminho. Se z ≤ k, então a resposta
para o problema de decisão certamente é sim, isto é, existe um uv-caminho com custo
menor que k (tome, por exemplo, o próprio uv-caminho mı́nimo). Se z > k, então a
resposta para o problema de decisão certamente é n~ ao, pois se o menor uv-caminho
tem custo maior do que k e qualquer outro uv-caminho tem custo maior que z, então
não é possı́vel existir um uv-caminho com custo no máximo k.
Agora suponha que sabemos resolver o problema do caminho (sabemos dizer sim ou
ao para qualquer valor de k). Seja C o custo da aresta de maior custo do grafo e seja
n~
n = |V (G)|. Note que qualquer uv-caminho terá custo no máximo nC pois ele pode no
máximo usar n − 1 arestas. Assim, basta testar todos os valores de k ∈ {1, 2, . . . , nC}
e, para o menor valor cuja solução for sim, temos a resposta para o caminho mı́nimo.
Veja a Figura 24.1.
Por esse motivo, toda a teoria que estudaremos neste capı́tulo foi feita com base nos
problemas de decisão. Nas seções a seguir definiremos importantes classes de problemas
e as dificuldades envolvidas nas soluções dos problemas presentes nas mesmas.

24.1 Classes P e NP

258
Definição 24.1: Classe P

P é o conjunto de todos os problemas de decisão que podem ser resolvidos por


um algoritmo eficiente.

Sabemos que o Problema 24.2, 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 o resolvem.
Outro exemplo de problema na classe P é o problema de decidir se um grafo
possui uma árvore geradora de peso total menor do que um valor k. Isso porque se
executarmos, por exemplo, o algoritmo de Prim e verificarmos se a árvore geradora
mı́nima devolvida tem peso menor que k, então sabemos que a resposta para o problema
de decisão é sim, caso contrário a resposta é n~
ao.
Ademais, não é difı́cil perceber que a maioria dos problemas vistos anteriormente
nesse livro, portanto, possuem uma versão de decisão correspondente que está em P.
Dizemos “a maioria”, pois nem todos os problemas do universo estão em P: existem
problemas para os quais ainda não se conhece algoritmos eficientes que os resolvam.
Um ciclo hamiltoniano é um ciclo que passa por todos os vértices de um grafo.
Considere agora o problema a seguir.

Problema 24.2: TSP-k

Dado um grafo G completo com custo nas arestas e um valor k, existe um ciclo
hamiltoniano de custo no máximo k?

TSP é uma sigla para Travelling Salesman Problem, nome em inglês de um famoso
problema em computação (o Problema do Caixeiro Viajante). Na versão de otimização,
mais famosa, o objetivo é encontrar um ciclo hamiltoniano de custo mı́nimo no grafo.
Veja que não é difı́cil pensar em um algoritmo simples de força bruta para resolvê-los:
podemos enumerar todas as n! permutações dos n vértices do grafo, calcular seu custo
e manter a menor delas. Claramente, esse algoritmo simples não é nem um pouco
eficiente.
Na verdade, o TSP-k é um problema que acredita-se não estar na classe P. Desde
sua origem, em torno de 1800, ninguém conseguiu encontrar um algoritmo eficiente

259
que o resolva.
Acontece que o fato de ninguém ter conseguido encontrar um algoritmo para um
problema não implica diretamente que ele não está em P; apenas significa que ninguém
ainda foi capaz de encontrá-lo. A área de projeto de algoritmos é muito rica e, apesar
de já existirem várias técnicas como de algoritmos gulosos ou divisão e conquista, novas
técnicas são criadas a todo momento. Será que em algum momento futuro alguém
conseguiria descobrir uma técnica diferente que resolva o TSP-k, por exemplo?
A afirmação “acredito que o TSP-k não está em P” não é feita apenas porque
ninguém conseguiu um algoritmo eficiente que resolva o TSP-k. Ela é feita porque
ninguém conseguiu um algoritmo eficiente que resolve muitos outros problemas que são
tão difı́ceis quanto o TSP-k! Antes de continuar nossa discussão, precisamos definir
a ideia de redução, que é muito importante para comparação de dificuldade entre
problemas.
Basicamente, redução é uma forma de converter um problema em outro de tal
forma que a solução do segundo possa ser usado para resolver o primeiro. Em outras
palavras, um algoritmo para o segundo problema pode ser usado como “caixa preta”
para resolver o primeiro. Por exemplo, no inı́cio deste capı́tulo mostramos como reduzir
o problema Caminho, de decisão, para o problema Caminho mı́nimo, de otimização, e
vice-versa. O problema “encontrar a mediana de um conjunto de números” pode ser
reduzido para o problema de ordenação: uma vez ordenado, a mediana do conjunto
é o número que está na posição central. O problema de calcular o quadrado de um
número pode ser reduzido ao problema de multiplicar dois números.
Especificamente, vamos utilizar redução entre dois problemas de decisão. No que
segue, se P é o nome de um problema, chamaremos de IP uma instância (entrada)
para P .

Definição 24.3: Redução polinomial

Sejam P e Q problemas de decisão. O problema P é redutı́vel para Q se existe


algoritmo eficiente f tal que f (IP ) = IQ onde IP é sim se e somente se IQ é sim.

A definição acima nos permite obter dois tipos de resultados importantes. Primeiro,
se sabemos resolver Q em tempo polinomial e conseguimos reduzir (polinomialmente) P
para Q, então automaticamente sabemos resolver P em tempo polinomial. Segundo, se

260
reduzimos P para Q e P não pode ser resolvido em tempo polinomial, então Q também
não pode ser resolvido em tempo polinomial (caso contrário usarı́amos a solução de Q
para obter uma para P ). Em resumo, de P é redutı́vel para Q, então Q é tão difı́cil
quanto P . O conceito de redução portanto nos permite tanto aumentar o conjunto de
problemas tratáveis quanto o dos intratáveis.
Voltando à discussão sobre o TSP-k, gostarı́amos de ter uma evidência da sua
intratabilidade podendo dizer que ele é tão difı́cil quanto muitos outros problemas.
Aqui entra a ideia da completude. Se X é um conjunto qualquer de problemas, dizemos
que P é X -completo se P ∈ X e se todos os outros problemas de X são redutı́veis a P .
Quer dizer, P é tão difı́cil quanto todos os outros problemas em X . Se tivermos TSP-k
pertencente a X e dissermos que todos os problemas de X são intratáveis, então nossa
afirmação terá mais impacto quanto maior for X .
Poderı́amos talvez pensar em X contendo todos os problemas conhecidos? Infeliz-
mente, alguns problemas conhecidos sequer podem possuir algoritmos que os resolvam,
sendo portanto estritamente mais difı́ceis do que o TSP-k (mesmo ruim, o algoritmo de
força bruta que descrevemos anteriormente o resolve). Esses problemas são chamados
indecidı́veis, sendo o mais famoso deles o problema da parada.

Problema 24.4: Parada

Dados um algoritmo e uma instância, a execução desse algoritmo sobre essa


instância termina?

E se pensarmos em X contendo os problemas que podem ser resolvidos por força


bruta? Note que todos os problemas desse tipo possuem algo em comum: uma solução
para eles pode ser facilmente reconhecida. Por exemplo, dada uma sequência de vértices
de um grafo, é fácil decidir se ela é um ciclo que contém todos os vértices do mesmo
em tempo polinomial. Ou então, dada uma sequência de vértices de um grafo, é fácil
decidir se ela é um caminho que tem custo menor do que um dado k. Um algoritmo
que toma esse tipo de decisão é chamado de algoritmo verificador.

Definição 24.5: Algoritmo verificador

Seja P um problema qualquer. Um algoritmo A é dito verificador se:

261
1. para toda instância IP que é sim, existe um conjunto de dados D tal que
A(IP , D) retorna sim; e

2. para toda instância IP que é n~


ao, qualquer conjunto de dados D faz A(IP , D)
retornar n~
ao.

D acima é chamado de certificado positivo.

Definição 24.6: Classe NP

NP é o conjunto de todos os problemas de decisão para os quais existe um


algoritmo verificador que aceita um certificado positivo.

Vejamos outros problemas que pertencem à classe NP.

Problema 24.7: Clique-k

Dados um grafo G e um inteiro positivo k, existe conjunto S ⊆ V (G) de vértices


tais que para todo par u, v ∈ S existe uma aresta uv ∈ E(G) (S é clique) e
|S| ≥ k?

No problema acima, a resposta é sim caso o subconjunto S exista e n~ ao caso


contrário. O problema Clique-k está em NP pois, dados G, k e um conjunto S
qualquer de vértices, é fácil escrever um algoritmo eficiente que verifique se S é uma
clique de tamanho pelo menos k: basta verificar se todos os pares de vértices em S
formam arestas e contar a quantidade de vértices de S.

Problema 24.8: Bipartido

Dado um grafo G, é possı́vel particionar V (G) em dois conjuntos S e V (G) \ S


tal que para toda aresta uv ∈ E(G), u ∈ S e v ∈ V (G) \ S?

O problema Bipartido está em NP pois, dados G e um conjunto S qualquer de


vértices, é fácil escrever um algoritmo eficiente que verifica se todas as arestas do grafo
possuem um extremo em S e outro não.
Note que todos os problemas em P também estão em NP, pois um algoritmo que

262
resolve o problema pode ser usado diretamente como verificador para o mesmo. Ou
seja, claramente temos P ⊆ NP. A grande questão é, será que NP ⊆ P?

Problema 24.9: P vs. NP

P é igual a NP?

Esse problema, porém, continua em aberto até os dias atuais. Dada sua importância,
ele é um dos Problemas do Milênio e o Clay Institute oferece um prêmio monetário de
$1.000.000, 00 para quem conseguir resolvê-lo1 .

24.2 Classe NP-completo

Definição 24.1: Classe NP-completo

NP-completo é o conjunto de problemas Q tais que Q ∈ NP e todo outro


problema de NP é redutı́vel a Q.

Pela definição acima e pela definição de redução, podemos concluir que se um único
algoritmo eficiente para resolver um problema NP-completo for encontrado, então
teremos um algoritmo eficiente para resolver todos os problemas em NP.

Teorema 24.2

Seja X um problema NP-completo. P = NP se e somente se X pertence a P.

Por isso, se quisermos dar uma forte razão da intratabilidade de um problema,


basta mostrarmos que ele é NP-completo.
Mas como mostramos que um problema é NP-completo? Pela definição, precisamos
mostrar primeiro que o novo problema está em NP e depois precisarı́amos enumerar
todos os problemas em NP e fazer uma redução deles para o nosso problema. Essa
segunda parte não parece nada simples. Acontece que a redução de problemas é uma
operação que pode ser composta. Por isso, basta escolher algum problema que já é
NP-completo e reduzir dele para o nosso. Porém, para que essa estratégia funcione, é
1
https://www.claymath.org/millennium-problems

263
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 para
funcionar. Esse ponto de partida é o problema 3-SAT.

Considere um conjunto de variáveis booleanas x1 , . . . , xn , 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

(x1 ∨ x2 ∨ x3 ∨ x4 ) ∧ (x1 ∨ x2 ) e (x1 ∨ x2 ∨ x3 ) ∧ (x1 ∨ x2 ∨ x4 ∨ x5 ) ∧ (x4 ∨ x5 ∨ x6 ) .

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.

(x1 ∨ x2 ∨ x3 ) ∧ (x1 ∨ x2 ∨ x4 ) e (x1 ∨ x2 ∨ x3 ) ∧ (x1 ∨ x2 ∨ x4 ) ∧ (x4 ∨ x5 ∨ x6 ) .

Problema 24.3: 3-SAT

Dada uma fórmula 3-CNF φ contendo literais de variáveis booleanas x1 , . . . , xn ,


existe uma atribuição de valores a x1 , . . . , xn tal que φ é satisfatı́vel, i.e., φ tem
valor 1?

Note que o 3-SAT está em NP pois, dada uma fórmula φ e uma atribuição das
variáveis, é fácil verificar se essa atribuição satisfaz a fórmula. Em 1971, os pesquisadores
Stephen Cook e Leonid Levin provaram que o 3-SAT é NP-completo.

Teorema 24.4: Cook-Levin

3-SAT é NP-completo.

Em 1972, Richard Karp apresentou um artigo com uma lista de 21 outros problemas
em NP-completo, criando de fato, na época, um conjunto desses problemas. Hoje em
dia temos milhares de problemas NP-completos.

264
24.3 Exemplos de problemas NP-completos
Nessa seção mostraremos vários exemplos de reduções para mostrar que um problema
novo é NP-completo. Partiremos do fato que o 3-SAT é NP-completo apenas.
Nosso primeiro resultado é sobre o problema Clique-k (24.7).

Teorema 24.1

3-SAT é redutı́vel para Clique-k.

Demonstração. Precisamos exibir um algoritmo eficiente que converte uma entrada do


3-SAT, isto é, uma fórmula 3-CNF φ, em um grafo G de forma que φ é satisfatı́vel se e
somente se G contém uma clique com pelo menos k vértices.
Seja então φ uma fórmula com m cláusulas sobre as variáveis x1 , . . . , xn . O grafo G
que construiremos possui 3m vértices, de modo que cada uma das m 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 a Figura 24.2 para um exemplo de
construção de G.
Tomando k = m, temos uma instância para o Clique-k. O próximo passo é verificar
que φ é satisfatı́vel se e somente se G contém um grafo completo com k = m vértices.
Para mostrar um lado dessa implicação note que se φ é satisfatı́vel, então em cada
uma das k = m cláusulas existe ao menos um literal com valor 1. Como um literal e
sua negação não podem ter valor 1, sabemos que em todo par {x, y} desses ao menos
k literais temos x 6= y. Portanto, existe uma aresta entre quaisquer dois vértices
representando esses literais em G, de modo que elas formam um grafo completo com
pelo menos k vértices dentro de G.
Para verificar a volta da implicação, suponha existe subconjunto S dos vértices
de G que é uma clique com pelo menos k vértices. Como existe uma aresta entre
quaisquer dois vértices de S, sabemos que qualquer par de vértices de S representa
dois literais que não são a negação um do outro e estão em diferentes cláusulas. Dando
valor 1 aos literais representados pelos vértices de S, portanto, satisfaz φ.

Já havı́amos mostrado anteriormente que Clique-k está em NP. Isso juntamente

265
Figura 24.2: Construção de um grafo G dada uma instância de 3-SAT.

266
com o Teorema 24.1 prova o seguinte resultado.

Teorema 24.2

Clique-k é NP-completo.

Considere agora o seguinte problema.

Problema 24.3: k-Cobertura por vértices

Dado um grafo G e um inteiro k, existe conjunto S ⊆ V (G) tal que, para toda
aresta uv ∈ E(G), u ∈ S ou v ∈ S e |S| ≤ k?

Primeiro note que esse problema está em NP, pois dados G, k e algum conjunto de
vértices, é fácil em tempo polinomial verificar se tal conjunto tem tamanho no máximo
k e se todas as arestas do grafo têm ao menos um extremo nesse conjunto. O teorema
a seguir mostra uma redução de Clique-k para k-Cobertura por vértices.

Teorema 24.4

Clique-k é redutı́vel para k-Cobertura por vértices.

O teorema acima juntamente com o fato de k-Cobertura por vértices estar em NP


diretamente prova o seguinte resultado.

Teorema 24.5

k-Cobertura por vértices é NP-completo.

24.4 Classe NP-difı́cil

Definição 24.1: Classe NP-difı́cil

NP-difı́cil é o conjunto de problemas Q tais que todo outro problema de NP é


redutı́vel a Q.

267
Pela definição acima, vemos que outra definição para a classe NP-completo pode
ser: o conjunto de problemas que estão em NP e são NP-difı́ceis.
Mas por que precisamos de duas classes de problemas tão parecidas? Essa distinção
se dá basicamente porque problemas de otimização não estão em NP. Veja por exemplo
o problema da mochila inteira. É fácil verificar se um dado conjunto de itens cabe na
mochila (basta somar seus pesos e comparar com a capacidade máxima), porém não é
fácil saber se o conjunto dá o melhor valor possı́vel. Ao menos não sem de fato resolver
o problema de fato. Assim, NP-completo ⊂ NP-difı́cil.
Para mostrar que um problema novo é NP-difı́cil, basta tomarmos um problema
que já é NP-difı́cil ou já é NP-completo e reduzi-lo para o novo problema. Pela
composição da redução, isso mostraria que todos os problemas em NP também se
reduzem ao novo problema. Por exemplo, o Teorema 24.1 prova diretamente o seguinte
resultado.

Teorema 24.2

Clique-k é NP-difı́cil.

Lembre-se que o fato de Clique-k ser NP finalizou a prova de que ele é NP-completo.

268
Abordagens para lidar com problemas
NP-difı́ceis

Em breve.