Projeto de Algoritmos

Quicksort

O problema desta página é o mesmo das três páginas anteriores:  rearranjar um vetor v[0..n-1] (ou seja, permutar os elementos do vetor) de modo que ele fique em ordem crescente, isto é, de modo que tenhamos v[0] ≤ ... ≤ v[n-1].

O algoritmo Quicksort, inventado por C.A.R. Hoare [Computer Journal, 5, pp.10-15, 1962], resolve o problema.  O algoritmo é muito rápido em geral, mas é lento em algumas raras instâncias especiais.  O algoritmo consome tempo proporcional a  n log(n)  em média e proporcional a  n2  no pior caso.

Usaremos duas abreviaturas ao discutir o algoritmo.  A expressão  "v[h..j] ≤ x"  será usada como abreviatura de  "v[i] ≤ x para todo i no conjunto de índices h..j".  Analogamente, a expressão  "v[h..j] ≤ v[k..m]"  será interpretada como "v[i] ≤ v[l] para todo i no conjunto h..j e todo l no conjunto k..m".

Veja o verbete Quicksort na Wikipedia.   Veja também minha página "Análise do Quicksort".  Veja também o capítulo 11 do "Programming Pearls".

O problema da separação

O núcleo do algoritmo Quicksort é o seguinte problema da separação (= partition subproblem), que formularemos de maneira propositalmente vaga:

rearranjar um vetor  v[p..r]  de modo que todos os elementos pequenos fiquem na parte esquerda do vetor  e  todos os elementos grandes fiquem na parte direita.

O ponto de partida para a solução deste problema é a escolha de um "pivô", digamos c. Os elementos do vetor que forem maiores que c serão considerados grandes e os demais serão considerados pequenos.  É importante escolher c de tal modo que cada uma das duas partes do vetor rearranjado seja estritamente menor que o vetor todo.  A dificuldade está em construir um algoritmo que resolva o problema de maneira rápida e não use vetor auxiliar.

O problema da separação admite muitas formulações concretas.  Eis uma primeira:  rearranjar v[p..r] de modo que tenhamos

v[p..j]  ≤  v[j+1..r]

para algum j em p..r-1.   Outra formulação concreta do problema:  rearranjar v[p..r] de modo que

v[p..j-1]  ≤  v[j]  <  v[j+1..r]

para algum j em p..r.  Esta página usa a segunda formulação; outras formulações são mencionadas nos exercícios.

Exercícios preliminares

  1. Escreva uma função que rearranje um vetor v[p..r] de inteiros de modo que tenhamos   v[p..j-1]0   e   v[j..r] > 0   para algum j em p..r+1.  Faz sentido exigir que j esteja em p..r?  Procure fazer uma função rápida que não use vetor auxiliar.   Repita o exercício depois de trocar "v[j..r] > 0" por "v[j..r]  0".  Faz sentido exigir que v[j] seja 0?
  2. Um vetor v[p..r] está arrumado se existe j em p..r tal que   v[p..j-1]v[j] < v[j+1..r] .  Escreva um algoritmo que decida se v[p..r] está arrumado. Em caso afirmativo, o seu algoritmo deve devolver o valor de j.
  3. Critique a seguinte versão do algoritmo da separação:
    int sep( int v[], int p, int r) {
       int w[1000], i = p, j = r, c = v[p], k;
       for (k = p+1; k <= r; ++k) 
          if (v[k] <= c) w[i++] = v[k];
          else w[j--] = v[k];
       // agora i == j
       w[i] = c;
       for (k = p; k <= r; ++k) v[k] = w[k];
       return i;
    }
    
  4. Um programador inexperiente afirma que a seguinte implementação da função de separação rearranja o vetor v[p..r], com p < r, e devolve um índice j em p..r-1 tal que v[p..j]v[j+1..r].
    int sep( int v[], int p, int r) {
       int q, i, j, t;
       i = p; q = (p + r) / 2; j = r;
       do {
          while (v[i] < v[q]) ++i;
          while (v[j] > v[q]) --j;
          if (i <= j) {
             t = v[i], v[i] = v[j], v[j] = t;
             ++i, --j;
          }
       } while (i <= j);
       return i;
    }
    

    Mostre um exemplo onde essa função não dá o resultado esperado.  E se trocarmos "return i" por "return i-1"?  É possível fazer algumas poucas correções de modo que a função dê o resultado esperado?

O algoritmo da separação

Eis como D. Gries [The Science of Programming, 1981, pag.345] escreve o algoritmo da separação:

int separa( int v[], int p, int r) 
{
   int c = v[p], i = p+1, j = r, t;               // 1
   while (1) {                                    // 2
      while (i <= r && v[i] <= c) ++i;            // 3
      while (c < v[j]) --j;                       // 4
      if (i >= j) break;                          // 5
      t = v[i], v[i] = v[j], v[j] = t;            // 6
      ++i; --j;                                   // 7
   }                                              // 8
   v[p] = v[j], v[j] = c;                         // 9
   return j;                                      // 10
}

A função devolve um elemento j do conjunto p..r depois de rearranjar o vetor v[p..r] de tal que de modo que

v[p..j-1]  ≤  v[j]  <  v[j+1..r] .

Gostaríamos que j ficasse a meio caminho entre pr, mas devemos estar preparados para aceitar os casos extremos j == p e j == r .
p j r
c c c c = c > c > c > c > c > c

Eis algumas perguntas que chamam a atenção para detalhes importantes:  Que acontece se executarmos a função com p igual a r?  Que acontece se eliminarmos "i <= r" na linha 3?  Por que não há um "j >= p" na linha 4?  Que acontece se escrevermos "i > j" na linha 5?  Que acontece se trocarmos "j" por "i" nas linhas 910?

O algoritmo da separação: análise

Eu prefiro escrever a função separa de maneira um pouco diferente, para facilitar a análise de sua correção:

// Recebe vetor v[p..r] com p < r. Rearranja os 
// elementos do vetor e devolve j em p..r tal que 
// v[p..j-1] <= v[j] < v[j+1..r].

int separa( int v[], int p, int r)
{
   int c = v[p], i = p+1, j = r, t;
   while (/*A*/ i <= j) {
      if (v[i] <= c) ++i;
      else if (c < v[j]) --j; 
      else {
         t = v[i], v[i] = v[j], v[j] = t;
         ++i; --j;
      }
   }
   // agora i == j+1                 
   v[p] = v[j], v[j] = c;
   return j; 
}

No início de cada iteração, ou seja, a cada passagem pelo ponto A, temos a seguinte configuração:
p i j r
= c c c ? ? ? ? ? > c > c

Na penúltima passagem por A, teremos
p i=j r
= c c c c c ? > c > c > c > c

Na última passagem por A valem as seguintes relações:

Portanto,  v[p..j-1] ≤ v[j] < v[j+1..r]  na fim da execução da função.

O algoritmo da separação: desempenho

Qual o desempenho da função separa?  Quanto tempo a função consome?   O consumo de tempo é proporcional ao número de comparações entre elementos do vetor e portanto proporcional ao número

r - p + 1

de elementos do vetor.

Exercícios

  1. Qual o resultado da função  separa  quando os elementos de v[p..r] são todos iguais?  E quando v[p..r] é crescente?  E quando v[p..r] é decrescente?  E quando cada elemento de v[p..r] tem um de dois valores possíveis?
  2. A função separa produz um rearranjo estável do vetor, ou seja, preserva a ordem relativa de elementos de mesmo valor?
  3. Analise e discuta a seguinte versão da função separa. Ela é equivalente à do livro de Cormen, Leiserson, Rivest e Stein (Introduction to Algorithms, 2a.ed., 2001).
    int separa_CLRS( int v[], int p, int r) {
       int c = v[r], i = p, j, t;
       for (j = p; j < r; ++j) {
          if (v[j] <= c) {
             t = v[i], v[i] = v[j], v[j] = t;
             ++i;
          }
       }
       t = v[i], v[i] = v[r], v[r] = t;
       return i;
    }
    
  4. Critique a seguinte versão do algoritmo da separação:
    int separa( int v[], int p, int r) {
       int c = v[p], i = p+1, j = r, t;
       while (i <= j) {
          if (v[i] <= c) ++i;
          else {
             t = v[i], v[i] = v[j], v[j] = t;
             --j;
          }
       }
       v[p] = v[j], v[j] = c;
       return j;
    }
    
  5. Critique a seguinte versão do algoritmo da separação:
    int separa( int v[], int p, int r) {
       int c = v[p], i = p+1, j = r, t;
       while (i <= j) {
          if (v[i] <= c) {
             v[i-1] = v[i];
             ++i;
          }
          else {
             t = v[i], v[i] = v[j], v[j] = t;
             --j;
          }
       }
       v[j] = c;
       return j;
    }
    
  6. Analise e discuta a seguinte versão da função separa. Ela foi extraída do livro de Roberts.
    int separa_R( int v[], int p, int r) {
       int c = v[p], i = p+1, j = r, t;
       while (1) {
          while (i < j && c < v[j]) --j;
          while (i < j && v[i] <= c) ++i;
          if (i == j) break;
          t = v[i], v[i] = v[j], v[j] = t;
       }
       if (v[i] > c) return p;
       v[p] = v[i], v[i] = c;
       return i;
    }
    
  7. [Versão Recursiva]  Escreva uma versão recursiva da função separa
  8. [Desafio]  Escreva uma versão da função separa tal que (r-p)/4j-p3(r-p)/4, sendo j o índice produzido pela função.
  9. Suponha dada uma lista encadeada que armazena números inteiros. Cada célula da lista tem a estrutura abaixo.
       struct celula {
          int            chave;
          struct celula *prox;
       };
    

    Escreva uma função que transforme a lista em duas: a primeira contendo as células cuja chave é par e a segunda aquelas cuja chave é ímpar.

Quicksort básico

Agora que resolvemos o problema da separação, podemos cuidar do Quicksort propriamente dito.  O algoritmo usa a estratégia da divisão e conquista e tem a aparência de um Mergesort "ao contrário".

// A função recebe um vetor v[p..r], com p <= r+1,
// e rearranja o vetor em ordem crescente.

void quicksort( int v[], int p, int r)
{
   int j;                         // 1
   if (p < r) {                   // 2
      j = separa( v, p, r);       // 3
      quicksort( v, p, j-1);      // 4
      quicksort( v, j+1, r);      // 5
   }
}

(Note que a função está correta mesmo quando p > r, ou seja, quando o vetor está vazio.)

Quicksort básico: desempenho

O consumo de tempo da função  quicksort  é proporcional ao número de comparações entre elementos do vetor.  Se o índice j devolvido por separa estiver sempre mais ou menos a meio caminho entre p e r , o número de comparações será aproximadamente

n log2n ,

sendo n o número de elementos do vetor.  Caso contrário, o número de comparações estará na ordem de  n2 .  (Isso acontece, por exemplo, se o vetor já estiver ordenado ou quase ordenado.)  Portanto, o pior caso do Quicksort não é melhor que o dos algoritmos elementares.

Felizmente, o pior caso é muito raro.  Em média, o consumo de tempo do  quicksort  é  proporcional a   n log2n .   (Veja detalhes na minha página "Análise do Quicksort".)

Exercícios

  1. Que acontece se trocarmos "p < r" por "p != r" na linha 2 do quicksort?
  2. Que acontece se trocarmos "j-1" por "j" na linha 4 do quicksort? Que acontece se trocarmos "j+1" por "j" na linha 5 do código?
  3. Submeta o vetor  77 55 33 99  indexado por  1..4  à função quicksort. Teremos a seguinte sequência de invocações da função (observe a indentação):
       quicksort( v,1,4)
           quicksort( v,1,2)
               quicksort( v,1,0)
               quicksort( v,2,2)
           quicksort( v,4,4)
    

    Repita o exercício com o vetor  55 44 22 11 66 33  indexado por  1..6.

  4. [Tail recursion]  Verifique que a segunda invocação da função quicksort (linha 5 do código) pode ser eliminada se trocarmos o "if" por um "while":
    void quicksrt( int v[], int p, int r) {
       int j;
       while (p < r) {          
          j = separa( v, p, r); 
          quicksrt( v, p, j-1);
          p = j + 1;            
       }
    }
    
  5. A função quicksort produz uma ordenação estável do vetor?
  6. Discuta a seguinte implementação do algoritmo Quicksort. A função só se aplica a vetores v[p..r] com p < r.
    void qcksrt( int v[], int p, int r) {
       int j;                   
       j = separa( v, p, r);
       if (p < j-1) qcksrt( v, p, j-1);
       if (j+1 < r) qcksrt( v, j+1, r);
    }
    
  7. Suponha dada uma implementação da função separa que funciona assim:  rearranja v[p..r] e devolve um índice j tal que   v[p..j] ≤ v[j+1..r].   Escreva uma versão do algoritmo Quicksort que use essa implementação do separa. Que restrições devem ser impostas sobre j?
  8. [Versão Iterativa]  Escreva uma versão não recursiva do algoritmo Quicksort.   [Solução]
  9. [Quicksort Aleatorizado]  Escreva uma versão aleatorizada (= randomized) do algoritmo Quicksort.

Quicksort: altura da pilha de execução

Na versão básica do Quicksort, o código cuida imediatamente do subvetor v[p..j-1] e trata do subvetor v[j+1..r] somente depois que v[p..j-1] estiver ordenado.  Dependendo do valor de j nas sucessivas invocações da função, a pilha de execução pode crescer muito, atingindo altura  n := r-p+1 .  (Isso acontece, por exemplo, se o vetor estiver em ordem decrescente.)  O fenômeno não afeta o consumo de tempo do algoritmo, mas pode esgotar o espaço de memória.  Para controlar o crescimento da pilha de execução é preciso tomar duas providências:

  1. cuidar primeiro do menor dos subvetores v[p..j-1] e v[j+1..r]  e
  2. eliminar a segunda invocação recursiva da função (veja exercício acima).

Se adotarmos estas providências, o tamanho do subvetor que está no topo da pilha de execução será menor que a metade do tamanho do subvetor que está logo abaixo na pilha.  De modo mais geral, o subvetor que está em qualquer das posições da pilha de execução será menor que metade do subvetor que está imediatamente abaixo.  Assim, se a função for aplicada a um vetor com n elementos, a altura da pilha não passará de  log2n.

// A função rearranja o vetor v[p..r], com p <= r+1,
// de modo que ele fique em ordem crescente.

void quickSort( int v[], int p, int r)
{
   int j;
   while (p < r) {      
      j = separa( v, p, r);    
      if (j - p < r - j) {     
         quickSort( v, p, j-1);
         p = j + 1;            
      } else {                 
         quickSort( v, j+1, r);
         r = j - 1;
      }
   }
}

Animações do Quicksort

 

APÊNDICE: outras formas do Quicksort

A algoritmo Quicksort pode ser escrito de várias outras maneiras, diferentes da discutida acima. A diferença entre as várias versões está no modo de resolver o subproblema da separação.  (Em todas as implementações abaixo, o mecanismo de controle da altura da pilha de execução foi omitido para tornar o código mais legível.)

  1. A versão abaixo começa por rearranjar v[p..r] de modo que   v[p..i-1]v[i]v[i+1..r]    e    p ≤ i ≤ r   (compare com o resultado da separação feita acima).
    // A função rearranja o vetor v[p..r], com p <= r+1,
    // de modo que ele fique em ordem crescente.
    
    void quick_FlipFlop( int v[], int p, int r)
    {
       int i, j, t, ff;
       if (p < r) {      
          i = p, j = r;
          ff = -1;
          while (i < j) {
             if (v[i] > v[j] {
                t = v[i], v[i] = v[j], v[j] = t;
                ff = -ff;
             }
             if (ff == -1) --j;
             else ++i;
          }
          quick_FlipFlop( v, p, i-1);
          quick_FlipFlop( v, i+1, r);
       }
    }
    
  2. A versão abaixo (Cormen, Leiserson e Rivest, Introduction to Algorithms, 1991) começa por rearranjar v[p..r] de modo que   v[p..j]v[j+1..r]   e   pj < r   (compare com o resultado da separação feita acima).  Note que os tamanhos dos vetores v[p..j] e v[j+1..r] são ambos estritamente menores que o tamanho do vetor original.
    // A função rearranja o vetor v[p..r], com p <= r+1,
    // de modo que ele fique em ordem crescente.
    
    void quick_CLR( int v[], int p, int r)
    {
       int c, i, j, t;
       if (p < r) {      
          c = v[p];
          i = p-1, j = r+1;
          while (1) {
             do --j; while (v[j] > c);
             do ++i; while (v[i] < c);
             if (i >= j) break;
             t = v[i], v[i] = v[j], v[j] = t;
          }
          quick_CLR( v, p, j);
          quick_CLR( v, j+1, r);
       }
    }
    
  3. Eis outra implementação (se não me engano, ela consta do livro de Sedgewick).
    // A função rearranja o vetor v[p..r], com p <= r+1,
    // de modo que ele fique em ordem crescente.
    
    void quick_S( int v[], int p, int r) {
       int c, i, j, t;
       if (p < r) {      
          c = v[(p+r)/2];
          i = p, j = r;
          while (i <= j) {
             while (v[i] < c) ++i;
             while (c < v[j]) --j;
             if (i <= j) {
                t = v[i], v[i] = v[j], v[j] = t;
                ++i, --j;
             }
          }
          quick_S( v, p, j);
          quick_S( v, i, r);
       }
    }
    

    Exercício: formule com precisão o subproblema da separação que quick_S resolve.

  4. A seguinte implementação do Quicksort é sugerida no livro de Sedgewick (página 307).
    #define troca( A, B) { int t = A; A = B; B = t; } 
    void quick_S2( int v[], int p, int r) {
        if (p < r) {
           int i = p-1, j = r, c = v[r];
           while (1) { 
               while (v[++i] < c) ;
               while (c < v[--j]) if (j == p) break;
               if (i > j) break;
               troca( v[i], v[j]);
           }
           troca( v[i], v[r]);
           quick_S2( v, p, j);
           quick_S2( v, i+1, r);
        }
    }
    

 

Exercícios

  1. Familiarize-se com a função qsort da biblioteca stdlib.

 


Veja Quicksort no Cprogramming.com
URL of this site: www.ime.usp.br/~pf/algoritmos/
Last modified: Mon Jan 21 07:15:52 BRST 2013
Paulo Feofiloff
IME-USP

Valid HTML 4.01 Transitional     Valid CSS!