Estrutura de Dados I

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 se 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 comparações com 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 comparações.

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:

sequencial.cC

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.

Pergunta

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 chamadas empilhadas ao mesmo tempo. Com um milhão de elementos, são um milhão de quadros na pilha de execução, o que pode estourá-la. A versão iterativa usa memória constante. A busca binária recursiva, a seguir, não tem esse problema, porque divide o vetor ao meio a cada chamada.

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.

Três linhas com o vetor 2, 5, 8, 12, 16, 23, 38, 56, 72, 91, nas posições 0 a 9, procurando o 23. Passo 1: ini = 0, fim = 9, m = 4, V[4] = 16 é menor que 23, então ini passa a 5. Passo 2: ini = 5, fim = 9, m = 7, V[7] = 56 é maior que 23, então fim passa a 6. Passo 3: ini = 5, fim = 6, m = 5, V[5] = 23, encontrado na posição 5. As posições descartadas aparecem em cinza
A busca binária procurando o 23: cada passo descarta metade do trecho que ainda pode conter o valor.

A busca mantém dois índices, ini e fim, que delimitam o trecho V[ini..fim] em que x ainda pode estar:

  1. 1

    Comece com o vetor inteiro#

    ini = 0 e fim = n - 1.

  2. 2

    Enquanto o trecho não estiver vazio (ini <= fim)#

    Calcule o meio, m = ini + (fim - ini) / 2, e compare V[m] com x.

  3. 3

    Decida#

    Se V[m] == x, devolva m. Se V[m] < x, o trecho passa a ser V[m+1..fim] (ini = m + 1). Se V[m] > x, passa a ser V[ini..m-1] (fim = m - 1).

  4. 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 iterações: cerca de 20 para um milhão de elementos (veja a comparação com a sequencial).

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:

binaria.cC

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 , que volta por todas as chamadas. Como o trecho cai pela metade a cada chamada, a profundidade da recursão é no máximo : cerca de 21 para um milhão de elementos, sem risco para a pilha.

Pergunta

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.

(ini + fim) / 2 ou ini + (fim - ini) / 2?

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.

Quatro linhas com o vetor 1, 3, 3, 3, 3, 3, 3, 3, 5, 7, 7, 9, procurando a primeira ocorrência do 3. Passo 1: m = 5, V[5] = 3, resposta = 5 e fim = 4. Passo 2: m = 2, V[2] = 3, resposta = 2 e fim = 1. Passo 3: m = 0, V[0] = 1 é menor que 3, ini = 1. Passo 4: m = 1, V[1] = 3, resposta = 1 e fim = 0, e o trecho fica vazio. A resposta final é 1
Primeira ocorrência do 3: cada vez que encontra o 3, a busca guarda a posição e continua à esquerda.

A última ocorrência é simétrica (continua à direita). Com as duas, contar quantas vezes x aparece custa , mesmo que ele apareça milhares de vezes:

ocorrencias.cC
Pergunta

Dado um vetor em ordem não decrescente e um valor , queremos a quantidade de ocorrências de em , em tempo no pior caso, mesmo que apareça muitas vezes. Uma ideia é encontrar uma ocorrência qualquer com a busca binária e, a partir dela, andar para a esquerda e para a direita enquanto os elementos forem iguais a . Essa ideia cumpre o objetivo? Qual é a complexidade da solução com primeiraOcorrencia e ultimaOcorrencia? Justifique.

Ver respostaEsconder resposta

Andar para os lados não cumpre o objetivo. A busca binária custa , mas a caminhada custa , em que é o número de ocorrências. No pior caso, quase todo o vetor é igual a , é da ordem de , e o algoritmo inteiro fica , o mesmo que percorrer o vetor.

Com primeiraOcorrencia e ultimaOcorrencia, cada busca descarta metade do trecho a cada iteração, independentemente de quantas vezes aparece: ao encontrar , ela não para nem anda, só continua a busca em uma das metades. Cada uma faz no máximo iterações de custo constante, ou seja, . A contagem é ultima - primeira + 1, em . O total é , em qualquer caso.

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 perguntas, procurando sobre o intervalo de respostas possíveis.

Por exemplo, a raiz quadrada inteira de é o maior com . A pergunta "?" é "sim" para e "não" daí em diante. Então, buscamos em : quando m serve, ele é candidato e tentamos um maior. Quando não serve, tentamos um menor.

raiz.cC

Dois cuidados com números grandes: o meio é calculado como ini + (fim - ini) / 2, e o teste é feito como m <= x / m, porque m * m estouraria o long long para m perto de . Para o maior 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?

SequencialBinária
Exige vetor ordenadonãosim
Pior caso
Funciona em lista encadeadasimnão (precisa acessar o meio em )
Inserir um elemento novo (no fim) (precisa abrir espaço para manter a ordem)

Se o vetor não está ordenado, ordená-lo custa . Para uma única busca, não compensa: a sequencial resolve em . Para buscas, a sequencial custa , e ordenar e fazer buscas binárias custa , o que compensa já para maior que algumas dezenas. Se o vetor muda muito (inserções e remoções), manter a ordem fica caro, e o problema passa a pedir outra estrutura, como as árvores de busca.

bsearch

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 em com menor ou igual a um limite. A função lastTrue devolve o último valor para o qual check é verdadeiro.

ultimo.cC
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

sentinela.cC
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

ocorrencias.cC
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

turma.cC
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.

Como mover sem perder valores?

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.

Busca sequencial autoorganizadaC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

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.

Como escrever com ponteiros e trecho semiaberto?

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

lowerBound com ponteirosC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Menor capacidade do caminhão. Uma fila de pacotes precisa ser entregue, nesta ordem, por um caminhão que faz uma viagem por dia, levando os próximos pacotes da fila enquanto a soma dos pesos não passar da sua capacidade. Implemente 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.

Onde está a busca binária?

Dada uma capacidade , como saber se ela serve? E por que essa pergunta permite uma busca binária?

Ver dica 1Esconder dica 1

Para saber se serve, simule as viagens: percorra os pacotes, acumulando a carga, e comece uma viagem nova quando o próximo pacote não couber. Conte as viagens e compare com 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 : use long long para a soma, para a carga e para os limites da busca.

Menor capacidade do caminhãoC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

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.

Nesta página