Sobes.tech
Middle

Como avaliar a complexidade de um algoritmo ou determinar a sua assimptota?

sobes.tech IA

Resposta da IA

Para avaliar a complexidade de um algoritmo, utiliza-se a análise assintótica, que permite descrever a eficiência do algoritmo em função do tamanho dos dados de entrada ($n$). Os passos principais:

  1. Definição de operações básicas: Identificam-se as operações cujo tempo de execução depende significativamente de $n$ (por exemplo, comparações, atribuições, operações aritméticas).

  2. Cálculo do número de operações: Determina-se a quantidade de operações básicas em função de $n$. Isto pode ser uma fórmula exata ou uma estimativa.

  3. Determinação da classe assintótica: Utilizam-se as notações Grande O ($O$), Omega ($\Omega$) e Theta ($\Theta$) para descrever o comportamento assintótico superior, inferior e exato, respetivamente.

    • $O(f(n))$: O algoritmo executa-se em tempo que não excede uma constante multiplicada por $f(n)$ para grandes $n$. É usado para descrever o pior caso.
    • $\Omega(f(n))$: O algoritmo executa-se em tempo que não é inferior a uma constante multiplicada por $f(n)$ para grandes $n$. É usado para descrever o melhor caso.
    • $\Theta(f(n))$: O algoritmo executa-se em tempo proporcional a $f(n)$ para grandes $n$. É usado para descrever o caso médio ou quando o melhor e pior caso têm a mesma ordem assintótica.

O uso mais frequente é a Grande O ($O$) para descrever o limite superior do tempo de execução, o que é importante para compreender a escalabilidade do algoritmo no pior cenário.

  1. Omissão de constantes e termos menores: Ao determinar a notação assintótica, ignoram-se os fatores constantes e os termos de menor ordem, pois para grandes $n$ domina a função com o maior expoente. Por exemplo, para $3n^2 + 5n + 10$, a notação assintótica será $O(n^2)$.

Classes assintóticas típicas (por ordem crescente de complexidade):

  • $O(1)$: Complexidade constante (tempo de execução não depende de $n$).
  • $O(\log n)$: Complexidade logarítmica (tempo de execução cresce muito lentamente com o aumento de $n$, característico de algoritmos de busca binária).
  • $O(n)$: Complexidade linear (tempo de execução é proporcional a $n$, característico de buscas lineares simples).
  • $O(n \log n)$: Complexidade linear-logarítmica (típico de algoritmos de ordenação eficientes como Quick Sort ou Merge Sort).
  • $O(n^2)$: Complexidade quadrática (tempo de execução cresce com o quadrado de $n$, típico de algoritmos de ordenação simples como Bubble Sort).
  • $O(n^c)$ (para $c > 1$): Complexidade polinómica.
  • $O(c^n)$ (para $c > 1$): Complexidade exponencial (tempo de execução cresce muito rapidamente com $n$, típico de buscas exaustivas).
  • $O(n!)$: Complexidade fatorial (a mais elevada classe de complexidade, cresce de forma extremamente rápida).

Para determinar a assintótica de estruturas cíclicas:

  • Blocos de código sequenciais: Soma-se a complexidade dos blocos. $O(A+B) = O(\max(A, B))$.
  • Laços aninhados: Multiplica-se o número de iterações dos laços. Um laço com $n$ iterações dentro de outro com $m$ iterações tem complexidade $O(n \times m)$. Se $m=n$, então $O(n^2)$.
  • Laços com redução do tamanho da entrada: Por exemplo, dividir por 2 a cada iteração leva a uma complexidade logarítmica ($O(\log n)$).

Exemplo:

Percurso simples de um array:

# Recontagem de operações básicas (comparações, atribuições)
# A operação principal é a comparação no ciclo
def find_max(arr):
    if not arr:
        return None
    max_val = arr[0]  # 1 atribuição (fora do ciclo)
    for i in range(1, len(arr)): # O ciclo é executado len(arr) - 1 vezes
        # Dentro do ciclo:
        # 1 comparação (if arr[i] > max_val)
        # potencialmente 1 atribuição (max_val = arr[i])
        if arr[i] > max_val:
            max_val = arr[i]
    return max_val

Se o tamanho do array for $n = \texttt{len(arr)}$, o ciclo é executado $n-1$ vezes. Em cada iteração, realiza-se um número constante de operações. O número total de operações é proporcional a $n$. Complexidade assintótica: $O(n)$.

Exemplo: Ordenação por inserção (Insertion Sort)

# Recontagem de operações básicas
# Operações principais: comparações e atribuições/deslocamentos de elementos
def insertion_sort(arr):
    # O ciclo externo é executado em $n-1$ vezes
    for i in range(1, len(arr)):
        key = arr[i] # 1 atribuição
        j = i - 1 # 1 atribuição
        # O ciclo interno no pior caso é executado $i$ vezes
        while j >= 0 and key < arr[j]:
            arr[j + 1] = arr[j] # 1 atribuição (deslocamento)
            j -= 1 # 1 subtração e 1 atribuição
        arr[j + 1] = key # 1 atribuição

O ciclo externo é executado $n-1$ vezes. No pior caso (array em ordem inversa), o ciclo interno executa em média $i$ vezes (ou até $i$ vezes). A soma das operações em todas as iterações do ciclo externo é aproximadamente $1 + 2 + ... + (n-1) \approx n^2/2$. Complexidade assintótica: $O(n^2)$ no pior e médio caso, $O(n)$ no melhor caso (array já ordenado). Ao avaliar a complexidade, geralmente considera-se o pior caso (Grande $O$).