O std::sort
algoritmo (e seus primos std::partial_sort
e std::nth_element
) da Biblioteca Padrão C ++ é, na maioria das implementações, uma combinação complicada e híbrida de algoritmos de classificação mais elementares , como classificação de seleção, classificação de inserção, classificação rápida, classificação de mesclagem ou classificação de pilha.
Há muitas perguntas aqui e em sites irmãos, como https://codereview.stackexchange.com/, relacionadas a bugs, complexidade e outros aspectos das implementações desses algoritmos de classificação clássicos. A maioria das implementações oferecidas consiste em loops brutos, usa manipulação de índice e tipos de concreto, e geralmente não são triviais para analisar em termos de correção e eficiência.
Pergunta : como os algoritmos de classificação clássica acima mencionados podem ser implementados usando o C ++ moderno?
- sem loops brutos , mas combinando os blocos de construção algorítmicos da Biblioteca Padrão de
<algorithm>
- interface do iterador e uso de modelos em vez de manipulação de índice e tipos concretos
- Estilo C ++ 14 , incluindo a Biblioteca Padrão completa, bem como redutores de ruído sintáticos, como
auto
aliases de modelo, comparadores transparentes e lambdas polimórficas.
Notas :
- para obter referências adicionais sobre implementações de algoritmos de classificação, consulte Wikipedia , Rosetta Code ou http://www.sorting-algorithms.com/
- de acordo com as convenções de Sean Parent (slide 39), um loop bruto é muito
for
mais longo que a composição de duas funções com um operador. Então,f(g(x));
ouf(x); g(x);
ouf(x) + g(x);
não são loops de matérias, e nem são os loops emselection_sort
einsertion_sort
abaixo. - Sigo a terminologia de Scott Meyers para denotar o C ++ 1y atual como C ++ 14 e para denotar C ++ 98 e C ++ 03 como C ++ 98, portanto, não me chame por isso.
- Conforme sugerido nos comentários de @Mehrdad, forneço quatro implementações como um Exemplo ao vivo no final da resposta: C ++ 14, C ++ 11, C ++ 98 e Boost e C ++ 98.
- A resposta em si é apresentada apenas em termos de C ++ 14. Onde relevante, indico as diferenças sintáticas e de biblioteca em que as várias versões de idiomas diferem.
Respostas:
Blocos de construção algorítmicos
Começamos montando os blocos de construção algorítmicos da Biblioteca Padrão:
std::begin()
estd::end()
tambémstd::next()
estão disponíveis apenas a partir do C ++ 11 e além. Para o C ++ 98, é necessário escrevê-los ele mesmo. Existem substitutos do Boost.Range emboost::begin()
/boost::end()
e do Boost.Utility emboost::next()
.std::is_sorted
algoritmo está disponível apenas para C ++ 11 e além. Para o C ++ 98, isso pode ser implementado em termos destd::adjacent_find
e um objeto de função manuscrita. O Boost.Algorithm também forneceboost::algorithm::is_sorted
um substituto.std::is_heap
algoritmo está disponível apenas para C ++ 11 e além.Guloseimas sintáticas
O C ++ 14 fornece comparadores transparentes da forma
std::less<>
que agem polimorficamente em seus argumentos. Isso evita precisar fornecer o tipo de um iterador. Isso pode ser usado em combinação com os argumentos do modelo de função padrão do C ++ 11 para criar uma única sobrecarga para algoritmos de classificação que tomam<
como comparação e aqueles que têm um objeto de função de comparação definido pelo usuário.No C ++ 11, é possível definir um alias de modelo reutilizável para extrair o tipo de valor de um iterador, o que adiciona um pouco de confusão às assinaturas dos algoritmos de classificação:
No C ++ 98, é necessário escrever duas sobrecargas e usar a
typename xxx<yyy>::type
sintaxe detalhadaauto
parâmetros deduzidos como argumentos de modelo de função).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
.boost::bind
e_1
/_2
placeholder.std::find_if_not
, ao passo que C ++ 98 precisastd::find_if
com umstd::not1
em torno de um objecto função.Estilo C ++
Ainda não existe um estilo C ++ 14 geralmente aceitável. Para o bem ou para o mal, sigo de perto o rascunho de Scott Meyers, Effective Modern C ++ e o renovado GotW de Herb Sutter . Eu uso as seguintes recomendações de estilo:
()
e{}
ao criar objetos", de Scott Meyers, e escolhe consistentemente a inicialização entre chaves em{}
vez da boa e antiga inicialização entre parênteses()
(a fim de evitar todos os problemas de análise mais irritante no código genérico).typedef
economizar tempo e adicionar consistência.for (auto it = first; it != last; ++it)
padrão em alguns lugares, para permitir a verificação invariável de loop para subintervalos já classificados. No código de produção, o uso dewhile (first != last)
e um++first
lugar dentro do loop pode ser um pouco melhor.Classificação da seleção
A classificação por seleção não se adapta aos dados de forma alguma, portanto seu tempo de execução é sempre
O(N²)
. No entanto, a classificação de seleção tem a propriedade de minimizar o número de trocas . Em aplicativos em que o custo de troca de itens é alto, o tipo de seleção muito bem pode ser o algoritmo de escolha.Para implementá-lo usando a Biblioteca Padrão, use repetidamente
std::min_element
para encontrar o elemento mínimo restante eiter_swap
troque-o no lugar:Observe que
selection_sort
o intervalo já processado foi[first, it)
classificado como invariante. Os requisitos mínimos são iteradores avançados , em comparação comstd::sort
os iteradores de acesso aleatório.Detalhes omitidos :
if (std::distance(first, last) <= 1) return;
(ou para iteradores diretos / bidirecionais:)if (first == last || std::next(first) == last) return;
.[first, std::prev(last))
, porque o último elemento é garantido como o elemento restante mínimo e não requer troca.Classificação de inserção
Embora seja um dos algoritmos de classificação elementar com
O(N²)
pior momento, a classificação por inserção é o algoritmo de escolha quando os dados são quase classificados (por serem adaptáveis ) ou quando o tamanho do problema é pequeno (por ter uma sobrecarga baixa). Por esses motivos, e como também é estável , a classificação por inserção é frequentemente usada como o caso base recursivo (quando o tamanho do problema é pequeno) para algoritmos de classificação de divisão e conquista de sobrecarga, como classificação de mesclagem ou classificação rápida.Para implementar
insertion_sort
com a Biblioteca padrão, use repetidamentestd::upper_bound
para encontrar o local para onde o elemento atual precisa ir e usestd::rotate
para deslocar os elementos restantes para cima no intervalo de entrada:Observe que
insertion_sort
o intervalo já processado foi[first, it)
classificado como invariante. A classificação por inserção também funciona com iteradores avançados.Detalhes omitidos :
if (std::distance(first, last) <= 1) return;
(ou para iteradores avançados / bidirecionais:)if (first == last || std::next(first) == last) return;
e um loop no intervalo[std::next(first), last)
, porque o primeiro elemento é garantido para estar no lugar e não requer uma rotação.std::find_if_not
algoritmo da Biblioteca Padrão .Quatro exemplos dinâmicos ( C ++ 14 , C ++ 11 , C ++ 98 e Boost , C ++ 98 ) para o fragmento abaixo:
O(N²)
comparações, mas isso melhora asO(N)
comparações para entradas quase ordenadas. A pesquisa binária sempre usaO(N log N)
comparações.Ordenação rápida
Quando implementada com cuidado, a classificação rápida é robusta e tem
O(N log N)
complexidade esperada, mas comO(N²)
complexidade de pior caso que pode ser acionada com dados de entrada escolhidos adversamente. Quando uma classificação estável não é necessária, a classificação rápida é uma excelente classificação de uso geral.Mesmo para as versões mais simples, a classificação rápida é um pouco mais complicada de implementar usando a Biblioteca Padrão do que os outros algoritmos de classificação clássicos. A abordagem abaixo usa alguns utilitários do iterador para localizar o elemento do meio do intervalo de entrada
[first, last)
como o pivô e, em seguida, use duas chamadas parastd::partition
(que sãoO(N)
) para particionar de três maneiras o intervalo de entrada em segmentos de elementos menores que, iguais a, e maior que o pivô selecionado, respectivamente. Finalmente, os dois segmentos externos com elementos menores e maiores que o pivô são classificados recursivamente:No entanto, a ordenação rápida é um pouco complicada para ser correta e eficiente, pois cada uma das etapas acima deve ser cuidadosamente verificada e otimizada para o código do nível de produção. Em particular, por
O(N log N)
complexidade, o pivô deve resultar em uma partição balanceada dos dados de entrada, que não podem ser garantidos em geral para umO(1)
pivô, mas que podem ser garantidos se alguém definir o pivô como aO(N)
mediana do intervalo de entrada.Detalhes omitidos :
O(N^2)
complexidade para a entrada de " tubo de órgão "1, 2, 3, ..., N/2, ... 3, 2, 1
(porque o meio é sempre maior que todos os outros elementos).O(N^2)
.std::partition
não é oO(N)
algoritmomais eficientepara alcançar esse resultado.O(N log N)
complexidade garantida pode ser alcançada através da seleção de mediana de pivô usandostd::nth_element(first, middle, last)
, seguida de chamadas recursivas paraquick_sort(first, middle, cmp)
equick_sort(middle, last, cmp)
.O(N)
complexidade destd::nth_element
pode ser mais caro do que o daO(1)
complexidade de um pivô com mediana de 3 seguido de umaO(N)
chamada parastd::partition
(que é uma passagem de encaminhamento único amigável para cache) os dados).Mesclar classificação
Se o uso de
O(N)
espaço extra não for motivo de preocupação, a classificação por mesclagem é uma excelente opção: é o único algoritmo de classificação estávelO(N log N)
.É simples de implementar usando algoritmos Padrão: use alguns utilitários de iterador para localizar o meio do intervalo de entrada
[first, last)
e combinar dois segmentos classificados recursivamente com umstd::inplace_merge
:A classificação de mesclagem requer iteradores bidirecionais, sendo o gargalo
std::inplace_merge
. Observe que, ao classificar listas vinculadas, a classificação por mesclagem requer apenasO(log N)
espaço extra (para recursão). O último algoritmo é implementadostd::list<T>::sort
na Biblioteca Padrão.Classificação da pilha
A classificação de heap é simples de implementar, executa uma classificação
O(N log N)
no local, mas não é estável.O primeiro loop,
O(N)
fase "heapify", coloca a matriz em ordem de heap. O segundo loop, aO(N log N
fase) "ordenação", extrai repetidamente o máximo e restaura a ordem do heap. A Biblioteca Padrão torna isso extremamente simples:Caso considere "trapaça" usar
std::make_heap
estd::sort_heap
, você pode ir um nível mais fundo e escrever essas funções em termos destd::push_heap
estd::pop_heap
, respectivamente:A Biblioteca padrão especifica tanto
push_heap
epop_heap
como complexidadeO(log N)
. Observe, no entanto, que o loop externo acima do intervalo[first, last)
resulta emO(N log N)
complexidade paramake_heap
, enquanto questd::make_heap
possui apenasO(N)
complexidade. Pois aO(N log N)
complexidade geralheap_sort
disso não importa.Detalhes omitidos :
O(N)
implementação demake_heap
Teste
Aqui estão quatro exemplos dinâmicos ( C ++ 14 , C ++ 11 , C ++ 98 e Boost , C ++ 98 ) testando todos os cinco algoritmos em uma variedade de entradas (não destinadas a ser exaustivas ou rigorosas). Observe as enormes diferenças no LOC: C ++ 11 / C ++ 14 precisa de cerca de 130 LOC, C ++ 98 e Boost 190 (+ 50%) e C ++ 98 mais de 270 (+ 100%).
fonte
auto
(e muitas pessoas discordam de mim), eu gostei de ver os algoritmos da biblioteca padrão sendo bem utilizados. Eu estava querendo ver alguns exemplos desse tipo de código depois de ver a palestra de Sean Parent. Além disso, eu não tinha idéia de questd::iter_swap
existia, embora me pareça estranho que esteja<algorithm>
.if (first == last || std::next(first) == last)
. Eu devo atualizar isso mais tarde. A implementação do material nas seções "detalhes omitidos" está além do escopo da pergunta, IMO, porque eles contêm links para perguntas e respostas inteiras. Implementar rotinas de classificação com palavras reais é difícil!nth_element
na minha opinião.nth_element
já faz metade de uma seleção rápida (incluindo a etapa de particionamento e uma recursão na metade que inclui o n-ésimo elemento em que você está interessado).Outro pequeno e bastante elegante originalmente encontrado na revisão de código . Eu pensei que valia a pena compartilhar.
Contando classificação
Embora seja bastante especializada, a ordenação por contagem é um algoritmo simples de ordenação de números inteiros e geralmente pode ser muito rápido, desde que os valores dos inteiros a serem ordenados não estejam muito distantes. Provavelmente é ideal se você precisar classificar uma coleção de um milhão de números inteiros que se sabe entre 0 e 100, por exemplo.
Para implementar uma classificação de contagem muito simples que funcione com números inteiros assinados e não assinados, é necessário encontrar os menores e maiores elementos da coleção a serem classificados; sua diferença indicará o tamanho da matriz de contagens a ser alocada. Em seguida, uma segunda passagem pela coleção é feita para contar o número de ocorrências de cada elemento. Finalmente, escrevemos novamente o número necessário de cada número inteiro para a coleção original.
Embora seja útil apenas quando se sabe que o intervalo dos números inteiros a serem classificados é pequeno (geralmente não é maior que o tamanho da coleção a ser classificada), tornar a classificação da contagem mais genérica tornaria mais lenta para seus melhores casos. Se não for conhecido que o intervalo é pequeno, outro algoritmo, como radix sort , ska_sort ou spreadsort, pode ser usado.
Detalhes omitidos :
Poderíamos ter ultrapassado os limites do intervalo de valores aceitos pelo algoritmo como parâmetros para se livrar totalmente da primeira
std::minmax_element
passagem pela coleção. Isso tornará o algoritmo ainda mais rápido quando um limite de alcance pequeno e útil for conhecido por outros meios. (Não precisa ser exato; passar uma constante de 0 a 100 ainda é muito melhor do que uma passagem extra de um milhão de elementos para descobrir que os limites verdadeiros são de 1 a 95. Até 0 a 1000 valeriam a pena; elementos extras são escritos uma vez com zero e lidos uma vez).Crescer
counts
rapidamente é outra maneira de evitar um primeiro passe separado. Dobrar ocounts
tamanho cada vez que precisa crescer fornece tempo O (1) amortizado por elemento classificado (consulte a análise de custo de inserção da tabela de hash para a prova de que o crescimento exponencial é a chave). Crescer no final para um novomax
é fácil comstd::vector::resize
a adição de novos elementos zerados. Mudar rapidamentemin
e inserir novos elementos zerados na frente pode ser feitostd::copy_backward
após o crescimento do vetor. Em seguida,std::fill
zere os novos elementos.O
counts
loop de incremento é um histograma. Se é provável que os dados sejam altamente repetitivos e o número de posições seja pequeno, vale a pena desenrolar sobre várias matrizes para reduzir o gargalo da dependência de dados de serialização de armazenar / recarregar na mesma bandeja. Isso significa mais contagens para zero no início e mais repetições no final, mas deve valer a pena na maioria das CPUs para o nosso exemplo de milhões de 0 a 100 números, especialmente se a entrada já puder ser (parcialmente) classificada e tem longas execuções do mesmo número.No algoritmo acima, usamos uma
min == max
verificação para retornar mais cedo quando cada elemento tiver o mesmo valor (nesse caso, a coleção é classificada). Na verdade, é possível verificar totalmente se a coleção já está classificada e encontrar os valores extremos de uma coleção sem perder tempo adicional (se a primeira passagem ainda estiver com gargalo de memória com o trabalho extra de atualizar min e max). No entanto, esse algoritmo não existe na biblioteca padrão e escrever um seria mais tedioso do que escrever o resto da classificação em si. É deixado como um exercício para o leitor.Como o algoritmo funciona apenas com valores inteiros, asserções estáticas podem ser usadas para impedir que os usuários cometam erros de tipo óbvios. Em alguns contextos, uma falha de substituição com
std::enable_if_t
pode ser preferida.Enquanto o C ++ moderno é legal, o C ++ futuro pode ser ainda mais interessante: ligações estruturadas e algumas partes do Ranges TS tornariam o algoritmo ainda mais limpo.
fonte
std::minmax_element
que coleta apenas informações). A propriedade usada é o fato de que números inteiros podem ser usados como índices ou compensações e que eles são incrementáveis enquanto preserva a última propriedade.counts | ranges::view::filter([](auto c) { return c != 0; })
para que você não precise repetidamente testar contagens diferentes de zero dentro dofill_n
.small
umrather
eappart
- posso mantê-los até a edição sobre reggae_sort?)counts[]
em tempo real seria uma vitória vs. atravessar a entradaminmax_element
antes da histograma. Especialmente para o caso de uso em que isso é ideal, com entradas muito grandes com muitas repetições em um intervalo pequeno, porque você aumentará rapidamentecounts
para o tamanho máximo, com poucas previsões erradas de ramificação ou duplicação de tamanho. (É claro que, sabendo um pequeno-suficiente encadernado na gama permitirá que você evitar umaminmax_element
varredura e a evitar limites de verificação dentro do loop histograma.)