Anda di halaman 1dari 209

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 março 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 Algoritmos: corretude e tempo de execução 3


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

2 Recursividade 25
2.1 Algoritmos recursivos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
2.1.1 Fatorial . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
2.1.2 Busca binária . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.1.3 Algoritmos recursivos × algoritmos iterativos . . . . . . . . . . 27

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


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

3.2.4 Mais exemplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40


3.3 Método iterativo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
3.3.1 Limitantes assintóticos inferiores e superiores . . . . . . . . . . . 45
3.4 Método da árvore de recorrência . . . . . . . . . . . . . . . . . . . . . . 46
3.5 Método mestre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
3.5.1 Resolvendo recorrências com o método mestre . . . . . . . . . . 51
3.5.2 Ajustes para aplicar o método mestre . . . . . . . . . . . . . . . 52

II Estruturas de dados 57

4 Vetor, lista encadeada, fila e pilha 59


4.1 Lista encadeada . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.2 Pilha . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
4.3 Fila . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64

5 Heap binário 69
5.1 Construção de um heap binário . . . . . . . . . . . . . . . . . . . . . . 70

6 Fila de prioridades 79

7 Union-find 83

III Algoritmos de ordenação 85

8 Ordenação por inserção 87


8.1 Corretude e tempo de execução . . . . . . . . . . . . . . . . . . . . . . 88
8.1.1 Análise de melhor caso, pior caso e caso médio . . . . . . . . . . 90
8.1.2 Uma análise mais direta . . . . . . . . . . . . . . . . . . . . . . 91

9 Merge sort 93

10 Selection sort e Heapsort 97


10.1 Selection sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
10.2 Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
SUMÁRIO v

11 Quicksort 103
11.1 Tempo de execução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107

12 Ordenação em tempo linear 113


12.1 Counting sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113

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

13 Divisão e conquista 119

14 Algoritmos gulosos 121

15 Programação dinâmica 123


15.1 Um problema simples . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
15.2 Aplicação e caracterı́sticas principais . . . . . . . . . . . . . . . . . . . 126
15.3 Utilizando programação dinâmica . . . . . . . . . . . . . . . . . . . . . 130
15.3.1 Corte de barras . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
15.4 Comparando algoritmos top-down e bottom-up . . . . . . . . . . . . . 135

V Algoritmos em grafos 137

16 Grafos 139
16.1 Conceitos essenciais . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140
16.2 Formas de representar um grafo . . . . . . . . . . . . . . . . . . . . . . 141
16.3 Trilhas, passeios, caminhos e ciclos . . . . . . . . . . . . . . . . . . . . 143

17 Buscas 147
17.1 Busca em largura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
17.1.1 Distância entre vértices . . . . . . . . . . . . . . . . . . . . . . . 150
17.2 Busca em profundidade . . . . . . . . . . . . . . . . . . . . . . . . . . . 155
17.2.1 Ordenação topológica . . . . . . . . . . . . . . . . . . . . . . . . 159
17.2.2 Componentes fortemente conexas . . . . . . . . . . . . . . . . . 160
17.2.3 Outras aplicações dos algoritmos de busca . . . . . . . . . . . . 162
18 Árvores geradoras mı́nimas 163
18.1 Algoritmo de Prim . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
18.2 Algoritmo de Kruskal . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168

19 Trilhas Eulerianas 173

20 Caminhos mı́nimos 177


20.1 Algoritmo de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . 178
20.2 Algoritmo de Bellman-Ford . . . . . . . . . . . . . . . . . . . . . . . . 182
20.3 Caminhos mı́nimos entre todos os pares de vértices . . . . . . . . . . . 187
20.3.1 Algoritmo de Floyd-Warshall . . . . . . . . . . . . . . . . . . . 188
20.3.2 Algoritmo de Johnson . . . . . . . . . . . . . . . . . . . . . . . 191

VI Teoria da computação 195

21 Complexidade computacional 197


21.1 Classes P, NP e co-NP . . . . . . . . . . . . . . . . . . . . . . . . . . 197
21.2 NP-completude . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 200

vi
Pa rt e

I
Introdução à análise de algoritmos

“Suppose computers were infinitely fast and computer


memory was free. Would you have any reason to study
algorithms? The answer is yes, if for no other reason than
that you would still like to demonstrate that your solution
method terminates and does so with the correct answer.”

Cormen, Leiserson, Rivest, Stein — Introduction to


Algorithms, 2009.
Capı́tulo

1
Algoritmos: 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 utilizamos um algoritmo 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, não apenas
de ordenar um conjunto de itens, mas também para, por exemplo, descobrir qual o
menor caminho entre uma origem e um destino, 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 contém a 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 pe-
los processadores comuns atuais, como por exemplo somar/subtrair/multiplicar/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 que resolvem
esse problema. 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 com capacidade para 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
1
Estamos aqui falando de números que possam ser representados por 32 ou 64 bits, que são
facilmente manipulados por computadores.

4
subvetor de A que contém os elementos A[i], A[i + 1], . . . , A[j].

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.

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, caso em que x não
está em A.

Algoritmo 1: BuscaLinear(A[1..n], x)
1 i = 1
2 enquanto i ≤ n faça
3 se A[i] == x então
4 retorna i
5 i=i+1
6 retorna −1

No que segue, seja n a quantidade de elementos do vetor A (seu tamanho). 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.
Intuitivamente, é fácil perceber que BuscaLinear funciona corretamente, isto é,
que para qualquer vetor A de números reais e número real x, o algoritmo irá retornar
a posição de x em A, caso ela exista, ou irá retornar −1, caso x não esteja em A.
Mas como podemos ter certeza que o comportamento de BuscaLinear é sempre
como esperamos que seja? Na Seção ?? veremos uma forma de provar que algoritmos
funcionam corretamente. Antes, vejamos outra forma de resolver o problema de
encontrar um valor em um vetor A, mas agora com a informação extra de que A está

5
ordenado.
Considere um vetor ordenado (ordem não-decrescente2 ) 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 está na primeira metade de A, i.e., x está em A[1..n/2 − 1] (isso segue
do fato de A estar ordenado). Caso x > A[n/2], então sabemos que, se x estiver em A,
então x está no vetor A[n/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 5 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.

Algoritmo 2: BuscaBinaria(A[1..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

2
Aqui utilizamos o termo não-decrescente em vez de crescente para indicar que podemos ter
A[i] = A[i + 1], para algum i.

6
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 recebida3 . Como analisar se
um algoritmo está correto? A seguir veremos uma maneira de responder a essa pergunta.
Basicamente, mostraremos que o algoritmo possui certas propriedades e que 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. Mais formalmente, uma invariante de laço é definida
como abaixo.

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 utilizando essa
invariante. Uma observação importante é que quando dizemos “imediatamente antes
de uma iteração” estamos nos referindo ao momento imediatamente antes de iniciar a
linha correspondente ao laço.
Para entender como podemos utilizar as invariantes de laço para provar a corretude
de algoritmos, vamos inicialmente fazer a análise dos algoritmos de busca em vetores.
Comecemos com o algoritmo Busca linear, 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.

3
É claro que considerando que temos uma entrada válida para o problema.

7
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. Mas note que se o
algoritmo retorna i na linha 4, então a comparação na linha 3 é verificada com sucesso,
de modo que temos A[i] = x como desejado. Porém, se o algoritmo retorna −1, então
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. Portanto,
o algoritmo funciona corretamente.
À primeira vista todo o processo que fizemos para mostrar que o algoritmo Busca
linear funciona corretamente pode parecer excessivamente complicado. Porém, essa
impressão vem do fato desse algoritmo ser muito simples (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].

Algoritmo 3: Produtorio(A[1..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 Produto-

8
rio(A[1..n])? A cada iteração do laço para nós ganhamos mais informação. Precisamos
entender como essa informação ajuda a obter a saı́da desejada do algoritmo. No caso de
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 Produtório funciona.

Invariante: Produtorio

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


dos elementos de A[1..i − 1].

Trivialmente a invariante é válida antes da primeira iteração do laço para, de modo


que o item (i) da definição de invariante de laço é válido. Para verificar o item (ii),
suponha que a invariante seja válida antes da iteração i, i.e., produto = i−1
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
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.

9
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. Um algoritmo será mais rápido quando
implementado em um computador mais potente do que quando implementado em um
computador menos potente. Se a entrada for pequena, o algoritmo provavelmente será
executado mais rapidamente do que se a entrada for muito grande. Vários fatores
afetam o tempo de execução de um algoritmo. Por exemplo, o sistema operacional
utilizado, a linguagem de programação utilizada, a velocidade do processador, o modo
com o algoritmo foi implementado, dentre outros. Assim, queremos um conceito de
eficiência que seja independente da entrada, da plataforma utilizada e que possa ser de
alguma forma quantificada concretamente de acordo com o tamanho da entrada.
Para analisar a eficiência de um algoritmo vamos analisar o seu tempo de execução
contando a quantidade de operações primitivas (operações aritméticas entre números
pequenos, comparações, etc.) e “passos básicos” executados. Dessa forma, é possı́vel
ter uma boa estimativa do quão rápido um algoritmo é, além de permitir comparar
seu tempo de execução com o de outros algoritmos, o que nos permite escolher o mais
eficiente para uma determinada tarefa.
Em geral, o tempo de execução de um algoritmo cresce junto com a quantidade de
dados passados como entrada. Portanto, definimos o tempo de execução como uma
função no tamanho da entrada. E 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 bem devagar com o tamanho da entrada. Para entender
melhor 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

10
executada ou não. Assim, vamos assumir que toda operação primitiva leva “tempo” 1
para ser executada. Por comodidade, repetimos o algoritmo BuscaLinear abaixo.

Algoritmo 4: BuscaLinear(A[1..n], x)
1 i = 1
2 enquanto i ≤ n faça
3 se A[i] == x então
4 retorna i
5 i=i+1
6 retorna −1

Denote por tx a posição do elemento x no vetor A[1..n], onde colocamos 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[1..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. Se A contém n elementos e x está na última posição de A, então T (n) = 3n + 1.
Porém, se x está na primeira posição de A, temos T (n) = 4.
Para a busca binária, vamos fazer uma análise semelhante. Por comodidade,
repetimos o algoritmo Busca binária abaixo. Lembre-se que na busca binária
assumimos que o vetor está ordenado de modo não decrescente.
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

11
Algoritmo 5: BuscaBinaria(A[1..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

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[1..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 do tamanho da entrada.


Note que o algoritmo de busca binária sempre descarta metade do vetor que está
sendo considerado, diminuindo o tamanho do vetor analisado pela metade, até que se
chegue em um vetor com uma única posição (ou duas, dependendo da paridade de n).
Como sempre metade do vetor é descartado, o algoritmo analisa, nessa ordem, vetores
de tamanho n, n/2, n/22 , . . ., n/2i , onde o último vetor analisado tem tamanho 1,
i.e., temos n/2i = 1, 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


O tempo de execução de melhor caso de um algoritmo é o tempo de execução da
instância de entrada que executa de forma mais rápida, dentre todas as instâncias
possı́veis de um dado tamanho n. No caso da BuscaLinear, o melhor caso ocorre

12
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 Busca linear e da
BuscaBinaria ocorre quando o elemento x a ser buscado não se encontra no vetor A,
pois a busca linear precisa percorrer todo o vetor, e a busca binária vai subdividir o
vetor até que não seja 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

13
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 da forma an + b, para constantes a
e b. 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 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,

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

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 .

15
Note que podemos utilizar as três notações acima 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 .

Analisando agora o tempo de execução T (n) de melhor caso de um algoritmo, 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. Assim, podemos afirmar 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 Cn log n,
onde 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

16
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
+ 2 ≥ 10 ,
10 +
n n
basta tomar n0 = 1 e c = 10. Concluı́mos então que f (n) ≥ 10n2 para todo n ≥ 1 e,
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

17
√ √
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).

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.

• 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.5

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

18
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 Θ.

Teorema 1.6: 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 Θ;

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

para todo n ≥ n0 . Assim, analisando as desigualdades em (1.7), 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 .

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

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

21
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.8

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

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

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

23
24
Capı́tulo

2
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
passos a seguir.

(i) Se a instância for suficientemente pequena, resolva o problema diretamente,

(ii) caso contrário, divida a instância em instâncias menores, resolva-as 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: 
n! = 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)

26
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)”.
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. Nesse caso,
podemos facilmente desenvolver uma variação recursiva do algoritmo BuscaBinaria
que consegue realizar (como na versão iterativa) a busca por uma chave x em A em
tempo O(log n) no pior caso. A estratégia é muito simples, equivalente à versão iterativa.
Se A[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], 1, n, x).

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

27
Algoritmo 7: BuscaBinariaRecursiva(A[1..n], 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)

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

Em geral, Fn ≈ 20.684n .

28
Problema 2.1: Número de Fibonacci

Dado um inteiro n ≥ 0, encontrar Fn .

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: Fibonacci(n)
1 se n ≤ 2 então
2 retorna 1
3 retorna Fibonacci(n − 1) + Fibonacci(n − 2)

Apesar de sua simplicidade, o procedimento acima é muito ineficiente. Seja T (n) o


tempo necessário para computar Fn . Para qualquer n ≥ 2, Fibonacci(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 Fibonacci(n − 1) + Fibonacci(n − 2) é executado,
além da chamada a Fibonacci(n − 2) que é feita, a chamada a Fibonacci(n − 1)
fará mais uma chamada a Fibonacci(n − 2), mesmo que ele já tenho sido calculado
antes, e esse fenômeno cresce exponencialmente até chegar à base da recursão.
É 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.
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.
Na Parte III apresentamos diversos algoritmos recursivos para resolver o problema

29
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 Fibonacci(n) (Algoritmo 8). Cada nó representa
uma chamada ao algoritmo.

de ordenação dos elementos de um vetor. Ao longo deste livro muitos outros algoritmos
recursivos serão discutidos.

30
Capı́tulo

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

Relações como T (n) = T (n − 1) + T (n − 2) + 1, T (n) = 2T (n/2) + n ou T (n) = T (n/3) +


T (n/4) + 3 log n são chamadas de recorrências, que são equações ou inequações que
descrevem uma função em termos de seus valores para entradas menores. Recorrências
são muito comuns para descrever o tempo de execução de algoritmos recursivos.
Portanto, elas são compostas de duas partes que indicam, respectivamente, o tempo
gasto quando não há recursão (caso base) e o tempo gasto quando há recursão, que
consiste no tempo das chamadas recursivas juntamente com o tempo na 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 em geral 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.

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

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

33
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

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

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

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

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

38
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

39
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

40
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

41
√ √
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 ,

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

43
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, InsertionSort 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 InsertionSort.

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.

44
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

45
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

46
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

47
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

48
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

49
f (n) é “grande” comparado com o tempo gasto para dividir o problema em a partes
de uma fração 1/b da instância em questão. Portanto, faz sentido que f (n) determine
o tempo de execução do algoritmo nesse caso, que é a conclusão obtida no item (3). O
caso intermediário, no item (2), corresponde ao caso em que a função f (n) e dividir o
algoritmo recursivamente são ambos essenciais no tempo de execução do algoritmo.
Infelizmente, existem alguns casos não cobertos pelo Teorema Mestre, mas mesmo
nesses casos conseguir utilizar o teorema para conseguir limitantes superiores e/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 a seguinte
versão simplificada do Teorema Mestre, que dispensa a condição extra no item (3). Seja
f (n) um polinômio de grau k com coeficientes não negativos (para k constante), i.e.,
f (n) = ki=0 ai ni , onde a0 , a1 , . . . , ak são constantes e a0 , a1 , . . . , ak−1 ≥ 0 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 com


coeficientes não negativos. 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.

50
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) −
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.

3.5.1 Resolvendo recorrências com o método mestre


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

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


Claramente, temos a = 2, b = 2 e f (n) = n. Como f (n) = n = 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.

51

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

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.

52
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
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

53
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.
A seguir temos um exemplo de recorrência que não satisfaz a condição extra do
item (3) do Teorema 3.1. Ressaltamos que é improvável que tal recorrência descreva o
tempo de execução de um algoritmo.

Exemplo 5. T (n) = T (n/2) + n(2 − cos n).


Primeiro vamos verificar em que caso estarı́amos no Teorema Mestre. De fato,
como a = 1 e b = 2, temos 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 ??. 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

Logo, para infinitos valores de n, a constante c precisa ser pelo menos 3/2, e portanto
não é possı́vel obter a condição extra no caso (3). Assim, não há como aplicar o
Teorema Mestre à recorrência T (n) = T (n/2) + n(2 − cos n).
Existem outros métodos para resolver equações de recorrência mais gerais que
equações do tipo T (n) = aT (n/b) + f (n). Um exemplo importante é o método
de Akra-Bazzi, que consegue resolver equações não tão balanceadas, como T (n) =

54
T (n/3) + T (2n/3) + Θ(n), mas não entraremos em detalhes desse método aqui.

55
56
Pa rt e

II
Estruturas de dados
Capı́tulo

4
Vetor, lista encadeada, fila e pilha

Algoritmos geralmente precisam manipular conjuntos de dados que podem crescer,


diminuir ou sofrer diversas modificações durante sua execução. Muitos algoritmos
necessitam realizar algumas operações essenciais, como inserção e remoção de elementos
em um conjunto de dados. A eficiência dessas e de outras operações depende fortemente
do tipo de estrutura de dados utilizada. Abaixo vamos discutir as estruturas lista
encadeada, pilha e fila.

4.1 Lista encadeada


Lista encadeada é uma estrutura de dados 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, 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 baseado nos atributos chave, que sempre contêm inteiros não
Figura 4.1: Lista duplamente encadeada circular.

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.

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 abaixo 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 Busca lista abaixo realiza uma busca pelo primeiro elemento
com chave k na lista L. Primeiramente, a cabeça da lista L é analisada e em seguida
os elementos da lista são analisados, um a um, até que k seja encontrado ou até que
a lista seja completamente verificada. No pior caso, toda a lista deve ser verificada,
de modo que o tempo de execução de Busca na lista é O(n) para uma lista com n

60
elementos.

Algoritmo 9: Busca na lista(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 procedimento abaixo inserimos


um elemento 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.

Algoritmo 10: Insere na lista(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

Como somente uma quantidade constante de operações é executada, o procedimento


Insere na lista é executado em tempo O(1) para uma lista com n elementos. Note
que o procedimento de inserção em uma lista encadeada ordenada levaria tempo O(n),
pois precisarı́amos inserir x na posição correta dentro da lista, tendo que percorrer
toda a lista no pior caso.

O procedimento Remove da lista abaixo, remove um elemento x de uma lista


L. Note que o parâmetro passado para o procedimento é um ponteiro para x e não
um valor chave k. Esse ponteiro pode ser retornado, por exemplo, por uma chamada
à Busca na lista. A remoção é simples, sendo necessário somente atualizar os
ponteiros x. anterior . proximo e x. proximo . anterior, tendo cuidado com os casos onde

61
x é a cabeça ou a cauda de L.
Algoritmo 11: Remove da lista(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 O(1) para ser executada. Porém, se quisermos remover um elemento que contém
uma dada chave k, precisamos primeiramente efetuar uma chamada ao algoritmo
Busca na lista(L, k) e remover o elemento retornado pela busca, gastando tempo
O(n) no pior caso.
Observe que o fato do procedimento Remove da lista ter sido feito em uma lista
duplamente encadeada é essencial para que seu tempo de execução seja O(1). Se L
for uma lista encadeada simples, não temos a informação de qual elemento em L está
na posição anterior a x, dado que não existe x. anterior. Portanto, seria necessário
uma busca por esse elemento, para podermos efetuar a remoção de x. Desse modo, um
procedimento de remoção em uma lista encadeada simples leva tempo O(n) no pior
caso.

4.2 Pilha
Pilha é uma estrutura de dados onde as operações de inserção e remoção são feitas na
mesma extremidade, chamada de topo da pilha. Ademais, ao se realizar uma remoção
na pilha, o elemento a ser removido é sempre o último elemento que foi inserido na
pilha. Essa polı́tica de remoção é conhecida como “LIFO”, acrônimo para “last in, first
out”.
Existem inúmeras aplicações para pilhas. Por exemplo, verificar se uma palavra é
um palı́ndromo é um procedimento muito simples de se realizar utilizando uma pilha.
Basta inserir as letras em ordem e depois realizar a remoção uma a uma, verificando
se a palavra formada é a mesma que a inicial. Uma outra aplicação (muito utilizada)

62
é a operação “desfazer”, presente em vários editores de texto. Toda mudança de
texto é colocada em uma pilha, de modo que cada remoção da pilha fornece a última
modificação realizada. Mencionamos também que pilhas são úteis na implementação
de algoritmos de busca em profundidade em grafos.

Vamos mostrar como implementar uma pilha de no máximo n elementos utilizando


um vetor P [1..n]. Ressaltamos que existem ainda outras formas de implementar pilhas.
Por exemplo, poderı́amos utilizar listas encadeadas para realizar essa tarefa.

Dado um vetor P [1..n], o atributo P. topo contém o ı́ndice do elemento que foi
inserido por último, contendo 0 quando a pilha estiver vazia. O atributo P. tamanho
contém o tamanho do vetor, i.e., n. Em qualquer momento, o vetor P [1..P. topo]
representa a pilha em questão, onde P [1] contém o primeiro elemento inserido na pilha
e P [P. topo] contém o último.

Quando inserimos um elemento x na pilha P , dizemos que estamos empilhando x


em P . Similarmente, ao remover x de P nós desempilhamos x de P . As duas operações
de pilha a seguir, Empilha e Desempilha, são bem simples e todas elas levam tempo
O(1) para serem executadas.

Para acrescentar um elemento x à pilha P , utilizamos o procedimento Empilha


abaixo, que verifica se a pilha está cheia e, caso ainda haja espaço, atualiza o topo da
pilha P. topo para P. topo +1 e insere x em P [P. topo].

Algoritmo 12: Empilha(P, x)


1 se P. topo == P. tamanho então
2 retorna “Pilha cheia”
3 senão
4 P. topo = P. topo +1
5 P [P. topo] = x

Para desempilhar basta verificar se a pilha está vazia e, caso contrário, decrementar

63
de uma unidade o valor de P. topo, retornando o elemento que estava no topo da pilha.
Algoritmo 13: Desempilha(P )
1 se P. topo == 0 então
2 retorna “Pilha vazia”
3 senão
4 P. topo = P. topo −1
5 retorna P [P. topo +1]

A figura abaixo ilustra as seguinte operações, em ordem, onde a pilha P está


inicialmente vazia: Empilha(P, 3), Empilha(P, 5), Empilha(P, 1), Desempilha(P ),
Desempilha(P ), Empilha(P, 8).

Figura 4.2: Operações em uma pilha.

4.3 Fila
A fila é uma estrutura de dados onde as operações de inserção e remoção são feitas em
extremidades opostas, a cabeça e a cauda da fila. Ademais, ao se realizar uma remoção
na fila, o elemento a ser removido é sempre o primeiro elemento que foi inserido na
fila. Essa polı́tica de remoção é conhecida como “FIFO”, acrônimo para “first in, first
out”.
O conceito de fila é amplamente utilizado na vida real. Por exemplo, qualquer
sistema que controla a ordem de atendimento em bancos pode ser implementado
utilizando filas. Mais geralmente, filas podem ser utilizadas em algoritmos que precisam
controlar acesso a recursos, de modo que a ordem de acesso é definida pelo tempo em
que o recurso foi solicitado. Outra aplicação é a implementação de busca em largura

64
em grafos.
Como acontece com pilhas, filas podem ser implementadas de diversas formas. Aqui
vamos focar na implementação utilizando vetores. Vamos mostrar como implementar
uma fila de no máximo n − 1 elementos utilizando um vetor F [1..n]. Para ter o controle
de quando a pilha está vazia ou cheia, conseguimos guardar no máximo n − 1 elementos
em um vetor de tamanho n.
Dado um vetor F [1..n], os atributos F. cabeca e F. cauda contêm, respectivamente,
os ı́ndices para o inı́cio de F e para a posição onde o próximo elemento será inserido em F .
Portanto, os elementos da fila encontram-se nas posições F. cabeca, F. cabeca +1, . . . , F. cauda −2, F. cauda
onde as operações de soma e subtração são feitas módulo F. tamanho = n, i.e., podemos
enxergar o vetor F de forma circular.
Quando inserimos um elemento x na fila F , dizemos que estamos enfileirando x em
F . Similarmente, ao remover x de F nós estamos desenfileirando x de F .
Antes de descrever as operações, vamos discutir alguns detalhes sobre filas. Inicial-
mente, temos F. cabeca = F. cauda = 1. Sempre que F. cabeca = F. cauda, a fila está
vazia, e a fila está cheia quando F. cabeca = F. cauda +1. As duas operações de fila a
seguir, Fila-adiciona e Fila-remove levam tempo O(1) para serem executadas.
O procedimento Fila-adiciona abaixo adiciona um elemento à fila. Primeiramente
é verificado se a fila está cheia, caso onde nada é feito. Caso contrário, o elemento é
adicionado na posição F. cauda e atualizamos o valor de F. cauda. Esse procedimento
realiza uma quantidade constante de operações, de modo que é claramente executado
em tempo O(1).
Algoritmo 14: Fila-adiciona(F, x)
1 se (F. cabeca == 1 e F. cauda == n) ou (F. cabeca == F. cauda +1) então
2 retorna “Fila cheia”
3 senão
4 F [F. cauda] = x
5 se F. cauda == F. tamanho então
6 F. cauda = 1
7 senão
8 F. cauda = F. cauda +1

Para remover um elemento da fila, utilizamos o procedimento Fila-remove abaixo,

65
que verifica se a fila está vazia e, caso contrário, retorna o primeiro elemento que foi
inserido na fila (elemento contido no ı́ndice F. cabeca) e atualiza o valor de F. cabeca.
Como no procedimento Fila-adiciona, claramente o tempo gasto em Fila-remove
é O(1).
Algoritmo 15: Fila-remove(F )
1 se F. cabeca == F. cauda então
2 retorna “Fila vazia”
3 senão
4 x = F [F. cabeca]
5 se F. cabeca == F. tamanho então
6 F. cabeca = 1
7 senão
8 F. cabeca = F. cabeca +1

9 retorna x
A figura abaixo ilustra as seguinte operações (as mesmas que fizemos para ilus-
trar as operações de pilha), em ordem, onde a fila F está inicialmente vazia: Fila-
adiciona(F, 3), Fila-adiciona(F, 5), Fila-adiciona(F, 1), Fila-remove(F ), Fila-
remove(F ), Fila-adiciona(F, 8).

Figura 4.3: Operações em uma fila. H aponta para a cabeça e T para a cauda.

Resumindo as informações deste capı́tulo, temos que pilhas e filas são estruturas de
dados simples mas com diversas aplicações. Inserção e remoção em ambas as estruturas
levam tempo O(1) para serem executadas e são pré-determinadas pela estrutura.
Inserções e remoções em pilha são feitas na mesma extremidade, implementando a
polı́tica LIFO. Na fila, a polı́tica FIFO é implementada, onde o primeiro elemento
inserido é o primeiro a ser removido.
Listas encadeadas são organizadas com a utilização de ponteiros nos elementos.

66
Uma caracterı́stica interessante de listas duplamente encadeadas é que inserção e
remoção são feitas em tempo O(1). Uma vantagem em relação ao uso de vetores é que
não é necessário saber a quantidade de elementos que serão utilizados previamente.
Em geral, o tempo de execução das operações em listas encadeadas depende do tipo de
lista em questão, que sumarizamos na tabela abaixo.

Não ordenada, Ordenada, Não ordenada, Ordenada


simples simples dupla. enc. dupla. enc.
Busca-Lista O(n) O(n) O(n) O(n)
Inserção-Lista O(1) O(n) O(1) O(n)
Remoção-Lista O(n) O(n) O(1) O(1)

67
68
Capı́tulo

5
Heap binário

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

representado em A tem no máximo A. tam-heap elementos, onde A. tam-heap ≤


A. tamanho. Vamos utilizar nomenclatura de pai e filhos, como em árvores. O elemento
em A[1] é o único elemento que não tem pai e, para todo 2 ≤ i ≤ A. tam-heap, temos
que o ı́ndice do pai de A[i] é bi/2c. Os filhos esquerdo e direito de um elemento A[i]
estão, respectivamente, nos ı́ndices 2i e 2i + 1, onde um elemento tem filho esquerdo
somente se 2i ≤ A. tam-heap e tem filho direito somente se 2i + 1 ≤ A. tam-heap.
Finalmente, o vetor A satisfaz a propriedade de heap: para todo 2 ≤ i ≤ A. tam-heap,
temos A[bi/2c] ≥ A[i], i.e., o valor do pai é sempre maior ou igual ao valor de seus
filhos.
Analisando a definição acima podemos enxergar um heap como uma árvore binária
quase completa onde a propriedade de heap é satisfeita. Ademais, em um heap máximo
visto como uma árvore binária, o último nı́vel da árvore é preenchido de forma contı́gua
da esquerda para a direita. A Figura 5.1 vista anteriormente representa um heap
máximo.

5.1 Construção de um heap binário

Primeiramente descreveremos um procedimento chamado de Corrige-heap-para-


baixo que será útil na construção de um heap e também para o algoritmo Heapsort.
Corrige-heap-para-baixo recebe um vetor A e um ı́ndice i e assume que as
subárvores com raiz A[2i] ou A[2i + 1] são heaps máximos. Corrige-heap-para-
baixo vai mover A[i] para baixo na árvore, trocando nós pais com nós filhos, de modo
que, ao final do procedimento, a subárvore com raiz em A[i] também irá satisfazer a

70
propriedade de heap.
Algoritmo 16: Corrige-heap-para-baixo(A, i)
1 maior = i
2 se 2i ≤ A. tam-heap então
3 se A[2i] > A[maior] então
4 maior = 2i

5 se 2i + 1 ≤ A. tam-heap então
6 se A[2i + 1] > A[maior] então
7 maior = 2i + 1

8 se maior 6= i então
9 troca A[i] com A[maior]
10 Corrige-heap-para-baixo(A, maior)

A Figura 5.2 mostra um exemplo de execução do algoritmo Corrige-heap-para-


baixo.

Figura 5.2: Execução de Corrige-heap-para-baixo(A, 2) em A =


[20, 0, 10, 6, 8, 3, 5, 1, 4, 7, 2].

Teorema 5.1: Corretude de Corrige-heap-para-baixo

O algoritmo Corrige-heap-para-baixo(A, i) recebe um vetor A e um ı́ndice


i tal que as subárvores com raiz A[2i] ou A[2i + 1] são heaps máximos, e modifica

71
A de modo que a árvore com raiz em A[j] para todo i ≤ j ≤ A. tam-heap é um
heap máximo.

Demonstração. Vamos provar a corretude de Corrige-heap-para-baixo(A, i) por


indução em i. Como os últimos A. tam-heap /2 elementos de A são folhas (heaps de
tamanho 1), sabemos que as árvores com raiz em A[i] para bA. tam-heap /2 + 1 ≤ i ≤
A. tam-heap são heaps máximos.
Seja i ≥ 1 e suponha agora que o algoritmo funciona corretamente quando recebe
A e um ı́ndice i + 1 ≤ j ≤ A. tam-heap. Precisamos mostrar que Corrige-heap-
para-baixo(A, i) funciona corretamente, i.e., a árvore com raiz A[j], para todo
i ≤ j ≤ A. tam-heap, é um heap máximo.
Considere uma execução de Corrige-heap-para-baixo(A, i). Note que se A[i] é
maior ou igual a seus filhos, então os testes nas linhas 3, 6 e 8 não serão verificados e o
algoritmo não faz nada, que é o esperado, uma vez que a árvore com raiz em A[i] já é
um heap máximo.
Assuma agora que A[i] é menor do que algum dos seus filhos. Caso A[2i] seja o
maior dos filhos, o teste na linha 2 e na linha 3 será executado com sucesso e teremos
maior = 2i. A linha 7 não será executada, e como maior 6= i, o algoritmo troca
A[i] com A[maior] e executa Corrige-heap-para-baixo (A, maior) na linha 10.
Como maior = 2i ≥ i + 1, sabemos pela hipótese de indução que o algoritmo funciona
corretamente, de onde concluı́mos que a árvore com raiz em A[2i] é um heap máximo.
Como A[i] é agora maior que A[2i], concluı́mos que a árvore com raiz A[j], para todo
i ≤ j ≤ A. tam-heap, é um heap máximo. A prova á análoga quando A[2i + 1] é o
maior dos filhos de A[i].

Vamos analizar o tempo de execução de Corrige-heap-para-baixo(A, i) em um


heap com n elementos representado pelo vetor A. O ponto chave é perceber que a cada
chamada recursiva, Corrige-heap-para-baixo desce um nı́vel na árvore. Assim, em
uma árvore de altura h, em O(h) passos a base da árvore é alcançada. Como em cada
passo somente tempo constante é gasto, concluı́mos que Corrige-heap-para-baixo
tem tempo de execução O(h), onde h é a altura da árvore em questão. Sabendo que um
heap pode ser visto como uma árvore binária quase completa, que tem altura O(log n),
o tempo de execução de Corrige-heap-para-baixo é O(log n).
Vamos fazer uma análise mais detalhada do tempo de execução T (n) de Corrige-

72
heap-para-baixo(A, i). Note que a cada chamada recursiva o problema diminui
consideravelmente de tamanho. Se estamos na iteração correspondente a um elemento
A[i], a próxima chamada recursiva será na subárvore cuja raiz é um filho de A[i].
Mas qual o pior caso possı́vel? No pior caso, se o problema inicial tem tamanho
n, o subproblema seguinte possui tamanho no máximo 2n/3. Isso segue do fato de
possivelmente analisarmos a subárvore cuja raiz é o filho esquerdo de A[1] (i.e., está no
ı́ndice 2) e o último nı́vel da árvore está cheio até a metade. Assim, a subárvore com raiz
no ı́ndice 2 possui aproximadamente 2/3 dos vértices, enquanto que a subárvore com
raiz em 3 possui aproximadamente 1/3 dos vértices. Em todos os próximos passos os
subproblemas são divididos na metade do tamanho da instância atual. Como queremos
um limitante superior, podemos calcular o tempo de execução de Corrige-heap-
para-baixo 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. Sabendo que o tempo de execução


T (n) de Corrige-heap-para-baixo é no máximo T (2n/3) + 1. Podemos 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).

Note que os últimos n/2 elementos de A são folhas (heaps de tamanho 1), de

73
modo que um heap pode ser construı́do simplesmente chamando o procedimento
Corrige-heap-para-baixo(A, i) para i = n/2, . . . , 1, nessa ordem. Seja a rotina
Constroi-heap(A) abaixo tal procedimento.
Algoritmo 17: Constroi-heap(A[1..n])
1 A. tam-heap = n
2 para i = bn/2c até 1 faça
3 Corrige-heap-para-baixo(A, i)

A Figura 5.3 tem um exemplo de execução da rotina Constroi-heap. Antes


de estimarmos o tempo de execução do algoritmo, vamos mostrar que ele funciona
corretamente. Para isso precisaremos da seguinte invariante de laço.

Invariante: Constroi-heap

Antes de cada iteração do laço para (indexado por i), para todo i + 1 ≤ j ≤ n,
a árvore com raiz A[j] é um heap máximo.

Teorema 5.3

O algoritmo Constroi-heap(A[1..n]) transforma o vetor A em um heap


máximo.

Demonstração. Inicialmente temos i = bn/2c, então precisamos verificar se, para todo
bn/2c + 1 ≤ j ≤ n, a árvore com raiz A[j] é um heap máximo. Mas essa árvore é
composta somente pelo elemento A[j], pois como j > bn/2c, o elemento A[j] não tem
filhos. Assim, a árvore com raiz em A[j] é um heap máximo.
Suponha agora que a invariante é válida imediatamente antes da i-ésima iteração
do laço para, i.e., para todo i + 1 ≤ j ≤ n, a árvore com raiz A[j] é um heap máximo.
Para mostrar que a invariante é válida imediatamente antes da (i − 1)-ésima iteração,
note que na i-ésima iteração do laço temos que as árvores com raiz A[j] são heaps,
para i + 1 ≤ j ≤ n. Portanto, caso A[i] tenha filhos, esses são raı́zes de heaps, de modo
que a chamada a Corrige-heap-para-baixo(A, i) na linha 3 funciona corretamente,

74
transformando a árvore com raiz A[i] em um heap máximo. Assim, para todo i ≤ j ≤ n,
a árvore com raiz A[j] é um heap máximo. Portanto, a invariante se mantém válida
antes de todas as iterações do laço.
Ao fim da execução do laço temos i = 0, de modo que, pela invariante de laço, a
árvore com raiz em A[1] é um heap máximo.

No que segue seja T (n) o tempo de execução de Constroi-heap em um vetor


A com n elementos. Uma simples análise permite concluir que T (n) = O(n log n): o
laço para é executado no máximo n/2 vezes, e em cada uma dessas execuções a rotina
Corrige-heap-para-baixo, que leva tempo O(log n) é executada. Logo, concluı́mos
que T (n) = O(n log n).
Uma análise mais cuidadosa fornece um limitante melhor que O(n log n). Primeiro
vamos observar que em um heap de tamanho n existem no máximo 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.
Assim, para cada elemento de altura h, a chamada recursiva de Corrige-heap-
para-baixo correspondente executa em tempo O(h). Assim, para n suficientemente
grande, temos que cada uma dessas chamadas recursivas é executada em tempo no
máximo C(h + 1) para alguma constante C > 0. Portanto, o tempo de execução de
Constroi-heap é dado como segue.

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

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

76

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

77
78
Capı́tulo

6
Fila de prioridades

Neste capı́tulo introduzimos filas de prioridades. Essas estruturas são úteis em diversos
procedimentos, incluindo uma implementação eficiente dos algoritmos de Prim e Dijkstra
(veja Capı́tulos 18 e 20).
Dado um conjunto V de elementos, onde cada elemento de v ∈ V possui um atributo
v. chave e um atributo v. indice. Uma fila de prioridades baseada nos atributos chave
dos elementos de V é uma estrutura de dados que contém as chaves de V e permite
executar algumas operações de forma eficiente. Filas de prioridades podem ser de
mı́nimo ou de máximo, mas como os algoritmos são todos análogos, mostraremos aqui
somente as operações em uma fila de prioridades de mı́nimo.
Uma fila de prioridades F sobre um conjunto V , baseada nos valores v. chave para
cada v ∈ V , permite remover (ou consultar) um elemento com chave mı́nima, inserir
um novo elemento em F , e alterar o valor da chave de um elemento em F para um
valor menor.
Vamos mostrar como implementar uma fila de prioridades F utilizando um heap
mı́nimo. Após quaisquer operações em F , essa fila de prioridades sempre representará
um heap mı́nimo.
No Capı́tulo 5 introduzimos diversos algoritmos sobre a estrutura de dados heap.
Fizemos isso utilizando um vetor F com um conjunto de chaves. A seguir discutimos
uma pequena variação dos algoritmos Corrige-heap-para-baixo e Constroi-heap
apresentados na Seção 5 que, em vez de um conjunto de chaves, mantém um vetor F de
elementos v de um conjunto V tal que, cada v ∈ V possui atributos v. chave e v. indice,
representando respectivamente a chave do elementos e o ı́ndice em que o elemento se
encontra dentro do vetor F . Os algoritmos que apresentaremos mantém os ı́ndices dos
elementos de F atualizados. Esses algoritmos serão úteis para uma implementação
eficiente dos algoritmos de Prim e Dijkstra vistos nas próximas seções. Lembre que F
possui tamanho elementos e o heap contém F. tam-heap ≤ F. tamanho. Abaixo temos
a versão correspondente a heaps mı́nimos do algoritmo Corrige-heap-para-baixo,
onde mantemos os ı́ndices dos elementos de F atualizados.
Algoritmo 18: Corrige-heapmin-para-baixo(F, i)
1 menor = i
2 se 2i ≤ F. tam-heap então
3 se F [2i]. chave < F [menor]. chave então
4 menor = 2i

5 se 2i + 1 ≤ F. tam-heap então
6 se F [2i + 1]. chave < F [menor]. chave então
7 menor = 2i + 1

8 se menor 6= i então
9 troca F [i]. indice com F [menor]. indice
10 troca F [i] com F [menor]
11 Corrige-heapmin-para-baixo (F, menor)

Para construir um heap baseado no vetor F , vamos utilizar um procedimento similar


ao descrito na Seção 5, fazendo uso do algoritmo Corrige-heapmin-para-baixo.
Algoritmo 19: Constroi-heap-indice(F )
1 F. tam-heap = F. tamanho
2 para i = 1 até F. tam-heap faça
3 F [i]. indice = i
4 para i = bF. tam-heap /2c até 1 faça
5 Corrige-heapmin-para-baixo(F, i)

Vamos voltar nossa atenção às filas de prioridade. Se Mı́nimo(F ) é o procedimento


para retornar o elemento de menor valor em F , basta que ele retorne F [1], de modo que
é executado em tempo constante. Porém, se quisermos remover o elemento de menor
valor, precisamos fazer isso de modo que ao fim da operação a fila de prioridades ainda

80
seja um heap mı́nimo. Para garantir essa propriedade, salvamos o valor de F [1]. chave
em uma variável e colocamos F [F. tam-heap] em F [1], reduzindo em seguida o tamanho
do heap F em uma unidade. Porém, como a propriedade de heap pode ter sido
destruı́da, vamos consertá-la executando Corrige-heapmin-para-baixo(F, 1). O
algoritmo Remove-min(F ) abaixo remove e retorna o elemento que contém a menor
chave dentre todos os elementos de F .
Algoritmo 20: Remove-min(F )
1 se F. tam-heap < 1 então
2 retorna “Fila de prioridades está vazia”
3 indice menor = F [1]
4 F [F. tam-heap]. indice = 1
5 F [1] = F [F. tam-heap]
6 F. tam-heap = F. tam-heap −1
7 Corrige-heapmin-para-baixo(F, 1)
8 retorna indice menor
Como Corrige-heapmin-para-baixo(F, 1) é executado em tempo O(log n) para
um heap F com n elementos, é fácil notar que o tempo de execução de Remove-min(F )
é O(log n) para uma fila de prioridades F com n elementos.
Para alterar o valor de uma chave salva em F [i]. chave para um valor menor,
basta realizar a alteração diretamente e ir “subindo” esse elemento no heap até que a
propriedade de heap seja restaurada. O seguinte procedimento realiza essa operação.
Algoritmo 21: Diminui-chave(F, i, x)
1 se x > F [i].chave então
2 retorna “x é maior que F [i].chave”
3 F [i].chave = x
4 enquanto i > 1 e F [i].chave < F [bi/2c].chave faça
5 troca F [i].indice e F [bi/2c].indice
6 troca F [i] e F [bi/2c]
7 i = bi/2c

Como o algoritmo simplesmente “sobe” no heap, i.e., a cada passo o ı́ndice i é divi-

81
dido por 2, então em uma fila de prioridades com n elementos, Diminui-chave(F, i, x)
é executado em tempo O(log n).
Para inserir um novo elemento com chave x em uma fila de prioridades F , primeiro
verificamos se é possı́vel aumentar o tamanho do heap, caso seja possı́vel, aumen-
tamos seu tamanho tam-heap em uma unidade, inserimos um elemento com valor
maior que todas as chaves em F (aqui representado por ∞) e executamos Diminui-
chave(F, tam-heap, x) para colocar esse elemento em sua posição correta.

Algoritmo 22: Insere-fila-prioridades(F, x)


1 se F.tam-heap = F.tamanho então
2 retorna “heap está cheio”
3 F.tam-heap = F.tam-heap + 1
4 F [tam-heap].indice = F.tam-heap
5 F [tam-heap].chave = ∞
6 Diminui-chave(F, tam-heap, x)

Como o algoritmo realiza somente uma operação Diminui-chave e todas as


outras operações são executadas em tempo constante, concluı́mos que Insere-fila-
prioridades(F, x) é executado em tempo O(log n).

82
Capı́tulo

7
Union-find

A estrutura de dados conhecida como union-find mantém uma partição de um conjunto


de elementos A e permite as seguintes operações:

• Cria conjunto(x): cria um conjunto novo contendo somente o elemento x;

• Find(x): retorna qual é o conjunto de A que contém o elemento x;

• Union(x, y): gera um conjunto obtido da união dos conjuntos que contém os
elementos x e y de A.

Podemos facilmente obter algoritmos que realizam as operações Cria conjunto(x)


e Find(x) em tempo constante, i.e., O(1). Para a operação Union(x, y) vamos
descrever as ideias de um algoritmo que a realiza em tempo O(|X|), onde X e Y são
respectivamente o tamanho dos conjuntos que contém x e y, e |X| ≤ |Y |.
Dado um conjunto A, cada subconjunto X de A mantido pela estrutura Union-find é
identificado através de um atributo x.representante presente em cada elemento de A.
Assim, se temos X = {a, b, c}, os três elementos de X tem o mesmo representante, como
por exemplo a.representante = a, b.representante = a e c.representante = a. A
operação Cria conjunto(x) faz x.representante = x, de modo que para realizar a
operação Union(x, y) onde x ∈ X, y ∈ Y e |X| ≤ |Y |, vamos atualizar o representante
de todo elemento de X (o menor dentre X e Y ) para ter o mesmo representante dos
elementos de Y , isto é, basta fazer v.representante = y.representante para todo
v ∈ X. Assim, é possı́vel executar essa operação em tempo O(|X|).
84
Pa rt e

III

Algoritmos de ordenação
Capı́tulo

8
Ordenação por inserção

O problema de ordenação consiste em ordenar um conjunto de chaves contidas em


um vetor. Mais precisamente, seja A = (a1 , a2 , . . . , an ) uma sequência com n números
dada como entrada. Queremos obter uma permutação (a01 , a02 , . . . , a0n ) desses números
de modo que a01 ≤ a02 ≤ . . . ≤ a0n , i.e., desejamos obter como saı́da os elementos da
sequência de entrada ordenados de modo não-decrescente.
Dentre caracterı́sticas importantes de algoritmos de ordenação, podemos destacar
duas: um algoritmo é dito in-place se utiliza somente espaço constante 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.
Vamos analisar um algoritmo simples, chamado Insertion sort, que recebe um
vetor A[1..n] com n números e retorna esse mesmo vetor A em ordem não-decrescente.
A ideia desse algoritmo é executar n “rodadas” de instruções onde, a cada rodada
temos um subvetor de A ordenado que contém um elemento a mais do que o subvetor
da rodada anterior. Mais precisamente, ao fim na i-ésima rodada, o algoritmo garante
que o vetor A[1..i] está ordenado. Sabendo que o vetor A[1..i] está ordenado, é fácil
“encaixar” o elemento A[i + 1] na posição correta no vetor A[1..i + 1]: 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].
Assim, a posição correta de A[i + 1] é a posição j + 1. Segue o pseudocódigo do
Insertion sort.
Algoritmo 23: Insertion sort(A)
1 para i = 2, . . . , n faça
2 atual = A[i]
3 j =i−1
4 enquanto j > 0 e A[j] > atual faça
5 A[j + 1] = A[j]
6 j =j−1
7 A[j + 1] = atual
8 retorna A

Note que o Insertion sort é um algoritmo in-place e estável. A Figura 8.1 mostra
uma execução do algoritmo.

Figura 8.1: Execução do Insertion sort no vetor A = [2, 5, 1, 4, 3].

Na seção seguinte mostraremos que o algoritmo funciona corretamente.

8.1 Corretude e tempo de execução

88
Para entender como podemos utilizar as invariantes de laço para provar a corretude
de algoritmos vamos fazer a análise do algoritmo Insertion sort. Considere a seguinte
invariante de laço.

Invariante: Insertion sort

Antes de cada iteração do laço para (indexado por i), o subvetor A[1..i − 1]
está ordenado de modo não-decrescente.

Observe que o item (i) é válido antes da primeira iteração, quando i = 2, pois o
vetor A[1, . . . , i − 1] contém somente um elemento e, portanto, sempre está ordenado.
Para verificar (ii), suponha agora que o vetor A[1, . . . , i − 1] está ordenado e o laço
para executa sua i-ésima iteração. O laço enquanto “move” passo a passo o elemento
A[i] para a esquerda até encontrar sua posição correta, deixando o vetor A[1, . . . , i]
ordenado. Por fim, precisamos mostrar que ao final da execução o algoritmo ordena
todo o vetor A. Note que o laço termina quando i = n + 1, de modo que a invariante
de laço considerada garante que A[1, . . . , i − 1] = A[1, . . . , n] está ordenado, de onde
concluı́mos que o algoritmo está correto.
Para calcular o tempo de execução de Insertion sort, basta notar que a linha 1
é executada n vezes, as linhas 2, 3 e 7 são executadas n − 1 vezes cada, e se ri é a
quantidade de vezes que o laço enquanto é executado na i-ésima iteração do laço
para, então a linha 4 é executada ni=2 (ri ) vezes, e as linhas 5 e 6 são executadas
P
Pn
i=2 (ri − 1) vezes cada uma. Por fim, a linha 8 é executada somente uma vez. Assim,
o tempo de execução T (n) de Insertion sort é dado por
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 Insertion sort precisa-
mos saber o valor de cada ri , mas para isso é preciso assumir algo sobre a ordenação

89
do vetor de entrada.

8.1.1 Análise de melhor caso, pior caso e caso médio


No Insertion sort, o melhor caso ocorre quando a sequência de entrada está ordenada
de modo crescente. Nesse caso, o laço da linha 4 é executado somente uma vez para
cada 2 ≤ i ≤ n, de modo que temos ri = 1. De fato, a condição A[j] > atual será falsa
já na primeira iteração do laço enquanto, pois aqui temos j = i − 1 e como o vetor de
entrada está ordenado, temos A[i − 1] < A[i]. Portanto, nesse caso, temos que
n
X
T (n) = 2n + 3 ri
i=2

= 5n − 3
= Θ(n).

Geralmente estamos interessados no tempo de execução de pior caso do algoritmo,


isto é, o maior tempo de execução do algoritmo entre todas as entradas possı́veis de um
dado tamanho. A análise de pior caso é muito importante, pois limita superiormente
o tempo de execução para qualquer entrada, garantindo que o algoritmo nunca vai
demorar mais do que esse limite. Outra razão para a análise de pior caso ser considerada
é que para alguns algoritmos, o pior caso (ou algum caso próximo do pior) ocorre
com muita frequência. O pior caso do Insertion sort acontece quando o vetor está
ordenado de modo decrescente, pois o laço da linha 4 será executado i vezes em cada
iteração i do laço na linha 1, de modo que temos ri = i. Assim, temos
n
X
T (n) = 2n + 3 ri
i=2
2
= n + 2n − 6 (8.1)
= Θ(n2 ), (8.2)

Podemos concluir que assintoticamente o tempo de execução do pior caso de


Insertion sort é menos eficiente que o tempo no melhor caso.
Como vimos anteriormente, o tempo de execução do caso médio de um algoritmo é
a média do tempo de execução dentre todas as entradas possı́veis. Por exemplo, no

90
caso do Insertion sort, pode-se assumir que quaisquer das n! permutações dos n
elementos tem a mesma chance de ser o vetor de entrada. Note que, nesse caso, cada
número tem a mesma probabilidade de estar em quaisquer das n posições do vetor.
Assim, em média, metade dos elementos em A[1, . . . , i − 1] são menores que A[i], de
modo que na i-ésima execução do laço para, o laço enquanto é executado cerca de
i/2 vezes em média. Portanto, temos em média por volta de n(n − 1)/4 execuções
do laço enquanto. Com uma análise simples do tempo de execução do Insertion
sort que descrevemos anteriormente, obtemos que no caso médio, T (n) é uma função
quadrática em n, i.e., uma função da forma T (n) = a2 n + bn + c, onde a, b e c são
constantes que não dependem de n.
Muitas vezes o tempo de execução no caso médio é quase tão ruim quanto no
pior caso, como na análise do Insertion sort que fizemos anteriormente, onde para
ambos os casos obtivemos uma função quadrática no tamanho do vetor de entrada.
Mas é necessário deixar claro que esse nem sempre é o caso. Por exemplo, seja n o
tamanho de um vetor que desejamos ordenar. Um algoritmo de ordenação chamado
Quicksort tem tempo de execução de pior caso quadrático em n, mas em média o
tempo gasto é da ordem de n log n, que é muito menor que uma função quadrática em
n para valores grandes de n. Embora o tempo de execução de pior caso do Quicksort
seja pior do que de outros algoritmos de ordenação (e.g., Merge sort, Heapsort),
ele é comumente utilizado, dado que seu pior caso raramente ocorre. Por fim, vale
mencionar que nem sempre é simples descrever o que seria uma “entrada média” para
um algoritmo, e análises de caso médio são geralmente mais complicadas que análises
de pior caso.

8.1.2 Uma análise mais direta

Não precisamos fazer uma análise tão cuidadosa como a que fizemos na seção anterior.
Essa é uma das vantagens de se utilizar notação assintótica para estimar tempo de
execução de algoritmos. No que segue vamos fazer a análise do tempo de execução
do Insertion sort de forma mais rápida, focando apenas nos pontos que realmente
importam. Todas as instruções de todas as linhas de Insertion sort são executadas
em tempo constante, de modo que o que vai determinar a eficiência do algoritmo é
a quantidade de vezes que os laços para e enquanto são executados. O laço para é

91
executado n − 1 vezes, mas a quantidade de execuções do laço enquanto depende da
distribuição dos elementos dentro do vetor A. Se A estiver em ordem decrescente, então
as instruções dentro do laço enquanto são executadas i vezes para cada execução do
laço para (indexado por i), totalizando 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 1 vez para cada execução do laço para, totalizando
n − 1 = Θ(n) execuções, bem menos que no caso anterior.
Para deixar claro como a análise assintótica pode ser útil para simplificar a análise,
imagine que um algoritmo tem tempo de execução dado por T (n) = 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 ).

92
Capı́tulo

9
Merge sort

O algoritmo Merge sort é um algoritmo simples que faz uso do paradigma de divisão
e conquista. Dado um vetor de entrada A com n números, o Merge sort divide
A em duas partes de tamanho n/2, ordena as duas partes recursivamente e depois
combina as duas partes ordenadas em uma única parte ordenada. O procedimento
Merge sort é como segue, onde Combina é um procedimento para combinar duas
partes ordenadas em uma só parte ordenada. Para ordenar um vetor A de n posições,
basta executar Merge sort (A, 1, n).

Algoritmo 24: Merge sort(A, inicio, f im)


1 se inicio < f im então
2 meio = b(inicio + f im)/2c
3 Merge sort(A, inicio, meio)
4 Merge sort(A, meio + 1, f im)
5 Combina(A, inicio, meio, f im)

Na Figura 16.1 ilustramos uma execução do algoritmo Merge sort no vetor


A = [7, 3, 1, 10, 2, 8, 15, 6]. Note que na metade superior da figura corresponde às
chamadas recursivas nas linhas (3) e (4). A metade inferior da figura corresponde às
chamadas recursivas ao procedimento Combina (linha (5)). Logo a seguir temos o
algoritmo Combina.
Figura 9.1: Execução de Merge sort(A, 1, n) para A = [7, 3, 1, 10, 2, 8, 15, 6].

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


1 n1 = meio − inicio + 1
2 n2 = f im − meio
3 cria vetores auxiliares E[1..(n1 + 1)] e D[1..(n2 + 1)]
4 E[n1 + 1] = ∞
5 D[n2 + 1] = ∞
6 para i = 1 até n1 faça
7 E[i] = A[inicio + i − 1]
8 para j = 1 até n2 faça
9 D[j] = A[meio + j]
10 i=1
11 j=1
12 para k = inicio até f im faça
13 se E[i] ≤ D[j] então
14 A[k] = E[i]
15 i=i+1
16 senão
17 A[k] = D[j]
18 j =j+1

94
O procedimento Combina(A, inicio, meio, f im) cria um vetor E com meio−inicio+
1 posições e um vetor D com f im − meio posições, que recebem, respectivamente, o
vetor ordenado A[inicio..meio] e A[meio + 1..f im]. Comparando os elementos desses
dois vetores, é fácil colocar em ordem todos esses elementos em A[inicio..f im]. Note
que por usar os vetores auxiliares E e D, o Merge sort não é um algoritmo in-place.

Na Figura 9.2 temos uma simulação da execução de Combina(A, 1, 4, 8), onde


A = [1, 3, 7, 10, 2, 6, 8, 15].

Figura 9.2: Execução de Combina(A, p, q, r) no vetor A = [1, 3, 7, 10, 2, 6, 8, 15] com


parâmetros p = 1, q = 4 e r = 8.

Considere uma execução de Combina ao receber um vetor A e parâmetros inicio,


meio e f im como entrada. Note que a linha 3 é executada em tempo Θ(f im − inicio)
e todas as outras linhas são executadas em tempo constante. O laço para na linha
(6) é executado meio − inicio + 1 vezes, o laço para na linha (8) é executado f im − 1
vezes, e o laço para na linha (12)) é executado f im − inicio + 1 vezes. Se C(n) é

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

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

para n > 1, e T (n) = 1 para n = 1.


Como visto no Capı́tulo ??, o tempo de execução de Merge sort é dado por
T (n) = 2T (n/2) + n = Θ(n log n).

96
Capı́tulo

10
Selection sort e Heapsort

Neste capı́tulo vamos introduzir dois algoritmos para o problema de ordenação, o


Selection sort e o Heapsort. O Selection sort é um algoritmo que sempre mantém
o vetor de entrada A dividido em dois subvetores contı́guos, uma parte inicial Ae de A
contendo elementos não ordenados, e a segunda parte Ad de A contendo os maiores
elementos de A (já ordenados). A cada iteração do algoritmo, o maior elemento x
do subvetor Ae é encontrado, e o subvetor Ad é aumentado de uma unidade com a
inserção do elemento x em sua posição correta. O Heapsort utiliza uma estrutura
de dados chamada de heap binário (ou, simplesmente, heap) para encontrar o maior
elemento de um subvetor de forma eficiente. Dessa forma, o Heapsort pode ser visto
como uma versão mais eficiente do Selection sort.

10.1 Selection sort


O algoritmo Selection sort possui uma estrutura muito simples, contendo dois
laços para aninhados. O primeiro laço é executado n − 1 vezes, de modo que em
cada iteração desse laço, obtemos um vetor ordenado Ad que é uma unidade maior
que o vetor ordenado que tı́nhamos antes da iteração. Ademais, o vetor Ad sempre
contém os maiores elementos de A. Para manter essa propriedade, a cada passo, o
maior elemento fora do subvetor ordenado Ad é adicionado ao inı́cio de Ad . Abaixo
temos o pseudocódigo de Selection sort.
Algoritmo 26: Selection sort(A[1..n])
1 para i = n até 2 faça
2 indiceM ax = i
3 para j = i − 1 até 1 faça
4 se A[j] > A[indiceM ax] então
5 indiceM ax = j

6 troca A[indiceM ax] com A[i]

Note que todas as linhas são executadas em tempo constante e cada um dos laços
para é executado Θ(n) vezes cada. Como um dos laços está dentro do outro, temos
que o tempo de execução de Selection sort(A[1..n]) é Θ(n2 ).
Na Figura 10.1 temos um exemplo de execução do algoritmo Selection sort(A).
No que segue vamos utilizar a seguinte invariante de laço para mostrar que o
algoritmo Selection sort(A[1..n]) funciona corretamente.

Invariante: Selection sort

Antes de cada iteração do primeiro laço para (indexado por i), o subvetor
A[i + 1..n] está ordenado de modo não-decrescente e contém os maiores elementos
de A.

Teorema 10.2

O algoritmo Selection sort(A) ordena um vetor A de modo não-decrescente.

Demonstração. Como inicialmente i = n, a invariante é trivialmente satisfeita. Su-


ponha agora que a invariante é válida imediatamente antes da i-ésima iteração do
primeiro laço para, i.e., o subvetor A[i + 1..n] está ordenado de modo não-decrescente
e contém os maiores elementos de A. Precisamos mostrar que antes da (i − 1)-ésima
iteração o subvetor A[i..n] está ordenado de modo não-decrescente e contém os maiores
elementos de A. Mas note que na i-ésima iteração do primeiro laço para, o segundo

98
Figura 10.1: Execução de Selection sort(A) no vetor A = [2, 5, 1, 4, 3].

laço para (na linha 3) verifica qual o ı́ndice indiceM ax do maior elemento do vetor
A[1..i − 1] (isso pode ser formalmente provado por uma invariante de laço!). Na linha 6,
o maior elemento de A[1..i − 1] é trocado de lugar com o elemento A[i], garantindo
que A[i..n] está ordenado e contém os maiores elementos de A.
Por fim, note que na última vez que a linha 1 é executada temos i = 1. Assim,
pela invariante de laço, o vetor A[2..n] está ordenado. Como sabemos que os maiores
elementos de A estão em A[2..n], concluı́mos que o vetor A[1..n] está ordenado.

10.2 Heapsort
O Heapsort é um algoritmo de ordenação com tempo de execução de pior caso
Θ(n log n), como o Merge sort. O Heapsort é um algoritmo in-place, apesar de
não ser estável.

99
O algoritmo troca o elemento na raiz do heap (maior elemento) com o elemento
na última posição do vetor e restaura a propriedade de heap para A[1, . . . , n − 1], em
seguida fazemos o mesmo para A[1, . . . , n − 2] e assim por diante. O algoritmo é como
segue.

Algoritmo 27: Heapsort (A)


1 Constroi-heap(A)
2 para i = n até 2 faça
3 troca A[1] com A[i]
4 A.tam-heap = A.tam-heap − 1
5 Corrige-heap-para-baixo(A, 1)

Na Figura 10.2 temos um exemplo de execução do algoritmo Heapsort.


Uma vez que já provamos a corretude de Constroi-heap e Corrige-heap-para-
baixo (veja Capı́tulo 5), a prova de corretude do algoritmo Heapsort é bem simples.
Utilizaremos a seguinte invariante de laço.

Invariante: Heapsort

Antes de cada iteração do laço para (indexado por i) temos que:

• O vetor A[i + 1..n] está ordenado de modo não-decrescente e contém os


maiores elementos de A.

• A.tam-heap = i e o vetor A[1..A.tam-heap] é um heap máximo.

Teorema 10.2

O algoritmo Heapsort(A) ordena o vetor A de modo não-decrescente.

Demonstração. A linha 1 constrói um heap a partir do vetor A. Assim, como inicial-


mente i = n, a invariante é trivialmente satisfeita. Suponha agora que a invariante é
válida imediatamente antes da i-ésima iteração do laço, i.e., o subvetor A[i+1..n] está or-

100
Figura 10.2: Algoritmo Heapsort executado no vetor A = [4, 7, 3, 8, 1, 9]. Note que a
primeira árvore da figura é o heap obtido por Constroi-heap(A).

denado de modo não-decrescente e contém os maiores elementos de A, e A.tam-heap = i


onde A[1..A.tam-heap] é um heap máximo. Precisamos mostrar que a invariante é
válida antes da (i − 1)-ésima iteração. Na i-ésima iteração do primeiro laço, o al-
goritmo troca A[1] com A[i], colocando o maior elemento de A[1..A.tam-heap] em
A[i], diminui A.tam-heap em uma unidade, fazendo com que A.tam-heap = i − 1,
e executa Corrige-heap-para-baixo(A, 1). Mas note que o único elemento de
A[1..A.tam-heap] que pode não satisfazer a propriedade de heap é A[1]. Como sabe-
mos que Corrige-heap-para-baixo(A, 1) funciona corretamente, temos que após
esse comando A[1..A.tam-heap] é um heap máximo. Como o maior elemento de
A[1..A.tam-heap] está em A[i] e dado que sabemos que A[i + 1..n] está ordenado de

101
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.
Assim, mostramos que a invariante é válida antes da (i − 1)-ésima iteração do laço.
Ao final da execução do laço, temos i = 1. Portanto, pela invariante, sabemos que
A[2..n] está ordenado de modo não-decrescente e contém os maiores elementos de A.
Como A[2..n] contém os maiores elementos de A, o menor elemento certamente está
em A[1], de onde concluı́mos que A está ordenado.

Claramente, esse algoritmo tem tempo de execução O(n log n). De fato, Constroi-
heap é feito em tempo O(n) e como são realizadas n − 1 execuções do laço para,
e Corrige-heap-para-baixo é executado em tempo O(log n), temos que o tempo
total gasto por Heapsort é O(n log n). Ademais, não é difı́cil perceber que se o vetor
de entrada estiver ordenado, Heapsort leva tempo Ω(n log n). Portanto, o tempo de
execução do Heapsort é Θ(n log n).

102
Capı́tulo

11

Quicksort

O algoritmo Quicksort é um algoritmo que resolve o problema de ordenação e tem


tempo de execução de pior caso Θ(n2 ), bem pior que o tempo O(n log n) gasto por
Heapsort e Mergesort. Porém, muitas vezes o Quicksort oferece a melhor escolha
na prática. Isso se dá pelo fato de seu tempo de execução ser em média Θ(n log n) e
a constante escondida em Θ(n log n) ser bem pequena. Vamos descrever o algoritmo
Quicksort e fazer uma análise do tempo médio de execução do Quicksort.

Seja A[1..n] um vetor. O algoritmo Quicksort faz uso do método de divisão


e conquista (assim como o Mergesort). O algoritmo funciona como segue: um
elemento de A chamado de pivô, é escolhido dentre todos os elementos de A. Feito
isso, o Quicksort reorganiza o vetor A de modo que o pivô fique em sua posição final
(no vetor ordenado), digamos A[x], todas as chaves em A[1, . . . , x − 1] são menores
que o pivô e todas as chaves em A[x + 1, . . . , n] são maiores que o pivô. O próximo
passo é ordenar recursivamente os vetores A[1, . . . , x − 1] e A[x + 1, . . . , n]. O algoritmo
Partição abaixo reorganiza o vetor A[inı́cio, . . . , f im] in-place, retornando a posição
correta do pivô escolhido.
Algoritmo 28: Partição(A, inı́cio, f im)
1 pivô = A[fim]
2 i = inı́cio
3 para j = inı́cio até f im − 1 faça
4 se A[j] ≤ pivô 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 11.1 temos um exemplo de execução do procedimento Partição.


A seguinte invariante de laço pode ser utilizada para provar a corretude do algoritmo
Partição(A, inı́cio, fim).

Invariante: Partição

Antes de cada iteração do laço para indexada por j, temos A[f im]=pivô e vale
que

(i) para inı́cio ≤ k ≤ i − 1, temos A[k] ≤ pivô;

(ii) para i ≤ k ≤ j − 1, temos A[k] > pivô.

Teorema 11.2

O algoritmo Partição(A[1..n]) retorna um ı́ndice i tal que o pivô está na posição


A[i], todo elemento em A[1..i − 1] é menor ou igual ao pivô, e todo elemento em
A[i + 1..n] é maior que o pivô.

Demonstração. Como o pivô está inicialmente em A[f im], não precisamos nos pre-
ocupar com a condição A[f im]=pivô na invariante, 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 = inı́cio

104
e j = inı́cio, logo as condições (i) e (ii) são trivialmente satisfeitas. Suponha que a
invariante é válida antes da iteração j do laço para, i.e., para inı́cio ≤ k ≤ i − 1, temos
A[k] ≤ pivô, e para i ≤ k ≤ j − 1, temos A[k] > pivô. Provaremos que ela continua
válida imediatamente antes da (j + 1)-ésima iteração. Na j-ésima iteração do laço, caso
A[j] > pivô, a única operação feita é alterar j para j + 1, de modo que a condição (ii)
continua válida (nesse caso a condição (i) é claramente satisfeita). Caso A[j] ≤ pivô,
trocamos A[i] e A[j] de posição, de modo que agora temos que todo elemento em
A[1..i] é menor ou igual ao pivô (pois sabı́amos que, para inı́cio ≤ k ≤ i − 1, tı́nhamos
A[k] ≤ pivô). Feito isso, i é incrementado para i + 1. Assim, como para inı́cio ≤ k ≤ i,
temos A[k] ≤ pivô, a invariante continua válida.
Ao fim da execução do laço, temos j = 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] e A[f im]
de posição.

Como o laço para é executado f im−inı́cio vezes, o tempo de execução de Partição


é Θ(f im − inı́cio). Agora podemos descrever o algoritmo Quicksort. Para ordenar
A basta executar Quicksort(A, 1, n).

Algoritmo 29: Quicksort(A, inı́cio, f im)


1 se inı́cio < f im então
2 i = Partição(A, inı́cio, f im)
3 Quicksort (A, inı́cio, i − 1)
4 Quicksort (A, i + 1, f im)

Na Figura 11.2 temos um exemplo de execução do procedimento Quicksort.


Para provar que o algoritmo Quicksort funciona corretamente, usaremos indução
no ı́ndice i.

Teorema 11.3: Corretude de Quicksort

O algoritmo Quicksort(A[inı́cio..f im]) ordena o vetor A de modo não-


descrescente.

Demonstração. Claramente o algoritmo ordena um vetor que contém somente um

105
Figura 11.1: Partição executado em A = [3, 8, 6, 1, 5, 2, 4] com inı́cio = 1 e f im = 7.

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

106
Figura 11.2: Algoritmo Quicksort executado no vetor A = [3, 9, 1, 2, 7, 4, 8, 5, 0, 6]
com inı́cio = 1 e f im = 10.

11.1 Tempo de execução

O tempo de execução de Quicksort depende fortemente de como as chaves estão


distribuı́das dentro do vetor de entrada A. Se na linha 1 de Quicksort, o elemento
escolhido como pivô é sempre o maior do vetor analisado, então o problema de ordenar
é sempre quebrado em dois subproblemas, um de tamanho n − 1 e um de tamanho 0.
Lembrando que o tempo de execução de Partição(A, 1, n) é Θ(n), temos que, nesse
caso, o tempo de execução de Quicksort é dado por T (n) = T (n − 1) + Θ(n). Se

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

Então, no caso analisado, T (n) = Θ(n2 ). Intuitivamente, esse é o pior caso possı́vel.
Mas pode ser que o vetor seja sempre dividido em duas partes de mesmo tamanho,
tendo tempo de execução dado por T (n) = 2T (n/2) + Θ(n) = Θ(n log n).
Felizmente, para grande parte das possı́veis ordenações iniciais do vetor A, o tempo
de execução do caso médio para o Quicksort é assintoticamente bem próximo de
Θ(n log n). Por exemplo, se Partição divide o problema em um subproblema de
tamanho (n − 1)/1000 e outro de tamanho 999(n − 1)/1000, o tempo de execução é
dado por

T (n) = T ((n − 1)/1000) + T (999(n − 1)n/1000) + Θ(n)


= T (n/1000) + T (999n/1000) + Θ(n).

É possı́vel mostrar que temos T (n) = O(n log n). De fato, para qualquer constante
k > 1 (e.g., k = 10100 ), se Partição divide A em partes de tamanho aproximadamente
n/k e (k − 1)n/k, o tempo de execução ainda é O(n log n).
Vamos utilizar o método da substituição para mostrar que T (n) = O(n log n).
Assumindo que T (n) ≤ c para alguma constante c ≥ 1 e todo n ≤ k − 1. Vamos provar
que T (n) = T (n/k) + T ((k − 1)n/k) + n é no máximo

dn log n + n

para todo n ≥ k e algum d > 0. Começamos notando que T (k) ≤ T (k − 1) + T (1) + k ≤

108
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 pelo fato do
tamanho da árvore de recursão nesse caso ser logk/(k−1) n = Θ(log n), e em cada passo
é executada uma quantidade de passos proporcional ao tamanho do vetor analisado,
então o tempo total de execução é O(n log n).

Vamos agora analisar formalmente o tempo de execução de pior caso. O pior caso é
dado por T (n) = max0≤x≤n−1 (T (x) + T (n − x − 1)) + n. Vamos utilizar o método da
substituição para mostrar que T (n) ≤ n2 . Supondo que T (m) ≤ m2 para todo m < n,
obtemos

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


0≤x≤n−1

= max (x2 + (n − x − 1)2 ) + n


0≤x≤n−1

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

109
onde o máximo na segundo 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.

Vamos agora analisar o que acontece no caso médio, quando todas as ordenações
possı́veis dos elementos de A tem a mesma chance de serem o vetor de entrada A.
Suponha agora que o pivô é escolhido uniformemente ao acaso dentre as chaves contidas
em A, i.e., cada uma das possı́veis n! ordenações de A tem a mesma chance de ser a
ordenação do vetor de entrada A.

É fácil ver que o tempo de execução de Quicksort é dominado pela quantidade


de operações feitas na linha 4 de Partição, dentro do laço para. Mostraremos agora
que a variável aleatória X que conta a quantidade de vezes que essa linha é executada
durante uma execução completa de Quicksort tem valor esperado O(n log n).

Sejam o1 , . . . , on os elementos de A em sua ordenação final (após estarem ordenados


de modo crescente), i.e., o1 < o2 < . . . < on . 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 é o pivô ele nunca mais
será comparado com nenhum outro elemento. Defina Xij como a variável aleatória
indicadora para o evento “oi é comparado com oj ”.

Vamos 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 {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 Partição e nunca serão comparados. Portanto,

P (oi ser comparado com oj ) = P (oi ou oj ser o primeiro a ser escolhido


como pivô em {oi , oi+1 , . . . , oj })
2
= .
j−i+1

110
Voltando nossa atenção para a variável aleatória X, temos

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

111
112
Capı́tulo

12
Ordenação em tempo linear

Vimos alguns algoritmos com tempo de execução (de pior caso ou caso médio) Θ(n log n).
Mergesort e Heapsort têm esse limitante no pior caso e Quicksort possui tempo
de execução esperado da ordem de n log n. Note que esses 3 algoritmos são baseados em
comparações entre os elementos de entrada. É possı́vel mostrar, analisando uma árvore
de decisão geral, que qualquer algoritmo baseado em comparações requer Ω(n log n)
comparações no pior caso. Portanto, Mergesort e Heapsort são assintoticamente
ótimos.
Algumas vezes, quando sabemos informações extras sobre os dados de entrada,
é possı́vel obter um algoritmo de ordenação em tempo linear. Obviamente, tais
algoritmos não são baseados em comparações. Para exemplificar, vamos discutir o
algoritmo Counting sort a seguir.

12.1 Counting sort


Assuma que o vetor de entrada A contém somente números inteiros entre 0 e k. Quando
k = O(n), o algoritmo Counting sort é executado em tempo Θ(n). Será necessário
utilizar um vetor extra B com n posições e um vetor C com k posições, de modo que o
algoritmo não é in-place. A ordem relativa de elementos iguais será mantida, de modo
que o algoritmo é estável.
Para cada elemento x em A, o Counting sort verifica quantos elementos de A
são menores ou iguais a x. Assim, o algoritmo consegue colocar x na posição correta
sem precisar fazer nenhuma comparação. O algoritmo pode ser visto abaixo.

Algoritmo 30: Counting sort(A, k)


/* C é um vetor auxiliar e B guardará o vetor ordenado */
1 Sejam B[1..A.tamanho] e C[0..k] novos vetores
/* Inicializando o vetor C */
2 para i = 0 até k faça
3 C[i] = 0
/* C[i] conterá a quantidade de ocorr^
encias de i em A */
4 para j = 1 até n faça
5 C[A[j]] = C[A[j]] + 1
/* C[i] conterá a quantidade de ocorr^
encias de elementos de
{0, . . . i} em A */
6 para i = 1 até k faça
7 C[i] = C[i] + C[i − 1]
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 12.1 contém um exemplo de execução do algoritmo Counting sort.


Os quatro laços para existentes no algoritmo Counting-sort são executados, res-
pectivamente, k, n, k e n vezes. Portanto, claramente a complexidade do procedimento
é Θ(n + k). Concluı́mos então que quando k = O(n), o algoritmo Counting sort é
executado em tempo Θ(n), de modo que é assintoticamente mais eficiente que todos os
algoritmos de ordenação vistos aqui. Uma caracterı́stica importante do algoritmo é
que ele é estável. Esse algoritmo é comumente utilizado como subrotina de um outro
algoritmo de ordenação em tempo linear, chamado Radix sort, e é essencial para o
funcionamento do Radix sort que o Counting sort seja estável.

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

115
116
Pa rt e

IV

Técnicas de construção de algoritmos


Capı́tulo

13
Divisão e conquista

“Dynamic programming is a fancy name for


divide-and-conquer with a table.”

Ian Parberry — Problems on Algorithms, 1995.

Divisão e conquista é um paradigma para o desenvolvimento de algoritmos que faz uso


da recursividade. Para resolver um problema utilizando esse paradigma, seguimos os
três seguintes passos.

• O problema é dividido em subproblemas menores;

• Os subproblemas menores são resolvidos recursivamente: cada um desses


subproblemas menores é divido em subproblemas ainda menores, a menos
que sejam tão pequenos a ponto de ser simples resolvê-los diretamente;
• Soluções dos subproblemas menores são combinadas para formar uma solução
do problema inicial.

Wait for it.

120
Capı́tulo

14
Algoritmos gulosos

Wait for it.


122
Capı́tulo

15
Programação dinâmica

Programação dinâmica é uma importante técnica de construção de algoritmos, utili-


zada em problemas cujas soluções podem ser modeladas de forma recursiva. Assim,
como na divisão e conquista, um problema gera subproblemas que serão resolvidos
recursivamente. Porém, quando a solução de um subproblema precisa ser utilizada
várias vezes em um algoritmo de divisão e conquista, a programação dinâmica pode ser
uma eficiente alternativa no desenvolvimento de um algoritmo para o problema. Uma
das caracterı́sticas mais marcantes da programação dinâmica é evitar resolver o mesmo
subproblema diversas vezes. Isso pode ser feito de duas formas (abordagens top-down
e bottom-up), que veremos ao longo deste capı́tulo.

15.1 Um problema simples

Antes de discutirmos a técnica de programação dinâmica, vamos analisar o problema de


encontrar o n-ésimo número da sequência de Fibonacci para obter um pouco de intuição
sobre o que será discutido adiante. A sequência 1, 1, 2, 3, 5, 8, 13, 21, 34, . . . é conhecida
como sequência de Fibonacci. O n-ésimo termo dessa sequência, denotado por F (n),
é dado por F (1) = 1, F (2) = 1 e para n ≥ 3 temos F (n) = F (n − 1) + F (n − 2).
Assim, o seguinte algoritmo recursivo para calcular o n-ésimo número da sequência de
Fibonacci é muito natural.
Algoritmo 31: Fibonacci(n)
1 se n ≤ 2 então
2 retorna 1
3 retorna Fibonacci(n − 1) + Fibonacci(n − 2)

O algoritmo acima é extremamente ineficiente. De fato, muito trabalho repetido


é feito, pois subproblemas são resolvidos recursivamente diversas vezes. A Figura ??
mostra como alguns subproblemas são resolvidos várias vezes em uma chamada a
Fibonacci(5).

Podemos estimar o método da substituição para mostrar que o tempo de execução


√ n 
T (n) = T (n − 1) + T (n − 2) + 1 de Fibonacci(n) é Ω (1 + 5)/2 . Para ficar
√ n
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, 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

124

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 Fibonacci é de fato muito ineficiente,



tendo tempo de execução T (n) = Ω (1, 618)n .
Mas como podemos evitar que o algoritmo repita trabalho já realizado? Uma forma
possı́vel é salvar o valor da solução de um subproblema em uma tabela na primeira vez
que ele for calculado. Assim, sempre que precisarmos desse valor, a tabela é consultada
antes de resolver o subproblema novamente. O seguinte algoritmo é uma variação
de Fibonacci onde cada vez que um subproblema é resolvido, o valor é salvo no vetor F .

Algoritmo 32: Fibonacci-TD(n)


1 Cria vetor F [1..n]
2 F[1] = 1
3 F[2] = 1
4 para i = 3 até n faça
5 F [i] = −1
6 retorna Fib-recursivo-TD(n)

Algoritmo 33: Fib-recursivo-TD(n)


1 se F [n] ≥ 0 então
2 retorna F [n]
3 F [n] = Fib-recursivo-TD(n − 1) + Fib-recursivo-TD(n − 2)
4 retorna F [n]

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

Algoritmo 34: Fibonacci-BU(n)


1 Cria vetor F [1..n]
2 F [1] = 1
3 F [2] = 1
4 para i = 3 até n faça
5 F [i] = F [i − 1] + F [i − 2]
6 retorna F [n]

15.2 Aplicação e caracterı́sticas principais


Problemas em que a programação dinâmica pode ser aplicada em geral são problemas
de otimização, i.e., problemas onde estamos interessados em maximizar ou minimizar
certa quantidade dadas algumas restrições. Algumas vezes a programação dinâmica

126
pode ser usada em problemas onde estamos interessados em determinar uma quantidade
recursivamente.
Abaixo definimos subestrutura ótima e sobreposição de problemas, duas carac-
terı́sticas que um problema deve ter para que programação dinâmica seja aplicada com
sucesso.

Definição 15.1: Subestrutura ótima

Um problema tem subestrutura ótima se uma solução ótima para o problema


pode ser obtida através de soluções ótimas de subproblemas.

Definição 15.2: Sobreposição de subproblemas

Um problema tem sobreposição de problemas quando pode ser dividido em


subproblemas que são utilizados repetidamente em um algoritmo recursivo que
resolve o problema.

Se um problemas possui subestrutura ótima e sobreposição de subproblemas, dizemos


que é um problema de programação dinâmica. Para clarear o entendimento sobre as
Definições 15.1 e 15.2, vamos analisar um clássico problema de decidir em que ordem
multiplicamos uma sequência de matrizes. No que segue, assuma que a multiplicação
AB de uma matriz A de ordem k × ` por uma matriz B de ordem ` × m realiza cerca
de k`m operações. O problema a seguir servirá para exemplificar os tópicos discutidos
nesta seção.

Problema 15.3: Multiplicação de sequências de matrizes

Dadas matrizes M1 , . . . , Mk tais que Mi é uma matriz mi ×mi+1 , para 1 ≤ i ≤ k,


encontrar a ordem em que precisamos multiplicar as matrizes para que o produto
M1 M2 . . . Mk seja feito da forma mais eficiente possı́vel.

Perceba que a ordem em que multiplicamos as matrizes é essencial para garantir a


eficiência do produto total. Por exemplo, considere k = 3, i.e., matrizes M1 , M2 e M3 ,
onde m1 = 1000, m2 = 2, m3 = 1000 e m4 = 2. Se fizermos primeiro o produto M1 M2 ,
i.e., estamos realizando a multiplicação ((M1 M2 )M3 ), então a quantidade de operações

127
realizadas é de cerca de

m1 m2 m3 + m1 m3 m4 = m1 m3 (m2 + m4 ) = 4000000.

Porém, se calcularmos primeiro M2 M3 , i.e., multiplicamos (M1 (M2 M3 )), então a


quantidade de operações realizadas é de cerca de

m2 m3 m4 + m1 m2 m4 = m2 m4 (m1 + m3 ) = 8000.

Claramente, pode haver uma grande diferença na eficiência dependendo da ordem em


que as multiplicações são realizadas.
Uma forma de ver que o problema de multiplicar sequência de matrizes possui
subestrutura ótima é notar o seguinte: Uma forma ótima de multiplicar matrizes
M1 . . . Mk é encontrar o ı́ndice 1 ≤ i ≤ k tal que a forma ótima de multiplicar
M1 . . . Mk é multiplicar (M1 . . . Mi ) e (Mi+1 . . . Mk ) de forma ótima e depois efetuar o
produto (M1 . . . Mi )(Mi+1 . . . Mk ). Portanto, para multiplicar (M1 M2 . . . Mi ) de forma
ótima, precisamos resolver os subproblemas de multiplicar de forma ótima (M1 . . . Mi )
e (Mi+1 . . . Mk ).
Para encontrar o melhor ı́ndice i para dividir o problema, precisamos considerar
todas as possibilidades, i.e., i = 1, i = 2, . . ., i = k − 1. Assim, já para escolhermos o
primeiro ı́ndice i para dividir o problema inicial em dois subproblemas, já precisamos
considerar o problema de multiplicar de forma ótima a sequencia M1 . . . Mi , para
1 ≤ i ≤ k − 1. Mas, por exemplo, para resolver o subproblema (M1 . . . Mi ) precisamos
considerar todos os subproblemas de multiplicar (M1 . . . Mj ) para 1 ≤ j ≤ i − 1, que
são subproblemas que já foram analisados antes. Portanto, é fácil notar que o problema
possui a propriedade de sobreposição de subproblemas. A programação dinâmica salva
cada subproblema analisado em uma tabela (ou uma matriz) evitando a resolução de
um mesmo subproblema repetidas vezes.
As propriedades de subestrutura ótima e sobreposição de subproblemas definem se
um problema de otimização pode ser atacado de forma eficiente por um algoritmo de
programação dinâmica.
Em geral, o tempo de execução de algoritmos de programação dinâmica é deter-
minado por dois fatores: (i) a quantidade de subproblemas que uma solução ótima
utiliza; (ii) quantidade de possibilidades analisadas para determinar que subproblemas

128
são utilizados em uma solução ótima. No exemplo do problema de multiplicação
de uma sequência de matrizes, temos que (i) o problema sempre é dividido em dois
subproblemas, e (ii) se o subproblema possui k matrizes, analisamos k − 1 subproblemas
para decidir quais duas subsequências compõem a solução ótima.
Dado um problema, podemos dividir os passos para a elaboração de um algoritmo
de programação dinâmica para o problema como na definição abaixo.

Definição 15.4: Construindo algoritmos de programação dinâmica

Os seguintes três passos compõem as etapas de construção de um algoritmo de


programação dinâmica.

(1) Caracterização da estrutura ótima e do valor de uma solução ótima recursi-


vamente;

(2) Cálculo do valor de uma solução ótima;

(3) Construção de uma solução ótima.

Antes de resolvermos alguns problemas utilizando a técnica de programação dinâmica


seguindo os passos acima, vamos discutir brevemente duas formas de implementar essa
técnica, que são as abordagens top-down e bottom-up.
Na abordagem top-down, o algoritmo é desenvolvido de forma recursiva natural,
com a diferença que, sempre que um subproblema for resolvido, o resultado é salvo em
uma tabela. Assim, sempre que o algoritmo precisar da solução de um subproblema,
ele consulta a tabela antes de resolver o subproblema. Em geral, algoritmos top-down
são compostos por dois procedimentos, um que faz uma inicialização de variáveis e
prepara a tabela, e outro procedimento que compõe o análogo a um algoritmo recursivo
natural para o problema. Veja os Algoritmos 32 e 33.
Na abordagem bottom-up, é necessário entender quais os tamanhos dos subproble-
mas que precisam ser resolvidos antes de resolvermos o problema. Assim, resolvendo os
subproblemas em ordem crescente de tamanho, i.e., começando pelos menores, conse-
guimos garantir que ao resolver um subproblema de tamanho n, todos os subproblemas
menores necessários já foram resolvidos. Essa abordagem dispensa verificar se um dado
subproblema já foi resolvido, dado que temos a certeza que isso já aconteceu.

129
Em geral as duas abordagens fornecem algoritmos com mesmo tempo de execução
assintótico. No final deste capı́tulo apresentamos uma comparação entre aspectos de
algoritmos top-down e bottom-up.

15.3 Utilizando programação dinâmica


Nesta seção vamos desenvolver e analisar algoritmos de programação dinâmica para
diversos problemas de programação dinâmica, discutindo algoritmos top-down e bottom-
up para alguns desses problemas.

15.3.1 Corte de barras


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 barra de tamanho inteiro e vender os
pedaços de modo a maximizar o lucro obtido.

Problema 15.1: Corte de barras

Sejam p1 , . . . , pn inteiros positivos que correspondem, respectivamente, ao preço


de venda de barras de tamanho 1, . . . , n. Dado um inteiro positivo n, o problema
consiste em maximizar o lucro `n obtido com a venda de uma barra de tamanho
n, que pode ser vendida em pedaços de tamanho inteiro.

Para exemplificar o problema, considere uma barra de tamanho 6 com preços dos
pedaços como na tabela abaixo.

n p1 p2 p3 p4 p5 p6
6 3 8 14 15 10 20

Tabela 15.1: Preços para o problema do corte de uma barra de tamanho 6.

Note que se a barra for vendida sem nenhum corte, então temos lucro `6 = 20.
Caso cortemos um pedaço de tamanho 5, então a única possibilidade é vender uma

130
parte de tamanho 5 e outra de tamanho 1, que fornece um lucro de `6 = p5 + p1 = 13,
o que é pior que vender a barra inteira. 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 lucro
`6 = p4 + p2 = 23. Porém, se vendermos dois pedaços de tamanho 3, obtemos um lucro
total de `6 = 2p3 = 28, que é o maior lucro possı́vel. De fato, vender somente pedaços
de tamanho 2 ou 1 garantirá um lucro menor.
Primeiro vamos construir um algoritmo de divisão e conquista natural para o
problema do corte de barras. Podemos definir `n recursivamente definindo onde aplicar
o primeiro corte na barra. Assim, se o melhor lugar para realizar o primeiro corte na
barra é no ponto i (onde 1 ≤ i ≤ n), então o lucro total é dado por `n = pi + `n−i , que
é o preço do pedaço de tamanho i somado ao maior lucro possı́vel obtido com a venda
do restante da barra, que tem tamanho n − i. Portanto, temos

`n = max {pi + `n−i }. (15.1)


1≤i≤n

A igualdade (15.1) sugere o seguinte algoritmo para resolver o problema, onde p é


um vetor contendo os preços dos pedaços de uma barra de tamanho n.

Algoritmo 35: Corte barras-DV(n,p)


1 se n = 0 então
2 retorna 0
3 lucro = 0
4 para i = 1 até n faça
5 valor = pi + Corte barras-DV(n − i,p)
6 se lucro < valor então
7 lucro = valor

8 retorna lucro

Apesar de ser um algoritmo intuitivo e calcular corretamente o lucro máximo


possı́vel, ele é extremamente ineficiente, pois muito trabalho é repetido pelo algoritmo.
De fato, seja T (n) o tempo de execução de Corte barras-DV(n,p). Vamos utilizar

131
o método da substituição para provar que T (n) ≥ 2n . Claramente temos T (0) = 1 = 20 .
Suponha que T (m) ≥ 2m para todo 0 ≤ m ≤ n − 1. Portanto, notando que T (n) =
1 + T (0) + T (1) + . . . + T (n − 1), obtemos

T (n) = 1 + T (0) + T (1) + . . . + T (n − 1)


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

Assim, o problema possui a propriedade de sobreposição de subproblemas. Cla-


ramente, o problema também possui a propriedade de subestrutura ótima, dado que
inclusive já modelamos o valor de uma solução ótima baseado em soluções ótimas
de subproblemas (veja (15.1)). Portanto, o problema tem os ingredientes necessários
para que um algoritmo de programação dinâmica o resolva de forma eficiente. Abaixo
apresentamos um algoritmo com abordagem top-down para o problema do corte de
barras. Esse algoritmo mantém a estrutura de Corte barras-DV(n,p), salvando
os valores de soluções ótimas de subproblemas em um vetor r[0..n], de modo que r[i]
contém o valor de uma solução ótima para o problema de corte de uma barra de
tamanho i. Ademais, vamos manter um vetor s[0..n] tal que s[j] contém o primeiro
lugar que deve-se efetuar o corte em uma barra de tamanho j.

Algoritmo 36: Corte barras-TD(n, p)


1 Cria vetores r[0..n] e s[0..n]
2 r[0] = 0
3 para i = 1 até n faça
4 r[i] = −1
5 retorna Corte barras-aux(n, p, r, s)

132
Algoritmo 37: Corte barras-aux(n,p,r,s)
1 se r[n] ≥ 0 então
2 retorna r[n]
3 lucro = −1
4 para i = 1 até n faça
5 (valor, s) = Corte barras-aux(n − i,p,r,s)
6 se lucro < pi + valor então
7 lucro = pi + valor
8 s[n] = i

9 r[n] = lucro
10 retorna (lucro, s)

O algoritmo Corte barras-TD(n) inicialmente cria os vetores r e s, faz r[0] = 0 e


inicializa todas as outras entradas de r com −1, representando que ainda não calculamos
esses valores. Feito isso, Corte barras-aux(n,p,r,s) é executado.

Inicialmente, nas linhas 1 e 2, o algoritmo Corte barras-aux(n,p,r,s) verifica


se o subproblema em questão já foi resolvido. Caso o subproblema não tenha sido
resolvido, então o algoritmo vai fazer isso de modo muito semelhante ao algoritmo 35.
A diferença é que agora salvamos o melhor local para fazer o primeiro corte em uma
barra de tamanho n em s[n].

Vamos analisar agora o tempo de execução de Corte barras-TD(n,p,r,s), que


obviamente tem, assintoticamente, o mesmo tempo de execução de Corte barras-
aux(n,p,r,s). Note que cada chamada recursiva de Corte barras-aux a um
subproblema que já foi resolvido retorna imediatamente, e todas as linhas são execu-
tadas em tempo constante. Como salvamos o resultado sempre que resolvemos um
subproblema, cada subproblema é resolvido somente uma vez. Na chamada recursiva
em que resolvemos um subproblema de tamanho m (para 1 ≤ m ≤ n), o laço para
na linha 4 é executado m vezes. Assim, como existem subproblemas de tamanho
0, 1, . . . , n, o tempo de execução T (n) de Corte barras-aux é assintoticamente dado

133
por
T (n) = 1 + 2 + . . . + n = Θ(n2 ).

Caso precise imprimir os pontos em que os cortes foram efetuados, basta executar
o seguinte procedimento.

Algoritmo 38: Imprime cortes(n,p)


1 (lucroT otal, s) = Corte barras-TD(n, p)
2 enquanto n > 0 faça
3 Imprime s[n]
4 n = n − s[n]

Vamos ver agora como é um algoritmo com abordagem bottom-up para o problema
do corte de barras. A ideia é simplesmente resolver os problemas em ordem de tamanho
de barras, pois assim quando formos resolver o problema para uma barra de tamanho
j, temos a certeza que todos os subproblemas menores já foram resolvidos. Abaixo
temos o algoritmo que torna esse raciocı́nio preciso.

Algoritmo 39: Corte barras-BU(n,p)


1 Cria vetores r[0..n] e s[0..n]
2 r[0] = 0
3 para i = 1 até n faça
4 lucro = −1
5 para j = 1 até i − 1 faça
6 se lucro < pj + r[i − j − 1] então
7 lucro = pj + r[i − j − 1]
8 s[i] = j

9 r[i] = lucro
10 retorna (r[n], s)

134
15.4 Comparando algoritmos top-down e bottom-
up
Nesta curta seção comentamos sobre alguns aspectos positivos e negativos das abor-
dagens top-down e bottom-up. Algoritmos top-down possuem a estrutura muito
semelhante a de um algoritmo recursivo cuja construção se baseia na estrutura re-
cursiva da solução ótima. Já na abordagem bottom-up, essa estrutura não existe, de
modo que o código pode ficar complicado no caso onde muitas condições precisam
ser analisadas. Por outro lado, algoritmo bottom-up são geralmente mais rápidos,
por conta de sua implementação direta, sem que diversas chamadas recursivas sejam
realizadas, como no caso de algoritmos top-down.
Por fim, mencionamos que embora na maioria dos casos, as duas abordagens levam
a tempos de execução assintoticamente iguais, é possı́vel que a abordagem top-down
seja assintoticamente mais eficiente no caso onde vários subproblemas não precisam
ser resolvidos. Nesse caso, um algoritmo bottom-up resolveria todos os subproblemas,
mesmo os desnecessários, diferentemente do algoritmo top-down, que resolve somente
os subproblemas necessários.

135
136
Pa rt e

V
Algoritmos em grafos

Suponha que haja três casas em um plano (ou superfı́cie de


uma esfera) e cada uma precisa ser ligada às empresas de
gás, água e eletricidade. O uso de uma terceira dimensão ou
o envio de qualquer uma das conexões através de outra
empresa ou casa não é permitido. Existe uma maneira de
fazer todas os nove ligações sem que qualquer uma das
linhas se cruzem?

Não.
Capı́tulo

16
Grafos

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.
Um grafo G é uma estrutura formada por um par (V, E), onde V é um conjunto
finito e E é um conjunto de pares de elementos de V . O conjunto V é chamado de
conjunto de vértices e E é o conjunto de arestas de G. Um digrafo D = (V, A) é
definido como um conjunto de vértices V e um conjunto de arcos A, que é um conjunto
de pares ordenados de V , i.e., um grafo cujas arestas têm uma direção associada.
Um grafo com conjunto de vértices V = {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
V = {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)). Dado um grafo G,
denotamos o conjunto de vértices de G e o conjunto de arestas de G, respectivamente,
por V (G) e E(G). Por simplicidade, muitas vezes vamos denotar arestas {u, v} de um
grafo ou (u, v) de um digrafo por uv apenas.
Figura 16.1: Representação gráfica de um grafo G e um digrafo D.

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.

16.1 Conceitos essenciais


No que segue, considere um grafo G = (V, E). Dizemos que u e v são vizinhos (ou
adjacentes) se {u, v} ∈ E. A vizinhança de um vértice u, denotada por 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 {u, v} e que u é adjacente a v (e vice versa).
Ademais, dizemos que a aresta {u, v} incide em u e em v. Arestas que compartilham
o mesmo extremo também são chamadas de adjacentes.
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 } .

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

16.2 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
ou matriz de adjacências. Por simplicidade vamos assumir que um grafo com n vértices
tem conjunto de vértices {1, 2, . . . , n}. Na representação por listas de adjacências, um
grafo G = (V, E) consiste em um vetor LG com |V (G)| listas de adjacências, 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 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 Θ(n2 ) necessário pela matriz de
adjacências se torna desnecessário. 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
um grafo, pois determinados algoritmos precisam de propriedades da representação
por listas e outros da representação por matriz para serem eficientes.

141
Figura 16.2: Representação gráfica de um grafo G e um digrafo D e suas listas de
adjacências.

Figura 16.3: Representação gráfica de um grafo G e um digrafo D e suas matrizes de


adjacências.

142
16.3 Trilhas, passeios, caminhos e ciclos
Dado um grafo G = (V, E), um passeio em G é uma sequência não vazia de vértices
P = (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 passa (ou alcança) pelos vértices vi (1 ≤ i ≤ k) e pelas arestas vi vi+1
(1 ≤ i < k). 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
passeio não tenha nem 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 conter um C3 ou G ter 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 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

143
Figura 16.4: Passeios, trilhas, ciclos e caminhos.

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.

144
Figura 16.5: Exemplos de árvores.

145
146
Capı́tulo

17

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. 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.
17.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. Por simplicidade, ao longo desta seção assumimos que o grafo
G em que aplicamos a busca em largura é conexo.
Apesar de estarmos considerando um grafo G = (V, E), o algoritmo para digrafos
é essencialmente o mesmo. O nome do algoritmo vem do fato de, nesse processo,
primeiramente são explorados os vértices à distância 1 de s, seguido pelos vértices à
distância 2 de s e assim por diante. Para possibilitar a exploração dos vértices de G
dessa maneira, vamos utilizar uma fila como estrutura de dados auxiliar.
Inicialmente, colocamos o vértice s na fila. Enquanto a fila não ficar vazia 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
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 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. Para a busca em largura,
veremos que será conveniente utilizar a representação de grafos em listas de adjacências.
O Algoritmo 40 mostra o pseudocódigo para esse procedimento.
Vamos agora explicar o algoritmo BuscaLargura em detalhes. O algoritmo
primeiramente inicializa todos os vértices como não visitados e todos os predecessores
como null, exceto pelo vértice s, que já está visitado e é 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.
Na Figura 17.1 simulamos uma execução da busca em largura começando no
vértice s.
Seja n = |V (G)| e m = |E(G)|. Vamos analisar o tempo de execução do algoritmo
BuscaLargura aplicado em um grafo conexo G = (V, E). Na inicialização (linhas 1

148
Algoritmo 40: BuscaLargura(G = (V, E), s)
1 para todo vértice v ∈ V (G) \ {s} faça
2 v. visitado = 0
3 v. predecessor = null
4 s. visitado = 1
5 s. predecessor = s
6 cria fila vazia F
7 Fila-adiciona(F, s)
8 enquanto Fila F não é vazia faça
9 u = Fila-remove(F )
10 para todo vértice v ∈ N (u) faça
11 se v. visitado == 0 então
12 v. visitado = 1
13 v. predecessor = u
14 Fila-adiciona(F, v)

Figura 17.1: Execução de BuscaLargura(G = (V, E), s).

149
a 6) é gasto tempo total Θ(n) no laço e todas as outras operações levam tempo
constante. Note que antes de um vértice v entrar na fila, atualizamos v. visitado de 0
para 1 (linha 12) e depois que o laço enquanto é iniciado, nenhum vértice possui o
atributo visitado modificado de 1 para 0. Assim, uma vez que um vértice entra na
fila, ele nunca mais passará no teste da linha 11. Portanto, todo vértice entra somente
uma vez na fila, e como a linha 9 sempre remove alguém da fila, o laço enquanto é
executado n vezes, sendo uma execução para cada vértice.
O ponto essencial da análise é a quantidade total de vezes que o laço para da
linha 10 é 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 Θ(n2 ). Porém, se utilizarmos lista de
adjacências, então em cada execução do laço para, ele é executado |N (u)| vezes, de
P
modo que, no total, é executado u∈V (G) |N (u)| = 2m vezes, e então o tempo total de
execução do algoritmo é Θ(n + m).
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
“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 ).

17.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
quantidade de arestas do menor caminho entre u e v. 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. dist.
O Algoritmo 41 contém poucas diferenças com relação ao algoritmo BuscaLargura
anterior: as linhas 4, 7 e 15. Essas linhas salvam as distâncias entre s e os outros
vértices do grafo. Quando não existe caminho entre s e v, definimos a distância entre s
e v como distG (s, v) = ∞.

150
Algoritmo 41: BuscaLarguraDistancia(G = (V, E), s)
1 para todo vértice v ∈ V (G) \ {s} faça
2 v. visitado = 0
3 v. predecessor = null
4 v. dist = ∞
5 s. visitado = 1
6 s. predecessor = s
7 s. dist = 0
8 cria fila vazia F
9 Fila-adiciona(F, s)
10 enquanto Fila F não é vazia faça
11 u = Fila-remove(F )
12 para todo vértice v ∈ N (u) faça
13 se v. visitado == 0 então
14 v. visitado = 1
15 v. dist = u. dist +1
16 v. predecessor = u
17 Fila-adiciona(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. dist contém a distância entre s e v, para todo vértice v do grafo
G. Começamos apresentando o Lema 17.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 17.1

Seja G = (V, E) um grafo. Para qualquer s ∈ V (G) e toda aresta uv ∈ E(G)


temos
distG (s, u) ≤ distG (s, v) + 1 .

151
Lema 17.2

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


Distancia(G, s), temos o seguinte para todo v ∈ V (G):

v. dist ≥ distG (s, v) .

Demonstração. Comece notando que cada vértice é adicionado à fila somente uma
vez. A prova segue por indução na quantidade de vértices adicionados à fila, i.e., na
quantidade de vezes que a rotina Fila-adiciona é executada. O primeiro vértice
adicionado à fila é o vértices s, antes do laço enquanto. Nesse ponto, temos s. dist =
0 ≥ distG (s, s) e v. dist = ∞ ≥ 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 os primeiros k − 1 vértices
adicionados à 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 12 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. dist ≥ distG (s, u). Mas note que, pela linha 15 e utilizando o
Fato 17.1 temos

v. dist = u. dist +1 ≥ distG (s, u) + 1 ≥ distG (s, v) .

Como cada vértice entra na fila somente uma vez, o valor em v. dist não muda mais
durante a execução do algoritmo.

O próximo resultado, Lema 17.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. dist ≥ v. dist.
Como uma vez que a estimativa v. dist 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.

152
Lema 17.3

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

153
Distancia(G, s). Para todos os pares de vértices u e v na fila tal que u entrou
na fila antes de v, vale que no momento em que v entra na fila temos

u. dist ≤ v. dist ≤ u. dist +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. dist ≤ v. dist ≤ u. dist +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 execução do laço enquanto. Seja F = (u, v1 , . . . , v` )
a fila no inı́cio dessa execução do laço enquanto. Durante a execução do laço, o
algoritmo remove u de F e adiciona os vizinhos não visitados de u, digamos u1 , . . ., uk à
fila F , deixando F = (v1 , . . . , v` , u1 , . . . , uk ). O algoritmo então faz uj . dist = u. dist +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. dist ≤ vi . dist ≤ u. dist +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 . dist ≤ u. dist +1 = uj . dist = u. dist +1 ≤ vi . dist +1 .

Por hipótese de indução (lembrando que o valor em uj . dist não muda depois de
modificado), sabemos que os pares em {u, v1 , . . . , v` } satisfazem a conclusão do lema.
Ademais, pares dos vizinhos de u que entraram na fila têm a mesma estimativa de
distância (u. dist +1). Portanto, todos os pares de vértices em {v1 , . . . , v` , u1 , . . . , uk }
satisfazem a conclusão do lema.

Com os Lemas 17.2 e 17.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.

154
Teorema 17.4

Sejam G = (V, E) um grafo conexo e s ∈ V (G). Ao fim de BuscaLargura-


Distancia(G, s), vale o seguinte para todo v ∈ V (G):

v. dist = distG (s, v) .

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


Distancia(G, s) exista um vértice v ∈ V (G) com v. dist 6= distG (s, v). Seja v o vértice
com menor v. dist tal que v. dist 6= distG (s, v). Pelo Lema 17.2, sabemos que

v. dist > distG (s, v) . (17.1)

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


distG (s, u) + 1. Assim, usando (17.1), temos que

v. dist > distG (s, v) = distG (s, u) + 1 = u. dist +1 . (17.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. dist = w. dist +1. Pelo Lema 17.3, w. dist ≤ u. dist. Portanto, temos
v. dist ≤ u. dist +1, uma contradição com (17.2). Podemos então assumir que quando
u foi removido da fila F , o vértice v não estava em F . Se v tinha entrado em F
anteriormente e foi removido de F , temos, pelo Lema 17.3, que v. dist ≤ u. dist, uma
contradição com (17.2). Assim, assuma que v 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. dist = u. dist +1, uma contradição com (17.2).

17.2 Busca em profundidade


Na busca em profundidade (DFS, de depth-first search), os vértices são explorados de
forma diferente de como é feito na busca em largura, que explora primeiramente os
vizinhos de s para somente depois explorar os vértices à distância 2 de s e assim por

155
diante. Na busca em profundidade, sempre exploramos o vértice vizinho ao vértice que
foi mais recentemente explorado que ainda tenha vizinhos não explorados. Essa é uma
forma mais “agressiva” de exploração, como é feito em um labirinto. Para possibilitar
a exploração dos vértices de G dessa maneira, vamos utilizar uma pilha como estrutura
de dados auxiliar. Cada vértice que é descoberto (visitado pela primeira vez) pelo
algoritmo é inserido na pilha. A cada iteração, o algoritmo consulta o topo u da pilha,
segue por um vizinho v de u ainda não explorado e adiciona v na pilha. Caso todos os
vizinhos de u já tenham sido explorados, u é removido da pilha.
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 manter uma variável 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 42 mostra o pseudocódigo para esse procedimento, lembrando que, dada
uma pilha P , os procedimentos Empilha(P, u), Desempilha(P ) e Consulta(P )
fazem, respectivamente, inserção de um elemento u em P , remoção do elemento do
topo de P , e consulta ao último valor inserido em 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–8 inicializamos alguns atributos, criamos a pilha e colocamos s na
pilha. Então, nas linhas 11–14 o algoritmo alcança um vizinho de u ainda não visitado
e o coloca na pilha. Se u não tem vizinhos não visitados, então a exploração de u é
encerrada e o mesmo retirado da pilha (linhas 15–18).
Prosseguiremos agora com a análise do tempo de execução do algoritmo, onde
assumimos que o grafo G está representado por uma lista de adjacências. Note que
imediatamente antes de um vértice x ser empilhado (linha 12), 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–8 leva tempo
O(|V (G)|), a condição na linha 11 é feita uma vez para cada vizinho de cada vértice,
de modo que é executada O(|E(G)|) vezes ao todo, e todas as outras instruções são

156
Algoritmo 42: BuscaProfundidade(G = (V, E), s)
1 para todo vértice v ∈ V (G) \ {s} faça
2 v. visitado = 0
3 v. predecessor = null
4 s. visitado = 1
5 s. predecessor = s
6 encerramento = 0
7 cria pilha vazia P
8 Empilha(P, s)
9 enquanto P 6= ∅ faça
10 u = Consulta(P)
11 se existe uv ∈ E(G) e v. visitado == 0 então
12 v. visitado = 1
13 v. predecessor = u
14 Empilha(P, v)
15 senão
16 encerramento = encerramento + 1
17 u. fim = encerramento
18 u = Desempilha(P )

157
executadas em tempo constante. Assim, o tempo total de execução da Busca em
Profundidade é O(|V (G)| + |E(G)|), como na Busca em Largura (considerando listas
de adjacências).
Na Figura 17.2 simulamos uma execução da busca em profundidade começando no
vértice a.

Figura 17.2: Execução de BuscaProfundidade(G = (V, E), a), indicando a pilha e


o tempo de encerramento de cada vértice.

Uma observação interessante é que, dada a estrutura em que os vértices são visitados
(sempre explorando um vizinho assim que o mesmo é visitado), é simples escrever
um algoritmo recursivo para a busca em profundidade. O Algoritmo 44 descreve o
pseudocódigo para esse algoritmo, enquanto o Algoritmo 43 mostra como utilizar a
busca em profundidade para visitar todos os vértices do grafo, mesmo que o grafo seja

158
desconexo.

Algoritmo 43: 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) com u. visitado == 0 faça
6 BuscaProfundidadeRecursiva(G, u)

Algoritmo 44: BuscaProfundidadeRecursiva(G = (V, E),s)


1 s. visitado = 1
2 para todo vizinho v de s faça
3 se v. visitado == 0 então
4 v. predecessor = s
5 BuscaProfundidadeRecursiva(G,v)
6 encerramento = encerramento + 1
7 u. fim = encerramento

Note que o algoritmo de busca em profundidade funciona da mesma forma em um


digrafo.

17.2.1 Ordenação topológica


Nesta seção, consideraremos digrafos que não têm ciclos orentados, i.e., 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.
Uma ordenação topológica de um digrafo D é uma ordenação dos vértices de D
tal que, para toda aresta (u, v), o vértice u aparece antes de v na ordenação. Assim,
podemos pensar em cada uma das arestas orientadas (u, v) como representando uma
relação de dependência, indicando que v depende de u. Por exemplo, os vértices podem
representar tarefas e uma aresta (u, v) indica que a tarefa u deve ser executada antes
da tarefa v.

159
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. 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.
O Algoritmo 45 encontra uma ordenação topológica de um digrafo acı́clico D.

Algoritmo 45: OrdenacaoTopologica(D = (V, A))


1 cria uma lista de elementos L inicialmente vazia
2 escolhe um vértice s qualquer
3 executa BuscaProfundidade(D, s) e toda vez que um vértice v é encerrado
ele é inserido no começo da lista L
4 retorna L

Nas Figuras 17.3 e 17.4 temos um exemplo de execução do algoritmo Ordenacao-


Topologica.

17.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.
Um simples algoritmo para encontrar componentes fortemente conexas faz uso
da busca em profundidade. Dado um digrafo D, vamos executar duas buscas em
profundidade, sendo uma em D e uma no digrafo D̄, que é o grafo obtido de D
invertendo o sentido de todas suas arestas. No Algoritmo 46, considere D̄ como o
digrafo descrito acima.
Se o grafo estiver representado com lista de adjacências, então o Algoritmo 46 acima
funciona em tempo O(|V (D)| + |A(D)|).

160
Figura 17.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.

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

161
Algoritmo 46: ComponentesFortementeConexas(D = (V, A))
1 executa BuscaComponentes(D̄)
2 execute BuscaComponentes(D) novamente, mas considerando os vértices
em ordem decrescente do atributo fim no laço da linha 5

17.2.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 e listar todos os
vértices de uma componente conexa. Ademais, podem ser usados como ferramenta na
implementação do método de Ford-Fulkerson, que calcula o fluxo máximo em uma rede
de fluxos. Uma outra aplicação interessante do algoritmo de Busca em Profundidade é
resolver de forma eficiente (tempo O(|V | + |E|)) o problema de encontrar 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.

162
Capı́tulo

18
Árvores geradoras mı́nimas

Uma árvore geradora de um grafo G é uma árvore que é um subgrafo gerador de G,


i.e., uma árvore que contém todos os vértices de G. Dado um grafo G = (V, E) e
uma função w : E(G) → R de pesos nas arestas de G, 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
X
w(T ) = w(e) = min{w(T 0 ) : T 0 é uma árvore geradora de G} .
e∈E(T )

Uma árvore T com essas propriedades é uma árvore geradora mı́nima de G.

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 (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.
Figura 18.1: Exemplo de um grafo G e uma árvore geradora mı́nima (representada
pelas arestas ressaltadas).

Lema 18.1

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 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 18.1 é que se e é a única aresta que cruza um dado
corte, então e não pertence a nenhum ciclo.
Dado um corte (S, V (G) \ S) de um grafo G, o seguinte teorema indica uma
estratégia para se obter uma árvore geradora mı́nima.

Teorema 18.2

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 que contém e.

164
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 18.1, sabemos que existe outra
aresta f de T que cruza o corte (S, V (G) \ S). Portanto, o grafo obtido da remoção
da aresta f de T e da adição da aresta e a T é uma árvore (geradora). Seja T 0 essa
árvore. Claramente, temos w(T 0 ) = w(T ) − w(f ) + w(e) ≤ w(T ), onde usamos o fato
de w(e) ≤ w(f ), que vale pela escolha de e. Como T é uma árvore geradora de peso
mı́nimo e temos w(T 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 18.2 para obter árvores geradoras mı́nimas de grafos conexos.

18.1 Algoritmo de Prim


Dado um grafo conexo G = (V, E) e uma função de pesos nas arestas de G, o algoritmo
de Prim começa obtendo uma árvore que consiste de somente uma aresta e, a cada
iteração, acrescenta uma nova aresta à árvore que está sendo mantida, aumentando
assim a quantidade de arestas da mesma. O algoritmo termina quando temos uma
árvore geradora de G.
Para garantir que uma árvore geradora mı́nima é encontrada, o algoritmo começa
com uma árvore vazia T = (V, E), e a cada passo adiciona uma aresta mı́nima que
cruza o corte (V (T ), V (G) \ V (T )). Pelo Teorema 18.2, ao se obter uma árvore geradora
dessa forma, tal árvore é mı́nima.
O algoritmo de Prim mantém uma fila de prioridades H que contém os vértices
que não estão na árvore T = (V, E) que estamos construindo (inicialmente, portanto,
H = V (G)). A prioridade de cada vértice v que está na fila H é indicada pelo peso
da aresta de menor peso que conecta v a algum vértice de T , de forma que quanto
menor o peso, maior a prioridade. Essa informação fica salva no atributo v. estimativa.

165
Mantendo esses atributos atualizados, é simples encontrar uma aresta mı́nima que
cruza (V (T ), V (G) \ V (T )). O atributo v. predecessor indica o vértice que levou v a
ser inserido na árvore T (v foi inserido na árvore porque em algum momento a aresta
{v, v. predecessor} era mı́nima no corte). Assim, utilizando os atributos v. predecessor,
ao fim do algoritmo de Prim, a árvore geradora mı́nima T terá o conjunto de arestas

E(T ) = {v, v. predecessor} : v ∈ V (G) \ {s} ,

onde s é o primeiro vértice analisado pelo algoritmo, passado como entrada. O algoritmo
de Prim vai manter também um atributo v. arvore para cada vértice, indicando se
o vértice pertence ou não à árvore T , i.e., temos v. arvore = 1 se v está em T e
v. arvore = 0 caso contrário.

Algoritmo 47: Prim(G = (V, E), w, s)


1 para todo vértice v ∈ V (G) faça
2 v. estimativa = ∞
3 v. predecessor = null
4 v. arvore = 0
5 s. estimativa = 0
6 cria fila de prioridades H com elementos do conjunto V (G) baseada em
v. estimativa
7 enquanto H 6= ∅ faça
8 u = Remove(H)
9 u. arvore = 1
10 para todo vértice v ∈ N (u) faça
11 se v. arvore == 0 e w(uv) < v. estimativa então
12 v. predecessor = u
13 v. estimativa = w(uv)
14 Diminui-chave(H, v. indice, w(uv))

A Figura 18.2 mostra um exemplo de execução do algoritmo de Prim.


O algoritmo de Prim toma, a cada passo, a decisão mais apropriada no momento
(a escolha da aresta a ser incluı́da na árvore) e nunca muda essa decisão. Algoritmos
dessa forma são conhecidos como algoritmos gulosos.
Perceba a semelhança na estrutura do algoritmo de Prim e no algoritmo de busca
em largura. O tempo de execução depende de como o grafo G e a fila de prioridades H

166
Figura 18.2: Execução do algoritmo de Prim. Um vértice fica preenchido no momento
em que é removido da fila de prioridades.

são implementados. Vamos assumir que G é representado por uma lista de adjacências,
que é a forma mais eficiente para o algoritmo de Prim, e que H é uma fila de prioridades
implementada através do uso de um heap binário como no Capı́tulo 6.
No que segue, temos n = |V (G)| e m = |E(G)|. Na inicialização, o algoritmo
leva tempo Θ(n) para executar as linhas 1–5 e tempo Θ(n) para construir a fila de
prioridades H na linha 6, pois um heap com n elementos pode ser construı́do em
tempo Θ(n) (basta criar o vetor H com os elementos de V (G) e executar Constroi-
heap(H)). O laço enquanto na linha 7 é executado n vezes, uma execução para cada
elemento de H. Como a operação Remove(H) executa em tempo O(log n), o tempo
total gasto com as operações na linha 8 é

O(n log n) . (18.1)

A linha 9 é claramente executada em tempo constante. O laço para na linha 10 é


executado |N (v)| vezes para cada v, de modo que no total é executado Θ(m) vezes.
Para finalizar a análise precisamos saber o tempo gasto com a execução das linhas 11,
12 e 13. As linhas 11, 12 e 13 são claramente executadas em tempo constante, de
modo que levam tempo Θ(m) ao todo. A linha 14 executa o procedimento Diminui-

167
chave(F, v. indice, w(u, v)) que leva tempo O(log n). Assim, o tempo total gasto com
execuções da linha 14 é
O(m log n) . (18.2)

Portanto, por (18.1) e (18.2), temos que o tempo total de execução do algoritmo de
Prim é

O(n log n) + O(m log n) = O (m + n) log n .

Como o grafo G é conexo, sabemos que G possui m ≥ n − 1 arestas. Logo, concluı́mos


que o tempo de execução do algoritmo de Prim é

O (m + n) log n = O(m log n) .

18.2 Algoritmo de Kruskal


Dado um grafo conexo G = (VG , EG ) e uma função de pesos nas arestas de G, o
algoritmo de Kruskal, assim como o algoritmo de Prim, começa com um conjunto
vazio A de arestas e a cada passo adiciona uma aresta e a A garantindo que A ∪ {e}
é um subconjunto de uma árvore geradora mı́nima. Porém, diferente do que ocorre
no algoritmo de Prim, o conjunto A não é uma árvore em todo momento da execução
do algoritmo. O algoritmo de Kruskal vai adicionando a A sempre a aresta de menor
peso que não forma ciclos com as arestas que já estão em A. Dessa forma, cada aresta
adicionada pertence a uma árvore geradora mı́nima junto com as arestas de A. O
algoritmo termina quando A tem n − 1 arestas, de modo que é o conjunto de arestas
de uma árvore geradora mı́nima de G.
Para o algoritmo a seguir lembre que dado um, grafo G = (V, E) e um subconjunto
A ⊆ E, o grafo G[A] é o subgrafo de G com conjunto das arestas A e os vértices de V
são todos os extremos de arestas de A.

Nas linhas 1 e 2 o conjunto das arestas é copiado para um vetor C[1..|EG |] e


ordenado. Assim, para considerar arestas de menor peso, basta percorrer o vetor C em
ordem. Na linha 3 criamos o conjunto A que receberá iterativamente as arestas que
compõem uma árvore geradora mı́nima. Nas linhas 4, 5 e 6 são adicionadas, passo a

168
Algoritmo 48: Kruskal(G = (VG , EG ), w, s)
1 Crie um vetor C[1..|EG |] e copie as arestas para C
2 Ordene C de modo não-decrescente de pesos das arestas
3 Crie conjunto A = ∅
4 para i = 1 até |EG | faça
5 se G[A ∪ {C[i]}] não contém ciclos então
6 A = A ∪ {C[i]}
7 retorna (A)

passo, aresta de peso mı́nimo que não formam ciclos com as arestas que já estão em A.
Seja G = (V, E) um grafo com n vértices e m arestas. Se o grafo está representado
por listas de adjacências, então é simples executar a linha 1 em tempo Θ(n + m).
Utilizando algoritmos de ordenação como Merge sort ou Heapsort, podemos
executar a linha 2 em tempo O(m log m). A linha 3 leva tempo O(1) e o laço para
(linha 4) é executado m vezes. O tempo gasto na linha 5 depende de como identificamos
os ciclos. Utilizando algoritmos de busca para verificar a existência de ciclos em
A ∪ {C[i]} levamos tempo O(n + |A|). Mas note que A possui no máximo n − 1 arestas,
de modo que a linha 5 é executada em tempo O(n). Portanto, como o laço é executado
m vezes, no total o tempo gasto nas linhas 4–6 é O(mn). Se T (n, m) é o tempo de
execução de Kruskal(G = (VG , EG ), w, s), então vale o seguinte.

T (n, m) = O(n + m) + O(m log m) + O(mn)


= O(m) + O(m log n) + O(mn) (18.3)
= O(mn).

Para entender as igualdades acima, note que como G é conexo, temos m ≥ n − 1,


de modo que vale que 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).
Mas é possı́vel melhorar o tempo de execução em (18.3) através do uso de uma
estrutura de dados apropriada. Vamos agora enxergar o algoritmo de Kruskal sob outra
perspectiva: ao adicionar uma aresta que não forma ciclos com as arestas que estavam
em A, o que o algoritmo faz é adicionar uma aresta entre duas componentes conexas do

169
grafo que contém somente as arestas de A. Assim, se fizermos o algoritmo de Kruskal
manter uma partição de A em componentes conexas, e a cada passo adicionar a A
sempre a aresta de menor peso que conecta duas dessas componentes, não precisamos
verificar a existência de ciclos, que é o fator determinante para o tempo obtido em (18.3).
Para manter essas componentes conexas de modo eficiente, vamos utilizar a estrutura
de dados union-find (veja Capı́tulo 7). Abaixo temos uma versão do algoritmo de
Kruskal utilizando a estrutura union-find.

Algoritmo 49: Kruskal-UF(G = (VG , EG ), w, s)


1 Crie um vetor C[1..|EG |] e copie as arestas para C
2 Ordene C de modo não-decrescente de pesos das arestas
3 Crie conjunto A = ∅
4 para todo v ∈ VG faça
5 Cria conjunto(v)
6 para i = 1 até |EG | faça
7 se Find(u) 6= Find(v), onde C[i] = {u, v} então
8 A = A ∪ {u, v}
9 Union(u, v)

10 retorna (A)

A ideia é muito semelhante à do algoritmo Kruskal. Nas três primeiras linhas as


arestas são ordenadas e o conjunto A é criado. Nas linhas 4 e 5 criamos um conjunto
para cada um dos vértices. Esses conjuntos são nossas componentes conexas iniciais.
Nas linhas 6–9 são adicionadas, passo a passo, aresta de peso mı́nimo que conecta
duas componentes conexas (considerando apenas as arestas de A). Note que o teste da
linha 7 falha para uma aresta cujos extremos estão no mesmo conjunto. Ao adicionar
uma aresta {u, v} ao conjunto A (linha 8), vamos juntar as componentes que contém u
e v (linha 9).
Seja G = (V, E) um grafo com n vértices e m arestas. Como na análise do algoritmo
Kruskal, executamos a linha 1 em tempo Θ(n + m) e a linha 2 em tempo O(m log m).

170
A linha 3 leva tempo O(1) e levamos tempo O(n) nas linhas 4 e 5. O laço para (linha
6) é executado m vezes. Como a linha 7 tem somente operações find, e executada
em tempo O(1) e a linha 8 também é executada em tempo O(1). Precisamos analisar
com cuidado o tempo de execução gasto na linha 9. Para isso, vamos estimar quantas
vezes essa linha pode ser executada no total, ao fim de todas as execuções do laço
para. Lembrando de como a operação Union é realizada (veja Capı́tulo 7), sabemos
que ao utilizar Union(x, y) com x ∈ X, y ∈ Y e |X| ≤ |Y |, gastamos tempo O(|X|)
atualizando os representantes de todos os elementos de X. A pergunta importante a ser
respondida agora é: quantas vezes um vértice pode ter seu representante atualizado?
Como na operação Union somente os elementos do conjunto de menor tamanho são
atualizados, então toda vez que isso acontece com um elemento x, o seu conjunto dobra
de tamanho. Assim, como o grafo tem n vértices, cada vértice x tem seu representante
atualizado no máximo log n vezes. Logo, de novo pelo fato do grafo ter n vértices, o
tempo total gasto nas linhas 6–9 é de O(n log n). Se T (n, m) é o tempo de execução
de Kruskal-UF(G = (VG , EG ), w, s), então vale o seguinte.

T (n, m) = O(n + m) + O(m log m) + O(n log n)


= O(m) + O(m log n) + O(m log n)
= O(m log n).

171
172
Capı́tulo

19
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 19.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 19.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 abaixo, 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 50: Fleury-Euleriano(G = (VG , EG ))


1 para todo vértice v ∈ VG faça
2 se d(v) é ı́mpar então
3 retorna “Não existe trilha Euleriana em G”

4 v = vértice qualquer de VG
5 cria vetor T [1..|EG |]
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 ∈ VG 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

A Figura ?? contém um exemplo de execução do algoritmo de Fleury.


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.

174
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.
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 teo:Euler). O seguinte
resultado vai ser útil na prova de corretude do algoritmo de Fleury,

Teorema 19.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 19.4

Seja G = (VG , EG ) um grafo onde todos seus vértices têm grau par. Então o
algoritmo Fleury-Euleriano(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.

175
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 .
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 := VG \ 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 19.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.

176
Capı́tulo

20
Caminhos mı́nimos

Dado um grafo ou digrafo G = (VG , EG ) e um vértice s ∈ VG , o algoritmo de busca em


largura explora os vértices de G calculando a quantidade de arestas em um caminho
mı́nimo de s a qualquer outro vértice de G alcançável a partir de s. Porém, diversas
aplicações são modeladas através de grafos que possuem pesos nas arestas. Assim, é
interessante encontrar caminhos mı́nimos em grafos levando em conta os pesos nas
arestas. Dados um grafo G = (VG , EG ) e uma função w : EG → R de pesos, definimos o
peso de um caminho P = (v0 , v1 , . . . , vk ) como a soma dos pesos das arestas de P , i.e.,

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

Assim, dados u, v ∈ VG , o peso de um caminho mı́nimo de u a v em G, denotado por


distG (u, v), é definido como

min{w(P ) : P é caminho de u a v}, se existe caminho de u a v,
distG (u, v) =
∞, caso contrário.

Pesos de ciclos são definidos da mesma forma, i.e., é igual a soma dos pesos das arestas
do ciclo. No restante desta seção vamos considerar um grafo G = (VG , EG ) e uma
função w : EG → R de pesos nas arestas de G.
Antes de analisarmos algoritmos para encontrar caminhos mı́nimos, precisamos
tratar de algumas tecnicalidades envolvendo ciclos: se existe um ciclo de peso negativo
em uma trilha de u a v, então ao percorrer uma trilha que passa repetidamente por tal
ciclo, conseguimos obter uma trilha de u a v de peso tão pequeno quanto quisermos.
Assim, no problema de caminhos mı́nimos vamos assumir que não existem ciclos de
peso negativo no grafo em questão.

20.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 ∈ VG , 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.dist 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.dist de cada vértice v ∈ VG .
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.dist pelo algoritmo no momento. Veremos que essa escolha garante
que, no momento em que v é escolhido para sair de F , temos v.dist = distG (s, v) (veja
Teorema 20.2).

178
O algoritmo também manterá atributos v.pai que permitem se obter um caminho
mı́nimo de s a v, e os atributos v.indice contendo o ı́ndice de v dentro da fila de
prioridades F . Ao fim do algoritmo a fila F fica vazia, garantindo que a distância de s
a todos os vértices do grafo foi calculada.

Algoritmo 51: Dijkstra(G = (VG , EG ), w, s)


1 para todo vértice v ∈ VG faça
2 v.dist = ∞
3 v.pai = null
4 s.dist = 0
5 cria fila de prioridades F com conjunto VG baseada em v.dist
6 para i = 1 até |VG | faça
7 u = Remoção-min(F )
8 para todo vértice v ∈ N (u) em F faça
9 se v.dist > u.dist + w(u, v) então
10 v.pai = u
11 v.dist = u.dist + w(u, v)
12 Diminui-chave(F, v.indice, u.dist + w(u, v))

A Figura 20.1 contém um exemplo de execução do algoritmo de Dijkstra.

Figura 20.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.

179
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 6.
Seja n = |VG | e m = |EG |. Dado que o primeiro laço para é executado n vezes, o se-
gundo laço para é executado |N (v)| vezes para cada v ∈ VG , cada operação Remoção-
min(F ) é executada em tempo O(log n), e cada operação Diminui-chave(F, v, u) que
leva tempo O(log n), uma análise muito similar a feita no algoritmo de Prim mostra

que o tempo de execução de Dijkstra(G = (VG , EG ), w, s) é O (m + n) log n .
O seguinte lema será usado na prova da corretude do algoritmo de Dijkstra.

Lema 20.1

Sejam G = (VG , EG ) um grafo, w uma função de pesos não negativos em EG , e


s ∈ VG . Em qualquer ponto da execução de Dijkstra(G = (VG , EG ), w, s), temos
que v.dist ≥ distG (s, v) para todo v ∈ VG .

O seguinte resultado mostra que o algoritmo de Dijkstra calcula corretamente os


caminhos mı́nimos.

Teorema 20.2

Ao final da execução de Dijkstra(G = (VG , EG ), w, s) temos v.dist = distG (s, v)


para todo v ∈ VG .

Demonstração. Nessa prova consideramos uma execução de Dijkstra(G = (VG , EG ), 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 é
encerrado após |VG | iterações desse laço e a fila F é vazia. Precisamos mostrar que

180
quando isso acontece, temos v.dist = distG (s, v) para todo v ∈ VG .

Uma vez que o algoritmo nunca modifica o atributo v.dist depois que v sai de F ,
basta provarmos que

quando um vértice v é removido de F , temos v.dist = distG (s, v) nesse momento.

Suponha por contradição que existe um vértice u com

u.dist > distG (s, u) (20.1)

no momento em que u saiu de F . Seja u o primeiro vértice com u.dist > distG (s, u)
a ser removido de F . Assim, para todo vértice v removido de F antes de u, temos
v.dist = distG (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.dist = distG (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.dist para v.dist + w(v, w) (caso ainda
não tenha esse valor). Portanto, temos

w.dist = v.dist + w(v, w) = distG (s, v) + w(v, w) = distG (s, w).

Como não existem arestas de peso negativo, distG (s, w) ≤ distG (s, u). Logo,

w.dist = distG (s, w) ≤ distG (s, u), (20.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.dist ≤ w.dist. Combinando esse fato
com (20.2), temos u.dist ≤ distG (s, u), uma contradição com (20.1).

181
20.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 = (VG , EG ), 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.dist 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 |VG | − 1 iterações, melhorar a distância conhecida
de s a todos os vértices v analisando todas as |EG | arestas de G em cada iteração.
O algoritmo mantém atributos v.pai que permitem se obter um caminho mı́nimo
de s a v. No final de sua execução, o algoritmo retorna “verdade” se G não contém
ciclos de peso negativo, e retorna “falso” caso exista algum ciclo de peso negativo em G.

Algoritmo 52: Bellman-Ford(G = (VG , EG ), w, s)


1 para todo vértice v ∈ V faça
2 v.dist = ∞
3 v.pai = null
4 s.dist = 0
5 para i = 1 até |VG | − 1 faça
6 para toda aresta uv ∈ EG faça
7 se v.dist > u.dist + w(u, v) então
8 v.pai = u
9 v.dist = u.dist + w(u, v)

10 para toda aresta uv ∈ EG faça


11 se v.dist > u.dist + w(u, v) então
12 retorna “falso”

13 retorna “verdade”

182
A Figura 20.2 mostra um exemplo de execução do algoritmo Bellman-Ford(G =
(VG , EG ), w, s).

Figura 20.2: Execução do algoritmo de Bellman-Ford.

Antes de entendermos qual a razão do algoritmo de Bellman-Ford funcionar corre-


tamente, vamos analisar seu tempo de execução. Seja n = |VG | e m = |EG | 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 = (VG , EG ), 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

183
relaxação da aresta uv, i.e., dizemos que a aresta uv é relaxada quando verificamos se
v.dist > u.dist + w(u, v), atualizando, em caso positivo, o valor de v.distancia para
u.dist + w(u, v).

Lema 20.1

Seja G = (VG , EG ) um grafo com uma função de pesos w em suas arestas e seja
s ∈ VG . Considere s.dist = 0 e v.dist = ∞ para todo vértice v ∈ VG \ {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 .dist = 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.dist = 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 .dist = dist(s, vk−1 ). Caso vk .dist = dist(s, vk ), então a
prova está concluı́da. Assim, podemos assumir que

vk .dist > 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 .dist > dist(s, vk ) =
dist(s, vk−1 ) + w(vk−1 , vk ), atualizando o valor de vk .dist como abaixo.

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


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

184
Com isso, a prova está concluı́da.

Note que, no Lema 20.1, não importa que arestas tenham sido relaxadas entre
quaisquer das relaxações sv1 , v1 v2 , . . ., vk−1 vk . O Lema 20.1 garante que se as arestas
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 ∈ VG ? 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 20.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 20.2 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.

Figura 20.3: Ordem de relaxação das arestas de um caminho mı́nimo de s a v.

Lema 20.2

Seja G = (VG , EG ) um grafo com uma função de pesos w em suas arestas e seja
s ∈ VG . Se G não contém ciclos de peso negativo, então após terminar a execução

185
das linhas 5–9 de Bellman-Ford(G = (VG , EG ), w, s) temos v.dist = dist(s, v)
para todo vértice v ∈ VG .

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 = (VG , EG ), w, s). Se
vk não é alcançável a partir de s, então temos v.dist = ∞ e não é difı́cil verificar que
o algoritmo nunca vai modificar o valor de v.dist. 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 |VG | − 1 arestas.
Seja v0 = s. Como a cada uma das |VG | − 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 20.1, temos vk .dist = dist(s, vk ). Assim, a prova do lema
está concluı́da.

Usando o Lema 20.2, podemos facilmente notar que o algoritmo identifica um ciclo
de peso negativo.

Corolário 20.3

Seja G = (VG , EG ) um grafo com uma função de pesos w em suas arestas e seja
s ∈ VG . Se Bellman-Ford(G = (VG , EG ), w, s) retorna “falso”, então G contém
um ciclo de peso negativo.

Demonstração. Se Bellman-Ford(G = (VG , EG ), w, s) retorna “falso”, então após


a execução das linhas 5–9, existe uma aresta uv tal que v.dist > u.dist + w(u, v).
Mas é fácil mostrar que a qualquer momento do algoritmo, se o valor em v.dist
é finito, então ele representa o peso de algum caminho entre s e v. Logo, como
v.dist > u.dist + w(u, v), sabemos que o peso em v.dist é maior do que o peso de
um caminho de s a v passando por u. Portanto, v.dist > dist(s, v). Assim, usando a
contrapositiva do Lema 20.2, concluı́mos que G contém um ciclo de peso negativo.

Agora que sabemos que o algoritmo de Bellman-Ford funciona corretamente, vamos

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

20.3 Caminhos mı́nimos entre todos os pares de


vértices
Considere agora o problema de encontrar caminhos mı́nimos (e calcular seus pesos)
entre todos os pares de vértices de um grafo ou digrafo G = (VG , EG ) com n vértices
e m arestas. Certamente uma opção simples para resolver esse problema é executar
Dijkstra ou Bellman-Ford n vezes, passando cada um dos vértices v em VG como vértice
inicial do algoritmo. Dessa forma, a cada uma das n execuções de Dijkstra ou Bellman-
Ford, encontramos um caminho mı́nimo de um vértice v a todos os outros vértices do
grafo G. Note que, como o tempo de execução de Dijkstra(G = (VG , EG ), 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 .

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

187
terı́amos um tempo de execução total de Θ(n2 m), que no caso de grafos densos é da
ordem de
Θ(n4 ).

20.3.1 Algoritmo de Floyd-Warshall

O algoritmo de Floyd-Warshall, que é um algoritmo de programação dinâmica, encontra


caminhos mı́nimos (e calcula seus pesos) entre todos os pares de vértices de um grafo
ou digrafo G em tempo Θ(n3 ).

Dado um grafo G = (VG , EG ) com n vértices e m arestas, o algoritmo de Floyd-


Warshall recebe como entrada uma matriz W com n linhas e n colunas, onde o elemento
W (i, j) na i-ésima linha e j-ésima coluna contém o peso da aresta ij, caso ela exista.
Temos W (i, i) = 0 para 1 ≤ i ≤ n, e se ij não é uma aresta de G, então W (i, j) = ∞. O
algoritmo retorna matrizes n×n D e Π tal que D(i, j) e Π(i, j) contêm, respectivamente,
o peso de um caminho mı́nimo de i a j, e o vértice que está imediatamente antes de j
em um caminho mı́nimo de i a j.

Primeiramente vamos analisar a estrutura de caminhos mı́nimos para descrever


tal estrutura e definir recursivamente o peso dos caminhos mı́nimos baseados nessa
estrutura. No que segue, seja VG = {v1 , v2 , . . . , vn }. Note que, dado um caminho
mı́nimo P de vi a vj tal que todos os vértices internos de P estão no conjunto dos
primeiros k vértices de VG , i.e., {v1 , . . . , vk }, temos as duas seguinte possibilidades:
(i) se vk não é vértice interno de P , então existe um caminho mı́nimo de vi a vj com
vértices internos em {v1 , . . . , vk−1 }; (ii) se vk é vértice interno de P , então P é formado
por um caminho mı́nimo de vi a vk , e um caminho mı́nimo de vk a vj , ambos com
vértices internos no conjunto {v1 , . . . , vk−1 }.

Dada a discussão acima, já conseguimos definir a estrutura recursiva que vamos
utilizar. Defina a matriz n × n Dk tal que Dk (i, j) armazena o peso de um caminho
mı́nimo dado que todos os vértices internos do caminho estejam no conjunto {v1 , . . . , vk }.
Note que D = D0 e que Dn contém os pesos dos caminhos mı́nimos entre todos os
pares de vértices. A seguinte definição recursiva para o peso de um caminho mı́nimo

188
Dk (i, j) de vi a vj cujos vértices internos estão em {v1 , . . . , vk } é dada por

W (i, j), se k = 0,
Dk (i, j) =
min{Dk−1 (i, j), Dk−1 (i, k), +Dk−1 (k, j)}, se 1 ≤ k ≤ n.

Lembre que queremos manter o vértice que está imediatamente antes de vj em um


caminho mı́nimo de vi a vj na posição Π(i, j) de Π. O seguinte algoritmo Floyd-
Warshall-pre(W, n) (versão Bottom-up) implementa a discussão acima. O parâmetro
n passado para o algoritmo é a quantidade de linhas (e colunas) de W .

Algoritmo 53: Floyd-Warshall-pre(W, n)


1 D0 = W
2 Cria matriz Π com n linhas e n colunas, todas contendo null
/* Para toda aresta vi vj , vamos fazer Π(i, j) = i */
3 para i = 1 até n faça
4 para j = 1 até n faça
5 se W (i, j) 6= ∞ então
6 Π(i, j) = i

7 para k = 1 até n faça


8 Cria matriz Dk = Dk−1
9 para i = 1 até n faça
10 para j = 1 até n faça
11 valor = Dk−1 (i, k) + Dk−1 (k, j)
12 se Dk (i, j) > valor então
13 Dk (i, j) = valor
14 Π(i, j) = Π(k, j)

15 retorna (Dn , Π)

Note que, devido à ordem em que os três laços aninhados são executados, podemos
utilizar somente uma matriz D durante todo o algoritmo em vez de usar as matrizes
D0 , D1 , . . . , Dn , pois a matriz Dk−1 é usada somente na k-ésima iteração do laço para

189
na linha 7. Assim, podemos simplificar o algoritmo acima.

Algoritmo 54: Floyd-Warshall(W, n)


1 D=W
2 Cria matriz Π com n linhas e n colunas
3 para i = 1 até n faça
4 para j = 1 até n faça
5 se W (i, j) 6= ∞ então
6 Π(i, j) = i

7 para k = 1 até n faça


8 para i = 1 até n faça
9 para j = 1 até n faça
10 se D(i, j) > D(i, k) + D(k, j) então
11 D(i, j) = D(i, k) + D(k, j)
12 Π(i, j) = Π(k, j)

13 retorna (D, Π)

Por causa dos três lações aninhados, claramente o tempo de execução de Floyd-
Warshall(W, n) é Θ(n3 ), 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(n2 ) arestas), a opção mais eficiente assintoticamente é executar o algoritmo de
Dijkstra repetidamente, gastando tempo total o(n3 ) (veja (20.3)). Mas, novamente,
temos o empecilho de que o algoritmo de Dijkstra funciona somente para grafos sem
arestas de peso negativo. Na próxima seção veremos o algoritmo de Jonhson, que tem

tempo de execução igual a repetidas execuções de Dijkstra, i.e., tempo O n2 log n+nm ,
que é igual a o(n3 ) para grafos esparsos. O algoritmo de Johnson combina execuções
de Bellman-Ford e Dijkstra, funcionando mesmo para grafos que contêm arestas de
peso negativo.

190
20.3.2 Algoritmo de Johnson

O algoritmo de Johnson faz uso de um truque para converter um grafo G = (VG , EG )


com função de pesos w : EG → R em um novo grafo G0 = (VG0 , EG0 ) 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 : EG0 → R≥0 .

O algoritmo de Johnson adiciona um vértice s a VG e todas as arestas sv, para todo


v ∈ VG . Todas as novas arestas tem peso 0, i.e., faça w(s, v) = 0 para todo v ∈ VG .
Feito isso, executamos Bellman-Ford(G, w, s) para obter o peso de um caminho
mı́nimo, dist(s, v) entre s e todo vértice v ∈ VG . Agora vem um passo 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 (u, v) = dist(s, u) + w(u, v) − dist(s, v).



(20.4)

Note que dada uma aresta uv, sempre temos dist(s, u) + w(u, v) ≥ dist(s, v). Portanto,
a função w0 é composta por pesos não negativos. Podemos aplicar Dijkstra(G0 , w0 , s)
n vezes, passando em cada uma dessas vezes um dos vértices de G como vértice inicial
s, 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,

dist(u, v) = dist0 (u, v) + dist(s, v) − dist(s, u).

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

191
utilizando (20.4), obtemos

dist0 (u, v) = w0 (v1 , v2 ) + . . . + w0 (vk−1 , vk )


= w(v1 , v2 ) + . . . + w(vk−1 , vk )
+ dist(s, v1 ) + dist(s, v2 ) + · · · + dist(s, vk−1 )
− dist(s, v2 ) − · · · − dist(s, vk−1 ) − dist(s, vk )
= w(v1 , v2 ) + . . . + w(vk−1 , vk ) + dist(s, u) − dist(s, v)
= dist(u, v) + dist(s, u) − dist(s, v).

Portanto, de fato temos dist(u, v) = dist0 (u, v) + dist(s, v) − dist(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 .

Algoritmo 55: Johnson(G = (VG , EG ), w)


1 Crie grafo G0 = (VG0 , EG0 ), onde VG0 = VG ∪ {s} e EG0 = EG ∪ {sv : v ∈ VG }
2 Estenda a função w fazendo w(s, v) = 0 para todo v ∈ VG
3 Crie uma matriz D com n linhas e n colunas
4 se Bellman-Ford(G, w, s) == “falso” então
5 retorna “O grafo G contém ciclo de peso negativo”
6 crie vetor A = [1..n] para todo vértice u ∈ VG faça
7 Execute Bellman-Ford(G, w, s) para fazer u.dist-s = dist(s, u)
8 para toda aresta uv ∈ EG0 faça
9 w0 (u, v) = u.dist-s + w(u, v) − v.dist-s
10 para todo vértice u ∈ VG faça
11 Execute Dijkstra(G, w0 , u) para fazer v.dist = dist0 (u, v) ∀v ∈ VG
12 para todo vértice v ∈ VG faça
13 D(u, v) = v.dist + v.dist-s − u.dist-s

14 retorna D

O tempo de execução de Johnson(G = (VG , EG ), w) é o mesmo de n execuções

192
de Dijkstra. De fato, a linha 11, que é executada para cada vértice do grafo é o que
determina o tempo de execução de Johnson(G = (VG , EG ), w).

193
194
Pa rt e

VI

Teoria da computação
Capı́tulo

21
Complexidade computacional

Um algoritmo é dito eficiente se seu tempo de execução é O(nk ), onde n é o tamanho


da entrada do algoritmo e k é um inteiro positivo que não depende de n. Todos os
problemas que vamos tratar nesta seção são problemas de decisão, que definimos abaixo.

Definição 21.1

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

Por exemplo, decidir se um número é par é um problema de decisão. Outro problema


de decisão é decidir se existe um caminho entre dois vértices de um grafo. Um problema
que não é problema de decisão é exibir um caminho mı́nimo entre dois vértices de um
grafo.
No que segue vamos classificar problemas de decisão e discutir as relações entre
essas classes de problemas. As principais classes de problemas são P, NP e co-NP.
Mas antes precisamos de algumas definições relacionadas à verificação de soluções para
problemas.

21.1 Classes P, NP e co-NP

Considere o problema Clique-k abaixo.


Problema 21.1: Clique-k

Dados um grafo G e um inteiro positivo k, o problema Clique-k(G, k) consiste


em determinar se G contém um subgrafo isomorfo a um grafo completo com pelo
menos k vértices.

Nesse problema, a resposta é sim caso exista o grafo completo e não caso contrário.
Note que, se de alguma forma recebermos um subgrafo completo H de G com k vértices,
é fácil escrever um algoritmo Alg eficiente para verificar se H é realmente um grafo
completo: basta verificar se todos seus pares de vértices formam arestas. Nesse caso,
dizemos que H é um certificado positivo para Clique-k(G,k), e o algoritmo Alg é
um verificador que aceita o certificado positivo H.
Um grafo é bipartido se é possı́vel particionar seu conjunto de vértices em duas
partes tal que todas as arestas do grafo estão entre essas partes. Considere agora
o problema Bipartido(G) que consiste em determinar se um grafo G é bipartido.
Nesse problema, a resposta é sim caso G seja bipartido e não caso contrário. Um
clássico resultado da Teoria dos Grafos afirma que um grafo é bipartido se e somente se
não contém um ciclo com uma quantidade ı́mpar de vértices. Note que uma partição
dos vértices do grafo em duas partes tal que todas as arestas estão entre as partes
é um verificador positivo para Bipartido(G) e é fácil escrever um verificador para
esse certificado. Mas observe também que um ciclo ı́mpar C é o que chamamos de
certificado negativo, que é um conjunto de dados tal que existe um algoritmo eficiente
que verifica que a resposta de Bipartido(G) é não. Tal algoritmo é um verificador
que aceita o certificado negativo C.

Definição 21.2: Certificado positivo

Um certificado positivo para um problema de decisão P e uma instância I é um


conjunto de dados D tal que existe um algoritmo eficiente que recebe D e verifica
se a resposta de P para a instância I é sim. Tal algoritmo é um verificador que
aceita o certificado positivo D.

198
Definição 21.3: Certificado negativo

Um certificado negativo para um problema de decisão P e uma instância I é um


conjunto de dados D tal que existe um algoritmo eficiente que recebe D e verifica
se a resposta de P para a instância I é não. Tal algoritmo é um verificador que
aceita o certificado negativo D.

Agora estamos prontos para definir as classes P, NP e co-NP.

Definição 21.4: Classe P

P é a classe dos problemas de decisão que podem ser resolvidos por um algoritmo
eficiente.

Portanto, sabemos que o problema de determinar se existe um caminho entre dois


vértices de um grafo está na classe P, pois, por exemplo, os algoritmos de busca em
largura e profundidade são algoritmos eficientes que resolvem este problema.
Outro exemplo de problema na classe P é o problema de decidir se um grafo possui
uma árvore geradora de peso total menor que k. Pois se executarmos, por exemplo, o
algoritmo de Prim e verificarmos se uma árvore geradora mı́nima tem peso menor que
k então a resposta para o problema é sim, caso contrário a resposta é não. Portanto,
todos os problemas para os quais conhecemos um algoritmo eficiente que o resolva
estão na classe P.
Para definir as classes NP e co-NP precisamos usar os conceitos de verificadores e
certificados positivos e negativos.

Definição 21.5: Classe NP

NP é a classe dos problemas de decisão em que existe um verificador que aceita


um certificado positivo.

A definição da classe co-NP é similar à da classe NP.

199
Definição 21.6: Classe co-NP

co-NP é a classe dos problemas de decisão em que existe um verificador que


aceita um certificado negativo.

Como discutido anteriormente, existe um verificador que aceita um certificado


positivo para o problema Clique-k(G, k). Assim, Clique-k(G, k) está em NP.
Também mencionamos que existem verificadores que aceitam certificados positivos e
negativos para Bipartido(G), que garante que Bipartido(G) está em NP e em
co-NP. Na verdade, todo problema da classe P está em NP e em co-NP. Isso se dá
pelo fato de que um algoritmo eficiente que resolve o problema é um verificador que
aceita certificados positivos e negativos, onde os certificados são a própria entrada do
algoritmo, pois o algoritmo recebe a entrada e verifica se a resposta do problema é sim
ou não em tempo polinomial. Portanto, temos o seguinte resultado.

Teorema 21.7

Vale que P ⊆ NP e P ⊆ co-NP.

Uma questão natural (e muito importante!) é saber se é verdade que NP ⊆ P.


Porém, essa questão continua em aberto até os dias atuais. Dada sua importância,
esse problema é um dos Problemas do Milênio e o Clay Institute oferece um prêmio
monetário de $1.000.000, 00.

21.2 NP-completude

Muitas vezes é possı́vel resolver um problema de decisão P utilizando para isso um


problema de decisão Q que sabemos resolver. Para isso, precisamos converter a entrada
E1 de P para uma entrada de E2 Q de modo que a resposta de E2 em Q é sim se
e somente se a resposta para E1 em P é sim. Dessa forma, se sabemos resolver Q,
então automaticamente obtemos a resposta para P . A definição abaixo torna essa ideia
precisa.

200
Definição 21.1: Redução polinomial

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


um algoritmo eficiente que converte uma entrada E1 para P em uma entrada E2
para Q de modo que a resposta para P com entrada E1 é sim se e somente se a
resposta para Q com entrada E2 é sim.
Escrevemos P ≤ Q para denotar que P é redutı́vel a Q.

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

Considere o seguinte problema conhecido como 3-satisfabilidade ou 3-sat.

Problema 21.2: 3-SAT

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


o problema 3-Sat(φ) consiste em decidir se existe uma atribuição de valores a
x1 , . . . , xn tal que φ é satisfatı́vel, i.e., φ tem valor 1.

O resultado abaixo mostra que 3-Sat ≤ Clique-k, i.e., existe uma redução
polinomial de 3-Sat para Clique-k, ou ainda, 3-Sat é redutı́vel a Clique-k.

Teorema 21.3

3-Sat ≤ Clique-k.

201
Demonstração. Precisamos exibir um algoritmo eficiente que converte uma 3-CNF φ
em um grafo G tal que φ é satisfatı́vel se e somente se G contém um grafo completo
com k vértices.
O grafo G que construiremos possui 3k vértices, de modo que cada uma das k
cláusulas tem 3 vértices representando cada um de seus literais. Um par de vértices
v e w de G forma uma aresta se e somente se v e w estão em cláusulas diferentes, v
corresponde a um literal x, e w não corresponde ao literal x. Veja Figura 21.1 para um
exemplo de construção de G.

Figura 21.1: Construção de um grafo G dada uma instância de 3-Sat.

O próximo passo é verificar que φ é satisfatı́vel se e somente se G contém um grafo


completo com k vértices. Para mostrar um lado dessa implicação note que se φ é
satisfatı́vel, então em cada uma das k cláusulas existe um literal com valor 1. Como

202
um literal e sua negação não podem ter valor 1, sabemos que em todo par {x, y}
desses k literais temos x 6= y. Portanto, existe uma aresta entre quaisquer dois vértices
representando esses literais em G, de modo que formam um grafo completo com k
vértices dentro de G.
Para verificar a volta da implicação, suponha que G contém um grafo completo
H com k vértices. Assim, como existe uma aresta entre quaisquer dois vértices de
H, sabemos que qualquer par de vértices de H representa dois literais que não são a
negação um do outro e estão em diferentes cláusulas. Logo, φ é satisfatı́vel.

A definição abaixo descreve quando um problema está na classe dos problemas


NP-completos.

Definição 21.4: NP-completude

Um problema de decisão R é NP-completo se R ∈ NP e todo problema Q ∈ NP


é redutı́vel a R, i.e., R ≤ Q.

Portanto, uma solução eficiente de um problema NP-completo resolve todos os


problemas da classe NP. De fato, isso segue direto da definição de redução polinomial
e da definição de NP-completude.
A forma mais utilizada para mostrar que um problema R é NP-completo é reduzindo
um problema Q que é NP-completo a R. Porém, para que essa estratégia funcione, é
necessário um ponto de partida, i.e., é necessário que exista uma prova de que algum
problema é NP-completo que não necessite de outro problema NP-completo. Esse
ponto de partida é o problema 3-Sat. Foi provado por Cook e Levin que 3-Sat é
NP-completo. Assim, note que o Teorema 21.3 prova o seguinte resultado.

Teorema 21.5

Clique-k é NP-completo.

Note que para mostrar que NP ⊆ P, é suficiente provar que existe um algoritmo
eficiente que resolve um problema NP-completo Q, pois como todo problema da classe
NP é redutı́vel a Q, terı́amos um algoritmo eficiente para resolver todos os problemas
de NP.

203

Anda mungkin juga menyukai