c++ - metodos - ordenamiento por intercambio
¿Cómo implementar algoritmos de clasificación clásicos en C++ moderno? (2)
Bloques de construcción algorítmicos
Comenzamos por ensamblar los bloques de construcción algorítmicos de la biblioteca estándar:
#include <algorithm> // min_element, iter_swap,
// upper_bound, rotate,
// partition,
// inplace_merge,
// make_heap, sort_heap, push_heap, pop_heap,
// is_heap, is_sorted
#include <cassert> // assert
#include <functional> // less
#include <iterator> // distance, begin, end, next
- las herramientas de iterador como
std::begin()
/std::end()
no miembro, así como constd::next()
solo están disponibles a partir de C ++ 11 y posteriores. Para C ++ 98, uno necesita escribir estos mismos. Hay sustitutos de Boost.Range enboost::begin()
/boost::end()
, y de Boost.Utility enboost::next()
. - el algoritmo
std::is_sorted
solo está disponible para C ++ 11 y posteriores. 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 unboost::algorithm::is_sorted
como sustituto. - el algoritmo
std::is_heap
solo está disponible para C ++ 11 y posteriores.
Golosinas sintácticas
C ++ 14 proporciona comparadores transparentes de la forma std::less<>
que actúan de forma polimórfica en 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 sobrecarga única para los algoritmos de clasificación que toman <
como comparación y aquellos que tienen un objeto de función de comparación definido por el usuario.
template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
En C ++ 11, uno 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:
template<class It>
using value_type_t = typename std::iterator_traits<It>::value_type;
template<class It, class Compare = std::less<value_type_t<It>>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
En C ++ 98, uno necesita escribir dos sobrecargas y utilizar la sintaxis de typename xxx<yyy>::type
verboso typename xxx<yyy>::type
template<class It, class Compare>
void xxx_sort(It first, It last, Compare cmp); // general implementation
template<class It>
void xxx_sort(It first, It last)
{
xxx_sort(first, last, std::less<typename std::iterator_traits<It>::value_type>());
}
- Otra amabilidad sintáctica es que C ++ 14 facilita el ajuste de comparadores definidos por el usuario a través de lambdas polimórficas (con parámetros
auto
que se deducen como argumentos de la plantilla de función). - C ++ 11 solo tiene lambdas monomórficas, que requieren el uso del alias de la plantilla anterior
value_type_t
. - En C ++ 98, uno necesita escribir un objeto de función independiente o recurrir al tipo
std::not1
de sintaxisstd::bind1st
/std::bind2nd
/std::not1
. - Boost.Bind mejora esto con
boost::bind
y la sintaxis de marcador de posición_1
/_2
. - C ++ 11 y posteriores también tienen
std::find_if_not
, mientras que C ++ 98 necesitastd::find_if
con unstd::not1
alrededor de un objeto de función.
Estilo C ++
Todavía no hay 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:
- La recomendación de Herb Sutter "Almost Always Auto" y de Scott Meyers "Preferir auto a declaraciones de tipo específico" , por la cual la brevedad es insuperable, aunque a veces se disputed su claridad.
- "Distinguish
()
y{}
Scott Meyers al crear objetos" y elige consistentemente la inicialización con refuerzo{}
lugar de la antigua inicialización entre paréntesis()
(para evitar todos los problemas más desconcertantes en el código genérico). - Scott Meyers "Prefiere declaraciones de alias a typedefs" . Para las plantillas, esto es una necesidad de todos modos, y usarlo en todas partes en lugar de
typedef
ahorra tiempo y agrega consistencia. - Utilizo un patrón
for (auto it = first; it != last; ++it)
en algunos lugares, para permitir la verificación invariante del bucle para sub-rangos ya ordenados. En el código de producción, el uso dewhile (first != last)
y a++first
algún lugar dentro del bucle podría ser ligeramente mejor.
Selección 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 swaps . En aplicaciones donde el costo de intercambiar elementos es alto, la selección puede muy bien 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, e iter_swap
para intercambiarlo en su lugar:
template<class FwdIt, class Compare = std::less<>>
void selection_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
for (auto it = first; it != last; ++it) {
auto const selection = std::min_element(it, last, cmp);
std::iter_swap(selection, it);
assert(std::is_sorted(first, std::next(it), cmp));
}
}
Tenga en cuenta que selection_sort
tiene el rango ya procesado [first, it)
ordenado como su bucle invariante. Los requisitos mínimos son los iteradores hacia adelante , en comparación con los iteradores de acceso aleatorio de std::sort
.
Detalles omitidos :
- la clasificación de selección se puede optimizar con una prueba temprana
if (std::distance(first, last) <= 1) return;
(o para los iteradores directos / bidireccionales:if (first == last || std::next(first) == last) return;
). - para los iteradores bidireccionales , la prueba anterior se puede combinar con un bucle en el intervalo
[first, std::prev(last))
, porque se garantiza que el último elemento es 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 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 adaptables ) o cuando el tamaño del problema es pequeño (porque tiene gastos indirectos bajos). Por estas razones, y debido a que también es estable , la clasificación por inserción se utiliza a menudo como el caso base recursivo (cuando el tamaño del problema es pequeño) para los algoritmos de clasificación de división y conquista de mayor sobrecarga, como la clasificación por fusión o la clasificación rápida.
Para implementar insertion_sort
con la biblioteca estándar, use repetidamente std::upper_bound
para encontrar la ubicación a la que debe ir el elemento actual, y use std::rotate
para desplazar los elementos restantes hacia arriba en el rango de entrada:
template<class FwdIt, class Compare = std::less<>>
void insertion_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
for (auto it = first; it != last; ++it) {
auto const insertion = std::upper_bound(first, it, *it, cmp);
std::rotate(insertion, it, std::next(it));
assert(std::is_sorted(first, std::next(it), cmp));
}
}
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 :
- la clasificación de inserción se puede optimizar con una prueba temprana
if (std::distance(first, last) <= 1) return;
(o para los iteradores hacia adelante / bidireccionales:if (first == last || std::next(first) == last) return;
) y un bucle durante el intervalo[std::next(first), last)
, porque El primer elemento está garantizado para estar en su lugar y no requiere una rotación. - para los iteradores bidireccionales , la búsqueda binaria para encontrar el punto de inserción se puede reemplazar con una búsqueda lineal inversa utilizando el algoritmo
std::find_if_not
la Biblioteca estándar.
Cuatro ejemplos en vivo ( C++14 , C++11 , C ++ 98 y Boost , C++98 ) para el fragmento a continuación:
using RevIt = std::reverse_iterator<BiDirIt>;
auto const insertion = std::find_if_not(RevIt(it), RevIt(first),
[=](auto const& elem){ return cmp(*it, elem); }
).base();
- Para entradas aleatorias, esto da comparaciones de
O(N²)
, pero esto mejora a comparaciones deO(N)
para entradas casi ordenadas. La búsqueda binaria siempre usa comparacionesO(N log N)
. - Para rangos de entrada pequeños, la mejor ubicación de memoria (caché, búsqueda previa) de una búsqueda lineal también podría dominar una búsqueda binaria (uno debería probar esto, por supuesto).
Ordenación rápida
Cuando se implementa con cuidado, la ordenación rápida es robusta y tiene O(N log N)
complejidad esperada, pero con O(N²)
complejidad del peor caso que puede activarse con datos de entrada seleccionados adversamente. Cuando no se necesita una clasificación estable, la clasificación rápida es una excelente clasificación de propósito general.
Incluso para las versiones más simples, la clasificación rápida es un poco más complicada de implementar utilizando la Biblioteca estándar que los otros algoritmos de clasificación clásicos. El siguiente enfoque usa algunas utilidades de iterador para ubicar el elemento central del rango de entrada [first, last)
como pivote, luego usa dos llamadas a std::partition
(que son O(N)
) a la partición de tres vías la entrada rango en segmentos de elementos que son más pequeños, iguales y más grandes que el pivote seleccionado, respectivamente. Finalmente, los dos segmentos exteriores con elementos más pequeños y más grandes que el pivote se clasifican de forma recursiva:
template<class FwdIt, class Compare = std::less<>>
void quick_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
auto const N = std::distance(first, last);
if (N <= 1) return;
auto const pivot = *std::next(first, N / 2);
auto const middle1 = std::partition(first, last, [=](auto const& elem){
return cmp(elem, pivot);
});
auto const middle2 = std::partition(middle1, last, [=](auto const& elem){
return !cmp(pivot, elem);
});
quick_sort(first, middle1, cmp); // assert(std::is_sorted(first, middle1, cmp));
quick_sort(middle2, last, cmp); // assert(std::is_sorted(middle2, last, cmp));
}
Sin embargo, la clasificación rápida es bastante difícil de obtener de manera correcta y eficiente, ya que cada uno de los pasos anteriores debe comprobarse y optimizarse cuidadosamente para el código de nivel de producción. En particular, para la complejidad de O(N log N)
, el pivote debe resultar en una partición equilibrada de los datos de entrada, lo que no se puede garantizar en general para un pivote O(1)
, pero se puede garantizar si se establece el pivote como la mediana O(N)
del rango de entrada.
Detalles omitidos :
- la implementación anterior es particularmente vulnerable a entradas especiales, por ejemplo, tiene complejidad
O(N^2)
para las entradas1, 2, 3, ..., N/2, ... 3, 2, 1
del " tubo de órgano " ( porque el medio es siempre más grande que todos los demás elementos). - median-of-3 selección de pivote de la median-of-3 partir de elementos elegidos al azar de las guardas de rango de entrada contra entradas casi ordenadas para las cuales la complejidad se deterioraría a
O(N^2)
. - La partición de 3 vías (separando elementos más pequeños, iguales y más grandes que el pivote) como lo muestran las dos llamadas a
std::partition
no es el algoritmoO(N)
más eficiente para lograr este resultado. - para los iteradores de acceso aleatorio , se puede lograr una complejidad
O(N log N)
garantizada mediante la selección de pivote mediano utilizandostd::nth_element(first, middle, last)
, seguido de llamadas recursivas aquick_sort(first, middle, cmp)
yquick_sort(middle, last, cmp)
. - esta garantía tiene un costo, sin embargo, porque el factor constante de la complejidad
O(N)
destd::nth_element
puede ser más costoso que el de la complejidadO(1)
de un pivote de mediana de 3 seguido de unO(N)
llamada astd::partition
(que es un paso directo simple y fácil de almacenar en caché sobre los datos).
Fusionar orden
Si no le preocupa el uso de O(N)
espacio adicional, entonces la ordenación por combinación es una excelente opción: es el único algoritmo de clasificación estable O(N log N)
.
Es fácil de implementar utilizando algoritmos estándar: use algunas utilidades de iterador para ubicar la mitad del rango de entrada [first, last)
y combine dos segmentos ordenados recursivamente con un std::inplace_merge
:
template<class BiDirIt, class Compare = std::less<>>
void merge_sort(BiDirIt first, BiDirIt last, Compare cmp = Compare{})
{
auto const N = std::distance(first, last);
if (N <= 1) return;
auto const middle = std::next(first, N / 2);
merge_sort(first, middle, cmp); // assert(std::is_sorted(first, middle, cmp));
merge_sort(middle, last, cmp); // assert(std::is_sorted(middle, last, cmp));
std::inplace_merge(first, middle, last, cmp); // assert(std::is_sorted(first, last, cmp));
}
El ordenamiento de fusión requiere iteradores bidireccionales, siendo el cuello de botella el std::inplace_merge
. Tenga en cuenta que al ordenar las listas vinculadas, la clasificación de fusión solo requiere O(log N)
espacio adicional (para la recursión). Este último algoritmo se implementa mediante std::list<T>::sort
en la Biblioteca estándar.
Tipo de pila
La ordenación de pila es simple de implementar, realiza una ordenación en el lugar O(N log N)
, pero no es estable.
El primer bucle, O(N)
"heapify" fase, pone la matriz en orden de pila. El segundo bucle, la fase de "clasificación" de O(N log N
), extrae repetidamente el máximo y restaura el orden de almacenamiento dinámico. La biblioteca estándar hace que esto sea extremadamente sencillo:
template<class RandomIt, class Compare = std::less<>>
void heap_sort(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
lib::make_heap(first, last, cmp); // assert(std::is_heap(first, last, cmp));
lib::sort_heap(first, last, cmp); // assert(std::is_sorted(first, last, cmp));
}
En caso de que consideres "engañoso" usar std::make_heap
y std::sort_heap
, puedes ir un nivel más profundo y escribir esas funciones en términos de std::push_heap
y std::pop_heap
, respectivamente:
namespace lib {
// NOTE: is O(N log N), not O(N) as std::make_heap
template<class RandomIt, class Compare = std::less<>>
void make_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
for (auto it = first; it != last;) {
std::push_heap(first, ++it, cmp);
assert(std::is_heap(first, it, cmp));
}
}
template<class RandomIt, class Compare = std::less<>>
void sort_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
for (auto it = last; it != first;) {
std::pop_heap(first, it--, cmp);
assert(std::is_heap(first, it, cmp));
}
}
} // namespace lib
La biblioteca estándar especifica tanto push_heap
como pop_heap
como complejidad O(log N)
. Sin embargo, make_heap
cuenta que el bucle externo sobre el rango [first, last)
da como resultado una complejidad O(N log N)
para make_heap
, mientras que std::make_heap
solo tiene una complejidad O(N)
. Para la complejidad general de O(N log N)
de heap_sort
no importa.
Detalles omitidos : O(N)
implementación de make_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 (que no pretenden ser exhaustivas o rigurosas). Solo tenga en cuenta que las enormes diferencias en el LOC: C ++ 11 / C ++ 14 necesitan alrededor de 130 LOC, C ++ 98 y Boost 190 (+ 50%) y C ++ 98 más de 270 (+ 100%).
El algoritmo std::sort
(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 amalgama complicada e híbrida de algoritmos de clasificación más elementales , como la clasificación por selección, la clasificación por inserción, la clasificación rápida , fusionar ordenar, o ordenar en montón.
Hay muchas preguntas aquí y en sitios hermanos como https://codereview.stackexchange.com/ relacionado 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, uso de 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 arriba utilizando C ++ moderno?
- sin bucles sin procesar , pero combinando los bloques de construcción algorítmicos de la Biblioteca Estándar de
<algorithm>
- Interfaz de iterador y uso de plantillas en lugar de manipulación de índices y tipos concretos.
- Estilo C ++ 14 , que incluye 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 referencias adicionales 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 en bruto es un ciclo más largo que la composición de dos funciones con un operador. Entonces
f(g(x));
of(x); g(x);
f(x); g(x);
of(x) + g(x);
no son bucles sin procesar, y tampoco lo son los bucles enselection_sort
yinsertion_sort
continuación. - Sigo la terminología de Scott Meyers para indicar el C ++ 1y actual como C ++ 14, y para indicar C ++ 98 y C ++ 03 como C ++ 98, así que no me llames por eso.
- Como se sugiere en los comentarios de @Mehrdad, proporciono cuatro implementaciones como 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. Donde sea relevante, denuncio las diferencias sintácticas y de biblioteca en las que difieren las diferentes versiones lingüísticas.
Otro pequeño y bastante elegante originalmente encontrado en la revisión del código . Pensé que valía la pena compartirlo.
Contando orden
Si bien es más bien especializado, la ordenación por conteo es un algoritmo simple de clasificación de enteros y, a menudo, puede ser realmente rápido siempre que los valores de los enteros a ordenar no estén muy separados. Probablemente sea ideal si alguna vez se necesita clasificar una colección de un millón de enteros entre 0 y 100, por ejemplo.
Para implementar una ordenación de conteo muy simple que funciona 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 dirá el tamaño de la matriz de conteos a 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, volvemos a escribir el número requerido de cada entero en la colección original.
template<typename ForwardIterator>
void counting_sort(ForwardIterator first, ForwardIterator last)
{
if (first == last || std::next(first) == last) return;
auto minmax = std::minmax_element(first, last); // avoid if possible.
auto min = *minmax.first;
auto max = *minmax.second;
if (min == max) return;
using difference_type = typename std::iterator_traits<ForwardIterator>::difference_type;
std::vector<difference_type> counts(max - min + 1, 0);
for (auto it = first ; it != last ; ++it) {
++counts[*it - min];
}
for (auto count: counts) {
first = std::fill_n(first, count, min++);
}
}
Si bien solo es útil cuando se sabe que el rango de los enteros a ordenar es pequeño (generalmente no más grande que el tamaño de la colección a ordenar), hacer que la clasificación sea más genérica lo haría más lento para sus mejores casos. Si no se sabe que el rango sea pequeño, en su lugar se puede usar otro algoritmo como una clasificación de radix , ska_sort o spreadsort .
Detalles omitidos :
Podríamos haber superado los límites del rango de valores aceptados por el algoritmo como parámetros para deshacernos totalmente del primer paso
std::minmax_element
a través de la colección. Esto hará que el algoritmo sea aún más rápido cuando se conoce un límite de rango útilmente pequeño por otros medios. (No tiene que ser exacto; pasar de 0 a 100 constante es mucho mejor que un pase adicional de más de un millón de elementos para descubrir que los límites reales son de 1 a 95. Incluso de 0 a 1000 valdría la pena; Los elementos adicionales se escriben una vez con cero y se leen una vez.El
counts
creciente sobre la marcha es otra forma de evitar una primera pasada separada. Duplicar el tamaño de loscounts
cada vez que tiene que crecer proporciona un tiempo amortizado O (1) por elemento ordenado (consulte el análisis de costos de inserción de la tabla hash para ver la prueba de que la clave es el crecimiento exponencial). Crecer al final para un nuevomax
es fácil constd::vector::resize
para agregar nuevos elementos puestos a cero. Cambiarmin
al vuelo e insertar nuevos elementosstd::copy_backward
cero en la parte delantera se puede hacer constd::copy_backward
después de hacer crecer el vector. Entoncesstd::fill
a cero los nuevos elementos.El bucle de incremento de
counts
es un histograma. Si es probable que los datos sean altamente repetitivos, y el número de contenedores sea pequeño, puede valer la pena desenrollarlos en múltiples arreglos para reducir el cuello de botella de la dependencia de datos de serialización de almacenamiento / recarga en el mismo contenedor. Esto significa que más cuentas a cero al comienzo, y más para hacer un bucle al final, pero debería valer la pena en la mayoría de las CPU para nuestro ejemplo de millones de 0 a 100 números, especialmente si la entrada ya puede estar ordenada (parcialmente) y tienen carreras largas del mismo número.En el algoritmo anterior, usamos un
min == max
check para regresar temprano cuando cada elemento tiene el mismo valor (en cuyo caso se clasifica la colección). En realidad, es posible verificar completamente si la colección ya está ordenada mientras se encuentran los valores extremos de una colección sin pérdida de tiempo adicional (si la primera pasada aún es un cuello de botella con el trabajo adicional de actualizar el mínimo y el máximo). Sin embargo, tal algoritmo no existe en la biblioteca estándar y escribir uno sería más tedioso que escribir el resto del orden de conteo en sí. Se deja como ejercicio para el lector.Dado que el algoritmo solo funciona con valores enteros, se pueden usar aserciones estáticas para evitar que los usuarios cometan errores de tipo obvios. En algunos contextos, podría preferirse una falla de sustitución con
std::enable_if_t
.Si bien el C ++ moderno es genial, el C ++ futuro podría ser incluso más frío: los enlaces estructurados y algunas partes de los Rangos TS harán que el algoritmo sea aún más claro.