Algoritmos e complexidade
Pesquisar, justificar uma redução e comparar crescimento do trabalho para entradas maiores.
Procuramos 14 em [2, 5, 8, 11, 14, 17, 20]; podemos visitar os valores desde o início ou usar a ordenação para excluir metade dos candidatos. O contrato deve dizer se queremos uma posição qualquer ou a primeira ocorrência, e como representar a ausência.
Pesquisa linear e pesquisa binária
A pesquisa linear visita os elementos por ordem até encontrar o alvo; escolhemos neste exemplo devolver um índice, ou None quando o alvo falta, convenção que evita confundir ausência com o índice zero.
A pesquisa binária exige uma lista ordenada. Mantém um intervalo de candidatos, compara com o meio e conserva só a metade possível.
def linear(valores, alvo):
for i, valor in enumerate(valores):
if valor == alvo:
return i
return None
def binaria(valores, alvo):
inicio, fim = 0, len(valores) - 1
while inicio <= fim:
meio = (inicio + fim) // 2
if valores[meio] == alvo:
return meio
if alvo < valores[meio]:
fim = meio - 1
else:
inicio = meio + 1
return None
valores = [2, 5, 8, 11, 14, 17, 20]
print(linear(valores, 14), binaria(valores, 14))
print(binaria(valores, 9), binaria([], 2))Executa o código para ver a saída e os gráficos.
| Intervalo de índices | Meio e valor | Decisão ao procurar 14 |
|---|---|---|
[0, 6] | 3, valor 11 | Excluir até 3, porque 14 é maior |
[4, 6] | 5, valor 17 | Excluir a partir de 5, porque 14 é menor |
[4, 4] | 4, valor 14 | Encontrado |
A condição inicio <= fim inclui o último candidato, e atualizar para meio - 1 ou meio + 1 exclui o meio já comparado para garantir progresso.
Se o alvo faltar, o intervalo torna-se vazio. A ordenação é a razão para descartar cada metade, e numa lista desordenada essa decisão pode perder o alvo.
Com valores repetidos, esta função pode devolver qualquer posição com o alvo. Se o contrato pede a primeira, encontrar uma ocorrência ainda não chega; tens de continuar à esquerda ou usar outra estratégia.
Contar operações
Conta primeiro num caso concreto: com 7 elementos, a pesquisa linear pode fazer 7 comparações; a binária do exemplo encontra 14 em 3 iterações. Se n é o comprimento da lista, a pesquisa linear faz até n comparações de igualdade. A binária reduz o número de candidatos aproximadamente a metade por iteração, fazendo um número de iterações proporcional a .
A notação é um limite superior do crescimento do trabalho para entradas suficientemente grandes. Por exemplo, é : a partir de , nunca ultrapassa . Também é , mas esse limite é menos informativo. Para exprimir um limite superior e inferior da mesma ordem, usa-se ; é . Dizer apenas não prova que um algoritmo precise de trabalho quadrático.
| Crescimento típico | Situação |
|---|---|
| Aceder a uma posição de lista | |
| Pesquisa binária numa lista ordenada | |
| Percorrer toda a lista uma vez | |
Ordenação por comparação como limite geral de sorted | |
Comparar todos os pares de n elementos |
Esta análise é um complemento para comparar estratégias. O tamanho da entrada e a operação contada têm de estar definidos. Os inteiros Python podem ter tamanho arbitrário; tratar qualquer operação aritmética como constante é uma simplificação que exige valores de tamanho limitado.
Ciclos sucessivos e aninhados
Dois percursos sucessivos de n elementos fazem trabalho proporcional a , portanto linear, enquanto dois percursos aninhados, ambos de n, fazem visitas. Não é o número de ciclos escritos que decide o custo, é quantas vezes o corpo executa.
n = 4
pares = 0
for i in range(n):
for j in range(i + 1, n):
pares += 1
print(pares)
A saída é 6: para i = 0, visitamos j = 1, 2, 3; para i = 1, visitamos j = 2, 3; para i = 2, só j = 3; para i = 3, não há parceiros. As visitas são e, em geral, somamos , logo o crescimento é quadrático embora o ciclo interior não percorra sempre n posições.
Custos das operações mais usadas
Uma fatia de k elementos de lista cria outra lista e custa trabalho proporcional a k. Inserir no início desloca elementos. append tem custo amortizado constante.
Ver custos com hipóteses
Algumas inserções com append exigem aumentar a capacidade. Pertença numa lista pode visitar todos os elementos; num dicionário ou conjunto, espera-se custo médio constante com hipóteses adequadas sobre hashes, mas o pior caso pode ser linear.
Para uma única procura, ordenar primeiro costuma acrescentar trabalho desnecessário. Para muitas procuras sobre a mesma lista, a preparação pode compensar.
Uma medição em segundos compara uma implementação e um ambiente; a análise de crescimento explica o que muda quando a entrada aumenta. Nenhuma destas medidas substitui a prova de que a resposta está correta.
Para aprofundar invariantes de ciclos e crescimento do trabalho, tens este complemento opcional de Rodrigo Dias:
Exercícios
Construir um contraexemplo à pesquisa binária
Aplica a função binaria da lição à lista [4, 1, 8, 2] para procurar 4.
Regista os intervalos visitados e o resultado. Usa esse percurso para explicar
por que não basta os valores serem distintos. Se ordenares a lista, a posição
devolvida ainda identifica a posição na entrada original?
Pista
Solução
No intervalo [0, 3], o meio é 1 e contém 1. Como 4 é maior, a função conserva
[2, 3] e perde o índice 0, onde estava o alvo. O meio seguinte é 2 e contém 8;
a função passa para [2, 1], um intervalo vazio, e devolve None.
A exclusão só é válida com a lista ordenada. A pesquisa linear encontra 4 no
índice 0. Ordenando, obtemos [1, 2, 4, 8] e encontramos 4 no índice 2, que é
uma posição da lista nova e não da entrada original.
Confere a tua resposta:
- Segui os meios 1 e 2, com valores 1 e 8.
- Obtive None apesar de 4 existir.
- Justifiquei a pré-condição de ordenação.
Seguir uma pesquisa sem resultado
Segue a função binaria da lição ao procurar 9 em [2, 5, 8, 11, 14, 17, 20]. Regista inicio, fim, meio e o valor do meio em cada iteração. Indica o resultado, o número de iterações e o número de comparações entre o alvo e um valor da lista. Conta cada == e cada < executado, sem contar a condição do while.
Pista
<.Solução
| Intervalo | Meio | Valor | Intervalo seguinte |
|---|---|---|---|
[0, 6] | 3 | 11 | [0, 2] |
[0, 2] | 1 | 5 | [2, 2] |
[2, 2] | 2 | 8 | [3, 2] |
O último intervalo é vazio porque inicio > fim, por isso a função devolve None. Houve três iterações e seis comparações entre valores: em cada iteração, a igualdade falhou e foi preciso avaliar <. Definir a operação contada evita confundir iterações com comparações.
Confere a tua resposta:
- Segui [0, 6], [0, 2] e [2, 2].
- Terminei em [3, 2] e None.
- Distingui 3 iterações de 6 comparações entre valores.
Contar pares distintos
Para n = 5, quantas visitas faz o ciclo sobre todos os pares i < j da lição?
Pista
Mais uma pista
Solução
Erros frequentes
Devolver a primeira posição
Define primeira_posicao(valores, alvo): recebe uma lista, que pode estar
desordenada, e devolve o índice da primeira ocorrência do alvo, ou None se
faltar. Conserva a entrada. Justifica para ti por que podes parar quando
encontras o primeiro igual.
def primeira_posicao(valores, alvo):
return NoneVerifica o código para comparar o resultado com os testes.
Testes
Desordenada e com repetição
Código do testeprint(primeira_posicao([8, 3, 8, 1], 8))Esperado:0
Repetição depois do início
Código do testeprint(primeira_posicao([2, 7, 7, 9], 7))Esperado:1
Última posição
Código do testeprint(primeira_posicao([3, 1, 4], 4))Esperado:2
Alvo ausente
Código do testeprint(primeira_posicao([2, 7], 5))Esperado:None
Lista vazia
Código do testeprint(primeira_posicao([], 1))Esperado:None
Entrada preservada
Código do testexs = [8, 3, 8, 1] print(primeira_posicao(xs, 3)) print(xs)Esperado:1 [8, 3, 8, 1]
print(primeira_posicao([8, 3, 8, 1], 8))0
print(primeira_posicao([2, 7, 7, 9], 7))1
print(primeira_posicao([3, 1, 4], 4))2
print(primeira_posicao([2, 7], 5))None
print(primeira_posicao([], 1))None
xs = [8, 3, 8, 1]
print(primeira_posicao(xs, 3))
print(xs)1 [8, 3, 8, 1]
Pista
Sem ordenação, não podes descartar metade por comparar com o meio.
Mais uma pista
enumerate fornece o índice e o valor. Devolve o índice no primeiro igual; só
devolve None depois do percurso.
Solução
O percurso começa no índice zero e avança por ordem. Quando encontra o alvo, todas as posições anteriores já foram excluídas. No pior caso faz n comparações para uma lista de n elementos.
def primeira_posicao(valores, alvo):
for indice, valor in enumerate(valores):
if valor == alvo:
return indice
return NoneErros frequentes
Ordenar a entrada e devolver o índice novo, usar zero para ausência ou continuar a procurar e devolver a última ocorrência.
