Pesquisa em arrays e pesquisa de resposta
Intervalos semiabertos, valores repetidos, limites de inserção e predicados monótonos.
Em [1,3,3,3,8], encontrar um 3 não resolve todas as consultas. A primeira posição é 1 e há três ocorrências. Fixa o resultado antes de escolheres a pesquisa: qualquer ocorrência, a primeira, a contagem ou a primeira posição que satisfaz uma condição.
lower_bound(x) devolve uma posição de inserção: a primeira posição com valor maior ou igual a x. Não devolve «o valor encontrado». A contagem obtém-se por diferença entre os dois limites, sem desreferenciar fronteiras.
Pesquisa sequencial
Percorre todas as posições até encontrares o valor. Funciona num vetor desordenado, usa espaço auxiliar e tem pior caso . O melhor caso é se encontrares o primeiro elemento. Com um alvo presente e uniformemente distribuído pelas posições, a média é comparações. Sem uma hipótese sobre as entradas, não existe essa média garantida.
Uma sentinela pode eliminar a verificação do fim dentro do ciclo, mas exige espaço extra ou alteração temporária da entrada. Tens de distinguir a sentinela de uma ocorrência real e repor qualquer valor alterado. Não altera a ordem linear do custo.
Ordenar uma vez para responderes a q pesquisas custa . Pesquisar sequencialmente custa . Com uma única pesquisa, ordenar primeiro costuma ser trabalho desnecessário. Se precisas das posições originais, guarda-as antes de ordenar.
Pesquisa binária com limites
Num vetor ordenado por ordem crescente, lower_bound(x) é a primeira posição com valor maior ou igual a x. upper_bound(x) é a primeira posição com valor estritamente maior. Ambas podem devolver n, a posição depois do último elemento. Não acedas a v[n].
Confirma que o valor existe antes de o leres: verifica l<n e v[l]==x. Se as fronteiras coincidem, a contagem é zero.
Para o limite inferior, mantém [l,r), começando em [0,n):
- Todas as posições antes de
lcontêm valores menores quex. - Todas as posições a partir de
rcontêm valores maiores ou iguais ax. - A fronteira procurada está entre
ler, incluindo os extremos.
Calcula m=l+(r-l)/2. Se v[m]<x, a ordenação permite excluir todo [l,m], ficando l=m+1. Caso contrário, m ainda pode ser a primeira posição válida, pelo que fazes r=m. O intervalo perde pelo menos uma posição. Ao terminar, l=r e as duas regiões conhecidas provam a resposta.
Passo para x=3 em [1,3,3,3,8] | l | r | m | Atualização |
|---|---|---|---|---|
| 1 | 0 | 5 | 2 | v[2]=3, portanto r=2 |
| 2 | 0 | 2 | 1 | v[1]=3, portanto r=1 |
| 3 | 0 | 1 | 0 | v[0]=1, portanto l=1 |
| fim | 1 | 1 | resposta 1 |
No limite superior, a região descartada à esquerda contém valores menores ou iguais a x. O teste passa a v[m]<=x. A diferença entre os dois limites conta as ocorrências, incluindo zero quando o alvo falta.
#include <iostream>
#include <vector>
enum class Limite { inferior, superior };
std::size_t limite(const std::vector<int>& v, int x, Limite tipo) {
std::size_t l = 0, r = v.size();
while (l < r) {
const std::size_t m = l + (r - l) / 2;
if (v[m] < x || (tipo == Limite::superior && v[m] == x)) l = m + 1;
else r = m;
}
return l;
}
int main() {
const std::vector<int> v{1, 3, 3, 3, 8};
for (int x : {0, 3, 9}) {
const auto l = limite(v, x, Limite::inferior), r = limite(v, x, Limite::superior);
std::cout << x << ": " << l << ' ' << r << ' ' << r - l << '\n';
}
std::cout << "vazio: " << limite({}, 3, Limite::inferior) << '\n';
}Executa o código para ver a saída e os gráficos.
O programa trata também o alvo menor que o mínimo, maior que o máximo e o vetor vazio. Em C++, std::lower_bound e std::upper_bound devolvem iteradores; num vector, a diferença entre iteradores dá a posição. Em iteradores apenas de avanço, o número de comparações continua logarítmico, mas os avanços podem ser lineares. Num set, prefere o método set.lower_bound.
Para q consultas «quantos valores são diferentes de x?», ordena uma vez e devolve n-(upper_bound(x)-lower_bound(x)), usando posições ou diferenças de iteradores do vetor. Se x faltar, as fronteiras coincidem e todos os n valores são diferentes; se for o máximo, o limite superior pode ser n. Não precisas de desreferenciar nenhuma fronteira. Com comparações constantes, o total é .
Com strings de no máximo M caracteres, cada comparação pode custar , dando por pesquisa binária num vetor. Numa lista ligada ordenada, obter o elemento do meio exige avanços pelas ligações; a ordenação não elimina esse custo. Copiar a sequência já ordenada para um vetor uma vez pode compensar muitas consultas, incluindo o custo de copiar as strings.
Num vetor não vazio ordenado, o máximo é v.back(): a primeira ocorrência do máximo é o seu limite inferior. Devolver sempre n-1 daria a última ocorrência quando há empates.
O custo no vetor é e o espaço auxiliar é . Usar [l,r) evita um índice -1 num tipo sem sinal e dispensa um caso especial para n=0. Alterar só um dos limites sem reveres o invariante pode causar um ciclo infinito ou saltar uma ocorrência.
Pesquisa binária da resposta
A pesquisa binária também encontra a menor resposta viável, mesmo sem um vetor explícito. Precisas de um predicado monótono: falso para respostas demasiado pequenas e verdadeiro a partir de uma fronteira.
Divide pesos positivos [4,6,3,7] em no máximo dois grupos contíguos, minimizando a maior soma. Para um limite M, percorre da esquerda para a direita e acrescenta pesos ao grupo atual enquanto couberem; quando o próximo exceder M, abre outro grupo. Se algum peso exceder M, a resposta é inviável.
A estratégia gulosa usa o menor número de grupos: o primeiro grupo termina tão tarde quanto é permitido. Qualquer solução válida não pode terminar o seu primeiro grupo mais à direita. Repetindo o argumento no sufixo, a solução gulosa nunca precisa de mais grupos que outra solução.
M | Grupos gulosos | Viável com até 2? |
|---|---|---|
| 9 | [4], [6,3], [7] | não |
| 10 | [4,6], [3,7] | sim |
| 15 | [4,6,3], [7] | sim |
Se M funciona, um limite maior também funciona, porque a mesma divisão continua válida. Pesquisa no intervalo fechado entre o maior peso e a soma total, convertendo o esquema para uma primeira resposta verdadeira. Cada avaliação do predicado percorre o vetor em ; a pesquisa faz avaliações, logo o total é com espaço auxiliar constante. Contar apenas as divisões do intervalo omite o percurso do vetor em cada teste. Com [5,2,8,4] e no máximo dois grupos, os limites são 8 e 19; M=11 exige três grupos e M=12 usa [5,2] e [8,4].
Pesos negativos invalidam a justificação gulosa. «Exatamente k grupos não vazios» só equivale a «no máximo k» aqui quando 1<=k<=n, porque podes subdividir grupos de pesos positivos sem aumentar a soma máxima. Declara esta condição antes de reutilizares o predicado.
Bisseção em números reais
Para encontrares uma raiz de uma função contínua com sinais opostos nos extremos, calcula o meio e conserva a metade que mantém a mudança de sinal. A continuidade garante a existência de uma raiz; não garante que seja única. Uma função com uma raiz de multiplicidade par pode não mudar de sinal.
Em , no intervalo [1,2], os primeiros meios são 1,5, 1,75 e 1,625. Os novos intervalos são [1,5;2], [1,5;1,75] e [1,625;1,75]. Após k divisões, a largura é . Usar o meio final dá erro absoluto de localização no máximo metade da largura, em aritmética exata.
Com double, termina também se o meio for igual a um extremo ou atingires um limite de iterações. Compara os sinais diretamente, em vez de multiplicares dois valores que podem exceder o intervalo representável. Um teste de valor pequeno de f(x) e um teste de intervalo pequeno medem coisas diferentes. Para , a aproximação a seis casas decimais é 1,732051.
Pesquisa discreta exige monotonia do predicado; bisseção com mudança de sinal exige continuidade. Justifica a hipótese usada antes de descartares metade do espaço de respostas.
Exercícios
As justificações e os programas em C++ são de autoavaliação: compara-os com os critérios da solução. Nas questões numéricas, a verificação automática confirma apenas o valor.
Encontrar as duas fronteiras
Em [2,4,4,4,7,9], traça lower_bound(4) e
upper_bound(4) com [l,r), e indica o número de ocorrências.
Pista
Os dois testes diferem na igualdade.
Mais uma pista
lower usa v[m]<4; upper usa v[m]<=4.
Solução
Lower: [0,6), m=3 dá r=3; m=1 dá r=1; m=0 dá l=1. Resultado 1. Upper: m=3 dá l=4; m=5 dá r=5; m=4 dá r=4. Resultado 4. Há 4-1=3 ocorrências, porque as posições antes do limite inferior são menores e a partir do superior são maiores.
Confere a tua resposta:
- Tracei os dois intervalos até lower=1 e upper=4.
- Contei três ocorrências pela diferença das fronteiras.
- Expliquei por que a igualdade conserva o candidato só no limite inferior.
Erros frequentes
Parar na primeira igualdade não encontra a fronteira; os limites são posições, não valores.
Pesquisar a menor capacidade
Divide [5,2,8,4] em no máximo dois grupos contíguos. Qual é a menor soma
máxima possível? Justifica a inviabilidade imediatamente abaixo da resposta,
a monotonia do predicado e o custo total em função de n, número de pesos,
e S, soma dos pesos. Os pesos são positivos e não podem mudar de ordem.
Pista
Usa um percurso guloso para um limite proposto.
Mais uma pista
Com limite 11, os grupos são [5,2], [8] e [4].
Solução
O mínimo é 12: [5,2] e [8,4]. Com 11, o percurso exige três grupos, porque
a escolha do maior prefixo que cabe prova que nenhuma divisão usa menos
grupos. Como aumentar o limite conserva qualquer divisão válida, o
predicado é monótono. Os limites iniciais são 8 e 19. Cada teste guloso
percorre os n pesos em O(n). Há O(log(S+1)) testes, logo o total é
O(n log(S+1)), com espaço auxiliar constante. O primeiro grupo guloso
acaba tão à direita quanto possível; nenhum primeiro grupo de outra
divisão pode acabar mais tarde. Aplicar esta comparação sucessivamente
prova que o guloso não usa mais grupos do que qualquer outra divisão
válida.
Confere a tua resposta:
- Obtive 12 e rejeitei 11 com três grupos.
- Justifiquei o menor número de grupos do percurso guloso e a monotonia.
- Incluí o custo linear do predicado no custo total.
Erros frequentes
Não mudes a ordem dos pesos; uma divisão por soma total/2 não prova que os grupos contíguos cabem.
Preparar uma sequência para muitas consultas
A sequência [4,9,4,2,9,4] recebe consultas «quantos valores são diferentes
de k?». Responde para k=4,5,9 e descreve um algoritmo para n valores e Q
consultas com custo logarítmico por consulta após preparação. Se as chaves
fossem strings ordenadas com no máximo M caracteres, como mudaria o custo
das pesquisas num vetor? Uma lista simplesmente ligada permitiria manter
esse mesmo custo só por ter os valores ordenados?
Solução
Ordena uma vez para [2,4,4,4,9,9]. As fronteiras são (1,4) para 4,
(4,4) para 5 e (4,6) para 9; a resposta n-(upper-lower) dá 3, 6 e 4.
O fim 6 é uma fronteira válida, sem acesso a um elemento nessa posição.
Com inteiros e comparações constantes, ordenar custa O(n log n) e cada
consulta usa duas pesquisas de O(log(n+1)), total
O(n log n+Q log(n+1)).
Para strings já ordenadas num vetor, cada comparação pode examinar M
caracteres, dando O(M log(n+1)) por consulta e O(QM log(n+1)) no total.
A lista não tem acesso direto ao meio; obter meios exige seguir ligações.
Mesmo com poucas comparações, uma pesquisa pode fazer uma quantidade
linear de avanços. Copiar uma vez a sequência ordenada para um vetor
permite recuperar o custo logarítmico por pesquisa; copiar strings também
tem custo, até O(nM). A ordenação sozinha não torna o acesso constante.
Confere a tua resposta:
- Obtive 3,6,4 para as consultas 4,5,9.
- Usei n-(upper-lower), incluindo ausência e a posição de fim.
- Justifiquei O(n log n+Q log(n+1)) com comparações constantes.
- Incluí M na comparação de strings e o percurso de uma lista ligada.