Eu tenho que concordar que é muito estranho a primeira vez que você vê um algoritmo O (log n) ... de onde diabos vem esse logaritmo? No entanto, descobriu-se que há várias maneiras diferentes de fazer com que um termo de log apareça em notação big-O. Aqui estão alguns:
Dividindo repetidamente por uma constante
Pegue qualquer número n; digamos, 16. Quantas vezes você pode dividir n por dois antes de obter um número menor ou igual a um? Para 16, temos que
16 / 2 = 8
8 / 2 = 4
4 / 2 = 2
2 / 2 = 1
Observe que isso acaba levando quatro etapas para ser concluído. Curiosamente, também temos esse log 2 16 = 4. Hmmm ... e 128?
128 / 2 = 64
64 / 2 = 32
32 / 2 = 16
16 / 2 = 8
8 / 2 = 4
4 / 2 = 2
2 / 2 = 1
Isso levou sete etapas e log 2 128 = 7. Isso é uma coincidência? Não! Há um bom motivo para isso. Suponha que dividamos um número n por 2 i vezes. Então obtemos o número n / 2 i . Se quisermos resolver o valor de i onde esse valor é no máximo 1, obtemos
n / 2 i ≤ 1
n ≤ 2 i
log 2 n ≤ i
Em outras palavras, se pegarmos um inteiro i tal que i ≥ log 2 n, então, depois de dividir n pela metade i vezes, teremos um valor que é no máximo 1. O menor i para o qual isso é garantido é aproximadamente log 2 n, então se tivermos um algoritmo que divide por 2 até que o número fique suficientemente pequeno, podemos dizer que ele termina em O (log n) etapas.
Um detalhe importante é que não importa por qual constante você está dividindo n (contanto que seja maior que um); se você dividir pela constante k, levará log k n passos para chegar a 1. Portanto, qualquer algoritmo que divide repetidamente o tamanho da entrada por alguma fração precisará de O (log n) iterações para terminar. Essas iterações podem levar muito tempo e, portanto, o tempo de execução da rede não precisa ser O (log n), mas o número de etapas será logarítmico.
Então, de onde vem isso? Um exemplo clássico é a pesquisa binária , um algoritmo rápido para pesquisar um valor em uma matriz classificada. O algoritmo funciona assim:
- Se a matriz estiver vazia, retorne que o elemento não está presente na matriz.
- De outra forma:
- Observe o elemento do meio da matriz.
- Se for igual ao elemento que estamos procurando, retorna sucesso.
- Se for maior do que o elemento que procuramos:
- Jogue fora a segunda metade da matriz.
- Repetir
- Se for menor que o elemento que procuramos:
- Jogue fora a primeira metade da matriz.
- Repetir
Por exemplo, para pesquisar 5 na matriz
1 3 5 7 9 11 13
Primeiro, veríamos o elemento do meio:
1 3 5 7 9 11 13
^
Como 7> 5, e como o array está classificado, sabemos com certeza que o número 5 não pode estar na metade posterior do array, então podemos simplesmente descartá-lo. Isso deixa
1 3 5
Então, agora olhamos para o elemento do meio aqui:
1 3 5
^
Como 3 <5, sabemos que 5 não pode aparecer na primeira metade da matriz, então podemos lançar a primeira metade da matriz para sair
5
Novamente, olhamos para o meio desta matriz:
5
^
Como esse é exatamente o número que estamos procurando, podemos relatar que 5 está de fato na matriz.
Então, quão eficiente é isso? Bem, em cada iteração estamos jogando fora pelo menos metade dos elementos restantes do array. O algoritmo para assim que o array fica vazio ou encontramos o valor que desejamos. Na pior das hipóteses, o elemento não está lá, então continuamos reduzindo o tamanho da matriz pela metade até ficarmos sem elementos. Quanto tempo isso leva? Bem, uma vez que continuamos cortando o array pela metade repetidamente, terminaremos em no máximo O (log n) iterações, uma vez que não podemos cortar o array pela metade mais do que O (log n) vezes antes de executar fora dos elementos da matriz.
Algoritmos que seguem a técnica geral de dividir e conquistar (cortar o problema em partes, resolver essas partes e, em seguida, recompor o problema) tendem a ter termos logarítmicos por este mesmo motivo - você não pode continuar cortando algum objeto metade mais do que O (log n) vezes. Você pode querer ver a classificação por mesclagem como um ótimo exemplo disso.
Processando valores um dígito de cada vez
Quantos dígitos há no número de base 10 n? Bem, se houver k dígitos no número, então teríamos que o maior dígito é algum múltiplo de 10 k . O maior número de k dígitos é 999 ... 9, k vezes, e isso é igual a 10 k + 1 - 1. Consequentemente, se sabemos que n contém k dígitos, então sabemos que o valor de n é no máximo 10 k + 1 - 1. Se quisermos resolver para k em termos de n, obtemos
n ≤ 10 k + 1 - 1
n + 1 ≤ 10 k + 1
log 10 (n + 1) ≤ k + 1
(log 10 (n + 1)) - 1 ≤ k
Do qual obtemos que k é aproximadamente o logaritmo de base 10 de n. Em outras palavras, o número de dígitos em n é O (log n).
Por exemplo, vamos pensar sobre a complexidade de adicionar dois números grandes que são muito grandes para caber em uma palavra de máquina. Suponha que tenhamos esses números representados na base 10, e chamaremos os números m e n. Uma maneira de adicioná-los é através do método da escola primária - escreva os números um dígito de cada vez e depois trabalhe da direita para a esquerda. Por exemplo, para adicionar 1337 e 2065, começaríamos escrevendo os números como
1 3 3 7
+ 2 0 6 5
==============
Adicionamos o último dígito e carregamos o 1:
1
1 3 3 7
+ 2 0 6 5
==============
2
Em seguida, adicionamos o penúltimo ("penúltimo") dígito e carregamos o 1:
1 1
1 3 3 7
+ 2 0 6 5
==============
0 2
Em seguida, adicionamos o terceiro ao último ("antepenúltimo") dígito:
1 1
1 3 3 7
+ 2 0 6 5
==============
4 0 2
Finalmente, adicionamos o quarto ao último ("preantepenúltimo" ... eu amo o inglês) dígito:
1 1
1 3 3 7
+ 2 0 6 5
==============
3 4 0 2
Agora, quanto trabalho fizemos? Fazemos um total de O (1) trabalho por dígito (ou seja, uma quantidade constante de trabalho), e há O (max {log n, log m}) dígitos totais que precisam ser processados. Isso dá um total de complexidade O (max {log n, log m}), porque precisamos visitar cada dígito nos dois números.
Muitos algoritmos obtêm um termo O (log n) por trabalharem um dígito por vez em alguma base. Um exemplo clássico é a classificação radix , que classifica inteiros um dígito por vez. Existem muitos tipos de classificação de raiz, mas eles geralmente são executados no tempo O (n log U), onde U é o maior número inteiro possível que está sendo classificado. A razão para isso é que cada passagem da classificação leva O (n) tempo, e há um total de O (log U) iterações necessárias para processar cada um dos O (log U) dígitos do maior número sendo classificado. Muitos algoritmos avançados, como o algoritmo de caminhos mais curtos de Gabow ou a versão de escala do algoritmo de fluxo máximo de Ford-Fulkerson , têm um termo de log em sua complexidade porque trabalham um dígito por vez.
Quanto à sua segunda pergunta sobre como resolver esse problema, você pode querer olhar para esta questão relacionada, que explora um aplicativo mais avançado. Dada a estrutura geral dos problemas que são descritos aqui, agora você pode ter uma noção melhor de como pensar sobre os problemas quando sabe que há um termo de registro no resultado, portanto, não aconselho olhar para a resposta até que você a dê algum pensamento.
Espero que isto ajude!
O(log n)
pode ser visto como: Se você dobrar o tamanho do probleman
, seu algoritmo precisará apenas de um número constante de etapas a mais.