A[p..r] localmente.
A seguir, as Figuras 24 e 25 ilustram o funcionamento do procedimento particionar. Os elementos da lista ligeiramente sombreados estão na primeira partição, com valores não maiores que x. Elementos fortemente sombreados estão na segunda partição, com valores maiores que x. Os elementos não sombreados ainda não foram inseridos em nenhuma partição, e o elemento final branco é o pivô.
A Figura 25 ilustra as seguintes situações: Figura 25: Iteração do procedimento particionar.
(a) Arranjo inicial. Nenhum dos elementos foi inserido em qualquer das duas primeiras partições.
(b) O valor 2 é inserido na partição de valores menores.
(c)-(d) Os valores 8 e 7 foram acrescentados à partição de valores maiores.
(e) Os valores 1 e 8 são permutados, e a partição menor cresce.
(f) Os valores 3 e 8 são permutados, e a partição menor cresce.
(g)-(h) Os valores 5 e 6 são incluídos na partição maior e o loop termina.
(i) O elemento pivô é permutado com o primeiro elemento da segunda partição, de forma a residir entre as duas partições.
(a) Se A[j] > x, j é incrementado, e o loop se mantém sem mudanças.
(b) Quando A[j]<=x, i é incrementado, A[i] e A[J] são permutados, e então j é incrementado. Por causa da troca, agora temos que A[i] <= x; de modo semelhante, também temos que A[j-1] > x, pois o item que foi trocado em A[j - 1] é sempre maior que x.
4.3.2 Funcionamento do algoritmo na forma paralela
Existem algumas vantagens na utilização deste algoritmo, no que toca a paralelização de algoritmos. Uma, porque é considerado um dos algoritmos mais rápidos. E outra, por ser um algoritmo que chama a si próprio recursivamente, sendo que essas chamadas podem ser executadas independentemente.
Em relação ao desenvolvimento deste algoritmo, inicialmente os valores encontram-se distribuídos ao longo dos processos. Escolhemos então um pivô de cada um dos processos. Após isso, cada processo divide os seus números em duas listas: aqueles que são menores ou iguais ao pivô, e aqueles que são maiores do que o pivô (que chamaremos aqui de “lista menor” e “lista maior”, respectivamente) – procedimento que denominamos de rearranjo local. Cada processo na parte de cima da lista de processos (parte da lista em que se encontram os elementos maiores que o último pivô) envia a sua “lista menor” para um processo concorrente na parte inferior da lista de processos (parte da lista em que se encontram os elementos menores que o último pivô; em princípio a parte inferior se encontra no início da lista) e recebe uma “lista maior” em retorno. Neste momento, os processos na parte superior da lista de processos têm valores maiores do que o pivô, e os processos na parte inferior da lista de processos têm valores menores ou iguais ao pivô, procedimento este denominado rearranjo global. A partir deste ponto, os processos dividem-se em dois grupos e o algoritmo é chamado novamente. Em cada grupo de processos, é distribuído um pivô. Os processos dividem a sua lista e trocam valores com processos concorrentes. Após log p recursões, cada processo tem uma lista desordenada de valores, que são diferentes dos valores possuídos pelos outros processos. Cada processo pode agora ordenar a lista que controla usando quicksort seqüencial.
Figura 26: Exemplo da aplicação paralela do algoritmo Quick sort.
Em relação à complexidade, se tivermos p processadores, podemos dividir a lista de n elementos em p sublistas em O(n). Após isso, a sua ordenação será em O((n/p)log(n/p)) [Grama, 1994].
Uma das vantagens deste algoritmo em relação a outros algoritmos de ordenação em paralelo é o fato de não ser necessária a sua sincronização. Um processo é criado por cada sublista gerada, sendo que esse processo só trabalha com essa lista, não se comunicando com os outros processos.
O tempo de execução do algoritmo começa a ser contabilizado quando o primeiro processo começa a sua execução, e termina quando o último processo termina a sua execução. É por isto que é importante assegurar que todos os processos tenham a mesma carga de trabalho, para que terminem todos por volta do mesmo tempo. Neste algoritmo, a carga de trabalho é relacionada com o número de elementos controlados pelos processos. Um ponto negativo deste algoritmo é a necessidade de se conseguir um pivô perto da mediana dos valores, para garantir
maior divisão de trabalho pelos processos. Por exemplo, em uma lista com os valores 1, 2, 4, 7, 9, 10, o pivô deve ter valor próximo a 5,5 (pois a mediana é (4+7)/2, que é 5.5).
No entanto, poderia ser feito um melhor balanceamento do tamanho da lista pelos processos se, em vez de escolher um número arbitrário da lista para ser pivô, escolhêssemos um valor mais perto do valor mediano da lista ordenada. Este ponto é a motivação por trás do próximo algoritmo em paralelo: hyperquicksort.
4.3.3 Hyperquicksort (uma variação do quick sort)
Este algoritmo, desenvolvido por Wagar [Wagner, 1987], em vez de escolher um número arbitrário da lista para ser pivô, escolhe um valor mais perto do valor mediano da lista ordenada. Considerando que os valores continuam a mover-se entre processos, teremos então um pivô para dividir os números em dois grupos: a parte superior e a parte inferior. O hyperquicksort possui o seguinte princípio: cada processador resolve um subproblema usando o algoritmo seqüencial quicksort e utiliza um mecanismo de comunicação paralela eficiente para gerar a solução final partindo das soluções parciais dos processadores. No primeiro passo do hyperquicksort, cada processador usa o quicksort para ordenar sua lista local. Durante cada passo da segunda fase do algoritmo, um hipercubo [Hipercubo, 2008] é dividido em dois subcubos. Cada processador envia valores ao seu vizinho no outro subcubo, com isso cada processador recebe valores e os acrescenta a sua lista remanescente.
Os passos finais do hyperquicksort são os mesmos do quicksort paralelo. Após a execução desses passos, cada processo tem uma sublista ordenada própria e uma sublista ordenada que recebe de um outro processo. Esse processo vai então juntar as duas listas, para que todos os elementos que controla estejam ordenados. É importante que os processos terminem esta fase com listas ordenadas, porque quando o algoritmo se chamar novamente, dois processos precisarão escolher o elemento mediano das suas listas como pivô.
O hyperquicksort assume que o número de processos é um expoente de 2. Se imaginarmos a lista de processos como um hipercubo, poderemos modelar as
comunicações, de modo que estas sejam sempre feitas entre pares de processos adjacentes (Ver Figura 27).
Figura 27: Pares de processos adjacentes do Hyperquicksort.
No início do algoritmo, cada processo tem no máximo n/p valores, onde n é o número de elementos a serem ordenados, p é o número de processadores e n é maior que p. A complexidade de tempo esperada do passo inicial do quicksort é Θ[(n/p)log(n/p)] [Grama, 1994]. Assumindo que cada processo guarda n/2p valores e transmite n/2p valores em cada passo de separar-e-juntar, o número esperado de comparações necessárias para juntar as duas listas numa única lista ordenada é n/p. O número de comparações feitas durante o algoritmo todo é Θ[(n/p)(logn+logp)], para log p iterações.
Assumindo que cada processo passa metade dos seus valores em cada iteração, o tempo necessário para enviar e receber n/2p valores ordenados para outro processo, é Θ(n/p).
Procedimento hipercubo_quicksort(B,n) início
Para i := 1 até d faça x := pivô
Particionar B em B1 e B2 tal que B1 <= x < B2 Se (n-ésimo_bit(i)=0) então
Enviar B2 para um processo através da i-ésima dimensão C recebe uma sublista ordenada dessa mesma dimensão B recebe B1 U C
Senão
Enviar B1 para um processo através da i-ésima dimensão C recebe uma sublista ordenada dessa mesma dimensão B recebe B2 U C
Ordenar B usando quicksort seqüencial fim