2.4. Exemplo: Detecção de Anagramas¶
Um bom exemplo de problema para mostrar algoritmos com diferentes ordens
de magnitude é o problema clássico de detecção de anagramas para strings.
Uma string é um anagrama de outra se a segunda é simplesmente um rearranjo
da primeira. Por exemplo, 'amor'
e 'roma'
são anagramas. As strings
'marrocos'
e 'socorram'
também são anagramas. Por uma questão de
simplicidade, vamos assumir que as duas strings em questão são de
comprimentos iguais e que são compostas por símbolos do conjunto dos 26
caracteres alfabéticos minúsculos. Nosso objetivo é escrever uma função
booleana que receberá duas strings e devolverá se elas são anagramas.
2.4.1. Solução 1: Marcação¶
Nossa primeira solução para o problema dos anagramas verificará se cada
caractere na primeira string realmente ocorre na segunda. Se for possível
“marcar” cada caractere, então as duas strings são anagramas. A marcação
de um caractere será realizada substituindo-o pelo valor especial em
Python None
. Entretanto, como strings em Python são imutáveis, o
primeiro passo do processo será converter a segunda string em uma lista.
Cada caractere da primeira string será buscado entre os caracteres da
segunda lista e, caso encontrado, será marcado por substituição.
ActiveCode 1 mostra essa função.
Para analisar este algoritmo, devemos notar que cada um dos n
caracteres em s1
causará uma iteração por até n caracteres
na lista de s2
. Cada uma das n posições na lista será visitada
uma vez para marcar um caractere de s1
. O número de visitas
então é dado pela soma dos inteiros entre 1 e n. Vimos
anteriormente que isso pode ser escrito como
Conforme \(n\) aumenta, o termo \(n^{2}\) dominará sobre o termo \(n\) e o \(\frac {1}{2}\) pode ser ignorado. Assim, a solução é \(O(n^{2})\).
2.4.2. Solução 2: Ordenar e Comparar¶
Outra solução para o problema do anagrama utilizará o fato de que mesmo
que s1
e s2
sejam diferentes, eles são anagramas somente se
consistem exatamente dos mesmos caracteres. Então, se começarmos ordenando
cada string alfabeticamente, de a a z, concluiremos com a mesma string
se as duas strings originais forem anagramas. ActiveCode 2
mostra essa solução. Novamente, em Python podemos utilizar o método sort
em listas simplesmente convertendo cada string em uma lista no início.
À primeira vista você pode se sentir tentado a pensar que este algoritmo
é \(O(n)\), já que há somente uma simples iteração para comparar os
n caracteres depois do processo de ordenação. Entretanto, as duas
chamadas ao método sort
do Python também têm seus custos. Como
veremos no último capítulo, ordenações são tipicamente \(O(n^{2})\)
ou \(O(n\log n)\), então as operações de ordenação dominam sobre a
iteração. No fim, este algoritmo terá a mesma ordem de magnitude do
processo de ordenação.
2.4.3. Solução 3: Força Bruta¶
Uma técnica de força bruta para resolver um problema tipicamente
tenta todas as possibilidades à exaustão. Para o problema de detecção
de anagramas, podemos simplesmente gerar uma lista com todas as strings
possíveis usando os caracteres de s1
e verificar se s2
ocorre.
Entretanto, há uma dificuldade com esta abordagem. Ao gerar todas as
strings possíveis a partir de s1
, há n possíveis primeiros
caracteres, \(n-1\) possíveis caracteres para a segunda posição,
\(n-2\) para a terceira e assim por diante. O número total de
strings candidatas é \(n*(n-1)*(n-2)*...*3*2*1\), ou seja, \(n!\).
Embora algumas das strings sejam duplicadas, o programa não pode saber
disso de antemão e então ele ainda gerará \(n!\) strings diferentes.
Acontece que \(n!\) cresce mais rápido ainda do que \(2^{n}\)
conforme n cresce. De fato, se s1
contivesse 20 caracteres, haveria
\(20!=2.432.902.008.176.640.000\) possíveis strings candidatas. Se
processássemos uma possibilidade a cada segundo, ainda seriam necessários
77.146.816.596 anos para percorrer toda a lista. Esta provavelmente não
será uma boa solução.
2.4.4. Solução 4: Contar e Comparar¶
Nossa solução final para o problema do anagrama se aproveita do fato de que quaisquer dois anagramas terão o mesmo número de a’s, o mesmo número de b’s, o mesmo número de c’s e assim por diante. Para decidir se duas strings são anagramas, vamos inicialmente contar o número de vezes que cada caractere ocorre. Como há 26 possibilidades de caracteres, podemos utilizar uma lista de 26 contadores, um para cada caractere possível. Cada vez que encontramos um caractere em particular, incrementamos o seu contador naquela posição. Ao final, se as duas listas de contadores são idênticas, as strings são anagramas. ActiveCode 3 mostra esta solução.
Novamente, a solução tem algumas iterações. Entretanto, diferentemente da primeira solução, nenhuma delas é aninhada. As duas primeiras iterações utilizadas para contar os caracteres são ambas baseadas em n. A terceira iteração, comparando as duas listas de contadores, sempre demoram 26 passos, já que há somente 26 possíveis caracteres nas strings. Adicionando tudo temos \(T(n)=2n+26\) passos. Ou seja, \(O(n)\). Encontramos um algoritmo com ordem de magnitude linear para resolver este problema.
Antes de deixarmos este exemplo, devemos dizer algo sobre requisitos de espaço. Embora a última solução tenha sido capaz de ser executada em tempo linear, ela só pôde fazer isso utilizando memória adicional para armazenar as duas listas de contadores de caracteres. Em outras palavra, este algoritmo sacrificou espaço para ganhar tempo.
Isso é bastante comum. Em diversas ocasiões você deverá tomar decisões entre tempo e espaço. Neste caso, a quantidade de espaço adicional não é significativa. Entretanto, se o alfabeto utilizado tivesse milhões de caracteres, seria uma preocupação maior. Como um cientista da computação, quando dada a escolha de algoritmos, caberá a você determinar o melhor uso dos recursos computacionais dado um problema em particular.
Auto Avaliação
- O(n)
- Em um exemplo como este você deve contar o número de laços aninhados. Especialmente os laços que dependem da mesma variável, neste caso, n.
- O(n^2)
- Um único laço aninhado como este é O(n^2).
- O(log n)
- log n tipicamente é indicado por um problema que é feito iterativamente menor.
- O(n^3)
- Em um exemplo como este você deve contar o número de laços aninhados. Especialmente os laços que dependem da mesma variável, neste caso, n.
Q-1: Dado o seguinte trecho de código, qual é o seu tempo de execução em Notação O?
teste = 0
for i in range(n):
for j in range(n):
teste = teste + i * j
- O(n)
- Apesar de haver dois laços, eles não são aninhados. Você pode entender este caso como O(2n), mas podemos ignorar a constante 2.
- O(n^2)
- Cuidado, ao contar laços verifique que eles são aninhados.
- O(log n)
- log n tipicamente é indicado por um problema que é feito iterativamente menor.
- O(n^3)
- Cuidado, ao contar laços verifique que eles são aninhados.
Q-2: Dado o seguinte trecho de código, qual é o seu tempo de execução em Notação O?
teste = 0
for i in range(n):
teste = teste + 1
for j in range(n):
teste = teste - 1
- O(n)
- Olhe atentamente para a variável i do laço. Note que o valor de i é cortado pela metade cada a cada iteração. Esse é um grande indício de que a performance é melhor do que O(n).
- O(n^2)
- Verifique novamente, esse é um laço aninhado?
- O(log n)
- O valor de i é cortado pela metade a cada iteração do laço, então serão necessárias somente log n iterações.
- O(n^3)
- Verifique novamente, esse é um laço aninhado?
Q-3: Dado o seguinte trecho de código, qual é o seu tempo de execução em Notação O?
i = n
while i > 0:
k = 2 + 2
i = i // 2