El std::sort
algoritmo (y sus primos std::partial_sort
y std::nth_element
) de la Biblioteca estándar de C ++ es, en la mayoría de las implementaciones, una amalgamación complicada e híbrida de algoritmos de clasificación más elementales , como clasificación de selección, clasificación de inserción, clasificación rápida, clasificación de fusión o clasificación de montón.
Hay muchas preguntas aquí y en sitios hermanos como https://codereview.stackexchange.com/ relacionadas con errores, complejidad y otros aspectos de las implementaciones de estos algoritmos de clasificación clásicos. La mayoría de las implementaciones ofrecidas consisten en bucles sin procesar, usan manipulación de índices y tipos concretos, y generalmente no son triviales para analizar en términos de corrección y eficiencia.
Pregunta : ¿cómo se pueden implementar los algoritmos de clasificación clásicos mencionados anteriormente utilizando C ++ moderno?
- sin bucles sin procesar , pero combinando los bloques de construcción algorítmicos de la Biblioteca Estándar
<algorithm>
- interfaz de iterador y uso de plantillas en lugar de manipulación de índices y tipos concretos
- Estilo C ++ 14 , incluida la biblioteca estándar completa, así como reductores de ruido sintáctico, como
auto
alias de plantillas, comparadores transparentes y lambdas polimórficas.
Notas :
- Para obtener más referencias sobre implementaciones de algoritmos de clasificación, consulte Wikipedia , Rosetta Code o http://www.sorting-algorithms.com/
- De acuerdo con las convenciones de Sean Parent (diapositiva 39), un bucle sin procesar es un
for
bucle más largo que la composición de dos funciones con un operador. Así quef(g(x));
of(x); g(x);
of(x) + g(x);
no son bucles primas, como tampoco lo son los bucles enselection_sort
yinsertion_sort
por debajo. - Sigo la terminología de Scott Meyers para denotar el C ++ 1y actual como C ++ 14, y para denotar C ++ 98 y C ++ 03, ambos como C ++ 98, así que no me llame por eso.
- Como se sugiere en los comentarios de @Mehrdad, proporciono cuatro implementaciones como un ejemplo en vivo al final de la respuesta: C ++ 14, C ++ 11, C ++ 98 y Boost y C ++ 98.
- La respuesta en sí se presenta solo en términos de C ++ 14. Cuando sea relevante, denoto las diferencias sintácticas y de biblioteca donde difieren las diferentes versiones de idioma.
Respuestas:
Bloques de construcción algorítmicos
Comenzamos ensamblando los bloques de construcción algorítmicos de la Biblioteca estándar:
std::begin()
/std::end()
así como constd::next()
, solo están disponibles a partir de C ++ 11 y posteriores. Para C ++ 98, uno necesita escribirlos él mismo. Hay sustitutos de Boost.Range enboost::begin()
/boost::end()
, y de Boost.Utility enboost::next()
.std::is_sorted
algoritmo solo está disponible para C ++ 11 y más allá. Para C ++ 98, esto se puede implementar en términos destd::adjacent_find
y un objeto de función escrito a mano. Boost.Algorithm también proporciona aboost::algorithm::is_sorted
como sustituto.std::is_heap
algoritmo solo está disponible para C ++ 11 y más allá.Golosinas sintácticas
C ++ 14 proporciona comparadores transparentes de la forma
std::less<>
que actúan polimórficamente sobre sus argumentos. Esto evita tener que proporcionar un tipo de iterador. Esto se puede usar en combinación con los argumentos de la plantilla de función predeterminada de C ++ 11 para crear una sola sobrecarga para los algoritmos de clasificación que toman<
como comparación y los que tienen un objeto de función de comparación definido por el usuario.En C ++ 11, se puede definir un alias de plantilla reutilizable para extraer el tipo de valor de un iterador que agrega un desorden menor a las firmas de los algoritmos de clasificación:
En C ++ 98, uno necesita escribir dos sobrecargas y usar la
typename xxx<yyy>::type
sintaxis detalladaauto
parámetros que se deducen como argumentos de plantilla de función).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
.boost::bind
y_1
/_2
sintaxis de marcador de posición.std::find_if_not
, mientras que C ++ 98 necesidadesstd::find_if
con unastd::not1
alrededor de un objeto función.Estilo C ++
Todavía no existe un estilo C ++ 14 generalmente aceptable. Para bien o para mal, sigo de cerca el borrador de Scott Meyers, Effective Modern C ++, y el renovado GotW de Herb Sutter . Yo uso las siguientes recomendaciones de estilo:
()
y{}
al crear objetos" de Scott Meyers y elegir consistentemente la inicialización{}
entre paréntesis()
en lugar de la buena inicialización entre paréntesis (para evitar todos los problemas de análisis más molestos en código genérico).typedef
ahorrar tiempo y agrega consistencia.for (auto it = first; it != last; ++it)
patrón en algunos lugares, para permitir la comprobación invariante de bucles para subrangos ya ordenados. En el código de producción, el uso dewhile (first != last)
y en++first
algún lugar dentro del bucle podría ser un poco mejor.Tipo de selección
El orden de selección no se adapta a los datos de ninguna manera, por lo que su tiempo de ejecución es siempre
O(N²)
. Sin embargo, la selección por selección tiene la propiedad de minimizar el número de intercambios . En aplicaciones donde el costo de intercambiar elementos es alto, la selección por selección muy bien puede ser el algoritmo de elección.Para implementarlo usando la Biblioteca estándar, use repetidamente
std::min_element
para encontrar el elemento mínimo restante yiter_swap
cambiarlo a su lugar:Tenga en cuenta que
selection_sort
tiene el rango ya procesado[first, it)
ordenado como su bucle invariante. Los requisitos mínimos son iteradores directos , en comparación constd::sort
los iteradores de acceso aleatorio.Detalles omitidos :
if (std::distance(first, last) <= 1) return;
(o para iteradores directos / bidireccionales:)if (first == last || std::next(first) == last) return;
.[first, std::prev(last))
, ya que el último elemento está garantizado como el elemento restante mínimo y no requiere un intercambio.Tipo de inserción
Aunque es uno de los algoritmos de clasificación elemental con el
O(N²)
peor de los casos, la clasificación por inserción es el algoritmo de elección cuando los datos están casi ordenados (porque son adaptativos ) o cuando el tamaño del problema es pequeño (porque tiene una sobrecarga baja). Por estas razones, y debido a que también es estable , la ordenación por inserción a menudo se usa como el caso base recursivo (cuando el tamaño del problema es pequeño) para algoritmos de clasificación de divide y vencerás más elevados, como la ordenación por fusión o la ordenación rápida.Para implementar
insertion_sort
con la Biblioteca estándar, use repetidamentestd::upper_bound
para encontrar la ubicación donde debe ir el elemento actual y usestd::rotate
para desplazar los elementos restantes hacia arriba en el rango de entrada:Tenga en cuenta que
insertion_sort
tiene el rango ya procesado[first, it)
ordenado como su bucle invariante. La ordenación por inserción también funciona con iteradores hacia adelante.Detalles omitidos :
if (std::distance(first, last) <= 1) return;
(o para iteradores hacia adelante / bidireccionales:)if (first == last || std::next(first) == last) return;
y un bucle durante el intervalo[std::next(first), last)
, porque se garantiza que el primer elemento esté en su lugar y no requiere una rotación.std::find_if_not
algoritmo de la Biblioteca estándar .Cuatro ejemplos en vivo ( C ++ 14 , C ++ 11 , C ++ 98 y Boost , C ++ 98 ) para el siguiente fragmento:
O(N²)
comparaciones, pero esto mejora lasO(N)
comparaciones para entradas casi ordenadas. La búsqueda binaria siempre usaO(N log N)
comparaciones.Ordenación rápida
Cuando se implementa con cuidado, la ordenación rápida es robusta y tiene la
O(N log N)
complejidad esperada, pero con laO(N²)
peor de las situaciones que se puede desencadenar con datos de entrada elegidos de forma contradictoria. Cuando no se necesita una ordenación estable, la ordenación rápida es una excelente ordenación de propósito general.Incluso para las versiones más simples, la ordenación rápida es bastante más complicada de implementar utilizando la Biblioteca estándar que los otros algoritmos de ordenación clásicos. El siguiente enfoque utiliza algunas utilidades de iterador para ubicar el elemento central del rango de entrada
[first, last)
como pivote, luego usa dos llamadas astd::partition
(que sonO(N)
) para dividir en tres vías el rango de entrada en segmentos de elementos que son más pequeños, iguales a, y más grande que el pivote seleccionado, respectivamente. Finalmente, los dos segmentos externos con elementos más pequeños y más grandes que el pivote se ordenan recursivamente:Sin embargo, la ordenación rápida es bastante difícil de obtener de manera correcta y eficiente, ya que cada uno de los pasos anteriores debe verificarse cuidadosamente y optimizarse para el código de nivel de producción. En particular, por
O(N log N)
complejidad, el pivote tiene que dar como resultado una partición equilibrada de los datos de entrada, que no se puede garantizar en general para unO(1)
pivote, pero que se puede garantizar si se establece el pivote como laO(N)
mediana del rango de entrada.Detalles omitidos :
O(N^2)
complejidad para la entrada de " órgano organ "1, 2, 3, ..., N/2, ... 3, 2, 1
(porque el medio siempre es más grande que todos los demás elementos).O(N^2)
.std::partition
no es elO(N)
algoritmomás eficientepara lograr este resultado.O(N log N)
se puede lograr una complejidad garantizada mediante la selección de pivote medio usandostd::nth_element(first, middle, last)
, seguido de llamadas recursivas aquick_sort(first, middle, cmp)
yquick_sort(middle, last, cmp)
.O(N)
complejidad destd::nth_element
puede ser más costoso que el de laO(1)
complejidad de un pivote de mediana de 3 seguido de unaO(N)
llamada astd::partition
(que es un paso hacia adelante único compatible con caché los datos).Ordenar fusión
Si el uso de
O(N)
espacio adicional no es motivo de preocupación, la combinación de clasificación es una excelente opción: es el único algoritmo de clasificación estableO(N log N)
.Es simple de implementar usando algoritmos estándar: use algunas utilidades de iterador para ubicar el medio del rango de entrada
[first, last)
y combine dos segmentos recursivamente ordenados con unstd::inplace_merge
:La ordenación por fusión requiere iteradores bidireccionales, siendo el cuello de botella el
std::inplace_merge
. Tenga en cuenta que al ordenar las listas vinculadas, la ordenación por fusión solo requiereO(log N)
espacio adicional (para la recursividad). El último algoritmo se implementastd::list<T>::sort
en la Biblioteca estándar.Tipo de montón
La ordenación del montón es simple de implementar, realiza una
O(N log N)
ordenación in situ, pero no es estable.El primer ciclo, la
O(N)
fase "heapify", pone la matriz en orden de montón. El segundo ciclo, laO(N log N
fase de "clasificación", extrae repetidamente el máximo y restaura el orden de almacenamiento dinámico. La Biblioteca estándar hace que esto sea extremadamente sencillo:En caso de que consideres que es "trampa" usar
std::make_heap
ystd::sort_heap
, puedes ir un nivel más profundo y escribir esas funciones tú mismo en términos destd::push_heap
ystd::pop_heap
, respectivamente:La biblioteca estándar especifica ambos
push_heap
ypop_heap
como complejidadO(log N)
. Sin embargo , tenga en cuenta que el bucle externo sobre el rango[first, last)
resulta enO(N log N)
complejidad paramake_heap
, mientras questd::make_heap
solo tieneO(N)
complejidad. Por laO(N log N)
complejidad general deheap_sort
eso no importa.Detalles omitidos :
O(N)
implementación demake_heap
Pruebas
Aquí hay cuatro ejemplos en vivo ( C ++ 14 , C ++ 11 , C ++ 98 y Boost , C ++ 98 ) que prueban los cinco algoritmos en una variedad de entradas (no pretende ser exhaustivo o riguroso). Solo tenga en cuenta las grandes diferencias en el LOC: C ++ 11 / C ++ 14 necesita alrededor de 130 LOC, C ++ 98 y Boost 190 (+ 50%) y C ++ 98 más de 270 (+ 100%).
fuente
auto
(y muchas personas no están de acuerdo conmigo), disfruté al ver que los algoritmos estándar de la biblioteca se usan bien. Tenía ganas de ver algunos ejemplos de este tipo de código después de ver la charla de Sean Parent. Además, no tenía idea de questd::iter_swap
existía, aunque me parece extraño que esté dentro<algorithm>
.if (first == last || std::next(first) == last)
. Podría actualizar eso más tarde. La implementación de las cosas en las secciones de "detalles omitidos" está más allá del alcance de la pregunta, IMO, porque contienen enlaces a preguntas y respuestas completas. ¡Implementar rutinas de clasificación de palabras reales es difícil!nth_element
en mi opinión , has hecho trampa con tu clasificación rápida .nth_element
ya realiza la mitad de una ordenación rápida (incluido el paso de partición y una recursión en la mitad que incluye el enésimo elemento que le interesa).Otro pequeño y bastante elegante originalmente encontrado en la revisión de código . Pensé que valía la pena compartirlo.
Tipo de conteo
Si bien es bastante especializado, el ordenamiento de conteo es un algoritmo de ordenación de enteros simple y, a menudo, puede ser realmente rápido siempre que los valores de los enteros para ordenar no estén demasiado separados. Probablemente sea ideal si alguna vez necesita ordenar una colección de un millón de enteros que se sabe que están entre 0 y 100, por ejemplo.
Para implementar un ordenamiento de conteo muy simple que funcione con enteros con y sin signo, uno necesita encontrar los elementos más pequeños y más grandes en la colección para ordenar; su diferencia indicará el tamaño de la matriz de recuentos para asignar. Luego, se realiza una segunda pasada a través de la colección para contar el número de ocurrencias de cada elemento. Finalmente, reescribimos el número requerido de cada número entero a la colección original.
Si bien solo es útil cuando se sabe que el rango de los enteros para ordenar es pequeño (generalmente no es más grande que el tamaño de la colección para ordenar), hacer que el ordenamiento sea más genérico lo haría más lento para sus mejores casos. Si no se sabe que el rango es pequeño, se puede utilizar otro algoritmo como una clasificación de radix , ska_sort o spreadsort .
Detalles omitidos :
Podríamos haber pasado los límites del rango de valores aceptados por el algoritmo como parámetros para eliminar por completo el primer
std::minmax_element
paso a través de la colección. Esto hará que el algoritmo sea aún más rápido cuando se conozca un límite de rango útilmente pequeño por otros medios. (No tiene que ser exacto; pasar un constante de 0 a 100 sigue siendo mucho mejor que un pase adicional sobre un millón de elementos para descubrir que los límites verdaderos son de 1 a 95. Incluso 0 a 1000 valdría la pena; el los elementos adicionales se escriben una vez con cero y se leen una vez).Crecer
counts
sobre la marcha es otra forma de evitar un primer pase por separado. Duplicar elcounts
tamaño cada vez que tiene que crecer da tiempo O (1) amortizado por elemento ordenado (consulte el análisis de costos de inserción de la tabla hash para la prueba de que el crecimiento exponencial es la clave). Crecer al final para un nuevomax
es fácil constd::vector::resize
agregar nuevos elementos a cero. Se puede cambiarmin
sobre la marcha e insertar nuevos elementos a cero en el frentestd::copy_backward
después de hacer crecer el vector. Luegostd::fill
a cero los nuevos elementos.El
counts
bucle de incremento es un histograma. Si es probable que los datos sean muy repetitivos, y el número de contenedores es pequeño, puede valer la pena desenrollarlo en múltiples arreglos para reducir el cuello de botella de la dependencia de datos serializados de almacenar / recargar en el mismo contenedor. Esto significa más recuentos hasta cero al comienzo y más para repetir al final, pero debería valer la pena en la mayoría de las CPU para nuestro ejemplo de millones de números de 0 a 100, especialmente si la entrada ya podría estar (parcialmente) ordenada y tener carreras largas del mismo número.En el algoritmo anterior, usamos una
min == max
verificación para regresar temprano cuando cada elemento tiene el mismo valor (en cuyo caso la colección está ordenada). En realidad, es posible verificar completamente si la colección ya está ordenada mientras se encuentran los valores extremos de una colección sin perder tiempo adicional (si el primer paso todavía tiene un cuello de botella en la memoria con el trabajo adicional de actualizar min y max). Sin embargo, dicho algoritmo no existe en la biblioteca estándar y escribir uno sería más tedioso que escribir el resto del conteo en sí. Se deja como ejercicio para el lector.Dado que el algoritmo solo funciona con valores enteros, se podrían usar aserciones estáticas para evitar que los usuarios cometan errores de tipo obvio. En algunos contextos, se
std::enable_if_t
podría preferir un fallo de sustitución con .Si bien el C ++ moderno es genial, el C ++ futuro podría ser aún más genial: los enlaces estructurados y algunas partes del Ranges TS harían que el algoritmo sea aún más limpio.
fuente
std::minmax_element
que solo recopila información). La propiedad utilizada es el hecho de que los enteros pueden usarse como índices o compensaciones, y que son incrementables mientras se preserva la última propiedad.counts | ranges::view::filter([](auto c) { return c != 0; })
para que no tenga que probar repetidamente los conteos distintos de cero dentro delfill_n
.small
unrather
eappart
- puedo mantenerlos hasta la edición relativa a reggae_sort?)counts[]
sobre la marcha sería una victoria frente a atravesar la entradaminmax_element
antes del histograma. Especialmente para el caso de uso donde esto es ideal, de entrada muy grande con muchas repeticiones en un rango pequeño, porque crecerá rápidamentecounts
a su tamaño completo, con pocas predicciones erróneas de rama o duplicación de tamaño. (Por supuesto, conocer un límite lo suficientemente pequeño en el rango le permitirá evitar unminmax_element
escaneo y evitar la verificación de límites dentro del bucle de histograma.)