terça-feira, 17 de julho de 2012

Quick-Sort: Uma abordagem indutiva.

Neste post, vamos fazer uma análise indutiva do método de ordenação que na prática é o mais rápido. O quick-sort demostra a eficiência do paradigma "dividir e conquistar".
O primeiro passo do quick-sort é escolher aleatoriamente um elemento x que será chamado de pivô. Em seguida, o vetor será particionado de forma que os elementos a esquerda do pivô sejam menores que ele, e os elementos a direita sejam maiores ou iguais a ele. A Figura 1 apresenta uma partição cujo pivô é 4.

Figura 1. Vetor particionado

Descrição indutiva da "partição": Para construir o nosso algoritmo vamos utilizar a abordagem indutiva, detalhando o caso base, a hipótese de indução e o caso geral..

Idéia indutiva:
  • Caso Base: o vetor possui apenas 1 elemento, então ele já está particionado!
  • Hipótese de Indução: vamos supor que o problema da partição já está resolvido para n - 1 elementos e que sabemos onde termina a lista de elementos menores que o pivô.
  • Caso Geral: é quase trivial, pois basta verificar se o último elemento do vetor é menor ou maior que o pivô. Caso o último elemento seja menor que o pivô, então basta colocá-lo no final da lista de elementos menores que o pivô.
Implementação da partição: Seguindo o esquema indutivo apresentado, podemos implementar um método de forma simples. Nessa implementação, o pivô escolhido é o primeiro elemento do vetor.
  • Implementação em C++:
int partition(int X[], int left, int right) {
    int pivot = X[left];
    int i = left;

    for (int j = left; j <= right; j++)
        if (X[j] < pivot)
            swap(X, j, ++i);
    swap(X, left, i);
    return i;
}
Após a execução do método "partition", apenas o pivô estará na posição correta. Precisamos ordenar os elementos que estão à esquerda e à direita do pivô. No entanto, podemos assumir que a hipótese de indução fará esse trabalho, como podemos ver na Figura 2.
Figura 2.
Idéia indutiva:
  • Caso Base: um vetor com 1 elemento já está ordenado.
  • Hipótese de Indução: após a execução do método "partition", vamos assumir que a hipótese de indução sabe como ordenar todos os elemento que estão à esquerda e à direita do pivô.
  • Caso Geral: simplesmente já temos o vetor ordenado!

Na Figura 3, apresentamos o resultado final, um vetor ordenado!
Figura 3. Vetor ordenado.
A seguir, apresentamos a implementação completa do Quick-Sort.
  • Implementação em Haskell: por se tratar de uma linguagem funcional, Haskell permite que o programador implemente uma solução enxuta, sem se preocupar com detalhes.
quickSort :: [Int] -> [Int]
quickSort [] = []
quickSort [x] = [x]
quickSort (x:xs) = quickSort menores ++ [x] ++ quickSort maiores
  where
    menores = [p | p <- xs, p <= x]
    maiores = [p | p <- xs, p > x]
  • Implementação em C++
void quickSort(int A[], int left, int right){
    if(left < right){
        int middle = partition(A, left, right);
        quickSort(A, left, middle - 1);
        quickSort(A, middle + 1, right);
    }
}
  • Implementação em Python
def quickSort(lista):
  if lista == []:
    return []
  else:
    pivot = lista[0]
    menores = [x for x in lista[1::] if x < pivot]
    maiores = [x for x in lista[1::] if x >= pivot]
    return quickSort(menores) + [pivot] + quickSort(maiores)

Complexidade do Quick-Sort: A complexidade do algoritmo depende da escolha do pivô, caso o pivô escolhido divida o vetor em duas partes com tamanhos iguais ou similares, a sua complexidade será O(n log n).
Caso os elementos do vetor já estejam ordenados, a complexidade do Quick-Sort no pior caso será descrita pela recorrência $T(n) = T(n - 1) + O(n) = O(n^2)$. Na prática, como o pior caso raramente acontece, a literatura reconhece que o Quick-Sort é o melhor algoritmo de ordenação, e por isso ele é utilizado em uma grande variedade de aplicações.

sábado, 7 de julho de 2012

Particionando em pares e ímpares

Agora, vamos utilizar indução para resolver um problema fácil, que com indução ficou mais fácil ainda.
Problema:
  • Dada uma sequência de n valores inteiros, rearranjar os elementos de forma que os valores pares apareçam antes dos ímpares.
Idéia indutiva:
  • Caso Base: uma lista com 1 elemento já está particionada em pares e ímpares.
  • Hipótese de Indução: podemos assumir que o problema já foi resolvido para n-1 elementos, e que sabemos onde termina a lista de pares.
  • Caso Geral: basta verificar se o último elemento é par ou ímpar, se ele for par basta inseri-lo no final da lista de pares, caso contrário, não fazemos nada.  
Agora, vamos ilustrar o problema com um exemplo:
  • Situação inicial: Um vetor com valores pares e ímpares misturados.
  • Hipótese de indução: agora vamos "imaginar" que o problema já foi resolvido para os primeiros n - 1 elementos, e que sabemos onde termina a lista de pares.


  • Caso Geral: Pronto! Agora ficou muito fácil, podemos perceber que o último elemento é par, então vamos incrementar o final da lista de pares e trocar o último  elemento da lista de pares com o último elemento do vetor.


  • E assim chegamos a solução do problema.


O algoritmo segue exatamente a idéia apresentada acima. Vamos assumir que o problema já foi resolvido para os primeiros n - 1 elementos, e dentro do loop só escreveremos código para trocar o último elemento do vetor (caso ele seja par) com o último elemento da lista de pares. E não podemos esquecer do caso base!
  • Implementação em C++ (versão iterativa)
fimPares = -1; // caso base
for(int i = 0; i < tamVetor; i++)
  if(vetor[i] % 2 == 0){  // passo indutivo
    fimPares++;
    troca(vetor[i], vetor[fimPares]);
  }

Complexidade do algoritmo: A relação de recorrência da nossa solução é T(n) = T(n - 1) + 1, ou seja, o tempo gasto pela hipótese de indução para separar os n - 1 elementos mais o tempo gasto para colocar o último elemento no final da lista de pares. Derivando T(n) temos:

$T(n) = T(n - 1) + 1$
$T(n) = T(n - 2) + 1 + 1$
$T(n) = T(n - 3) + 1 + 1 + 1$
$T(n) = T(n - 4) + 1 + 1 +1 +1$
...
$T(n) = 1 + 1 + 1 + 1 + 1 + 1 + ... + 1 + 1$
$T(n) = O(n)$

Logo, concluímos que o nossa solução tem complexidade linear.

Por que usar indução?

Em seu artigo, intitulado "Using induction to design algorithms",  Udi Manber faz uma analogia entre a prova de teoremas matemáticos e a construção de algoritmos usando indução. E diz que embora esses dois processos sirvam para alcançar resultados diferentes, eles são muito similares. Além disso, Manber deixa claro que a indução não serve apenas para construir algoritmos recursivos, pois támbem podemos construir algoritmos iterativos pensando de forma indutiva.

"Although induction suggests implementation by recursion, this is not necessarily the case. (Indeed, we call this approach inductive rather than recursive to deemphasize recursive implementations.) In many cases, iterative implementation is just as easy, even if the algorithm was designed with induction in mind; iteration is also generally more efficient." Udi Manber

Então pessoal, não deixem de conferir as nossas postagens, e fiquem atentos ao trio: caso base, hipótese de indução e caso geral.