Análise do algoritmo Quicksort

[Veja a versão desta página em formato perguntas-e-respostas]

 

Este página é inspirada no capítulo 8 de CLR.  Ela trata do seguinte problema de ordenação de vetor:

Problema da ordenação:  rearranjar um vetor A[p..r] de modo que ele fique em ordem crescente.

O algoritmo Quicksort, inventado por C.A.R. Hoare, usa o paradigma da divisão e conquista para resolver o problema Ele é muito rápido em média, mas lento no pior caso.

Veja o verbete Quicksort na Wikipedia.

O subproblema da separação

O coração do algoritmo Quicksort está no seguinte subproblema, que formularemos de maneira propositalmente vaga:

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

Este é o subproblema da separação.  O ponto de partida para a solução do subproblema é a escolha de um "pivô", digamos x: os elementos do vetor que forem maiores que x serão considerados grandes e os demais serão considerados pequenos. É preciso escolher x de tal modo que cada uma das duas partes do vetor rearranjado seja estritamente menor que o vetor todo.

O seguinte algoritmo resolve o subproblema da separação da seguinte maneira: supondo p < r, o algoritmo rearranja os elementos de A[p..r] e devolve um índice q tal que  p ≤ q < r e, para algum x,

p q q+1 r
x x x x x x x x x x x x

A seguinte versão do algoritmo adota como pivô x o valor inicial de A[p].

  Separe (A, p, r)
1o xA[p]
1o ip−1
1o jr+1
1o enquanto 0 = 0 faça
1oooo repita jj−1
1ooooooo até que A[j] ≤ x
1oooo repita ii+1
1ooooooo até que A[i] ≥ x
1oooo se i < j
10  ooooooo então troque A[i] ↔ A[j]
11  ooooooo senão devolva j

Para entender como e por que o algoritmo funciona como deveria, observe que no início de cada iteração do loop que começa na linha 4 temos as seguintes propriedades invariantes:

A[p..i] ≤ x ,    i < j ,    A[j..r] ≥ x .

(Se eu estivesse programando pra valer, escreveria estas relações, como comentário, logo depois da linha 4.)

p i j r
x x x               x x

Na última passagem pela linha 4, o vetor A[i+1 .. j−1] consiste em

No primeiro caso, o algoritmo chega à linha 11 com j = i−1.

p j i r
x x x x x x x x x x x x

No segundo caso, o algoritmo chega à linha 11 com ji.

p j=i r
x x x x x = x x x x x x x

Não é difícil perceber agora que o algoritmo faz o que prometeu; em particular, que pj < r na linha 11.

Exercícios

  1. Suponha que todos os elementos do vetor A[p..r] são iguais entre si. Quantas vezes a linha 4 do algoritmo Separe é executada? Qual o valor de j que o algoritmo devolve?
  2. [Este exercício faz parte da análise do desempenho médio do Quicksort.]  Suponha que A[p..r] é uma permutação de 1, 2, … , n.  Que índice Separe devolve se o valor inicial de A[p] for 1? E se o valor inicial de A[p] for 2? E se o valor inicial de A[p] estiver entre 3 e n
  3. É verdade que a relação pj < r vale no início início de cada iteração do Separe? Ela vale quando a linha 11 é executada?

O consumo de tempo do algoritmo de separação

Quanto tempo o algoritmo Separe consome? Comecemos contando o número total de execuções das linhas 6 e 8. (Note que as execuções do bloco de linhas 5-6 não são, necessariamente, consecutivas. O mesmo vale para o bloco de linhas 7-8.)  Se a o número total de execuções da linha 6 e b o número total de execuções da linha 8, então  a+b ≤ n+2 ,  sendo n = rp+1.  Analogamente, a soma do número total de execuções da linha 5 e da linha 7 não passa de n+2.

Agora considere as demais linhas.  O número total de execuções da linha 9 não passa de n+1.  O número de execuções da linha 10 não passa de n, o número de execuções da linha 4 não passa de n+1, a as linhas 1 a 3 são executadas 1 vez cada.

Se a execução de cada linha consome 1 unidade de tempo, o consumo total de tempo não passa de

5n + 6 .

Se cada execução de cada linha consumisse uma quantidade de tempo diferente de 1 (mas independente de n), o consumo total não passaria de um múltiplo de n,  ou seja,  o consumo estaria em

Ο(n) .

Portanto, o algoritmo é linear.

O algoritmo Quicksort

O algoritmo Quicksort recebe um vetor A[p..r] e rearranja o vetor em ordem crescente.

Quicksort (A, p, r)
o se  p < r  então
oooo qSepare (A, p, r)
oooo Quicksort (A, p, q)
oooo Quicksort (A, q+1, r)

Note que qp e q < r;  portanto os vetores A[p..q] e A[q+1 .. r] são estritamente menores que o vetor original A[p..r].  Isso garante que a execução do algoritmo termina, mais cedo ou mais tarde.

(Se você estivesse programando pra valer, que comentários escreveria no seu programa? Eu escreveria só dois comentários. Antes da linha 1 eu diria "estamos supondo p<r". Depois da linha 2 eu diria "pq<r e A[i]≤A[j] para cada i em p..q e cada j em q+1..r".)

Exercícios

  1. Incorpore o código de Separe ao código de Quicksort, ou seja, reescreva Quicksort substituindo a invocação de Separe pelo seu código. Faça os ajustes necessários.
  2. Submeta um vetor crescente ao Quicksort. Mostre que o algoritmo consome tempo proporcional a n².
  3. Seja f a função definida pela recorrência  f(n) = 5n + 7 + f(1) + f(n−1),  com f(1) = 1.   Mostre que f(n) é Ο(n²).  Mostre que f(n) é Ω(n²).
  4. Seja f a função definida pela recorrência  f(n) = 5n + 7 + f(⌈n/2⌉) + f(⌊n/2⌋),  com f(1) = 1.   Mostre que f(n) é Ο(n lg n).  Mostre que f(n) é Ω(n lg n).
  5. Suponha dado um algoritmo Separ que faça o seguinte: ao receber um vetor A[p..r], devolve um índice q no intervalo p..r depois de rearranjar o vetor de tal modo que A[p..q−1] ≤ A[q] < A[q+1..r].  Escreva uma versão do Quicksort que use o algoritmo Separ no lugar de Separe.
  6. [CLR 8-4]  O algoritmo Quicksort, tal como apresentado acima, contém duas chamadas recursivas. Escreva uma versão Quicksort2 do algoritmo em que a segunda chamada é eliminada e substituída por um "enquanto".

    Durante a execução do Quicksort2, o computador usa um pilha para administrar a recursão. Mostre que a pilha pode chegar a ter Ω(n) elementos quando Quicksort2 processa um vetor com n elementos.

    Modifique Quicksort2 de modo que a pilha tenha Ο(log n) elementos, mesmo no pior caso. 

Desempenho do Quicksort no pior caso

Quanto tempo o algoritmo consome no pior caso?  Como vimos acima, podemos supor que Separe não consome mais que 5n+6 unidades de tempo, sendo nrp+1. Então o consumo de tempo do Quicksort no pior caso, digamos T(n), satisfaz a seguinte recorrência

  T(n)  =  5n + 7 + max 0<k<n (T(k) + T(nk)) (*)

onde o máximo é tomado sobre todos os possíveis valores de k no intervalo 1 .. n−1.  Podemos supor T(1) = 1.   Assim, por exemplo, T(2) ≤ 10+7+T(1)+T(1) = 19.  Outro exemplo: T(3) ≤ 15+7+T(1)+T(2) = 42.  Mais um exemplo: T(4) ≤ 20+7+T(1)+T(3) = 70, uma vez que T(1)+T(3) = 43 > 38 = T(2)+T(2).  Em geral, vamos mostrar que

T(n)  ≤  5n²  

para n = 1, 2, 3, …    A desigualdade é certamente verdadeira quando n = 1, 2, 3. Agora suponha que n > 3 e suponha que a desigualdade vale para T(i) sempre que i < n. Teremos então

T(n) = 5n + 7 + maxk (T(k) + T(nk))
  5n + 7 + maxk (5k² + 5(nk)
  = 5n + 7 + 5 (1 + (n−1)²)
  = 5n² − 5n + 17
  5n² .

O terceiro passo da prova segue da seguinte observação: para n fixo e k variando entre 1 e n−1, a expressão k² + (nk)² tem valor máximo nos pontos k = 1 e k = n−1 (e atinge o mínimo quando k está próximo de n/2).  O último passo da prova está correto pois quando n > 3 tem-se −5n+17 ≤ 0.

A cota superior de 5n² não é exagerada.  De fato, é fácil mostrar que T(n) ≥ 2n².  Portanto T(n) está em Ο(n²) e também em Ω(n²).

Se deixarmos de lado a hipótese artificial de que cada linha "simples" do algoritmo consome 1 unidade de tempo, as conclusões ainda serão as mesmas.  A recorrência pode ser reescrita como  T(n) = max 0<k<n (T(k) + T(nk)) + Ο(n)  e daí se deduz que T(n) está em Ο(n²).   Conclusão:  no pior caso, o algoritmo Quicksort é quadrático.  Por que, então, o algoritmo é tão popular?

Exercícios

  1. Imite a prova de T(n) ≤ 5n² para tentar provar que T(n) ≤ 1000n.  Repita com T(n) ≤ 100n lg n.  Repita com T(n) ≤ 2n².
  2. Seja T a função definida pela recorrência (*).  Mostre que T(n) ≥ 2n² para todo número natural n ≥ 1.  Deduza daí que T(n) é Ω(n²).
  3. Considere a variante do algoritmo Quicksort que usa o algoritmo Separ no lugar de Separe (veja exercício acima). Escreva a recorrência que governa o consumo de tempo de pior caso desta variante do Quicksort.  Resolva a recorrência.
  4. Mostre que  (n!)² > nn  para todo número natural não nulo n.   [Este exercício envolve cálculo semelhante ao que fizemos acima.]

Desempenho típico do Quicksort

O melhor desempenho do Quicksort ocorre quando todas as invocações de Separe dividem o vetor na proporção (1/2)-para-(1/2), ou seja, quando todas as invocações devolvem um índice que está a meio caminho entre pr.  O consumo de tempo do algoritmo nesse caso, digamos S(n), satisfaz a recorrência

S(n) = S(⌈n/2⌉) + S(⌊n/2⌋) + 5n + 7

para todo natural n ≥ 2  (veja a definição de teto).  A exemplo do que fizemos em outra ocasião, podemos mostrar que

S(n)  ≤  12 n log2 n

para todo natural n ≥ 3.

O comportamento não é muito diferente quando o vetor é dividido de maneira menos equilibrada.  Suponha, por exemplo, que todas as invocações de Separe dividem o vetor na proporção (1/9)-para-(8/9).  Nesse caso, o consumo de tempo, digamos R(n), satisfaz recorrência da forma

R(n)  =  R(⌈n/9⌉) + R(⌊8n/9⌋) + 5n + 7

para todo natural n ≥ 2.  Mostremos que

R(n)  ≤  7 n log9/8 n

para todo n ≥ 2.  Supondo R(1) = 1, é fácil verificar que a desigualdade vale quando n = 2, 3.  Agora tome n ≥ 4. Então, por hipótese de indução, e usando sempre log na base 9/8,

R(n) = 7 ⌈n/9⌉ log ⌈n/9⌉ + 7 ⌊8n/9⌋ log ⌊8n/9⌋ + 5n + 7
  7 (n/9⌉ + ⌊8n/9⌋) log ⌊8n/9⌋ + 5n + 7
  = 7 n log (8n/9) + 5n + 7
  = 7 n log (n) + 7 n log (8/9) + 5n + 7
  = 7 n log n − 7 n + 5n + 7
  = 7 n log n − 2n + 7
  < 7 n log n .

(Como seria de se esperar, o coeficiente de n lg n é maior nesse caso que no anterior:  7 log9/8 n  >  7×5.884 log2 n  >  41 log2 n  >  12 log2 n.)

A análise dos dois casos acima — a divisão na proporção (1/2)-para-(1/2) e a divisão na proporção (1/9)-para-(8/9) — sugere a seguinte conclusão:  se Separe sempre divide o vetor em alguma proporção tn-para-(1−t)n, o consumo de tempo do Quicksort é

Ο(n lg n). 

(É claro que a constante escondida sob a notação Ο é tanto maior quanto mais t se afasta de 1/2.)

As conclusões valem mesmo que o valor de t seja diferente em cada invocação de Separe.  Essas observações sugerem que o consumo de tempo "típico" do Quicksort está em Ο(n lg n).

Exercícios

  1. Mostre que para todo número natural n ≥ 3 tem-se ⌈n/9⌉ ≤ ⌊8n/9⌋.
  2. Mostre que para todo número natural n ≥ 2 tem-se ⌈n/9⌉ + ⌊8n/9⌋ = n.
  3. Invente uma variante de Separe que funcione assim: rearranja A[p..r] e devolve q tal que A[p..q] ≤ A[q+1..r] e p+2 ≤ q < r−2. (É claro que isso só faz sentido se o vetor tiver pelo menos 6 elementos; para vetores menores, use o Separe usual.)  Quanto tempo o seu algoritmo consome?  Se Quicksort usar o seu algoritmo no lugar de Separe, qual será o seu consumo de tempo no pior caso?
  4. Seja a um número real no intervalo (0,½). Invente uma variante de Separe que funcione assim: rearranja A[p..r] e devolve q tal que A[p..q] ≤ A[q+1..r] e p+an ≤q < ran, sendo n = rp+1.  Quanto tempo o seu algoritmo consome?  Se Quicksort usar o seu algoritmo no lugar de Separe, qual será o seu consumo de tempo no pior caso?

Quicksort aleatorizado

Se o vetor A[p..r] for crescente ou aproximadamente crescente, o algoritmo Quicksort consome Ω(n²) unidades de tempo.  Para evitar casos desfavoráveis como esses, podemos escolher o pivô aleatoriamente, como faremos a seguir.

Suponha dado um algoritmo auxiliar Random que funciona assim: ao receber um par p ≤ r de números naturais, devolve um número aleatório no intervalo p..r, sendo todos os números do intervalo igualmente prováveis.  Existem implementações (aproximadas) de Random que consomem uma quantidade de tempo constante, ou seja, independente de pr.  Esse algoritmo auxiliar pode ser usado para construir versões aleatorizadas (= randomized) de Separe e Quicksort:

Separe-Aleatorizado (A, p, r)
o iRandom(pr)
o troque  A[p] ↔ A[i]
o Separe (A, p, r)
Quicksort-Aleatorizado (A, p, r)
o se  p < r
oooo então  qSepare-Aleatorizado (A, p, r)
oooo então  Quicksort-Aleatorizado (A, p, q)
oooo então  Quicksort-Aleatorizado (A, q+1, r)

No pior caso, Quicksort-Aleatorizado consome tanto tempo quanto Quicksort.  Em média, entretanto, Quicksort-Aleatorizado é muito mais rápido:  seu consumo médio é de Ο(n lg n) unidades de tempo, como mostraremos a seguir.

A aplicação do subalgoritmo Separe-Aleatorizado ao vetor A[p..r] determina dois subvetores. Diremos que o Separe-Aleatorizado produz resultado (ini) se os dois vetores resultantes tiverem tamanhos i e ni, onde n = rp+1.  Os possíveis resultados são

(1, n−1),   (2, n−2),   … ,  (n−2, 2),   (n−1, 1) .

Ao todo, temos  n−1  possíveis resultados.  Resta saber qual a probabilidade de cada um.

Trataremos apenas do caso em que o vetor não tem elementos repetidos.  (Acho que os resultados não valem sem essa hipótese.)  Sob essa hipótese, a probabilidade de cada caso só depende do valor de A[p] antes no fim da linha 2 de Separe-Aleatorizado. Não é difícil verificar que

(Note que Separe-Aleatorizado produz o mesmo resultado quer A[p] seja o menor ou o segundo menor elemento do vetor.)

Em virtude da aleatorização, A[p] tem igual probabilidade de ser o menor, o segundo menor, etc., o n-ésimo menor elemento do vetor. Concluímos que o resultado (1,n−1) tem probabilidade 2/n e cada um dos demais resultados tem probabilidade 1/n.

Denotemos por E(n) o valor esperado do consumo de tempo do Quicksort-Aleatorizado.  Então

E(n)   =   5n + 9  +   s1 + s1 + s2 + … + sn−1  ,
n

onde  si = E(i)+E(ni)  para i = 1,2,3,…,n−1.  (O termo s1 aparece duas vezes, mas nossos cálculos mostrarão que isso não faz diferença para a solução assintótica da recorrência.)  Se agruparmos termos iguais teremos a recorrência

 
E(n)   =   5n + 9  +   E(1)+E(n−1) + 2 ∑0<i<n E(i)  .
n
(**)

A análise de uma recorrência mais simples (veja exercício abaixo) sugere que a solução está em Ο(n lg n).  Alguns cálculos confirmam esta suspeita:

E(n)  ≤  20 n lg n

para n = 2, 3, …

Exercícios

  1. Seja F a função sobre os naturais definida pela recorrência  F(n) = n + 2 ∑0<i<nF(i/ n  com F(1) = 1.   Mostre que  F(n) ≤ 10 n lg n  para n = 2, 3, …   (Dica: lg i ≤ lg(n/2) quando i ≤ n/2  e  lg i ≤ lg n quando i < n.)    [Solução]
  2. Verifique que a função E definida pela recorrência (**) é tal que E(n)  ≤  20 n lg n  para n = 2, 3, … 

Valid HTML 4.01 Strict Valid CSS!