Ads 468x60px

terça-feira, 4 de outubro de 2011

Introdução à análise de algoritmos

Fluxograma do algoritmo de ordenação bogosort

A análise de algoritmos é geralmente vista como algo enfadonho e sem utilidade real, até porque as linguagens modernas poupam grande parte do trabalho e trazem bibliotecas que implementam algoritmos de ordenação, busca etc., sem que o programador precise se preocupar com isso. Claro, se quiser ser mais um na multidão, não precisa se preocupar mesmo. Bons programadores pensam um pouquinho diferente, e com sorte o leitor entenderá a importância de estudar análise de algoritmos.


O que é análise de algoritmos?


Trata-se de um estudo fundamental em teoria da computação, que aborda os algoritmos sob 2 aspectos:

  • Eficiência: O quão rápido o algoritmo pode ser executado? Isso nos permite verificar qual é o algoritmo mais vantajoso para realizar determinada tarefa. Por exemplo, há inúmeros algoritmos de ordenação, então qual usarei?

  • Exatidão: O negócio pode até ser mais rápido que a luz, mas está correto? Na prática, temos inúmeros programas que parecem funcionar muito bem, até o infeliz do usuário entrar com um valor estranho e destruir a lógica do programa — isso no melhor dos casos, quando não forem descobertas falhas de segurança. A análise de algoritmos nos permite ter certeza de que o algoritmo produzirá uma saída adequada a qualquer entrada.

A análise de algoritmos e o desenvolvimento de software


A pergunta aqui não é nem se a análise de algoritmos é importante, mas como. A computação é toda sobre resolver problemas computacionais, problemas estes que geralmente apresentam semelhanças e podem ser reduzidas a um único caso comum, como na matemática. Quer ordenar um conjunto de clientes por nome? Ora, trata-se de um problema de ordenação de vetor, para o qual dispomos de inúmeros algoritmos.

Então ao invés de reinventar a roda, por que não estudar esse único problema, o de ordenação de um vetor, e selecionar a melhor resposta para usar em qualquer programa? Ou seja, podemos dizer que a análise de algoritmos nos ajuda a identificar estruturas comuns em situações diferentes, o que nos permite um estudo generalizado de algoritmos. Aí também se enquadram os paradigmas de projeto e análise, como programação dinâmica e algoritmos gulosos, mas não veremos neste post.

Análise assintótica e análise de casos


Dito isso, precisamos abstrair um pouco e recorrer à matemática. Aguenta aí um pouco que não é nada do outro mundo, e também não é necessário aprofundar tanto assim agora. Considere as funções

f(x) = x²
g(x) = 2x²
h(x) = 20x²
i(x) = 20x² + 20

E imagine o gráfico de cada uma delas. Ou melhor, veja abaixo:

Wolfram Alpha LLC. 2011. Wolfram|Alpha. http://tinyurl.com/4ysjtk2 (4 de outubro, 2011).

Bem, você provavelmente não precisaria do gráfico para "adivinhar" que as funções posteriores teriam valores iniciais maiores do que as primeiras. Mas precisamos de um método de comparar funções que considere a velocidade de crescimento delas. Quando tomamos valores de x suficientemente grandes, vemos que todas as funções acima crescem com a mesma velocidade — dizemos que pertencem à mesma ordem.

Quando fazemos esse tipo de comparação, estamos utilizando a análise assintótica. Para isso dispomos de 3 notações: notação de grande-O, notação de grande-Ômega e notação de grande-Teta. Por quê? Já veremos.

Notação de grande-O


Considere duas funções f e g, um valor n0 e um valor positivo qualquer c. Se f está na ordem O de g, ou seja, f = O(g) (mais rigorosamente, f está contido em O(g)), então

f(n) ≤ c * g(n)

Para todo n ≥ n0. Isso significa que c * g(n) é o limite máximo de f(n), que nunca excederá tal valor. Em termos mais cotidianos, dizemos que estamos diante do pior caso. Trata-se de uma noção importante, pois se conseguirmos garantir um processamento eficiente do pior caso, o processamento de qualquer outro caso também será eficiente. Em uma ordenação de vetor, geralmente o pior caso é quando o vetor está totalmente desordenado (em ordem decrescente ao invés de crescente, por exemplo).

Não precisa se preocupar muito com a matemática aqui. O fundamental é ter a seguinte noção: se uma função é definida na ordem O(n), podemos ter certeza que a função terá uma velocidade de crescimento equivalente à da função g(n) = n, e sempre inferior a h(n) = 2^n, por exemplo. Estamos estabelecendo o limite superior, portanto.

PS: Donald Knuth originalmente queria que o "O" fosse a letra grega ômicron, então esta seria a notação de grande-Ômicron, mas hoje em dia o mais comum é usar a letra latina "O".

Notação de grande-Ômega


Aqui é o contrário. Considerando o mesmo blablablá que disse na seção anterior, se f está na ordem ômega de g, ou f = Ω(g) (entenda-se f está contido em Ω(g)), então

f(n) ≥ c * g(n)

Para todo n ≥ n0. E se a notação de grande-O denota o pior caso, a notação de grande-Ômega lida com o melhor caso. Ou seja, a eficiência do algoritmo atinge seu ponto máximo em c * g(n), com a menor velocidade de crescimento. É o limite inferior.

Notação de grande-Teta


Já lidamos com o pior caso, com o melhor caso... Ora, falta o caso médio. Dessa forma, f está na ordem teta de g, ou f está contido em Θ(g), quando

c * g(n) ≤ f(n) ≤ c' * g(n)

Para valores suficientemente grandes de n e valores positivos c e c' quaisquer. De forma equivalente, se f = O(g) e f = Ω(g), então f = Θ(g). Se ficou perdido com as letras e desigualdades, explico melhor: f não é nem o pior caso nem o melhor caso; é algo entre os dois limites, o caso médio.

Tira do site xkcd: o problema do caixeiro viajante
Travelling Salesman Problem, por Randall Munroe (CC BY-NC 2.5). Veja o artigo na Wikipédia sobre o problema do caixeiro viajante.

Algoritmo de ordenação - insertion sort


Muito bem, agora vamos ao lado mais prático da coisa. Usaremos o que vimos até aqui para estudar um algoritmo que resolve o problema de ordenação: dada uma sequência de elementos (a1, a2, ..., an), o algoritmo deve retornar uma sequência (a1', a2', ..., an') com os mesmos elementos, de tal forma que a1' ≤ a2' ≤ ... ≤ an'. O insertion sort é um algoritmo prático e eficiente para pequenas sequências.

Descrição do insertion sort


Imagine uma pilha de cartas na mesa e que você pegue uma por uma, de modo que fiquem ordenadas em sua mão. Como o faria? A estratégia mais natural seria selecionar uma carta, comparar com as cartas que já estão na sua mão e posicioná-la adequadamente, correto? Pois bem, essa é a exata mesma estratégia do insertion sort — ordenação por inserção.

Dado um vetor A[0..r], podemos montar um sub-vetor contendo os elementos já em nossas mãos. No início, esse vetor é A'[0], depois A'[0, 1], depois A'[0, 1, 2]... de forma que A' sempre esteja ordenado. Sempre que pegamos um novo elemento de A, precisamos comparar com todos os elementos de A' para posicioná-lo adequadamente. Confira o vídeo abaixo se quiser:


Ok, agora já podemos defini-lo em termos de pseudocódigo, dado um vetor de inteiros A com uma quantidade n de elementos:

Insertion-sort(A, n)


  inteiro x, j, i;

  para j de 2 até n faça

    x ← A[j];
    i ← j - 1;
    enquanto i > 0 e A[i] > x faça
      A[i + 1] ← A[i];
      i ← i - 1;
    A[i + 1] ← x;

O insertion sort está correto?


Para verificar a exatidão do algoritmo, precisamos recorrer a um invariante, isto é, algo que vale no início de cada iteração e se mantém de uma iteração para a outra. No insertion sort, o invariante é este: em cada iteração, o sub-vetor A'[0..j-1] está ordenado. Precisamos demonstrar 3 aspectos básicos sobre este invariante. Se os 3 forem cumpridos, com certeza ele é correto:

  • Inicialização: Na primeira iteração, o invariante é verdadeiro? No caso do insertion sort sim, pois quando j = 2, nosso sub-vetor A' consiste de apenas um elemento. E, claro, ele está trivialmente ordenado.

  • Manutenção: O invariante se mantém verdadeiro antes da próxima iteração? Informalmente, vemos que o algoritmo vai "empurrando" os elementos do vetor, A[j - 1], A[j - 2]... uma posição à direita, até que seja encontrada a posição adequada para A[j]. Assim, antes da próxima iteração, o sub-vetor A' está definitivamente ordenado.

  • Término: Ao final do processamento, o invariante nos garante que o algoritmo é correto? Note que o loop externo termina quando j excede n, ou seja, j = n + 1. O invariante diz que, em cada iteração, o sub-vetor A'[0...j-1] está ordenado. Substituindo j por n + 1, temos o sub-vetor A'[0..n], que não apenas está ordenado como é o vetor original. Ora, isso quer dizer que A está ordenado.

O insertion sort é eficiente?


Chamemos T(n) ao tempo que o algoritmo consome. Vamos assumir que cada operação básica consome 1 unidade de tempo qualquer. Ignorando a declaração das variáveis e considerando o pior caso, isto é, quando o vetor está totalmente desordenado, vamos marcar quantas vezes cada instrução é executada:

Insertion-sort(A, n)


  inteiro x, j, i;

  para j de 2 até n faça       // n vezes

    x ← A[j];                  // n - 1 vezes
    i ← j - 1;                 // n - 1 vezes
    enquanto i > 0 e A[i] > x faça   // 2 + 3 + ... + n vezes
      A[i + 1] ← A[i];               // 2 + 3 + ... + n vezes
      i ← i - 1;                     // 2 + 3 + ... + n vezes
    A[i + 1] ← x;              // n - 1 vezes

Somando tudo, obtemos

T(n) = n + 3(n - 1) + 3[(1 + 2 + ... + n) - 1]
T(n) = 4n - 3 + 3[n(n + 1)/2 - 1]
T(n) = 4n - 3 + 3(n² + n)/2 - 3
T(n) = (3/2)n² + (3/2)n + 4n - 6
T(n) = (3/2)n² + (11/2)n - 6

Pouco importam os coeficientes aqui, pois sabemos que se trata de uma expressão do tipo an² + bn + c. E como tal, ela se encontra na ordem de grande-O de n². Portanto, T(n) = O(n²). Trata-se de um algoritmo que cresce em velocidade polinomial, conforme aumenta a entrada n. Se n dobra, o tempo de execução quadruplica. Se quadruplica, aumenta 16 vezes. Parece ruim? Fica tranquilo, há algoritmos muito piores =D

Todo algoritmo que consome tempo O(n^k) no pior caso, para algum inteiro k, são ditos polinomiais. São polinomiais os algoritmos lineares, O(n), quadráticos, O(n²) e logarítmicos, O(log n). Já os algoritmos exponenciais consomem tempo O(k^n) no pior caso, para algum inteiro k, e crescem com uma velocidade muito maior.

Fontes


Aulas de Análise de Algoritmos
Minicurso de Análise de Algoritmos
Introduction to Algorithms

0 comentários:

Postar um comentário