Escolar Documentos
Profissional Documentos
Cultura Documentos
Estruturas de Dados
26 de junho de 2019– Esta versão é um rascunho ainda em elaboração e não foi revisado.
ii
Sumário
2 Recursividade 27
2.1 Algoritmos recursivos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.1.1 Fatorial . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.1.2 Busca binária . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
2.1.3 Algoritmos recursivos × algoritmos iterativos . . . . . . . . . . 30
II Estruturas de dados 63
4 Estruturas lineares 67
4.1 Vetor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
4.2 Lista encadeada . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
5 Árvores 73
5.1 Árvores binárias de busca . . . . . . . . . . . . . . . . . . . . . . . . . 75
5.2 Árvores balanceadas . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
6 Pilha 79
7 Fila 83
8 Fila de prioridades 87
8.1 Heap binário . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
8.1.1 Construção de um heap binário . . . . . . . . . . . . . . . . . . 94
8.1.2 Remoção em um heap binário . . . . . . . . . . . . . . . . . . . 98
8.1.3 Inserção em um heap binário . . . . . . . . . . . . . . . . . . . . 98
8.1.4 Alteração em um heap binário . . . . . . . . . . . . . . . . . . . 99
20 Buscas 197
20.1 Busca em largura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 198
20.1.1 Distância entre vértices . . . . . . . . . . . . . . . . . . . . . . . 200
20.1.2 Componentes conexas . . . . . . . . . . . . . . . . . . . . . . . 206
20.2 Busca em profundidade . . . . . . . . . . . . . . . . . . . . . . . . . . . 206
20.2.1 Ordenação topológica . . . . . . . . . . . . . . . . . . . . . . . . 211
20.2.2 Componentes fortemente conexas . . . . . . . . . . . . . . . . . 214
20.3 Outras aplicações dos algoritmos de busca . . . . . . . . . . . . . . . . 216
vii
viii
Introdução à análise de algoritmos
1
Estamos falando aqui de números que possam ser representados por 32 ou 64 bits, que são
facilmente manipulados por computadores.
6
Problema 1.1: Busca
Veja que o problema é definido sobre um vetor que contém apenas números reais,
mas poderı́amos facilmente supor que o vetor contém registros e assumir que a busca é
feita sobre algum campo especı́fico dos registros que os diferenciariam (por exemplo, se
os registros armazenam informações de pessoas, poderia haver um campo CPF, que
é único para cada pessoa). Assim, frequentemente chamamos o valor x de chave de
busca.
O algoritmo mais simples para o Problema 1.1 é conhecido como busca linear e é
descrito no Algoritmo 1. Ele percorre o vetor, examinando todos os seus elementos,
um a um, até encontrar x ou até verificar todos os elementos de A e descobrir que x
não está em A.
Algoritmo 1: BuscaLinear(A, n, x)
1 i = 1
2 enquanto i ≤ n faça
3 se A[i] == x então
4 retorna i
5 i=i+1
6 retorna −1
7
Intuitivamente, é fácil perceber que BuscaLinear funciona corretamente, isto é,
que para qualquer vetor A de números reais e número real x, o algoritmo irá retornar a
posição de x em A caso ela exista, ou irá retornar −1 caso x não esteja em A. Mas como
podemos ter certeza que o comportamento de BuscaLinear é sempre como esperamos
que seja? Na Seção 1.1.1 veremos uma forma de provar que algoritmos funcionam
corretamente. Antes, vejamos outra forma de resolver o problema de encontrar um
valor em um vetor A, mas agora com a informação extra de que A está ordenado.
Considere um vetor ordenado (ordem não-decrescente3 ) A com n elementos, i.e.,
A[i] ≤ A[i + 1] para todo 1 ≤ i ≤ n − 1. Por simplicidade, assuma que n é múltiplo
de 2 (assim não precisamos nos preocupar com pisos e tetos). Nesse caso, existe um
outro procedimento, chamado de busca binária, que consegue realizar a busca por uma
chave x em A.
A estratégia da busca binária também é muito simples. A ideia é verificar se
A[n/2] = x e realizar o seguinte procedimento. Se A[n/2] = x, então a busca está
encerrada. Caso contrário, se x < A[n/2], então temos a certeza de que, se x estiver
em A, então x estará na primeira metade de A, i.e., x estará em A[1..n/2 − 1] (isso
segue do fato de A estar ordenado). Caso x > A[n/2], então sabemos que, se x estiver
em A, então x estará no vetor A[n/2 + 1..n].
Suponha que x < A[n/2]. Note que podemos verificar se x está em A[1..n/2 − 1]
utilizando a mesma estratégia, i.e., comparamos x com o valor que está na metade
do vetor A[1..n/2 − 1], A[n/4 − 2], e verificamos a primeira ou segunda metade desse
subvetor dependendo do resultado da comparação. O Algoritmo 2 apresenta a busca
binária, que recebe um vetor A[1..n] ordenado de modo não-decrescente e um valor x a
ser buscado. Ele retorna a posição em que x está armazenado, se x estiver em A, ou
retorna −1, caso contrário.
8
Algoritmo 2: BuscaBinaria(A, n, x)
1 esquerda = 1
2 direita = n
3 enquanto esquerda ≤ direita faça
meio = esquerda + direita−esquerda
4
2
5 se A[meio] == x então
6 retorna meio
7 senão se x > A[meio] então
8 esquerda = meio + 1
9 senão
10 direita = meio − 1
11 retorna −1
Para ser útil, uma invariante de laço precisa permitir que após a última iteração
do laço possamos concluir que o algoritmo funciona corretamente. Uma observação
importante é que quando dizemos “imediatamente antes de uma iteração” estamos nos
referindo ao momento imediatamente antes de iniciar a linha correspondente ao laço.
Para entender como podemos utilizar as invariantes de laço para provar a corretude
de algoritmos, vamos inicialmente fazer a análise dos algoritmos de busca em vetores.
9
Comecemos com o algoritmo BuscaLinear, considerando a seguinte invariante de
laço:
Invariante: BuscaLinear
Observe que o item (i) na Definição 1.2 de invariante é trivialmente válido antes da
primeira iteração, quando i = 1, pois nesse caso a invariante trata do vetor A[1..0], que
é vazio e, logo, não pode conter x. Para verificar o item (ii), suponha agora que vamos
começar a iteração indexada por i e que o vetor A[1..i − 1] não contém x. Suponha
agora que o laço enquanto termina a execução dessa iteração. Como a iteração foi
terminada, isso significa que a linha 4 não foi executada. Portanto, A[i] 6= x. Esse
fato, juntamente com o fato de que x ∈ / A[1..i − 1], implica que x ∈ / A[1..i]. Assim, a
invariante continua válida antes da iteração indexada por i + 1.
Precisamos agora utilizar a invariante para concluir que o algoritmo funciona
corretamente, i.e., caso x esteja em A o algoritmo deve retornar um ı́ndice i tal que
A[i] = x, e caso x não esteja em A o algoritmo deve retornar −1. Note que se o
algoritmo retorna i na linha 4, então a comparação na linha 3 é verificada com sucesso,
de modo que temos A[i] = x como desejado. Porém, se o algoritmo retorna −1, então
o laço enquanto foi executado por completo, até que chegamos em i = n + 1. Pela
invariante de laço, sabemos que x ∈ / A[1..i − 1], i.e., x ∈
/ A[1..n]. Na última linha o
algoritmo retorna −1, que era o desejado no caso em que x não está em A. Perceba
que não fizemos nenhuma suposição sobre os dados contidos em A ou sobre o valor de
x, portanto, o algoritmo funciona corretamente para qualquer entrada.
À primeira vista, todo o processo que fizemos para mostrar que o algoritmo
BuscaLinear funciona corretamente pode parecer excessivamente complicado. Porém,
essa impressão vem do fato desse algoritmo ser muito simples (assim, a análise de
algo simples parece ser desnecessariamente longa). Futuramente veremos casos onde
a corretude de um dado algoritmo não é tão clara, de modo que a necessidade de se
utilizar invariantes de laço é evidente.
Para clarear nossas ideias, analisaremos agora o Algoritmo 3, que realiza uma tarefa
muito simples: recebe um vetor A[1..n] e retorna o produtório de seus elementos, i.e.,
Qn
i=1 A[i].
10
Algoritmo 3: Produtorio(A, n)
1 produto = 1
2 para i = 1 até n faça
3 produto = produto × A[i]
4 retorna produto
Invariante: Produtorio
confirmando a validade do item (ii), pois mostramos que a invariante se manteve válida
11
após a i-ésima iteração.
Note que na última vez que a linha 2 do algoritmo é executada temos i = n + 1.
Assim, o algoritmo não executa a linha 3, e retorna produto. Como a invariante é
válida, temos que produto = ni=1 A[i], que é de fato o resultado desejado. Portanto, o
Q
12
o tempo de execução definido dessa forma nos possibilita obter uma boa estimativa do
quão rápido um algoritmo é. Podemos, assim, comparar um algoritmo com o outros
por meio da ordem de crescimento das funções que descrevem seus tempos de execução.
Vamos então considerar que um algoritmo é eficiente se seu tempo de execução,
qualquer que seja a entrada, puder ser descrito por uma função que cresce devagar
com o tamanho da entrada. Por exemplo, a função f (x) = x cresce mais devagar do
que a função g(x) = x2 e mais rápido do que a função h(x) = log x.
Para entender melhor essas definições, vamos começar com uma análise simples dos
algoritmos BuscaLinear e BuscaBinaria vistos anteriormente.
Veremos adiante que não é tão importante para a análise do tempo de execução de um
algoritmo se uma dada operação primitiva leva um certo “tempo” t para ser executada
ou não. Assim, vamos assumir que toda operação primitiva leva “tempo” 1 para ser
executada. Por comodidade, repetimos o algoritmo BuscaLinear no Algoritmo 4.
Algoritmo 4: BuscaLinear(A, n, x)
1 i = 1
2 enquanto i ≤ n faça
3 se A[i] == x então
4 retorna i
5 i=i+1
6 retorna −1
Denote por tx a posição do elemento x no vetor A[1..n], onde fazemos tx = n+1 caso
x não esteja em A. Note que a linha 1 é executada somente uma vez e somente uma
dentre as linhas 4 e 6 é executada (obviamente, somente uma vez, dado que o algoritmo
encerra quando retorna um valor). Já o laço enquanto da linha 2 é executado tx vezes,
a linha 3 é executada tx vezes, e a linha 5 é executada tx − 1 vezes. Assim, o tempo
de execução total T (n) de BuscaLinear(A, n, x) é dado como abaixo (note que o
tempo de execução depende do tamanho n do vetor de entrada A):
T (n) = 1 + 1 + tx + tx + tx − 1
= 3tx + 1 . (1.4)
13
Se A contém n elementos e x está na última posição de A, então T (n) = 3n + 1. Porém,
se x está na primeira posição de A, temos T (n) = 4.
Para a busca binária, vamos fazer uma análise semelhante. Por comodidade,
repetimos o algoritmo BuscaBinaria no Algoritmo 5. Lembre-se que na busca binária
assumimos que o vetor está ordenado de modo não decrescente.
Algoritmo 5: BuscaBinaria(A, n, x)
1 esquerda = 1
2 direita = n
3 enquanto esquerda ≤ direita faça
meio = esquerda + direita−esquerda
4
2
5 se A[meio] == x então
6 retorna meio
7 senão se x > A[meio] então
8 esquerda = meio + 1
9 senão
10 direita = meio − 1
11 retorna −1
T 0 (n) ≤ rx + 3 + rx + rx + rx
= 4rx + 3 . (1.5)
14
n/2i , onde o último vetor analisado pode chegar a ter tamanho 1, caso em que n/2i = 1,
o que implica i = log n. Assim, o laço enquanto é executado no máximo log n vezes,
de modo que temos rx ≤ log n. Assim, temos T 0 (n) ≤ 4 log n + 3.
T (n) = 4 .
T 0 (n) ≤ 4rx + 3 = 7 .
15
pois a busca linear precisa percorrer todo o vetor, e a busca binária vai subdividir o
vetor até que não seja mais possı́vel. No caso da busca linear, o tempo de execução do
pior caso é dado por
T (n) = 3(n + 1) + 1 = 3n + 4 .
T 0 (n) ≤ 4 log n + 3 .
3n 5
T (n) = 3tx + 1 = + .
2 2
16
em média o tempo gasto é da ordem de n log n, que é muito menor que uma função
quadrática em n para valores grandes de n. Embora o tempo de execução de pior
caso do Quicksort seja pior do que de outros algoritmos de ordenação (e.g., Mergesort,
Heapsort), ele é comumente utilizado, dado que seu pior caso raramente ocorre. Por
fim, vale mencionar que nem sempre é simples descrever o que seria uma “entrada
média” para um algoritmo, e análises de caso médio são geralmente mais complicadas
do que análises de pior caso.
1.3.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
17
Em outras palavras, f (n) = O(g(n)) quando, para todo n suficientemente grande
(maior que um n0 ), a função f (n) é limitada superiormente por Cg(n). Dizemos que
f (n) é no máximo da ordem de g(n). Por outro lado, f (n) = Ω(g(n)) quando, para
todo n suficientemente grande (maior que um n0 ), f (n) é limitada inferiormente por
cg(n). Dizemos que f (n) é no mı́nimo da ordem de g(n).
Dadas funções f (n) e g(n), se f (n) = O(g(n)) e f (n) = Ω(g(n)), então dizemos que
f (n) = Θ(g(n)).
Seja n um inteiro positivo e sejam f (n) e g(n) funções positivas. Dizemos que
f (n) = Θ(g(n)) se existem constantes positivas c, C e n0 tais que cg(n) ≤ f (n) ≤
Cg(n) para todo n ≥ n0 .
Note que as três notações acima são definidas em termos de funções. Assim, podemos
utilizar todas elas para analisar tempos de execução de melhor caso, pior caso ou caso
médio de algoritmos. No que segue assumimos que n é grande o suficiente.
Se um algoritmo tem tempo de execução T (n) no pior caso e sabemos que T (n) =
O(n log n), então para a instância de tamanho n em que o algoritmo é mais lento, ele
leva tempo no máximo Cn log n, onde C é constante. Portanto, podemos concluir que
para qualquer instância de tamanho n o algoritmo leva tempo no máximo da ordem
de n log n. Por outro lado, se dizemos que T (n) = Ω(n log n) é o tempo de execução de
pior caso de um algoritmo, então não temos muita informação útil. Sabemos somente
que para a instância In de tamanho n em que o algoritmo é mais lento, o algoritmo
leva tempo pelo menos Cn log n, onde C é constante. Mas isso não implica nada
sobre quaisquer outras instâncias do algoritmo, nem informa nada a respeito do tempo
máximo de execução para a instância In .
Se um algoritmo tem tempo de execução T (n) no melhor caso, uma informação
importante é mostrar que T (n) = Ω(g(n)), pois isso afirma que para a instância de
tamanho n em que o algoritmo é mais rápido, ele leva tempo no mı́nimo cg(n), onde
c é constante. Isso também afirma que, para qualquer instância de tamanho n,
o algoritmo leva tempo no mı́nimo da ordem de g(n). Porém, se sabemos somente
que T (n) = O(g(n)), então a única informação que temos é que para a instância de
tamanho n em que o algoritmo é mais rápido, ele leva tempo pelo menos Cg(n), onde
18
C é constante. Isso não diz nada sobre o tempo de execução do algoritmo para outras
instâncias.
Vamos trabalhar com alguns exemplos para entender melhor as notações O, Ω e Θ.
Fato 1.3
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
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
19
portanto, f (n) = Ω(n2 ).
Como mostramos que f (n) = O(n2 ) e f (n) = Ω(n2 ), então concluı́mos que f (n) =
Θ(n2 ).
Perceba que na prova do Fato 1.3 traçamos uma simples estratégia para encontrar
um valor apropriado para as constantes. Os valores para n0 escolhido nos dois casos
foi 1, mas algumas vezes é mais conveniente ou somente é possı́vel escolher um valor
maior para n0 . Considere o exemplo a seguir.
Fato 1.4
√ √
Se f (n) = 5 log n + n, então f (n) = O( n).
√
Demonstração. Comece percebendo que f (n) = O(n), pois sabemos que log n e n
são menores que n para valores grandes de n (na verdade, para qualquer n ≥ 2). Porém,
√
é possı́vel melhorar esse limitante para f (n) = O( n). De fato, basta obter C e n0
√ √
tais que para n ≥ n0 temos 5 log n + n ≤ C n. Logo, queremos que
5 log n
C≥ √ +1 . (1.6)
n
Mas nesse caso precisamos ter cuidado ao escolher n0 , pois com n0 = 1, temos
√
5(log 1)/ 1 + 1 = 1, o que pode nos levar a pensar que C = 1 é uma boa escolha
para C. Com essa escolha, precisamos que a desigualdade (1.6) seja válida para todo
√
n ≥ n0 = 1. Porém, se n = 2, então (1.6) não é válida, uma vez que 5(log 2)/ 2+1 > 1.
√
Para facilitar, podemos observar que, para todo n ≥ 16, temos (log n)/ n ≤ 1, de
√
modo que a desigualdade (1.6) é válida, i.e., (5 log n)/ n + 1 ≤ 6. Portanto, tomando
√
n0 = 16 e C = 6, mostramos que f (n) = O( n).
A estratégia utilizada nas demonstrações dos Fatos 1.3 e 1.4 de isolar a constante e
analisar a expressão restante não única. Veja o próximo exemplo.
Fato 1.5
√ √
Se f (n) = 5 log n + n, então f (n) = O( n).
√
Demonstração. Podemos observar facilmente que log n ≤ n sempre que n ≥ 16.
20
Assim,
√ √ √ √
5 log n + n≤5 n+ n=6 n , (1.7)
onde a desigualdade vale sempre que n ≥ 16. Como chegamos a uma expressão da
√
forma f (n) ≤ C n, concluı́mos nossa demonstração. Portanto, tomando n0 = 16 e
√
C = 6, mostramos que f (n) = O( n).
Fato 1.6
√ √
Se f (n) = 5 log n + n, então f (n) = O( n).
Demonstração. Para mostrar esse resultado, basta obter C e n0 tais que para n ≥ n0
√ √
temos 5 log n + n ≤ C n. Logo, queremos que
5 log n
C≥ √ +1 . (1.8)
n
Note que
5 log n 5 log n
lim √ +1 = lim √ + lim 1 (1.9)
n→∞ n n→∞ n n→∞
!
5 n1
= lim 1 +1 (1.10)
n→∞ √
2 n
10
= lim √ +1=0+1=1 , (1.11)
n→∞ n
21
• loga n = Θ(logb 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.7
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.
22
Teorema 1.8: Propriedades de notações assintóticas
Item 1. Esse item é simples, pois para qualquer n ≥ 1 temos que f (n) = 1 × f (n), de
modo que para n0 = 1, c = 1 e C = 1 temos que para todo n ≥ n0 vale que
Item 2. Note que basta provar uma das implicações (a prova da outra implicação é
idêntica). Provaremos que se f (n) = Θ(g(n)) então g(n) = Θ(f (n)). Se f (n) = Θ(g(n)),
então temos que existem constantes positivas c, C e n0 tais que
23
para todo n ≥ n0 . Assim, analisando as desigualdades em (1.12), concluı́mos que
1 1
f (n) ≤ g(n) ≤ f (n)
C c
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 ,
24
1.3.2 Notações o e ω
Apesar das notações assintóticas descritas até aqui fornecerem informações importantes
acerca do crescimento das funções, muitas vezes elas não são tão precisas quanto
gostarı́amos. Por exemplo, temos que 2n2 = O(n2 ) e 4n = O(n2 ). Apesar dessas
duas funções terem ordem de complexidade O(n2 ), somente a primeira é “justa”. para
descrever melhor essa situação, temos as notações o-pequeno e ω-pequeno.
Seja n um inteiro positivo e sejam f (n) e g(n) funções positivas. Dizemos que
• f (n) = o(g(n)) se para toda constante c > 0 existe uma constante n0 > 0
tal que 0 ≤ f (n) < cg(n) para todo n ≥ n0 ;
• f (n) = ω(g(n)) se para toda constante C > 0 existe n0 > 0 tal que
f (n) > Cg(n) ≥ 0 para todo n ≥ n0 .
Por exemplo, 2n = o(n2 ) mas 2n2 6= o(n2 ). O que acontece é que, se f (n) = o(g(n)),
então f (n) é insignificante com relação a g(n), para n grande. Alternativamente,
podemos dizer que f (n) = o(g(n)) quando limn→∞ (f (n)/g(n)) = 0. Por exemplo,
2n2 = ω(n) mas 2n2 6= ω(n2 ).
Vamos ver um exemplo para ilustrar como podemos mostrar que f (n) = o(g(n))
para duas funções f e g.
Fato 1.10
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 .
25
Note que com uma análise similar à feita na prova acima podemos provar que
10n + 3 log n = o(n1+ε ) para todo ε > 0. Basta que, para todo c > 0, façamos
n > (13/c)1/ε .
Outros exemplos de limitantes seguem abaixo, onde a e b são inteiros positivos.
26
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:
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)
28
Novamente, o estado atual é salvo na pilha de execução e uma chamada a “Fatorial(1)”
é realizada. Essa chamada recursiva será a última, pois nesse ponto a linha 2 será
executada e essa chamada retorna o valor 1. Assim, a pilha de execução começa a ser
desempilhada, e o resultado final será 3 × (2 × (1 × 1)).
Pelo exemplo do parágrafo anterior, conseguimos perceber que a execução de um
programa recursivo precisa salvar vários estados do programa ao mesmo tempo, de
modo que isso aumenta o uso de memória. Por outro lado, muitas vezes uma solução
recursiva é bem mais simples que uma iterativa correspondente.
29
2.1.3 Algoritmos recursivos × algoritmos iterativos
Quando utilizar um algoritmo recursivo ou um algoritmo iterativo? Vamos discutir
algumas vantagens e desvantagens de cada tipo de procedimento.
A utilização de um algoritmo recursivo tem a vantagem de, em geral, ser simples
e oferecer códigos claros e concisos. Assim, alguns problemas que podem parecer
complexos de inı́cio, acabam tendo uma solução simples e elegante, enquanto que
algoritmos iterativos longos requerem experiência por parte do programador para
serem entendidos. Por outro lado, uma solução recursiva pode ocupar muita memória,
dado que o computador precisa manter vários estados do algoritmo gravados na pilha
de execução do programa. Muitas pessoas acreditam que algoritmos recursivos são,
em geral, mais lentos do que algoritmos iterativos para o mesmo problema, mas a
verdade é que isso depende muito do compilador utilizado e do problema em si. Alguns
compiladores conseguem lidar de forma rápida com as chamadas a funções e com o
gerenciamento da pilha de execução.
Algoritmos recursivos eliminam a necessidade de se manter o controle sobre diversas
variáveis que possam existir em um algoritmo iterativo para o mesmo problema. Porém,
pequenos erros de implementação podem levar a infinitas chamadas recursivas, de
modo que o programa não encerraria sua execução.
Nem sempre a simplicidade de um algoritmo recursivo justifica o seu uso. Um
exemplo claro é dado pelo problema de se calcular termos da sequência de Fibonacci,
que é a sequência infinita de números: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, . . . Por
definição, o n-ésimo número da sequência, escrito como Fn , é dado por
1 se n = 1
Fn = 1 se n = 2 (2.1)
F
n−1 + Fn−2 se n > 2 .
Não fica muito claro pela definição, mas F30 é maior do que 1 milhão, F100 é um número
com 21 dı́gitos e, em geral, Fn ≈ 20.684n . Ou seja, Fn é um valor exponencial em n.
30
O Algoritmo 8 calcula recursivamente Fn para um n dado como entrada e ilustra o
quão ineficiente um algoritmo recursivo pode ser.
Algoritmo 8: FibonacciRecursivo(n)
1 se n ≤ 2 então
2 retorna 1
3 retorna FibonacciRecursivo(n − 1) + FibonacciRecursivo(n − 2)
31
Fn
Fn−1 Fn−2
Algoritmo 9: Fibonacci(n)
1 se n ≤ 2 então
2 retorna 1
3 Seja F [1..n] um vetor de tamanho n
4 F [1] = 1
5 F [2] = 1
6 para i = 3 até n faça
7 F [i] = F [i − 1] + F [i − 2]
8 retorna F [n]
32
realizada. É razoável imaginar que um número de 32 bits ou de 64 bits possa ser
somado com outro rapidamente (os processadores atuais fazem isso), mas o n-ésimo
número da sequência de Fibonacci precisa de uns 0.694n bits para ser armazenado e isso
é bem maior do que 64 conforme n cresce. Essa análise não cuidadosa foi proposital,
pois mesmo com ela podemos ver a diferença entre os dois algoritmos para o problema
do número de Fibonacci. Estritamente falando, o Algoritmo 8 faz cerca de Fn somas
mas usa um número de passos básicos proporcional a nFn .
Esse exemplo clássico mostra como as estruturas de dados podem ter grande impacto
na análise de algoritmos. Na Parte II veremos várias estruturas de dados que devem
ser de conhecimento de todo bom desenvolvedor de algoritmos.
Na Parte III apresentamos diversos algoritmos recursivos para resolver o problema
de ordenação dos elementos de um vetor. Ao longo deste livro muitos outros algoritmos
recursivos serão discutidos.
33
34
Métodos para solução de equações de
recorrência
Em geral, o tempo gasto nos casos base dos algoritmos é constante (Θ(1)), de forma
que é comum descrevemos apenas a segunda parte. Por exemplo, o tempo de execução
T (n) do Algoritmo 7, BuscaBinariaRecursiva, é T (n/2) + 1.
É claro que a informação “o tempo de execução do algoritmo é T (n) = T (n/3) +
T (n/4) + n não nos diz muita coisa. Gostarı́amos portanto de resolver a recorrência,
encontrando uma expressão que não depende da própria função, para que de fato
possamos observar sua taxa de crescimento.
Neste capı́tulo apresentaremos quatro métodos para resolução de recorrências:
(i) substituição, (ii) iterativo, (iii) árvore de recorrência e (iv) mestre. Antes disso,
apresentamos na próxima seção algumas relações matemáticas e somas que surgem
com frequência nesses métodos. O leitor familiarizado com os conceitos apresentados
deve seguir para a seção seguinte, que explica o método iterativo.
Fato 3.1
(i) aloga b = b.
36
(i) aloga b = b. Segue diretamente da definição de logaritmo, uma vez que ax = b se e
somente se x = loga b.
(ii) logc (ab) = logc a + logc b. Como a, b e c são positivos, existem números k e ` tais
que a = ck e b = c` . Assim, temos
(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,
37
Vamos agora verificar como se obter fórmulas para algumas somas que aparecem
com frequência, que são as somas dos termos de progressões aritméticas e a soma dos
termos de progressões geométricas.
Teorema 3.2
Esse fato pode facilmente ser provado por indução em n. Agora considere a soma
38
Pn
i=1 (a1 + (i − 1)r). Temos que
n
X
a1 + (i − 1)r = a1 n + r(1 + 2 + · · · + (n − 1))
i=1
rn(n − 1)
= a1 n +
2
= n a1 + (a1 + r(n − 1))
n(a1 + an )
= ,
2
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
39
Mostraremos inicialmente que T (n) = O(n2 ). Para isso, provaremos por indução
que T (n) ≤ cn2 para c ≥ 1 e n ≥ 1, i.e., mostraremos que
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,
40
assintótica estamos preocupados somente com valores suficientemente grandes de n.
Assim, como T (2) = 2T (1) + 2 = 4 ≤ c × 2 × log 2 para c ≥ 2, vamos assumir que
n ≥ 2, de forma que a base da indução que vamos realizar é n = 2. Suponha agora que,
para 2 ≤ m < n, temos T (m) ≤ cm log m. Precisamos mostrar que T (n) ≤ cn log n.
Temos
T (n) = 2T (n/2) + n
≤ 2 c(n/2) log(n/2) + n
= cn log n − cn + n
≤ cn log n, para c ≥ 1 .
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,
41
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 .
42
Logo, mostramos que T (n) = O(n log n + n) = O(n log n).
Uma observação importante é que no passo indutivo é necessário provar exatamente
o que foi suposto, com a mesma constante. Por exemplo, se queremos mostrar que
T (n) ≤ cn log n e supomos que T (m) ≤ cm log m, mas mostramos no passo indutivo que
T (n) ≤ cn log n + 1, nós não provamos o que nos propusemos. Esse resultado portanto
não implica que T (n) = O(n log n), pois precisarı́amos provar que T (n)c ≤ n log n.
Vimos que, se T (n) = 2T (n/2) + n, então temos T (n) = O(n log n). Porém esse fato
não indica que não podemos diminuir ainda mais esse limite. Para garantir que a ordem
de grandeza de T (n) é n log n, precisamos mostrar que T (n) = Ω(n log n). Utilizando
o método da substituição, mostraremos que T (n) ≥ n log n, de onde concluı́mos que
T (n) = Ω(n log n). A base da indução nesse caso é n = 1, e temos que aqui o resultado
vale pois T (1) = 1 ≥ n log n. Suponha que para todo m, com 2 ≤ m < n, temos
T (m) ≥ m log m. Assim,
T (n) = 2T (n/2) + n
≥ 2 (n/2) log(n/2) + n
= n log n .
43
indutivo, temos
T (n) = 3T (n/3) + 1
≤ cn + 1 ,
o que não prova o que desejamos, pois para completar a prova por indução precisamos
mostrar que T (n) ≤ cn (e não cn + 1, como foi feito).
Acontece que é verdade que T (n) = O(n), mas o problema é que a expressão que
escolhemos para provar nosso palpite não foi “forte” o suficiente. Como corriqueiro
em provas por indução, precisamos fortalecer a hipótese indutiva. Vamos tentar agora
provar que T (n) ≤ cn − d, onde c e d são constantes e d ≥ 1/2. Note que provando
isso estaremos provando que T (n) = O(n) de fato. No passo indutivo, temos
T (n) = 3T (n/3) + 1
cn
≤3 −d +1
3
= cn − 3d + 1
≤ cn − d .
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.
44
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
45
√ √
para concluir que T (n) = O( n log n). A prova de que T (n) = Ω( n log n) é similar à
prova feita para o limitante superior, de modo que a deixamos por conta do leitor.
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 ,
46
onde a penúltima desigualdade vale para n ≥ 8 e a última desigualdade vale sempre
que c ≥ 1/(2 − log 3). Portanto, temos T (n) = O(log n).
Veremos agora uma outra abordagem, onde fortalecemos a hipótese de indução.
Provaremos que T (n) ≤ c log(n − a) para valores apropriados de a e c. No passo da
indução, temos
T (n) = T (bn/2c + 2) + 1
n
≤ c log +2−a +1
2
n−a
= c log +1
2
= c log(n − a) − c + 1
≤ c log(n − a) ,
T (n) = T (n/2) + 1
= (T ((n/2)/2) + 1) + 1 = T (n/22 ) + 2
= (T ((n/22 )/2) + 1) + 2 = T (n/23 ) + 3
..
.
= T (n/2i ) + i .
47
Sabemos que T (1) = 1. Então, tomando i = log n, continuamos a estimativa para
T (n):
T (n) = T (n/2i ) + i
= T (n/2log n ) + log n
= T (1) + log n
= Θ(log n) .
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 .
Como veremos na Parte III, Insertion sort e Mergesort são dois algoritmos que
resolvem o problema de ordenação e têm, respectivamente, tempos de execução de
pior caso T1 (n) = Θ(n2 ) e T2 (n) = 2T (n/2) + n. Como acabamos de verificar, temos
T2 (n) = Θ(n log n), de modo que podemos concluir que, no pior caso, Mergesort é
assintoticamente mais eficiente que Insertion sort.
48
Seguindo a mesma estratégia dos exemplos anteriores, obtemos o seguinte:
T (n) = 2T (n/3) + 1
= 2 2T (n/32 ) + 1 + 1 = 22 T (n/32 ) + (1 + 2)
= 23 T (n/33 ) + (1 + 2 + 22 )
..
.
i−1
X
i i
= 2 T (n/3 ) + 2j
j=0
= 2 T (n/3 ) + 2i − 1 .
i i
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
49
T (n). Note que
T (n) = 2T (n/3) + 1
= 2 2T (n/32 ) + 1 + 1 ≥ 22 T (n/32 ) + 2
≥ 23 T (n/33 ) + 3
..
.
≥ 2i T (n/3i ) + i .
50
Figura 3.1: Árvore de recorrência para T (n) = 2T (n/2) + cn.
temos o custo total em cada nı́vel da recursão. Por fim, no canto inferior direito das
Figuras 3.1 e 3.2 temos a estimativa para o valor das recorrências.
Note que o valor de c não faz diferença no resultado T (n) = O(n log n), de modo
que, quando for conveniente, podemos considerar tais constantes como tendo valor 1.
Geralmente o método da árvore de recorrência é utilizado para fornecer um bom palpite
para o método da substituição, de modo que é permitida uma certa “frouxidão” na
análise. Porém, uma análise cuidadosa da árvore de recorrência e dos custos associados
a cada nı́vel pode servir como uma prova direta para a solução da recorrência em
questão.
51
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 .
52
Figura 3.3: Árvore de recorrência para T (n) = aT (n/b) + f (n).
(1) se f (n) = O(nlogb a−ε ) para alguma constante ε > 0, então T (n) = Θ(nlogb a );
(3) se f (n) = Ω(nlogb a+ε ) para alguma constante ε > 0 e para n suficientemente
grande temos af (n/b) ≤ cf (n) para alguma constante c < 1, então T (n) =
Θ(f (n)).
Mas qual a intuição por trás desse resultado? Imagine um algoritmo com tempo de
execução T (n) = aT (n/b) + f (n). Primeiramente, lembre que a árvore de recorrência
descrita na Figura 3.3 sugere que o valor de T (n) depende de quão grande ou pequeno
f (n) é com relação a nlogb a . Se a função f (n) sempre assume valores “pequenos” (aqui,
pequeno significa f (n) = O(nlogb a−ε )), então é de se esperar que o mais custoso para
o algoritmo seja dividir cada instância do problema em a partes de uma fração 1/b
dessa instância. Assim, nesse caso, o algoritmo vai ser executado recursivamente logb n
vezes até que se chegue à base da recursão, gastando para isso tempo da ordem de
alogb n = nlogb a , como indicado pelo item (1). O item (3) corresponde ao caso em que
f (n) é “grande” comparado com o tempo gasto para dividir o problema em a partes
53
de uma fração 1/b da instância em questão. Portanto, faz sentido que f (n) determine
o tempo de execução do algoritmo nesse caso, que é a conclusão obtida no item (3). O
caso intermediário, no item (2), corresponde ao caso em que a função f (n) e dividir o
algoritmo recursivamente são ambos essenciais no tempo de execução do algoritmo.
Infelizmente, existem alguns casos não cobertos pelo Teorema Mestre, mas mesmo
nesses casos conseguir utilizar o teorema para conseguir limitantes superiores e/ou
inferiores. Entre os casos (1) e (2) existe um intervalo em que o Teorema Mestre não
fornece nenhuma informação, que é quando f (n) é assintoticamente menor que nlogb a ,
mas assintoticamente maior que nlogb a−ε para todo ε > 0, e.g., f (n) = Θ(nlogb a / log n)
ou Θ(nlogb a / log(log n)). De modo similar, existe um intervalo sem informações entre (2)
e (3).
Existe ainda um outro caso em que não é possı́vel aplicar o Teorema Mestre a uma
recorrência do tipo T (n) = aT (n/b)+f (n). Pode ser o caso que f (n) = Ω(nlogb a+ε ) mas
a condição af (n/b) ≤ cf (n) do item (3) não é satisfeita. Felizmente, essa condição é
geralmente satisfeita em recorrências que representam tempo de execução de algoritmos.
Desse modo, para algumas funções f (n) podemos considerar uma versão simplificada
do Teorema Mestre, que dispensa a condição extra no item (3). Veremos essa versão
na Seção 3.5.1.
Antes disso, a seguir temos um exemplo de recorrência que não satisfaz a condição
extra do item (3) do Teorema 3.1. Ressaltamos que é improvável que tal recorrência
descreva o tempo de execução de um algoritmo.
54
Logo, para infinitos valores de n, a constante c precisa ser pelo menos 3/2, e portanto
não é possı́vel obter a condição extra no caso (3). Assim, não há como aplicar o
Teorema Mestre à recorrência T (n) = T (n/2) + n(2 − cos n).
Seja f (n) um polinômio de grau k cujo coeficiente do monômio de maior grau é positivo
(para k constante), i.e., f (n) = ki=0 ai ni , onde a0 , a1 , . . . , ak são constantes e ak > 0.
P
(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.
Primeiro note que como f (n) = ki=0 ai ni = Ω(nlogb a+ε ) temos k = logb a + ε.
P
Resta provar que af (n/b) ≤ cf (n) para algum c < 1. Logo, basta provar que cf (n) −
55
af (n/b) ≥ 0 para algum c < 1. Assim,
k k
X
i
X ni
cf (n) − af (n/b) = c ai n − a ai
i=0 i=0
bi
k−1
a k X a
= ak c− k n + ai c − i ni
b i=0
b
k−1
a X a
≥ ak c − k nk − ai i ni
b i=0
b
k−1
!
a k−1 X
≥ ak c − k nn − a ai nk−1
b i=0
Abaixo mostramos uma segunda prova para o Teorema 3.2. Reformulamos seu
enunciado com base nas seguintes observações. Primeiro, sendo f (n) = ki=0 ai ni , onde
P
a0 , a1 , . . . , ak são constantes e ak > 0, não é difı́cil mostrar que f (n) = Θ(nk ). Segundo,
se Θ(nk ) = O(nlogb a−ε ) para algum ε > 0, então essencialmente estamos assumindo
nk ≤ nlogb a−ε . Mas nlogb a−ε < nlogb a pois ε > 0, ou seja, estamos assumindo nk < nlogb a ,
que equivale a assumir bk < a. Com argumentos semelhantes, assumir Θ(nk ) = Θ(nlogb a )
significa essencialmente assumir bk = a, e assumir Θ(nk ) = Ω(nlogb a+ε ) significa
essencialmente assumir bk > a.
Demonstração. Como T (n) = aT (n/b) + Θ(nk ), isso significa que existem constantes
56
c1 e c2 para as quais vale que:
1. T (n) ≤ aT (n/b) + c1 nk ; e
2. T (n) ≥ aT (n/b) + c2 nk .
logb n a j logb n
X
k k
X a j
T (n) ≤ c1 n = c1 n , (3.3)
j=0
bk j=0
bk
a
(1) se a > bk , temos bk
> 1, e a equação (3.3) pode ser desenvolvida da seguinte
forma:
a logb n+1
!
−1 c1 n k
k b k a logb n+1
T (n) ≤ c1 n a = a −1
bk
−1 bk
−1 bk
c1 nk a logb n+1 ac1 nk a logb n
≤ a = a
bk
− 1 bk b k − 1 bk bk
ac1 nk logb a/bk 0 k n
logb a
= a
n = c n
bk
− 1 bk nlogb bk
= c0 nlogb a ,
onde c0 = (a/bac k
k −1)bk é constante. Ou seja, acabamos de mostrar que se a > b ,
1
a
(2) se a = bk , temos bk
= 1, e a equação (3.3) pode ser desenvolvida da seguinte
57
forma:
c1
onde c0 = 1−a/b k
k é constante. Ou seja, acabamos de mostrar que se a < b , então
T (n) = O(nk ).
Considere agora que o item 2 vale, isto é, T (n) ≥ aT (n/b) + c2 nk . De forma
semelhante, ao analisar a árvore de recorrência para T (n), somando o tempo gasto em
todos os nı́veis, temos que
logb n a j logb n
X
k k
X a j
T (n) ≥ c2 n = c2 n , (3.4)
j=0
bk j=0
bk
de onde vemos que o tempo também depende da relação entre a e bk . Não é difı́cil
mostrar que
58
Exemplo 1. T (n) = 2T (n/2) + n.
Claramente, temos a = 2, b = 2 e f (n) = n. Como f (n) = n = nlog2 2 , o caso
do Teorema Mestre em que esses parâmetros se encaixam é o caso (2). Assim, pelo
Teorema Mestre, T (n) = Θ(n log n).
√
Exemplo 2. T (n) = 4T (n/10) + 5 n.
√
Neste caso temos a = 4, b = 10 e f (n) = 5 n. Assim, logb a = log10 4 ≈ 0, 6.
√
Como 5 n = 5n0,5 = O(n0,6−0,1 ), estamos no caso (1) do Teorema Mestre. Logo,
T (n) = Θ(nlogb a ) = Θ(nlog10 4 ).
√
Exemplo 3. T (n) = 4T (n/16) + 5 n.
√
Note que a = 4, b = 16 e f (n) = 5 n. Assim, logb a = log16 4 = 1/2. Como
√
5 n = 5n0,5 = Θ(nlogb a ), estamos no caso (2) do Teorema Mestre. Logo, T (n) =
√
Θ(nlogb a log n) = Θ(nlog16 4 log n) = Θ( n log n).
(i) nenhuma das três condições assintóticas no teorema é válida para f (n); ou
(ii) f (n) = Ω(nlogb a+ε ) para alguma constante ε > 0, mas não existe c < 1 tal que
af (n/b) ≤ cf (n) para todo n suficientemente grande.
59
Para afirmar que o Teorema Mestre não vale devido à (i), temos que verificar
que valem as três seguintes afirmações: 1) f (n) 6= Θ(nlogb a ); 2) f (n) 6= O(nlogb a−ε )
para qualquer ε > 0; e 3) f (n) 6= Ω(nlogb a+ε ). Lembre que, dado que temos a versão
simplificada do Teorema Mestre (Teorema 3.2), não precisamos verificar o item (ii), pois
essa condição é sempre satisfeita para polinômios f (n) com coeficientes não negativos.
No que segue mostraremos que não é possı́vel aplicar o Teorema Mestre diretamente
a algumas recorrências, mas sempre é possı́vel conseguir limitantes superiores e inferiores
analisando recorrências levemente modificadas.
√
Exemplo 3. T (n) = 3T (n/9) + n log n.
√ √
Começamos notando que a = 3, b = 9 e f (n) = n log n. Logo, nlogb a = n.
√ √
Para todo n suficientemente grande e qualquer constante C vale que n log n ≥ C n.
√ √
Assim, para qualquer ε > 0, temos que n log n = 6 O( n/nε ), de onde concluı́mos
√ √
que a recorrência T (n) não se encaixa no caso (1). Como n log n =6 Θ( n), também
não podemos utilizar o caso (2). Por fim, como log n 6= Ω(nε ) para qualquer ε > 0,
√ √
temos que n log n 6= Ω( nnε ), de onde concluı́mos que o caso (3) do Teorema Mestre
60
também não se aplica.
61
62
Estruturas de dados
Neste capı́tulo veremos as estruturas de dados mais simples e clássicas, que formam a
base para muitos dos algoritmos vistos neste livro.
4.1 Vetor
Um vetor é uma coleção de elementos de um mesmo tipo que são referenciados por um
identificador único. Esses elementos ocupam posições contı́guas na memória, o que
permite acesso direto (em tempo constante – Θ(1)) a qualquer elemento por meio de
um ı́ndice inteiro.
Denota um vetor A com capacidade para m elementos por A[1..m]. Se o vetor
armazena n elementos (seu tamanho), então podemos denotá-lo também por A =
(a1 , a2 , . . . , an ) e A[i] = ai é o elemento que está armazenado na posição i, para todo
1 ≤ i ≤ n. Para quaisquer 1 ≤ i < j ≤ n, denotamos por A[i..j] o subvetor de A que
contém os elementos A[i], A[i + 1], . . . , A[j].
Como já foi discutido na Seção 1.1, o tempo de busca em um vetor de tamanho n é
O(n) pois, no pior caso, precisamos acessar todos os elementos armazenados no vetor.
A inserção de um novo elemento x em um vetor A de tamanho n é feita em tempo
constante Θ(1), pois basta inseri-lo na primeira posição disponı́vel, em A[n + 1]. Já a
remoção de algum elemento do vetor envolve inicialmente uma busca pela posição na
qual o elemento se encontra e, por isso, leva tempo O(n).
É claro que, se o vetor estiver ordenado, então os tempos mencionados acima
mudam. Como vimos, a busca binária nos garante que o tempo de busca em um
vetor de tamanho n é O(log n). A inserção, no entanto, não pode mais ser feita
em tempo constante em uma posição qualquer, pois precisamos garantir que o vetor
continuará ordenado. Assim, potencialmente precisaremos mover vários elementos do
vetor durante uma inserção, de forma que ela leva tempo O(n). De forma similar, a
remoção precisa de tempo O(log n) para que se encontre o elemento no vetor, e ainda
precisa de tempo O(n) para mover os elementos à direita do elemento removido e
manter o vetor ordenado.
O fato do vetor estar ordenado ainda nos permite realizar a operação de encontrar
o k-ésimo menor elemento do vetor em tempo Θ(1). Se o vetor não estiver ordenado,
existe um algoritmo que consegue realizar tal operação em tempo O(n).
68
Figura 4.1: Lista duplamente encadeada circular.
69
Algoritmo 11: InsereNaLista(L, x)
1 x. proximo = L. cabeca
2 se L. cabeca 6= null então
3 L.cabeca . anterior = x
4 L. cabeca = x
5 x. anterior = null
que o procedimento de inserção em uma lista encadeada ordenada levaria tempo O(n),
pois precisarı́amos inserir x na posição correta dentro da lista, tendo que percorrer
toda a lista no pior caso.
O Algoritmo 12 mostra o procedimento RemoveDaLista, que remove um nó x
de uma lista L. Note que o parâmetro passado para o procedimento não é um valor
chave k, mas sim um ponteiro para um nó x. Esse ponteiro pode ser encontrado, por
exemplo, com uma chamada à BuscaNaLista. A remoção é simples, sendo necessário
somente atualizar os ponteiros x. anterior . proximo e x. proximo . anterior, e tendo
cuidado com os casos onde x é a cabeça ou a cauda de L.
70
uma busca por esse elemento, para podermos efetuar a remoção de x. Desse modo, um
procedimento de remoção em uma lista encadeada simples leva tempo Θ(n) no pior
caso.
71
72
Árvores
Árvores são, de certa forma, um conceito estendido de listas ligadas. São estruturas
não lineares constituı́das de nós, onde cada nó x contém um elemento armazenado
em x. chave e pode ter um ou mais ponteiros para outros nós. Mais especificamente,
árvores são estruturas hierárquicas nas quais um nó aponta para os nós abaixo dele
na hierarquia, chamados seus nós filhos. Um nó especial é a raiz, que é o topo da
hierarquia e está presente no nı́vel 0 da árvore. Nós filhos da raiz estão no nı́vel 1,
os nós filhos destes estão no nı́vel 2, e assim por diante. O nı́vel de um nó é definido
formalmente como a menor quantidade de nós que existem entre o nó e a raiz. Um nó
sem filhos é chamado de folha da árvore. Veja na Figura 5.1 um exemplo de árvore e
as devidas nomenclaturas.
Em uma árvore, só temos acesso direto ao nó raiz e qualquer manipulação, portanto,
x nı́vel 0
y z w nı́vel 1
a b c nı́vel 2
d nı́vel 3
Figura 5.1: Exemplo de estrutura árvore com 4 nı́veis e altura 3, onde: (i) x é o nó raiz
(nı́vel 0), (ii) y, z e w são filhos de x, (iii) y é pai de a e b, (iv) a, d, z e c são folhas.
Figura 5.2: Árvore binária quase completa.
deve percorrer os ponteiros entre os nós. Note ainda que existe um único caminho entre
a raiz e uma folha. A distância do caminho raiz-folha mais longo, considerando todas
as folhas, define a altura da árvore. Equivalentemente, a altura de uma árvore é igual
ao maior nı́vel. A altura de um nó x da árvore é definida como a menor quantidade
de nós existentes entre x e uma folha. De outra forma, a altura de x é a altura da
subárvore com raiz em x.
Considerando apenas essas informações, vemos que qualquer busca deve ser feita
percorrendo a árvore toda. Inserções e remoções não estão bem definidas também.
Assim, essencialmente, não ganhamos muita coisa com relação a uma lista ligada.
Uma árvore binária é dita completa se todos os seus nı́veis estão completamente
preenchidos. Note que árvores binárias completas com altura h possuem 2h+1 − 1 nós.
Uma árvore binária com altura h é dita quase completa se os nı́veis 0, 1, . . . , h − 1 têm
todos os nós possı́veis. Na Figura 5.2 temos um exemplo de uma árvore binária quase
completa.
74
5.1 Árvores binárias de busca
Árvores binárias de busca são árvores binárias especiais nas quais, para cada nó x,
todos os nós da subárvore esquerda possuem chaves menores do que x. chave e todos
os nós da subárvore direita possuem chaves maiores do que x. chave. Essa propriedade
é usada justamente para guiar a operação de busca. Assim, se quisermos procurar
um elemento k na árvore, primeiro o comparamos com a raiz: (i) k é igual à chave da
raiz e a busca termina, (ii) k é menor do que a chave da raiz e o problema se reduz
a procurar k na subárvore esquerda, ou (iii) k é maior do que a chave da raiz e o
problema se reduz a procurar k na subárvore direita. Note que o pior caso de uma
busca será percorrer um caminho raiz-folha inteiro, de forma que a busca pode levar
tempo O(h), onde h é a altura da árvore. Agora temos uma potencial melhora com
relação a listas ligadas: pode ser que a árvore tenha altura menor do que o número n
de elementos armazenados nela.
Outras operações possı́veis em árvores de busca que não alteram sua estrutura são:
• Encontrar o menor elemento: basta seguir os filhos esquerdos a partir da raiz até
chegar em um nó que não tem filho esquerdo – este contém o menor elemento da
árvore. Tempo necessário: O(h).
• Encontrar o maior elemento: basta seguir os filhos direitos a partir da raiz até
chegar em um nó que não tem filho direito – este contém o maior elemento da
árvore. Tempo necessário: O(h).
75
30
17 90
4 20 60 97
18 45
37
No caso de remoções, precisamos tomar alguns cuidados extras para garantir que a
árvore continue sendo de busca. Se o nó a ser removido é folha, então não há problemas
76
90 45 37
60 60 45
60 97 37 97 60
45 90 45 90
45 60 90
37 97 37 97
37 90 97
Figura 5.4: Cinco exemplos de árvores formadas pela inserção dos elementos 37, 45, 60,
90 e 97 em diferentes ordens.
e basta removê-lo. Se o nó a ser removido tem um único filho, então temos um caso
simples também e basta substituı́-lo por esse filho. Agora, se o nó x a ser removido tem
dois filhos, precisamos substituı́-lo por algum outro nó que tenha no máximo um filho e
vá manter a propriedade de busca. Um bom candidato para substituir x é seu sucessor:
todos os nós à esquerda de x têm elementos menores do que o sucessor de x e todos
os nós à direita têm elementos maiores. Como o sucessor de x é o nó de menor chave
da subárvore direita de x (pois x tem dois filhos) e o menor nó de uma árvore tem no
máximo um filho (à direita), podemos de fato trocar o nó sucessor com x e prosseguir
removendo x, que passa a ter um único filho. Note que o tempo de execução dessa
operação depende basicamente da operação que encontra o sucessor de um nó (pois
nos outros casos temos simples atualizações de ponteiros), de forma que ela também
leva tempo O(h).
Assim, buscar por um elemento, inserir um novo nó, remover algum nó, encontrar
o k-ésimo menor elemento e encontrar o predecessor ou sucessor de um elemento são
operações que podem ser feitas em tempo O(h) em uma árvore binária de busca, onde
h é a altura da árvore.
Note agora que a inserção é feita “de qualquer forma”, apenas respeitando a
propriedade de busca. Assim, a árvore gerada após um certo número n de inserções
pode ter qualquer formato. Um mesmo conjunto de elementos, dependendo da ordem
na qual são inseridos, pode dar origem a várias árvores diferentes, veja a Figura 5.4
Todas as operações que mencionamos têm tempo O(h) e, como vimos na Figura 5.4,
uma árvore binária de busca com n nós pode ter altura h = n e, portanto, ser tão ruim
quanto uma lista ligada. Uma forma de melhorar os tempos de execução das operações,
portanto, é garantir que a altura da árvore não seja tão grande.
77
5.2 Árvores balanceadas
Uma árvore balanceada garante que sua altura vai ser sempre pequena o suficiente
mesmo depois de várias inserções e remoções. No caso de árvores binárias, se ela tem
altura h então existem no máximo 20 + 21 + 22 + · · · + 2h = 2h+1 − 1 nós. Se n é o
total de nós, então n ≤ 2h+1 − 1, o que implica que h ≥ blog n + 2c − 1. Ou seja, a
menor altura de qualquer árvore binária com n nós é O(log n).
Árvore AVL é uma árvore binária de busca balanceada que mantém a seguinte
propriedade: a diferença entre as alturas da subárvore esquerda e direita de qualquer nó
é no máximo 1. Isso garante que a altura h de qualquer árvore AVL é sempre O(log n).
Árvore Red-Black é uma árvore binária de busca balanceada que também tem
altura O(log n).
Árvore-B é uma árvore de busca balanceada mas que não é binária: cada nó tem
m elementos e m + 1 filhos. Ela garante altura O(log n) também.
78
Pilha
Pilha é uma coleção dinâmica de dados cuja operação de remoção deve remover o
elemento que está na coleção há menos tempo. Essa polı́tica de remoção é conhecida
como “LIFO”, acrônimo para “last in, first out”. Ela é um tipo abstrato de dados
que oferece as operações de adicionar e remover um elemento. Independente da
implementação, é possı́vel realizar ambas em tempo Θ(1).
Existem inúmeras aplicações para pilhas. Por exemplo, verificar se uma palavra é
um palı́ndromo é um procedimento muito simples de se realizar utilizando uma pilha.
Basta inserir as letras em ordem e depois realizar a remoção uma a uma, verificando
se a palavra formada é a mesma que a inicial. Uma outra aplicação (muito utilizada)
é a operação “desfazer”, presente em vários editores de texto. Toda mudança de
texto é colocada em uma pilha, de modo que cada remoção da pilha fornece a última
modificação realizada. Vale mencionar também que pilhas são úteis na implementação
de algoritmos de busca em profundidade em grafos.
Vamos mostrar como implementar uma pilha utilizando um vetor P [1..m] com
capacidade para m elementos. Ressaltamos que existem ainda outras formas de
implementar pilhas. Por exemplo, poderı́amos utilizar listas encadeadas para realizar
essa tarefa.
Dado um vetor P [1..n], o atributo P. topo contém o ı́ndice do elemento que foi
inserido por último, que inicialmente é 0. O atributo P. capacidade contém a capaci-
dade total do vetor, que é m. Em qualquer momento, o vetor P [1..P. topo] armazena
os elementos da pilha em questão, onde P [1] contém o primeiro elemento inserido na
pilha e P [P. topo] contém o último. Note que o tamanho da pilha é dado por P. topo.
Quando inserimos um elemento x na pilha P , dizemos que estamos empilhando x
em P . Similarmente, ao remover um elemento de P nós desempilhamos de P . As duas
operações, Empilha e Desempilha, são dadas nos Algoritmos 14 e 15, respectivamente.
Elas são bem simples e, como dito acima, levam tempo Θ(1) para serem executadas.
Para acrescentar um elemento x à pilha P , utilizamos o procedimento Empilha,
que verifica se a pilha está cheia e, caso ainda haja espaço, atualiza o topo e o tamanho
da pilha e insere x em P [P. topo].
Para desempilhar, basta verificar se a pilha está vazia e, caso contrário, decrementar
de uma unidade o valor de P. topo, retornando o elemento que estava no topo da pilha.
80
Figura 6.1: Operações em uma pilha P inicialmente vazia: Empilha(P , 3), Empilha(P ,
5), Empilha(P , 1), Desempilha(P ), Desempilha(P ), Empilha(P , 8).
81
82
Fila
Fila é uma coleção dinâmica de dados cuja operação de remoção deve remover o elemento
que está na coleção há mais tempo. Essa polı́tica de remoção é conhecida como “FIFO”,
acrônimo para “first in, first out”. Ela é um tipo abstrato de dados que oferece as
operações de adicionar e remover um elemento. Independente da implementação, é
possı́vel realizar ambas em tempo Θ(1).
O conceito de fila é amplamente utilizado em aplicações práticas. Por exemplo,
qualquer sistema que controla a ordem de atendimento em bancos pode ser imple-
mentado utilizando filas. Também são úteis para manter a ordem de documentos que
são enviados a uma impressora. De forma mais geral, filas podem ser utilizadas em
algoritmos que precisam controlar acesso a recursos, de modo que a ordem de acesso é
definida pelo tempo em que o recurso foi solicitado. Outra aplicação é a implementação
de busca em largura em grafos.
Como acontece com pilhas, filas podem ser implementadas de diversas formas.
Vamos mostrar como implementar uma fila utilizando um vetor F [1..m] com capacidade
para m elementos. O atributo F. cabeca contém o ı́ndice para o elemento que está há
mais tempo na fila. O atributo F. cauda contém o ı́ndice para o último elemento que
foi inserido na fila. Inicialmente F. cabeca = F. cauda = 1. Em qualquer momento, se
F. cabeca < F. cauda, então os elementos da fila encontram-se nas posições F. cabeca,
F. cabeca +1, . . . , F. cauda −1, F. cauda. Se F. cabeca > F. cauda, então os elementos
encontram-se nas posições F. cabeca, F. cabeca +1, . . . , F. capacidade, 1, 2, . . . ,
F. cauda. E se F. cabeca = F. cauda, então a fila está vazia. Note ainda que a
fila estará cheia quando F. cabeca = F. cauda +1 ou então quando F. cabeca = 1 e
F. cauda = m. Por isso, as operações de soma e subtração nos valores de F. cabeca e
F. cauda são feitas módulo F. capacidade = m, i.e., podemos enxergar o vetor F de
forma circular.
Quando inserimos um elemento x na fila F , dizemos que estamos enfileirando x
em F . Similarmente, ao remover um elemento de F nós estamos desenfileirando de F .
As duas operações de fila, Enfileira e Desenfileira, são mostradas respectivamente nos
Algoritmos 16 e 17 e levam tempo Θ(1) para serem executadas.
O procedimento Enfileira adiciona um elemento x à fila. Primeiramente é
verificado se a fila está cheia, caso onde nada é feito. Caso contrário, o elemento é
adicionado na posição F. cauda e atualizamos esse valor e o tamanho da fila. Esse
procedimento realiza uma quantidade constante de operações, de modo que é claramente
executado em tempo Θ(1).
84
Algoritmo 17: Desenfileira(F )
1 x = null
2 se F. cabeca 6= F. cauda então
3 x = F [F. cabeca]
4 se F. cabeca == F. capacidade então
5 F. cabeca = 1
6 senão
7 F. cabeca = F. cabeca +1
8 F. tamanho = F. tamanho −1
9 retorna x
Figura 7.1: Operações em uma fila F inicialmente vazia: Enfileira(F , 3), Enfi-
leira(F , 5), Enfileira(F , 1), Desenfileira(F ), Desenfileira(F ), Enfileira(F ,
8).
85
86
Fila de prioridades
Uma fila de prioridades é uma coleção dinâmica de elementos que possuem prioridades
associadas e a operação de remoção deve sempre remover o elemento que possui maior
prioridade. Ela é um tipo abstrato de dados que, além da remoção do elemento de
maior prioridade, também oferece as operações de construção (feita a partir de um
conjunto pré-existente de elementos), busca pelo elemento de maior prioridade, inserção
de um elemento novo e alteração da prioridade de um elemento já armazenado.
É importante perceber que o termo prioridade é usado de maneira genérica: ter
maior prioridade não significa necessariamente que o valor indicativo da prioridade
é o maior. Por exemplo, se falamos de atendimento em um banco e a prioridade de
atendimento é indicada pela idade da pessoa, então tem maior prioridade a pessoa que
tiver maior idade. Por outro lado, se falamos de gerenciamento de estoque de remédios
em uma farmácia e a prioridade de compra é indicada pela quantidade em estoque,
então tem maior prioridade o remédio que estiver em menor quantidade.
Filas de prioridades são muito úteis na implementação de diversos algoritmos
clássicos como Dijkstra, Prim, Huffman e Heapsort.
Podem ser implementadas de diversas formas, como por exemplo um vetor ordenado
pela prioridade dos elementos. Com essa implementação, se a estrutura possui n
elementos, então construir a fila leva tempo O(n log n), inserir e alterar a prioridade de
um elemento levam tempo O(n) e encontrar o elemento de maior prioridade e remover
o elemento de maior prioridade levam tempo Θ(1) cada. No entanto, a implementação
mais comum é por meio da estrutura de dados Heap binário, que permite construção
em tempo O(n), inserção, remoção e alteração de um elemento em tempo O(log n) e
busca pelo elemento de maior prioridade em tempo Θ(1).
No que segue vamos assumir que queremos manter um conjunto de elementos tais
que cada elemento x possui um atributo x. prioridade, que guarda o valor referente à
prioridade do elemento x, e um atributo x. indice, que guarda o ı́ndice do vetor em
que x está armazenado.
Seja H um vetor que armazena n = H. tamanho elementos e tem espaço para
armazenar H. capacidade elementos. Usamos o vetor H para armazenar os elementos
de forma conceitual a uma árvore da seguinte maneira. O elemento na posição i tem
filho esquerdo na posição 2i (se 2i ≤ n), filho direito na posição 2i + 1 (se 2i + 1 ≤ n)
e pai na posição bi/2c (se i > 1). Assim, dizemos que H é um heap se ele satisfaz a
propriedade de heap dada na Definição 8.1, isto é, se para todo i com 2 ≤ i ≤ n, temos
H[bi/2c]. prioridade ≥ H[i]. prioridade, i.e., a prioridade do do pai é sempre maior
ou igual à prioridade de seus filhos.
Note que, ao percorrer o vetor H da esquerda para a direita, estamos acessando
todos os nós do nı́vel ` consecutivamente antes de acessar os nós do nı́vel ` + 1. Além
88
100
19 36
A = ( |{z}
100 , 19, 36 , 17, 8, 25, 1, 2, 7, 5)
| {z } | {z } | {z }
17 8 25 1 nı́vel 0 nı́vel 1 nı́vel 2 nı́vel 3
7 12 5
Figura 8.1: Exemplo de heap binário na forma de árvore binária e vetor. Os valores
indicados são as prioridades dos elementos armazenados. Nesse caso, ter maior valor
equivale a ter maior prioridade.
disso, um elemento na posição i de H tem altura blog(n/i)c e está no nı́vel blog ic.
Veja a Figura 8.1.
Perceba que a propriedade de heap garante que H[1] sempre armazena o elemento
de maior prioridade do heap. Assim, a operação de busca pelo elemento de maior
prioridade se dá em tempo Θ(1). Nas seções seguintes, discutiremos cada uma das
outras quatro operações fornecidas pela estrutura (remoção, inserção, construção e
alteração). Antes disso, precisamos definir dois procedimentos muito importantes que
serão utilizados por todas elas.
As quatro operações fornecidas por uma fila de prioridades podem perturbar a
estrutura, de forma que precisamos ser capazes de restaurar a propriedade de heap se for
necessário. Os procedimentos CorrigeHeapDescendo e CorrigeHeapSubindo,
formalizados nos Algoritmos 18 e 19, respectivamente, e discutidos a seguir, têm
como objetivo restaurar a propriedade de heap quando apenas um dos elementos está
causando a falha da propriedade.
O algoritmo CorrigeHeapDescendo recebe um vetor H e um ı́ndice i tal que as
subárvores enraizadas em H[2i] e H[2i + 1] já são heaps. O objetivo dele é transformar
a árvore enraizada em H[i] em heap. Veja que se H[i] não tem prioridade maior ou
igual à de seus filhos, então basta trocá-lo com o filho que tem maior prioridade para
restaurar localmente a propriedade. Potencialmente, o filho alterado pode ter causado
falha na prioridade também. Por isso, fazemos trocas sucessivas entre pais e filhos até
que atingimos um vértice folha ou até que não tenhamos mais falha na propriedade.
89
100 100 100
2 36 17 36 17 36
17 8 25 1 2 8 25 1 12 8 25 1
7 12 5 7 12 5 7 2 5
Durante essas trocas, os ı́ndices onde os elementos estão armazenados mudam, de forma
que precisamos mantê-los atualizados também. A Figura 8.2 mostra um exemplo de
execução desse algoritmo. O Teorema 8.2 mostra que o CorrigeHeapDescendo de
fato consegue transformar a árvore enraizada em H[i] em um heap.
Demonstração. Seja hx a altura de um nó que está na posição x na heap (isto é,
hx = blog(n/x)c). Vamos provar o resultado por indução na altura hi do nó i.
Quando hi = 0, o nó deve ser uma folha, que por definição são heaps (de tamanho
90
1). O algoritmo não faz nada nesse caso, já que folhas não possuem filhos e, portanto,
está correto.
Suponha que o CorrigeHeapDescendo(H, k) corretamente transforma H[k] em
heap se H[2k] e H[2k + 1] já forem heaps, para todo nó k tal que hk < hi .
Precisamos agora mostrar que CorrigeHeapDescendo(H, i) funciona correta-
mente, i.e., a árvore com raiz H[i] é um heap. Considere uma execução de Corri-
geHeapDescendo(H, i). Note que se H[i] tem prioridade maior ou igual a seus
filhos, então os testes nas linhas 2, 4 e 6 serão falsos e o algoritmo não faz nada, o que
é o esperado nesse caso, uma vez que as árvores com raiz em H[2i] e H[2i + 1] já são
heaps.
Assuma agora que H[i] tem prioridade menor do que a de algum dos seus filhos.
Caso H[2i] seja filho de maior prioridade, o teste na linha 2 será verdadeiro e teremos
maior = 2i. Como maior 6= i, o algoritmo troca H[i] com H[maior] e executa
CorrigeHeapDescendo(H, maior). Como qualquer filho de i tem altura menor do
que a de i, hmaior < hi e sabemos, pela hipótese de indução, que o algoritmo funciona
corretamente, de onde concluı́mos que a árvore com raiz em H[2i] é heap. Como H[i]
tem agora prioridade maior do que as prioridades de H[2i] e H[2i + 1] e a árvore em
H[2i + 1] já era heap, concluı́mos que a árvore com raiz H[i] agora é um heap. A prova
á análoga quando A[2i + 1] é o filho de maior prioridade de H[i].
91
cuja raiz é um filho de H[i]. Mas qual o pior caso possı́vel? No pior caso, se o problema
inicial tem tamanho n, o subproblema seguinte possui tamanho no máximo 2n/3. Isso
segue do fato de possivelmente analisarmos a subárvore cuja raiz é o filho esquerdo
de H[1] (i.e., enraizada em H[2]) e o último nı́vel da árvore estar cheio até a metade.
Assim, a subárvore com raiz no ı́ndice 2 possui aproximadamente 2/3 dos vértices,
enquanto que a subárvore com raiz em 3 possui aproximadamente 1/3 dos vértices.
Em todos os próximos passos, os subproblemas são divididos na metade do tamanho
da instância atual. Como queremos um limitante superior, podemos calcular o tempo
de execução de CorrigeHeapDescendo como:
T (n) ≤ T (2n/3) + 1
≤ T (2/3)2 n + 2
..
.
≤ T (2/3)i n + i
= T n/(3/2)i + i .
T (n) ≤ 1 + log3/2 n
= O(log n) .
92
100 100 125
17 36 17 125 17 100
12 8 125 1 12 8 36 1 12 8 36 1
7 2 5 7 2 5 7 2 5
fazemos trocas sucessivas entre filhos e pais até que atingimos a raiz ou até que não
tenhamos mais falha na propriedade de heap. A Figura 8.3 mostra um exemplo de
execução desse algoritmo. O Teorema 8.3 mostra que o CorrigeHeapSubindo de
fato consegue transformar o subvetor H[1..i] em um heap.
Demonstração. Seja `x o nı́vel de um nó que está na posição x da heap (isto é,
`x = blog xc). Vamos provar o resultado por indução no nı́vel `i do nó i.
Quando `i = 0, o nó deve ser a raiz, H[1], que é um heap (de tamanho 1). O
algoritmo não faz nada nesse caso, pois a raiz não tem pai, e, portanto, está correto.
Suponha que o CorrigeHeapSubindo(H, k) corretamente transforma H[1..k]
em heap se H[1..k − 1] já for heap, para todo k tal que `k < `i .
Considere então uma execução de CorrigeHeapSubindo(H, i). Note que se H[i]
tem prioridade menor ou igual à de que seu pai, então o teste na linha 2 falha e o
93
algoritmo não faz nada, o que é o esperado, uma vez que H[1..i − 1] já é heap.
Assuma então que H[i] tem prioridade maior do que a de seu pai e seja p = bi/2c. O
algoritmo então troca H[i] com H[p] e executa CorrigeHeapSubindo(H, p). Como
o pai de i está em um nı́vel menor do que o nı́vel de i, `p < `i e sabemos, pela hipótese
de indução, que o algoritmo funciona corretamente sobre p. Assim, concluı́mos que
H[1..p] é heap. Como H[i] tem agora prioridade menor ou igual à prioridade de H[p],
H[1..i − 1] já era heap antes e os elementos de H[p + 1..i − 1] não foram mexidos,
concluı́mos que H[1..i] agora é heap.
Suponha que temos um vetor H já preenchido com n = H. tamanho elementos que não
necessariamente é um heap (ele precisa satisfazer a propriedade de heap para isso), o
objetivo do procedimento ConstroiHeap é transformar H em heap.
Note que os últimos bn/2c + 1 elementos de H são folhas e, portanto, são heaps
de tamanho 1. O elemento H[bn/2c], que é o primeiro elemento que tem filhos, pode
não ser uma heap. No entanto, como seus filhos são, podemos utilizar o algoritmo
CorrigeHeapDescendo para corrigir a situação. O mesmo vale para o elemento
H[bn/2c − 1] e todos os outros elementos que são pais de folhas. Com isso teremos
várias heaps de altura 2, de forma que podemos aplicar o CorrigeHeapDescendo
aos elementos pais dessas também. O Algoritmo 20 formaliza essa ideia.
A Figura 8.4 tem um exemplo de execução da rotina ConstroiHeap. Antes
de estimarmos o tempo de execução do algoritmo, vamos mostrar que ele funciona
corretamente. Para isso precisaremos da seguinte invariante de laço.
94
Algoritmo 20: ConstroiHeap(H)
1 para i = 1 até H. tamanho faça
2 H[i]. indice = i
3 para i = bH. tamanho /2c até 1 faça
4 CorrigeHeapDescendo(H, i)
Invariante: ConstroiHeap
Antes de cada iteração do laço para indexado por i, para todo j tal que
i + 1 ≤ j ≤ n = H. tamanho, a árvore enraizada em H[j] é um heap.
Teorema 8.5
Demonstração. Inicialmente temos i = bn/2c, então precisamos verificar se, para todo
j tal que bn/2c + 1 ≤ j ≤ n, a árvore com raiz H[j] é um heap. Perceba que tal árvore
é composta somente pelo elemento H[j], pois como j > bn/2c, o elemento H[j] é folha
e não tem filhos. Assim, de fato a árvore com raiz em H[j] é um heap.
Suponha agora que a invariante é válida imediatamente antes de uma certa iteração
de ı́ndice i do laço para, i.e., para todo j tal que i + 1 ≤ j ≤ n, a árvore com raiz
H[j] é um heap. Precisamos mostrar que a invariante é válida imediatamente antes da
próxima iteração (onde teremos i−1). Se H[i] tem filhos, então esses são raı́zes de heaps
devido à invariante ser válida imediatamente antes da iteração atual. Assim, a chamada
a CorrigeHeapDescendo(H, i) na linha 4 funciona corretamente, transformando a
árvore com raiz H[i] em um heap. Assim, para todo j tal que i ≤ j ≤ n, a árvore com
raiz H[j] é um heap, e essa é justamente a invariante quando considerada imediatamente
antes da próxima iteração. Portanto, a invariante se mantém válida antes de todas as
iterações do laço.
Ao fim da execução do laço temos i = 0, de modo que, pela invariante de laço, a
árvore com raiz em H[1] é um heap.
95
H com n elementos. Uma simples análise permite concluir que T (n) = O(n log n):
o laço para é executado n/2 vezes e, em cada uma dessas execuções, a rotina Cor-
rigeHeapDescendo, que leva tempo O(log n) é executada. Logo, concluı́mos que
T (n) = O(n log n).
Uma análise mais cuidadosa, no entanto, fornece um limitante melhor que O(n log n).
Primeiro vamos observar que em um heap de tamanho n existem no máximo dn/2h+1 e
elementos com altura h. Verificaremos isso por indução na altura h. As folhas são os
elementos com altura h = 0. Como temos n/2 = dn/20+1 e folhas, então a base está
verificada. Seja 1 ≤ h ≤ blog nc e suponha que existem no máximo dn/2h e elementos
com altura h − 1. Note que na altura h existem no máximo metade da quantidade
máxima possı́vel de elementos de altura h − 1. Assim, utilizando a hipótese indutiva, na
altura h temos no máximo dn/2h e/2 elementos, que implica que existem no máximo
dn/2h+1 e elementos com altura h.
blog nc l
X n m
T (n) ≤ C(h + 1)
h=0
2h+1
blog nc blog nc+1 ∞
X h+1 X i X i
= Cn h+1
= Cn i
≤ Cn .
h=0
2 i=1
2 i=1
2i
Note que para todo i ≥ 1, vale que (i + 1)/2i+1 /(i/2i ) < 1. Assim, temos que
∞ ∞
X i Cn X i
T (n) ≤ Cn ≤ 1 = Cn.
i=1
2i 2 i=1
Portanto,
T (n) = O(n) .
96
Figura 8.4: Execução do ConstroiHeap sobre o vetor H = [3, 1, 5, 8, 2, 4, 7, 6, 9].
97
8.1.2 Remoção em um heap binário
Sabendo que o elemento de maior prioridade em um heap H está em H[1], se quisermos
removê-lo, precisamos fazer isso de modo que ao fim da operação H ainda seja um heap.
Dado que H já é heap, podemos tentar remover H[1] sem mexer em muitos outros
elementos, de forma que os algoritmos de correção possam ser facilmente utilizados, se
necessário. A ideia do algoritmo RemoveDaHeap é trocar H[1] com H[H. tamanho],
o que potencialmente destrói a propriedade de heap na posição 1. Como essa é a única
posição que está causando problemas, aplicamos CorrigeHeapDescendo(H, 1) para
restaurar a propriedade. O Algoritmo 21 formaliza essa ideia.
98
Algoritmo 22: InsereNaHeap(H, x)
1 se H. tamanho 6= H. capacidade então
2 H. tamanho = H. tamanho +1
3 x. indice = H. tamanho
4 H[H. tamanho] = x
5 CorrigeHeapSubindo(H, H. tamanho)
99
dele é O(log n).
100
Disjoint Set
Um disjoint set é um tipo abstrato de dados que serve para manter uma coleção de
elementos particionados em grupos disjuntos. Formalmente, dizemos que A1 , A2 , . . . , Am
é uma partição de um conjunto B se para cada Ai temos que Ai ⊆ B, Ai ∩ Aj = ∅
para todo i 6= j e A1 ∪ · · · Am = B. Um disjoint set fornece as operações de criação
de um novo conjunto, união de dois conjuntos existentes e busca pelo conjunto que
contém um determinado elemento.
Uma forma possı́vel de implementar um disjoint set é usando uma árvore para
representar cada conjunto. Cada nó dessa árvore é um elemento do conjunto e pode-se
usar a raiz da árvore como representante do conjunto. Assim, a criação de um novo
conjunto pode ser feita gerando-se uma árvore com apenas um nó, a união pode ser
feita fazendo a raiz de uma árvore apontar para a raiz da outra, e a busca pelo conjunto
que contém um elemento pode ser feita percorrendo o caminho do elemento até a raiz.
Perceba que as duas primeiras operações são eficientes, podendo ser realizadas em
tempo constante, mas a operação de busca pode potencialmente levar tempo O(n) se a
sequência de operações de união que construiu uma árvore criar uma estrutura linear
com n nós.
• Union(x, y): gera um conjunto obtido da união dos conjuntos que contêm os
elementos x e y.
102
Quando a operação de união de dois conjuntos é requerida, fazemos com que o
conjunto de menor tamanho passe a ter o mesmo representante que o conjunto de
maior tamanho. Para isso, acessamos os elementos do conjunto de menor tamanho e
atualizamos seus atributos. Veja o Algoritmo 26.
103
104
Tabelas hash
Suponha que queremos projetar um sistema que armazena dados de funcionários usando
como chave seus CPFs. Basicamente, esse sistema vai precisar fazer inserções, remoções
e buscas (todas dependentes do CPF dos funcionários). Note que podemos usar um
vetor ou lista ligada para isso, porém neste caso a busca é feita em tempo linear, o
que pode ser custoso na prática se o número n de funcionários armazenados for muito
grande. Se usarmos um vetor ordenado, a busca pode ser melhorada para ter tempo
O(log n), mas inserções e remoções passam a ser custosas. Uma outra opção é usar
uma árvore binária de busca balanceada, que garante tempo O(log n) em qualquer uma
das três operações. Uma terceira solução é criar um vetor grande o suficiente para que
ele seja indexado pelos CPFs. Essa estratégia, chamada endereçamento direto, é ótima
pois garante que as três operações serão executadas em tempo Θ(1).
Acontece que um CPF tem 11 dı́gitos, sendo 9 válidos e 2 de verificação, de forma
que podemos ter 910 possı́veis números diferentes (algo na casa dos bilhões). Logo,
endereçamento direto não é viável. Por outro lado, a empresa precisa armazenar a
informação de n funcionários apenas, o que é um valor bem menor. Temos ainda uma
quarta opção: tabelas hash.
Uma tabela hash é uma estrutura de dados que basicamente mapeia chaves a
elementos. Ela implementa eficientemente – em tempo médio O(1) – as operações de
busca, inserção e remoção. Ela usa uma função hash, que recebe como entrada uma
chave (um CPF, no exemplo acima) e devolve um número pequeno (entre 1 e m), que
serve como ı́ndice da tabela que vai armazenar os elementos de fato (que tem tamanho
m). Assim, se h é uma função hash, um elemento de chave k vai ser armazenado
(falando de forma bem geral) na posição h(k).
Note, no entanto, que sendo o universo U de chaves grande (tamanho M ) e o
tamanho m da tabela bem menor do que M , não importa como seja a função h: várias
chaves serão mapeadas para a mesma posição – o que é chamado de colisão. Aliás,
vale mencionar que mesmo se o contrário fosse verdade ainda terı́amos colisões: por
exemplo, se 2450 chaves forem mapeadas pela função hash para uma tabela de tamanho
1 milhão, mesmo com uma distribuição aleatória perfeitamente uniforme, de acordo
com o Paradoxo do Aniversário, existe uma chance de aproximadamente 95% de que
pelo menos duas chaves serão mapeadas para a mesma posição.
Temos então que lidar com dois problemas quando se fala em tabelas hash: (i)
escolher uma função hash que minimize o número de colisões, e (ii) lidar com as colisões,
que são inevitáveis.
Se bem implementada e considerando que os dados não são problemáticos, as
operações de busca, inserção e remoção podem ser feitas em tempo O(1) no caso médio.
106
Algoritmos de ordenação
Algoritmo Bogosort
Nesta parte
Note que estamos considerando um vetor que contém números, mas poderı́amos
facilmente supor que o vetor contém registros e assumir que existe um campo de tipo
comparável em cada registro (que forneça uma noção de ordem, por exemplo numérica
ou lexicográfica).
Dentre caracterı́sticas importantes de algoritmos de ordenação, podemos destacar
duas. Um algoritmo é dito in-place se utiliza somente espaço constante além dos dados
de entrada e é dito estável se a ordem em que chaves de mesmo valor aparecem na
saı́da são a mesma da entrada. Discutiremos essas propriedades e a aplicabilidade e
tempo de execução dos algoritmos que serão apresentados nas seções a seguir. Perceba
que em um vetor ordenado, todos os elementos à esquerda de um certo elemento são
menores ou iguais a ele e todos os elementos à direita são maiores ou iguais a ele. Esse
argumento simples será usado muito nas discussões de corretude dos algoritmos que
veremos.
110
Ordenação por inserção
Dado um vetor A[1..n] com n números, a ideia do Insertion sort é executar n rodadas
de instruções onde, a cada rodada temos um subvetor de A ordenado que contém um
elemento a mais do que o subvetor da rodada anterior. Mais precisamente, ao fim na
i-ésima rodada, o algoritmo garante que o subvetor A[1..i] está ordenado. Sabendo
que o subvetor A[1..i] está ordenado, é fácil “encaixar” o elemento A[i + 1] na posição
correta para deixar o subvetor A[1..i + 1] ordenado: compare A[i + 1] com A[i], A[i − 1],
e assim por diante, até encontrar um ı́ndice j tal que A[j] < A[i + 1], caso em que a
posição correta de A[i + 1] é j, ou até descobrir que A[1] > A[i + 1], caso em que a
posição correta de A[i + 1] é 1. Veja no Algoritmo 27 um pseudocódigo desse algoritmo,
o InsertionSort.
2 5 1 4 3 2 5 1 4 3 1 2 5 4 3 1 2 4 5 3
2 5 5 4 3 1 2 5 5 3 1 2 4 5 5
2 2 5 4 3 1 2 4 5 3 1 2 4 4 5
1 2 5 4 3 1 2 3 4 5
112
11.1.1 Corretude
Vamos mostrar que o InsertionSort funciona corretamente, isto é, que para qualquer
vetor A com n elementos dado na entrada, ele ordena os elementos de A de forma
não-decrescente utilizando uma invariante de laço (veja a Seção 1.1.1 para relembrar
esse conceito).
Invariante: InsertionSort
Antes de cada iteração do laço para indexado por i, o subvetor A[1..i−1] contém
os elementos contidos originalmente em A[1..i − 1] em ordem não-decrescente.
Observe que o item (i) da definição de invariante de laço é válido antes da primeira
iteração, quando i = 2, pois o vetor A[1..i − 1] = A[1] contém somente um elemento e,
portanto, sempre está ordenado. Para verificar o item (ii), suponha que a invariante
vale antes de uma certa iteração (fixe um valor de i qualquer entre 2 e n), isto é, que o
vetor A[1..i − 1] contém os elementos originais em ordem não-decrescente. Note que o
laço enquanto “move” o elemento A[i] para a esquerda para uma posição onde todos
os elementos à sua direita (até a posição i) são maiores do que ele e os elementos à sua
esquerda são menores. Com isso, subvetor A[1..i] fica ordenado e contém os elementos
originalmente naquelas posições, ou seja, a invariante se mantém verdadeira antes da
próxima iteração (quando temos i + 1). Por fim, precisamos mostrar que ao final da
execução o algoritmo ordena todo o vetor A. Note que o laço termina quando i = n + 1,
de modo que a invariante de laço considerada garante que A[1..i − 1] = A[1..n] está
ordenado com todos os elementos originais, de onde concluı́mos que o algoritmo está
correto.
113
dado por
n
X n
X
T (n) = n + 3(n − 1) + ri + 2 (ri − 1) + 1
i=2 i=2
n
X n
X
= 4n − 2 + 3 ri − 2 1
i=2 i=2
n
X
= 2n + 3 ri .
i=2
= 5n − 3
= Θ(n) . (11.1)
Por outro lado, o pior caso do InsertionSort ocorre quando todas as linhas são
executadas o máximo de vezes possı́vel. Veja que isso acontece quando o vetor está
ordenado de modo decrescente, pois o laço enquanto será executado i vezes para cada
114
valor i do laço para, de modo que, nesse caso, ri = i. Assim, temos
n
X
T (n) = 2n + 3 ri
i=2
2
= n + 2n − 6
= Θ(n2 ) . (11.2)
115
Quando vistas de forma separada, todas as instruções de todas as linhas do Inser-
tionSort são executadas em tempo constante, de modo que o que vai determinar
a eficiência do algoritmo é a quantidade de vezes que os laços para e enquanto
são executados. O laço para é claramente executado n − 1 vezes, independente da
entrada, mas a quantidade de execuções do laço enquanto depende da distribuição dos
elementos dentro do vetor A. Se A estiver em ordem decrescente, então as instruções
dentro do laço enquanto são executadas i vezes para cada execução do laço para,
totalizando 1 + 2 + . . . + n − 1 = n(n − 1)/2 = Θ(n2 ) execuções. Porém, se A já estiver
corretamente ordenado no inı́cio, então o laço enquanto é executado somente uma
vez para cada execução do laço para, totalizando n − 1 = Θ(n) execuções, bem menos
que no caso anterior.
Para deixar claro como a análise assintótica pode ser útil para simplificar a análise,
imagine que um algoritmo tem tempo de execução dado por T (n) = an2 + bn + c.
Em análise assintótica queremos focar somente no termo que é relevante para valores
grandes de n. Portanto, na maioria dos casos podemos esquecer as constantes envolvidas
em T (n) (nesse caso, a, b e c). Podemos também esquecer dos termos que dependem
de n mas que não são os termos de maior ordem (nesse caso, podemos esquecer do
termo an). Assim, fica fácil perceber que temos T (n) = Θ(n2 ). Para verificar que essa
informação é de fato verdadeira, basta tomar n0 = 1 e notar que para todo n ≥ n0
temos an2 ≤ an2 + bn + c ≤ (a + b + c)n2 , i.e., fazemos c = a e C = a + b + c na
definição da notação Θ.
Com uma análise similar, podemos mostrar que para qualquer polinômio
k
X
f (n) = ai n i ,
i=1
11.2 Shellsort
O Shellsort é uma variação do Insertion sort que faz comparação de elementos mais
distantes e não apenas vizinhos.
A seguinte definição é muito importante para definirmos o funcionamento desse
116
algoritmo. Dizemos que um vetor está h-ordenado se, a partir de qualquer posição,
considerar todo elemento a cada h posições leva a uma sequência ordenada. Por
exemplo, o vetor A = (1, 3, 5, 8, 4, 15, 20, 7, 9, 6) está 5-ordenado, pois as sequências
de elementos (1, 15), (3, 20), (5, 7), (8, 9) e (4, 6) estão ordenadas. Já o vetor A =
(1, 3, 5, 6, 4, 9, 8, 7, 15, 20) está 3-ordenado, pois (1, 6, 8, 20), (3, 4, 7), (5, 9, 15), (6, 8, 20),
(4, 7), (9, 15) e (8, 20) são sequências ordenadas de elementos que estão à distância 3
entre si. Note que um vetor 1-ordenado está totalmente ordenado.
A ideia do Shellsort é iterativamente h-ordenar o vetor de entrada com uma
sequência de valores de h que termina em 1. Ele usa o fato de que é fácil h0 -ordenar
um vetor que já está h-ordenado, para h0 < h. Esse algoritmo se comporta exatamente
como o Insertion sort quando h = 1. O procedimento Shellsort é formalizado no
Algoritmo 28. Ele recebe o vetor A com n números a serem ordenados e um vetor
H com m inteiros. Ele assume que H mantém uma sequência decrescente de inteiros
menores do que n tal que H[m] = 1.
117
118
Ordenação por intercalação
O algoritmo que veremos nesse capı́tulo usa a ideia de ordenação por intercalação e
faz uso do paradigma de divisão e conquista. Dado um vetor A com n números, esse
algoritmo divide A em duas partes de tamanho bn/2c e dn/2e, ordena as duas partes
recursivamente e depois intercala o conteúdo as duas partes ordenadas em uma única
parte ordenada. Esse algoritmo foi inventado por Jon von Neumann em 1945.
O procedimento, MergeSort, é dado no Algoritmo 29, onde Combina é um
procedimento para combinar duas partes ordenadas em uma só parte ordenada e será
visto com mais detalhes adiante. Como o procedimento recursivamente acessa partes
do vetor, ele recebe A e duas posições inicio e f im, e seu objetivo é ordenar o subvetor
A[inicio..f im]. Assim, para ordenar um vetor A inteiro de n posições, basta executar
MergeSort(A, 1, n).
120
Combina formalizado no Algoritmo 30.
121
Figura 12.2: Execução de Combina(A, p, q, r) sobre o vetor A =
(1, 3, 7, 10, 2, 6, 8, 15, 28, 19, 2) com parâmetros p = 1, q = 4 e r = 8.
são executados ao todo f im − inicio + 1 vezes (podemos notar isso pela quantidade
de valores diferentes que k assume). Se R(n) é o tempo de execução de Combina(A,
inicio, meio, f im) onde n = f im − inicio + 1, então claramente temos R(n) = Θ(n).
Vamos agora analisar o tempo de execução do algoritmo MergeSort quando
ele é utilizado para ordenar um vetor com n elementos. Vimos que o tempo para
combinar as soluções recursivas é Θ(n). Como os vetores em questão são sempre
divididos ao meio no algoritmo MergeSort, seu tempo de execução T (n) é dado
por T (n) = T (bn/2c) + T (dn/2e) + Θ(n). Como estamos preocupados em fazer uma
análise assintótica, podemos substituir Θ(n) por n apenas, pois isso não fará diferença
no resultado obtido. Também podemos desconsiderar pisos e tetos, como visto na
Seção 3.2.1, de forma que o tempo do MergeSort pode ser descrito por
T (n) = 2T (n/2) + n ,
122
Ordenação por seleção
Neste capı́tulo vamos introduzir dois algoritmos para o problema de ordenação que
utilizam a ideia de ordenação por seleção. Em ambos, consideramos uma posição i do
vetor por vez, selecionamos o i-ésimo menor elemento do vetor e o colocamos em i,
posição final desse elemento no vetor ordenado.
Note que todas as linhas são executadas em tempo constante e cada um dos laços
para é executado Θ(n) vezes cada. Como um dos laços está dentro do outro, temos
que o tempo de execução de SelectionSort(A, n) é Θ(n2 ).
Na Figura 13.1 temos um exemplo de execução do algoritmo SelectionSort.
No que segue vamos utilizar a seguinte invariante de laço para mostrar que o
algoritmo SelectionSort funciona corretamente, isto é, para qualquer vetor A e n
dados na entrada, ele corretamente deixa os n elementos de A em ordem não-decrescente.
Invariante: SelectionSort
Teorema 13.2
124
Figura 13.1: Execução de SelectionSort(A, 5) no vetor A = (2, 5, 1, 4, 3).
125
ordenado de modo não-decrescente e contém os maiores elementos de A. Precisamos
mostrar que antes da próxima iteração, quando teremos i − 1, o subvetor A[i..n] estará
ordenado de modo não-decrescente e conterá os maiores elementos de A.
Note que na iteração correspondente a i, o segundo laço para (da linha 3) encontra
o ı́ndice indiceM ax do maior elemento do vetor A[1..i] (isso pode ser formalmente
provado por outra invariante de laço!). Na linha 6, o maior elemento de A[1..i] é trocado
de lugar com o elemento A[i]. Como, pela invariante, todos os elementos de A[i + 1..n]
são maiores do que A[i], temos que A[i..n] está ordenado e contém os maiores elementos
de A, valendo assim a invariante antes da próxima iteração.
Por fim, note que na última vez que a linha é executada, temos i = 1. Assim, pela
invariante de laço, o vetor A[2..n] está ordenado com os maiores elementos de A. Logo,
concluı́mos que o vetor A[1..n] está ordenado.
13.2 Heapsort
O Heapsort, assim como o Selection sort, é um algoritmo que sempre mantém o vetor
de entrada A[1..n] dividido em dois subvetores contı́guos separados por uma posição i,
onde o subvetor da esquerda, A[1..i], contém os menores elementos da entrada ainda
não ordenados e o subvetor da direita, A[i + 1..n], contém os maiores elementos da
entrada já ordenados. A diferença está no fato do Heapsort utilizar a estrutura de
dados heap binário (ou, simplesmente, heap) para repetidamente encontrar o maior
elemento de A[1..i] e colocá-lo na posição i (o Selection sort faz essa busca percorrendo
todo o vetor A[1..i]). Com isso, seu tempo de execução de pior caso é Θ(n log n), como
o Merge sort. Dessa forma, o Heapsort pode ser visto como uma versão mais eficiente
do Selection sort. O Heapsort é um algoritmo in-place, apesar de não ser estável.
Com relação à estrutura heap, o Heapsort faz uso especificamente apenas dos
procedimentos CorrigeHeapDescendo e ConstroiHeap, definidos na Seção 8.1.
Consideraremos aqui que os valores armazenados no vetor A de entrada diretamente
indicam as suas prioridades. Por comodidade, reproduzimos esses dois procedimentos
nos Algoritmos 32 e 33, adaptados com essa consideração das prioridades.
Note que se um vetor A com n elementos é um heap, então A[1] contém o maior
elemento de A[1..n]. O primeiro passo do Heapsort é trocar A[1] com A[n], colocando
assim o maior elemento em sua posição final após a ordenação. Como A era heap,
126
Algoritmo 32: CorrigeHeapDescendo(H, i)
1 maior = i
2 se 2i ≤ H. tamanho e H[2i] > H[maior] então
3 maior = 2i
4 se 2i + 1 ≤ H. tamanho e H[2i + 1] > H[maior] então
5 maior = 2i + 1
6 se maior 6= i então
7 troca H[i] com H[maior]
8 CorrigeHeapDescendo(H, maior)
127
Figura 13.2: Execução de Heapsort(A, 6), com A = (4, 7, 3, 8, 1, 9). Note que a
primeira árvore da figura é o heap obtido por ConstroiHeap(A).
128
Invariante: Heapsort
Teorema 13.2
129
Claramente, esse algoritmo tem tempo de execução O(n log n). De fato, Cons-
troiHeap é feito em tempo O(n). Como são realizadas n − 1 execuções do laço para,
e CorrigeHeapDescendo é executado em tempo O(log n), temos que o tempo total
gasto por Heapsort é O(n log n).
130
Ordenação por troca
14.2 Quicksort
O Quicksort tem tempo de execução de pior caso Θ(n2 ), o que é bem pior que o
tempo O(n log n) gasto pelo Heapsort ou pelo Mergesort. No entanto, o Quicksort
costuma oferece a melhor escolha na prática. De fato, seu tempo de execução esperado
é Θ(n log n) e a constante escondida em Θ(n log n) é bem pequena. Esse algoritmo
também faz uso do paradigma de divisão e conquista, assim como o Mergesort.
Seja A[1..n] um vetor com n elementos. Dizemos que A está particionado com
relação a um elemento, chamado pivô, se os elementos que são menores do que o pivô
estão à esquerda dele, os outros elementos (maiores ou iguais) estão à direita dele.
Perceba que o pivô está em sua posição correta final (com relação ao vetor ordenado).
A ideia do Quicksort é particionar o vetor e recursivamente ordenar as duas partes,
não sendo mais necessário considerar o elemento pivô.
Formalmente, o algoritmo escolhe um elemento pivô qualquer (discutiremos adiante
formas de escolha do pivô). Feito isso, ele particiona o vetor A com relação ao pivô.
Suponha que após a partição o pivô termine na posição x. Assim, todos os elementos
em A[1..x − 1] são menores ou iguais ao pivô e todos os elementos em A[x + 1..n]
são maiores ou iguais ao pivô. O próximo passo é ordenar recursivamente os vetores
A[1..x − 1] e A[x + 1..n], que efetivamente são menores do que o vetor original, pois
removemos ao menos um elemento, o A[x].
O procedimento, Quicksort, é formalizado no Algoritmo 35, onde Particiona é
um procedimento que particiona o vetor com relação a um pivô e será visto com mais
detalhes adiante e Particiona é um procedimento que faz a escolha de um elemento
como pivô. Como Quicksort recursivamente acessa partes do vetor, ele recebe A e
duas posições inicio e f im, e seu objetivo é ordenar o subvetor A[inicio..f im]. Assim,
para ordenar um vetor A inteiro com n elementos, basta executar Quicksort(A, 1,
n).
132
Figura 14.1: Execução de Quicksort(A, 1, 10), onde A = (3, 9, 1, 2, 7, 4, 8, 5, 0, 6).
133
elementos maiores do que o pivô.
Para realmente realizar uma única varredura no vetor, precisamos garantir que a
cada passo o valor de j aumente. Se A[j] é menor ou igual ao pivô, então ele deve ser
colocado próximo aos elementos de A[inicio..i − 1]. Se A[j] é maior do que o pivô, então
ele já estão próximo aos elementos maiores, que estão em A[i..j − 1]. O Particiona é
formalizado no Algoritmo 36.
Invariante: Particiona
Antes de cada iteração do laço para indexado por j, temos pivo = A[f im] e
vale que
Teorema 14.2
134
Figura 14.2: Execução de Particiona(A,
135 1, 7), onde A = (3, 8, 6, 1, 5, 2, 4).
O algoritmo Particiona retorna um ı́ndice x tal que o pivô está na posição
x, todo elemento em A[1..x − 1] é menor ou igual ao pivô, e todo elemento em
A[x + 1..n] é maior que o pivô.
Demonstração. Como o pivô está inicialmente em A[f im], não precisamos nos preocu-
par com a condição pivo = A[f im] na invariante por enquanto, dado que A[f im] só é
alterado após a execução do laço.
Antes da primeira iteração do laço para temos i = inicio e j = inicio, logo as
condições (i) e (ii) são trivialmente satisfeitas.
Suponha que a invariante é válida antes da iteração j do laço para, i.e., A[inicio..i−
1] contém elementos menores ou iguais a pivo e A[i..j − 1] contém elementos maiores do
que pivo. Precisamos provar que ela continua válida imediatamente antes da próxima
iteração, onde teremos j + 1.
Na iteração j do laço, se A[j] > pivo, a única operação feita é alterar j para j + 1,
de modo que agora A[i..j] contém elementos maiores do que pivo e A[inicio..i − 1]
continua contendo elementos menores ou iguais. Portanto, nesse caso a invariante
continua válida para antes da próxima iteração.
Se A[j] ≤ pivo, então trocamos A[i] com A[j], de modo que agora temos que todo
elemento em A[inicio..i] é menor ou igual a pivo e todo elemento em A[i + 1..j] é maior
do que pivo. Feito isso, i é incrementado para i + 1. Assim, a invariante continua
válida para antes da iteração j + 1.
Ao fim da execução do laço, temos j = f im, de modo que o teorema segue
diretamente da validade da invariante de laço e do fato da linha 7 trocar A[i] (que tem
um elemento maior do que pivo) com A[f im].
136
Demonstração. Quando n = 1, o algoritmo não faz nada, funcionando corretamente,
já que um vetor com um elemento está trivialmente ordenado.
Seja A um vetor com n elementos e suponha que o algoritmo funciona corretamente
para vetores com menos do que n elementos.
Note que a linha 4 devolve um ı́ndice x que contém um elemento em sua posição
final na ordenação desejada, todos os elementos de A[inicio..x − 1] são menores ou
iguais a A[x], e todos os elementos de A[x + 1..f im] são maiores do que A[x]. Após
a execução da linha 5, por hipótese de indução, sabemos que A[inicio..x − 1] estará
ordenado (esse vetor certamente tem tamanho menor do que f im − inicio + 1, pois
ao menos o pivô foi desconsiderado). Da mesma forma, após a execução da linha 6,
sabemos que A[x + 1..f im] estará ordenado. Portanto, todo o vetor A fica ordenado
ao final da execução de Quicksort.
T (n) = T (n − 1) + n
= T (n − 2) + n + (n − 1)
..
.
n−1
X
= T (1) + i
i=2
(n + 1)(n − 2)
=1+
2
2
= Θ(n ) .
137
Intuitivamente, conseguimos perceber que esse é o pior caso possı́vel. Formalmente,
o tempo de execução de pior caso é dado por T (n) = max0≤x≤n−1 (T (x)+T (n−x−1))+n.
Vamos utilizar indução para mostrar que T (n) ≤ n2 . Supondo que T (m) ≤ m2 para
todo m < n, obtemos
≤ (n − 1)2 + n
= n2 − (2n − 1) + n
≤ n2 ,
138
para todo n ≥ k e alguma constante d > 0. Começamos notando que T (k) ≤
T (k − 1) + T (1) + k ≤ 2c + k ≤ dk log k + k. Suponha que T (m) ≤ dm log m + m para
todo k < m < n e vamos analisar T (n):
onde a última desigualdade vale se d ≥ k/ log k, pois para tal valor de d temos
d(k − 1)n
dn log k ≥ log(k − 1) + n .
k
Portanto, acabamos de mostrar que T (n) = O(n log n) quando o Quicksort divide o
vetor A sempre em partes de tamanho aproximadamente n/k e (k − 1)n/k.
A ideia por trás desse fato que, a princı́pio, pode parecer contraintuitivo, é que o
tamanho da árvore de recursão é logk/(k−1) n = Θ(log n) e, em cada passo, é executada
uma quantidade de passos proporcional ao tamanho do vetor analisado, de forma que
o tempo total de execução é O(n log n). Com isso, vemos que qualquer divisão que
não deixe um subvetor vazio já seria boa o suficiente para termos um bom tempo de
execução (assintoticamente falando).
O problema da discussão que tivemos até agora é que é improvável que a partição
seja sempre feita da mesma forma em todas as chamadas recursivas. Vamos agora
analisar o que acontece no caso médio, quando cada uma das n! possı́veis ordenações
dos elementos de A tem a mesma chance de ser a ordenação do vetor de entrada A.
Suponha que Particiona sempre retorna a posição f im.
É fácil ver que o tempo de execução de Quicksort é dominado pela quantidade de
operações feitas na linha 4 de Particiona. Seja então X uma variável aleatória que
conta o número de vezes que essa linha é executada durante uma execução completa
do Quicksort, isto é, ela representa o número de comparações feitas durante toda
139
a execução. Pela segunda observação acima, é fácil ver que o tempo de execução do
Quicksort é T (n) ≤ E[X]. Logo, basta encontrar um limitante superior para E[X].
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 ) . (14.1)
i=1 j=i+1
Vamos então calcular P (oi ser comparado com oj ). Comecemos notando que para
oi ser comparado com oj , um dos dois precisa ser o primeiro elemento de Oij =
{oi , oi+1 , . . . , oj } a ser escolhido como pivô. De fato, caso ok , com i < k < j, seja
escolhido como pivô antes de oi e oj , então oi e oj irão para partes diferentes do vetor
ao fim da chamada atual ao algoritmo Particiona e nunca serão comparados durante
toda a execução. Portanto,
P (oi ser comparado com oj ) = P (oi ou oj ser o primeiro a ser escolhido como pivô em Oij )
2
= .
j−i+1
140
Assim, voltando à (14.1), temos
n−1 X
n
X 2
E[X] =
i=1 j=i+1
j−i+1
n−1 X
n
X 1
<2
i=1 k=1
k
n−1
X
= O(log n)
i=1
= O(n log n) .
Portanto, concluı́mos que o tempo médio de execução de Quicksort é O(n log n).
Se, em vez de escolhermos um elemento fixo para ser o pivô, escolhermos um dos
elementos do vetor uniformemente ao acaso, então uma análise análoga a que fizemos
aqui mostra que o tempo esperado de execução dessa versão aleatória de Quicksort
é O(n log n). Assim, sem supor nada sobre a entrada do algoritmo, garantimos um
tempo de execução esperado de O(n log n).
141
142
Ordenação em tempo linear
Vimos, nos capı́tulos anteriores, alguns algoritmos com tempo de execução (de pior
caso ou caso médio) Θ(n log n). Mergesort e Heapsort têm esse limitante no pior caso
e Quicksort possui tempo de execução esperado da ordem de n log n. Note que esses
três algoritmos são baseados em comparações entre os elementos de entrada.
Suponha um algoritmo correto para o problema da ordenação que recebe como
entrada n números. Veja que, por ser correto, ele deve corretamente ordenar qual-
quer uma das n! possı́veis entradas. Suponha que esse algoritmo faz no máximo k
comparações para ordenar qualquer uma dessas entradas. Como uma comparação
tem dois resultados possı́veis (sim ou não), podemos associar uma string binária de
k bits com cada possı́vel execução do algoritmo. Temos, portanto, no máximo 2k
possı́veis execuções diferentes do algoritmo para todas as n! entradas. Pelo Princı́pio
da Casa dos Pombos e porque supomos que o algoritmo está correto, devemos ter
2k ≥ n! (uma execução diferente para cada entrada). Como n! ≥ (n/2)n/2 , temos que
k ≥ (n/2) log(n/2), isto é, k = Ω(n log n).
Pela discussão acima, temos que qualquer algoritmo baseado em comparações
requer Ω(n log n) comparações no pior caso. Portanto, Mergesort e Heapsort são
assintoticamente ótimos.
Algumas vezes, no entanto, sabemos informações extras sobre os dados de entrada.
Nesses casos, é possı́vel obter um algoritmo de ordenação em tempo linear. Obviamente,
tais algoritmos não são baseados em comparações. Para exemplificar, vamos discutir o
algoritmo Counting sort a seguir.
15.1 Counting sort
Assuma que o vetor A de entrada contém somente números inteiros entre 0 e k. Quando
k = O(n), o algoritmo CountingSort é executado em tempo Θ(n). Será necessário
utilizar um vetor extra B com n posições e um vetor C com k posições, de modo que o
algoritmo não é in-place. A ordem relativa de elementos iguais será mantida, de modo
que o algoritmo é estável.
Para cada elemento x em A, o CountingSort verifica quantos elementos de A são
menores ou iguais a x. Assim, o algoritmo consegue colocar x na posição correta sem
precisar fazer nenhuma comparação. O procedimento é formalizado no Algoritmo 37.
144
Figura 15.1: Execução do CountingSort(A, 6), onde A = (3, 0, 5, 4, 3, 0, 1, 2).
145
algoritmo de ordenação em tempo linear, chamado Radix sort, e é essencial para o
funcionamento do Radix sort que o Counting sort seja estável.
146
Técnicas de construção de algoritmos
Infelizmente, não existe uma solução única para todos os problemas computacionais.
Também não existe fórmula que nos ajude a descobrir qual a solução para um problema.
Uma abordagem prática é discutir técnicas que já foram utilizadas antes e que possam
ser aplicadas a vários problemas, na esperança de poder reutilizá-las ou adaptá-las aos
novos problemas. Veremos os três principais paradigmas de projeto de algoritmos, que
são estratégias gerais para solução de problemas.
A maioria dos problemas que consideraremos nesta parte são problemas de oti-
mização. Em geral, um problema desses possui um conjunto de restrições que define o
que é uma solução viável e uma função objetivo que determina o valor de cada solução.
O objetivo é encontrar uma solução ótima, que é uma solução viável com melhor valor
de função objetivo (maximização ou minimização).
150
Divisão e conquista
• Soluções dos subproblemas menores são combinadas para formar uma solução do
problema inicial.
5678
×1234
2 2712
17 0340
+11 3 5600
56 7 8000
70 0 6652
A seguir provamos que ele está de fato correto, isto é, para quaisquer dois inteiros
x e y, ele retorna xy. Seja y = y1 y2 . . . yn , onde yi é um dı́gito de 0 a 9. Note que o
algoritmo faz
152
c = 12 e d = 4. Podemos então escrever
153
Algoritmo 39: Karatsuba(x, y, n)
1 se n == 1 então
2 retorna xy
3 Seja x = 10n/2 a + b e y = 10n/2 c + d, onde a, b, c e d são números com n/2
dı́gitos cada
4 p1 = MultiplicaInteiros(a, c, n/2)
5 p2 = MultiplicaInteiros(a, d, n/2)
6 p3 = MultiplicaInteiros(a + b, c + d, n/2 + 1)
7 retorna 10n p1 + 10n/2 (p3 − p1 − p2 ) + p2
154
Algoritmos gulosos
Um algoritmo guloso é aquele que constrói uma solução através de uma sequência de
decisões que visam o melhor cenário de curto prazo, sem garantia de que isso levará ao
melhor resultado global. Algoritmos gulosos são muito usados porque costumam ser
rápidos e fáceis de implementar. Em geral, é fácil descrever um algoritmo guloso que
forneça uma solução viável e tenha complexidade de tempo fácil de ser analisada. A
dificuldade normalmente se encontra em provar se a solução obtida é de fato ótima.
Na maioria das vezes, inclusive, elas não são ótimas, mas em alguns casos é possı́vel
mostrar que elas têm valor próximo ao de uma solução ótima.
Neste capı́tulo veremos diversos algoritmos que utilizam esse paradigma. Também
são gulosos alguns algoritmos clássicos em grafos como Prim (Seção 21.2), Kruskal
(Seção 21.1) e Dijkstra (Seção 23.1.1).
Note como temos escolhas a fazer: tarefas que sejam compatı́veis com as tarefas
já escolhidas. Como a intenção é escolher o maior número de tarefas, talvez uma
boa escolha seja por uma tarefa que acabe o quanto antes (escolha gulosa). Esse
procedimento de sempre escolher a tarefa que termina primeiro (com menor valor fi ) é
descrito no Algoritmo 40. Ele mantém um conjunto S de tarefas escolhidas.
Note que o primeiro passo do algoritmo é ordenar as tarefas de acordo com o tempo
final e renomeá-las, de forma que em t1 temos a tarefa que termina primeiro. Essa é a
primeira escolha do algoritmo. Em seguida, dentre as tarefas restantes, são escolhidas
apenas aquelas que começam após a última tarefa escolhida. Dessa forma, garantimos
156
que estamos escolhendo apenas tarefas compatı́veis. Assim, o conjunto S devolvido é
de fato uma solução viável para o problema. O Lema 17.2 mostra que na verdade S é
uma solução ótima.
Lema 17.2
Demonstração. Denote por Tk = {ti ∈ T : si ≥ fk }, isto é, o conjunto das tarefas que
começam após o fim da tarefa tk . Seja tx ∈ Tk uma tarefa que termina primeiro em Tk
(com menor fi em Tk ). Note que EscalonaCompativel escolhe tx . Vamos supor que
essa escolha não está presente em nenhuma solução ótima, isto é, se Sk ⊆ Tk é uma
solução ótima para Tk , então tx ∈
/ Sk .
Seja ty ∈ Sk uma tarefa que termina primeiro em Sk (com menor fi em Sk ). Monte
o conjunto Sk0 = (Sk \ {ty }) ∪ {tx }. Note que, como ambas tx e ty estão em Tk , temos
que fx ≤ fy . E como fy ≤ fz para qualquer tz ∈ Sk , temos que Sk0 é uma solução
viável para Tk (é um conjunto de tarefas mutuamente compatı́veis). Mas note que
|Sk | = |Sk0 |, de forma que Sk0 deve, portanto, ser solução ótima para Tk também, o que
é uma contradição. Ou seja, a escolha gulosa está de fato presente em uma solução
ótima.
Com relação ao tempo de execução, note que as linhas 1 e 2 levam tempo Θ(n log n)
para serem executadas (podemos usar, por exemplo, o algoritmo Mergesort para ordenar
as tarefas). O laço para da linha 5 claramente leva tempo total Θ(n) para executar,
pois analisamos todas as tarefas fazendo operações de tempo constante. Assim, o tempo
desse algoritmo é dominado pela ordenação das tarefas, tendo tempo total portanto de
Θ(n log n).
157
Figura 17.2: Instância do problema da mochila onde W = 50, v1 = 60, w1 = 10,
v2 = 100, w2 = 20, v3 = 120 e w3 = 30.
158
o algoritmo não encontra a solução ótima basta mostrar um exemplo no qual ele falha.
Note que a estratégia anterior falha porque a escolha pelo valor ignora totalmente
outro aspecto do problema, que é a restrição do peso da mochila. Intuitivamente, o que
queremos é escolher itens de maior valor que ao mesmo tempo tenham pouco peso, isto
é, que tenham melhor custo-benefı́cio. Assim, uma outra estratégia gulosa é sempre
escolher o item com a maior razão wv (valor/peso). No exemplo da Figura 17.2, temos
v1
w1
= 6, wv22 = 5 e wv33 = 4, de forma que essa estratégia funcionaria da seguinte forma.
O item com a maior razão valor/peso é o item 1 e ele cabe inteiro na mochila, portanto
faça f1 = 1. Temos agora capacidade restante de 40. O próximo item de maior razão
valor/peso é o item 2 e ele também cabe inteiro na mochila atual, portanto faça f2 = 1.
Temos agora capacidade restante de peso 20. O próximo item de maior razão é o item
3, mas ele não cabe inteiro. Pegamos então a maior fração possı́vel dele que caiba, que
é 23 , portanto faça f3 = 23 . Veja que essa é de fato a solução ótima do exemplo dado.
Isso não prova que a estratégia escolhida é ótima, no entanto. Devemos fazer uma
demonstração formal se suspeitarmos que nossa estratégia é ótima. Essa, no caso, de
fato é (veja o Lema 17.2). O algoritmo usa essa estratégia está descrito formalmente
no Algoritmo 41.
159
v1
w1
≥ wv22 ≥ · · · ≥ wvnn . Assim, o item 1 tem a maior razão valor/peso. Mantemos
uma variável capacidade para armazenar a capacidade restante da mochila. No laço
enquanto da linha 5 o algoritmo seleciona itens inteiros (fi = 1) na ordem da razão
valor/peso enquanto eles couberem inteiros na mochila (wi ≤ capacidade). O próximo
item, se existir, é pego de fracionadamente (linha 10). Nenhum outro item é considerado,
tendo fi = 0 (laço da linha 11). Note que a solução gerada é de fato viável, tem custo
Pn Pn
i=1 f [i]vi e vale que i=1 f [i]wi = W .
Lema 17.2
2. f 0 [i] = f [i];
3. f 0 [j], para i < j ≤ n, recebe valores de f ∗ [j] corrigidos apropriadamente para que
wi (f 0 [i] − f ∗ [i]) = nj=i+1 wj (f ∗ [j] − f 0 [j]) para manter f 0 uma solução viável.
P
160
Por construção,
n
X Xi−1 n
X
0 ∗ 0
f [j]vj = ( f [j]vj ) + f [i]vi + f 0 [j]vj
j=1 j=1 j=i+1
Xn X n n
X
∗ ∗ ∗ 0
=( f [j]vj − f [i]vi − f [j]vj ) + f [i]vi + f 0 [j]vj
j=1 j=i+1 j=i+1
n
X n
X
= f ∗ [j]vj + vi (f 0 [i] − f ∗ [i]) − vj (f ∗ [j] − f 0 [j])
j=1 j=i+1
n n
X wi X wj
= f ∗ [j]vj + vi (f 0 [i] − f ∗ [i]) − vj (f ∗ [j] − f 0 [j])
j=1
wi j=i+1 wj
n n
X
∗ vi 0 ∗
X vi ∗
≥ f [j]vj + (f [i] − f [i])wi − (f [j] − f 0 [j])wj (17.1)
j=1
w i j=i+1
w i
n n
!
X v i
X
= f ∗ [j]vj + f 0 [i] − f ∗ [i])wi − (f ∗ [j] − f 0 [j])wj
j=1
w i j=i+1
n
X
= f ∗ [j]vj , (17.2)
j=1
v
onde (17.1) vale porque wvii ≥ wjj e (17.2) vale pelo item 3 da construção de f 0 . Com
isso, concluı́mos que f 0 não é pior do que f ∗ . De fato, como f ∗ é ótima, concluı́mos
que f 0 também deve ser. Fazendo essa transformação repetidamente chegaremos a f , e,
portanto, f também deve ser ótima.
Com relação ao tempo de execução, note que a linha 1 leva tempo Θ(n log n) para
ser executada (usando, por exemplo, o Mergesort para fazer a ordenação). Os dois
laços do algoritmo levam tempo total Θ(n), pois apenas fazemos operações constantes
para cada item da entrada. Assim, o tempo desse algoritmo é dominado pela ordenação,
tendo tempo total portanto de Θ(n log n).
161
Figura 17.3: Árvores representando três códigos diferentes para o alfabeto A =
{a, b, c, d}.
Por exemplo, suponha que o alfabeto é A = {a, b, c, d}. Poderı́amos usar um código
de largura fixa, fazendo a = 00, b = 01, c = 10 e d = 11. Assim, a sequência “acaba”
pode ser representada em binário por “0010000100”. Mas note que a letra a aparece
bastante nessa sequência, de modo que talvez utilizar um código de largura variável
seja melhor. Poderı́amos, por exemplo, fazer a = 0, b = 01, c = 10 e d = 1, de forma
que a sequência “acaba” ficaria representada por “0100010”. No entanto, “0100010”
poderia ser interpretado também como “baaac”, ou seja, esse código escolhido possui
ambiguidade. Perceba que o problema está no fato de que o bit 0 pode tanto representar
a letra a como o prefixo do código da letra b. Podemos nos livrar desse problema
utilizando um código de largura variável que seja livre de prefixo. Assim, podemos
fazer a = 0, b = 10, c = 110 e d = 111.
Vamos representar os códigos de um alfabeto A por uma árvore binária onde existe
o rótulo 0 nas arestas que levam a filhos da esquerda, rótulo 1 nas arestas que levam
a filhos da direita e existem rótulos em alguns nós com os sı́mbolos de A. Assim, o
código formado no caminho entre a raiz e o nó rotulado por um sı́mbolo i ∈ A é o
código binário desse sı́mbolo. Note que uma árvore como a descrita acima é livre de
prefixo se e somente se os nós rotulados são folhas. Veja a Figura 17.3 para exemplos.
162
Figura 17.4: Construção de árvores representativas de códigos binários tendo inı́cio
com n = |A| árvores triviais.
No que seque, seja n = |A|. Uma forma de construir uma árvore pode ser partir
de n árvores que contêm um único nó cada, um para cada i ∈ A, e repetitivamente
escolher duas árvores e uni-las por um novo nó pai sem rótulo até que se chegue em
uma única árvore. Veja na Figura 17.4 três exemplos simples.
Note que independente de como as árvores são escolhidas, são feitas exatamente
n − 1 uniões para gerar a árvore final. O ponto importante desse algoritmo é decidir
quais duas árvores serão escolhidas para serem unidas em um certo momento. Veja que
nossa função de custo envolve multiplicar a frequência do elemento pelo nı́vel em que
ele aparece na árvore. Assim, intuitivamente, parece bom manter os elementos de maior
163
Figura 17.5: Exemplo de execução de Huffman com A = {a, b, c, d}, fa = 60, fb = 25,
fc = 10 e fd = 5. O custo final da árvore é c(T ) = fa + 2fb + 3fc + 4fd .
frequência próximos à raiz. Vamos associar a cada árvore um certo peso. Inicialmente,
esse peso é a frequência do elemento que rotula os nós. Quando escolhemos duas árvores
e a unimos, associamos à nova árvore a soma dos pesos das duas que a formaram.
Assim, uma escolha gulosa bastante intuitiva é selecionar as duas árvores de menor
peso sempre. Veja que no inı́cio isso equivale aos dois elementos de menor frequência.
Essa ideia encontra-se formalizada no Algoritmo 42, conhecido como algoritmo de
Huffman. Um exemplo de execução é dado na Figura 17.5.
Note que o algoritmo pode ser facilmente implementado em tempo Θ(n2 ) no pior
164
caso: existem Θ(n) chamadas recursivas pois essa é a quantidade total de uniões que
faremos, e uma chamada pode levar tempo Θ(n) para encontrar os dois elementos de
menor frequência (procurando-os de maneira simples dentre todos os disponı́veis). Uma
forma de melhorar esse tempo é usando uma estrutura de dados apropriada. Note que
a operação que mais leva tempo é a de encontrar os dois elementos de menor frequência.
Assim, podemos usar a estrutura heap, que fornece remoção do elemento de maior
prioridade (no caso, o de menor frequência) em tempo O(log n) sobre um conjunto de
n elementos. Ela também fornece inserção em tempo O(log n), o que precisa ser feito
quando o novo sı́mbolo é criado e sua frequência definida como a soma das frequências
dos elementos anteriores (linhas 4 e 5). Assim, o tempo total do algoritmo melhora
para Θ(n log n) no pior caso.
Até agora, o que podemos afirmar é que o algoritmo de Huffman de fato calcula uma
árvore binária que representa códigos binários livres de prefixo de um dado alfabeto.
Veja que, por construção, os nós rotulados são sempre folhas. O Lema 17.3 mostra que
na verdade a estratégia escolhida por Huffman sempre gera uma árvore cujo custo é o
menor possı́vel dentre todas as árvores que poderiam ser geradas dado aquele alfabeto.
Lema 17.3
Demonstração. É fácil perceber que árvore binária T devolvida pelo algoritmo possui
apenas folhas rotuladas com elementos de A. Vamos mostrar por indução em n = |A|
que c(T ) é mı́nimo.
Quando n = 2, a árvore construı́da pelo algoritmo é claramente ótima. Suponha
que o algoritmo constrói uma árvore ótima para qualquer alfabeto de tamanho menor
do que n, dadas as frequências dos elementos.
Seja n > 2 e A um alfabeto com n elementos. Sejam a, b ∈ A os dois elementos de
menor frequência em A. Construa A0 a partir de A substituindo ambos a e b por um
novo elemento ab e defina a frequência desse novo elemento como sendo fab = fa + fb .
Note que existe uma bijeção entre “árvores cujas folhas são rotuladas com sı́mbolos
de A0 ” e “árvores cujas folhas são rotuladas com sı́mbolos de A onde a e b são irmãos”.
165
Vamos chamar o conjunto de árvores desse último tipo de Ta,b . Seja T̂ 0 uma árvore cujas
folhas são rotuladas com sı́mbolos de A0 e seja T̂ uma árvore de Ta,b . Por definição,
X
c(T̂ ) = fi dT̂ (i) + fa dT̂ (a) + fb dT̂ (b) ,
i∈A\{a,b}
e
X
c(T̂ 0 ) = fi dT̂ 0 (i) + fab dT̂ 0 (ab) .
i∈A0 \{ab}
Além disso, dT̂ (a) = dT̂ (b) = dT̂ 0 (ab) + 1 e fab = fa + fb , por construção. Então temos
c(T̂ ) − c(T̂ 0 ) = fa + fb , o que independe do formato das árvores.
Agora note que, por hipótese de indução, o algoritmo encontra uma árvore T 0
que é ótima para A0 (isto é, minimiza c(T 0 ) dentre todas as árvores para A0 ). Então
diretamente pela observação acima, a árvore correspondente T construı́da para A é
ótima dentre as árvores contidas em Ta,b . Com isso, basta mostrar que existe uma
árvore ótima para A (dentre todas as árvores para A) que está contida em Ta,b para
provar que T é de fato ótima para A.
Seja T ∗ qualquer árvore ótima para A e sejam x e y nós irmãos no maior nı́vel de
T ∗ . Crie uma árvore T̄ a partir de T ∗ trocando os rótulos de x com a e de y com b.
Claramente, T̄ ∈ Ta,b . Seja B = A \ {x, y, a, b}. Temos, por definição,
X
c(T ∗ ) = fi dT ∗ (i) + fx dT ∗ (x) + fy dT ∗ (y) + fa dT ∗ (a) + fb dT ∗ (b) ,
i∈B
e
X
c(T̄ ) = fi dT ∗ (i) + fx dT ∗ (a) + fy dT ∗ (b) + fa dT ∗ (x) + fb dT ∗ (y) .
i∈B
Assim,
c(T ∗ ) − c(T̄ ) = fx (dT ∗ (x) − dT ∗ (a)) + fy (dT ∗ (y) − dT ∗ (b)) + fa (dT ∗ (a) − dT ∗ (x)) + fb (dT ∗ (b) − dT ∗ (y))
= (fx − fa )(dT ∗ (x) − dT ∗ (a)) + (fy − fb )(dT ∗ (y) − dT ∗ (b)) .
166
Pela nossa escolha, dT ∗ (x) ≥ dT ∗ (a), dT ∗ (y) ≥ dT ∗ (b), fa ≤ fx e fb ≤ fy . Então,
c(T ∗ ) − c(T̄ ) ≥ 0, isto é, c(T ∗ ) ≥ c(T̄ ), o que só pode significar que T̄ também é
ótima.
167
168
Programação dinâmica
170
18.1 Sequência de Fibonacci
A sequência 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 . . . é conhecida como sequência de Fibonacci.
Por definição, o n-ésimo número da sequência, escrito como Fn , é dado por
1 se n = 1
Fn = 1 se n = 2 (18.1)
F
n−1 + Fn−2 se n > 2 .
171
temos que
T (2) ≥ x2 ,
√
para todo x ≥ 3 ≈ 1, 732.
Suponha que T (m) ≥ xn para todo 2 ≤ m ≤ n − 1. Assim, aplicando isso a T (n)
temos
T (n) = T (n − 1) + T (n − 2) + 1
≥ xn−1 + xn−2
≥ xn−2 (1 + x) .
√ √
Note que 1 + x ≥ x2 sempre que (1 − 5)/2 ≤ x ≤ (1 + 5)/2. Portanto, fazendo
√
x = (1 + 5)/2 e substituindo em T (n), obtemos
√ !n−2 √ !!
1+ 5 1+ 5
T (n) ≥ 1+
2 2
√ !n−2 √ !2
1+ 5 1+ 5
≥
2 2
√ !n
1+ 5
=
2
≈ (1, 618)n .
172
Algoritmo 44: Fibonacci-TopDown(n)
1 Cria vetor F [1..n] global
2 para i = 1 até n faça
3 F [i] = −1
4 retorna FibonacciRecursivo-TopDown(n)
173
Algoritmo 46: Fibonacci-BottomUp(n)
1 se i ≤ 2 então
2 retorna 1
3 Seja F [1..n] um vetor de tamanho n
4 F [1] = 1
5 F [2] = 1
6 para i = 3 até n faça
7 F [i] = F [i − 1] + F [i − 2]
8 retorna F [n]
Considere uma barra de tamanho 6 com preços dos pedaços dados por:
p1 p2 p3 p4 p5 p6
3 8 14 15 10 20
Temos várias possibilidades de cortá-la e vender os pedaços. Por exemplo, se a barra
for vendida sem nenhum corte, então temos lucro 20. Caso cortemos um pedaço de
tamanho 5, então a única possibilidade é vender uma parte de tamanho 5 e outra de
tamanho 1, o que fornece um lucro de p5 + p1 = 13. Caso efetuemos um corte de
tamanho 4, o que aparentemente é uma boa opção (dado que p4 é um valor alto), então
o melhor a se fazer é vender uma parte de tamanho 4 e outra de tamanho 2, obtendo
174
lucro p4 + p2 = 23. Outra opção ainda seria vendermos dois pedaços de tamanho 3,
obtendo lucro total de 2p3 = 28. De todas as possibilidades, queremos a que permita o
maior lucro possı́vel que, nesse caso, é de fato 28.
Veja que é relativamente fácil resolver esse problema: basta enumerar todas as
formas possı́veis de cortar a barra, calcular o custo de cada forma e guardar o melhor
valor possı́vel. No entanto, existem 2n−1 formas diferentes de cortar uma barra de
tamanho n pois, para cada ponto que está à distância i do extremo da barra, com
1 ≤ i ≤ n − 1, temos a opção de cortar ali ou não. Além disso, para cada forma
diferente de cortar a barra, levamos tempo O(n) para calcular seu custo. Ou seja, esse
algoritmo leva tempo O(n2n ) para encontrar uma solução ótima para o problema.
Um algoritmo que enumera todas as possibilidades de solução, testa sua viabilidade
e calcula seu custo é chamado de algoritmo de algoritmo de força bruta. Eles utilizam
muito esforço computacional para encontrar uma solução e ignoram quaisquer estruturas
combinatórias do problema. Vamos então fazer algumas observações sobre a forma de
uma solução para tentar construir outro algoritmo para o problema do corte de barras.
Seja Lm o maior lucro obtido ao cortar uma barra de tamanho m dados os preços
pi de venda das barras de tamanho i. Claramente, L0 = 0. Note que se cortarmos
um pedaço de tamanho i, com 1 ≤ i ≤ n, então temos uma barra de tamanho n − i
restante. Ou seja, reduzimos o tamanho do problema: de uma barra de tamanho n
para uma de tamanho n − i. Note ainda que se o pedaço de tamanho i está em uma
solução ótima, então o lucro total é dado por Ln = pi + Ln−i , que é o preço do pedaço
de tamanho i somado ao maior lucro possı́vel obtido com a venda do restante da barra,
que tem tamanho n − i. Como não sabemos exatamente qual é o valor de i, podemos
simplesmente tentar todos os valores de i possı́veis. Portanto, temos
A igualdade (18.2) sugere um algoritmo bem simples recursivo para resolver o problema,
mostrado no Algoritmo 47.
Com uma indução simples em n e usando a Equação 18.2, podemos mostrar que o
algoritmo CorteBarras está de fato correto. Apesar disso e de ser um algoritmo
intuitivo, ele é extremamente ineficiente, pois muito trabalho é repetido. De fato,
seja T (n) o tempo de execução de CorteBarras(n, p). Claramente, T (0) = 1 e
175
Algoritmo 47: CorteBarras(n, p)
1 se n == 0 então
2 retorna 0
3 lucro = −1
4 para i = 1 até n faça
5 valor = pi + CorteBarras(n − i, p)
6 se valor > lucro então
7 lucro = valor
8 retorna lucro
T (n) = 1 + ni=1 T (n − i). Vamos utilizar o método da substituição para provar que
P
Assim, esse algoritmo não é tão melhor se comparado ao algoritmo de força bruta.
De fato, esse algoritmo repete muito trabalho porque vários subproblemas são
resolvidos recursivamente diversas vezes. No entanto, existem apenas n−1 subproblemas
diferentes: o de calcular Ln−1 , o de calcular Ln−2 , e assim por diante, até o de calcular
L1 . Podemos então, com programação dinâmica, utilizar um vetor simples para
armazenar os valores de cada um desses subproblemas e acessar o valor diretamente
quando necessário. O Algoritmo 48 é uma variação de CorteBarras que, cada vez
que um subproblema é resolvido, o valor é salvo em um vetor B. Ele foi escrito com a
abordagem top-down. O algoritmo também mantém um vetor S tal que S[j] contém o
primeiro lugar onde deve-se efetuar um corte em uma barra de tamanho j.
176
Algoritmo 49: CorteBarrasRecursivo-TopDown(m, p)
1 se B[m] == −1 então
2 lucro = −1
3 para i = 1 até m faça
4 valor = pi + CorteBarrasRecursivo-TopDown(m − i, p)
5 se valor > lucro então
6 lucro = valor
7 S[m] = i
8 B[m] = lucro
9 retorna B[m]
T (m) = 1 + 2 + · · · + m = Θ(m2 ) .
177
Note que o algoritmo apenas retorna o lucro obtido pelos cortes da barra. Caso
precisemos de fato construir uma solução (descobrir o tamanho dos pedaços em que a
barra foi cortada), podemos utilizar o vetor S. Para cortar uma barra de tamanho n e
obter seu lucro máximo B[n], cortamos um pedaço S[n] da mesma, o que significa que
sobrou um pedaço de tamanho n − S[n]. Para cortar essa barra de tamanho n − S[n]
e obter seu lucro máximo B[n − S[n]], cortamos um pedaço S[n − S[n]] da mesma.
Essa ideia é sucessivamente repetida até que tenhamos uma barra de tamanho 0. O
procedimento é formalizado no Algoritmo 50.
178
18.3 Mochila inteira
O problema da mochila é um dos clássicos em computação. Nessa seção veremos a
versão da mochila inteira. A Seção 17.2 apresenta a versão da mochila fracionária.
179
Se n ∈ / S ∗ , perceba que S ∗ ⊆ {1, 2, . . . , n−1} é uma solução possı́vel para a instância
(In−1 , v, w, W ). Em particular, ela deve ser ótima para tal instância. Isto porque se
houvesse outra solução S 0 ⊆ In−1 cujo valor i∈S 0 vi fosse maior do que o valor i∈S ∗ vi ,
P P
então poderı́amos usar S 0 como solução melhor para a instância (In , v, w, W ), o que é
uma contradição com o fato de que S ∗ era ótima para tal instância. Assim, temos que,
nesse caso, Vn,W = Vn−1,W .
Agora, se n ∈ S ∗ , então perceba que (S ∗ \ {n}) ⊆ {1, 2, . . . , n − 1} é uma solução
possı́vel para a instância (In−1 , v, w, W − wn ). Em particular, ela também deve ser
ótima para tal instância. Isto porque se houvesse outra solução S 0 ⊆ In−1 cujo valor
0
P P
i∈S 0 vi fosse maior do que o valor i∈S ∗ \{n} vi , então poderı́amos usar S ∪ {n} como
solução melhor para a instância (In , v, w, W ), o que é uma contradição com o fato de
S ∗ ser ótima para tal instância. Assim, temos que, nesse caso, Vn,W = vn + Vn−1,W −wn .
Mas como saber se n está ou não na solução ótima? Dado que temos apenas duas
opções para essa resposta, podemos testar ambas e retornar a melhor delas. Assim,
pela discussão acima,
(
max{Vn−1,W , Vn−1,W −wn + vn } se wn ≤ W
Vn,W = (18.3)
Vn−1,W se wn > W
É claro que se não houver itens, o melhor valor possı́vel de ser obtido é 0, independente
da capacidade da mochila, isto é, V0,x = 0 para todo 0 ≤ x ≤ W . A equação (18.3)
nos dá diretamente um algoritmo recursivo bem simples para resolver o problema,
formalizado no Algoritmo 52.
180
pior caso, descrito pela recorrência T (n) = 2T (n − 1), cuja solução é O(2n ). Também
não é difı́cil perceber que o problema desse algoritmo está no fato de ele realizar as
mesmas chamadas recursivas diversas vezes, pois, ao todo, temos no máximo nW
subproblemas diferentes apenas (um para cada par j ∈ {1, . . . , n} e x ∈ {1, . . . , W }).
Assim, podemos usar uma estrutura de dados para manter seus valores e acessá-los
diretamente sempre que necessário ao invés de recalculá-los. Poderı́amos utilizar um
vetor com nW entradas, uma para cada subproblema, porém utilizar uma matriz de
dimensões n × W nos permite um acesso mais intuitivo. Assim, a ideia é armazenar em
M [j][x] o valor Vj,x , de forma que nosso objetivo é calcular M [n][W ]. O Algoritmo 53
formaliza a ideia dessa estratégia de programação dinâmica com a abordagem top-down
enquanto que o Algoritmo 55 o faz com a abordagem bottom-up.
A tabela a seguir mostra o resultado final da matriz M após execução dos algoritmos
sobre a instância onde n = 4, W = 7, w1 = 1, v1 = 10, w2 = 3, v2 = 40, w3 = 4,
v3 = 50, w4 = 5 e v4 = 70:
181
Algoritmo 55: MochilaInteira-BottomUp(n, v, w, W )
1 Seja M [0..n][0..W ] uma matriz
2 para x = 0 até W faça
3 M [0][x] = 0
4 para j = 1 até n faça
5 para x = 0 até W faça
6 se wn > W então
7 M [j][x] = M [j − 1][x]
8 senão
9 usa = vj + M [j − 1][x − wj ]
10 naousa = M [j − 1][x]
11 M [j][x] = max{usa, naousa}
12 retorna M [n][W ]
item ↓ \ capacidade → 0 1 2 3 4 5 6 7
0 0 0 0 0 0 0 0 0
1 0 10 10 10 10 10 10 10
2 0 10 10 40 50 50 50 50
3 0 10 10 40 50 60 60 90
4 0 10 10 40 50 70 80 90
182
Algoritmo 56: ConstroiMochila(n, v, w, W , M )
1 S = ∅
2 x = W
3 j = n
4 enquanto i ≥ 1 faça
5 se M [j][x] == M [j − 1][x − wj ] + vj então
6 S = S ∪ {i}
7 x = x − wj
8 j =j−1
9 retorna S
A G G G C T A G G G C − T
e
A G G − C A A G G − C A −
183
Podemos então definir formalmente este problema.
Uma vez alinhadas, cada caractere ou espaço de uma sequência fica emparelhado
com um caractere ou espaço de outra – apenas não há espaço emparelhado com espaço
–, assim, podemos nos referir a uma “posição do alinhamento”. Nos dois exemplos
acima, na posição 4 de ambos, o caractere G está alinhado com um gap. Seja O∗ um
alinhamento ótimo de X e Y para a função α. Note que na posição final de O∗ só
podemos ter um dos três casos:
184
Claramente, P0,j = jα(gap) e Pi,0 = iα(gap) são as pontuações obtidas ao alinhar
todos os caracteres com gap.
O Algoritmo 57 mostra um algoritmo de programação dinâmica na abordagem
bottom-up para o problema do alinhamento de sequências.
185
186
Algoritmos em grafos
Não.
Nesta parte
Um grafo G é uma estrutura formada por um par (V, E), onde V é um conjunto finito
e E é um conjunto de pares de elementos de V . O conjunto V é chamado de conjunto
de vértices e E é o conjunto de arestas de G. Um digrafo D também é formado por
um par (V, E), onde V é um conjunto de vértices e A é um conjunto de arcos, que é
um conjunto de pares ordenados de V , i.e., um grafo cujas arestas têm uma direção
associada. Dado um grafo (ou digrafo) G = (V, E), denotamos o conjunto de vértices
de G e o conjunto de arestas (ou arcos) de G, respectivamente, por V (G) e E(G).
Um grafo com conjunto de vértices {v1 , . . . , vn } é dito simples quando não existem
arestas do tipo {vi , vi } e, para cada par de ı́ndices 1 ≤ i < j ≤ n, existe no máximo
uma aresta {vi , vj }. De modo similar, um digrafo com conjunto de vértices {v1 , . . . , vn }
é dito simples quando não existem arestas do tipo (vi , vi ) e, para cada par de ı́ndices,
1 ≤ i < j ≤ n existe no máximo uma aresta (vi , vj ) e no máximo uma aresta (vj , vi ).
Todos os grafos e digrafos considerados aqui, a menos que dito explicitamente o
contrário, são simples. Note que o máximo de arestas que um grafo (resp. digrafo) com
n vértices pode ter é n(n − 1)/2 (resp. n(n − 1)). Por simplicidade, muitas vezes vamos
denotar arestas {u, v} de um grafo ou (u, v) de um digrafo por uv apenas.
No que segue, considere um grafo G = (V, E). Dizemos que u e v são vizinhos
(ou adjacentes) se uv ∈ E(G). A vizinhança de um vértice u, denotada por NG (u)
(ou simplesmente N (u), se G for claro do contexto), é o conjunto dos vizinhos de u.
Dizemos ainda que u e v são extremos da aresta uv e que u é adjacente a v (e vice versa).
Ademais, dizemos que a aresta uv incide em u e em v. Arestas que compartilham o
mesmo extremo também são chamadas de adjacentes.
Figura 19.1: Representação gráfica de um grafo G e um digrafo D.
O grau 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)|
192
Figura 19.2: Representação gráfica de um grafo G e um digrafo D e suas listas de
adjacências.
Por simplicidade vamos assumir que um grafo com n vértices tem conjunto de
vértices {1, 2, . . . , n}. Na representação por listas de adjacências, um grafo G = (V, E)
consiste em um vetor LG com |V (G)| entradas, uma para cada vértice, onde LG [u]
contém uma lista encadeada com todos os vizinhos de u em G. Isto é, em LG [u] temos
a cabeça de uma lista que contém N (u). Note que o espaço necessário para armazenar
as listas de adjacências de um grafo é Θ(|V (G)| + |E(G)|).
Na representação por matriz de adjacências, um grafo G = (V, E) é dado por uma
matriz quadrada simétrica M = (mij ) de tamanho |V (G)| × |V (G)| onde mij = 1 se
ij ∈ E, e mij = 0 caso contrário. No caso de um digrafo D = (V, A), a matriz M não
necessariamente é simétrica mas, de forma equivalente, temos mij = 1 se (i, j) ∈ A, e
mij = 0 caso contrário. Note que o espaço necessário para armazenar uma matriz de
adjacências de um grafo é Θ(|V (G)|2 ).
Em geral, o uso de listas de adjacências é preferido para representar grafos esparsos,
que são grafos com n vértices e O(n) arestas, pois o espaço Θ(n2 ) necessário pela matriz
de adjacências é dispendioso. Já a representação por matriz de adjacências é muito
usada para representar grafos densos, que são grafos com Θ(n2 ) arestas. Porém, esse não
é o único fator importante na escolha da estrutura de dados utilizada para representar
193
Figura 19.3: Representação gráfica de um grafo G e um digrafo D e suas matrizes de
adjacências.
194
Figura 19.4: Passeios, trilhas, ciclos e caminhos.
passeio não tenha vértices repetidos, dizemos que esse passeio é um caminho (note como
impedir a repetição de vértices também impede a repetição de arestas). Denotamos
um caminho de comprimento n por Pn . Um uv-caminho é um caminho tal que u é seu
começo e v é seu fim.
Um passeio é dito fechado se seu começo e fim são o mesmo vértice. Um passeio
fechado em que o inı́cio e os vértices internos são dois a dois distintos é chamado de
ciclo. Denotamos um ciclo de comprimento n por Cn .
Um subgrafo H = (V, E) de um grafo G = (V, E) é um grafo com V (H) ⊂ V (G)
e E(H) é um conjunto de pares em V (H) tal que E(H) ⊂ E(H). O subgrafo H é
gerador se V (H) = V (G). Dado um conjunto de vértices S ⊂ V (G), dizemos que
um subgrafo H de G é induzido por S se V (H) = S e uv ∈ E(H) se e somente se
uv ∈ E(G). Dado F ⊂ E(G), um subgrafo H de G é induzido por F se E(H) = F e v
é um vértice de H se e somente se existe alguma aresta de F que incide em v.
Um grafo (ou subgrafo) G é maximal com respeito a uma propriedade P (por
exemplo, uma propriedade de um grafo G pode ser ‘G não contém um C3 ” ou “G tem
pelo menos k arestas”) se G possui a propriedade P e não está contido em nenhum
outro grafo que possui a propriedade P. Similarmente, um grafo (ou subgrafo) G é
minimal com respeito a uma propriedade P se G possui a propriedade P e não contém
195
nenhum grafo que possui a propriedade P.
Um grafo G = (V, E) é conexo se existir um caminho entre quaisquer dois vértices
de V (G). Um grafo que não é conexo é dito desconexo. Os subgrafos conexos de
um grafo desconexo G que são maximais com respeito à conexidade são chamados de
componentes.
Um digrafo G = (V, A) é fortemente conexo se existir um caminho entre quaisquer
dois vértices de V (G). Um digrafo que não é fortemente conexo consiste em um
conjunto de componentes fortemente conexas, que são subgrafos fortemente conexos
maximais. Nas representações gráficas, podemos facilmente distinguir as componentes,
o que nem sempre é o caso para componentes fortemente conexas.
Uma árvore T com n vértices é um grafo conexo com n − 1 arestas ou, alternativa-
mente, é um grafo conexo sem ciclos.
196
Buscas
198
Algoritmo 58: BuscaLargura(G = (V, E), s)
1 s. visitado = 1
2 s. predecessor = s
3 cria fila vazia F
4 Enfileira(F , s)
5 enquanto F. tamanho > 0 faça
6 u = Desenfileira(F )
7 para todo vértice v ∈ N (u) faça
8 se v. visitado == 0 então
9 v. visitado = 1
10 v. predecessor = u
11 Enfileira(F , v)
199
Figura 20.1: Execução de BuscaLargura(G = (V, E), s).
200
linhas salvam as distâncias entre s e os outros vértices do grafo. Ele considera que
quem o chamou já inicializou todos os vértices como não visitados, seus predecessores
com null e as distâncias com ∞.
Fato 20.1
201
Lema 20.2
Demonstração. Comece notando que cada vértice é adicionado à fila somente uma
vez. A prova segue por indução na quantidade k de vértices adicionados à fila, i.e.,
na quantidade de vezes que a rotina Enfileira é executada. Se k = 1, o único
vértice adicionado à fila é o vértice s, antes do laço enquanto começar. Nesse ponto,
temos s. distancia = 0 ≥ distG (s, s) = 0 e v. distancia = ∞ ≥ distG (s, v) para todo
v ∈ V (G) \ {s}, de modo que o resultado é válido.
Suponha agora que o enunciado do lema vale para as primeiras k − 1 inserções à
fila. Considere o momento em que o algoritmo acaba de realizar a k-ésima inserção na
fila, onde v é o vértice que foi adicionado. O vértice v foi considerado no laço para da
linha 8 por estar na vizinhança de um vértice u que foi removido da fila. Por hipótese
de indução, como u foi um dos k − 1 primeiros vértices a ser inserido na fila, temos que
u. distancia ≥ distG (s, u). Mas note que, pela linha 11 e utilizando o Fato 20.1, temos
Como cada vértice entra na fila somente uma vez, o valor em v. distancia não muda
mais durante a execução do algoritmo.
O próximo resultado, Lema 20.3, garante que se um vértice u entra na fila antes
de um vértice v, então no momento em que v é adicionado à fila temos u. distancia ≥
v. distancia. Como uma vez que a estimativa v. distancia de um vértice v é calculada
ela nunca muda, concluı́mos que a relação entre as estimativas para as distâncias de s
a u e v não mudam até o final da execução do algoritmo.
202
Lema 20.3
203
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 . distancia não muda depois de mo-
dificado), sabemos que os pares em {u, v1 , . . . , v` } satisfazem a conclusão do lema. Ade-
mais, pares dos vizinhos de u que entraram na fila têm a mesma estimativa de distância
(u. distancia +1). Portanto, todos os pares de vértices em {v1 , . . . , v` , u1 , . . . , uh } satis-
fazem a conclusão do lema.
Com os Lemas 20.2 e 20.3, temos todas as ferramentas necessárias para mostrar
que BuscaLarguraDistancia calcula corretamente as distâncias de s a todos os
vértices do grafo.
204
Teorema 20.4
205
20.1.2 Componentes conexas
Os algoritmos BuscaLargura e BuscaLarguraDistancia como vistos anterior-
mente visitam todos os vértices que são alcançáveis a partir de s, isto é, todos os
vértices que estão na mesma componente conexa que s está. Se o grafo é conexo,
então as buscas irão visitar todos os vértices do grafo. No entanto, se o grafo não
é conexo, existirão ainda vértices não visitados ao fim de uma execução desses dois
algoritmos. O Algoritmo 8 mostra como utilizar a BuscaLargura para visitar todos
os vértices do grafo, mesmo que ele seja desconexo. Cada vértice possui um atributo
componente, que irá manter o vértice representante de sua componente (no caso, o
vértice no qual a busca se originou). Para o bom funcionamento desse algoritmo, o
algoritmo BuscaLargura deve ser alterado, com uma linha extra “v. componente = s”
sendo adicionada logo antes do vértice v entrar na fila.
206
ao fim de sua execução, todos os vértices que estão no mesmo componente de s são
visitados. Mesmo assim, esse algoritmo pode ser aplicado tanto sobre grafos quanto
sobre digrafos e, apesar de estarmos considerando um grafo G = (V, E), o algoritmo
para digrafos é essencialmente o mesmo.
O nome desse algoritmo vem do fato de ele explorar vértices de forma “agressiva”,
sempre visitando o vértice vizinho ao vértice que foi mais recentemente visitado e
que ainda tenha vizinhos não visitados. Para possibilitar a exploração dos vértices de
G dessa maneira, vamos utilizar uma pilha como estrutura de dados auxiliar (veja o
Capı́tulo 6 para mais informações sobre pilhas).
Cada vértice que é descoberto (visitado pela primeira vez) pelo algoritmo é inserido
na pilha. A cada iteração, o algoritmo consulta o topo u da pilha, segue por um vizinho
v de u ainda não visitado e adiciona v na pilha. Caso todos os vizinhos de u já tenham
sido explorados, u é removido da pilha.
Cada vértice u possui os atributos u. predecessor, u. fim e u. visitado. O atributo
u. predecessor indica qual vértice antecede u em um su-caminho (qual vértice levou u a
ser inserido na pilha). O atributo u. fim indica o momento em que o algoritmo termina
a verificação da lista de adjacências de u (e remove u da pilha). O algoritmo vai fazer
uso de uma variável global encerramento, que auxiliará a preencher u. fim. Por fim,
u. visitado tem valor 1 se o vértice u já foi visitado pelo algoritmo e 0 caso contrário. O
Algoritmo 61 mostra o pseudocódigo para esse procedimento. Ele considera que quem
o chamou já inicializou todos os vértices como não visitados, seus predecessores com
null e inicializou a variável encerramento. O procedimento Consulta(P ) consulta o
último valor inserido na pilha P .
O grafo T = (V, E) com conjunto de vértices V (T ) = {v ∈ V (G) : v. predecessor 6=
null} e conjunto de arestas E(T ) = {{v. predecessor, v} : v ∈ V (T ) \ {s}} é uma árvore
geradora de G e é chamada de árvore de busca em profundidade.
Nas linhas 1 a 4 inicializamos alguns atributos, criamos a pilha e empilhamos s.
Então, nas linhas 7 a 10 o algoritmo alcança um único vizinho de u (topo da pilha)
que ainda não foi visitado e o coloca na pilha, visitando-o. Se u não tem vizinhos não
visitados, então a exploração de u é encerrada e o mesmo é retirado da pilha (linhas 11
a 14).
Prosseguiremos agora com a análise do tempo de execução do algoritmo, onde
assumimos que o grafo G está representado por uma lista de adjacências. Sejam Vs (G)
207
Algoritmo 61: BuscaProfundidadeIterativa(G = (V, E), s)
1 s. visitado = 1
2 s. predecessor = s
3 cria pilha vazia P
4 Empilha(P , s)
5 enquanto P 6= ∅ faça
6 u = Consulta(P )
7 se existe uv ∈ E(G) e v. visitado == 0 então
8 v. visitado = 1
9 v. predecessor = u
10 Empilha(P , v)
11 senão
12 encerramento = encerramento + 1
13 u. fim = encerramento
14 u = Desempilha(P )
208
Figura 20.2: Execução de BuscaProfundidadeIterativa(G = (V, E), a), indicando
a pilha e o tempo de encerramento de cada vértice.
desconexo. Cada vértice possui um atributo componente, que irá manter o vértice
representante do seu componente (no caso, o vértice no qual a busca se originou). O
algoritmo BuscaComponentes ainda mantém uma variável global representante,
que irá auxiliar no preenchimento desse atributo.
209
Algoritmo 62: BuscaComponentes(G = (V, E))
1 para todo vértice v ∈ V (G) faça
2 v. visitado = 0
3 v. predecessor = null
4 encerramento = 0
5 para todo u ∈ V (G) faça
6 se u. visitado == 0 então
7 u. componente = u
8 representante = u
9 BuscaProfundidade(G, u)
210
20.2.1 Ordenação topológica
Uma ordenação topológica de um digrafo D é uma rotulação f dos vértices de D tal
que:
• f (u) 6= f (v) se u 6= v,
211
Algoritmo 64: OrdenacaoTopologica(D = (V, A))
1 para todo vértice v ∈ V (D) faça
2 v. visitado = 0
3 v. predecessor = null
4 encerramento = 0
5 para todo vértice v ∈ V (D) faça
6 se v. visitado == 0 então
7 BuscaProfundidade(D, v)
8 para todo vértice v ∈ V (D) faça
9 f (v) = |V (D)| − v. fim +1
10 retorna f
Figura 20.3: Um digrafo acı́clico com vértices representando tópicos de estudo de uma
disciplina, e uma aresta (u, v) indica que o tópico u deve ser compreendido antes do
estudo referente ao tópico v. Para cada vértice u, indicamos o valor de u. fim.
212
Figura 20.4: Uma ordenação topológica obtida com uma execução de OrdenacaoTo-
pologica no grafo da Figura 20.3.
213
Lema 20.1
Demonstração. Claramente, f (u) ∈ {1, . . . , |V (D)|} para todo u ∈ V (D) e f (u) 6= f (v)
sempre que u 6= v. Assim, basta mostrar que f (u) < f (v) para qualquer aresta
uv ∈ A(D).
Tome uma aresta uv qualquer e suponha primeiro que u é visitado antes de v
pela busca em profundidade. Isso significa que BuscaProfundidade(D, v) termina
antes que BuscaProfundidade(D, u), ou seja, v. fim < u. fim, de onde vemos que
f (v) > f (u).
Suponha agora que v é visitado antes de u. Como D é acı́clico, não existe vu-
caminho. Então BuscaProfundidade(D, v) não visita u e termina antes mesmo de
considerarmos u. Logo, v. fim < u. fim também, de onde temos f (v) > f (u).
214
a b c
d e f g h i
1. Execute BuscaComponentes (Algoritmo 62) sobre D̄: esse passo tem objetivo
de calcular a ordem dos vértices mencionada acima;
Se o grafo estiver representado com lista de adjacências, então não é difı́cil perceber
que o Algoritmo 65 acima funciona em tempo O(|V (D)| + |A(D)|).
215
20.3 Outras aplicações dos algoritmos de busca
Tanto a busca em largura como a busca em profundidade podem ser aplicadas em
vários problemas além dos já vistos. Alguns exemplos são testar se um dado grafo é
bipartido1 , detectar ciclos em grafos, encontrar caminhos entre vértices, listar todos os
vértices de uma componente conexa e encontrar vértices ou arestas de corte (vértices
ou arestas que quando removidos desconectam o grafo). Ademais, podem ser usados
como ferramenta na implementação do método de Ford-Fulkerson, que calcula o fluxo
máximo em uma rede de fluxos. Uma outra aplicação interessante do algoritmo de
busca em profundidade é resolver de forma eficiente (tempo O(|V (G)| + |E(G)|)) o
problema de encontrar uma trilha Euleriana.
Algoritmos importantes em grafos têm estrutura semelhante ao algoritmo de busca
em largura, como é o caso do algoritmo de Prim para encontrar uma árvore geradora
mı́nima, e o algoritmo de Dijkstra, que encontra caminhos mı́nimos em grafos que
possuem pesos não-negativos nas arestas.
Além de todas essas aplicações dos algoritmos de busca em problemas clássicos da
Teoria de Grafos, eles continuam sendo de extrema importância no desenvolvimentos
de novos algoritmos. O algoritmo de busca em profundidade, por exemplo, vem sendo
muito utilizado em algoritmos que resolvem problemas em Teoria de Ramsey, uma
vertente da Teoria de Grafos e Combinatória.
1
Um grafo G é bipartido se V (G) pode ser dividido em dois conjuntos S e V (G) \ S tais que toda
aresta uv ∈ E(G) é tal que u ∈ S e v ∈ V (G) \ S.
216
Árvores geradoras mı́nimas
Lema 21.2
Uma implicação clara do Lema 21.2 é que se e é a única aresta que cruza um dado
corte, então e não pertence a nenhum ciclo.
Dado um corte (S, V (G) \ S) de um grafo G, o seguinte teorema indica uma
estratégia para se obter uma árvore geradora mı́nima.
218
Teorema 21.3
Nas seções a seguir veremos os algoritmos de Prim e Kruskal, que utilizam a ideia
do Teorema 21.3 para obter árvores geradoras mı́nimas de grafos conexos.
219
subconjunto F ⊆ E(G), o grafo G[F ] é o subgrafo de G com conjunto de arestas F e
com os vértices que são extremos das arestas de F .
Lema 21.1
Kruskal retorna F tal que G[F ] é árvore geradora mı́nima para qualquer grafo
G = (V, E) conexo e função de custo w sobre as arestas.
220
o conjunto de vértices da componente do grafo G[Fi ] que contém u. Logo, S não contém
v. Como e tem o menor custo em (S, V \ S) devido à ordem de escolha do algoritmo,
então pelo Teorema 21.3 ela deve fazer parte de uma árvore geradora mı́nima de G.
Ou seja, o algoritmo apenas fez escolhas de arestas que estão em uma árvore geradora
mı́nima e, portanto, construiu uma árvore geradora mı́nima.
221
certamente não cria ciclos. É suficiente, portanto, adicionar a aresta de menor peso
que conecta vértices mantidos em conjuntos diferentes, não sendo necessário procurar
explicitamente por ciclos.
O Algoritmo 67 reapresenta o algoritmo de Kruskal utilizando explicitamente a
estrutura union-find. O procedimento MakeSet(x) cria um conjunto novo contendo
somente o elemento x.
222
dentre os conjuntos que contêm x e y, pois precisamos atualizar todos os representantes
desse conjunto (veja Seção 9.1 para mais detalhes). De fato, dois conjuntos unidos
podem ter O(n) vértices cada, mas poucos deles de fato terão Θ(n) vértices. Porém,
quantas vezes um vértice pode ter seu representante atualizado? Como na operação
Union somente os elementos do conjunto de menor tamanho são atualizados, então
toda vez que isso acontece com um elemento x, o seu conjunto pelo menos dobra
de tamanho. Assim, como cada vértice x começa em um conjunto de tamanho 1
e termina em um conjunto de tamanho n, x tem seu representante atualizado no
máximo log n vezes. Logo, de novo pelo fato do grafo ter n vértices, o tempo total
gasto nas execuções da linha 9 é O(n log n). Se T (n, m) é o tempo de execução de
KruskalUnionFind(G = (V, E), w), então vale o seguinte:
223
b 4 e 3 h
-1 3 3 1
a 8 c 5 f
9 4 2
d 4 g 0 i
-1 3 3 1 -1 3 3 1
a 8 c 5 f a 8 c 5 f
9 4 2 9 4 2
d 4 g 0 i d 4 g 0 i
(b) Primeira iteração: escolhidos = {c}; (c) Segunda iteração: escolhidos = {c, e};
aresta de menor custo que liga um não aresta de menor custo que liga um não
escolhido a um escolhido = ce. escolhido a um escolhido = eh.
b 4 e 3 h b 4 e 3 h
-1 3 3 1 -1 3 3 1
a 8 c 5 f a 8 c 5 f
9 4 2 9 4 2
d 4 g 0 i d 4 g 0 i
-1 3 3 1 -1 3 3 1
a 8 c 5 f a 8 c 5 f
9 4 2 9 4 2
d 4 g 0 i d 4 g 0 i
-1 3 3 1 -1 3 3 1
a 8 c 5 f a 8 c 5 f
9 4 2 9 4 2
d 4 g 0 i d 4 g 0 i
Lema 21.1
Prim retorna F tal que G[F ] é árvore geradora mı́nima para qualquer grafo
G = (V, E) conexo e função de custo w sobre as arestas.
Demonstração. Note que o algoritmo termina: se esse não fosse o caso, haveria alguma
iteração onde o corte (S, V (G) \ S) seria vazio e não haveria escolha para e, o que
significaria que G não é conexo, uma contradição. Então no fim temos de fato S = V (G).
Seja F a árvore devolvida ao fim da execução. Por construção, F é geradora pois
todo vértice que é extremo de alguma aresta de F está em S.
Note agora que F não tem ciclos: considere uma iteração onde e = uv é escolhida
para ser adicionada a F . Neste momento, todas as arestas de F têm extremos em S,
então e é a primeira aresta a cruzar (S, V (G) \ S) em G[F ] e, portanto, não participa
de ciclos em G[F ], pelo resultado do Lema 21.2.
Resta mostrar que w(G[F ]) é mı́nimo. Note que cada aresta e ∈ F é a menor do
corte (S, V (G) \ S) no momento de sua adição. Então, pelo Teorema 21.3, G[F ] é uma
árvore geradora mı́nima.
Note que a operação mais importante do Prim está na linha 4, que consiste em
escolher a aresta de menor custo no corte (S, V (G) \ S) (as outras envolvem simples
atualizações de conjuntos). Para implementar essa escolha, podemos simplesmente
percorrer todas as arestas do grafo verificando seus extremos e armazenando a de menor
custo, o que leva tempo Θ(m), onde m = |E(G)|. Pela condição do laço enquanto,
temos então que essa implementação leva tempo Θ(nm), onde n = |V (G)|.
É possı́vel melhorar esse tempo de execução através do uso de uma estrutura de
225
dados apropriada para esse tipo de operação. Heap é uma estrutura que oferece a
operação RemoveDaHeap(H), que remove o elemento de maior prioridade em tempo
O(log k), onde k é a quantidade de elementos armazenados na estrutura. Veja mais
sobre essa estrutura na Seção 8.1.
Note que o algoritmo de Prim no fundo faz uma escolha por um novo vértice ainda
não visitado. Dentre todos os vértices não visitados que possuem uma aresta que os
conecta a vértices já visitados, escolhemos o que tenha a aresta de menor custo. Vamos
utilizar um heap para armazenar vértices e o valor da prioridade de um vértice x será
o custo da aresta de menor custo que conecta x a um vértice que não está mais na
heap. Mais especificamente, nossa heap irá manter os vértices de V (G) \ S e, para cada
x ∈ V (G) \ S, sua prioridade será o custo da aresta de menor custo xv onde v ∈ S. Se
tal aresta não existir, a prioridade será ∞. Note que tem mais prioridade o vértice
que tem menor valor. Assim, é suficiente escolher o vértice removido da heap para
adicionar a S.
O Algoritmo 69 reapresenta o algoritmo de Prim utilizando explicitamente a
estrutura heap. Assuma que V (G) = {1, . . . , |V (G)|} e que cada vértice x possui os
atributos prioridade, para armazenar sua prioridade, indice, para indicar em que
posição da heap x está, e predecessor, para indicar o vértice v ∈ S tal que a aresta xv
é a de menor custo que conecta x a um elemento de S. Note que quando um vértice
v é removido da heap (para ser inserido na árvore), algumas prioridades de alguns
vértices mudam, pois o conjunto V (G) \ S muda. No entanto, é suficiente recalcular
apenas as prioridades dos vértices que são adjacentes a v, pois é em v que saem as
únicas arestas que não estavam no corte antes e agora estão.
Assuma que a representação do grafo é dada por listas de adjacências. No que
segue, temos n = |V (G)| e m = |E(G)|. Inicialmente, temos S = {s}, de forma que
em tempo O(m) conseguimos calcular os valores das prioridades dos vértices que estão
em V (G) \ S (laço da linha 4) e com O(n log n) operações inserimos todos os vértices
de V (G) \ S na heap. Claramente, são feitas O(n) remoções da heap, que levam tempo
total O(n log n). O total de alterações feitas é O(m), já que essa operação é feita toda
vez que um dos extremos de uma aresta vai ser adicionado a S, de forma que o tempo
total gasto nessas operações é de O(m log n). Asim, o tempo total gasto no algoritmo
é de O(m log n).
226
Algoritmo 69: PrimHeap(G = (V, E), w)
1 Seja S = {s}, onde s ∈ V (G) é um vértice qualquer
2 Seja F = ∅
3 Seja H[1..|V (G)| − 1] um vetor vazio
4 para todo vértice v ∈ N (s) faça
5 v. prioridade = −w(sv)
6 v. predecessor = s
7 InsereNaHeap(H, v)
8 para todo vértice v ∈
/ N (s) faça
9 v. prioridade = −∞
10 v. predecessor = null
11 InsereNaHeap(H, v)
12 enquanto H. tamanho > 0 faça
13 v = RemoveDaHeap(H)
14 para cada vx ∈ E(G) faça
15 se x. prioridade < −w(vx) então
16 x. predecessor = v
17 AlteraHeap(H, x. indice, −w(vx))
18 Seja u = v. predecessor
19 F = F ∪ {uv}
20 S = S ∪ {v}
21 retorna F
227
228
Trilhas Eulerianas
Uma trilha em um grafo G é uma sequência de vértices v1 , . . . , vk tal que vi vi+1 ∈ E(G)
para todo 1 ≤ i ≤ k − 1 e todas essas arestas são distintas (pode haver repetição
de vértices). Uma trilha é dita fechada se tem comprimento não nulo e tem inı́cio e
término no mesmo vértice. Se a trilha inicia em um vértice e termina em outro vértice,
então dizemos que a trilha é aberta. Um clássico problema em Teoria dos Grafos é o
de, dado um grafo conexo G, encontrar uma trilha que passa por todas as arestas de
G. Uma trilha com essa propriedade é chamada de trilha Euleriana, em homenagem a
Euler, que observou que propriedades um grafo deve ter para que contenha uma trilha
Euleriana. O seguinte clássico teorema fornece uma condição necessária e suficiente
para que existe uma trilha Euleriana fechada em um grafo conexo.
Teorema 22.1
Teorema 22.2
Para encontrar uma trilha Euleriana aberta em um grafo G, caso tal trilha exista,
basta executar o algoritmo de Fleury começando em um vértice de grau ı́mpar.
Um ponto chave no algoritmo é como descobrir se uma dada aresta é uma ponte.
Uma maneira simples de descobrir se uma aresta {u, v} é uma ponte em um grafo H
é remover {u, v} e executar uma busca em profundidade começando de u em H. A
aresta {u, v} é uma ponte se e somente se v não é alcançado na execução da busca em
profundidade. Uma maneira mais eficiente é utilizar um algoritmo desenvolvido por
Tarjan.
230
Claramente, o primeiro laço para faz com que o algoritmo retorne “Não existe
trilha Euleriana em G” caso isso seja verdade (veja Teorema 22.1). O seguinte resultado
vai ser útil na prova de corretude do algoritmo de Fleury.
Teorema 22.3
Seja G um grafo onde dG (v) é par para todo v ∈ V (G). Então G não contém
pontes.
Teorema 22.4
Seja G = (V, E) um grafo onde todos seus vértices têm grau par. Então o
algoritmo Fleury(G) retorna uma trilha euleriana T de G.
A seguir vamos utilizar o seguinte fato que pode ser provado facilmente: uma
trilha T de um grafo G cujo vértice final tem grau par em T é uma trilha fechada.
O algoritmo termina sua execução quando analisa um vértice T [i] sem vizinhos no
grafo Gi . Como ao fim da execução do algoritmo temos dGi (T [i]) = 0 e todos os vértices
do grafo inicial G têm grau par, sabemos que o vértice T [i] tem grau par na trilha Ti .
231
Logo, Ti é fechada.
Em resumo, até o momento, sabemos que o algoritmo termina sua execução
retornando uma trilha fechada T . Resta mostrar que T é Euleriana. Suponha por
contradição que T não é Euleriana. Assim, existem arestas no grafo final H =
(VG , EG \ E(T )). Seja V≥1 os vértices v de H com dH (v) ≥ 1. Seja V0 := V (G) \ V≥1 .
Assim, para todo vértice v ∈ V0 temos dH (v) = 0 (não confunda dH (v) com dG (v)).
Como o grafo inicial G é conexo, em G existe pelo menos uma aresta entre V0 e
V≥1 . Assim, seja xy a última aresta da trilha T tal que x ∈ V≥1 e y ∈ V0 . Esse fato
juntamente com o fato do vértice final de T estar em V0 (isso segue da condição do
laço enquanto), sabemos que a aresta xy de T foi “atravessada” por T de x para y,
i.e., x vem antes de y em T . Como xy é a última aresta entre V0 e V≥1 e a trilha T
termina em um vértice de V0 , no momento em que v é adicionado em T , xy é uma
ponte. Mas note que todo vértice v de V≥1 tem grau par em H, pois todo vértice
tem grau par em G e foram removidas somente as arestas da trilha fechada T . Assim,
temos dH (v) ≥ 2 para todo v em V≥1 . Logo, pelo Teorema 22.3, não existem pontes
em H. Portanto, quando o algoritmo escolheu a aresta xy, essa aresta não era ponte
do grafo, uma contradição com a escolha do algoritmo.
232
Caminhos mı́nimos
k−1
X
w(P ) = w(vi vi+1 ) .
i=0
Definimos a distância entre dois vértices u, v ∈ V (G), denotada por por distw
G (u, v),
como sendo o peso de um uv-caminho de menor peso, isto é,
min{w(P ) : P é caminho de u a v}, se existe caminho de u a v,
w
distG (u, v) =
∞, caso contrário .
Dados um grafo G = (V, E), uma função w de peso nas arestas e um vértice
s ∈ V (G), calcular distw
G (s, v) para todo v ∈ V (G).
Problemas de caminhos mı́nimos de única fonte basicamente podem ser resolvidos por
três algoritmos. De forma geral, se o grafo/digrafo em questão não possui pesos nas
arestas ou se w(e) = 1 para toda aresta e, então o algoritmo de busca em largura
pode ser utilizado. Se o grafo/digrafo possui apenas arestas com peso positivo, então o
algoritmo de Dijkstra pode ser utilizado. Para quaisquer valores de pesos nas arestas, o
algoritmo de Bellman-Ford pode ser utilizado. É importante saber quais as vantagens
e desvantagens de cada um para fazer uma boa escolha.
234
23.1.1 Algoritmo de Dijkstra
235
Algoritmo 71: Dijkstra(G = (V, E), w, s)
1 para todo vértice v ∈ V (G) faça
2 v. distancia = ∞
3 v. predecessor = null
4 s. distancia = 0
5 cria fila de prioridades F com conjunto V (G) baseada em v. distancia
6 para i = 1 até |V (G)| faça
7 u = RemoveDaHeap(F )
8 para todo vértice v ∈ N (u) em F faça
9 se v. distancia > u. distancia +w(uv) então
10 v. predecessor = u
11 v. distancia = u. distancia +w(uv)
12 AlteraHeap(F , v. indice, u. distancia +w(uv))
236
Assim como o algoritmo de Prim, o algoritmo de Dijkstra toma, a cada passo, a
decisão mais apropriada no momento. Mais precisamente, o algoritmo escolhe o vértice
v ∈ F incidente à aresta de menor peso entre vértices de F e vértices fora de F e essa
decisão não é modificada no restante da execução do algoritmo. Assim, também é
considerado um algoritmo guloso.
O tempo de execução depende de como o grafo G e a fila de prioridades F são
implementados. Assim, como na busca em largura e no algoritmo de Prim, a forma
mais eficiente é representar o grafo G através de uma lista de adjacências. Vamos
assumir que F é uma fila de prioridades implementada através do uso de um heap
binário como no Capı́tulo 8.
Seja n = |V (G)| e m = |E(G)|. Dado que o primeiro laço para é executado
n vezes, o segundo laço para é executado |N (v)| vezes para cada v ∈ V (G), cada
operação RemoveDaHeap(F ) é executada em tempo O(log n), e cada operação
AlteraHeap(F , v, u) que leva tempo O(log n), uma análise muito similar a feita no
algoritmo de Prim mostra que o tempo de execução de Dijkstra(G = (V, E), w, s) é
O (m + n) log n .
O seguinte lema será usado na prova da corretude do algoritmo de Dijkstra.
Lema 23.1
Teorema 23.2
237
encerrado após |V (G)| iterações desse laço e a fila F é vazia. Precisamos mostrar que
quando isso acontece, temos v. distancia = dist(s, v) para todo v ∈ V (G).
Uma vez que o algoritmo nunca modifica o atributo v. distancia depois que v sai de
F , basta provarmos que
no momento em que u saiu de F . Seja u o primeiro vértice com u. distancia > dist(s, u)
a ser removido de F . Assim, para todo vértice v removido de F antes de u, temos
v. distancia = dist(s, v).
Como não existem arestas de peso negativo, dist(s, w) ≤ dist(s, u). Logo,
238
23.1.2 Algoritmo de Bellman-Ford
O algoritmo de Bellman-Ford resolve o problema de caminhos mı́nimos mesmo quando
há arestas de peso negativo no grafo ou digrafo em questão. Mais ainda, quando existe
um ciclo de peso total negativo, o algoritmo identifica a existência de tal ciclo. Dessa
forma, é um algoritmo que funciona para mais instâncias que o algoritmo de Dijkstra.
Por outro lado, como veremos a seguir, é menos eficiente que o algoritmo de Dijkstra.
O algoritmo de Bellman-Ford recebe um grafo G = (V, E), uma função w de pesos
nas arestas de G e um vértice s inicial. Assim como no algoritmo de Dijkstra, dado um
vértice v, o atributo v. distancia sempre contém a menor distância de s a v conhecida
pelo algoritmo. Porém, a forma como essas distâncias são atualizadas ocorre de forma
bem diferente. O algoritmo vai tentar, em |V (G)| − 1 iterações, melhorar a distância
conhecida de s a todos os vértices v analisando todas as |E(G)| arestas de G em cada
iteração. O algoritmo mantém atributos v. predecessor que permitem se obter um
caminho mı́nimo de s a v. No final de sua execução, o algoritmo retorna “verdadeiro”
se G não contém ciclos de peso negativo, e retorna “f also” caso exista algum ciclo
de peso negativo em G. O algoritmo de Bellman-Ford é descrito formalmente no
Algoritmo 72.
239
Figura 23.2: Execução do algoritmo Bellman-Ford.
240
Lema 23.3
Logo, ao relaxar a aresta vk−1 vk , o algoritmo vai verificar que vk . distancia > dist(s, vk ) =
dist(s, vk−1 ) + w(vk−1 vk ), atualizando o valor de vk . distancia como abaixo.
Note que, no Lema 23.3, não importa que arestas tenham sido relaxadas entre
quaisquer das relaxações sv1 , v1 v2 , . . ., vk−1 vk . O Lema 23.3 garante que se as arestas
241
Figura 23.3: Ordem de relaxação das arestas de um caminho mı́nimo de s a v.
Lema 23.4
Seja G = (V, E) um grafo com uma função de pesos w em suas arestas e seja
s ∈ V (G). Se G não contém ciclos de peso negativo, então após terminar a execução
das linhas 5–9 de Bellman-Ford(G = (V, E), w, s) temos v. distancia = dist(s, v)
para todo vértice v ∈ V (G).
242
que o algoritmo nunca vai modificar o valor de v. distancia. Como não existem ciclos
de peso negativo, sabemos que existe algum caminho mı́nimo de s a qualquer vértice
alcançável a partir de s. Assim, seja P = (s, v1 , v2 , . . . , vk ) um caminho mı́nimo de s a
um vértice arbitrário vk que pode ser alcançável a partir de s. Note que como P é um
caminho mı́nimo, então P tem no máximo |V (G)| − 1 arestas.
Seja v0 = s. Como a cada uma das |V (G)| − 1 iterações do laço para na linha 5
todas as arestas do grafo são relaxadas, temos que certamente, para 1 ≤ i ≤ k, a aresta
vi−1 vi é relaxada na iteração i. Assim, as arestas v0 v1 , v1 v2 , . . ., vk−1 vk são relaxadas
nessa ordem. Pelo Lema 23.3, temos vk . distancia = dist(s, vk ). Assim, a prova do lema
está concluı́da.
Usando o Lema 23.4, podemos facilmente notar que o algoritmo identifica um ciclo
de peso negativo.
Corolário 23.5
Seja G = (V, E) um grafo com uma função de pesos w em suas arestas e seja
s ∈ V (G). Se Bellman-Ford(G = (V, E), w, s) retorna “f also”, então G
contém um ciclo de peso negativo.
243
executado. Porém, o algoritmo de Bellman-Ford funciona em grafos que contém arestas
de peso negativo, diferentemente do algoritmo de Dijkstra. Por fim, observamos que o
algoritmo de Bellman-Ford também tem a capacidade de identificar a existência de
ciclos negativos no grafo.
O n2 log n + nm .
(23.3)
Para grafos densos (i.e., grafos com Θ(n2 ) arestas), esse valor representa um tempo de
execução da ordem de
O n3 .
Nase seções a seguir veremos dois algoritmos especı́ficos para o problema de caminhos
mı́nimos entre todos os pares. Um deles é o algoritmo de Floyd-Warshall, mais conhecido,
244
−4
2 2 3 5
17 −10 −10 10
Figura 23.4: Considere que o grafo da figura é apenas uma parte do grafo de entrada.
Seja i = 17, j = 10 e k = 5. Aqui só existe um ij-caminho que só possui vértices em
V 5 , que é (17, 2, 3, 10). Existe, no entanto, outro ij-caminho, inclusive de custo menor:
(17, 7, 10).
que executa em tempo Θ(n3 ) independente do grafo ser denso ou não, e aceita custos
negativos nas arestas. O outro é o algoritmo de Johnson, que também aceita pesos
negativos e combina execuções de Bellman-Ford e Dijkstra, executando em tempo
Θ(nm log n).
245
Assim, conseguimos definir uma estrutura recursiva para resolver esse problema. Seja
Di,j,k o custo de um ij-caminho mı́nimo que só tem vértices internos em {1, 2, . . . , k}.
Claramente, se k > 0, temos
Nossa intenção é, portanto, calcular Di,j,n para todo par i, j ∈ V (G).
A ideia do algoritmo de Floyd-Warshall é manter uma matriz W de dimensões
(n + 1) × (n + 1) × (n + 1) onde W [i][j][k] = Di,j,k . Como cada vértice pode participar
de vários caminhos mı́nimos, armazenar um único vértice no atributo predecessor
de cada vértice não nos ajudará a descrever os caminhos mı́nimos de fato ao fim da
execução. Assim, consideraremos o atributo predecessor de cada vértice como sendo
um vetor de tamanho n tal que j. predecessor[i] armazenará o vértice predecessor de j
em um ij-caminho mı́nimo. O Algoritmo 73 formaliza essas ideias. Ele está escrito na
abordagem bottom-up de programação dinâmica.
Veja que devido à ordem em que os laços são executados, a terceira dimensão da
matriz W é um tanto desperdiçada: para calcular algo na k-ésima posição, usamos
apenas o que está na (k − 1)-ésima posição. Assim, é possı́vel utilizar apenas uma
matriz bidimensional para obter o mesmo resultado. O Algoritmo 74 formaliza essa
ideia.
Por causa dos três laços aninhados, independente da economia de espaço ou não,
claramente o tempo de execução de Floyd-Warshall(G, w) é Θ(n3 ), o que é bem
melhor que o tempo Θ(n4 ) gasto em n execuções do algoritmo de Bellman-Ford. Porém,
note que para grafos esparsos (i.e., com m = O(n) arestas), a opção mais eficiente
assintoticamente é executar o algoritmo de Dijkstra repetidamente, gastando tempo
total o(n3 ) (veja (23.3)). Mas, novamente, temos o empecilho de que o algoritmo de
Dijkstra é correto somente para grafos sem arestas de peso negativo.
Perceba que em nenhum momento o algoritmo de Floyd-Warshall falha se o grafo de
246
Algoritmo 73: Floyd-Warshall(G = (V, E), w)
1 Seja W [0..n][0..n][0..n] uma matriz
2 para i = 1 até n faça
3 para j = 1 até n faça
4 se i == j então
5 W [i][j][0] = 0
6 j. predecessor[i] = i
7 senão se ij ∈ E(G) então
8 W [i][j][0] = w(ij)
9 j. predecessor[i] = i
10 senão
11 W [i][j][0] = ∞
12 j. predecessor[i] = null
21 retorna W
247
Algoritmo 74: Floyd-Warshall(G = (V, E), w)
1 Seja W [0..n][0..n] uma matriz
2 para i = 1 até n faça
3 para j = 1 até n faça
4 se i == j então
5 W [i][j] = 0
6 j. predecessor[i] = i
7 senão se ij ∈ E(G) então
8 W [i][j] = w(ij)
9 j. predecessor[i] = i
10 senão
11 W [i][j] = ∞
12 j. predecessor[i] = null
19 retorna W
248
entrada possuir um ciclo negativo. De fato, ele executa, porém não corretamente. Como
saber se o grafo possui um ciclo negativo para poder executar Floyd-Warshall e ter
certeza de que o resultado está correto? A boa notı́cia é que podemos usar o próprio
Floyd-Warshall. A matriz W devolvida por ele pode ser utilizada para verificar se
o grafo possui ciclo negativo ou não, isto é, para verificar se o problema de caminhos
mı́nimos entre todos os pares pode ser resolvido em G ou não. Veja o Algoritmo 75.
249
importantı́ssimo, que é transformar os pesos da função w em pesos não negativos,
formando a função w0 . O novo peso de cada aresta uv será dado por
w0 (uv) = distw w
G (s, u) + w(uv) − distG (s, v) . (23.6)
0
distw w w w
G (u, v) = distG (u, v) + distG (s, v) − distG (s, u) .
O seguinte fato garante que a igualdade acima coloca o peso correto em distw G (u, v):
0
seja P = (u = v1 , . . . , vk = v) um caminho mı́nimo de u a v com função w . Assim,
utilizando (23.6), obtemos
0
0 0
distw
G (u, v) = w (v1 v2 ) + · · · + w (vk−1 vk )
= w(v1 v2 ) + · · · + w(vk−1 vk )
+ distw w w
G (s, v1 ) + distG (s, v2 ) + · · · + distG (s, vk−1 )
− distw w w
G (s, v2 ) − · · · − distG (s, vk−1 ) − distG (s, vk )
= distw w w
G (u, v) + distG (s, u) − distG (s, v) .
0
Portanto, de fato temos distw w w w
G (u, v) = distG (u, v) + distG (s, v) − distG (s, u). Abaixo
temos o algoritmo de Johnson, que, caso não exista ciclo de peso negativo no grafo,
retorna uma matriz D com n linhas e n colunas tal que D[i][j] contém o peso de um
caminho mı́nimo de vi a vj .
Note que o tempo de execução de Johnson(G = (V, E), w) é o mesmo de n
execuções de Dijkstra. De fato, a linha 11, que é executada para cada vértice do
250
Algoritmo 77: Johnson(G = (V, E), w)
0 0
1 Crie grafo G = (V, E), onde V (G ) = V (G) ∪ {s} e
0
E(G ) = E(G) ∪ {sv : v ∈ VG }
2 Estenda a função w fazendo w(s, v) = 0 para todo v ∈ V (G)
3 Crie uma matriz D[1..n][1..n]
4 se Bellman-Ford(G, w, s) == f also então
5 retorna “O grafo G contém ciclo de peso negativo”
6 Crie vetor A[1..n]
7 para todo vértice u ∈ V (G) faça
8 Execute Bellman-Ford(G, w, s) para fazer u. distancias = distw
G (s, u)
251
grafo, é o que determina o tempo de execução de Johnson.
252
Teoria da computação
A maioria dos problemas que vimos até aqui neste livro são ditos tratáveis. São
problemas para os quais existem algoritmos eficientes para resolvê-los.
Definição 23.1
Busca (1.1), Ordenação (10.1), Mochila fracionária (17.1), Corte de barras (18.1),
Árvore geradora mı́nima (21.1), Caminhos mı́nimos em grafos (23.1 e 23.2) são alguns
exemplos de problemas tratáveis. No entanto, muitos problemas, até onde se sabe, não
possuem algoritmos eficientes que os resolvam, como é o caso do problema da Mochila
inteira (18.1), por exemplo. Estes são ditos intratáveis.
Na verdade, muitos problemas interessantes e com fortes motivações e aplicações
práticas são intratáveis, como por exemplo escalonar um conjunto de tarefas a proces-
sadores, interligar de forma barata computadores especı́ficos em uma rede com diversos
outros computadores que podem ser usados como intermediários, cortar placas de
vidros em pedaços de tamanhos especı́ficos desperdiçando pouco material, ou decompor
um número em fatores primos. Para esses problemas, não se tem muita esperança
de encontrar algoritmos eficientes que os resolvam, porém felizmente existem vários
algoritmos eficientes que encontram boas soluções.
Nos capı́tulos a seguir veremos mais sobre a teoria envolvendo esses tipos de
problemas e formas de lidar com os mesmos.
256
Complexidade computacional
Definição 24.1
Dados um grafo G com pesos nas arestas, dois vértices u, v ∈ V (G) e um valor
k, existe uv-caminho de peso no máximo k?
Dados um grafo G com pesos nas arestas e dois vértices u, v ∈ V (G), qual o
uv-caminho de peso mı́nimo?
Definição 24.4
Note, no entanto, que existe uma relação entre o Problema 24.2 e o Problema 24.3:
dada a mesma instância de entrada, se resolvermos um deles, então resolvemos o
outro, conforme a discussão a seguir. Seja G um grafo com pesos c nas arestas e sejam
u, v ∈ V (G) dois vértices quaisquer. Suponha primeiro que sabemos resolver o problema
do caminho mı́nimo e que z é o custo do menor uv-caminho. Se z ≤ k, então a resposta
para o problema de decisão certamente é sim, isto é, existe um uv-caminho com custo
menor que k (tome, por exemplo, o próprio uv-caminho mı́nimo). Se z > k, então a
resposta para o problema de decisão certamente é n~ ao, pois se o menor uv-caminho
tem custo maior do que k e qualquer outro uv-caminho tem custo maior que z, então
não é possı́vel existir um uv-caminho com custo no máximo k.
Agora suponha que sabemos resolver o problema do caminho (sabemos dizer sim ou
ao para qualquer valor de k). Seja C o custo da aresta de maior custo do grafo e seja
n~
n = |V (G)|. Note que qualquer uv-caminho terá custo no máximo nC pois ele pode no
máximo usar n − 1 arestas. Assim, basta testar todos os valores de k ∈ {1, 2, . . . , nC}
e, para o menor valor cuja solução for sim, temos a resposta para o caminho mı́nimo.
Veja a Figura 24.1.
Por esse motivo, toda a teoria que estudaremos neste capı́tulo foi feita com base nos
problemas de decisão. Nas seções a seguir definiremos importantes classes de problemas
e as dificuldades envolvidas nas soluções dos problemas presentes nas mesmas.
24.1 Classes P e NP
258
Definição 24.1: Classe P
Dado um grafo G completo com custo nas arestas e um valor k, existe um ciclo
hamiltoniano de custo no máximo k?
TSP é uma sigla para Travelling Salesman Problem, nome em inglês de um famoso
problema em computação (o Problema do Caixeiro Viajante). Na versão de otimização,
mais famosa, o objetivo é encontrar um ciclo hamiltoniano de custo mı́nimo no grafo.
Veja que não é difı́cil pensar em um algoritmo simples de força bruta para resolvê-los:
podemos enumerar todas as n! permutações dos n vértices do grafo, calcular seu custo
e manter a menor delas. Claramente, esse algoritmo simples não é nem um pouco
eficiente.
Na verdade, o TSP-k é um problema que acredita-se não estar na classe P. Desde
sua origem, em torno de 1800, ninguém conseguiu encontrar um algoritmo eficiente
259
que o resolva.
Acontece que o fato de ninguém ter conseguido encontrar um algoritmo para um
problema não implica diretamente que ele não está em P; apenas significa que ninguém
ainda foi capaz de encontrá-lo. A área de projeto de algoritmos é muito rica e, apesar
de já existirem várias técnicas como de algoritmos gulosos ou divisão e conquista, novas
técnicas são criadas a todo momento. Será que em algum momento futuro alguém
conseguiria descobrir uma técnica diferente que resolva o TSP-k, por exemplo?
A afirmação “acredito que o TSP-k não está em P” não é feita apenas porque
ninguém conseguiu um algoritmo eficiente que resolva o TSP-k. Ela é feita porque
ninguém conseguiu um algoritmo eficiente que resolve muitos outros problemas que são
tão difı́ceis quanto o TSP-k! Antes de continuar nossa discussão, precisamos definir
a ideia de redução, que é muito importante para comparação de dificuldade entre
problemas.
Basicamente, redução é uma forma de converter um problema em outro de tal
forma que a solução do segundo possa ser usado para resolver o primeiro. Em outras
palavras, um algoritmo para o segundo problema pode ser usado como “caixa preta”
para resolver o primeiro. Por exemplo, no inı́cio deste capı́tulo mostramos como reduzir
o problema Caminho, de decisão, para o problema Caminho mı́nimo, de otimização, e
vice-versa. O problema “encontrar a mediana de um conjunto de números” pode ser
reduzido para o problema de ordenação: uma vez ordenado, a mediana do conjunto
é o número que está na posição central. O problema de calcular o quadrado de um
número pode ser reduzido ao problema de multiplicar dois números.
Especificamente, vamos utilizar redução entre dois problemas de decisão. No que
segue, se P é o nome de um problema, chamaremos de IP uma instância (entrada)
para P .
A definição acima nos permite obter dois tipos de resultados importantes. Primeiro,
se sabemos resolver Q em tempo polinomial e conseguimos reduzir (polinomialmente) P
para Q, então automaticamente sabemos resolver P em tempo polinomial. Segundo, se
260
reduzimos P para Q e P não pode ser resolvido em tempo polinomial, então Q também
não pode ser resolvido em tempo polinomial (caso contrário usarı́amos a solução de Q
para obter uma para P ). Em resumo, de P é redutı́vel para Q, então Q é tão difı́cil
quanto P . O conceito de redução portanto nos permite tanto aumentar o conjunto de
problemas tratáveis quanto o dos intratáveis.
Voltando à discussão sobre o TSP-k, gostarı́amos de ter uma evidência da sua
intratabilidade podendo dizer que ele é tão difı́cil quanto muitos outros problemas.
Aqui entra a ideia da completude. Se X é um conjunto qualquer de problemas, dizemos
que P é X -completo se P ∈ X e se todos os outros problemas de X são redutı́veis a P .
Quer dizer, P é tão difı́cil quanto todos os outros problemas em X . Se tivermos TSP-k
pertencente a X e dissermos que todos os problemas de X são intratáveis, então nossa
afirmação terá mais impacto quanto maior for X .
Poderı́amos talvez pensar em X contendo todos os problemas conhecidos? Infeliz-
mente, alguns problemas conhecidos sequer podem possuir algoritmos que os resolvam,
sendo portanto estritamente mais difı́ceis do que o TSP-k (mesmo ruim, o algoritmo de
força bruta que descrevemos anteriormente o resolve). Esses problemas são chamados
indecidı́veis, sendo o mais famoso deles o problema da parada.
261
1. para toda instância IP que é sim, existe um conjunto de dados D tal que
A(IP , D) retorna sim; e
262
resolve o problema pode ser usado diretamente como verificador para o mesmo. Ou
seja, claramente temos P ⊆ NP. A grande questão é, será que NP ⊆ P?
P é igual a NP?
Esse problema, porém, continua em aberto até os dias atuais. Dada sua importância,
ele é um dos Problemas do Milênio e o Clay Institute oferece um prêmio monetário de
$1.000.000, 00 para quem conseguir resolvê-lo1 .
Pela definição acima e pela definição de redução, podemos concluir que se um único
algoritmo eficiente para resolver um problema NP-completo for encontrado, então
teremos um algoritmo eficiente para resolver todos os problemas em NP.
Teorema 24.2
263
necessário um ponto de partida, i.e., é necessário que exista uma prova de que algum
problema é NP-completo que não necessite de outro problema NP-completo para
funcionar. Esse ponto de partida é o problema 3-SAT.
Note que o 3-SAT está em NP pois, dada uma fórmula φ e uma atribuição das
variáveis, é fácil verificar se essa atribuição satisfaz a fórmula. Em 1971, os pesquisadores
Stephen Cook e Leonid Levin provaram que o 3-SAT é NP-completo.
3-SAT é NP-completo.
Em 1972, Richard Karp apresentou um artigo com uma lista de 21 outros problemas
em NP-completo, criando de fato, na época, um conjunto desses problemas. Hoje em
dia temos milhares de problemas NP-completos.
264
24.3 Exemplos de problemas NP-completos
Nessa seção mostraremos vários exemplos de reduções para mostrar que um problema
novo é NP-completo. Partiremos do fato que o 3-SAT é NP-completo apenas.
Nosso primeiro resultado é sobre o problema Clique-k (24.7).
Teorema 24.1
Já havı́amos mostrado anteriormente que Clique-k está em NP. Isso juntamente
265
Figura 24.2: Construção de um grafo G dada uma instância de 3-SAT.
266
com o Teorema 24.1 prova o seguinte resultado.
Teorema 24.2
Clique-k é NP-completo.
Dado um grafo G e um inteiro k, existe conjunto S ⊆ V (G) tal que, para toda
aresta uv ∈ E(G), u ∈ S ou v ∈ S e |S| ≤ k?
Primeiro note que esse problema está em NP, pois dados G, k e algum conjunto de
vértices, é fácil em tempo polinomial verificar se tal conjunto tem tamanho no máximo
k e se todas as arestas do grafo têm ao menos um extremo nesse conjunto. O teorema
a seguir mostra uma redução de Clique-k para k-Cobertura por vértices.
Teorema 24.4
Teorema 24.5
267
Pela definição acima, vemos que outra definição para a classe NP-completo pode
ser: o conjunto de problemas que estão em NP e são NP-difı́ceis.
Mas por que precisamos de duas classes de problemas tão parecidas? Essa distinção
se dá basicamente porque problemas de otimização não estão em NP. Veja por exemplo
o problema da mochila inteira. É fácil verificar se um dado conjunto de itens cabe na
mochila (basta somar seus pesos e comparar com a capacidade máxima), porém não é
fácil saber se o conjunto dá o melhor valor possı́vel. Ao menos não sem de fato resolver
o problema de fato. Assim, NP-completo ⊂ NP-difı́cil.
Para mostrar que um problema novo é NP-difı́cil, basta tomarmos um problema
que já é NP-difı́cil ou já é NP-completo e reduzi-lo para o novo problema. Pela
composição da redução, isso mostraria que todos os problemas em NP também se
reduzem ao novo problema. Por exemplo, o Teorema 24.1 prova diretamente o seguinte
resultado.
Teorema 24.2
Clique-k é NP-difı́cil.
Lembre-se que o fato de Clique-k ser NP finalizou a prova de que ele é NP-completo.
268
Abordagens para lidar com problemas
NP-difı́ceis
Em breve.