Uma visão geral do algoritmo QuickSort

Uma visão geral do algoritmo QuickSort

1. Introdução

Neste artigo, vamos dar uma olhada no algoritmo quicksort e entender como ele funciona.

Quicksort é um algoritmo de dividir e conquistar. Isso significaeach iteration works by dividing the input into two parts and then sorting those, antes de combiná-los novamente

Foi originalmente desenvolvido por Tony Hoare e publicado em 1961 e ainda é um dos algoritmos de classificação geral mais eficientes disponíveis.

2. Requisitos de Algoritmo

The only real requirement for using the quicksort algorithm is a well-defined operation to compare two elements, de modo que possamos determinar se algum elemento é estritamente menor que outro. A natureza exata desta comparação não é importante, desde que seja consistente. Observe que a comparação direta de igualdade não é necessária, apenas uma comparação inferior.

For many types, this is an undeniable comparison. Por exemplo, os números definem implicitamente como fazer isso. Outros tipos são menos óbvios, mas ainda podemos definir isso com base nos requisitos da classificação. Por exemplo, ao classificar cadeias, precisamos decidir se um caso de caractere é importante ou como os caracteres Unicode funcionam.

3. Classificação de árvore binária

The Binary Tree Sort is an algorithm where we build a balanced binary tree consisting of the elements we’re sorting. Depois que tivermos isso, podemos construir os resultados dessa árvore.

A ideia é selecionar umpivot como um nó na árvore e, em seguida, atribuir todos os elementos ao ramoleft ouright do nó com base no fato de eles serem menores que o pivô elemento ou não. Podemos então classificar recursivamente esses galhos até termos uma árvore completamente classificada.

3.1. Exemplo Trabalhado

Por exemplo, para classificar a lista de números "3 7 8 5 2 1 9 5 4". Nosso primeiro passe seria o seguinte:

Input: 3 7 8 5 2 1 9 5 4
Pivot = 3
Left = 2 1
Right = 7 8 5 9 5 4

Isso nos deu duas partições da entrada original. Everything in the Left list is strictly less than the Pivot, and everything else is in the Right list.

Em seguida, classificamos essas duas listas usando o mesmo algoritmo:

Input: 2 1
Pivot = 2
Left = 1
Right = Empty

Input: 7 8 5 9 5 4
Pivot = 7
Left = 5 5 4
Right = 8 9

When we sorted the left partition from the first pass, we have ended up with two lists that are both length 1 or less. Eles já estão classificados - porque é impossível ter uma lista de tamanho um que não esteja classificada. Isso significa que podemos parar por aqui e, em vez disso, focar nas partes restantes da partição correta.

Neste ponto, temos a seguinte estrutura:

      / [1]
    2
  /   \ []
3
  \   / [5 5 4]
    7
      \ [8 9]

Neste ponto, já estamos chegando perto de uma lista classificada. Temos mais duas partições para classificar e então terminamos:

        1
      /
    2       4
  /       /
3       5
  \   /   \
    7       5
      \
        8
          \
            9

This has sorted the list in 5 passes of the algorithm, applied to increasingly smaller sub-lists. No entanto, as necessidades de memória são relativamente altas, tendo sido necessário alocar 17 elementos adicionais no valor de memória para classificar os nove elementos em nossa lista original.

4. Algoritmo Quicksort

O algoritmo Quicksort é semelhante em conceito a uma Classificação de Árvore Binária. Em vez de criar sublistas em cada etapa que precisamos classificar, ele faz tudo no lugar na lista original.

It works by dynamically swapping elements within the list around a selected pivot, and then recursively sorting the sub-lists to either side of this pivot. Isso o torna significativamente mais eficiente em termos de espaço, o que pode ser importante para grandes listas.

Quicksort depende de dois fatores principais - a seleção depivote o mecanismo parapartitioning os elementos.

The key to this algorithm is the partition function, which we will cover soon. Isso retorna um índice para a matriz de entrada, de modo que cada elemento abaixo desse índice seja menor que o elemento nesse índice e o elemento nesse índice seja menor que todos os elementos acima dele.

Isso envolverá a troca de alguns dos elementos da matriz para que eles sejam o lado apropriado desse índice.

Depois de fazer esse particionamento, aplicamos o algoritmo às duas partições em cada lado deste índice. Isso termina quando temos partições que contêm apenas um elemento cada, momento em que a matriz de entrada está classificada agora.

4.1. Particionamento Lomuto

O particionamento do Lomuto é atribuído ao Nico Lomuto. This works by iterating over the input array, swapping elements that are strictly less than a pre-selected pivot element such that they appear earlier in the array, but on a sliding target index.

Esse índice de destino deslizante é o novo índice de partição que retornaremos para as próximas recursões do algoritmo maior para trabalhar.

O objetivo disso é garantir que nosso índice de destino deslizante esteja em uma posição em que todos os elementos anteriores a ele na matriz sejam menores que esse elemento e que esse elemento seja menor que todos os elementos após a mesma na matriz.

Vamos dar uma olhada em pseudocódigo:

fun quicksort(input : T[], low : int, high : int)
    if (low < high)
        p := partition(input, low, high)
        quicksort(input, low, p - 1)
        quicksort(input, p + 1, high)

fun partition(input: T[], low: int, high: int) : int
    pivot := input[high]
    partitionIndex := low
    loop j from low to (high - 1)
        if (input[j] < pivot) then
            swap(input[partitionIndex], input[j])
            partitionIndex := partitionIndex + 1
    swap(input[partitionIndex], input[high]
    return partitionIndex

Como exemplo, podemos particionar nossa matriz anteriormente:

Sorting input: 3,7,8,5,2,1,9,5,4 from 0 to 8
Pivot: 4
Partition Index: 0

When j == 0 => input[0] == 3 => Swap 3 for 3 => input := 3,7,8,5,2,1,9,5,4, partitionIndex := 1
When j == 1 => input[1] == 7 => No Change
When j == 2 => input[2] == 8 => No Change
When j == 3 => input[3] == 5 => No Change
When j == 4 => input[4] == 7 => Swap 7 for 2 => input := 3,2,8,5,7,1,9,5,4, partitionIndex := 2
When j == 5 => input[5] == 8 => Swap 8 for 1 => input := 3,2,1,5,7,8,9,5,4, partitionIndex := 3
When j == 6 => input[6] == 9 => No Change
When j == 7 => input[7] == 5 => No Change

After Loop => Swap 4 for 5 => input := 3,2,1,4,7,8,9,5,5, partitionIndex := 3

Podemos ver, ao trabalharmos com isso, que realizamos três swaps e determinamos um novo ponto de partição do índice "3". The array after these swaps is such that elements 0, 1, and 2 are all less than element 3, and element 3 is less than elements 4, 5, 6, 7 and 8.

Feito isso, o algoritmo maior se repete, de modo que classificaremos o sub-array de 0 a 2 e o sub-array de 4 a 8. Por exemplo, repetindo isso para o sub-array de 0 a 2, faremos:

Sorting input: 3,2,1,4,7,8,9,5,5 from 0 to 2
Pivot: 1
Partition Index: 0

When j == 0 => input[0] == 3 => No Change
When j == 1 => input[1] == 2 => No Change

After Loop => Swap 1 for 3 => input := 1,2,3,4,7,8,9,5,5, partitionIndex := 0

Observe que ainda estamos passando todo o array de entrada para o algoritmo trabalhar, mas como temos índices baixos e altos, só prestamos atenção na parte que nos interessa. This is an efficiency that means we’ve had no need to duplicate the entire array or sections of it.

Em todo o algoritmo, classificando toda a matriz, realizamos 12 swaps diferentes para chegar ao resultado.

4.2. Particionamento Hoare

O particionamento de Hoare foi proposto por Tony Hoare quando o algoritmo quicksort foi publicado originalmente. Em vez de trabalhar na matriz de baixo para cima,it iterates from both ends at once towards the center. Isso significa que temos mais iterações e mais comparações, mas menos swaps.

Isso pode ser importante, pois muitas vezes comparar valores de memória é mais barato do que trocá-los.

No pseudocódigo:

fun quicksort(input : T[], low : int, high : int)
    if (low < high)
        p := partition(input, low, high)
        quicksort(input, low, p) // Note that this is different than when using Lomuto
        quicksort(input, p + 1, high)

fun partition(input : T[], low: int, high: int) : int
    pivotPoint := low + (high - low) / 2
    pivot := input[pivotPoint]
    loop
        loop while (input[low] < pivot)
            low := low + 1
        loop while (pivot < input[high])
            high := high - 1
        if (low >= high)
            return high
        swap(input[low], input[high])
        low := low + 1
        high := high - 1

Como exemplo, podemos particionar nossa matriz anteriormente:

Sorting input: 3,7,8,5,2,1,9,5,4 from 0 to 8
Pivot: 2

Loop #1
    Iterate low => input[0] == 3 => Stop, low == 0
    Iterate high => input[8] == 4 => high := 7
    Iterate high => input[7] == 5 => high := 6
    Iterate high => input[6] == 9 => high := 5
    Iterate high => input[5] == 1 => Stop, high == 5
    Swap 1 for 3 => input := 1,7,8,5,2,3,9,5,4
    Low := 1
    High := 4
Loop #2
    Iterate low => input[1] == 7 => Stop, low == 1
    Iterate high => input[4] == 2 => Stop, high == 4
    Swap 2 for 7 => input := 1,2,8,5,7,3,9,5,4
    Low := 2
    High := 3
Loop #3
    Iterate low => input[2] == 8 => Stop, low == 2
    Iterate high => input[3] == 5 => high := 2
    Iterate high => input[2] == 8 => high := 1
    Iterate high => input[1] == 2 => Stop, high == 1
    Return 1

Em face disso, este parece ser um algoritmo mais complicado que está fazendo mais trabalho. No entanto, faz um trabalho mais barato em geral. The entire algorithm only needs 8 swaps instead of the 12 needed by the Lomuto partitioning scheme to achieve the same results.

5. Ajustes de Algoritmo

Existem vários ajustes que podemos fazer no algoritmo normal, dependendo dos requisitos exatos. Eles não se encaixam em todos os casos, portanto, devemos usá-los apenas quando apropriado, mas podem fazer uma diferença significativa no resultado.

5.1. Seleção de pivô

The choice of the element to pivot around can be significant to how efficient the algorithm is. Acima, selecionamos um elemento fixo. Isso funciona bem se a lista é realmente embaralhada em uma ordem aleatória, mas quanto mais ordenada a lista, menos eficiente ela é.

Se tivéssemos que classificar a lista1, 2, 3, 4, 5, 6, 7, 8, 9, o esquema de particionamento Hoare faria isso com zero swaps, mas o esquema Lomuto precisa de 44 Igualmente, a lista9, 8, 7, 6, 5, 4, 3, 2, 1 precisa de 4 trocas com Hoare e 24 com Lomuto.

No caso do esquema de particionamento Hoare, isso já é muito bom, mas o esquema Lomuto pode melhorar bastante. By introducing a change to how we select the pivot, to use a median of three fixed points, we can get a dramatic improvement.

Esse ajuste é conhecido simplesmente como Mediana de três:

mid := (low + high) / 2
if (input[mid] < input[low])
    swap(input[mid], input[low])
if (input[high] < input[low])
    swap(input[high], input[low])
if (input[mid] < input[high])
    swap(input[mid], input[high])

Aplicamos isso em todas as passagens do algoritmo. Isso leva os três pontos fixos e garante que eles sejam pré-classificados na ordem inversa.

Isso parece incomum, mas o impacto fala por si. Using this to sort the list 1, 2, 3, 4, 5, 6, 7, 8, 9 now takes 16 swaps, where before it took 44. That’s a 64% reduction in the work done. No entanto, a lista9, 8, 7, 6, 5, 4, 3, 2, 1 cai para 19 trocas com isso, em vez de 24 antes, e a lista3, 7, 8, 5, 2, 1, 9, 5, 4 sobe para 18 onde era 12 antes.

5.2. Elementos Repetidos

Quicksort suffers slightly when there are large numbers of elements that are directly equal. Ele ainda tentará classificar tudo isso e potencialmente fará muito mais trabalho do que o necessário.

Um ajuste que podemos fazer é detectar esses elementos iguais como parte da fase de particionamento e retornar limites de ambos os lados, em vez de apenas um único ponto. We can then treat an entire stretch of equal elements as already sorted and just handle the ones on either side.

Vamos ver isso em pseudocódigo:

fun quicksort(input : T[], low : int, high : int)
    if (low < high)
        (left, right) := partition(input, low, high)
        quicksort(input, low, left - 1)
        quicksort(input, right + 1, high)

Aqui, toda vez que o esquema de particionamento retorna um pivô, ele retorna os índices inferior e superior para todos os elementos adjacentes que têm o mesmo valor. Isso pode remover rapidamente faixas maiores da lista sem a necessidade de processá-las.

Para implementar isso, precisamos ser capazes de comparar elementos para igualdade e para menos que. No entanto, essa é geralmente uma comparação mais fácil de implementar.

6. Desempenho do Algoritmo

O algoritmo quicksort é geralmente considerado muito eficiente. On average, it has O(n log(n)) performance for sorting arbitrary inputs.

O esquema de particionamento Lomuto original degradará paraO(n²) no caso em que a lista já está classificada e escolhemos o elemento final como o pivô. Como vimos, isso melhora quando implementamos mediana de três para nossa seleção de pivô e, de fato, isso nos leva de volta aO(n log(n)).

Por outro lado, o esquema de particionamento Hoare pode resultar em mais comparações porque ele se repete emlow → p em vez delow → p-1. Isso significa que a recursão faz mais comparações, mesmo que resulte em menos trocas.

7. Sumário

Aqui, tivemos uma introdução ao que é quicksort e como funciona o algoritmo. Também cobrimos algumas variações que podem ser feitas no algoritmo para diferentes casos.