Por que o quicksort é melhor do que outros algoritmos de classificação na prática?


308

Em um curso padrão de algoritmos, aprendemos que o quicksort é em média e no pior caso. Ao mesmo tempo, outros algoritmos de classificação são estudados que são no pior dos casos (como mergesort e heapsort ) e até tempo linear no melhor dos casos (como bubblesort ), mas com algumas necessidades adicionais de memória.O ( n 2 ) O ( n log n )O(nlogn)O(n2)O(nlogn)

Após uma rápida olhada em mais alguns tempos de execução , é natural dizer que o quicksort não deve ser tão eficiente quanto os outros.

Além disso, considere que os alunos aprendem nos cursos básicos de programação que a recursão não é realmente boa em geral, porque poderia usar muita memória, etc. Portanto (e mesmo que este não seja um argumento real), isso dá a ideia de que o quicksort pode não muito bom porque é um algoritmo recursivo.

Por que, então, o quicksort supera outros algoritmos de classificação na prática? Isso tem a ver com a estrutura dos dados do mundo real ? Isso tem a ver com o modo como a memória funciona nos computadores? Sei que algumas memórias são muito mais rápidas que outras, mas não sei se essa é a verdadeira razão desse desempenho contra-intuitivo (quando comparado às estimativas teóricas).


Atualização 1: uma resposta canônica está dizendo que as constantes envolvidas no do caso médio são menores do que as constantes envolvidas em outros algoritmos . No entanto, ainda não vi uma justificativa adequada disso, com cálculos precisos, em vez de apenas idéias intuitivas.O ( n log n )O(nlogn)O(nlogn)

De qualquer forma, parece que a diferença real ocorre, como algumas respostas sugerem, no nível da memória, em que as implementações aproveitam a estrutura interna dos computadores, usando, por exemplo, que a memória cache é mais rápida que a RAM. A discussão já é interessante, mas eu ainda gostaria de ver mais detalhes com relação ao gerenciamento de memória, pois parece que a resposta tem a ver com isso.


Atualização 2: Existem várias páginas da Web que oferecem uma comparação de algoritmos de classificação, algumas mais sofisticadas que outras (principalmente a classificação- algorithms.com ). Além de apresentar uma boa ajuda visual, essa abordagem não responde à minha pergunta.


2
A classificação de mesclagem é na pior das hipóteses, e a classificação de uma matriz de números inteiros onde existe um limite conhecido no tamanho dos números inteiros pode ser feita em tempo com uma classificação de contagem. O ( n )O(nlogn)O(n)
Carl Mummert

13
sorting-algorithms.com tem uma comparação bastante completa dos algoritmos de classificação.
31512 Joe

2
Atualização do anúncio 1: Suponho que você pode ter uma análise rigorosa ou suposições realistas. Eu não vi os dois. Por exemplo, a maioria das análises formais conta apenas comparações.
Raphael

9
Esta pergunta ganhou um concurso recente de programadores .
Raphael

3
Pergunta interessante. Fiz alguns testes há algum tempo com dados aleatórios e uma implementação ingênua de ordenação rápida e mesclagem. Ambos os algoritmos tiveram um desempenho muito bom para pequenos conjuntos de dados (até 100.000 itens), mas após esse tipo de mesclagem, ficou muito melhor. Isso parece contradizer a suposição geral de que a ordenação rápida é tão boa e ainda não encontrei uma explicação para isso. A única idéia que eu poderia ter é que, normalmente, o termo classificação rápida é usado para algoritmos mais complexos, como introdução, e que a implementação ingênua de classificação rápida com pivô aleatório não é tão boa.
Giorgio

Respostas:


215

Resposta curta

O argumento de eficiência do cache já foi explicado em detalhes. Além disso, há um argumento intrínseco, por que o Quicksort é rápido. Se implementado como com dois "ponteiros cruzados", por exemplo , aqui , os loops internos têm um corpo muito pequeno. Como esse é o código executado com mais frequência, isso compensa.

Resposta longa

Em primeiro lugar,

O caso médio não existe!

Como o melhor e o pior caso costumam ocorrer extremos, raramente ocorrem na prática, é feita uma análise de caso médio. Mas qualquer análise de caso médio pressupõe alguma distribuição de entradas ! Para a classificação, a escolha típica é o modelo de permutação aleatória (tacitamente assumido na Wikipedia).

Por que Notação?O

O descarte de constantes na análise de algoritmos é feito por um motivo principal: se estou interessado em tempos de execução exatos , preciso de custos (relativos) de todas as operações básicas envolvidas (mesmo ignorando os problemas de cache, canalizando nos processadores modernos ...). A análise matemática pode contar com que frequência cada instrução é executada, mas o tempo de execução de instruções únicas depende dos detalhes do processador, por exemplo, se uma multiplicação de números inteiros de 32 bits leva tanto tempo quanto a adição.

Existem duas maneiras de sair:

  1. Corrija algum modelo de máquina.

    Isso é feito na série de livros de Don Knuth , “The Art of Computer Programming”, para um computador “típico” artificial inventado pelo autor. No volume 3, você encontra resultados médios exatos de casos para muitos algoritmos de classificação, por exemplo

    • Classificação :11.667(n+1)ln(n)1.74n18.74
    • Incorporação:12.5nln(n)
    • Montante: 16nln(n)+0.01n
    • inserção: [ origem ]2.25n2+7.75n3ln(n) Tempos de execução de vários algoritmos de classificação

    Esses resultados indicam que o Quicksort é o mais rápido. Mas, isso só é provado na máquina artificial de Knuth, não implica necessariamente nada para dizer o seu PC x86. Observe também que os algoritmos se relacionam de maneira diferente para pequenas entradas:
    Tempos de execução de vários algoritmos de classificação para pequenas entradas
    [ fonte ]

  2. Analisar operações básicas abstratas .

    Para classificação baseada em comparação, normalmente são trocas e comparações de chaves . Nos livros de Robert Sedgewick, por exemplo, "Algoritmos" , essa abordagem é seguida. Você encontra lá

    • Classificação rápida: comparações e swaps em média12nln(n)13nln(n)
    • : , mas até acessos de array (o mergesort não é baseado em troca, portanto, não podemos contar isso).8,66 n ln ( n )1.44nln(n)8.66nln(n)
    • Insertionsort: comparações e swaps em média.114n214n2

    Como você vê, isso não permite comparações de algoritmos como a análise exata do tempo de execução, mas os resultados são independentes dos detalhes da máquina.

Outras distribuições de entrada

Como observado acima, os casos médios são sempre com relação a alguma distribuição de entrada, portanto, pode-se considerar outros que não sejam permutações aleatórias. Por exemplo, foram feitas pesquisas para o Quicksort com elementos iguais e há um bom artigo sobre a função de classificação padrão em Java


8
Os resultados do tipo 2. podem ser transformados em resultados do tipo 1. inserindo constantes dependentes da máquina. Portanto, eu argumentaria 2. é uma abordagem superior.
Raphael

2
@Raphael +1. Suponho que você esteja assumindo que a dependência da máquina também depende da implementação, certo? Quero dizer, máquina rápida + má implementação provavelmente não são muito eficientes.
Janoma

2
@Janoma Assumi que o algoritmo analisado fosse fornecido de forma muito detalhada (conforme a análise é detalhada) e que a implementação fosse o mais possível pela letra. Mas sim, a implementação também levaria em consideração.
Raphael

3
Na verdade, a análise do tipo 2 é inferior na prática. Máquinas do mundo real são tão complicadas que os resultados do tipo 2 não podem ser traduzidos de maneira viável para o tipo 1. Compare isso ao tipo 1: a plotagem de tempos de execução experimentais leva 5 minutos de trabalho.
Jules

4
@Jules: "planejar o tempo de execução experimental" não é do tipo 1; não é um tipo de análise formal e não é transferível para outras máquinas. É por isso que fazemos análise formal, afinal.
Raphael

78

Existem vários pontos que podem ser feitos em relação a essa questão.

Quicksort geralmente é rápido

O(n2)

n1O(nlogn)

O Quicksort geralmente é mais rápido do que a maioria dos tipos

O(nlogn)O(n2)n

O(nlogn)O(nBlog(nB))B

A razão para essa eficiência de cache é que ela varre linearmente a entrada e particiona linearmente a entrada. Isso significa que podemos aproveitar ao máximo cada carregamento de cache que fazemos enquanto lemos todos os números que carregamos no cache antes de trocá-lo por outro. Em particular, o algoritmo é inconsciente do cache, o que fornece um bom desempenho para cada nível de cache, o que é outra vitória.

O(nBlogMB(nB))Mk

O Quicksort geralmente é mais rápido que o Mergesort

Essa comparação é completamente sobre fatores constantes (se considerarmos o caso típico). Em particular, a escolha é entre uma escolha subótima do pivô para o Quicksort versus a cópia de toda a entrada do Mergesort (ou a complexidade do algoritmo necessário para evitar essa cópia). Acontece que o primeiro é mais eficiente: não há teoria por trás disso, apenas é mais rápido.

nO(logn)O(n)

Por fim, observe que o Quicksort é um pouco sensível às informações que estão na ordem correta; nesse caso, pode pular alguns swaps. O Mergesort não possui essas otimizações, o que também torna o Quicksort um pouco mais rápido em comparação ao Mergesort.

Use o tipo que atenda às suas necessidades

Em conclusão: nenhum algoritmo de classificação é sempre ideal. Escolha o que melhor se adapte às suas necessidades. Se você precisar de um algoritmo que seja o mais rápido para a maioria dos casos, e não se importar que possa acabar sendo um pouco lento em casos raros, e não precise de uma classificação estável, use o Quicksort. Caso contrário, use o algoritmo que melhor se adapte às suas necessidades.


3
Sua última observação é especialmente valiosa. Atualmente, um colega meu analisa as implementações do Quicksort sob diferentes distribuições de entrada. Alguns deles quebram por muitas duplicatas, por exemplo.
Raphael

4
O(n2)

8
"Não há teoria por trás disso, apenas é mais rápido." Essa afirmação é altamente insatisfatória do ponto de vista científico. Imagine Newton dizendo: "As borboletas voam, as maçãs caem: não há teoria por trás disso, as maçãs simplesmente caem".
David Richerby

2
@ Alex ten Brink, o que você quer dizer com “Em particular, o algoritmo é inconsciente do cache ”?
Hibou57

4
@ David Richerby, “Essa afirmação é altamente insatisfatória do ponto de vista científico”: ele pode estar apenas testemunhando um fato sem fingir que devemos estar felizes com isso. Algumas famílias de algoritmos sofrem com a falta de formalização completa; funções de hash são um exemplo de caso.
Hibou57

45

Em um dos tutoriais de programação da minha universidade, pedimos aos alunos que comparassem o desempenho do quicksort, mergesort, tipo de inserção versus o list.sort interno do Python (chamado Timsort ). Os resultados experimentais me surpreenderam profundamente, uma vez que a list.sort embutida teve um desempenho muito melhor do que outros algoritmos de classificação, mesmo em instâncias que facilmente executavam quicksort, combinando uma falha. Portanto, é prematuro concluir que a implementação usual do quicksort é a melhor prática. Mas tenho certeza de que há uma implementação muito melhor do quicksort, ou alguma versão híbrida dele por aí.

Este é um bom artigo de blog de David R. MacIver que explica o Timsort como uma forma de fusão adaptativa.


17
@Raphael Para colocar de maneira sucinta, Timsort é uma classificação de mesclagem para os assintóticos, além de inserção para entradas curtas, além de algumas heurísticas para lidar eficientemente com dados que apresentam uma explosão ocasional já classificada (o que geralmente ocorre na prática). Dai: além do algoritmo, list.sortbeneficia-se de ser uma função interna otimizada por profissionais. Uma comparação mais justa teria todas as funções escritas no mesmo idioma e no mesmo nível de esforço.
Gilles

1
@Dai: Você poderia pelo menos descrever com que tipo de entradas (resp. Sua distribuição) sob quais circunstâncias (baixa RAM, uma implementação paralelizou, ...) você obteve seus resultados.
Raphael

7
Testamos na lista de números aleatórios e classificamos parcialmente, classificamos completamente e classificamos inversamente. Era um curso introdutório do 1º ano, portanto não era um estudo empírico profundo. Mas o fato de agora ser usado oficialmente para classificar matrizes no Java SE 7 e na plataforma Android significa algo.
Dai

3
Isso também foi discutido aqui: cstheory.stackexchange.com/a/927/74
Jukka Suomela

34

Eu acho que uma das principais razões pelas quais o QuickSort é tão rápido em comparação com outros algoritmos de classificação é porque é compatível com o cache. Quando o QS processa um segmento de uma matriz, ele acessa elementos no início e no final do segmento e se move em direção ao centro do segmento.

Portanto, quando você inicia, acessa o primeiro elemento da matriz e uma parte da memória (“local”) é carregada no cache. E quando você tenta acessar o segundo elemento, ele (provavelmente) já está no cache, por isso é muito rápido.

Outros algoritmos como o heapsort não funcionam assim, eles pulam muito no array, o que os torna mais lentos.


5
Essa é uma explicação discutível: o mergesort também é compatível com o cache.
Dmytro Korduban

2
Eu acho que esta resposta é basicamente certo, mas aqui está alguns detalhes youtube.com/watch?v=aMnn0Jq0J-E
rgrig

3
provavelmente a constante multiplicativa para a complexidade média do tempo de processo da classificação rápida também é melhor (independentemente do fator de cache que você mencionou).
Kaveh

1
O ponto que você mencionou não é tão importante em comparação com outras boas propriedades de classificação rápida.
MMS

1
@ Kaveh: "a constante multiplicativa para a complexidade média do tempo de processo da ordenação rápida também é melhor" Você tem algum dado sobre isso?
Giorgio

29

Outros já disseram que o tempo médio de execução assintótico do Quicksort é melhor (na constante) do que em outros algoritmos de classificação (em determinadas configurações).

O(nlogn)

Observe que existem muitas variantes do Quicksort (veja, por exemplo, a dissertação de Sedgewick). Eles têm desempenho diferente em diferentes distribuições de entrada (uniforme, quase classificado, quase inversamente classificado, muitas duplicatas, ...) e outros algoritmos podem ser melhores para alguns.

k10


20

O(nlgn)

ps: para ser preciso, ser melhor que outros algoritmos depende da tarefa. Para algumas tarefas, pode ser melhor usar outros algoritmos de classificação.

Veja também:


3
@Janoma, é uma questão de qual idioma e compilador você usa. Quase todas as linguagens funcionais (ML, Lisp, Haskell) podem fazer otimizações que impedem o crescimento da pilha, e compiladores mais inteligentes para linguagens imperativas podem fazer o mesmo (GCC, G ++ e acredito que o MSVC faça isso). A exceção notável é o Java, que nunca fará essa otimização, por isso faz sentido em Java reescrever sua recursão como iteração.
Rafe Kettler

4
@JD, você não pode usar a otimização de chamada de cauda com quicksort (pelo menos não completamente), porque se chama duas vezes. Você pode otimizar a segunda chamada, mas não a primeira.
svick

1
@Janoma, você realmente não precisa da implementação recursiva. Por exemplo, se você observar a implementação da função qsort em C, ela não usa chamadas recursivas e, portanto, a implementação se torna muito mais rápida.
Kaveh

1
O Heapsort também está instalado, por que o QS geralmente é mais rápido?
Kevin

6
23240

16

Θ(n2)Θ(nlogn)

O segundo motivo é que ele executa a in-placeclassificação e funciona muito bem com ambientes de memória virtual.

ATUALIZAÇÃO:: (Após os comentários de Janoma e Svick)

Para ilustrar isso melhor, deixe-me dar um exemplo usando a Classificação por Mesclagem (porque a classificação por Mesclagem é o próximo algoritmo de classificação amplamente adotado após a classificação rápida, eu acho) e dizer de onde vêm as constantes extras (até onde eu sei e por que eu penso) A classificação rápida é melhor):

Considere a seguinte seqência:

12,30,21,8,6,9,1,7. The merge sort algorithm works as follows:

(a) 12,30,21,8    6,9,1,7  //divide stage
(b) 12,30   21,8   6,9   1,7   //divide stage
(c) 12   30   21   8   6   9   1   7   //Final divide stage
(d) 12,30   8,21   6,9   1,7   //Merge Stage
(e) 8,12,21,30   .....     // Analyze this stage

Se você observar atentamente como está ocorrendo o último estágio, os 12 primeiros serão comparados com os 8 e 8 serão menores, portanto serão os primeiros. Agora, 12 é NOVAMENTE em comparação com 21 e 12 passa a seguir e assim por diante. Se você fizer a mesclagem final, ou seja, 4 elementos com 4 outros elementos, ocorrerá muitas comparações EXTRA como constantes que NÃO são incorridas na Classificação Rápida. Essa é a razão pela qual a classificação rápida é preferida.


1
Mas o que torna as constantes tão pequenas?
svick

1
@svick Por serem classificados in-place, ou seja, não é necessária memória extra.
0x0

Θ(nlgn)

15

Minha experiência trabalhando com dados do mundo real é que o quicksort é uma má escolha . O Quicksort funciona bem com dados aleatórios, mas os dados do mundo real geralmente não são aleatórios.

Em 2008, rastreei um bug de software suspenso até o uso do quicksort. Algum tempo depois, escrevi implicações simples de classificação por inserção, classificação rápida, classificação por heap e classificação por mesclagem e testei-as. Minha classificação de mesclagem superou todas as outras enquanto trabalhava em grandes conjuntos de dados.

Desde então, a classificação por mesclagem é o meu algoritmo de escolha. É elegante. É simples de implementar. É um tipo estável. Não degenera para comportamento quadrático como o quicksort. Eu mudo para a inserção de classificação para classificar pequenas matrizes.

Em muitas ocasiões, achei que uma determinada implementação funciona surpreendentemente bem para o quicksort apenas para descobrir que na verdade não é o quicksort. Às vezes, a implementação alterna entre o quicksort e outro algoritmo e, às vezes, não usa o quicksort. Como um exemplo, as funções qsort () do GLibc realmente usam a classificação de mesclagem. Somente se a alocação do espaço de trabalho falhar, ele voltará ao quicksort no local, que um comentário de código chama de "o algoritmo mais lento" .

Editar: linguagens de programação como Java, Python e Perl também usam classificação de mesclagem ou, mais precisamente, uma derivada, como Timsort ou classificação de mesclagem para conjuntos grandes e classificação de inserção para conjuntos pequenos. (O Java também usa o quicksort de pivô duplo, mais rápido que o quicksort simples.)


Eu tinha visto algo parecido com isso porque estávamos constantemente acrescentando / recorrendo para inserir em um lote de dados já classificados. Você pode solucionar isso em média usando uma classificação rápida aleatória (e se surpreender com uma classificação rara e aleatória terrivelmente lenta) ou pode tolerar uma classificação sempre mais lenta que nunca leva uma quantidade surpreendente de tempo para concluir. Às vezes, você também precisa de estabilidade de classificação. Java deixou de usar a classificação de mesclagem para uma variante de classificação rápida.
Rob

@Rob Isso não é exato. Java ainda usa uma variante do mergesort (Timsort) até hoje. Também usa uma variante do quicksort (quicksort de pivô duplo).
Erwan Legrand

14

1 - A classificação rápida está no local (não precisa de memória extra, exceto uma quantidade constante).

2 - A classificação rápida é mais fácil de implementar do que outros algoritmos de classificação eficientes.

3 - A classificação rápida possui fatores constantes menores no tempo de execução do que outros algoritmos de classificação eficientes.

Atualização: para classificação de mesclagem, você precisa fazer algumas "mesclagens", que precisam de matriz (s) extra (s) para armazenar os dados antes da mesclagem; mas de maneira rápida, você não. É por isso que a classificação rápida está no lugar. Também existem algumas comparações extras feitas para mesclar, que aumentam os fatores constantes na classificação de mesclagem.


3
Você viu implementações avançadas e iterativas no Quicksort? São muitas coisas, mas não são "fáceis".
Raphael

2
O número 2 não responde à minha pergunta, e os números 1 e 3 precisam de justificativa adequada, na minha opinião.
Janoma

@ Rafael: Eles são fáceis. É muito mais fácil implementar a classificação rápida no local usando uma matriz, em vez de ponteiros. E não precisa ser iterativo para estar no local.
MMS

As matrizes para mesclagem não são tão ruins. Depois de mover um item de uma pilha de origem para a pilha de destino, ele não precisa mais estar lá. Se você estiver usando matrizes dinâmicas, haverá sobrecarga de memória constante ao mesclar.
Oskar Skog

@ 1 O Mergesort também pode estar no local. @ 2 O que define eficiente? Eu gosto da classificação por mesclagem, porque é muito simples e eficiente na minha opinião. @ 3 Irrelevante ao classificar grandes quantidades de dados e requer que o algoritmo seja implementado com eficiência.
Oskar Skog

11

Em que condições um algoritmo de classificação específico é realmente o mais rápido?

Θ(log(n)2)Θ(nlog(n)2)

Θ(nk)Θ(nm)k=2#number_of_Possible_valuesm=#maximum_length_of_keys

3) A estrutura de dados subjacente consiste em elementos vinculados? Sim -> use sempre a classificação de mesclagem no local. Existem ambos, fáceis de implementar, de tamanho fixo ou de baixo para cima adaptáveis ​​(também conhecidos como naturais), que mesclam tipos de aridades diferentes para estruturas de dados vinculadas e, como nunca exigem a cópia de todos os dados em cada etapa e tampouco exigem recursões, também são mais rápido que qualquer outra classificação geral baseada em comparação, mais rápido que a classificação rápida.

Θ(n)

5) O tamanho dos dados subjacentes pode ser vinculado a um tamanho pequeno a médio? por exemplo, n <10.000 ... 100.000.000 (dependendo da arquitetura e estrutura de dados subjacentes)? Sim -> use classificação bitônica ou ímpares pares mesclados do Batcher. Ir para 1)

Θ(n)Θ(n2)Θ(nlog(n)2)o pior caso de tempo de execução é conhecido ou talvez tente classificar o pente. Não tenho certeza se a classificação por shell ou a classificação por pente funcionaria razoavelmente bem na prática.

Θ(log(n))Θ(n)Θ(n)Θ(log(n))Θ(n2)Θ(n)Θ(n)Θ(log(n))Θ(nlog(n))

Θ(nlog(n))

Dicas de implementação para quicksort:

Θ(n)Θ(log(n))Θ(nlogk(k1))

2) Existem variantes iterativas de baixo para cima e iterativas do quicksort, mas o AFAIK possui os mesmos limites de espaço e tempo assintóticos que os de cima para baixo, sendo os aspectos negativos adicionais difíceis de implementar (por exemplo, gerenciar explicitamente uma fila). Minha experiência é que, para quaisquer fins práticos, nunca vale a pena considerar.

Dicas de implementação para mergesort:

1) a fusão de baixo para cima é sempre mais rápida que a fusão de cima para baixo, pois não requer chamadas de recursão.

2) a fusão muito ingênua pode ser acelerada usando um buffer duplo e alterne o buffer em vez de copiar os dados de volta da matriz temporal após cada etapa.

3) Para muitos dados do mundo real, o mergesort adaptável é muito mais rápido do que um mergesort de tamanho fixo.

Θ(k)Θ(log(k))Θ(1)Θ(n)

Pelo que escrevi, fica claro que o quicksort geralmente não é o algoritmo mais rápido, exceto quando todas as seguintes condições se aplicam:

1) existem mais do que "poucos" valores possíveis

2) a estrutura de dados subjacente não está vinculada

3) não precisamos de uma ordem estável

4) os dados são grandes o suficiente para que o leve tempo de execução assintótico subótimo de um classificador bitônico ou ímpar de Batcher mescla pares

5) os dados não estão quase classificados e não consistem em partes já classificadas maiores

6) podemos acessar a sequência de dados simultaneamente de vários lugares

Θ(log(n))Θ(n)

ps: Alguém precisa me ajudar com a formatação do texto.


(5): A implementação de classificação da Apple verifica uma execução em ordem crescente ou decrescente, tanto no início quanto no final da matriz. Isso é muito rápido se não houver muitos desses elementos e poderá lidar com esses elementos de maneira muito eficaz se houver mais de n / ln n deles. Concatenar duas matrizes ordenadas e classificar o resultado, e você terá um merge
gnasher729

8

A maioria dos métodos de classificação precisa mover dados em etapas curtas (por exemplo, a classificação por mesclagem faz alterações localmente, depois mescla esses pequenos dados e depois os maiores ...). Em conseqüência, você precisa de muitos movimentos de dados se os dados estiverem longe de seu destino.

ab


5
Seu argumento sobre a classificação quicksort vs merge não se sustenta. O Quicksort começa com um movimento grande e depois faz movimentos cada vez menores (cerca da metade do tamanho a cada etapa). A classificação de mesclagem começa com um pequeno movimento, depois faz movimentos cada vez maiores (aproximadamente o dobro do tamanho a cada etapa). Isso não indica que um seja mais eficiente que o outro.
Gilles
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.