Busca sequencial e binária
Os dois algoritmos básicos de busca em vetores, com sentinela, versões recursivas, primeira e última ocorrência, busca binária na resposta e os erros mais comuns.
Procurar um valor é uma das operações mais frequentes em um programa: um aluno pela matrícula, uma palavra no dicionário, um produto pelo código. Na análise de algoritmos, a busca sequencial e a busca binária serviram de exemplo para contar operações e comparar taxas de crescimento. Nesta página, olhamos para elas como algoritmos: variações, versões recursivas, problemas que parecem diferentes mas são resolvidos com a mesma ideia e os erros que mais aparecem ao implementá-las.
Em todas as funções, a busca recebe um vetor V com n elementos e um
valor x, e devolve a posição de x em V, ou x não estiver
lá.
Busca sequencial
A busca sequencial compara x com cada elemento, do primeiro ao último, e
para quando encontra. Ela não exige nada do vetor: funciona com os valores
em qualquer ordem. O custo é proporcional à posição de x, e, no pior caso
(x no fim ou ausente), são x.
int buscaSequencial(const int *V, int n, int x) {
for (int i = 0; i < n; i++)
if (V[i] == x)
return i;
return -1;
}Repare que cada iteração faz dois testes: i < n (ainda há elementos?)
e V[i] == x (é este?). As duas variações a seguir reduzem esse trabalho.
Busca com sentinela
Se x estiver garantidamente no vetor, o teste i < n é desnecessário: o
laço vai parar quando encontrar x. A ideia da sentinela é garantir isso
colocando uma cópia de x logo depois do último elemento, em V[n]. O laço
fica com um único teste por iteração. No fim, se a posição encontrada for
n, foi a sentinela que parou o laço, e x não estava no vetor.
/* V precisa ter espaço para n + 1 elementos */
int buscaSentinela(int *V, int n, int x) {
V[n] = x; /* a sentinela garante que o laço para */
int i = 0;
while (V[i] != x)
i++;
return i < n ? i : -1;
}O preço é que o vetor precisa ter uma posição a mais, e a função escreve
nele (por isso o parâmetro não é mais const).
Busca em vetor ordenado
Se o vetor estiver em ordem crescente, a busca pode parar assim que
encontrar um elemento maior que x: daquele ponto em diante, todos são
maiores, e x não pode estar lá. Para valores ausentes que cairiam no meio
do vetor, a busca termina mais cedo. O pior caso, porém, continua sendo
int buscaOrdenada(const int *V, int n, int x) {
int i = 0;
while (i < n && V[i] < x)
i++;
if (i < n && V[i] == x)
return i;
return -1;
}O programa abaixo conta todas as comparações (com x e com o limite
n) das três versões, em um vetor com 1000 valores pares de 0 a 1998:
A sentinela faz cerca de metade das comparações da versão básica. A busca
ordenada só ganha quando x está ausente e cairia antes do fim (o 7): ela
para no 8. Nos outros casos, faz o mesmo que a básica.
Uma busca sequencial também pode ser escrita de forma recursiva: procura
em V[0..n-1] comparando V[n - 1] com x e, se não for, procura em
V[0..n-2]. Escreva essa versão. Que problema ela tem para vetores
grandes?
Ver respostaEsconder resposta
int buscaSequencialRec(const int *V, int n, int x) {
if (n == 0)
return -1; /* vetor vazio */
if (V[n - 1] == x)
return n - 1;
return buscaSequencialRec(V, n - 1, x);
}Cada chamada diminui o vetor em um elemento, então, no pior caso, são
Busca binária
Em um vetor ordenado, dá para fazer muito melhor. A busca binária compara
x com o elemento do meio do trecho em que ele pode estar. Se for
igual, terminou. Se o elemento do meio for menor que x, toda a metade
esquerda também é, e a busca continua só na metade direita. Se for maior,
continua só na esquerda. A cada passo, metade dos candidatos é descartada.
A busca mantém dois índices, ini e fim, que delimitam o trecho
V[ini..fim] em que x ainda pode estar:
- 1
Comece com o vetor inteiro#
ini = 0efim = n - 1. - 2
Enquanto o trecho não estiver vazio (ini <= fim)#
Calcule o meio,
m = ini + (fim - ini) / 2, e compareV[m]comx. - 3
Decida#
Se
V[m] == x, devolvam. SeV[m] < x, o trecho passa a serV[m+1..fim](ini = m + 1). SeV[m] > x, passa a serV[ini..m-1](fim = m - 1). - 4
Trecho vazio#
Se
ini > fim, não sobrou nenhum candidato: devolva.
O que garante que o algoritmo está certo é uma propriedade que vale no
início de toda iteração, o invariante: se x está no vetor, então ele
está em V[ini..fim]. No começo, o trecho é o vetor inteiro. A cada
passo, só descartamos posições que não podem conter x. E o elemento do
meio já foi comparado, então ele também sai (daí o + 1 e o - 1). Quando
o trecho fica vazio, o invariante diz que x não está no vetor.
Como o trecho cai pela metade a cada iteração, a busca faz no máximo
Versão recursiva
A descrição da busca binária já é recursiva: procurar em um trecho é
comparar com o meio e procurar em uma das metades. Os casos base são o
trecho vazio (não encontrou) e o meio igual a x (encontrou). A função
recebe os limites do trecho como parâmetros:
A versão recursiva imprime cada chamada, com recuo proporcional à
profundidade. Procurando o 7, que não está no vetor, a última chamada recebe
o trecho V[2..1], vazio, e devolve
Na versão recursiva, as chamadas recursivas são precedidas de return. O
que acontece se esse return for esquecido, como em
buscaBinariaRec(V, m + 1, fim, x, nivel + 1);?
Ver respostaEsconder resposta
A chamada recursiva calcula a resposta, mas ela é descartada, e a
função chega ao fim sem executar um return. Em C, usar o valor de uma
função que termina assim é comportamento indefinido. O compilador
avisa (control reaches end of non-void function). O mais traiçoeiro é
que, sem otimizações, o programa costuma funcionar por acaso: o
valor devolvido pela última chamada fica no registrador usado para o
retorno e "sobe" até a primeira. Com otimizações ou outro compilador, o
resultado pode ser qualquer coisa. Por isso, avisos do compilador não
devem ser ignorados.
As duas expressões dão o mesmo meio, mas ini + fim pode estourar o
int quando os dois índices são grandes (acima de cerca de um bilhão), e
o resultado vira um número negativo. Com vetores, isso só acontece em
vetores enormes. Mas a busca binária também é usada sobre intervalos de
valores (veja a busca na resposta), e
aí números dessa ordem são comuns. ini + (fim - ini) / 2 nunca passa de
fim, então não estoura. Esse erro ficou escondido por anos na busca
binária da biblioteca padrão do Java.
Primeira e última ocorrência
Se o vetor tem valores repetidos, a busca binária encontra alguma
ocorrência de x, não necessariamente a primeira. Em
{1, 3, 3, 3, 3, 3, 3, 3, 5, 7, 7, 9}, procurando o 3, ela encontra o da
posição 5. Para encontrar a primeira ocorrência, a ideia é não parar ao
encontrar x: guardamos a posição como candidata e continuamos procurando
na metade esquerda, onde pode haver outra ocorrência mais à esquerda.
A última ocorrência é simétrica (continua à direita). Com as duas, contar
quantas vezes x aparece custa
Dado um vetor primeiraOcorrencia e ultimaOcorrencia? Justifique.
Ver respostaEsconder resposta
Andar para os lados não cumpre o objetivo. A busca binária custa
Com primeiraOcorrencia e ultimaOcorrencia, cada busca descarta
metade do trecho a cada iteração, independentemente de quantas vezes
ultima - primeira + 1, em
Uma variação próxima responde "em que posição x deveria entrar para o
vetor continuar ordenado?", ou seja, qual é o primeiro elemento maior ou
igual a x. É o que a
tabela de símbolos com vetor ordenado
usa para inserir uma chave nova, e é o segundo exercício de implementação
desta página.
Busca binária na resposta
A busca binária não precisa de um vetor. O que ela precisa é de uma
pergunta de sim ou não que seja monotônica: a resposta é "não" até um
ponto e "sim" daí em diante (ou o contrário). Nesse caso, dá para encontrar
o ponto de virada com
Por exemplo, a raiz quadrada inteira de m serve, ele é
candidato e tentamos um maior. Quando não serve, tentamos um menor.
Dois cuidados com números grandes: o meio é calculado como
ini + (fim - ini) / 2, e o teste m <= x / m,
porque m * m estouraria o long long para m perto de long long, a busca faz só 63 iterações.
A mesma técnica resolve muitos problemas de "qual é o menor valor que serve?", como o último exercício desta página.
Sequencial ou binária?
| Sequencial | Binária | |
|---|---|---|
| Exige vetor ordenado | não | sim |
| Pior caso | ||
| Funciona em lista encadeada | sim | não (precisa acessar o meio em |
| Inserir um elemento novo |
Se o vetor não está ordenado, ordená-lo custa
A biblioteca padrão de C tem uma busca binária pronta, a bsearch, que
funciona com vetores de qualquer tipo. Ela recebe uma função de comparação
como parâmetro, assunto da página de
funções de callback.
Exercícios
Encontre e corrija o erro
Programa 1
O programa abaixo usa a busca binária na resposta
para encontrar o maior lastTrue devolve o último valor para o qual check é verdadeiro.
Resposta
A primeira busca termina, mas a segunda nunca termina, e a execução é
interrompida por tempo. Quando check(m) é verdadeiro, m ainda pode ser a
resposta, então ini = m está certo (e não ini = m + 1). O problema é a
combinação com o meio arredondado para baixo. Com o limite 30, o trecho
chega a [5, 6]: o meio é 5 + (6 - 5) / 2 = 5, check(5) é verdadeiro
(25 é menor que 30), e ini recebe 5 de novo. Em um trecho de dois
elementos, o meio arredondado para baixo é o próprio ini, e o trecho nunca
diminui. A correção é arredondar o meio para cima, para que m seja
sempre maior que ini:
int m = ini + (fim - ini + 1) / 2;Na primeira busca (limite check nunca é verdadeiro, só fim
diminui, e o laço termina. Por isso o erro não aparece em todas as entradas.
A regra prática: se o laço faz ini = m, o meio precisa ser arredondado para
cima. Se faz fim = m, para baixo.
Programa 2
Resposta
O programa termina com heap-buffer-overflow em uma escrita, na linha
V[n] = x. A busca com sentinela escreve na posição n, logo depois do
último elemento, mas o vetor foi alocado com espaço para exatamente n
inteiros. A posição da sentinela precisa ser reservada:
int *V = malloc((n + 1) * sizeof(int));Programa 3
Resposta
A contagem do 3 e a do 7 saem erradas (3 em vez de 7, e 1 em vez de 2). A
ultimaOcorrencia está certa, mas primeiraOcorrencia é uma busca binária
comum: ela devolve a primeira posição que encontra, que pode estar no
meio de uma sequência de repetidos. Ao encontrar x, ela precisa guardar a
posição e continuar procurando à esquerda:
int ini = 0, fim = n - 1, resposta = -1;
while (ini <= fim) {
int m = ini + (fim - ini) / 2;
if (V[m] == x) {
resposta = m;
fim = m - 1;
} else if (V[m] < x) {
ini = m + 1;
} else {
fim = m - 1;
}
}
return resposta;Programa 4
Resposta
Três matrículas são encontradas e duas não, embora todas estejam na turma. A busca binária está certa, mas o vetor está ordenado por nome, e a busca é por matrícula. A busca binária exige o vetor ordenado pela mesma chave usada na comparação. Sem isso, ela descarta metades que contêm o valor procurado. Há duas correções: ordenar a turma por matrícula antes das buscas (perdendo a ordem alfabética), ou usar uma busca sequencial.
Implemente
Busca autoorganizada. Em muitas aplicações, alguns valores são
procurados muito mais que outros. Uma busca sequencial pode tirar proveito
disso reorganizando o vetor a cada busca bem-sucedida, para que os valores
mais procurados fiquem perto do início. Implemente as duas estratégias
clássicas de auto.h: mover para a frente (o valor encontrado vai para
a posição 0, e os que estavam antes dele andam uma posição) e
transposição (o valor encontrado troca de lugar com o anterior). As
duas devolvem a posição em que o valor estava.
Em buscaMoveFrente, o que acontece com os elementos entre a posição 0 e
a posição em que x foi encontrado?
Ver dicaEsconder dica
Eles andam uma posição para a direita, como na inserção em um vetor
ordenado. Desloque do fim para o começo, a partir da posição de x, e
só depois escreva x na posição 0. Trocar V[0] com V[i] não é a
mesma coisa: muda a ordem dos outros elementos.
lowerBound. A biblioteca padrão de C++ tem a função
std::lower_bound,
que, em um trecho ordenado, devolve o primeiro elemento maior ou igual a
x. Implemente a versão em C de lowerbound.h, que recebe o trecho como
dois ponteiros, ini (o primeiro elemento) e fim (uma posição depois
do último), e devolve um ponteiro para o elemento encontrado, ou fim se
nenhum elemento for maior ou igual a x. Por exemplo, em
{1, 3, 3, 3, 5, 7, 7, 9, 12}, lowerBound devolve a posição 1 para
x = 3, a posição 4 para x = 4 e fim para x = 13. Os testes fazem
300 mil buscas em vetores com um milhão de elementos, com muitos repetidos.
Com o trecho [ini, fim), fim não é um elemento do trecho. O que muda
na busca binária?
Ver dica 1Esconder dica 1
A resposta é sempre um ponteiro entre ini e fim, incluindo fim
(que significa "nenhum"). Mantenha essa propriedade e pare quando os dois
ponteiros se encontrarem: while (ini < fim). No fim, ini é a
resposta.
Ver dica 2Esconder dica 2
O meio é ini + (fim - ini) / 2: a diferença de dois ponteiros é um
número de elementos, e somá-la a um ponteiro anda essa quantidade de
elementos (ini + fim nem compila). Se *m < x, o m e tudo antes dele
estão fora: ini = m + 1. Senão, m é candidato, mas pode haver outro
antes: fim = m (e não m - 1, porque o próprio m pode ser a
resposta).
Ver dica 3Esconder dica 3
Com lowerBound, a contagem de ocorrências de um inteiro x sai de
graça: lowerBound(ini, fim, x + 1) - lowerBound(ini, fim, x).
Menor capacidade do caminhão. Uma fila de menorCapacidade, que devolve a menor
capacidade que permite entregar tudo em no máximo dias viagens. Por
exemplo, com os pesos 1, 2, ..., 10 e 5 dias, a resposta é 15 (as viagens
levam 1 a 5, 6 e 7, 8, 9 e 10). Os testes têm 200 mil pacotes com pesos de
até um bilhão.
Dada uma capacidade
Ver dica 1Esconder dica 1
Para saber se dias. Isso custa
Ver dica 2Esconder dica 2
Se uma capacidade serve, qualquer capacidade maior também serve: a pergunta é monotônica. A resposta está entre o maior peso (todo pacote precisa caber) e a soma dos pesos (uma viagem só). Faça a busca binária nesse intervalo, como na raiz quadrada inteira.
Ver dica 3Esconder dica 3
Com 200 mil pacotes de até um bilhão, a soma passa de long long para a soma, para a carga e para os limites da busca.
Referências
- T. H. Cormen, C. E. Leiserson, R. L. Rivest e C. Stein. Algoritmos: Teoria e Prática. 3ª ed. Elsevier, 2012.
- N. Ziviani. Projeto de Algoritmos com Implementações em Pascal e C. 3ª ed. Cengage Learning, 2010. Capítulo 5 (pesquisa em memória primária).
- D. E. Knuth. The Art of Computer Programming, Volume 3: Sorting and Searching. 2ª ed. Addison-Wesley, 1998. Seção 6.1 (busca sequencial, sentinela e busca autoorganizada) e 6.2.1 (busca binária).
- J. Bloch. Extra, Extra: Read All About It: Nearly All Binary Searches and Mergesorts are Broken. Google Research Blog, 2006.
Backtracking
Busca exaustiva, poda e backtracking. Gerar todas as possibilidades, cortar cedo as que não servem e desfazer escolhas, com o labirinto como estudo de caso.
Tipo Abstrato de Dados
Interface, implementação e cliente. Como esconder os campos de uma struct, separar um programa em módulos e compilar cada parte separadamente.