NA-OrdBusca - Ciência da Computação

Propaganda
Assintótica
Notas de aula
 de maio de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Introdução
Quando manipulamos estruturas de dados, dois problemas são bastante comuns: o problema de buscar por
um dado numa estrutura, denominado BUSCA, e o problema de ordenar uma estrutura de dados, denominado
ORDENAÇÃO. Há vários algoritmos que resolvem ambos os problemas, e sempre que há mais de uma opção
para se fazer alguma coisa, uma pergunta nos vem: qual deles escolher?
Precisamos, então, analisar cada algoritmo para, enfim, entendermos as vantagens e desvantagens de cada
um deles. Neste contexto, deparamo-nos com uma das áreas de pesquisa mais centrais em Ciência da Computação: a área de Análise de Algoritmos.
Analisamos um algoritmo em termos dos recursos que a execução daquele algoritmo consome, seja tempo,
memória, banda de rede etc. Via de regra a quantificação desses recursos é função do tamanho da entrada
do algoritmo. Algumas ferramentas de comparação e de classificação de funções se mostram nesse esforço
bastante úteis, especialmente as ferramentas da Assintótica, disciplina da Matemática que estuda o comportamento assintótico das funções. A palavra assintótica etimologicamente significa o que não cai junto, o que não
coincide. Uma assíntota de uma função é um limitante do qual a função se aproxima mas jamais atinge. Um
clássico exemplo é o valor zero para a função x . À medida que x cresce, x se aproxima cada vez mais de zero,
mas nunca o atinge. Por extensão, e até por transcendência, o campo da Assintótica engloba tudo o que diz
respeito ao comportamento de funções quando os valores do domínio tendem ao infinito.

Uma hierarquia para funções
Consideremos as seguintes funções de n: f (n) = n e g(n) = n . Vejamos as imagens de alguns valores de
n por essas funções:
n







f (n)







g(n)







10000000
9000000
8000000
7000000
6000000
f
g
5000000
4000000
3000000
2000000
1000000
0
2 52 102152202252 302352402452502552 602652702752802852
Figura : Uma comparação entre f e g.
Olhando para esses resultados, vemos como a função f supera a função g, porque, embora ambas comecem
com o valor zero na imagem, logo as imagens por f se tornam muito maiores que as imagens por g. O gráfico
da Figura ?? ilustra o fenômeno.
Agora, notemos o que acontece com as funções se expandirmos um pouco mais nosso intervalo no domínio:
n







f (n)







g(n)







Vejamos na Figura ?? o que aconteceu! O crescimento da função g, outrora inferior ao crescimento constante da função f , agora supera o crescimento da função f . De fato, sabemos que o crescimento da função
f é dn/dn = , enquanto que o crescimento da função g é dn /dn = n. Logo, para n = , o
crescimento da função g é de , que é maior que o crescimento constante de  da função f .

60000000
50000000
40000000
f
g
30000000
20000000
10000000
0
11
407 803 1199 1595 19912387 2783 3179 3575 3971 4367 47635159
Figura : Uma comparação entre f e g.
Mas o crescimento da função g não para de crescer, enquanto o crescimento da função f permanece estagnado. Isso quer dizer que logo a função g ultrapassa a função f , como vemos a seguir.
n




f (n)




g(n)




A ultrapassagem ocorreu exatamente no ponto em que n = , como é ilustrado pela Figura ??. Claro,
n =  é solução para a equação f (n) = g(n).
450000000
400000000
350000000
300000000
250000000
f
g
200000000
150000000
100000000
50000000
0
40
1920 3800 5680 7560 9440 11320 13200 15080 16960 18840
Figura : Uma comparação entre f e g.
Mas não para por aí. Se expandimos ainda mais o intervalo no domínio, vemos como a função g deixa a
função f no chinelo.

n




f (n)


 × 

g(n)


, × 

Olhando para o gráfico da Figura ??, vemos que, quanto mais n se aproxima do infinito, mais f se torna
insignificante em relação a g. Mais formalmente,
f (n)
= .
n→+∞ g(n)
lim
12000000000
10000000000
8000000000
6000000000
f
g
4000000000
2000000000
0
20
0
12
20
0
24
20
0
36
20
0
48
20
0
60
20
0
72
20
0
84
20
0
96
20
0
Figura : Uma comparação entre f e g.
Assintoticamente, dizemos que g domina f e escrevemos f g. Essa notação não quer dizer que o valor
de g é sempre maior que o valor de f , mas significa que, não importando que haja intervalos onde f (n) > g(n),
à medida que n → +∞ a função g não só supera a função f mas também anula a razão g(n)/f (n).
Definição .
quando
Dizemos que uma função f : N → R domina uma função g : N → R, e escrevemos f g,
f (n)
= .
n→+∞ g(n)
lim
Definimos aqui que o domínio das funções é N porque, em Análise de Algoritmos, as funções são geralmente sobre o tamanho da entrada dos algoritmos: um número natural. A mesma definição pode ser estendida
sem problemas para R, ou até para um outro corpo qualquer munido de uma relação de ordem total .
Vamos mostrar que de fato n n . Ora,
lim
n→+∞
n


= lim
=  lim
=  ·  = .
n→+∞
n→+∞ n
n
n
Perceba que a relação estabelece uma hierarquia entre as funções de domínio N. Por exemplo, a Figura ?? ilustra como ln n n n n .
 Não entendeu bulhufas? Talvez você devesse considerar estudar Álgebra. As aplicações para a Ciência da Computação são maiores
do que você pensa. Procure pelo livro Finite Fields And Their Applications, de G. L. Mullen, editora Elsevier.

70
60
50
40
30
20
10
0
0
1
2
3
4
5
6
Figura : Uma comparação entre quatro funções.
Por fim, apenas comentamos que escrever f g é a mesma coisa que escrever g f .

Classificações de funções com o uso de e Dada uma função g(n), podemos definir com a relação uma classe de funções: a classe de todas as funções
f (n) tais que f (n) g(n). Essa classe denotamos por o(g(n)). De igual modo, a classe de todas as funções f (n)
tais que f (n) g(n) denotamos por ω(g(n)).
Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente inferior a g(n), e escrevemos f (n) = o(g(n)), quando f (n) g(n). Analogamente, dizemos que f (n) é assintoticamente superior a g(n), e
escrevemos f (n) = ω(g(n)) quando f (n) g(n).
Fica claro na Definição ?? que o uso do símbolo = é um abuso. Muito mais apropriado seria usar o símbolo
∈, já que o(g(n)) e ω(g(n)) são classes de funções. No entanto, como o uso do símbolo = já está por demais
consagrado na Literatura, mantemos a notação por convenção. Apenas advertimos que = não deve ser lido
como é igual a, mas sim como é. Por exemplo, n = o(n ) deve ser lido como n é o(n ), não como n é igual a o(n ).
Isso evita absurdos como:
n = o(n )
n = o(n )
logo
n = n
É comum também as notações serem usadas em expressões, como

T (n) = o(n)  − .
n
()
Neste caso, o(n) não deve ser entendida como uma classe de funções, o que não faz muito sentido, outrossim
como uma função da classe, ou seja, como uma função que é o(n). Mais especificamente, na Equação ?? temos
que T (n) é alguma função (qual função não sabemos, e nem importa) que é o(n) vezes a função ( − (/n)).


Outras classes assintóticas de funções
Dissemos que estabelece uma hierarquia para funções de domínio N. Com isso, alguém mais desavisado
poderia subentender que para quaisquer funções distintas f , g : N → R vale que ou f g ou g f , o que
nos permitiria ordenar todas as infinitas funções de RN , um absurdo, já que RN é incontável . Ora, tomemos
o contraexemplo das funções n e n :
n
= .
lim
n→+∞ n
Logo, não é verdade que n n nem que n n . Precisamos de uma notação mais poderosa que a
notação . Vamos conhecer agora a notação O (em inglês, the Big O Notation ):
Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente no máximo g(n), e escrevemos f (n) = O(g(n)), se existem duas constantes n ∈ N e c ∈ R, c > , para as quais
|f (n)| 6 c|g(n)|,
para todo n > n .
Vamos a alguns exemplos de funções O(n ):
• n = O(n ), pois
|n | 6 |n |,
para todo n > .
(c = , n = )
• n = O(n ), pois
|n| 6 |n |,
para todo n > .
(c = , n = )
• n + n = O(n ), pois
|n + n| 6 |n + n · n| 6 |n |,
para todo n > .
(c = , n = )
√
• n n +  = O(n ), pois
√
√
√
√
√
|n n + | 6 |n n| 6 ( )|n n| 6 ( )|n |,
para todo n > .
√
(c = , n = )
Para entendermos graficamente o que a notação O significa, observemos a Figura ??, a qual ilustra f (x) =
O(g(x)) plotando em azul no gráfico a curva c|(|g(x)).
Figura : Uma ilustração da notação O.
 Sendo A e B conjuntos, AB denota o conjunto de todas as funções f : B → A.
 A propósito, o é conhecida como the little o notation.

Em contrapartida à notação O, temos:
Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente no mínimo g(n), e escrevemos f (n) = Ω(g(n)), se existem duas constantes n ∈ N e c ∈ R, c > , para as quais
|f (n)| > c|g(n)|,
para todo n > n .
Lembremos que quando f (n) = o(g(n)) é impossível que f (n) = ω(g(n)), pois não pode ao mesmo tempo
ocorrer de f (n) g(n) e f (n) g(n). No entanto, é possível sim que uma função f (n) seja tanto O(g(n)) quanto
Ω(g(n)). Vejamos o caso da função n + n, a qual já sabemos ser O(n ): é imediato que |n + n| > |n | para
todo n >  e, portanto, que n + n = Ω(g(n)).
Definição . Sendo duas funções f , g : N → R, dizemos que f (n) é assintoticamente equivalente a g(n), e
escrevemos f (n) = Θ(g(n)), se f (n) = O(g(n)) e f (n) = Ω(g(n)).
No entanto, nem sempre que f (n) = O(g(n)) vale que f (n) = Ω(g(n)). Por exemplo, já sabemos que
√
√
n n +  = O(n ). Contudo, se vale que n n +  = Ω(n ), então, existem c e n tais que,
√ para todo n > n ,
√
|n n +√| > c|n |. Mas isso implicaria que para todo n > max{n , } valeria que cn 6 n n e, portanto, que
√

n 6 c , o que seria um absurdo.

Exercícios
Exercício .
Mostre que p(n) = n + n + n + n = O(n ).
Exercício .
Mostre que:
• logb n = O(log n) para qualquer base b > .
• n log n = O(n ).
• n log n , Ω(n ).
• log n! = Θ(n log n).
Exercício .
Mostre que f (n) = o(g(n)) se e somente se para todo real não-nulo c existe um natural n tal que
|f (n)| 6 c|g(n)|,
para todo n > n .
Dica: utilize a definição formal de limite.
Exercício .
que
Mostre que f (n) = ω(g(n)) se e somente se para todo real não-nulo c existe um natural n tal
|f (n)| > c|g(n)|,
para todo n > n .
Dica: utilize a definição formal de limite.
Exercício .
Mostre que f (n) = O(g(n)) e f (n) , o(g(n)) se e somente se f (n) = Θ(g(n)).
Exercício .
Mostre que f (n) = Ω(g(n)) e f (n) , ω(g(n)) se e somente se f (n) = Θ(g(n)).
Exercício .
Mostre que O(g )O(g ) = O(g g ).
Exercício .
Mostre que f O(g) = O(f g).
Exercício .
Mostre que O(g ) + O(g ) = O(|g + g |).
Exercício .
Exercício .
Exercício .
Mostre que, se k é uma constante real não-nula, então, O(kg) = O(g).
Mostre que todo polinômio p(n) = O nd(p(n)) , sendo d(p(n)) o grau do polinômio p(n).
Mostre que p(n) cn para todo polinômio p(n) e para toda constante c > .

Métodos de ordenação quadráticos
Notas de aula
– de maio de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Introdução
O problema da ordenação é um dos problemas mais clássicos da Ciência da Computação. Dada uma estrutura
de dados E indexada por [..n − ] munida de uma relação de ordem total 6, o que se quer é permutar os
elementos em E de tal modo que E[i] 6 E[i + ] para todo i.
Há vários métodos de ordenação. Os mais intuitivos são o assunto destas notas, sendo todos eles de
complexidade quadrática, como veremos.

Método de ordenação por seleção
O método de ordenação por seleção (Insertion Sort, em inglês) é talvez o mais intuitivo dos métodos de ordenação. Consiste em selecionar o menor elemento para o primeiro índice, o segundo menor elemento para o
segundo índice e assim por diante. Abaixo exibimos uma implementação do método para a ordenação de um
vetor de inteiros em linguagem C.
void SelectionSort(int* V, int n) {
int i, j, menor, aux;
for (i = ; i <= n-; i++) {
menor = i;
for (j = i+; j <= n-; j++)
if (V[j] < V[menor])
menor = j;
aux = V[i];
V[i] = V[menor];
V[menor] = aux;
}
}
.
Esboço da análise do algoritmo
O crucial na análise de qualquer algoritmo de ordenação está em contar o número de comparações e o número
de trocas realizadas. Cada uma dessas operações tem seu custo dependendo da implementação e do contexto
em que o método é empregado. Finalizar a análise do tempo dos métodos de ordenação apresentados neste
texto é simplesmente multiplicar os resultados apresentados pelos respectivos custos de cada operação.
Consideramos como comparação apenas a linha if (V[j] < V[menor]), pois as condicionais dos laços for
não são sobre dados da estrutura propriamente dita. Para cada i em [..n−], faz-se (n−)−(i +)+ = n−i −
comparações. Logo, o número de comparações é
(n −  − ) + (n −  − ) + (n −  − ) + (n −  − ) + · · · + (n − (n − ) − )
= (n − ) + (n − ) + · · · +  + 
=
n(n − )
= Θ(n )

Mas o Selection Sort no número de trocas não é quadrático. Percebamos que ele realiza uma só troca para
cada i em [..n − ], o que resulta em n −  = Θ(n) trocas.

Método de ordenação por inserção
O método de ordenação por inserção (Insertion Sort, em inglês) parte de duas observâncias:
. um vetor com um só elemento é trivialmente um vetor ordenado;
. inserir um elemento num vetor já ordenado é fácil.
Assim, o método vai inserindo cada α = V [i], i ∈ [..n − ], em V [..i − ] procurando o primeiro j de trás
para frente tal que V [j] 6 α para pôr α em V [j + ]. Abaixo exibimos uma implementação do método para a
ordenação de um vetor de inteiros em linguagem C.
void InsertionSort(int* V, int n) {
int i, j, alpha;
for (i = ; i <= n-; i++) {
alpha = V[i];
for (j = i-; j >=  && V[j] > alpha; j--)
V[j+] = V[j];
V[j+] = alpha;
}
}
. Esboço da análise do algoritmo
O Selection Sort faz o mesmo número de comparações e de trocas para todos os casos, mas isso não vale para
o Insertion Sort. Perceba que inserir um elemento pode ser feito só com uma comparação, dando sorte, ou
com todas, dando azar. No melhor caso do algoritmo, damos sorte sempre. É o caso em que a estrutura já
está ordenada. Um V [j] nunca é maior que o elemento α que estamos querendo inserir. Logo, para cada
i ∈ [..n − ], fazemos uma só comparação, totalizando n −  = Θ(n) comparações.
No pior caso, damos sempre azar. É o caso em que a estrutura está reversamente ordenada. Assim, para
cada α = V [i] que vamos inserir no subvetor V [..i − ], fazemos todas as comparações possíveis para inserirmos α em V []. Neste caso, fazemos  +  + · · · + (n − ) = n(n − )/ = Θ(n ) comparações.
O caso médio é um tanto que difícil de calcular, mas não é de se surpreender que para cada V [i] a ser
inserido tenhamos um número esperado de Θ(n) comparações, o que também resulta num total de Θ(n )
comparações, igualando o caso médio ao pior caso.
Concluímos que o método de ordenação por inserção costuma ser melhor que o por seleção em número
de comparações, já que o método por inserção por ser, no melhor caso, linear, enquanto que o por seleção é
sempre quadrático. Vejamos, entretanto, quantas trocas o método por inserção faz.
Primeiramente, o conceito de troca é um conceito um tanto que torpe para o Insertion Sort, já que as trocas
não são feitas por triangulação, como no Selection. Cada troca por triangulação envolve  movimentos de
dados. Podemos então contar os movimentos feitos pelo Insertion e depois dividir o resultado por  (uma
constante, o que não faz diferença em termos assintóticos) para obtermos o número de trocas.
Note-se que o número de movimentos é sempre o número de comparações mais . Portanto, o número de
(n−)
trocas do Inertion Sort é n+
+  = Θ(n ) no pior e Θ(n ) no caso médio.
 = Θ(n) no melhor caso,



Método de ordenação por bolha
O método de ordenação por bolha é talvez o mais simples. Fazemos uma varredura na estrutura e, cada
vez que encontramos um par de elementos consecutivos desordenados (ou seja, um i tal que E[i] > E[i +
]), trocamos aquele par. Repetimos essa varredura tantas vezes quanto necessário até que o vetor esteja
completamente ordenado. Percebemos que o vetor está ordenado quando fazemos uma varredura sem que
ocorra troca alguma. Abaixo exibimos uma implementação do método para a ordenação de um vetor de
inteiros em linguagem C.
void BubbleSort(int* V, int n) {
int houve_trocas, j, aux;
do {
houve_trocas = ;
for (j = ; j <= n-; j++)
if (V[j] > V[j+]) {
aux = V[j];
V[j] = V[j+];
V[j+] = aux;
houve_trocas = ;
}
} while(houve_trocas);
}
Simulemos a primeira varredura para o caso do vetor (, −, , , ):
(, −, , , ) ⇒ (−, , , , ) ⇒ (−, , , , ) ⇒ (−, , , , ) ⇒ (−, , , , )
Vejamos como a varredura propagou numa bolha o elemento  até o fim do vetor. Não é difícil perceber que
logo após a primeira varredura, a última posição da estrutura já contém o maior elemento. Após a segunda,
então, a penúltima posição contém o segundo maior, e assim por diante. Isso significa que na segunda varredura não precisamos levar i até a comparação de E[n−] com E[n−]. Podemos parar ao compararmos E[n−]
com E[n − ]. Logo, podemos decrementar n a cada varredura, otimização esta que implementamos a seguir.
void BubbleSort(int* V, int n) {
int houve_trocas, j, aux;
do {
houve_trocas = ;
for (j = ; j <= n-; j++)
if (V[j] > V[j+]) {
aux = V[j];
V[j] = V[j+];
V[j+] = aux;
houve_trocas = ;
}
n--;
} while(houve_trocas);
}
.
Esboço da análise do algoritmo
No melhor caso, o método de ordenação por bolha faz uma só varredura na estrutura, constatando que ela já
está ordenada. Neste caso, faz n −  = Θ(n) comparações e zero trocas.
No pior caso, quando o vetor está ordenado reversamente, cada varredura acerta só uma posição, começando da última, à exceção da última varredura, a qual acerta tanto a primeira quanto a segunda posição.
Finalmente, o pior caso do método de ordenação por bolha faz n −  varreduras. Se consideramos a otimização
do decremento de n a cada varredura, temos que o número de comparações é, portanto, (n−)+(n−)+· · ·+ =

n(n − )/ = Θ(n ). Este é também o número de trocas, já que o pior caso sempre faz uma troca para cada comparação.
Encerramos nosso esboço mencionando que Θ(n ) também é o comportamento do Bubble Sort tanto em
número de comparações quanto em número de trocas.

Exercícios
Exercício . Sorteie  vetores de elementos em [..], um pequeno, um médio e um grande. O
pequeno deve ter  elementos, o médio, , e o grande, . Ordene cada um dos  vetores com
os  métodos de ordenação apresentados e compare o número de comparações, o número de trocas e o tempo
de execução.
Exercício . Crie uma struct chamada Produto contendo os campos nome (string com  caracteres),
codigo (um número inteiro), preco, descricao (string com  caracteres), cor e peso. Crie um vetor com
 Produtos, preenchidos aleatoriamente. Use os  métodos para ordenar esse vetor e compare o tempo
de execução.
Exercício .
Implemente os métodos de ordenação apresentados para listas duplamente encadeadas.
Exercício . O modo como o Insertion Sort insere cada α no subvetor já ordenado é linear. No entanto, como
o subvetor já está ordenado, bem que poderíamos proceder de modo binário. Reimplemente o Insertion Sort
utilizando essa nova estratégia. A propósito, este algoritmo já existe e se chama Binary Insertion Sort.

MergeSort
Notas de aula
 de junho de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Introdução
Todos os métodos de ordenação que abordamos nas notas de aula anteriores (Insertion Sort, Selection Sort e
Bubble Sort) chamamos de quadráticos. Assim o fizemos porque todos eles são de tempo Θ(n ) tanto no caso
médio quanto no pior caso, sendo n o número de elementos da estrutura a ser ordenada e considerando o
tempo como o número de comparações mais o número de trocas (ou o número de movimentos, dado que uma
troca consiste de três movimentos) realizadas pelo algoritmo. A pergunta que fica é: dá para termos um
algoritmo de ordenação melhor, que custe tempo o(n )?
A resposta é: sim! É possível, entretanto, provar matematicamente que nenhum algoritmo de ordenação
pode fazer o serviço em tempo o(n log n). Ou seja, todo algoritmo de ordenação leva tempo Ω(n log n). Dizemos
que Ω(n log n) é a cota inferior para o problema da ordenação. Agora podemos fazer a pergunta de um outro
jeito: é possível ter um algoritmo que custe tempo Θ(n log n)?
E o sim permanece! Evidentemente, os algoritmos que já apresentamos não são Θ(n log n). Eles são
Ω(n log n), como todo algoritmo de ordenação, mas não Θ(n log n). Hoje mostraremos que a cota Ω(n log n)
é uma cota inferior justa. Ou seja, embora a cota diga ser impossível um algoritmo de tempo o(n log n), vamos
mostrar um algoritmo de tempo Θ(n log n) inclusive para o pior caso. Trata-se do método de ordenação por
intercalação (em inglês, Merge Sort).
O Merge Sort parte de um procedimento bem simples conhecido como merge (intercalação), o qual, dados
dois vetores já ordenados, constrói um terceiro vetor também ordenado a partir da intercalação dos dois
vetores da entrada. Por exemplo, se temos como entrada os vetores A = (, , , ) e B = (−, , , ), o resultado
é o vetor (−, , , , , , ).
O Merge Sort é um clássico exemplo dos algoritmos desenvolvidos recursivamente através da estratégia
dividir para conquistar (em inglês, divide and conquer). Quando queremos resolver um problema utilizando
essa estratégia, fazemos o seguinte:
. dividimos a instância do problema em subinstâncias menores do mesmo problema;
. recursivamente, obtemos as soluções para as subinstâncias;
. combinamos as soluções obtidas para as subsinstâncias de modo a construirmos a solução para a instância original.
No caso do Merge Sort, o que fazemos é o seguinte:
. dividimos o vetor que queremos ordenar em dois subvetores;
. recursivamente, chamamos o Merge Sort para ordenar os subvetores;
. utilizamos o procedimento merge para intercalar os dois subvetores ordenados.

A recursão do MergeSort
Em linguagem C, no contexto específico da ordenação de um vetor de int, podemos implementar o Merge
Sort como segue:
void MergeSort(int *V, int p, int r) {
int q;
if (p >= r) return;
q = (p+r) / ;
MergeSort(V, p, q);
MergeSort(V, q+, r);
merge(V, p, q, r);
}
Note-se que recebemos não apenas o vetor V , mas também qual é a porção do vetor V que devemos considerar:
o subvetor que começa na posição p e termina na posição r. Assim, se queremos ordenar um vetor V com n
posições, devemos simplesmente chamar:
MergeSort(V, , n-);
Nossa implementação divide o vetor V [p..r] em dois subvetores: V [p..q] e V [q + ..r], sendo q o meio do
vetor: q = bp + rc/. Depois, chama recursivamente o Merge Sort duas vezes: uma para ordenar V [p..q] e outra
para ordenar V [q + ..r]. Por fim, chama o procedimento merge para, a partir dos dois vetores ordenados,
construir por intercalação a ordenação de V [p..r], o vetor origina. Vamos simular o que acontece quando
chamamos o Merge Sort para ordenar um vetor com  posições V [..]. Chamamos MergeSort(V, , ):
• Esta chamada de função chama MergeSort(V, , ).
– Esta chamada de função chama MergeSort(V, , ).
* Esta chamada de função chama MergeSort(V, , ).
· Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
· Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
· merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
* Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
* merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
– Esta chamada de função chama MergeSort(V, , ).
* Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
* Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
* merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
– merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
• Esta chamada de função chama MergeSort(V, , ).
– Esta chamada de função chama MergeSort(V, , ).
* Esta chamada de função chama MergeSort(V, , ).
· Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
· Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
· merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
* Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
* merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
– Esta chamada de função chama MergeSort(V, , ).
* Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
* Esta chamada de função chama MergeSort(V, , ), mas, como  > , nada ocorre.
* merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
– merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
• merge combina os vetores já ordenados V [..] e V [..] para obter a ordenação de V [..].
E o vetor V [..] volta ordenado!


O procedimento merge
Agora só falta implementar o procedimento de intercalação (merge). Recebemos um vetor V acompanhado de
 índices p, q e r. Sabemos que V [p..q] está ordenado e V [q + ..r] também. Precisamos então intercalar esses
dois subvetores para compor todo um vetor V [p..r] ordenado. Para tanto:
. criamos dois vetores auxiliares E (de esquerda) e D (de direita): E com |E | = q−p+ posições (a quantidade
de posições em V [p..q]) e D com |D| = r − q (a quantidade de posições em V [q + ..r]);
. copiamos V [p..q] para E[..|E | − ] e V [q + ..r] para D[..|D| − ];
. posicionamos um cabeçote e sobre E[] e um cabeçote d sobre D[] e fazemos um for para iterar uma
variável j sobre as posições de V [p..r], de tal modo que, para cada iteração, fazemos V [j] ← E[e] se
E[e] 6 D[d] ou V [j] ← D[d] caso contrário, andando o cabeçote usado para a direita. Se em algum
momento o cabeçote de um dos vetores E ou D ultrapassar seus limitantes, passamos a usar apenas os
elementos do outro vetor para construirmos V .
Apresentamos a implementação da função merge.
void merge(int *V, int p, int q, int r) {
int *D, *E;
int tamD, tamE, j, d, e;
tamE = q-p+; tamD = r-q; /* cálculo de tamE = |E| e de tamD = |D| */
E = (int*)malloc(tamE*sizeof(int));
D = (int*)malloc(tamD*sizeof(int));
for (j = ; j <= tamE-; j++) E[j] = V[p+j]; /* cópia de V[p..q] para E[..tamE-] */
for (j = ; j <= tamD-; j++) D[j] = V[q++j]; /* cópia de V[q+..r] para D[..tamD-] */
e = d = ; /* inicialização dos cabeçotes */
for (j = p; j <= r; j++) {
if (e <= tamE- && d <= tamD-)
V[j] = (E[e] <= D[d]) ? E[e++] : D[d++];
else if (e <= tamE-) /* significa que d > tamD- */
V[j] = E[e++];
else /* significa que e > tamE- */
V[j] = D[d++];
/* perceba que jamais ocorrerá de ambos os cabeçotes ultrapassarem seus limitantes */
}
free(E);
free(D);
}

Esboço da análise
Vamos esboçar uma análise apenas para intuitivamente percebermos que em todo caso o Merge Sort custa
tempo Θ(n log n). Vamos primeiramente observar quanto custa o procedimento merge.
Criar os vetores E e D e copiar para eles os subvetores V [p..q] e V [q + ..r] é algo que fazemos em tempo
linear no tamanho de V [p..r]. Fazemos q − p +  movimentos para preenchermos E e r − q movimentos para
preenchermos D. Isso totaliza r − p +  = n movimentos. Depois, para cada j ∈ [p..r], fazemos uma comparação
entre E[e] e D[d] e um movimento para pôr em V [j] o elemento apropriado. Isso totaliza n comparações mais
n movimentos. Finalmente, temos que o procedimento merge demanda n movimentos (ou n/ trocas) e n
comparações, e dessarte podemos concluir que custa tempo Θ(n).
Agora, quanto custa todo o Merge Sort para ordenar V [..n − ]? Chamemos esse tempo de T (n), pois é
função do número de elementos no vetor. Se V só possui um elemento (se n = ), então, custa T () = , já que
nada é feito, nem comparações nem movimentos. Senão (se n > ), custa:
• o tempo que a chamada recursiva do Merge Sort leva para ordenar V [..m] (m = bn − c/), isto é, T (m +
) T (n/)

• mais o tempo que a chamada recursiva do Merge Sort leva para ordenar V [m+..n−], isto é, T (n−m−) T (n/)
• mais o tempo do merge, isto é, Θ(n).
Chegamos então à uma recorrência:


,


 T (n) = 
n


+ Θ(n),
 T

se n = ;
se n > .
Vamos resolver a recorrência em alguns passos para percebermos o que ocorre.
n
T (n) = Θ(n) + T

n
n
= Θ(n) + Θ
+ T


n
n
n
= Θ(n) + Θ
+ Θ
+ T



n
n
n
n
= Θ(n) + Θ
+ Θ
+ Θ
+ T




= ···
Logo,
o. nível
o. nível
o. nível
o. nível
o. nível
z }| { z }| { z }| { z }| {
z}|{
n
n
n
n
+ Θ
+ Θ
+ Θ
+···
T (n) = Θ(n) + Θ




Quantos termos tem esta soma, afinal? Por quantos níveis podemos descer até chegarmos a T () = ? Note-se
que isso é o mesmo que perguntar quantas vezes podemos dividir sucessivamente n por  até chegarmos a ,
e a resposta é blg nc. Ou seja, nesta soma há blg nc níveis. Mas note que cada nível adiciona à soma exatamente
Θ(n). Por exemplo, o nível  adiciona Θ(n/) = Θ(n), o nível  adiciona Θ(n/) = Θ(n) etc. Enfim, se temos
blg nc níveis cada um adicionando à soma Θ(n), temos como resultado que
T (n) = blg ncΘ(n) = Θ(n log n),
como queríamos mostrar.

Exercícios
Exercício . Refaça o Exercício  das notas de aula sobre métodos de ordenação quadráticos ordenando os
 vetores também com o Merge Sort e compare o número de comparações, o número de trocas e o tempo de
execução de todos os métodos.
Exercício . Refaça o Exercício  das notas de aula sobre métodos de ordenação quadráticos ordenando o
vetor também com o Merge Sort e compare o tempo de execução de todos os métodos.
Exercício .
Implemente o Merge Sort para listas duplamente encadeadas.

Quick Sort
Notas de aula
 de junho de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Introdução
Nas notas de aula anteriores, apresentamos o Merge Sort, um método de ordenação bastante simples que se
mostra ótimo em tempo. No pior caso, o tempo do Merge Sort é Θ(n log n), precisamente a cota inferior de
tempo conhecida para o problema de ordenação. Está resolvido, então? Sempre que queremos ordenar uma
coleção de dados suficientemente grande, devemos utilizar o Merge Sort? A resposta é: Não! Se o Merge Sort
é ótimo em tempo, ele é completamente indesejável em espaço. Toda chamada da função merge duplica o vetor
passado como argumento, se bem que libera o espaço alocado antes de se encerrar. Na pior das chamadas,
merge aloca Θ(n) bits adicionais, considerando que o tamanho de cada posição do vetor a ser ordenado é
constante — se não for, a coisa fica pior ainda. Logo, o Merge Sort é O(n) em espaço.
Em Análise de Espaço de Algoritmos, O(n) é uma complexidade completamente indesejável. Significa
que toda a entrada precisa ser duplicada na memória. Pense só em algoritmos em bases de dados enormes,
como toda a Internet, por exemplo. Assim como o desejável é que os algoritmos sejam polinomiais em tempo,
é desejável que sejam O(log n) em espaço. Na verdade, sabemos que todos os problemas computáveis por
algoritmos O(log n) em espaço são também computáveis por algoritmos polinomiais em tempo, embora a
recíproca ainda não se saiba ser verdadeira nem falsa, sendo um dos tantos problemas em aberto em Teoria
da Computação.
Vamos estudar um algoritmo de ordenação que é Θ(n log n) em tempo no caso médio (e no melhor caso)
e O() em espaço: o Quick Sort, proposto por um estudante em  chamado Tony Hoare. No pior caso,
todavia, o Quick Sort é Θ(n ). Entretanto, sabemos que o pior caso tem probabilidade /n! de ocorrer, e
sabemos que n! n .

Apresentação do Quick Sort
Entender a ideia geral do Quick Sort é tão simples quanto entender a ideia do Merge Sort. O Quick Sort também
é um algoritmo do tipo dividir para conquistar (divide and conquer), baseado num procedimento chamado
partition (do inglês, particione), o qual seleciona um elemento pivô do vetor V [p..r] e rearranja V de modo que,
sendo q a posição onde foi parar o pivô, todos os elementos em V [p..q − ] sejam no máximo V [q] e todos
os elementos em V [q + ..p] sejam no mínimo V [q]. Após esse procedimento, perceba-se que V [q] já está na
posição em que deve ficar até o final da ordenação completa de V . O que falta fazer, portanto, é ordenar
V [p..q − ] e V [q + ..r]. Ambas as ordenações são conquistadas através de chamadas recursivas do Quick Sort.
Classicamente, o pivô que escolhemos num vetor V [p..r] é sempre o último elemento: V [r]. Evidentemente, após a operação partition, é bem provável que esse elemento já não esteja mais na última posição do
vetor. Portanto, o procedimento partition deve sempre retornar a posição q em que foi parar o pivô. Assim,
podemos chamar o Quick Sort recursivamente para ordenar V [p..q − ] e V [q + ..r], como já mencionamos.
Em linguagem C, no contexto específico da ordenação de um vetor de int, podemos implementar o Quick
Sort como segue:
void QuickSort(int *V, int p, int r) {
int q;
if (p >= r) return;
q = partition(V, p, r);
QuickSort(V, p, q-);
QuickSort(V, q+, r);
}
Assim, se queremos ordenar um vetor V com n posições, devemos simplesmente chamar:
QuickSort(V, , n-);

A implementação da função partition
Talvez a maior dificuldade em entender o Quick Sort esteja precisamente na implementação da função partition. Para que não precisemos duplicar o vetor, o que nos levaria a um resultado execrável em espaço similar
ao do Merge Sort, valemo-nos de um truque simples, mas que pode causar certa confusão aos estudantes mais
descuidados ou desatentos. O truque consiste em construir as duas partições do vetor (à esquerda e à direita
do pivô) a partir de trocas entre as próprias partições e, por fim, entre o primeiro elemento da partição à
direita e o próprio pivô, que é mantido no final do vetor até o fim do procedimento. Sejamos mais claros.
Tomemos por exemplo o vetor

−

−






Nosso pivô será o elemento da última posição do vetor, o elemento . A princípio, a partição à esquerda estará
vazia e a partição à direita conterá todo o vetor, à exceção do pivô. Marcamos a divisão entre as partições
através de uma variável i, inicialmente i = −, a qual informa a posição do último elemento da partição à
esquerda.
i = −

-


-





Vamos começar a varredura pela partição à direita procurando elementos que são no máximo o pivô, ou seja,
elementos que não deveriam estar na partição à direita, mas na partição à esquerda. Fazemos esta varredura
através de uma variável j, a qual começa valendo  (o primeiro elemento da partição à direita).
i = −
j =

-

-






Ora, V [j] =  > , portanto, está na partição certa. Prosseguimos a varredura incrementando j.
i = −
j =

-

-






Agora temos um problema: V [j] = − 6  e não devia estar na partição à direita, mas na partição à esquerda.
Vamos, então, incrementar i, o que faz com que a partição à esquerda aumente em  seu tamanho e a partição
à esquerda diminua em  seu tamanho. Fazendo isso, porém, V [i] será V [] = , o primeiro elemento da
partição à direita. Fazemos, então, uma troca entre V [i] e [j], pois V [i] seguramente deve ir para a partição à
direita e V [j] seguramente deve ir para a partição à esquerda. Ficamos com o vetor abaixo e incrementamos j
para prosseguirmos com a varredura.
i=
-
j =


-







V [] =  está no lugar certo. Incrementamos j.
i=
j =

-


-





V [j] = − 6  está no lugar errado. Incrementando i, temos que V [i] = V [] =  também passa a estar no lugar
errado. Trocamos então V [i] com V [j], incrementamos j e ficamos com:
i=
-
-
j =












V [j] =  >  está no lugar certo. Incrementamos j.
i=
-
-
j =




V [j] =  6  está no lugar errado. Incrementando i, temos que V [i] = V [] =  também passa a estar no lugar
errado. Trocamos então V [i] com V [j], incrementamos j e ficamos com:
i=
-
-

j =









V [j] =  >  está no lugar certo. Incrementamos j.
i=
-
-

j =





V [j] =  >  está no lugar certo. Incrementamos j.
i=
-
-

j =







V [j] =  >  está no lugar certo. Incrementando j, temos que j =  ultrapassa os limites da partição à direita.
Portanto, encerramos nossa varredura e ficamos com:
i=
-
-








Perceba que todos os elementos da partição à esquerda são no máximo o pivô, e que todos os elementos da
partição à direita são no mínimo o pivô. Agora, basta pormos o pivô no lugar certo, trocando o pivô com o
primeiro elemento da partição à direita (V [i + ]). Ficamos com:
i=
-
-






Finalmente, retornamos i +  =  como a posição onde foi parar o pivô.
Apresentamos a implementação da função partition.
int partition(int *V, int p, int r) {
int i, j, pivo=V[r], aux;
for (i = p-, j = p; j <= r-; j++)
if (V[j] <= pivo) {
i++;
aux = V[i];
V[i] = V[j];
V[j] = aux;
}
V[r] = V[i+];
V[i+] = pivo;
return i+;
}




Esboço da análise do QuickSort
Nas próximas notas de aula! Não perca!

Exercícios
Exercício . Refaça o Exercício  das notas de aula sobre Merge Sort ordenando os  vetores também com
o Quick Sort e compare o número de comparações, o número de trocas e o tempo de execução de todos os
métodos.
Exercício . Refaça o Exercício  das notas de aula sobre Merge Sort ordenando o vetor também com o Quick
Sort Sort e compare o tempo de execução de todos os métodos.
Exercício .
Implemente o Quick Sort para listas duplamente encadeadas.

Esboço da análise do Quick Sort
Notas de aula
 de junho de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Esboço da análise da função partition
Reapresentamos a implementação da função partition.
int partition(int *V, int p, int r) {
int i, j, pivo=V[r], aux;
for (i = p-, j = p; j <= r-; j++)
if (V[j] <= pivo) {
i++;
aux = V[i];
V[i] = V[j];
V[j] = aux;
}
V[r] = V[i+];
V[i+] = pivo;
return i+;
}
A função partition varre todos os elementos de V [p..r − ] comparando-os com o pivô, V [r]. Portanto,
sempre faz r − p +  comparações (o número de elementos no subvetor para o qual a função partition foi
chamada). No pior caso, também faz o mesmo número de trocas. No melhor caso, não faz troca alguma.
Portanto, não é difícil perceber que em todo caso o tempo da função partition (número de comparações mais
número de trocas, como temos convencionado) é Θ(r − p + ).

Esboço da análise do Quick Sort
Quando discorremos sobre o Merge Sort, também constatamos que a função merge é sempre executada em
tempo Θ(r − p + ). Como a recursão do Merge Sort sempre divide o vetor ao meio, observamos que a árvore de
recursão do Merge Sort desce balanceadamente em blg nc níveis, e que em cada nível a soma dos tempos de todas
as chamadas da função merge sempre dá Θ(n) (Θ(n) = Θ(n/) = Θ(n/) = · · · ). Portanto, foi fácil verificar
que o tempo do Merge Sort em todo caso é blg ncΘ(n) = Θ(n log n).
De igual modo, num mesmo nível da recursão do Quick Sort todas as chamadas da função partition somam
um tempo total de Θ(n), pois cada chamada pega uma parte disjunta do vetor para particionar, como ilustrado
abaixo.








−
⇓


partition = Θ(n)






.


⇓



partition = Θ
n

⇓

-






↓

⇓

partition = Θ
n




−

(TOTAL= Θ(n))






⇓
partition = Θ




partition = Θ
n

⇓


..
.
⇓

.
⇓


&

-

⇓
⇓
..
.


n


&

(TOTAL= Θ(n))

⇓
partition = Θ

n

(TOTAL= Θ(n))
⇓


..
.
O grande problema é que, diferentemente do Merge Sort, a razão segundo a qual os vetores são divididos
em cada nível não é constante. No Merge Sort, a razão era constante: /. Portanto, a profundidade da
árvore de recursão era blg nc. No Quick Sort, se ao menos a razão fosse constante, digamos /, teríamos
que a profundidade da árvore seria blog nc, e poderíamos concluir que o tempo do Quick Sort seria sempre
blog ncΘ(n) = Θ(n log n). Agora, como ficamos se a razão não é constante?
O jeito é avaliarmos caso por caso. É intuitivo que o melhor caso é aquele em que o Quick Sort se comporta
como o Merge Sort, dividindo os vetores sempre no meio. É o caso em que as chamadas da função partition
sempre são bem-sucedidas. Nesse caso, temos que a profundidade da árvore de recursão é blg nc e concluímos
que o tempo do Quick Sort é blg ncΘ(n) = Θ(n log n).
No pior caso, todavia, as chamadas da função partition são sempre mal-sucedidas, deixando sempre um
dos subvetores vazio e o outro com o número de elementos do vetor menos . Temos, então, que no primeiro
nível o vetor tem n elementos, no segundo tem n − , no terceiro tem n −  e, assim por diante, até  elemento
no último nível. Percebemos, então, um total de n níveis, o que implica no tempo do Quick Sort no pior caso
ser nΘ(n) = Θ(n ).
Isso quer dizer que podemos esperar do Quick Sort um comportamento similar ao dos outros algoritmos
quadráticos, como Insertion, Selection e Bubble Sort, por exemplo? Não! Embora o pior caso do Quick Sort
seja Θ(n ), não é esse o tempo do caso médio (ou caso esperado). Mostrar o caso médio do Quick Sort foge ao
escopo deste curso. Então, limitamo-nos a anunciar que, no caso médio, o Quick Sort é Θ(n log n). O Quick
Sort é um exemplo de um algoritmo cujo caso médio não é assintoticamente equivalente ao pior caso, mas
ao melhor. Ademais, o pior caso do Quick Sort tem baixíssima probabilidade de ocorrer: cerca de /n!. Uma
probabilidade tão baixa que pode ser considerada nula, já que a função /n! tende muito rapidamente a .
Note-se que, para n = , /n! ,.
Concluímos que o Quick Sort é um ótimo algoritmo de ordenação, pois, além de ter um desempenho em
tempo esperado de Θ(n log n), conforme a cota inferior justa conhecida, ainda usa espaço O(), como os métodos quadráticos, ao contrário do Merge Sort, pois não aloca espaço adicional para duplicação de subvetores.


Tabela comparativa dos métodos de ordenação
Tempo do melhor caso
Tempo do pior caso
Tempo do caso médio
Espaço

Selection Sort
Θ(n )
Θ(n )
Θ(n )
Θ()
Insertion Sort
Θ(n )
Θ(n )
Θ(n )
Θ()
Bubble Sort
Θ(n)
Θ(n )
Θ(n )
Θ()
Merge Sort
Θ(n log n)
Θ(n log n)
Θ(n log n)
Θ(n)
Quick Sort
Θ(n log n)
Θ(n )
Θ(n log n)
Θ()
Exercícios
Exercício . O caso médio Θ(n log n) do Quick Sort assume que todas as entradas possíveis têm igual probabilidade de ocorrerem, o que, na prática, não é bem assim. Um jeito de contornar esse problema e forçar uma
distribuição uniforme sobre o conjunto das entradas é sortear o pivô na função partition, ao invés de pegar
sempre o último elemento. É bem simples: basta sortear um elemento do vetor, trocá-lo com o último elemento e proceder com a função partition do mesmíssimo jeito. Essa versão prática do Quick Sort é conhecida
como Randomized Quick Sort ou Probabilistic Quick Sort. Implemente-a e agregue-a aos exercícios das notas de
aula anteriores, comparando o desempenho com os demais métodos já implementados.

Métodos de busca (parte )
Notas de aula
 de maio de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Introdução
Quando manipulamos estruturas de dados, dois problemas são bastante comuns: o problema de buscar por
um dado numa estrutura, denominado BUSCA, e o problema de ordenar uma estrutura de dados, denominado
ORDENAÇÃO. Há vários algoritmos que resolvem ambos os problemas. Estudaremos dois algoritmos para o
problema da BUSCA.

Definição do problema
Antes de nos aventurarmos a estudar algoritmos que resolvam o problema da BUSCA, vamos primeiramente
entendê-lo com um pouco mais de profundidade. Todo problema computacional é definível pelas entradas
que assume e pelas saídas esperadas para cada entrada. Um algoritmo para um problema é um algoritmo que
computa sempre a saída esperada para uma entrada de acordo com as especificações formais do problema.
No caso da BUSCA, temos:
• Problema: BUSCA.
• Entrada: Uma estrutura de dados E endereçável e um dado x.
• Saída: Um endereço em E onde se encontra x ou um endereço inválido (nulo), caso x não possa ser
achado em toda a estrutura.
Então, se x está em E, um algoritmo de busca deve sempre retornar um endereço onde podemos encontrar
x. Note que esse endereço pode não ser único e que, portanto, dois algoritmos distintos podem trazer duas
respostas distintas, desde que em ambos os endereços se encontre x. Mais que isso, se x não pode ser achado
em E, um algoritmo de busca deve retornar um endereço inválido.
No caso de E ser uma estrutura implementada com vetores, um endereço é meramente um índice (ou
uma tupla de índices) para os vetores. No caso de ser uma estrutura implementada com encadeamentos de
apontadores, um endereço é um apontador para um nodo da estrutura.

Busca linear
Vamos estudar agora o algoritmo mais trivial para o problema da BUSCA: o algoritmo da busca linear. A lógica é simples: varremos toda a estrutura linearmente até encontrarmos o elemento buscado. Por linearmente,
entenda-se que começamos do primeiro endereço da estrutura, depois vamos para o segundo, depois para o
terceiro etc. Apresentamos o algoritmo na implementação de uma função em C em que queremos encontrarmos um int x num vetor V de tamanho n. Perceba-se que o retorno também é um int, correspondente ao
índice j de V onde encontramos x. Caso não encontremos x ao longo da estrutura, o endereço inválido que
retornamos é n, já que V é preenchido só até n-.
int busca_linear(int x, int* V, int n) {
int j;
for (j = ; j < n; j++)
if (V[j] == x) return j;
return n;
}
.
Análise do algoritmo
Vamos esboçar a análise do tempo do algoritmo da busca linear contando o número de comparações que ele
faz. Para tanto, analisaremos três casos: o melhor, o pior e o médio.
O melhor caso é aquele em que encontramos x logo na primeira posição do vetor. Neste caso, fazemos só
uma comparação.
O pior caso é aquele em que não encontramos x em V. Neste caso, varremos todas as posições, totalizando
n comparações.
O caso médio é o valor esperado para uma entrada aleatória, tomada sob distribuição uniforme de todas
as entradas possíveis. Um palpite intuitivo seria n = Θ(n) comparações. Na verdade, fazendo-se as contas
formalmente, verifica-se que no caso médio o algoritmo faz
 
 
 n− 

+ −
+ ··· + n  −
= Θ(n)
 + −
n
n n
n n
n
n
comparações.
Reunimos os resultados no quadro a seguir:
Busca linear
Melhor caso
Caso médio
Pior caso
Número de comparações
Θ()
Θ(n)
Θ(n)
Perceba-se que assintoticamente o número de comparações do caso médio (o número que se espera para
uma entrada tomada uniformemente) é o mesmo número de comparações do pior caso. Isso é até que bastante
comum em Análise de Algoritmos. Embora no melhor caso o algoritmo tenha um desempenho formidável,
não podemos esperar pelo melhor caso em geral. Via de regra, o algoritmo apresentado vai se comportar como
no pior caso, retornando o endereço do elemento buscado depois de um número de comparações assintoticamente linear no número de elementos da estrutura. Se a estrutura for muito grande, como por exemplo
o conjunto de todas as URLs da Internet, isso é terrível. Significa que não só no pior caso, mas também no
caso médio (o caso esperado para uma entrada tomada uniformemente), vamos ter que esperar o algoritmo
comparar a URL que buscamos com assintoticamente todas as URLs da estrutura.
Veja que não necessariamente o número de comparações determina o tempo do algoritmo. Pode ser, por
exemplo, que as comparações não sejam tão simples nem rápidas de serem executadas. No exemplo apresentado, cada comparação é uma mera operação de == em C, o que custa O() instruções do processador. Mas
pode não ser assim. Pode ser que os dados da estrutura sejam registros gigantes, e que as comparações não
sejam lá tão simples. Como tudo depende muito da aplicação e da implementação, limitaremos nossas análises apenas a contar os números de comparações, deixando em aberto a conclusão da análise de tempo dos
algoritmos.

Exercícios
Exercício . Faça um programa que sorteia um vetor V de int com   posições, sendo cada posição um
número em [..  ]. Depois, sorteia um número x em [..  ] e utiliza o algoritmo da busca

linear para buscar por x em V. Por fim, seu programa deverá imprimir o número de comparações realizadas
na busca.
Exercício . Faça um programa que sorteia um vetor V de int com   posições, sendo cada posição um
número em [..  ]. Depois, sorteia  números x , x , . . . , x em [..  ] e utiliza o algoritmo
da busca linear para buscar por cada xi em V. Por fim, seu programa deverá imprimir o número de comparações realizadas em cada uma das  buscas realizadas e a média dos  números de comparações. Execute
seu programa mais de uma vez e perceba se a média se altera significativamente. Pense a respeito.
Exercício . Implemente uma lista simplesmente encadeada de números int e o algoritmo da busca linear
para buscar por um número na lista. Perceba que o retorno da função que implementa a busca deve ser um
ponteiro para o nodo onde se encontrou o número buscado.
Exercício . Faça um programa que lê um arquivo e armazena as linhas do arquivo num vetor de strings,
devendo cada linha do arquivo ser uma posição no vetor. Os \n devem ser desconsiderados. Depois, leia da
entrada padrão uma string e busque por ela, com o algoritmo da busca linear, no vetor.
Exercício .
matriz.
Faça um programa que utiliza o algoritmo da busca linear para buscar por um float numa

Métodos de busca (parte )
Notas de aula
 de maio de 
Ciência da Computação
Estruturas de Dados I
Prof. Leandro Zatesko

Introdução
Gertrudes está pensando num número em [..]. Mas antes de prosseguirmos com a história da Gertrudes, vamos antes esclarecer a notação [..] para os leitores mais desinformados. É bem conhecida a notação [, ], a qual denota o intervalo real fechado entre  e . Pertencem a [, ] números como π, , e,
,,  e . Analogamente, [..] denota o intervalo inteiro fechado, o que exclui da nossa lista os
números π, e e ,.
Voltemos aos pensamentos da Gertrudes. O número que ela mentaliza é o número , mas unicamente o
leitor sabe disso. Seu amigo, Astrobaldo, só sabe que o número em que ela está pensando pertence ao intervalo
[..]. Com segundas intenções, Astrobaldo tenta adivinhar o número através de chutes. Para cada chute que
ele dá, Gertrudes responde: mais, menos ou acertou. As segundas intenções residem no fato de que Gertrudes
prometeu beijos a Astrobaldo:  menos o número de chutes que ele fizer para acertar a resposta, o que
significa que quanto mais chutes Astrobaldo fizer, menos beijos vai ganhar.
Se Astrobaldo utilizasse uma estratégia linear para seus chutes, com certeza demoraria muito para achar a
resposta, pois começaria do número  e só após haver feito  chutes acertaria, ficando Gertrudes prometida
a pagar apenas  beijos, o que é muito pouco para as necessidades fisio-psicológicas de Astrobaldo. Mas é
óbvio que Astrobaldo não utilizaria uma estratégia linear. Quem utilizaria? Só um algoritmo estúpido como
o da busca linear o faria. O mais natural seria começar não por , mas por um outro número. Aliás, se são
duas as possibilidades de resposta em caso de erro (mais ou menos) é natural começar pelo meio do intervalo, o
número . Por quê? Porque se  não for o número, independente de a resposta ser mais ou menos, Astrobaldo
já descarta outros  números. Bem que poderia chutar , por exemplo, para receber uma resposta mais e
descartar não , mas  números! O problema é que, chutando , ele também poderia ouvir um menos, e aí
só conseguiria descartar  números. O melhor mesmo é chutar o , com certeza.
Assim procede Astrobaldo:
• Chuta  e recebe um mais.
• Chuta o meio do intervalo [..], , e recebe um mais.
• Chuta o meio do intervalo [..], , e recebe um menos.
• Chuta o meio do intervalo [..], , e recebe um mais.
• Chuta o meio do intervalo [..], , e recebe um mais.
• Chuta o meio do intervalo [..], , e recebe um menos.
• Chuta o meio do intervalo [..], , e recebe um acertou.
Agora Astrobaldo está feliz da vida! Fez só  chutes. Portanto, vai receber  beijos e ir pra casa saciado.
A estratégia de Astrobaldo segue exatamente a mesma lógica do algoritmo da busca binária, o qual apresentaremos hoje.

Busca binária
Na aula passada vimos um algoritmo bastante trivial para o problema da BUSCA: o algoritmo da busca linear.
Vimos que, quando busca por um elemento x num vetor V [..n − ] (essa notação significa: um vetor V
indexado por [..n − ]), começa a busca do índice  e, caso não encontre x em V [], tenta o índice , depois o
índice , e assim linearmente até o índice n − .
Se, a cada tentativa fracassada de encontrar x em V [i], pudermos de algum modo saber se x não está em
V antes de i ou depois de i, podemos descartar vários índices em [..]. Agora, note-se que naturalmente
temos essa informação quando o vetor está ordenado. Dessarte, se procuramos pelo número  em V [..]
começando a busca por V [] e encontramos em V [] o número , podemos descartar todo o subintervalo de índices [..], pois todos os elementos de V [..] são no máximo  e seguramente menores que
. Assim, conseguimos restringir nosso vetor apenas ao subintervalo de índices [..], nas vias em que
caminhamos no caso de Gertrudes e Astrobaldo, relatado na Introdução.
Precisamos saber, então, dentro de um intervalo de índices [a..b], qual é o melhor índice para por ele começarmos. Já mostramos intuitivamente na Introdução que o melhor índice é o meio do intervalo, já que esse
chute maximiza o tamanho do subintervalo descartado independentemente da resposta. O meio do intervalo
[a..b], claro, é b(a+b)/c. Apresentamos, por fim, o algoritmo implementado numa função da linguagem C. Na
implementação, os elementos do vetor são do tipo int, e o intervalo de índices começa com a =  e b = n − .
Mantemos a convenção da aula anterior segundo a qual n deve ser retornado caso x não possa ser encontrado
no vetor V .
int busca_binaria(int x, int* V, int n) {
int a=, b=n-, meio;
while (a <= b) {
meio = (a+b) / ;
if (x == V[meio]) return meio;
if (x < V[meio]) b = meio - ;
else a = meio + ;
}
return n;
}
Vamos simular o algoritmo para o caso em que buscamos por  no vetor V [..] tal que:
j










V [j]










• Começamos a busca com a =  e b = . meio = . Como  < V[] = , fazemos b = .
• Agora a =  e b = . meio = . Como  > V[] = , fazemos a = .

• Agora a =  e b = . meio = . Como  > V[] = , fazemos a = .
• Agora a =  e b = . meio = . Como  = V[] = , retornamos .
Vamos simular o algoritmo novamente para o caso em que buscamos por  no mesmo vetor do exemplo
anterior.
• Começamos a busca com a =  e b = . meio = . Como  < V[] = , fazemos a = .
• Agora a =  e b = . meio = . Como  < V[] = , fazemos b = .
• Agora a =  e b = . meio = . Como  > V[] = , fazemos a = .
• Agora a =  e b = . meio = . Como  < V[] = , fazemos b = .
• Mas agora a =  >  = b, quebrando a condição do while, o que faz a função retornar .

Esboço da análise do algoritmo
Primeiramente, é importante frisar que esse algoritmo só funciona no caso de o vetor já estar ordenado. Não
estando o vetor ordenado, não é uma boa ideia ordená-lo para depois aplicar o algoritmo da busca binária, já
que ordenação custa, como veremos nas aulas seguintes, Ω(n log n) comparações, enquanto que o algoritmo
da busca linear sozinho faz o serviço em O(n) comparações.
Dediquemo-nos agora a esboçar a análise do algoritmo da busca binária. Ora, no melhor caso, o algoritmo
encontra o elemento buscado logo no primeiro índice chutado, o que dá Θ() comparações, mesmo resultado
do algoritmo da busca linear.
O pior caso é aquele em que não é possível encontrar x em V . Neste caso, dividimos o intervalo ao
meio tantas vezes quantas forem possíveis. Quantas vezes sucessivas é possível dividir o número n por ,
desprezando-se os restos? Vamos conjecturar algo a respeito:
 →  →  →  →  ∴  vezes
 →  →  →  →  ∴  vezes
 →  →  →  →  ∴  vezes
 →  →  →  →  →  ∴  vezes
 →  →  →  →  →  ∴  vezes
 →  →  →  →  →  →  ∴  vezes
Percebemos que todos os números em [..] resultam em  vezes. Todos os números em [..] resultam
em  vezes. Continuando as contas, percebemos também que todos os números em [..] resultam em  vezes.
Todos os números em [..] resultam em  vezes etc. Ou seja, os saltos (incrementos) de vezes ocorrem
sempre nas potências de . Percebemos também que lg  =  =  − , lg  =  =  − , lg  =  =  −  etc. Logo,
quantas vezes sucessivas é possível dividir o número n por ? A resposta é blg nc + . Portanto, o número de
comparações no pior caso do algoritmo da busca binária é Θ(log n). A saber, este é também o tempo do caso
médio. Mas fazer esta análise está além dos pré-requisitos deste curso.

Exercícios
Exercício .
Implemente a função busca_binaria recursivamente.
Exercício . Refaça todos os exercícios da aula anterior implementando também a busca binária e comparando os resultados da busca binária com a busca linear. Atente agora para que as estruturas estejam sempre
ordenadas.

Download