[Algoritmos] Big O - Aula 1

Big O

A Big O Notation é uma forma de descrever o desempenho ou a complexidade de um algoritmo em termos do tempo de execução ou uso de memória, conforme o tamanho da entrada cresce. Ela ajuda a entender quão eficiente um algoritmo é ao lidar com grandes quantidades de dados.

Cenários

Omega (Ω) = O melhor cenário possível.

Theta (θ) = O cenário intermediário.

Big O (O) = O pior cenário possível.

O(n)

O O(n) significa que o tempo de execução do algoritmo cresce linearmente à medida que o tamanho da entrada (n) aumenta.

Se um algoritmo tem complexidade O(n), significa que, se você dobrar o tamanho da entrada, o tempo de execução também dobra.

Exemplo

Imagine que você tem uma lista de números e precisa encontrar um número específico dentro dela. O algoritmo pode simplesmente percorrer cada elemento até encontrar o número desejado.

Se a lista tiver 10 elementos, no pior caso (quando o número está no final ou não está na lista), o código faz 10 verificações. Se a lista tiver 1.000 elementos, faz 1.000 verificações. Esse crescimento linear mostra que a complexidade é O(n).

def encontrar_elemento(lista, alvo):
    for elemento in lista:  
        if elemento == alvo:
            return True  
    return False  

Drop Constants

No Big O Notation, ao analisar a complexidade de um algoritmo, desconsideramos constantes multiplicativas, pois elas não afetam o crescimento assintótico no longo prazo.

O objetivo do Big O é medir como o tempo de execução cresce conforme a entrada aumenta, e constantes não mudam a taxa de crescimento assintótica. Por exemplo:

  • O(2n) SIMPLIFICAMOS PARA → O(n)
  • O(100n) SIMPLIFICAMOS PARA → O(n)
  • O(0.5n) SIMPLIFICAMOS PARA → O(n)

O(n^2)

O O(n^2) significa que o tempo de execução do algoritmo cresce proporcionalmente ao quadrado do tamanho da entrada (n).

Ele aparece geralmente em algoritmos que usam dois loops aninhados, onde cada loop percorre a entrada (n) vezes.

Exemplo

def exemplo(n):
    for i in range(n):
        for j in range(n):  
            print(i, j)  

Perceba que se (n) for 10, teremos 100 elementos printados, ou seja, 10 ^ 2 = 100.

Neste caso, temos um loop dentro de outro loop, ambos percorrendo (n) elementos. Isso resulta em n * n = n², então a complexidade final é O(n²).

Características do O(n²)

  • Muito lento para entradas grandes.

  • Aparece em loops aninhados.

  • Pode ser otimizado para O(n log n) em alguns casos.

  • Usado em algoritmos básicos como Bubble Sort ou Selection Sort.

Drop Non-Dominants

Significa ignorar os termos menos significativos de uma função de complexidade para focar apenas no termo que mais impacta o crescimento conforme a entrada (n) aumenta.

Quando analisamos a eficiência de um algoritmo, nos preocupamos principalmente com o comportamento de sua complexidade conforme (n) cresce. Os termos de menor ordem se tornam irrelevantes para grandes valores de (n), então podemos descartá-los.

Exemplo

def exemplo(n):
    for i in range(n):
        for j in range(n):      # O(n^2)
            print(i, j) 

	for k in range(n):          # O(n)
		print(k)

Na função teríamos:

O(n² + n) SIMPLIFICAMOS PARA → O(n²)

Para valores pequenos de (n), ambos os termos importam. Mas conforme (n) cresce, o termo n2n^2n2 cresce muito mais rápido do que (n). Então, descartamos (n) e simplificamos.

O(1)

O(1) significa tempo constante, ou seja, o tempo de execução do algoritmo não muda independentemente do tamanho da entrada.

Exemplo

def soma(n):
	return n + n

Nesse caso, independente do tamanho da soma, a operação ocorre em tempo constante O(1).

Casos comuns de O(1):

  • Acessar um elemento de uma lista por índice: lista[i].

  • Acessar um valor em um dicionário: dicionario[chave].

  • Inserir um elemento em um set (conjunto), se não houver colisão.

O(log n)

O(log n) significa tempo logarítmico. Isso quer dizer que, conforme o tamanho da entrada (n) cresce, o tempo de execução cresce de forma muito mais lenta do que linearmente O(n).

Se um algoritmo tem complexidade O(log n), ele reduz a quantidade de trabalho necessária a cada passo. Isso geralmente acontece quando o problema pode ser dividido pela metade a cada iteração.

Exemplo

Binary Search:

# Vamos encontrar o 8

1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 

5 - 6 - 7 - 8    #1

7 - 8      #2

8      #3

Obs: Não vou fazer o algoritmo para poupar tempo e espaço.

Perceba que em 3 passos o algoritmo é capaz de encontrar o número:

2 ^ 3 = 8

ou seja

log_2(8) = 3

Lists

[A - B - C - D]
 0 - 1 - 2 - 3

Devemos observar nas listas, que em caso de retirarmos o último item (D), ou se adicionarmos um item ao final da lista, isso irá resultar em um O(1), tendo em vista não ter que reindexar os valores.

No caso de adicionarmos ou removermos um item no meio ou no começo da lista, isso causará a necessidade de reindexar os itens da lista, o que resulta em O(n), sendo n o número de itens da lista.

Se quisermos buscar um item da lista:

Iterar sobre a lista = O(n)

Ir direto no index = O(1)