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, ou seja, 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. Ele é muito rápido em média, mas lento no pior caso; felizmente, o pior caso é raro. O algoritmo consome tempo proporcional a  n log(n)  em média e a  n2  no pior caso.

Veja o verbete Quicksort na Wikipedia.

Exercícios preliminares

  1. Escreva uma função que rearranje um vetor v[p..r] de inteiros de modo que, para algum j em p..r+1, tenhamos

    v[p..j-1] ≤ 0     e     v[j..r] > 0 .

    A expressão "v[p..j-1]  0" significa "v[i]  0 para todo i no conjunto p..j-1".  Faz sentido exigir que j esteja em p..r?  Não use vetor auxiliar. Procure fazer uma função rápida.   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.

O subalgoritmo da separação

O coração do Quicksort é o problema da separação (= partition subproblem), que formularemos de maneira propositalmente vaga: rearranjar 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 do subproblema é a escolha de uma "chave", digamos c: os elementos do vetor que forem maiores que c serão considerados grandes e os demais serão considerados pequenos. A dificuldade está em escolher c de tal modo que cada uma das duas partes do vetor rearranjado seja estritamente menor que o vetor todo.

Eis como D. Gries [The Science of Programming, 1981, pag.345] escreve o subalgoritmo 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
   t = v[p]; v[p] = v[j]; v[j] = t;                  // 9
   return j;                                         // 10
}

Esta função separa só faz sentido se p < r.  Ela 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] .

(Minha taquigrafia deve ser interpretada com cuidado: a expressão "v[p..j-1]≤v[j]", por exemplo, significa "v[i]≤v[j] para todo i no conjunto p..j-1".)   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

(Como se vê, a função acima foge um pouco da formulação vaga do subproblema: além de uma parte esquerda v[p..j-1] e de uma parte direita v[j+1..r], o vetor rearranjado tem um "centro" v[j].) 

Eis algumas perguntas que chamam a atenção para detalhes importantes:  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 9 e 10?

O subalgoritmo da separação: outra versão

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                 
   t = v[p]; v[p] = v[j]; v[j] = t;
   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 subalgoritmo da separação: desempenho

Qual o desempenho da função separa? Quanto tempo a função consome? Esse tempo é proporcional ao número de comparações entre elementos do vetor. E esse número é essencialmente igual ao

número de elementos do vetor,

ou seja,  r - p + 1 .

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. Critique a seguinte versão do algoritmo da separação:
    int separa (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. 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;
       while (i <= j) {
          if (v[i] <= c) ++i;
          else {
             troca (v[i], v[j]);
             --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;
       while (i <= j) {
          if (v[i] <= c) {
             v[i-1] = v[i];
             ++i;
          }
          else {
             troca (v[i], v[j]);
             --j;
          }
       }
       v[j] = c;
       return j;
    }
    
  6. Escreva uma versão recursiva da função separa
  7. Um programador inexperiente afirma que a seguinte implementação da função de separação rearranja o vetor v[p..r] e devolve um índice j em p..r-1 tal que v[p..j]v[j+1..r].
    int separa (int v[], int p, int r) {
       int j;
       j = (p + r) / 2;
       do {
          while (v[p] < v[j]) p++;
          while (v[r] > v[j]) r--;
          if (p <= r) {
             troca (v[p], v[r]);
             p++, r--;
          }
       } while (p <= r) 
       return p;
    }
    

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

  8. 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 é impar.

Quicksort básico

Agora que resolvemos o problema da separação, podemos cuidar do Quicksort propriamente dito. O algoritmo se assemelha a um Mergesort "de trás pra diante".   Nosso algoritmo recebe um vetor v[p..r]possivelmente com p > r,  e rearranja o vetor de modo que ele fique em ordem crescente.

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

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

Poderíamos eliminar o segundo apelo à recursão e reescrever a função assim:

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

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 então o número de comparações será aproximadamente  n log2n ,  sendo n o número de elementos do vetor. Caso contrário, o algoritmo pode consumir tempo proporcional a  n2.  

Em média, o consumo de tempo do quicksort é  

proporcional a    n log n ,

onde n é r-p+1.   Mas no pior caso — em particular, se o vetor dado já estiver ordenado ou quase-ordenado — o consumo de tempo é proporcional a  n2, ou seja, tão elevado quanto o dos algoritmos elementares.

Quicksort básico: animações

Exercícios

  1. Que acontece se trocarmos "p < r" por "p != r" na linha 1 do quicksort?
  2. Que acontece se trocarmos "j-1" por "j" na linha 3 do quicksort? Que acontece se trocarmos "j+1" por "j" na linha 4 do quicksort?
  3. A função quicksort produz uma ordenação estável do vetor?
  4. Analise a seguinte implementação do quicksort, sugerida na página 307 do livro do Sedgewick.
    #define troca(A, B) { int t = A; A = B; B = t; } 
    void quicksort (int a[], int p, int r) {
        if (p < r) {
           int i = p-1, j = r, v = a[r];
           while (1) { 
               while (a[++i] < v) ;
               while (v < a[--j]) if (j == p) break;
               if (i > j) break;
               troca (a[i], a[j]);
           }
           troca (a[i], a[r]);
           quicksort (a, p, j);
           quicksort (a, i+1, r);
        }
    }
    

Quicksort: versão definitiva

Nas duas versões do Quicksort vistas acima, o algoritmo cuida imediatamente da parte esquerda do vetor, enquanto o resto do problema é colocado na pilha da recursão. (Poderia, igualmente bem, tem feito o contrário.) Com isso, a pilha de recursão pode crescer muito (por exemplo, se o vetor estiver em ordem decrescente), atingindo tamanho  n . Isso não afeta o consumo de tempo do algoritmo, mas pode esgotar o espaço de memória.

O remédio está na seguinte política: processe imediatamente a parte menor do vetor, deixando a outra parte na pilha. Com isso, cada subproblema que está na pilha é menor ou igual que a metade do subproblema que está logo abaixo na pilha. Resultado: o tamanho da pilha nunca passa de  log2n.

Eis o resultado dessa política aplicada à versão recursiva:

// 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;
      }
   }
}

 

Exercícios

  1. Mostre que na versão abaixo o tamanho da pilha de recursão pode não ficar limitada a log2(r-p):
    void quicksort (int v[], int p, int r) {
       int j;
       if (p < r) {      
          j = separa (v, p, r);    
          if (j - p < r - j) {     
             quicksort (v, p, j-1);
             quicksort (v, j+1, r);
          } else {                 
             quicksort (v, j+1, r);
             quicksort (v, p, j-1);
          }
       }
    }
    
  2. 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 quicksort que use essa implementação do separa. Que restrições devem ser impostas sobre j?

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.

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 quickCLR (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;
      }
      quickCLR (v, p, j);
      quickCLR (v, j+1, r);
   }
}

(O mecanismo de controle do tamanho da pilha de recursão foi omitido para tornar o código mais legível.)

Eis mais uma 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 quickSedg (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;
         }
      }
      quickSedg (v, p, j);
      quickSedg (v, i, r);
   }
}

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

(Convém acrescentar a esta versão o mecanismo de controle do tamanho da pilha de recursão que já discutimos acima.)

 


URL of this site: www.ime.usp.br/~pf/algoritmos/
1998 | Last modified: Tue Oct 10 17:44:06 BRST 2006
Paulo Feofiloff
IME-USP

Valid HTML 4.0!     Valid CSS!