Quicksort na prática: o pivô, a partição e o pior caso que você evita
Entenda o quicksort seguindo um vetor de sete números, implemente uma versão com pivô aleatório que não degrada em dado ordenado e saiba quando usar o sort da linguagem.
Um milhão de números já ordenados. A versão de quicksort que você escreveu na faculdade, com o primeiro elemento como pivô, faz cerca de 500 bilhões de comparações nesse caso. A mesma função, com o pivô sorteado, faz algumas dezenas de milhões. O algoritmo é o mesmo; a escolha do pivô decide se ele termina em menos de um segundo ou trava a requisição.
A resposta curta: o quicksort escolhe um elemento, o pivô, coloca os menores de um lado e os maiores do outro, e repete o processo em cada lado. Em média, isso custa na ordem de n log n comparações. Se o pivô cai sempre num extremo, a divisão não divide e o custo vira n². Pivô aleatório ou mediana de três evita o caso ruim na prática. Em código de produção, use o sort da biblioteca da linguagem, que já trata esses problemas.
A ideia em uma frase: separar em dois montes
Ordenar parece exigir comparar todo mundo com todo mundo. O quicksort evita isso com uma observação simples: se você sabe que todos à esquerda de um elemento são menores e todos à direita são maiores, esse elemento já está na posição final. Falta ordenar cada lado, e cada lado é um problema menor do mesmo tipo.
A operação central se chama partição. Ela percorre o trecho uma vez, compara cada elemento com o pivô e troca posições até os menores ficarem juntos no começo. No fim, o pivô vai para a fronteira. Uma passada linear, um elemento no lugar certo e dois subproblemas.
Seguindo um vetor de sete números
Pegue [7, 2, 9, 4, 1, 8, 3]. Para o exemplo ficar previsível, o pivô é o último elemento, 3. A partição usada é a de Lomuto: um índice i marca onde termina o monte dos menores.
i = 0. O 7 não é menor que 3; segue.- O 2 é menor: troca com a posição 0. Vetor:
[2, 7, 9, 4, 1, 8, 3],i = 1. - O 9 e o 4 não são menores; seguem.
- O 1 é menor: troca com a posição 1. Vetor:
[2, 1, 9, 4, 7, 8, 3],i = 2. - O 8 não é menor; segue.
- Fim da passada: o pivô troca com a posição 2. Vetor:
[2, 1, 3, 4, 7, 8, 9].
O 3 está no lugar definitivo. À esquerda ficou [2, 1] e à direita [4, 7, 8, 9]. Agora repare no lado direito: ele já está ordenado. Com pivô no último elemento, o 9 é o maior, a partição deixa três elementos de um lado e nenhum do outro. Depois o 8, depois o 7. Cada passada tira só um elemento do problema. Esse é o pior caso, em miniatura.
Por que o pior caso aparece justo em dado ordenado
Quando o pivô divide o trecho mais ou menos ao meio, o tamanho do problema cai pela metade a cada nível. Com um milhão de elementos, são cerca de 20 níveis, e cada nível percorre todos os elementos uma vez: algo como 20 milhões de comparações, um pouco mais na média real.
Quando o pivô é sempre o menor ou o maior, cada nível tira um elemento só. São um milhão de níveis, percorrendo 999.999, depois 999.998, e assim por diante. A soma dá perto de n²/2, ou 500 bilhões. Além do tempo, a recursão fica com um milhão de chamadas empilhadas, o que derruba muitas linguagens com estouro de pilha.
O detalhe cruel: dado já ordenado, ou quase ordenado, é extremamente comum. Lista de pedidos por data, IDs sequenciais, resultado de uma consulta com ORDER BY. Pegar o primeiro ou o último elemento como pivô é exatamente a escolha que falha no caso mais frequente.
Uma implementação que não degrada fácil
O código abaixo, em JavaScript, faz duas coisas além do básico. Sorteia o pivô, o que torna o pior caso improvável para qualquer entrada. E chama a recursão só no lado menor, tratando o maior num laço, o que limita a profundidade da pilha a algo na ordem de log n.
function quicksort(v, ini = 0, fim = v.length - 1) {
while (ini < fim) {
const p = particionar(v, ini, fim);
if (p - ini < fim - p) {
quicksort(v, ini, p - 1);
ini = p + 1;
} else {
quicksort(v, p + 1, fim);
fim = p - 1;
}
}
return v;
}
function particionar(v, ini, fim) {
const sorteado = ini + Math.floor(Math.random() * (fim - ini + 1));
[v[sorteado], v[fim]] = [v[fim], v[sorteado]];
const pivo = v[fim];
let i = ini;
for (let j = ini; j < fim; j++) {
if (v[j] < pivo) {
[v[i], v[j]] = [v[j], v[i]];
i++;
}
}
[v[i], v[fim]] = [v[fim], v[i]];
return i;
}
console.log(quicksort([7, 2, 9, 4, 1, 8, 3])); // [1, 2, 3, 4, 7, 8, 9]
Uma alternativa ao sorteio é a mediana de três: olhar o primeiro, o do meio e o último, e usar o valor do meio. Em dado ordenado, ela pega exatamente o centro. Outra fraqueza que vale conhecer: a partição de Lomuto também degrada quando há muitos valores repetidos, porque todos os iguais ao pivô caem do mesmo lado. A correção é a partição em três vias, com um monte para menores, um para iguais e um para maiores.
Como medir antes de acreditar
- Gere três entradas do mesmo tamanho: aleatória, já ordenada e com muitos repetidos (por exemplo, só valores de 0 a 9).
- Rode sua função em cada uma e anote o tempo com
performance.now()antes e depois. - Rode o
sortda linguagem nas mesmas entradas, sempre sobre uma cópia. - Compare. Se a sua versão perde, e quase sempre perde, a resposta para produção já está dada.
O que a biblioteca da linguagem já faz por você
As bibliotecas padrão raramente usam quicksort puro. O motor V8, do Chrome e do Node, usa TimSort no Array.prototype.sort, e a especificação do JavaScript exige ordenação estável desde 2019. O Python usa um algoritmo derivado do TimSort. O Java usa um quicksort de dois pivôs para arrays de tipos primitivos e TimSort para objetos. O Array.Sort do .NET e a maioria das implementações de std::sort em C++ usam introsort, que começa como quicksort e troca de estratégia se a recursão ficar funda demais.
Esses algoritmos foram ajustados por anos para dados reais, aproveitam trechos já ordenados e têm o pior caso tratado. Reescrever o sort "para ser mais rápido" sem medir é trocar código testado por um bug de desempenho esperando a entrada errada. Quando a lentidão aparece, o gargalo quase nunca é a ordenação; comece medindo, como em site mais rápido: por onde começar.
Erros comuns
- Pivô fixo no primeiro ou último elemento: em dado ordenado, o tempo vira quadrático e a recursão pode estourar a pilha.
- Recursão nos dois lados sem cuidado: no pior caso, a profundidade chega a n chamadas e o processo cai com estouro de pilha.
- Ignorar valores repetidos: com partição de duas vias, um vetor cheio de iguais degrada como se estivesse ordenado.
- Usar o
sortdo JavaScript sem comparador para números: o padrão compara como texto, e[10, 9, 1].sort()devolve[1, 10, 9]. Use(a, b) => a - b. - Esperar estabilidade de um quicksort caseiro: elementos iguais podem trocar de ordem, e uma ordenação por data seguida de outra por cliente embaralha o resultado.
- Medir só com entrada aleatória: o teste passa, e a produção, que recebe dado quase ordenado, encontra o pior caso.
Checklist
- Você consegue explicar a partição seguindo um vetor pequeno no papel.
- Sua implementação de estudo não usa pivô fixo num extremo.
- A recursão é limitada, chamando no lado menor ou com outra proteção.
- Você testou com entrada ordenada, inversa e cheia de repetidos.
- Em produção, o código usa o sort da biblioteca padrão.
- Todo sort numérico em JavaScript tem comparador explícito.
- Qualquer troca de algoritmo veio acompanhada de medição antes e depois.
Procure no seu código uma ordenação escrita à mão. Se você passar para ela um vetor já ordenado de cem mil itens, o pivô ainda corta no meio, ou você acabou de achar o pior caso esperando para aparecer?
Referência: https://www.youtube.com/watch?v=PrR3nfq9wSY