Estruturas de Dados
{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
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
II Estruturas de dados 57
5 Heap binário 69
5.1 Construção de um heap binário . . . . . . . . . . . . . . . . . . . . . . 70
6 Fila de prioridades 79
7 Union-find 83
9 Merge sort 93
11 Quicksort 103
11.1 Tempo de execução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
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
vi
Pa rt e
I
Introdução à análise de algoritmos
1
Algoritmos: corretude e tempo de
execução
4
subvetor de A que contém os elementos A[i], A[i + 1], . . . , A[j].
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
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.
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
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
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
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
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.
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
T (n) = 1 + 1 + tx + tx + tx − 1
= 3tx + 1 . (1.4)
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
T 0 (n) ≤ rx + 3 + rx + rx + rx
= 4rx + 3 . (1.5)
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 .
T 0 (n) ≤ 4rx + 3 = 7 .
T 0 (n) ≤ 4 log n + 3 .
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
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.
Seja n um inteiro positivo e sejam f (n) e g(n) funções positivas. Dizemos que
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 .
Fato 1.3
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
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
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).
• (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
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.
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 Θ.
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 ,
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.
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
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 .
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.
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
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
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.
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
Algoritmo 8: Fibonacci(n)
1 se n ≤ 2 então
2 retorna 1
3 retorna Fibonacci(n − 1) + Fibonacci(n − 2)
29
Fn
Fn−1 Fn−2
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
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.
Fato 3.1
(i) aloga b = b.
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
(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
(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
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,
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.
Teorema 3.2
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
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 ) .
b1 (q n − 1)
S= .
q−1
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
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,
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 .
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,
37
Similarmente,
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.
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 .
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 .
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.
40
2 ≤ m < n. Assim, temos que
T (n) = 4T (n/2) + n3
4cn3
≤ + n3
8
≤ cn3 ,
T (n) = 4T (n/2) + n3
4dn3
≥ + n3
8
≥ dn3 ,
√
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.
T (n) = T (n/2) + 1
≤ c log n − c + 1
≤ c log n ,
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) ,
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) .
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 .
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
T (n) = 2 × 2log3 n − 1
1/ log 3
= 2 2log n −1
= 2n1/ log 3 − 1
= Θ(n1/ log 3 ) .
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 .
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.
47
Figura 3.2: Árvore de recorrência para T (n) = 2T (n/2) + 1.
a1+logb n − 1
a0 + a1 + . . . + alogb n =
a−1
(bn)logb a − 1
=
a−1
= Θ nlogb a .
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 );
(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
(1) se f (n) = O(nlogb a−ε ) para alguma constante ε > 0, então T (n) = Θ(nlogb a );
(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
√
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).
(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.
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 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.
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.
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
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.
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.
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
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.
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.
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]
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
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.
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.
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)
71
A de modo que a árvore com raiz em A[j] para todo i ≤ j ≤ A. tam-heap é um
heap máximo.
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
T (n) ≤ 1 + log3/2 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)
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
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.
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)
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.
82
Capı́tulo
7
Union-find
• Union(x, y): gera um conjunto obtido da união dos conjuntos que contém os
elementos x e y de A.
III
Algoritmos de ordenação
Capı́tulo
8
Ordenação por inserção
Note que o Insertion sort é um algoritmo in-place e estável. A Figura 8.1 mostra
uma execução do algoritmo.
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.
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.
= 5n − 3
= Θ(n).
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.
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
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).
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.
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,
96
Capı́tulo
10
Selection sort e Heapsort
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.
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
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.
Invariante: Heapsort
Teorema 10.2
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).
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
Invariante: Partição
Antes de cada iteração do laço para indexada por j, temos A[f im]=pivô e vale
que
Teorema 11.2
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.
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.
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
É 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
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).
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
≤ (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.
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,
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
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
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.
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
13
Divisão e conquista
120
Capı́tulo
14
Algoritmos gulosos
15
Programação dinâmica
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 .
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.
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.
127
realizadas é de cerca de
m1 m2 m3 + m1 m3 m4 = m1 m3 (m2 + m4 ) = 4000000.
m2 m3 m4 + m1 m2 m4 = m2 m4 (m1 + m3 ) = 8000.
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.
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.
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
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
8 retorna lucro
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
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)
133
por
T (n) = 1 + 2 + . . . + n = Θ(n2 ).
Caso precise imprimir os pontos em que os cortes foram efetuados, basta executar
o seguinte procedimento.
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.
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
Não.
Capı́tulo
16
Grafos
A Teoria de Grafos, que estuda essas estruturas, tem aplicações em diversas áreas
do conhecimento, como Bioinformática, Sociologia, Fı́sica, Computação e muitas outras,
e teve inı́cio em 1736 com Leonhard Euler, que estudou um problema conhecido como
o problema das sete pontes de Königsberg.
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)|
141
Figura 16.2: Representação gráfica de um grafo G e um digrafo D e suas listas 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
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)
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 ).
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)
Fato 17.1
151
Lema 17.2
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
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
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
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 ≤ `,
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
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.
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.
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.
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
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
Lema 18.1
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
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.
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.
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 é
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 .
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.
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.
10 retorna (A)
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.
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
Teorema 19.2
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
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.
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.
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
k−1
X
w(P ) = w(vi vi+1 ).
i=0
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.
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.
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
Teorema 20.2
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
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).
Como não existem arestas de peso negativo, distG (s, w) ≤ distG (s, u). Logo,
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.
13 retorna “verdade”
182
A Figura 20.2 mostra um exemplo de execução do algoritmo Bellman-Ford(G =
(VG , EG ), w, s).
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.
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
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.
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.
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 .
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.
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.
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 .
187
terı́amos um tempo de execução total de Θ(n2 m), que no caso de grafos densos é da
ordem de
Θ(n4 ).
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.
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.
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
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.
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
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 .
14 retorna D
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
Definição 21.1
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.
198
Definição 21.3: Certificado negativo
P é a classe dos problemas de decisão que podem ser resolvidos por um algoritmo
eficiente.
199
Definição 21.6: Classe co-NP
Teorema 21.7
21.2 NP-completude
200
Definição 21.1: Redução polinomial
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.
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.
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