std::sort
Algorytm (i jego kuzyni std::partial_sort
i std::nth_element
) z C ++ Standard Library w większości implementacji skomplikowanej i hybrydowe połączenie więcej podstawowych algorytmów sortowania , takich jak wybór rodzaju, insercji sortowania szybkiego sortowania, łączenia sortowania lub sortowania sterty.
Tutaj i na siostrzanych stronach, takich jak https://codereview.stackexchange.com/, jest wiele pytań związanych z błędami, złożonością i innymi aspektami implementacji tych klasycznych algorytmów sortowania. Większość oferowanych implementacji składa się z surowych pętli, wykorzystuje manipulację indeksem i konkretne typy i generalnie nie jest trywialna do analizy pod kątem poprawności i wydajności.
Pytanie : jak można zaimplementować wyżej wymienione klasyczne algorytmy sortowania przy użyciu nowoczesnego C ++?
- brak surowych pętli , ale połączenie algorytmicznych elementów składowych biblioteki standardowej z
<algorithm>
- interfejs iteratora i użycie szablonów zamiast manipulowania indeksem i konkretnych typów
- Styl C ++ 14 , w tym pełna Biblioteka Standardowa, a także składniowe tłumiki szumów, takie jak
auto
aliasy szablonów, przezroczyste komparatory i polimorficzne lambdy.
Uwagi :
- dalsze odniesienia na temat implementacji algorytmów sortowania można znaleźć w Wikipedii , Kodzie Rosetta lub http://www.sorting-algorithms.com/
- zgodnie z konwencjami Seana Parenta (slajd 39), surowa pętla ma pętlę
for
dłuższą niż połączenie dwóch funkcji z operatorem. Więcf(g(x));
albof(x); g(x);
czyf(x) + g(x);
nie są surowe pętle, a nie są pętleselection_sort
iinsertion_sort
poniżej. - Postępuję zgodnie z terminologią Scotta Meyersa, aby oznaczyć obecny C ++ 1y już jako C ++ 14 oraz C ++ 98 i C ++ 03 zarówno jako C ++ 98, więc nie rozpalaj mnie za to.
- Jak zasugerowano w komentarzach @Mehrdad, podam na końcu odpowiedzi cztery implementacje: C ++ 14, C ++ 11, C ++ 98 oraz Boost i C ++ 98.
- Sama odpowiedź została przedstawiona wyłącznie w kategoriach C ++ 14. W stosownych przypadkach wskazuję różnice składniowe i biblioteczne w przypadku różnych wersji językowych.
Odpowiedzi:
Algorytmiczne elementy składowe
Zaczynamy od złożenia algorytmicznych bloków konstrukcyjnych z biblioteki standardowej:
std::begin()
/std::end()
oraz asstd::next()
są dostępne tylko od wersji C ++ 11 i późniejszych. W przypadku C ++ 98 należy je napisać samodzielnie. Istnieją substytuty z Boost.Range wboost::begin()
/boost::end()
i od Boost.Utility wboost::next()
.std::is_sorted
algorytm jest dostępna tylko dla c ++ 11 i poza nią. W przypadku C ++ 98 można to zaimplementować w postacistd::adjacent_find
obiektu funkcji napisanego ręcznie. Boost.Algorytm zapewnia równieżboost::algorithm::is_sorted
jako substytut.std::is_heap
algorytm jest dostępna tylko dla c ++ 11 i poza nią.Składniowe gadżety
C ++ 14 zapewnia przejrzyste komparatory formy,
std::less<>
które działają polimorficznie na ich argumenty. Pozwala to uniknąć konieczności podawania typu iteratora. Można tego użyć w połączeniu z domyślnymi argumentami szablonu funkcji w C ++ 11, aby utworzyć pojedyncze przeciążenie dla algorytmów sortujących, które biorą<
za porównanie, oraz tych, które mają zdefiniowany przez użytkownika obiekt funkcji porównania.W C ++ 11 można zdefiniować alias szablonu wielokrotnego użytku, aby wyodrębnić typ wartości iteratora, co dodaje drobne zakłócenia do podpisów algorytmów sortowania:
W C ++ 98 trzeba napisać dwa przeciążenia i użyć pełnej
typename xxx<yyy>::type
składniauto
parametrami, które są wywnioskowane jak argumenty szablonu funkcji).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
.boost::bind
i_1
/_2
placeholder.std::find_if_not
, podczas gdy C ++ 98 potrzebystd::find_if
zstd::not1
całego obiektu funkcyjnego.Styl C ++
Nie ma jeszcze ogólnie akceptowanego stylu C ++ 14. Na dobre lub na złe, uważnie śledzę projekt Scotta Meyersa Effective Modern C ++ i odnowiony GotW . Korzystam z następujących zaleceń dotyczących stylu:
()
i{}
podczas tworzenia obiektów” i konsekwentnie wybieraj opcję inicjacji usztywnionej{}
zamiast starej dobrej inicjalizacji()
w nawiasach (aby pominąć wszystkie najbardziej irytujące kwestie w kodzie ogólnym).typedef
oszczędzać czas i zapewnia spójność.for (auto it = first; it != last; ++it)
W niektórych miejscach używam wzorca, aby umożliwić niezmienne sprawdzanie pętli dla już posortowanych podzakresów. W kodzie produkcyjnym użyciewhile (first != last)
i++first
gdzieś wewnątrz pętli może być nieco lepsze.Sortuj wybór
Sortowanie selekcji w żaden sposób nie dostosowuje się do danych, więc jego czas działania jest zawsze
O(N²)
. Jednak sortowanie według wyboru ma właściwość minimalizowania liczby zamian . W aplikacjach, w których koszt wymiany elementów jest wysoki, algorytm wyboru może być bardzo dobrze sortowany.Aby zaimplementować go przy użyciu biblioteki standardowej, kilkakrotnie użyj,
std::min_element
aby znaleźć pozostały minimalny element iiter_swap
zamienić go na miejsce:Zauważ, że
selection_sort
już przetworzony zakres został[first, it)
posortowany jako niezmiennik pętli. Minimalne wymagania to iteratory przesyłania dalej , w porównaniu dostd::sort
iteratorów dostępu swobodnego.Szczegóły pominięte :
if (std::distance(first, last) <= 1) return;
(lub dla iteratorów do przodu / dwukierunkowych:)if (first == last || std::next(first) == last) return;
.[first, std::prev(last))
, ponieważ ostatni element jest gwarantowanym minimalnym pozostałym elementem i nie wymaga wymiany.Sortowanie przez wstawianie
Chociaż jest to jeden z elementarnych algorytmów sortowania o
O(N²)
najgorszym czasie, sortowanie z wstawianiem jest algorytmem z wyboru, gdy dane są prawie posortowane (ponieważ są adaptacyjne ) lub gdy rozmiar problemu jest mały (ponieważ ma niewielki narzut). Z tych powodów oraz ze względu na stabilność , sortowanie wstawiania jest często stosowane jako rekurencyjna podstawa (gdy rozmiar problemu jest niewielki) w celu uzyskania wyższych algorytmów sortowania dziel i zwyciężaj, takich jak sortowanie scalone lub szybkie sortowanie.Aby zaimplementować
insertion_sort
w Bibliotece standardowej, kilkakrotnie użyj,std::upper_bound
aby znaleźć lokalizację, do której powinien przejść bieżący element, i użyj,std::rotate
aby przesunąć pozostałe elementy w górę w zakresie wejściowym:Zauważ, że
insertion_sort
już przetworzony zakres został[first, it)
posortowany jako niezmiennik pętli. Sortowanie wstawiania działa również z iteratorami do przodu.Szczegóły pominięte :
if (std::distance(first, last) <= 1) return;
(lub dla iteratorów do przodu / dwukierunkowych:)if (first == last || std::next(first) == last) return;
i pętli w przedziale[std::next(first), last)
, ponieważ gwarantuje się, że pierwszy element jest na miejscu i nie wymaga obrotu.std::find_if_not
algorytmu biblioteki standardowej .Cztery aktywne przykłady ( C ++ 14 , C ++ 11 , C ++ 98 i Boost , C ++ 98 ) dla poniższego fragmentu:
O(N²)
porównania, ale poprawia się to wO(N)
porównaniu do prawie posortowanych danych wejściowych. Wyszukiwanie binarne zawsze wykorzystujeO(N log N)
porównania.Szybkie sortowanie
Po dokładnym wdrożeniu szybkie sortowanie jest niezawodne i
O(N log N)
oczekuje się złożoności, ale zO(N²)
najgorszą złożonością, która może zostać wywołana przy przeciwnie wybranych danych wejściowych. Gdy sortowanie stabilne nie jest potrzebne, szybkie sortowanie jest doskonałym sortowaniem ogólnego zastosowania.Nawet w przypadku najprostszych wersji szybkie sortowanie jest nieco bardziej skomplikowane do wdrożenia przy użyciu biblioteki standardowej niż w przypadku innych klasycznych algorytmów sortowania. Poniższe podejście wykorzystuje kilka narzędzi iteratora do zlokalizowania środkowego elementu zakresu wejściowego
[first, last)
jako osi przestawnej, a następnie użyj dwóch wywołaństd::partition
(które sąO(N)
) w celu trójstronnego podziału zakresu wejściowego na segmenty elementów, które są mniejsze niż, równe, i odpowiednio większy niż wybrany element przestawny. Na koniec dwa segmenty zewnętrzne z elementami mniejszymi i większymi niż oś są rekurencyjnie sortowane:Jednak szybkie sortowanie jest dość trudne, aby uzyskać prawidłowe i wydajne, ponieważ każdy z powyższych kroków musi być dokładnie sprawdzony i zoptymalizowany pod kątem kodu poziomu produkcyjnego. W szczególności, dla
O(N log N)
złożoności, oś przestawna musi skutkować zrównoważonym podziałem danych wejściowych, co nie może być ogólnie gwarantowane dlaO(1)
osi przestawnej, ale które można zagwarantować, jeśli ustawimy oś jakoO(N)
medianę zakresu wejściowego.Szczegóły pominięte :
O(N^2)
złożoność dla danych wejściowych „ piszczałki organowej ”1, 2, 3, ..., N/2, ... 3, 2, 1
(ponieważ środek jest zawsze większy niż wszystkie inne elementy).O(N^2)
.std::partition
nie jest najbardziej wydajnymO(N)
algorytmem do osiągnięcia tego wyniku.O(N log N)
złożoność można osiągnąć poprzez mediany selekcji obrotu za pomocąstd::nth_element(first, middle, last)
, a następnie rekurencyjnych wywołańquick_sort(first, middle, cmp)
iquick_sort(middle, last, cmp)
.O(N)
złożonościstd::nth_element
może być droższy niżO(1)
złożoności mediany 3-osiowej, po której następujeO(N)
wywołaniestd::partition
(które jest przyjazne dla pamięci podręcznej pojedyncze przejście do przodu dane).Scal sortowanie
Jeśli korzystanie z
O(N)
dodatkowej przestrzeni nie ma znaczenia, sortowanie po scaleniu jest doskonałym wyborem: jest to jedyny stabilnyO(N log N)
algorytm sortowania.Jest prosty do wdrożenia przy użyciu standardowych algorytmów: użyj kilku narzędzi iteracyjnych, aby zlokalizować środek zakresu wejściowego
[first, last)
i połączyć dwa rekursywnie posortowane segmenty zstd::inplace_merge
:Sortowanie po scaleniu wymaga iteratorów dwukierunkowych, przy czym wąskim gardłem jest
std::inplace_merge
. Pamiętaj, że podczas sortowania list połączonych sortowanie po scaleniu wymaga tylkoO(log N)
dodatkowego miejsca (do rekurencji). Ten drugi algorytm jest implementowanystd::list<T>::sort
w bibliotece standardowej.Sortuj sterty
Sortowanie sterty jest proste do wdrożenia, wykonuje
O(N log N)
sortowanie na miejscu, ale nie jest stabilne.Pierwsza pętla,
O(N)
faza „heapify”, ustawia tablicę w kolejności stosu. Druga pętla,O(N log N
faza „sortdown”, wielokrotnie wyodrębnia maksimum i przywraca porządek sterty. Biblioteka standardowa sprawia, że jest to wyjątkowo proste:W przypadku, gdy uznają to za „oszustwo” w użyciu
std::make_heap
istd::sort_heap
można przejść jeden poziom głębiej i pisać te funkcje się w warunkachstd::push_heap
istd::pop_heap
odpowiednio:Biblioteka standardowa określa zarówno
push_heap
ipop_heap
jako złożonośćO(log N)
. Zauważ jednak, że zewnętrzna pętla w zakresie[first, last)
powodujeO(N log N)
złożonośćmake_heap
, astd::make_heap
ma tylkoO(N)
złożoność. Dla ogólnejO(N log N)
złożonościheap_sort
nie ma to znaczenia.Szczegóły pominięte :
O(N)
wdrożeniemake_heap
Testowanie
Oto cztery przykłady na żywo ( C ++ 14 , C ++ 11 , C ++ 98 i Boost , C ++ 98 ) testujące wszystkie pięć algorytmów na różnych wejściach (nieprzeznaczonych jako wyczerpujące lub rygorystyczne). Zauważ, że ogromne różnice w LOC: C ++ 11 / C ++ 14 potrzebują około 130 LOC, C ++ 98 i Boost 190 (+ 50%), a C ++ 98 ponad 270 (+ 100%).
źródło
auto
(i wiele osób się ze mną nie zgadza), podobało mi się, że standardowe algorytmy biblioteczne są dobrze używane. Chciałem zobaczyć przykłady tego rodzaju kodu po obejrzeniu przemówienia Seana Parenta. Nie miałem też pojęciastd::iter_swap
, że wydaje mi się to dziwne<algorithm>
.if (first == last || std::next(first) == last)
. Mogę to zaktualizować później. Implementowanie rzeczy w sekcjach „pominiętych szczegółów” jest poza zakresem pytania, IMO, ponieważ zawierają one linki do samych samych pytań i odpowiedzi. Wdrożenie procedur sortowania słów rzeczywistych jest trudne!nth_element
moim zdaniem.nth_element
wykonuje już pół szybkiego sortowania (w tym krok partycjonowania i rekurencję na połowie, która zawiera n-ty element, który Cię interesuje).Kolejny mały i dość elegancki, pierwotnie znaleziony podczas przeglądu kodu . Myślałem, że warto się dzielić.
Liczenie sortuj
Chociaż jest raczej wyspecjalizowany, sortowanie zliczania jest prostym algorytmem sortowania liczb całkowitych i często może być naprawdę szybkie, pod warunkiem, że wartości liczb całkowitych do sortowania nie są zbyt daleko od siebie. Jest to prawdopodobnie idealne rozwiązanie, jeśli kiedykolwiek trzeba posortować zbiór milionów liczb całkowitych o znanej na przykład wartości od 0 do 100.
Aby wdrożyć bardzo proste sortowanie zliczające, które działa zarówno z liczbami całkowitymi ze znakiem, jak i bez znaku, należy znaleźć najmniejsze i największe elementy w kolekcji do sortowania; ich różnica wskaże rozmiar tablicy liczników do przydzielenia. Następnie wykonuje się drugie przejście przez kolekcję, aby policzyć liczbę wystąpień każdego elementu. Na koniec zapisujemy wymaganą liczbę każdej liczby całkowitej z powrotem do oryginalnej kolekcji.
Jest to przydatne tylko wtedy, gdy wiadomo, że zakres liczb całkowitych do sortowania jest niewielki (zwykle nie większy niż rozmiar kolekcji do sortowania), jednak bardziej ogólne liczenie sortowania spowodowałoby spowolnienie w najlepszych przypadkach. Jeśli zakres nie jest znany jako mały, można zastosować inny algorytm, taki jak sortowanie radix , ska_sort lub spreadsort .
Szczegóły pominięte :
Mogliśmy przekroczyć granice zakresu wartości przyjętych przez algorytm jako parametry, aby całkowicie pozbyć się pierwszego
std::minmax_element
przejścia przez kolekcję. To sprawi, że algorytm będzie jeszcze szybszy, gdy przydaje się inny użyteczny limit zasięgu. (To nie musi być dokładne; przekazanie stałej od 0 do 100 jest wciąż znacznie lepsze niż dodatkowe przejście przez milion elementów, aby dowiedzieć się, że prawdziwe granice wynoszą od 1 do 95. Nawet 0 do 1000 byłoby tego warte; dodatkowe elementy są zapisywane raz zerem i odczytywane raz).Dorastanie
counts
w locie to kolejny sposób na uniknięcie osobnego pierwszego przejścia. Podwojeniecounts
wielkości za każdym razem, gdy ma wzrosnąć, daje zamortyzowany czas O (1) na posortowany element (patrz analiza kosztów wstawiania tabeli skrótów, aby udowodnić, że wzrost wykładniczy jest kluczem). Rosnące na końcu nowemax
jest łatwe dziękistd::vector::resize
dodawaniu nowych zerowanych elementów. Wymianamin
w locie i wstawianie nowych zerowanych elementów z przodu można wykonaćstd::copy_backward
po wyhodowaniu wektora. Następnie,std::fill
aby wyzerować nowe elementy.counts
Pętli przyrost jest histogramem. Jeśli dane mogą być bardzo powtarzalne, a liczba pojemników jest niewielka, warto rozwinąć wiele tablic, aby zmniejszyć wąskie gardło zależności między seriami przechowywania / przeładowania do tego samego pojemnika. Oznacza to, że więcej liczy się od zera na początku, a więcej do zapętlenia na końcu, ale powinno być tego warte na większości procesorów w naszym przykładzie milionów od 0 do 100 liczb, szczególnie jeśli dane wejściowe mogą być już (częściowo) posortowane i mają długie serie o tej samej liczbie.W powyższym algorytmie używamy
min == max
czeku, aby wrócić wcześniej, gdy każdy element ma tę samą wartość (w takim przypadku kolekcja jest sortowana). Zamiast tego można w pełni sprawdzić, czy kolekcja jest już posortowana, jednocześnie wyszukując ekstremalne wartości kolekcji bez marnowania dodatkowego czasu (jeśli w pierwszym przejściu nadal brakuje pamięci z dodatkową pracą związaną z aktualizacją wartości minimalnej i maksymalnej). Jednak taki algorytm nie istnieje w standardowej bibliotece, a pisanie jednego z nich byłoby bardziej nużące niż pisanie samej reszty liczenia. Zostawia się to jako ćwiczenie dla czytelnika.Ponieważ algorytm działa tylko z wartościami całkowitymi, można zastosować twierdzenia statyczne, aby zapobiec popełnianiu oczywistych błędów typu. W niektórych kontekstach
std::enable_if_t
preferowane może być niepowodzenie podstawienia .Podczas gdy współczesne C ++ jest fajne, przyszłe C ++ może być jeszcze fajniejsze: powiązania strukturalne i niektóre części Ranges TS uczynią algorytm jeszcze czystszym.
źródło
std::minmax_element
który zbiera tylko informacje). Wykorzystywana właściwość polega na tym, że liczby całkowite mogą być używane jako indeksy lub przesunięcia oraz że są one zwiększane przy jednoczesnym zachowaniu tej drugiej właściwości.counts | ranges::view::filter([](auto c) { return c != 0; })
, abyś nie musiał wielokrotnie testować niezerowych zliczeń wewnątrzfill_n
.small
rather
appart
counts[]
locie byłby wygraną w porównaniu z przejściem danych wejściowychminmax_element
przed histogramem. Zwłaszcza w przypadku użycia, w którym jest to idealne, z bardzo dużym nakładem z wieloma powtórzeniami w małym zakresie, ponieważ szybko dorośnieszcounts
do pełnego rozmiaru, z niewielką liczbą nieprzewidzianych oddziałów lub podwojeniem wielkości. (Oczywiście znajomość wystarczająco małej granicy zakresu pozwoli ci uniknąćminmax_element
skanowania i uniknąć sprawdzania granic w pętli histogramu.)