¿Cómo implementar algoritmos de clasificación clásicos en C++ moderno?
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 combinación complicada e híbrida de algoritmos de clasificación más elementales , como clasificación por selección, clasificación por inserción, clasificación rápida, clasificación por fusión o clasificación en 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 formato, utilizan manipulación de índices y tipos concretos, y generalmente no son triviales de 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 formato , 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 , incluida la biblioteca estándar completa, así como reductores de ruido sintáctico como
auto
alias de plantilla, 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/
- Según las convenciones de Sean Parent (diapositiva 39), un bucle sin formato es un
for
bucle más largo que la composición de dos funciones con un operador. Entoncesf(g(x));
, orf(x); g(x);
of(x) + g(x);
no son bucles sin formato, y tampoco lo son los bucles dentroselection_sort
yinsertion_sort
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 molestes 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 únicamente en términos de C++14. Cuando sea relevante, denoto las diferencias sintácticas y de biblioteca en las que difieren las distintas versiones lingüísticas.
Bloques de construcción algorítmicos
Comenzamos ensamblando los componentes básicos 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 iteración como non-member
std::begin()
/std::end()
y withstd::next()
solo están disponibles a partir de C++ 11 y posteriores. Para C++98, es necesario escribirlos él mismo. Hay sustitutos de Boost.Range enboost::begin()
/boost::end()
y de Boost.Utility enboost::next()
. - el
std::is_sorted
algoritmo solo está disponible para C++ 11 y versiones posteriores. Para C++98, esto se puede implementar en términos destd::adjacent_find
un objeto de función escrito a mano. Boost.Algorithm también proporciona unboost::algorithm::is_sorted
sustituto. - el
std::is_heap
algoritmo solo está disponible para C++ 11 y versiones posteriores.
Beneficios sintácticos
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 sobrecarga única para ordenar algoritmos 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, se puede definir un alias de plantilla reutilizable para extraer el tipo de valor de un iterador, lo 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, es necesario escribir dos sobrecargas y utilizar la typename xxx<yyy>::type
sintaxis detallada.
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 sutileza sintáctica es que C++ 14 facilita envolver comparadores definidos por el usuario a través de lambdas polimórficas (con
auto
parámetros que se deducen como argumentos de plantilla de función). - C++ 11 solo tiene lambdas monomórficas, que requieren el uso del alias de plantilla anterior
value_type_t
. - En C++98, es necesario escribir un objeto de función independiente o recurrir al tipo de sintaxis detallada
std::bind1st
// .std::bind2nd
std::not1
- Boost.Bind mejora esto con la sintaxis
boost::bind
y_1
/_2
marcador de posición. - C++ 11 y posteriores también tienen
std::find_if_not
, mientras que C++ 98 necesitastd::find_if
unstd::not1
objeto de función alrededor.
Estilo C++
Todavía no existe un estilo C++ 14 generalmente aceptable. Para bien o para mal, sigo de cerca el borrador de Effective Modern C++ de Scott Meyers y el renovado GotW de Herb Sutter . Utilizo las siguientes recomendaciones de estilo:
- Las recomendaciones "Almost Always Auto" de Herb Sutter y "Preferir declaraciones automáticas a declaraciones de tipos específicos" de Scott Meyers , cuya brevedad es insuperable, aunque a veces se cuestiona su claridad .
- "Distinguir
()
y{}
al crear objetos" de Scott Meyers y elegir constantemente la inicialización entre llaves{}
en lugar de la antigua inicialización entre paréntesis()
(para evitar todos los problemas de análisis más molestos en el código genérico). - "Prefiera declaraciones de alias a typedefs" de Scott Meyers . Para las plantillas, esto es imprescindible de todos modos, y usarlo en todas partes ahorra
typedef
tiempo y agrega coherencia. - Utilizo un
for (auto it = first; it != last; ++it)
patrón en algunos lugares para permitir la verificación invariante de bucle de 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 ligeramente mejor.
Orden de selección
La ordenación por selección no se adapta a los datos de ninguna manera, por lo que su tiempo de ejecución es siempreO(N²)
. Sin embargo, la clasificació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 clasificación por selección puede ser el algoritmo elegido.
Para implementarlo usando la Biblioteca estándar, use repetidamente std::min_element
para encontrar el elemento mínimo restante e iter_swap
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
el rango ya procesado está [first, it)
ordenado como su invariante de bucle. Los requisitos mínimos son iteradores directos , en comparación con std::sort
los iteradores de acceso aleatorio.
Detalles omitidos :
- La clasificación de selección se puede optimizar con una prueba temprana
if (std::distance(first, last) <= 1) return;
(o para iteradores directos/bidireccionales:)if (first == last || std::next(first) == last) return;
. - para iteradores bidireccionales , la prueba anterior se puede combinar con un bucle sobre el intervalo
[first, std::prev(last))
, porque se garantiza que el último elemento será el elemento mínimo restante y no requiere un intercambio.
Tipo de inserción
Aunque es uno de los algoritmos de clasificación elemental con O(N²)
el peor de los casos, la clasificación por inserción es el algoritmo elegido cuando los datos están casi ordenados (porque es adaptativo ) o cuando el tamaño del problema es pequeño (porque tiene poca sobrecarga). Por estas razones, y porque también es estable , la ordenación por inserción se utiliza a menudo como caso base recursivo (cuando el tamaño del problema es pequeño) para algoritmos de ordenación de divide y vencerás con mayor sobrecarga, como la ordenación por fusión o la ordenación rápida.
Para implementar insertion_sort
con la Biblioteca estándar, use repetidamente std::upper_bound
para encontrar la ubicación donde 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
el rango ya procesado está [first, it)
ordenado como su invariante de bucle. La ordenación por inserción también funciona con iteradores directos.
Detalles omitidos :
- La clasificación por inserción se puede optimizar con una prueba temprana
if (std::distance(first, last) <= 1) return;
(o para iteradores directos/bidireccionales:)if (first == last || std::next(first) == last) return;
y un bucle sobre el intervalo[std::next(first), last)
, porque se garantiza que el primer elemento estará en su lugar y no requiere una rotación. - para 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 de la biblioteca estándar
std::find_if_not
.
Cuatro ejemplos en vivo ( C++14 , C++11 , C++98 y Boost , C++98 ) para el siguiente fragmento:
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 proporciona
O(N²)
comparaciones, pero esto mejora aO(N)
comparaciones para entradas casi ordenadas. La búsqueda binaria siempre utilizaO(N log N)
comparaciones. - Para rangos de entrada pequeños, la mejor localidad de memoria (caché, captación previa) de una búsqueda lineal también podría dominar una búsqueda binaria (se debe probar esto, por supuesto).
Ordenación rápida
Cuando se implementa cuidadosamente, la clasificación rápida es sólida y tiene O(N log N)
la complejidad esperada, pero en O(N²)
el peor de los casos la complejidad puede activarse con datos de entrada elegidos por el adversario. Cuando no se necesita una clasificación estable, la clasificación rápida es una excelente clasificación de uso 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 utiliza algunas utilidades de iterador para ubicar el elemento medio del rango de entrada [first, last)
como pivote, luego usa dos llamadas a std::partition
(que son O(N)
) para dividir en tres partes el rango de entrada en segmentos de elementos que son más pequeños que, iguales a, y mayor que el pivote seleccionado, respectivamente. Finalmente, los dos segmentos exteriores con elementos menores y mayores que el pivote se ordenan 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, es bastante complicado lograr que la clasificación rápida sea correcta y eficiente, ya que cada uno de los pasos anteriores debe verificarse y optimizarse cuidadosamente 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, lo que no se puede garantizar en general para un O(1)
pivote, pero sí se puede garantizar si se establece el pivote como la O(N)
mediana del rango de entrada.
Detalles omitidos :
- la implementación anterior es particularmente vulnerable a entradas especiales, por ejemplo, tiene
O(N^2)
complejidad para la entrada " tubo de órgano1, 2, 3, ..., N/2, ... 3, 2, 1
" (porque el medio siempre es más grande que todos los demás elementos). - La selección de pivote de mediana de 3 a partir de elementos elegidos aleatoriamente del rango de entrada protege contra entradas casi ordenadas cuya complejidad se deterioraría de otro modo hasta
O(N^2)
. - La partición de 3 vías (separar 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 algoritmo más eficienteO(N)
para lograr este resultado. - para iteradores de acceso aleatorio ,
O(N log N)
se puede lograr una complejidad garantizada mediante la selección de pivote medio usandostd::nth_element(first, middle, last)
, seguida de llamadas recursivas aquick_sort(first, middle, cmp)
yquick_sort(middle, last, cmp)
. - Sin embargo, esta garantía tiene un costo, porque el factor constante de la
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 apto para caché). los datos).
Combinar ordenar
Si usar O(N)
espacio adicional no es motivo de preocupación, entonces la ordenación por combinación es una excelente opción: es el único algoritmo de ordenación estable . O(N log N)
Es sencillo 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 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));
}
La ordenación por combinación requiere iteradores bidireccionales, siendo el cuello de botella el std::inplace_merge
. Tenga en cuenta que al ordenar listas vinculadas, la ordenación por combinación solo requiere O(log N)
espacio adicional (para recursividad). El último algoritmo se implementa std::list<T>::sort
en la biblioteca estándar.
clasificación de montón
La clasificación en montón es sencilla de implementar, realiza unaO(N log N)
clasificación in situ, pero no es estable.
El primer ciclo, O(N)
fase "heapify", coloca la matriz en orden de montón. El segundo ciclo, la O(N log N
fase de "clasificación", extrae repetidamente el máximo y restaura el orden del montón. 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 "hacer trampa" usar std::make_heap
y std::sort_heap
, puedes ir un nivel más profundo y escribir esas funciones tú mismo 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, tenga en cuenta que el bucle externo sobre el rango [first, last)
genera O(N log N)
complejidad para make_heap
, mientras que std::make_heap
solo tiene O(N)
complejidad. Por la O(N log N)
complejidad general, heap_sort
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 pretenden ser exhaustivos ni rigurosos). Solo tenga en cuenta las enormes 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%).